【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 个人抢。

第几个人剩余金额(分)剩余人数均值二倍均值随机范围抢到金额(模拟)
1100001010002000[1, 2000)1523
2847799411883[1, 1883)876
3760189501901[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:项目里的做法是多级保障:

  1. Redis 主从 + Sentinel 保证高可用;
  2. 抢红包结果异步落库,即使 Redis 挂了一段时间,数据库也有记录;
  3. 极端情况下可以重建 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 万人同时抢的红包系统,你会怎么架构?

参考答案:

  1. 接入层:Nginx + 限流,防止恶意刷接口;
  2. 应用层:Spring Boot 集群,无状态设计;
  3. 核心层:Redis Cluster 存储红包数据,Lua 脚本原子抢红包;
  4. 异步层:MQ 削峰,抢红包结果异步落库;
  5. 兜底:降级策略,Redis 挂了走数据库行锁(有损降级)。

场景题2:用户反馈他抢了红包但钱没到账,怎么排查?

参考答案:

  1. 先查 Redis 的领取记录 Set,确认他是否真的抢到了;
  2. 查 MQ 消费情况,看是不是异步落库消息丢了;
  3. 查数据库流水,看是否有记录但状态未更新;
  4. 最终一致性保障:每天对账,Redis 和数据库金额不一致时自动补偿。

场景题3:怎么防止用户用脚本刷红包?

参考答案:

  1. 接口限流:同一个用户 1 秒内只能抢 1 次;
  2. 验证码:异常行为触发验证码(如 1 分钟内抢 10 个红包);
  3. 设备指纹 + IP 黑名单;
  4. 业务限制:每个红包每人限领 1 次,这个在 Lua 脚本里已经做了。

场景题4:如果红包金额要支持小数点后三位(厘),二倍均值法怎么改?

参考答案:把最小单位从"分"改成"厘"就行了。总金额乘以 1000,最后展示的时候除以 1000。核心算法完全不用变,整数运算的优势就在这里——换单位零成本。

场景题5:红包系统怎么测试?压测指标是什么?

参考答案:

  1. 功能测试:金额守恒(所有红包之和 = 总金额)、每人至少最小单位、不重复领取;
  2. 并发测试:JMeter 模拟 1 万人抢 100 个红包,验证不超发;
  3. 压测指标:QPS(目标 1 万+)、P99 延迟(目标 < 50ms)、超发率(必须为 0)。

九、互动话题

你面试的时候被问到过红包算法吗?除了二倍均值法,你还听过哪些实现方案?欢迎在评论区聊聊你踩过的坑。


十、参考资料

  1. 微信红包的随机算法是怎样实现的? - 知乎
  2. Redis + Lua 实现高并发抢红包 - 掘金
  3. RedPacket Algorithm in Java - GitHub
  4. 分布式锁与高并发红包系统架构设计 - InfoQ
  5. 微信红包后台架构设计 - 腾讯云开发者社区