极客时间已完结课程限时免费阅读

42丨如何使用Redis来实现多用户抢票问题

42丨如何使用Redis来实现多用户抢票问题-极客时间

42丨如何使用Redis来实现多用户抢票问题

讲述:陈旸

时长08:30大小11.65M

在上一篇文章中,我们已经对 Redis 有了初步的认识,了解到 Redis 采用 Key-Value 的方式进行存储,在 Redis 内部,使用的是 redisObject 对象来表示所有的 key 和 value。同时我们还了解到 Redis 本身用的是单线程的机制,采用了多路 I/O 复用的技术,在处理多个 I/O 请求的时候效率很高。
今天我们来更加深入地了解一下 Redis 的原理,内容包括以下几方面:
Redis 的事务处理机制是怎样的?与 RDBMS 有何不同?
Redis 的事务处理的命令都有哪些?如何使用它们完成事务操作?
如何使用 Python 的多线程机制和 Redis 的事务命令模拟多用户抢票?

Redis 的事务处理机制

在此之前,让我们先来回忆下 RDBMS 中事务满足的 4 个特性 ACID,它们分别代表原子性、一致性、隔离性和持久性。
Redis 的事务处理与 RDBMS 的事务有一些不同。
首先 Redis 不支持事务的回滚机制(Rollback),这也就意味着当事务发生了错误(只要不是语法错误),整个事务依然会继续执行下去,直到事务队列中所有命令都执行完毕。在Redis 官方文档中说明了为什么 Redis 不支持事务回滚。
只有当编程语法错误的时候,Redis 命令执行才会失败。这种错误通常出现在开发环境中,而很少出现在生产环境中,没有必要开发事务回滚功能。
另外,Redis 是内存数据库,与基于文件的 RDBMS 不同,通常只进行内存计算和操作,无法保证持久性。不过 Redis 也提供了两种持久化的模式,分别是 RDB 和 AOF 模式。
RDB(Redis DataBase)持久化可以把当前进程的数据生成快照保存到磁盘上,触发 RDB 持久化的方式分为手动触发和自动触发。因为持久化操作与命令操作不是同步进行的,所以无法保证事务的持久性。
AOF(Append Only File)持久化采用日志的形式记录每个写操作,弥补了 RDB 在数据一致性上的不足,但是采用 AOF 模式,就意味着每条执行命令都需要写入文件中,会大大降低 Redis 的访问性能。启用 AOF 模式需要手动开启,有 3 种不同的配置方式,默认为 everysec,也就是每秒钟同步一次。其次还有 always 和 no 模式,分别代表只要有数据发生修改就会写入 AOF 文件,以及由操作系统决定什么时候记录到 AOF 文件中。
虽然 Redis 提供了两种持久化的机制,但是作为内存数据库,持久性并不是它的擅长。
Redis 是单线程程序,在事务执行时不会中断事务,其他客户端提交的各种操作都无法执行,因此你可以理解为 Redis 的事务处理是串行化的方式,总是具有隔离性的。

Redis 的事务处理命令

了解了 Redis 的事务处理机制之后,我们来看下 Redis 的事务处理都包括哪些命令。
MULTI:开启一个事务;
EXEC:事务执行,将一次性执行事务内的所有命令;
DISCARD:取消事务;
WATCH:监视一个或多个键,如果事务执行前某个键发生了改动,那么事务也会被打断;
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 调用时才会一次性全部执行。
我们经常使用 Redis 的 WATCH 和 MULTI 命令来处理共享资源的并发操作,比如秒杀,抢票等。实际上 WATCH+MULTI 实现的是乐观锁。下面我们用两个 Redis 客户端来模拟下抢票的流程。
我们启动 Redis 客户端 1,执行上面的语句,然后在执行 EXEC 前,等待客户端 2 先完成上面的执行,客户端 2 的结果如下:
然后客户端 1 执行 EXEC,结果如下:
你能看到实际上最后一张票被客户端 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="ticket_count"
# 模拟第i个用户进行抢票
def sell(i):
# 初始化 pipe
pipe = r.pipeline()
while True:
try:
# 监视票数
pipe.watch(KEY)
# 查看票数
c = int(pipe.get(KEY))
if c > 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__ == "__main__":
# 初始化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 的设计中,处处体现了这种乐观、简单的设计理念。
最后我们一起思考两个问题吧。Redis 既然是单线程程序,在执行事务过程中按照顺序执行,为什么还会用 WATCH+MULTI 的方式来实现乐观锁的并发控制呢?
我们在进行抢票模拟的时候,列举了两个 Redis 客户端的例子,当 WATCH 的键 ticket 发生改变的时候,事务就会被打断。这里我将客户端 2 的 SET ticket 设置为 1,也就是 ticket 的数值没有发生变化,请问此时客户端 1 和客户端 2 的执行结果是怎样的,为什么?
欢迎你在评论区写下你的思考,我会和你一起交流,也欢迎你把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 4

