在我的项目“过家家”里,有一个抢红包的功能。功能类似微信抢红包。

抢红包的基本逻辑

v1(低并发)抢红包时实时使用二倍均值法计算,只使用MySQL

1. 红包创建流程

  • 用户发红包时,指定总金额与红包个数
  • 系统将总金额拆分为指定数量的红包
  • 红包数量约束:
    • 最少为 1 个
    • 最多不能超过群成员数量

2. 红包金额分配策略

  • 单个红包:不进行随机计算,抢到即获得全部金额
  • 多个红包:采用二倍均值法进行随机分配
    • 当前用户最大可抢金额 = 剩余金额 / 剩余人数 × 2
    • 实际可抢区间为 [0.01, 最大可抢金额 - 0.01]
      • 下限 0.01 元:保证每个抢到的用户都有收益
      • 上限减 0.01 元:保证剩余红包仍有余额可抢
    • 边际示例:10 元红包 2 人分,第 1 人可抢区间为 [0.01, 9.99]

3. 数据持久化与防刷控制

  • 红包创建后入库存储
  • 发红包限流:同一用户 10 秒内仅允许发送 1 个红包,防止手抖误操作

4. 抢红包并发控制

  • 防重复抢:在 redpacket_grabs 表中建立 (userId, redpacketId) 联合唯一索引,确保同一用户对同一红包仅能抢一次
  • 防超卖:采用数据库乐观锁机制
    • 更新 redpacket 表时,必须同时满足:
      • remaining_count > 0
      • remaining_amount >= 本次扣减金额

5. 方法缺点

  • 每次请求直接打数据库,导致响应时间没有访问内存快
  • 每次请求都会使用二分均值法计算抢到的金额,并且做超卖判断,比较麻烦

v2 (高并发)红包创建好了就已经固定了抢红包的数量和个数,加入Redis

大致流程

  • 创建红包时:预热,红包信息塞进 redis
  • 抢红包时:Lua原子性
  • 抢完:异步落库,把哪个用户抢了多少慢慢写进DB

具体流程

创建红包

  1. 限流检查:同一用户10秒内仅允许发送1个红包
  2. 预拆分金额:使用二倍均值法将总金额拆分为N个子红包(单位:分)
  3. 写入Redis
    • 子红包金额列表 → RPUSH redpacket:{id}:amounts(元素为金额)
    • 红包元信息 → HSET redpacket:{id}:info(总金额、个数、状态等)
    • 已抢记录 → 初始化空Hash redpacket:{id}:grabbed
  4. 异步落库:将红包主数据写入MySQL(状态标记为pending)

抢红包(Lua原子操作)

执行Lua脚本(原子执行三步)

  1. 防重复检查HEXISTS redpacket:{id}:grabbed {userId},已抢过则返回 -1
  2. 弹出金额LPOP redpacket:{id}:amounts,列表为空则返回 -2(已抢完)
  3. 记录抢到信息HSET redpacket:{id}:grabbed {userId} {amount}

返回结果:成功返回 [1, 金额(分)],失败返回 [-1, 0] 或 [-2, 0]

异步落库(最终一致性)

  1. 写入队列:抢红包成功后,将记录 (userId, redpacketId, amount, time) 推入消息队列或内存队列
  2. 批量刷库:定时任务(每5秒或积压≥100条)批量插入MySQL:
    • redpacket_grabs 表(抢红包记录)
    • 更新 redpacket 表(剩余数量、剩余金额、状态)
  3. 异常补偿:落库失败记录日志,定时重试或对账修复

二倍均值法

要解决的问题

  • 红包总金额固定
  • 红包总个数固定
  • 每个人抢到的金额随机
  • 所有人抢完后,金额总和等于红包总金额

核心公式

当前用户最大可抢金额 = 剩余金额 / 剩余人数 × 2


边界说明

实际业务中需要增加上下限约束:

  • 下限:0.01 元(不能让用户抢到 0 元)
  • 上限剩余金额 / 剩余人数 × 2 - 0.01(必须为后续红包留出最少 0.01 元)

为什么上限要减 0.01?

如果第一个人把上限额度抢完,剩余金额恰好只够剩余人数每人 0.01 元,这样是可以的。

但如果上限不减 0.01,可能出现剩余金额为 0 的情况,导致后面的人抢不到钱。

举例:1000 分 3 人分,若第一个抢到 666 分(1000/3×2 ≈ 666),剩余 334 分,剩下两人每人最多 167 分,仍然 > 0.01,看起来没问题。

但考虑极端情况:假设剩余金额为 300 分,剩余 2 人,按公式最大可抢 = 300/2×2 = 300 分。如果第一个人真的抢到 300 分,剩余 0 分,第二个人就无钱可抢了。因此上限必须减 0.01,即最多抢 299 分,保证第二个人至少有 1 分钱。


