场景题:实现微信抢红包功能,要求每个抢到红包的人机会绝对均等

166 阅读7分钟

一、思路分析

1. 如何确保【抢到红包的人机会绝对均等】

思路 1:【以空间换时间】给出一个 amount 以及 count,将红包进行拆分,并打乱存储在 Redis 的 Set 中,用户抢就 poll 一个。

思路 2:【以时间换空间】每个用户抢的时候,随机计算一个金额。

2. 并发场景下要处理的一些问题

问题 1:多个用户抢一个红包,如何确保红包不【超卖】?

问题 2:如何确保对于每一个红包,每个用户只能抢一次?

问题 3:如何创建数据库表?

问题 4:使用 Redis 如何保证 Redis 与 MySQL 的数据一致性?

问题 5:如何对并发性能进行一些优化?

二、进一步思考

因为一个红包数量不会很多,金额也不会很大,因此选择以空间换时间的方式来计算金额,因此引出:

1. 第一个问题:如何对红包金额进行拆分?

比如将 200 元发给 5 个人。方法有:

  • 平均分配 + 扰动
  • 二倍均值法:每次金额分布在 [0.01, 剩余平均金额 * 2]

这样,我们就通过二倍均值法实现了对红包金额的拆分,具体实现中,输入 redpocketAmountcount,返回 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 呢?

我的初步思路是:

  1. 判断 redpocketId:1:amount 对应的列表长度是否大于 0
  2. 大于 0 则 poll 一个元素作为该用户抢到的金额;小于 0 则返回
  3. 然后将 userId 加入到 redpocketId:1:users 存储的 Set 中

这里其实可以引出一个优化点:可以直接对于 List 进行 Poll 操作,通过判断元素值是否有效来决定是否执行后续操作,这样可以减少 Redis 命令的数量,带来一点优化。

那现在就变成下列步骤:

  1. 对于 redpocketId:1:amount 对应的 List 进行 Poll 操作,判断 Poll 出来的值是否有效
  2. 有效则将值作为该用户抢到的金额,无效则返回
  3. 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 中