【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 Lua4.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)因为有排序 */publicstaticListIntegerlineCutting(inttotalAmount,inttotalNum){ListIntegercutsnewArrayList();RandomrandomnewRandom();// 随机切 totalNum - 1 刀范围 [1, totalAmount - 1]while(cuts.size()totalNum-1){intcutrandom.nextInt(totalAmount-1)1;if(!cuts.contains(cut)){cuts.add(cut);// 去重}}Collections.sort(cuts);ListIntegerresultnewArrayList();intprev0;for(intcut:cuts){result.add(cut-prev);prevcut;}result.add(totalAmount-prev);// 最后一段returnresult;}2.2 剩余平均法思路每次分剩余金额的平均值。优点实现简单两行代码搞定缺点先抢的有明显优势越往后越少。用户会骂你手慢无/** * 剩余平均法每次取剩余金额的平均值附近 * 缺陷先抢的人期望更高不公平 */publicstaticListIntegerremainingAverage(inttotalAmount,inttotalNum){ListIntegerresultnewArrayList();intremainingAmounttotalAmount;intremainingNumtotalNum;RandomrandomnewRandom();for(inti0;itotalNum-1;i){intavgremainingAmount/remainingNum;// 在平均值附近随机范围 [1, 2*avg - 1]intamountrandom.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 个人抢。第几个人剩余金额分剩余人数均值二倍均值随机范围抢到金额模拟1100001010002000[1, 2000)15232847799411883[1, 1883)8763760189501901[1, 1901)1345…………………10剩余1本身-全拿剩下的四、高并发红包实现4.1 核心问题怎么防止超发10000 人同时抢 100 个红包如果没有原子操作可能发出 120 个。解决方案Redis Lua 脚本原子扣减。4.2 预分配 vs 实时计算方案思路优点缺点预分配发红包时算好所有金额存在 Redis List抢的时候 O(1) 弹出极快大红包万级数量存储占用高实时计算抢的时候现场算存储省需要原子操作保证一致性复杂项目实践中小红包1000 个用预分配大红包用实时计算或分段预分配。4.3 完整代码预分配方案Java LuaLua 脚本原子弹出红包-- /workspace/lua/grab_redpacket.lua-- KEYS[1]: 红包列表 key如 redpacket:123:amounts-- KEYS[2]: 红包已抢集合 key如 redpacket:123:users-- ARGV[1]: 用户IDlocalamountListKEYS[1]localuserSetKEYS[2]localuserIdARGV[1]-- 1. 检查是否重复领取localexistsredis.call(sismember,userSet,userId)ifexists1thenreturn{-1}-- -1 表示已领取过end-- 2. 从列表右侧弹出一个金额RPOPlocalamountredis.call(rpop,amountList)ifnotamountthenreturn{0}-- 0 表示红包已抢完end-- 3. 记录用户已领取redis.call(sadd,userSet,userId)-- 4. 返回抢到的金额分return{tonumber(amount)}Java 红包服务ServiceSlf4jpublicclassRedPacketService{AutowiredprivateStringRedisTemplateredisTemplate;AutowiredprivateRedPacketMapperredPacketMapper;privatestaticfinalDefaultRedisScriptListGRAB_SCRIPTnewDefaultRedisScript();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. 生成红包IDStringpacketIdUUID.randomUUID().toString().replace(-,);StringlistKeyredpacket:packetId:amounts;StringuserKeyredpacket:packetId:users;// 2. 二倍均值法生成金额列表单位分ListIntegeramountsgenerateRedPacket(totalAmount*100,totalNum);// 3. 写入 Redis ListRPUSH抢的时候 RPOPredisTemplate.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){StringlistKeyredpacket:packetId:amounts;StringuserKeyredpacket:packetId:users;ListLongresultredisTemplate.execute(GRAB_SCRIPT,Arrays.asList(listKey,userKey),String.valueOf(userId));Longcoderesult.get(0);if(code-1){returnGrabResult.fail(你已经领过这个红包了);}if(code0){returnGrabResult.fail(手慢了红包已经被抢完了);}// 抢到金额分intamountcode.intValue();// 异步记录流水、更新数据库recordGrabAsync(packetId,userId,amount);returnGrabResult.success(amount/100.0);}/** * 二倍均值法生成红包金额列表 * param totalAmount 总金额单位分 * param totalNum 总个数 */publicstaticListIntegergenerateRedPacket(inttotalAmount,inttotalNum){ListIntegeramountsnewArrayList();intremainingAmounttotalAmount;intremainingNumtotalNum;RandomrandomnewSecureRandom();for(inti0;itotalNum-1;i){// 二倍均值随机范围 [1, remainingAmount / remainingNum * 2)intavgremainingAmount/remainingNum;intmaxavg*2;// 确保至少留 (remainingNum - 1) 分钱给后面的人intsafeMaxMath.min(max,remainingAmount-(remainingNum-1));intsafeMin1;intamountrandom.nextInt(safeMax-safeMin1)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微信红包后台架构设计 - 腾讯云开发者社区