完整抢红包流程

示例:发一个 1000 分(10 元)红包给 3 个人

第一个人抢

  • 剩余金额:1000 分,剩余人数:3 人
  • 最大可抢 = 1000 / 3 × 2 ≈ 666 分,上限再减 1 分 = 665 分
  • 实际可抢区间:[1, 665] 分
  • 假设抢到 100 分,剩余 900 分

第二个人抢

  • 剩余金额:900 分,剩余人数:2 人
  • 最大可抢 = 900 / 2 × 2 - 1 = 899 分
  • 实际可抢区间:[1, 899] 分
  • 假设抢到 500 分,剩余 400 分

第三个人抢

  • 剩余人数为 1,直接拿走全部剩余金额(不进行随机计算)
  • 获得 400 分

边界情况演示

极端场景:第一个人每次都抢上限,看最后一人是否仍有金额可拿

第一个人抢

  • 剩余金额:1000 分,剩余人数:3 人
  • 最大可抢 = 1000 / 3 × 2 - 1 ≈ 665 分
  • 抢到上限 665 分,剩余 335 分

第二个人抢

  • 剩余金额:335 分,剩余人数:2 人
  • 最大可抢 = 335 / 2 × 2 - 1 = 334 分
  • 抢到上限 334 分,剩余 1 分

第三个人抢

  • 剩余人数为 1,直接拿走全部剩余金额
  • 获得 1 分

✅ 验证通过:即使前两人每次都抢到上限,最后一人仍有 0.01 元保底,不会出现无钱可抢的情况。


为什么叫"二倍均值法"?

项目 说明
当前剩余人均金额 剩余金额 / 剩余人数
随机上限 人均金额 × 2
名字由来 每次随机的上限是当前人均金额的 2 倍

这样可以保证:

  • 前面的人不会一次性把红包抢光(最多只能拿人均的 2 倍)
  • 后面的人始终有剩余金额可抢
  • 金额分布相对均匀,不会出现极端悬殊的情况

多线程下的 random 问题 以及 为什么用 ThreadLocalRandom ?

  • random 的底层是 nextSeed() 方法,它使用了一个 AtomicLong 来保存随机种子Seed。多线程环境下,每次生成随机数都会尝试使用 CAS(Compare And Swap)操作去更新这个种子值。由于CAS同一时刻下只有一个线程能成功,其他线程会陷入自旋重试,导致CPU开销增加

AtomicLong 是 java.util.concurrent.atomic 包下的一个类,它提供了一种线程安全、非阻塞的方式来操作 long 类型的值,保证操作 long类型变量 的原子性。在多线程环境下,它可以替代 volatile + synchronized 的组合,实现高效的并发计数或ID生成。

  • ThreadLocalRandom 是 Random 的子类,但是采取了种子隔离机制,每个线程都有自己的种子副本,实现了无竞争,零阻塞。缺点就是占用一些内存空间。

一个有意思的问题

在二倍均值法中,每个用户抢到高额红包的概率似乎并不确定。在之前的例子中:

发一个1000分(10元)的红包给3个人。

第一个人抢,红包剩余1000分,该用户最多可得到665分,最少可以拿到1分。

第一个用户的随机范围是 1-665,似乎比其他用户大得多。一旦他拿到了665分的金额,那么其他人只能“吃瘪”了。但是实际真的是这样吗?

非也。

第一个用户获得的随机金额,本质上主宰了整个抢红包游戏的随机性,但我们不能把“A用户抢红包的随机范围大”就等同于“A用户抢到高额红包的概率更高”。

这里的关键在于 “概率的传递性”

  • 如果 A 抢到了 1分钱,那么他把 高额红包的概率 传递给了 B 和 C。此时剩余金额 999 分,B 和 C 的随机上限会变高,大概率会诞生一个大包。
  • 如果 A 抢到了 最高金额(665分),那么他把 低额红包的概率 传递给了 B 和 C。剩余金额只有 335 分,B 和 C 即便拿满,也远低于 A 的金额。
  • 如果 A 抢到了一个 中间值,那么 B 和 C 就在剩余金额中继续博弈,概率继续向下传递。

所以整体来看,每个人拿到任意金额的期望概率其实是相等的。A 有概率拿到大包,但同时也承担了把“大包机会”让给后者的风险;后者虽然随机范围小,但承接的是前者“没拿完”的剩余价值。

我们之所以会产生“先抢更占优势”的错觉,是因为我们只看到了“第一个人的随机范围大”,却忽略了“概率的传递性”和“剩余金额的守恒”。 就像三个人分一块蛋糕,第一个人切得多,后面就分得少;第一个人切得少,后面就分得多。切的人有选择权,但最终每个人吃到的蛋糕总量,在无数次重复后是相等的。