【Java项目技术亮点】红包算法二倍均值法
写在前面
说实话,我第一次面试被问到红包算法的时候,整个人是懵的。当时觉得不就是个随机数吗?直到面试官追问"怎么保证公平性"“高并发怎么防超发”,我才意识到这玩意儿水很深。后来我实际在项目中做过红包功能,踩过浮点数精度、超发、幂等一堆坑。这篇文章把我这些年的经验都摊开了讲,希望能帮你少走点弯路。
文章目录
- 一、红包算法有多难?
- 1.1 场景引入
- 1.2 核心约束
- 1.3 生活类比
- 二、常见红包算法对比
- 2.1 线段切割法
- 2.2 剩余平均法
- 2.3 二倍均值法(微信红包同款)
- 三、二倍均值法原理
- 3.1 核心公式
- 3.2 为什么叫"二倍均值"?
- 3.3 数学证明:每个人的期望值相等
- 3.4 完整示例:100 元 10 个红包
- 四、高并发红包实现
- 4.1 核心问题:怎么防止超发?
- 4.2 预分配 vs 实时计算
- 4.3 完整代码:预分配方案(Java + Lua)
- 4.4 大红包拆分优化
- 五、踩坑指南
- 六、问题与解答
- 七、面试高频考点汇总
- 八、模拟面试官提问和参考答案
- 九、互动话题
- 十、参考资料
一、红包算法有多难?
1.1 场景引入
微信群发 100 元红包,10 个人抢。
如果你直接随机,可能出现什么情况?第一个人抢到 99 元,后面 9 个人分 1 元——这红包还发得下去吗?
我见过太多人觉得"随机一下不就完了",结果上线第一天就被用户骂上热搜。
1.2 核心约束
一个能用的红包算法,必须同时满足下面 5 个条件:
| 约束 | 说明 | 违反后果 |
|---|---|---|
| 每个人至少 0.01 元 | 最小单位限制 | 有人抢到 0 元,体验极差 |
| 总金额 = 红包金额 | 金额守恒 | 多发或少发都是 P0 事故 |
| 随机性 | 不能预测下一个人抢多少 | 被薅羊毛、被破解 |
| 公平性 | 每个人期望金额相等 | 先抢后抢差距太大 |
| 高并发 | 同时抢不能超发 | 10000 人抢 100 个红包,多发出去了 |
1.3 生活类比
分蛋糕——随机切,但不能有人只分到一口,也不能有人分到一大半。
二倍均值法的精髓就是:既随机,又公平,还高效。
二、常见红包算法对比
2.1 线段切割法
思路:在[0, 100]上随机切 9 刀,分成 10 段。
- 优点:完全随机,数学上很优雅
- 缺点:可能出现极端值(某人分到 0.01,另一人分到 80);计算量随人数线性增长
/** * 线段切割法:模拟在总金额上随机切 n-1 刀 * 时间复杂度 O(n log n),因为有排序 */publicstaticList<Integer>lineCutting(inttotalAmount,inttotalNum){List<Integer>cuts=newArrayList<>();Randomrandom=newRandom();// 随机切 totalNum - 1 刀,范围 [1, totalAmount - 1]while(cuts.size()<totalNum-1){intcut=random.nextInt(totalAmount-1)+1;if(!cuts.contains(cut)){cuts.add(cut);// 去重}}Collections.sort(cuts);List<Integer>result=newArrayList<>();intprev=0;for(intcut:cuts){result.add(cut-prev);prev=cut;}result.add(totalAmount-prev);// 最后一段returnresult;}2.2 剩余平均法
思路:每次分剩余金额的平均值。
- 优点:实现简单,两行代码搞定
- 缺点:先抢的有明显优势,越往后越少。用户会骂你"手慢无"
/** * 剩余平均法:每次取剩余金额的平均值附近 * 缺陷:先抢的人期望更高,不公平 */publicstaticList<Integer>remainingAverage(inttotalAmount,inttotalNum){List<Integer>result=newArrayList<>();intremainingAmount=totalAmount;intremainingNum=totalNum;Randomrandom=newRandom();for(inti=0;i<totalNum-1;i++){intavg=remainingAmount/remainingNum;// 在平均值附近随机,范围 [1, 2*avg - 1]intamount=random.nextInt(Math.max(1,2*avg-1))+1;result.add(amount);remainingAmount-=amount;remainingNum--;}result.add(remainingAmount);// 最后一个人拿剩下的returnresult;}2.3 二倍均值法(微信红包同款)
思路:每次抢到的金额 = 随机(0.01, 剩余金额 / 剩余人数 × 2)。
- 优点:公平、随机、数学可证,工程上成熟稳定
三种算法对比如下:
| 维度 | 线段切割法 | 剩余平均法 | 二倍均值法 |
|---|---|---|---|
| 公平性 | 高 | 低(先抢占优) | 高(期望相等) |
| 随机性 | 完全随机 | 弱 | 强 |
| 极端值 | 可能出现 | 不易出现 | 不易出现 |
| 时间复杂度 | O(n log n) | O(n) | O(n) |
| 工程实践 | 少见 | 不推荐 | 微信同款,主流方案 |
三、二倍均值法原理
3.1 核心公式
每次抢到的金额 = 随机(1, 剩余金额 / 剩余人数 × 2)注意:这里金额用分做单位,整数运算,避免浮点精度问题。
3.2 为什么叫"二倍均值"?
剩余金额 / 剩余人数= 当前均值- 乘以 2,就是二倍均值
- 随机范围
[1, 二倍均值),保证:- 不会太大(不超过二倍均值)
- 不会太小(至少 1 分钱)
- 期望等于均值(数学上严格公平)
3.3 数学证明:每个人的期望值相等
设剩余金额为M,剩余人数为N。
当前均值avg = M / N。二倍均值法每次取rand(1, 2 * avg)。
均匀分布的期望 =(1 + 2 * avg) / 2 = 0.5 + avg ≈ avg(当金额较大时,0.5 可忽略)。
所以每个人抢到的期望 ≈avg = M / N。
不管第几个抢,期望都一样。这就是公平性的数学保证。
面试的时候不用推导完整证明,把上面这段讲清楚就够了。面试官要的是理解,不是数学竞赛。
3.4 完整示例:100 元 10 个红包
假设 100 元 = 10000 分,10 个人抢。
| 第几个人 | 剩余金额(分) | 剩余人数 | 均值 | 二倍均值 | 随机范围 | 抢到金额(模拟) |
|---|---|---|---|---|---|---|
| 1 | 10000 | 10 | 1000 | 2000 | [1, 2000) | 1523 |
| 2 | 8477 | 9 | 941 | 1883 | [1, 1883) | 876 |
| 3 | 7601 | 8 | 950 | 1901 | [1, 1901) | 1345 |
| … | … | … | … | … | … | … |
| 10 | 剩余 | 1 | 本身 | - | 全拿 | 剩下的 |
四、高并发红包实现
4.1 核心问题:怎么防止超发?
10000 人同时抢 100 个红包,如果没有原子操作,可能发出 120 个。
解决方案:Redis + Lua 脚本原子扣减。
4.2 预分配 vs 实时计算
| 方案 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 预分配 | 发红包时算好所有金额,存在 Redis List | 抢的时候 O(1) 弹出,极快 | 大红包(万级数量)存储占用高 |
| 实时计算 | 抢的时候现场算 | 存储省 | 需要原子操作保证一致性,复杂 |
项目实践中,小红包(<1000 个)用预分配,大红包用实时计算或分段预分配。
4.3 完整代码:预分配方案(Java + Lua)
Lua 脚本:原子弹出红包
-- /workspace/lua/grab_redpacket.lua-- KEYS[1]: 红包列表 key(如 redpacket:123:amounts)-- KEYS[2]: 红包已抢集合 key(如 redpacket:123:users)-- ARGV[1]: 用户IDlocalamountList=KEYS[1]localuserSet=KEYS[2]localuserId=ARGV[1]-- 1. 检查是否重复领取localexists=redis.call('sismember',userSet,userId)ifexists==1thenreturn{-1}-- -1 表示已领取过end-- 2. 从列表右侧弹出一个金额(RPOP)localamount=redis.call('rpop',amountList)ifnotamountthenreturn{0}-- 0 表示红包已抢完end-- 3. 记录用户已领取redis.call('sadd',userSet,userId)-- 4. 返回抢到的金额(分)return{tonumber(amount)}Java 红包服务
@Service@Slf4jpublicclassRedPacketService{@AutowiredprivateStringRedisTemplateredisTemplate;@AutowiredprivateRedPacketMapperredPacketMapper;privatestaticfinalDefaultRedisScript<List>GRAB_SCRIPT=newDefaultRedisScript<>();static{GRAB_SCRIPT.setScriptText("local exists = redis.call('sismember', KEYS[2], ARGV[1]); "+"if exists == 1 then return {-1} end; "+"local amount = redis.call('rpop', KEYS[1]); "+"if not amount then return {0} end; "+"redis.call('sadd', KEYS[2], ARGV[1]); "+"return {tonumber(amount)};");GRAB_SCRIPT.setResultType(List.class);}/** * 发红包:预分配金额,写入 Redis List */publicStringsendRedPacket(LonguserId,inttotalAmount,inttotalNum){// 1. 生成红包IDStringpacketId=UUID.randomUUID().toString().replace("-","");StringlistKey="redpacket:"+packetId+":amounts";StringuserKey="redpacket:"+packetId+":users";// 2. 二倍均值法生成金额列表(单位:分)List<Integer>amounts=generateRedPacket(totalAmount*100,totalNum);// 3. 写入 Redis List(RPUSH,抢的时候 RPOP)redisTemplate.opsForList().rightPushAll(listKey,amounts.stream().map(String::valueOf).toArray(String[]::new));// 4. 设置过期时间(24小时未领完退回)redisTemplate.expire(listKey,24,TimeUnit.HOURS);redisTemplate.expire(userKey,24,TimeUnit.HOURS);// 5. 异步落库redPacketMapper.insert(newRedPacketDO(packetId,userId,totalAmount,totalNum));log.info("用户{}发了红包{}, 总金额{}元, {}个",userId,packetId,totalAmount,totalNum);returnpacketId;}/** * 抢红包:Lua 脚本原子扣减 */publicGrabResultgrabRedPacket(StringpacketId,LonguserId){StringlistKey="redpacket:"+packetId+":amounts";StringuserKey="redpacket:"+packetId+":users";List<Long>result=redisTemplate.execute(GRAB_SCRIPT,Arrays.asList(listKey,userKey),String.valueOf(userId));Longcode=result.get(0);if(code==-1){returnGrabResult.fail("你已经领过这个红包了");}if(code==0){returnGrabResult.fail("手慢了,红包已经被抢完了");}// 抢到金额(分)intamount=code.intValue();// 异步记录流水、更新数据库recordGrabAsync(packetId,userId,amount);returnGrabResult.success(amount/100.0);}/** * 二倍均值法生成红包金额列表 * @param totalAmount 总金额(单位:分) * @param totalNum 总个数 */publicstaticList<Integer>generateRedPacket(inttotalAmount,inttotalNum){List<Integer>amounts=newArrayList<>();intremainingAmount=totalAmount;intremainingNum=totalNum;Randomrandom=newSecureRandom();for(inti=0;i<totalNum-1;i++){// 二倍均值:随机范围 [1, remainingAmount / remainingNum * 2)intavg=remainingAmount/remainingNum;intmax=avg*2;// 确保至少留 (remainingNum - 1) 分钱给后面的人intsafeMax=Math.min(max,remainingAmount-(remainingNum-1));intsafeMin=1;intamount=random.nextInt(safeMax-safeMin+1)+safeMin;amounts.add(amount);remainingAmount-=amount;remainingNum--;}// 最后一个人拿剩下的amounts.add(remainingAmount);// 打乱顺序,避免金额大小和领取顺序相关Collections.shuffle(amounts);returnamounts;}@AsyncpublicvoidrecordGrabAsync(StringpacketId,LonguserId,intamount){// 异步写入数据库,削峰redPacketMapper.recordGrab(packetId,userId,amount);}}4.4 大红包拆分优化
如果发 10000 元拆成 10000 个红包,Redis List 存 10000 个元素没问题。
但如果是 100 万元拆成 100 万个,直接存 List 就不合适了。
优化方案:分段预分配
- 先预分配前 1000 个,抢完后再生成下一批
- 或者改用实时计算 + 分布式锁 / Lua 脚本保证原子性
五、踩坑指南
坑1:浮点数精度
我见过太多人用
double算红包金额,结果 100 元分 3 个人,总和变成 99.99 或 100.01。**必须用分做单位,全程整数运算。**最后再除以 100 展示给用户。
坑2:红包抢完了还有人来抢
Redis List 为空的时候,要返回明确的提示"手慢了,红包已经被抢完了",不要抛异常或者返回 null。
坑3:红包退回机制
24 小时未领完要退回。用 Redis TTL + 定时任务扫描,或者 Redis 过期回调。注意:过期回调不保证准时,重要业务建议用延迟队列(如 RabbitMQ DLX、RocketMQ 延迟消息)。
坑4:并发下的重复领取
用户双击、网络重试,可能导致同一个用户领两次。Lua 脚本里的
sismember就是干这个的——先查再弹,原子操作。
坑5:红包金额生成后未打乱顺序
如果不
Collections.shuffle(),金额大小和领取顺序就有关联了,虽然期望一样,但体验不好。
六、问题与解答
Q1:二倍均值法为什么能保证公平性?先抢和后抢的期望值真的相等吗?
A:相等。数学上,每次抢的期望 ≈剩余金额 / 剩余人数。不管第几个抢,当前期望都是剩余金额的平均值。而且二倍均值的随机范围设计,保证了每个人的随机变量独立同分布。实际测试中,10 万元发 1000 个红包,先抢和后抢的均值差异在 1% 以内。
Q2:为什么不直接用数据库行锁来防止超发?
A:数据库行锁能防超发,但扛不住高并发。1 万个请求同时抢,数据库会瞬间被打爆。Redis Lua 脚本是原子操作,性能是数据库的 10 倍以上,而且是无锁的(单线程执行)。
Q3:如果 Redis 挂了,红包数据怎么保证不丢?
A:项目里的做法是多级保障:
- Redis 主从 + Sentinel 保证高可用;
- 抢红包结果异步落库,即使 Redis 挂了一段时间,数据库也有记录;
- 极端情况下可以重建 Redis 数据(从数据库反查)。
七、面试高频考点汇总
Q1:微信红包的二倍均值法是怎么实现的?
A:核心公式是rand(1, 剩余金额/剩余人数 × 2),用分做单位整数运算。发红包时预分配金额列表存在 Redis,抢的时候用 Lua 脚本原子弹出,配合 Set 做幂等校验防止重复领取。
Q2:怎么防止红包超发?
A:超发的根源是"检查库存"和"扣减库存"不是原子操作。解决方案是 Redis + Lua 脚本,把检查、弹出、记录用户三步放在一个 Lua 脚本里执行。Redis 单线程保证原子性。
Q3:二倍均值法和线段切割法有什么区别?
A:线段切割法是在总金额上随机切几刀,完全随机但可能出现极端值,而且需要排序,时间复杂度 O(n log n)。二倍均值法是每次取二倍均值范围内的随机数,时间复杂度 O(n),不会出极端值,而且数学上可证公平。
Q4:红包未领完怎么退回?
A:两种做法:一是 Redis 设置 24 小时 TTL,过期后通过过期回调或定时任务扫描退回;二是发延迟消息(如 RocketMQ 延迟级别),24 小时后消费消息触发退回逻辑。
Q5:大红包(比如 100 万个)怎么优化存储?
A:小红包直接预分配存 Redis List。大红包用分段预分配(先存一批,用完再生成),或者改用实时计算 + Lua 脚本做原子扣减和计算。
八、模拟面试官提问和参考答案
场景题1:如果让你设计一个支持 10 万人同时抢的红包系统,你会怎么架构?
参考答案:
- 接入层:Nginx + 限流,防止恶意刷接口;
- 应用层:Spring Boot 集群,无状态设计;
- 核心层:Redis Cluster 存储红包数据,Lua 脚本原子抢红包;
- 异步层:MQ 削峰,抢红包结果异步落库;
- 兜底:降级策略,Redis 挂了走数据库行锁(有损降级)。
场景题2:用户反馈他抢了红包但钱没到账,怎么排查?
参考答案:
- 先查 Redis 的领取记录 Set,确认他是否真的抢到了;
- 查 MQ 消费情况,看是不是异步落库消息丢了;
- 查数据库流水,看是否有记录但状态未更新;
- 最终一致性保障:每天对账,Redis 和数据库金额不一致时自动补偿。
场景题3:怎么防止用户用脚本刷红包?
参考答案:
- 接口限流:同一个用户 1 秒内只能抢 1 次;
- 验证码:异常行为触发验证码(如 1 分钟内抢 10 个红包);
- 设备指纹 + IP 黑名单;
- 业务限制:每个红包每人限领 1 次,这个在 Lua 脚本里已经做了。
场景题4:如果红包金额要支持小数点后三位(厘),二倍均值法怎么改?
参考答案:把最小单位从"分"改成"厘"就行了。总金额乘以 1000,最后展示的时候除以 1000。核心算法完全不用变,整数运算的优势就在这里——换单位零成本。
场景题5:红包系统怎么测试?压测指标是什么?
参考答案:
- 功能测试:金额守恒(所有红包之和 = 总金额)、每人至少最小单位、不重复领取;
- 并发测试:JMeter 模拟 1 万人抢 100 个红包,验证不超发;
- 压测指标:QPS(目标 1 万+)、P99 延迟(目标 < 50ms)、超发率(必须为 0)。
九、互动话题
你面试的时候被问到过红包算法吗?除了二倍均值法,你还听过哪些实现方案?欢迎在评论区聊聊你踩过的坑。
十、参考资料
- 微信红包的随机算法是怎样实现的? - 知乎
- Redis + Lua 实现高并发抢红包 - 掘金
- RedPacket Algorithm in Java - GitHub
- 分布式锁与高并发红包系统架构设计 - InfoQ
- 微信红包后台架构设计 - 腾讯云开发者社区