提建议

上一篇
41丨初识Redis:Redis为什么会这么快?
下一篇
43丨如何使用Redis搭建玩家排行榜?
unpreview
 写留言

精选留言(19)

  • Monday
    2019-09-09
    思考题: 1、redis服务器只支持单进程单线程,但是redis的客户端可以有多个,为了保证一连串动作的原子性,所以要支持事务。 2、客户端2成功,客户端1失败。这个问题类似于Java并发的CAS的ABA问题。redis应该是除了看ticket的值外,每个key还有一个隐藏的类似于版本的属性。
    共 1 条评论
    24
  • mickey
    2019-09-09
    客户端2首先返回 OK,客户端1返回 nil 。

    作者回复: 对的,客户端2 即使SET ticket的数值没有变化,也是对ticket进行了“修改”,也就是数据的版本发生了变化,因此和文章中的例子一样,客户端2会返回OK,客户端1是 nil

    17
  • tt
    2019-09-09
    单线程的REDIS也采用事物,我觉得主要是用来监视自己是否可以执行的条件是否得以满足,尤其是这个条件有可能不在REDIS自身的控制范围之内的时候。

    作者回复: 对的,单线程不一定代表要执行的事务的条件都满足,因为其他客户端的命令可能会在WATCH之后修改了KEY的值(如文中例子),导致事务条件不满足,打断事务执行的情况。

    共 2 条评论
    15
  • 花见笑
    2020-01-07
    推荐使用 Redis 脚本功能 来代替事务 性能高出很多
    共 5 条评论
    6
  • godfish
    2019-10-22
    抢票那个我有个问题是如果ticket大于1的情况,因为watch了key,客户端1岂不是也抢票失败了?实际上不应该失败吧。或者是这里ticket的1不是代表数量代表其中一张票?
    共 1 条评论
    6
  • steve
    2019-09-09
    是否能用DECR实现呢?

    作者回复: 可以的,使用DECR 可以实现原子性的递减

    5
  • Demon.Lee
    2019-09-09
    返回结果跟之前一样,因为客户端1还是因为key变化了执行失败

    作者回复: Good Job

    共 2 条评论
    5
  • ABC
    2019-11-26
    推荐大家在Docker容器里面搭建各种开发环境,方便而且又不用配置特别多东西.以前我是在Windows上直接部署MySQL作为开发环境,后来就换到Docker了,才发现那么方便!~

    作者回复: 嗯 Docker很方便

    3
  • 明月
    2022-10-22 来自陕西
    不是串行化事物了么,没有问题
  • 蝴蝶
    2021-10-10
    虽然Redis是单线程的,但是多个客户端进行多个操作时却无法保证原子性,也就是说多个指令是交叉执行的
    共 1 条评论
  • 大橘为重
    2020-10-23
    用 redis lua script 来实现更方便
  • 上校
    2020-07-03
    redis内抢完票如何同步到mysql呢?如何和mysql最终一致性?是通过持久化?还是说抢票成功就同步呢?老师有没有经验分享?谢谢
    共 1 条评论
  • 小白菜
    2020-06-06
    总感觉后面这几篇讲的Redis,有点浅显,抛砖引玉一下。可能由于篇幅的缘故吧!

    作者回复: 嗯嗯 后面有机会再拓展写下

  • 博弈
    2020-03-26
    问题一:我认为是用来判断当前事务执行得条件是否满足 问题二:这是ABA问题,每变化一次,相应的版本也会跟着变化
  • 小文同学
    2020-03-16
    Redis 集群的情况下,会不会抢票就因此不适用?
  • 扶幽
    2019-10-31
    思考题: 1,Redis是单线程程序,多个客户端在并发访问的时候要在多个线程间切换来交替执行,所以还是要进行并发控制。 2,??
  • 水如天
    2019-09-11
    能分析下JSON类型的存储和查询原理吗
  • tt
    2019-09-09
    对于第一个问题,我觉得原因在于WATCH+MULTI主要是事物来监视自身执行得以的条件是否满足的

    作者回复: 对的

  • mickey
    2019-09-09
    上面的抢票时序,Redis是串行化的,不能在T2时刻同时两个客户端都执行Watch吧。

    作者回复: 对 串行化的,所以同一时刻也会进行串行化的处理,比如顺序为:客户端1 watch -> 客户端2 watch,或者是 客户端2 watch -> 客户端1watch,都有可能。