红包系统的设计
秒杀系统中,很常见的一种就是抢红包,还有其他的例如:双11抢购、直播间秒杀下单、一元夺宝等等。其中抢红包的特点在于无法超售, 对于下单,如果比库存超售少许,其实是可以接受的,但是对于红包,一旦用户抢到的钱比发出去的钱更多,那就是大问题了。当然, 抢购也有特定的业务复杂度,例如下单之后,用户可能没有付款,超时之后需要重新把库存加回去等。我们这里主要讨论红包系统。
对于红包系统,我们需要保证两点:
- 扛得住并发,用户抢红包的时候带来的流量峰值是非常高的;
- 不能超售,数据一定要准确
那么我们怎么处理这两个问题呢?
处理峰值(削峰)
对于单个红包来说,红包的可抢人数是有限制的,例如200个,500个,再往上就比较少了,而参与的用户,大部分只要看到红包就会 参与点击,并且大都是多次点击,直到抢完。因此,这种行为带来的一个特点,就是红包发出以后,会迎来一波比红包个数大很多的 流量,但是超过红包个数的流量基本是无用的流量。因此,对于红包系统,我们需要在请求到达真正处理的后端服务之前,加上如下 防火墙,对流量进行过滤:
用户组织层限流:限制群组大小,这样就限制了一个红包最大的参与人数,例如一个群组最大2万人。但是有的场景下无法限制,例如 直播间。这一层面的限制取决于产品形态
用户端限流:浏览器、App、小程序等用户参与游戏的客户端,我们可以进行限速操作,例如1s点击一次,点击后1s内按钮置灰不可 再次点击
网关层限流:CDN,Nginx,限速,从这里开始,就涉及到服务端的处理了,这里就涉及到多种策略,例如针对IP限速,针对用户限速等
服务层限流:这里需要代码进行处理,例如设置当前参与人数超过红包总数3倍,直接拒绝当前请求,一般会用Redis来存储这个信息
应用层限流:当红包抢完之后,可以在Redis中记录一个标记,应用检测到这个信息,就不需要进行后面的操作了,可以直接拒绝
数据准确(加锁)
如何保证红包的数据准确性呢?如果是使用数据库来存储的话,就需要使用事务,加上行锁,也就是 SELECT ... FOR UPDATE
语句,
如果是使用Redis来存储的话,就需要使用CAS操作,由于Redis中的事务并不保证ACID,但是Lua脚本是原子执行的,因此需要使用Lua
脚本来进行操作。
分配方案
对于红包的分配,有两种方案:
预分配。发送红包的时候,就提前分配好每一个人能抢到多少,抢红包的时候,取一个未抢的金额出来,分配给对应用户,并且更新状态, 此处需要保证一个用户在一个红包下,只能抢一次。
实时分配。发送红包的时候不分配,在抢红包的时候进行分配,根据红包的剩余金额和剩余个数,计算一个金额给当前用户,此处也许要 在数据库层面保证一个用户在一个红包下,只能抢一次。
分配算法
红包分配算法可以参考微信红包的分配算法:随机,额度在0.01和剩余平均值*2之间。
数据落地方案
- 数据库事务 +
SELECT ... FOR UPDATE
,通过事务保证ACID,通过行锁保证不会产生并发更新错误。 - 数据库集群(分库sharding),当一个数据库吃不住的时候,可以使用集群来扛并发。
- Redis Lua 脚本,当集群扛不住的时候,就可以考虑使用Redis来进行操作了。但是最终数据还是需要落库到数据库。
因此我实现时,采用的是第一种方案,也就是直接数据库+行锁来实现。使用数据库实现时,需要注意的是避免死锁场景,我就踩到 死锁的坑导致数据库连接池爆了,排查了好一会儿。这种方案对于数据库本身有一定要求,尤其是磁盘IOPS,需要好好的调校数据库以 得到一个比较好的并发性能。
缓存
对于红包本身,我认为缓存的必要性不大,因为每一次成功抢红包,都会导致缓存失效,还是需要数据库参与。与之相对的,反而是红包 的基本信息更加管用,例如:
- 当前红包是否已经抢完?可以用于提前拒绝请求
- 当前用户是否抢过?可以用于提前拒绝请求
- 当前用户是否最近1s抢过?可以用于限速
把这些信息写到缓存里,更有助于提高整个系统的并发能力。
总结
这篇文章中,总结了一个红包系统需要用到的知识,以及如何取处理流量和如何设计系统。
- https://www.zybuluo.com/yulin718/note/93148
- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
更多文章
- socks5 协议详解
- zerotier简明教程
- 搞定面试中的系统设计题
- frp 源码阅读与分析(一):流程和概念
- 用peewee代替SQLAlchemy
- Golang(Go语言)中实现典型的fork调用
- DNSCrypt简明教程
- 一个Gunicorn worker数量引发的血案
- Golang validator使用教程
- Docker组件介绍(二):shim, docker-init和docker-proxy
- Docker组件介绍(一):runc和containerd
- 使用Go语言实现一个异步任务框架
- 协程(coroutine)简介 - 什么是协程?
- SQLAlchemy简明教程
- Go Module 简明教程