一、思路分析
1. 如何确保【抢到红包的人机会绝对均等】
思路 1:【以空间换时间】给出一个 amount 以及 count,将红包进行拆分,并打乱存储在 Redis 的 Set 中,用户抢就 poll 一个。
思路 2:【以时间换空间】每个用户抢的时候,随机计算一个金额。
2. 并发场景下要处理的一些问题
问题 1:多个用户抢一个红包,如何确保红包不【超卖】?
问题 2:如何确保对于每一个红包,每个用户只能抢一次?
问题 3:如何创建数据库表?
问题 4:使用 Redis 如何保证 Redis 与 MySQL 的数据一致性?
问题 5:如何对并发性能进行一些优化?
二、进一步思考
因为一个红包数量不会很多,金额也不会很大,因此选择以空间换时间的方式来计算金额,因此引出:
1. 第一个问题:如何对红包金额进行拆分?
比如将 200 元发给 5 个人。方法有:
- 平均分配 + 扰动
- 二倍均值法:每次金额分布在
[0.01, 剩余平均金额 * 2]
这样,我们就通过二倍均值法实现了对红包金额的拆分,具体实现中,输入 redpocketAmount 与 count,返回 amountList。
我们可以将 amountList 存储在 Redis 的 List 数据类型中,并为它设置一个 key,比如 redpocket:{redpocketId}:amount。
2. 第二个问题:如何确保每个用户只能抢一次红包?
我们可以通过 Redis 的 Set 数据类型来完成:
通过给每个红包存储一个用户集合:
- Key 为:
redpocket:1:users - Value 为一个 SET,用于存储抢过此红包的用户 Id,比如:
{user1, user2, user3}
当用户抢红包的时候,可以通过判断用户 Id 在不在对应 redpocketId 存储的 set 中,存在则返回"已抢过该红包",不存在则继续进行后续操作。
3. 第三个问题:如何确保红包不超卖?
假设一个红包 200 元被拆分成了 5 个金额,打乱顺序,存进 Redis:
redpocketId:1:amounts {55.68, 28.15, 45.91, 68.50, 1.76}
如果有 100 个用户同时抢红包(这 100 个用户之前都没抢过该红包),如何确保只有 5 个用户能抢到红包,并且金额之和正好等于 200 呢?
我的初步思路是:
- 判断
redpocketId:1:amount对应的列表长度是否大于 0 - 大于 0 则 poll 一个元素作为该用户抢到的金额;小于 0 则返回
- 然后将
userId加入到redpocketId:1:users存储的 Set 中
这里其实可以引出一个优化点:可以直接对于 List 进行 Poll 操作,通过判断元素值是否有效来决定是否执行后续操作,这样可以减少 Redis 命令的数量,带来一点优化。
那现在就变成下列步骤:
- 对于
redpocketId:1:amount对应的 List 进行 Poll 操作,判断 Poll 出来的值是否有效 - 有效则将值作为该用户抢到的金额,无效则返回
- 将
userId添加到redpocketId:1:users对应的 Set 中
这里又引出一个问题,这里有三条 Redis 命令:
- 查找
redpocket:1:users中是否有userId - Poll 一个 List 值作为抢到的红包金额
- 将
userId加入redpocket:1:users
需要实现哪几条 Redis 命令之间的原子性呢?
因为第一条命令其实是一个只读操作,在高并发场景下,即使多个请求同时读取,也不会产生数据不一致的问题。因此,这一步不需要强制的原子性保护。
对于后续的两条命令,如果不实现它俩之间的原子性,一个用户在很短的时间内可能会抢到两次红包。如果一个请求刚刚 Poll 了一个值,还没来得及写入 redpocket:1:users 的时候,第二个请求通过了判断,然后很快也 Poll 了一个值,这样就出现了一个用户能抢两次红包。所以这两条命令必须保持原子性。
厘清了命令之间的原子性关系,我们可以使用 Lua 脚本来实现命令 2 与命令 3 之间的原子性。
但是又出现了一个问题,因为 Redis 是工作在内存中的,最终还是要将用户抢红包数据持久化到数据库,那么应该如何建表呢?
4. 第四个问题:如何创建数据库表?
可以来思考一下后续是否还会使用这些数据?
我们可以联想一下微信红包的结果界面,会显示:
- 红包是谁发的?什么时候发的?总金额是多少?可以给几个人抢?
- 红包被哪些人抢了?抢了多少钱?什么时候抢的?
这样一套思考下来,我们可以实现两个数据库表:
- 红包信息表:
id,create_user,create_time,total_amount,count - 红包-用户表:
id,redpocket_id,user_id,amount,grab_time
需要补充的字段:
- 可以给红包信息表补充:红包类型、红包过期时间、红包描述、红包剩余面额、红包剩余个数、红包状态
- 关于红包剩余面额以及红包剩余个数字段,引入的话在读取频繁的场景下会带来一定的性能提升,但是也会增加维护数据一致性的成本,这里暂时就不考虑
其实一般发红包可能还涉及到用户账户等其他业务,如果红包过期金额会被退回,这部分比较复杂,暂时不考虑。
5. 第五个问题:如何确保 Redis 与 MySQL 的数据一致性?
首先来思考一下,需要实现怎样的一致性?强一致性?弱一致性?还是最终一致性?
我们可以结合场景来具体分析一下,抢红包很明显,它的业务周期很短暂,基本上抢完之后,就不会再进行修改了。但是它对于实时性的要求很高,而强一致性对于并发度是有一定牺牲的,因此我们要实现:最终一致性。
回归正题:如何确保 Redis 与 MySQL 的数据一致性?
既然要求的是最终一致性:我们可以选择通过消息队列来实现异步持久化:
- 当用户抢到一个金额时候,记录领取时间,同时生产一个消息队列消息
- 记录
user_id,记录redpocket_id,以及amount,grab_time,发送到消息队列 - 在消息队列的另一端,一个消费者专门负责读取消息,然后将这些信息更新到红包-用户表中
但是如果消息还没来得及更新到红包-用户表,这个时候有用户想查询红包领取信息,读到的数据就不是最新的,怎么办呢?思路如下:
- 评估不同业务场景下的延误时间,以及用户是否接受短暂的不一致
- 实时监控消息队列的各项指标,看看是否存在性能瓶颈。如果消息队列、以及消费端存在性能瓶颈,可以增加消费者实例,以及一些 MySQL 主从复制读写分离机制
- 考虑是否要把这些数据保存一份在 Redis 中,更新的话先更新 Redis 再更新 MySQL,但是先更新带来的数据不一致风险太大;如果采用其他的策略比如先更新数据库再删除缓存,并不能解决异步更新带来的延迟,不过可以解决因为 MySQL IO 瓶颈带来的延迟
- 最后如果对于数据一致性的要求特别苛刻,可以放弃异步更新方案,采纳其他更强的分布式事务协议
6. 第六个问题:如何进一步提升并发性能?
- 维护一个
redpocket:id:remainCount的变量,读这个比对 List Poll 性能会更好 - 使用 pipeline 机制将查找用户是否领取过红包与 Lua 脚本一起发送,减少传输 Redis 命令带来的网络开销
- 使用滑块锁机制,单独对每一个红包的金额加锁,避免对整个 List 加锁(但是可能会出现少卖的问题,需要一些额外的机制来处理,在红包数量较少的情况下,额外的处理机制可能会带来更多的性能开销)
- 消息队列相关的优化:比如之前说的增加消费实例
- 使用池化技术
- 使用集群技术:MySQL 分库分表、使用 Redis 集群
- 如果比如某个明星发红包,可以使用缓存预热,提前拆分红包,存入 Redis 中