在我的项目“过家家”里,有一个抢红包的功能。功能类似微信抢红包。
抢红包的基本逻辑
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 > 0remaining_amount >= 本次扣减金额
- 更新
5. 方法缺点
- 每次请求直接打数据库,导致响应时间没有访问内存快
- 每次请求都会使用二分均值法计算抢到的金额,并且做超卖判断,比较麻烦
v2 (高并发)红包创建好了就已经固定了抢红包的数量和个数,加入Redis
大致流程
- 创建红包时:预热,红包信息塞进 redis
- 抢红包时:Lua原子性
- 抢完:异步落库,把哪个用户抢了多少慢慢写进DB
具体流程
创建红包
- 限流检查:同一用户10秒内仅允许发送1个红包
- 预拆分金额:使用二倍均值法将总金额拆分为N个子红包(单位:分)
- 写入Redis:
- 子红包金额列表 →
RPUSH redpacket:{id}:amounts(元素为金额) - 红包元信息 →
HSET redpacket:{id}:info(总金额、个数、状态等) - 已抢记录 → 初始化空Hash
redpacket:{id}:grabbed
- 子红包金额列表 →
- 异步落库:将红包主数据写入MySQL(状态标记为pending)
抢红包(Lua原子操作)
执行Lua脚本(原子执行三步):
- 防重复检查:
HEXISTS redpacket:{id}:grabbed {userId},已抢过则返回 -1 - 弹出金额:
LPOP redpacket:{id}:amounts,列表为空则返回 -2(已抢完) - 记录抢到信息:
HSET redpacket:{id}:grabbed {userId} {amount}
返回结果:成功返回 [1, 金额(分)],失败返回 [-1, 0] 或 [-2, 0]
异步落库(最终一致性)
- 写入队列:抢红包成功后,将记录
(userId, redpacketId, amount, time)推入消息队列或内存队列 - 批量刷库:定时任务(每5秒或积压≥100条)批量插入MySQL:
redpacket_grabs表(抢红包记录)- 更新
redpacket表(剩余数量、剩余金额、状态)
- 异常补偿:落库失败记录日志,定时重试或对账修复
二倍均值法
要解决的问题
- 红包总金额固定
- 红包总个数固定
- 每个人抢到的金额随机
- 所有人抢完后,金额总和等于红包总金额
核心公式
当前用户最大可抢金额 = 剩余金额 / 剩余人数 × 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 有概率拿到大包,但同时也承担了把“大包机会”让给后者的风险;后者虽然随机范围小,但承接的是前者“没拿完”的剩余价值。
我们之所以会产生“先抢更占优势”的错觉,是因为我们只看到了“第一个人的随机范围大”,却忽略了“概率的传递性”和“剩余金额的守恒”。 就像三个人分一块蛋糕,第一个人切得多,后面就分得少;第一个人切得少,后面就分得多。切的人有选择权,但最终每个人吃到的蛋糕总量,在无数次重复后是相等的。