CategoryResourceRepost/极客时间专栏/SQL必知必会/第三章:认识DBMS/42丨如何使用Redis来实现多用户抢票问题.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

169 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="42丨如何使用Redis来实现多用户抢票问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b8/36/b80b8d71b133cacfdd5e7d924d345236.mp3"></audio>
在上一篇文章中我们已经对Redis有了初步的认识了解到Redis采用Key-Value的方式进行存储在Redis内部使用的是redisObject对象来表示所有的key和value。同时我们还了解到Redis本身用的是单线程的机制采用了多路I/O复用的技术在处理多个I/O请求的时候效率很高。
今天我们来更加深入地了解一下Redis的原理内容包括以下几方面
1. Redis的事务处理机制是怎样的与RDBMS有何不同
1. Redis的事务处理的命令都有哪些如何使用它们完成事务操作
1. 如何使用Python的多线程机制和Redis的事务命令模拟多用户抢票
## Redis的事务处理机制
在此之前让我们先来回忆下RDBMS中事务满足的4个特性ACID它们分别代表原子性、一致性、隔离性和持久性。
Redis的事务处理与RDBMS的事务有一些不同。
首先Redis不支持事务的回滚机制Rollback这也就意味着当事务发生了错误只要不是语法错误整个事务依然会继续执行下去直到事务队列中所有命令都执行完毕。在[Redis官方文档](https://redis.io/topics/transactions)中说明了为什么Redis不支持事务回滚。
只有当编程语法错误的时候Redis命令执行才会失败。这种错误通常出现在开发环境中而很少出现在生产环境中没有必要开发事务回滚功能。
另外Redis是内存数据库与基于文件的RDBMS不同通常只进行内存计算和操作无法保证持久性。不过Redis也提供了两种持久化的模式分别是RDB和AOF模式。
RDBRedis DataBase持久化可以把当前进程的数据生成快照保存到磁盘上触发RDB持久化的方式分为手动触发和自动触发。因为持久化操作与命令操作不是同步进行的所以无法保证事务的持久性。
AOFAppend Only File持久化采用日志的形式记录每个写操作弥补了RDB在数据一致性上的不足但是采用AOF模式就意味着每条执行命令都需要写入文件中会大大降低Redis的访问性能。启用AOF模式需要手动开启有3种不同的配置方式默认为everysec也就是每秒钟同步一次。其次还有always和no模式分别代表只要有数据发生修改就会写入AOF文件以及由操作系统决定什么时候记录到AOF文件中。
虽然Redis提供了两种持久化的机制但是作为内存数据库持久性并不是它的擅长。
Redis是单线程程序在事务执行时不会中断事务其他客户端提交的各种操作都无法执行因此你可以理解为Redis的事务处理是串行化的方式总是具有隔离性的。
## Redis的事务处理命令
了解了Redis的事务处理机制之后我们来看下Redis的事务处理都包括哪些命令。
1. MULTI开启一个事务
1. EXEC事务执行将一次性执行事务内的所有命令
1. DISCARD取消事务
1. WATCH监视一个或多个键如果事务执行前某个键发生了改动那么事务也会被打断
1. UNWATCH取消WATCH命令对所有键的监视。
需要说明的是Redis实现事务是基于COMMAND队列如果Redis没有开启事务那么任何的COMMAND都会立即执行并返回结果。如果Redis开启了事务COMMAND命令会放到队列中并且返回排队的状态QUEUED只有调用EXEC才会执行COMMAND队列中的命令。
比如我们使用事务的方式存储5名玩家所选英雄的信息代码如下
```
MULTI
hmset user:001 hero 'zhangfei' hp_max 8341 mp_max 100
hmset user:002 hero 'guanyu' hp_max 7107 mp_max 10
hmset user:003 hero 'liubei' hp_max 6900 mp_max 1742
hmset user:004 hero 'dianwei' hp_max 7516 mp_max 1774
hmset user:005 hero 'diaochan' hp_max 5611 mp_max 1960
EXEC
```
你能看到在MULTI和EXEC之间的COMMAND命令都会被放到COMMAND队列中并返回排队的状态只有当EXEC调用时才会一次性全部执行。
<img src="https://static001.geekbang.org/resource/image/4a/06/4aa62797167f41599b9e514d77fc0a06.png" alt=""><br>
我们经常使用Redis的WATCH和MULTI命令来处理共享资源的并发操作比如秒杀抢票等。实际上WATCH+MULTI实现的是乐观锁。下面我们用两个Redis客户端来模拟下抢票的流程。
<img src="https://static001.geekbang.org/resource/image/95/41/95e294bfb6843ef65beff61ca0bc3a41.png" alt=""><br>
我们启动Redis客户端1执行上面的语句然后在执行EXEC前等待客户端2先完成上面的执行客户端2的结果如下
<img src="https://static001.geekbang.org/resource/image/eb/1b/ebbadb4698e80d81dbf7c62a21dbec1b.png" alt=""><br>
然后客户端1执行EXEC结果如下
<img src="https://static001.geekbang.org/resource/image/6b/f8/6b23c9efcdbe1f349299fc32d41ab0f8.png" alt=""><br>
你能看到实际上最后一张票被客户端2抢到了这是因为客户端1WATCH的票的变量在EXEC之前发生了变化整个事务就被打断返回空回复nil
需要说明的是MULTI后不能再执行WATCH命令否则会返回WATCH inside MULTI is not allowed错误因为WATCH代表的就是在执行事务前观察变量是否发生了改变如果变量改变了就将事务打断所以在事务执行之前也就是MULTI之前使用WATCH。同时如果在执行命令过程中有语法错误Redis也会报错整个事务也不会被执行Redis会忽略运行时发生的错误不会影响到后面的执行。
## 模拟多用户抢票
我们刚才讲解了Redis的事务命令并且使用Redis客户端的方式模拟了两个用户抢票的流程。下面我们使用Python继续模拟一下这个过程这里需要注意三点。
在Python中Redis事务是通过pipeline封装而实现的因此在创建Redis连接后需要获取管道pipeline然后通过pipeline使用WATCH、MULTI和EXEC命令。
其次用户是并发操作的因此我们需要使用到Python的多线程这里使用threading库来创建多线程。
对于用户的抢票我们设置了sell函数用于模拟用户i的抢票。在执行MULTI前我们需要先使用pipe.watch(KEY)监视票数如果票数不大于0则说明票卖完了用户抢票失败如果票数大于0证明可以抢票再执行MULTI将票数减1并进行提交。不过在提交执行的时候可能会失败这是因为如果监视的KEY发生了改变则会产生异常我们可以通过捕获异常来提示用户抢票失败重试一次。如果成功执行事务则提示用户抢票成功显示当前的剩余票数。
具体代码如下:
```
import redis
import threading
# 创建连接池
pool = redis.ConnectionPool(host = '127.0.0.1', port=6379, db=0)
# 初始化 redis
r = redis.StrictRedis(connection_pool = pool)
# 设置KEY
KEY=&quot;ticket_count&quot;
# 模拟第i个用户进行抢票
def sell(i):
# 初始化 pipe
pipe = r.pipeline()
while True:
try:
# 监视票数
pipe.watch(KEY)
# 查看票数
c = int(pipe.get(KEY))
if c &gt; 0:
# 开始事务
pipe.multi()
c = c - 1
pipe.set(KEY, c)
pipe.execute()
print('用户 {} 抢票成功,当前票数 {}'.format(i, c))
break
else:
print('用户 {} 抢票失败,票卖完了'.format(i))
break
except Exception as e:
print('用户 {} 抢票失败,重试一次'.format(i))
continue
finally:
pipe.unwatch()
if __name__ == &quot;__main__&quot;:
# 初始化5张票
r.set(KEY, 5)
# 设置8个人抢票
for i in range(8):
t = threading.Thread(target=sell, args=(i,))
t.start()
```
运行结果:
```
用户 0 抢票成功,当前票数 4
用户 4 抢票失败,重试一次
用户 1 抢票成功,当前票数 3
用户 2 抢票成功,当前票数 2
用户 4 抢票失败,重试一次
用户 5 抢票失败,重试一次
用户 6 抢票成功,当前票数 1
用户 4 抢票成功,当前票数 0
用户 5 抢票失败,重试一次
用户 3 抢票失败,重试一次
用户 7 抢票失败,票卖完了
用户 5 抢票失败,票卖完了
用户 3 抢票失败,票卖完了
```
在Redis中不存在悲观锁事务处理要考虑到并发请求的情况我们需要通过WATCH+MULTI的方式来实现乐观锁如果监视的KEY没有发生变化则可以顺利执行事务否则说明事务的安全性已经受到了破坏服务器就会放弃执行这个事务直接向客户端返回空回复nil事务执行失败后我们可以重新进行尝试。
## 总结
今天我讲解了Redis的事务机制Redis事务是一系列Redis命令的集合事务中的所有命令都会按照顺序进行执行并且在执行过程中不会受到其他客户端的干扰。不过在事务的执行中Redis可能会遇到下面两种错误的情况
首先是语法错误也就是在Redis命令入队时发生的语法错误。Redis在事务执行前不允许有语法错误如果出现则会导致事务执行失败。如官方文档所说通常这种情况在生产环境中很少出现一般会发生在开发环境中如果遇到了这种语法错误就需要开发人员自行纠错。
第二个是执行时错误也就是在事务执行时发生的错误比如处理了错误类型的键等这种错误并非语法错误Redis只有在实际执行中才能判断出来。不过Redis不提供回滚机制因此当发生这类错误时Redis会继续执行下去保证其他命令的正常执行。
在事务处理中我们需要通过锁的机制来解决共享资源并发访问的情况。在Redis中提供了WATCH+MULTI的乐观锁方式。我们之前了解过乐观锁是一种思想它是通过程序实现的锁机制在数据更新的时候进行判断成功就执行不成功就失败不需要等待其他事务来释放锁。事实上在在Redis的设计中处处体现了这种乐观、简单的设计理念。
<img src="https://static001.geekbang.org/resource/image/e3/83/e3ae78a3220320015cb3e43c642ea683.png" alt=""><br>
最后我们一起思考两个问题吧。Redis既然是单线程程序在执行事务过程中按照顺序执行为什么还会用WATCH+MULTI的方式来实现乐观锁的并发控制呢
我们在进行抢票模拟的时候列举了两个Redis客户端的例子当WATCH的键ticket发生改变的时候事务就会被打断。这里我将客户端2的SET ticket设置为1也就是ticket的数值没有发生变化请问此时客户端1和客户端2的执行结果是怎样的为什么
<img src="https://static001.geekbang.org/resource/image/d4/44/d4bb30f5d415ea93980c465e4f110544.png" alt=""><br>
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎你把这篇文章分享给你的朋友或者同事,一起交流一下。