简介:这份PDF资源面向JavaScript初学者与对算法感兴趣的开发者,围绕微信抢红包这一经典场景,讲解如何用JS模拟实现红包分配并解决其中的公平性问题。内容从最朴素的随机函数入手,指出先抢者占优、后抢者金额受限的不合理之处,进而引出二倍均值法,通过将每次随机区间控制在平均值的一半到两倍之间,让每个人的机会趋于平等,同时兼顾每人至少0.01元、总额恰好等于100元等约束。文中还专门讨论了JavaScript浮点数运算导致的余额偏差问题,并给出相应的处理思路。资源包共1个PDF文件,约111KB,篇幅精炼、代码示例完整,适合作为算法练习或面试准备的参考材料。目前已有452人学习,读者可借此理解随机分配类问题的常见陷阱与改进方案,掌握从简单实现到公平算法的完整推导过程。
1. 从一次线上事故说起:为什么“随机发红包”会翻车
去年帮一个做社群工具的朋友排查过一个挺典型的线上问题:他们用 JavaScript 写了个“拼手气红包”功能,逻辑就是每次从剩余金额里随机切一刀,结果运营那边反馈——用户投诉“手气最佳永远被前几个抢走”,甚至有人专门写脚本卡点抢。我拉了下日志,前 3 个人拿走的金额占了总额的 60% 以上,最后几个人经常只有几分钱。这不是玄学,是算法本身的结构性缺陷。
这份资源要解决的,就是“用 JavaScript 实现一个金额分配相对公平、且能落地到生产环境的红包算法”。它适合两类人:一是刚接触随机分配类需求、想搞懂“为什么简单随机不公平”的前端或 Node.js 开发者;二是已经在业务里用了红包/优惠券拆分逻辑、但被浮点数误差和边界条件反复折磨的工程师。核心不是教你写一个Math.random(),而是把“公平性约束 + 浮点精度 + 最小金额兜底”这三件事串成一条能复现的链路。下面我从最朴素的实现开始拆,一路推到能扛住真实并发场景的版本。
2. 朴素随机切分法:代码能跑,但公平性从第一行就崩了
2.1 最直觉的实现长什么样
很多人拿到“100 元发 10 个人”这个需求,第一反应就是循环切香肠:每次从剩余金额里随机取一段,最后一个人拿剩下的。代码写出来大概是这样:
// 朴素随机切分:每次从剩余金额中随机取一段 function naiveRedPacket(total, count) { const result = []; let remaining = total; for (let i = 0; i < count - 1; i++) { // 随机范围是 [0.01, remaining - 0.01 * 剩余人数] // 这里先不扣剩余人数的最小金额,暴露问题 const amount = Math.random() * remaining; result.push(Number(amount.toFixed(2))); remaining -= amount; } result.push(Number(remaining.toFixed(2))); return result; } console.log(naiveRedPacket(100, 10));这段代码跑起来没问题,控制台能打印出 10 个数字,加起来也接近 100。但问题藏在随机范围的设定里:第一个人的取值范围是[0, 100],期望值是 50;第二个人面对的是剩余金额,期望值直接砍半;到第十个人,前面九个人已经把大部分钱拿走了,他只能捡剩下的零头。这不是“手气”问题,是概率分布本身就把优势倾斜给了先抢的人。
2.2 用期望值算一笔账
假设总额 100 元、10 个人,第一个人随机取[0, 100],数学期望是 50 元。第二个人面对剩余金额100 - x1,期望是(100 - x1) / 2。以此类推,第 k 个人的期望金额是100 / 2^k。第十个人的期望只有100 / 1024 ≈ 0.097 元,连一毛钱都不到。而第一个人期望 50 元,是第十个人的 500 多倍。这种指数级衰减的分布,在业务上就是“先到先得”的极端版本,完全违背了“每个人机会平等”的初衷。
更麻烦的是,如果第一个人运气爆棚抽到 90 元,后面九个人分剩下的 10 元,平均每人 1.1 元,但还要保证每人至少 0.01 元,实际分配会非常难看。我见过有团队直接用这种逻辑上线,结果用户投诉率飙升,最后不得不回滚重写。
2.3 浮点数误差:那个“余额对不上”的黑匣子
朴素实现还有一个隐蔽的坑:JavaScript 的浮点数运算。0.1 + 0.2 !== 0.3这个经典问题在红包场景里会直接导致“余额对不上”。比如你每次用toFixed(2)截断,截断产生的误差会累积,最后一个人的金额可能变成-0.01或者0.00,直接触发业务校验失败。
// 浮点误差演示 let remaining = 100; const parts = [33.33, 33.33, 33.33]; parts.forEach(p => { remaining -= p; }); console.log(remaining); // 0.009999999999994429,不是 0.01常见做法是全程用“分”作为整数单位,最后再除以 100 转回元。这样所有运算都在整数域完成,彻底绕开浮点误差。这个思路在后面所有版本里都会沿用,算是红包算法的地基。
3. 二倍均值法:把“公平”拆成可计算的上下界
3.1 算法原理:为什么是 0.5 倍到 2 倍
二倍均值法的核心思想是:每次随机金额的区间,不再从 0 开始,而是以“剩余人均金额”为中心,取[0.5 * 人均, 2 * 人均]作为随机范围。这样既保留了随机性,又不会让某一次抽取偏离均值太远。
具体来说,假设当前剩余金额为M,剩余人数为N,人均金额avg = M / N。那么本次红包金额在[0.5 * avg, 2 * avg]之间随机。这个区间的期望值大约是1.25 * avg,略高于人均,但因为有上限约束,不会出现第一个人拿走 90% 的情况。随着剩余金额和人数变化,区间会自动收窄,保证最后几个人也能拿到合理金额。
为什么选 0.5 和 2 这两个系数?这是工程上的经验值。下限太低(比如 0.1)会导致前面的人可能拿太少,后面的人压力大;上限太高(比如 3)又会让前面的人有机会拿走过多。0.5 到 2 这个范围,经过大量模拟测试,金额分布的方差比较可控,既不会太平均(失去红包乐趣),也不会太极端(引发投诉)。
3.2 整数分版本实现
下面是用“分”作为单位的二倍均值法实现,所有运算都是整数,最后再转回元:
// 二倍均值法:整数分版本,避免浮点误差 function fairRedPacket(totalYuan, count) { const totalFen = Math.round(totalYuan * 100); // 总金额转分 const minFen = 1; // 每人至少 1 分 const result = []; let remainingFen = totalFen; let remainingCount = count; for (let i = 0; i < count - 1; i++) { // 剩余人均金额(分) const avgFen = remainingFen / remainingCount; // 随机区间 [0.5 * avg, 2 * avg],并保证不超过剩余金额减去剩余人数的最小值 const maxFen = Math.min( Math.floor(avgFen * 2), remainingFen - minFen * (remainingCount - 1) ); const minFenThis = Math.max( minFen, Math.floor(avgFen * 0.5) ); // 在 [minFenThis, maxFen] 之间随机取整数 const amountFen = Math.floor( Math.random() * (maxFen - minFenThis + 1) ) + minFenThis; result.push(amountFen); remainingFen -= amountFen; remainingCount--; } // 最后一个人拿剩余全部 result.push(remainingFen); // 转回元,保留两位小数 return result.map(fen => Number((fen / 100).toFixed(2))); } console.log(fairRedPacket(100, 10));这段代码有几个关键点。第一,totalFen把元转成分,Math.round避免100 * 100出现9999.9999这种意外。第二,maxFen用Math.min做了双重约束:既不能超过二倍均值,也不能让剩余的人分不到最小金额。第三,minFenThis保证每次至少拿 1 分,同时不低于半倍均值。第四,最后一个人直接拿remainingFen,保证总额精确等于 10000 分。
3.3 参数怎么调:系数、最小值和人数上限
二倍均值法里有两个可调参数:下限系数和上限系数。默认是 0.5 和 2,但业务场景不同可以微调。比如做“小额高频”的红包,可以把下限调到 0.3,让金额分布更分散;做“大额少人”的场景,可以把上限调到 1.8,避免单个人拿太多。
最小金额minFen一般设 1 分,但有些业务要求每人至少 0.1 元,那就设成 10 分。注意minFen不能设太大,否则当remainingFen / remainingCount接近minFen时,随机区间会退化成一个点,失去随机性。
人数上限方面,如果count超过totalFen / minFen,比如 100 元发 20000 人,每人至少 1 分,总额只有 10000 分,根本不够分。这种情况必须在入口做校验,直接拒绝或者调整参数。我一般会在函数开头加一行判断:
if (totalFen < count * minFen) { throw new Error('总金额不足以满足每人最小金额'); }3.4 模拟验证:跑一万次看分布
写完算法不能只看一次输出,得用模拟验证分布是否合理。下面这段代码跑 10000 次,统计每个人拿到的平均金额和最大金额:
// 模拟 10000 次,验证公平性 function simulate(total, count, times) { const sumByIndex = new Array(count).fill(0); const maxByIndex = new Array(count).fill(0); for (let t = 0; t < times; t++) { const packets = fairRedPacket(total, count); packets.forEach((amount, idx) => { sumByIndex[idx] += amount; maxByIndex[idx] = Math.max(maxByIndex[idx], amount); }); } return { avg: sumByIndex.map(s => (s / times).toFixed(2)), max: maxByIndex.map(m => m.toFixed(2)) }; } console.log(simulate(100, 10, 10000));跑出来的结果,每个人的平均金额应该都在 10 元附近波动,最大金额也不会出现某个人长期拿 50 以上的情况。如果发现第一个人的平均金额明显高于其他人,说明随机区间设置有问题,需要检查maxFen的计算逻辑。
4. 避坑与排查:那些让我熬夜改代码的边界问题
4.1 现象:最后一个人金额为 0 或负数
原因:前面的人把剩余金额分完了,没有给最后一个人留最小金额。常见于随机范围上限没有扣减minFen * (remainingCount - 1)。
解决:在计算maxFen时强制约束remainingFen - minFen * (remainingCount - 1),并且每次分配后检查remainingFen是否还够后面的人分。如果不够,直接把剩余金额按最小金额平摊。
4.2 现象:总额加起来差几分钱
原因:浮点数运算累积误差,或者toFixed截断方向不一致。比如0.005用toFixed(2)可能变成0.00或0.01,取决于浏览器实现。
解决:全程用整数分运算,最后一步才转元。转元时用Number((fen / 100).toFixed(2)),确保输出格式统一。如果业务要求必须精确到分,那就直接返回分,让前端自己格式化。
4.3 现象:某个人抢到的金额超过剩余总额
原因:随机范围上限没有和remainingFen取最小值,导致amountFen可能大于剩余金额。
解决:maxFen必须用Math.min同时约束二倍均值和剩余金额。另外,在amountFen生成后加一道保险:
const amountFen = Math.min( Math.floor(Math.random() * (maxFen - minFenThis + 1)) + minFenThis, remainingFen - minFen * (remainingCount - 1) );4.4 现象:并发请求下红包金额重复或超发
原因:算法本身是纯函数,但业务层如果没有加锁,多个请求同时读取剩余金额,会导致超发。
解决:红包算法只负责“给定总额和人数,生成一组金额”,不负责并发控制。并发场景下,应该先把红包金额预生成好存入数据库或缓存,抢红包时只是“领取”已生成的金额,而不是实时计算。常见做法是用 Redis 的LPOP原子操作从预生成队列里取。
4.5 现象:人数很多时性能下降
原因:每次循环都做浮点除法和Math.random(),人数上万时耗时明显。
解决:整数运算本身很快,主要瓶颈在Math.random()的调用次数。如果人数特别多,可以改用“线段切割法”的变体,先随机生成count - 1个切分点,排序后计算相邻差值。这样只需要一次排序,随机数生成次数也是count - 1,但减少了循环内的除法运算。
5. 进阶技巧:从“能跑”到“敢上线”还差哪几步
5.1 预生成 + 队列:把计算和领取拆开
生产环境的红包系统,通常不会在用户点击“抢”的那一刻才计算金额。常见做法是:发红包时就把所有金额算好,存入一个队列(比如 Redis List),每个元素是一个红包金额。用户抢的时候,用LPOP原子弹出,弹到什么就是什么。这样既避免了并发超发,也把计算压力前移到了发红包的瞬间。
// 伪代码:预生成红包并存入队列 const packets = fairRedPacket(100, 10); // 假设 redis 是已连接的客户端 for (const amount of packets) { await redis.lpush('redpacket:queue:123', amount); } // 抢红包时 const amount = await redis.lpop('redpacket:queue:123'); if (amount === null) { return { code: 400, msg: '红包已抢完' }; } return { code: 0, amount: Number(amount) };这里的关键是LPOP的原子性,多个并发请求同时到达,Redis 会保证每个请求拿到不同的元素,不会重复也不会超发。
5.2 金额分布的可视化验证
上线前我习惯把模拟结果画成直方图,看看金额分布是不是符合预期。用 Node.js 可以快速输出一个文本版的分布:
// 统计金额落在各个区间的次数 function distribution(total, count, times) { const buckets = new Array(10).fill(0); // 0-10元、10-20元... for (let t = 0; t < times; t++) { const packets = fairRedPacket(total, count); packets.forEach(amount => { const idx = Math.min(Math.floor(amount / 10), 9); buckets[idx]++; }); } return buckets.map((c, i) => `${i * 10}-${i * 10 + 10}元: ${c}`)); } console.log(distribution(100, 10, 10000));如果发现某个区间占比异常高,比如 0-10 元占了 80%,说明随机区间下限太低,需要调高下限系数。正常情况应该是一个中间高、两边低的钟形分布,大部分金额集中在 5-15 元之间。
5.3 一个我踩过的坑:Math.random()的种子问题
早期做压测时,我发现每次重启服务后,前几个红包金额几乎一样。排查后发现是测试环境用了固定的随机种子(某些测试框架会 mockMath.random),导致随机序列可预测。生产环境虽然不会 mock,但如果用Math.random()做安全相关的随机(比如抽奖),需要换成crypto.getRandomValues()。红包算法对随机性要求没那么高,Math.random()够用,但要知道这个边界。
5.4 最后一步:把算法封装成可测试的模块
我现在的习惯是,任何涉及金额分配的算法,都必须写成纯函数,输入输出明确,不依赖外部状态。然后配一组单元测试,覆盖这些 case:总额 0.01 元发 1 人、总额 100 元发 1 人、总额 100 元发 10000 人、总额不足以满足最小金额、人数为 0 或负数。每次改完算法,先跑测试再上线。
从那以后,我每次写金额分配逻辑,都会先把“分”作为唯一单位,所有中间计算不出现浮点数,最后一步才转元。这个习惯帮我省掉了至少三次深夜排查“余额对不上”的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取