简介:这份PDF资料面向JavaScript初学者与对算法感兴趣的开发者,围绕微信抢红包这一经典场景,讲解如何用JS模拟实现红包分配算法并解决其中的公平性与精度问题。内容从最朴素的随机函数入手,剖析先抢者占优、后抢者金额受限的不公平现象,进而引入二倍均值法,通过将每次随机区间控制在平均值的一半到两倍之间,使各人机会趋于均等,同时兼顾每人至少0.01元、总额恰好等于100元的约束。文中还专门讨论了JavaScript浮点数运算导致的余额偏差问题,并给出四舍五入等处理思路,配有可直接运行的代码示例与测试结果。资源包为1个PDF文件,大小约111KB,轻量便携,适合随时查阅。目前已有452人学习,可作为理解随机分配算法、练习JS数值处理的实用参考,帮助读者掌握从问题发现到方案优化的完整思路。
1. 拆开红包那一刻,服务端到底算了什么
群里抢红包的场景你一定不陌生:手指点下去,金额跳出来,有人 0.58 有人 12.36,最后一个还常常是「手气最佳」。很多人第一次写红包算法,直觉是「随机分一下不就行了」,结果上线后被用户投诉「每次都抢到几分钱」「大额永远在前面被抢走」。JavaScript 实现微信红包算法及问题解决方法,核心其实不是「随机」,而是在总额固定、人数固定、每人至少一分钱的前提下,让金额分布看起来自然,同时保证任何并发情况下总额不超发、不欠发。
这篇文章面向的是需要在前端或 Node.js 服务端落地红包逻辑的开发者。我会把「二倍均值法」这个主流思路拆到能直接抄的程度,再讲清楚浮点误差、并发超发、金额边界这三个最容易翻车的地方。读完你能自己写出一版可复现、可测试、能扛住并发的红包分配函数,而不是只会背一句「随机区间是剩余均值两倍」。
2. 二倍均值法:为什么它比纯随机更像「真红包」
2.1 纯随机的两个致命问题
先看最朴素的写法:每次从剩余金额里随机取一个数。假设总额 100 元、10 个人,第一次随机可能取到 99 元,剩下 9 个人分 1 元,后面每个人只能拿 0.11 元左右。这种分布在数学上叫「不均匀到极端」,用户体验就是「前面的人吃肉,后面的人喝汤」。
第二个问题是边界失控。如果随机范围是[0, 剩余金额],理论上某一次可以取到接近全部剩余金额,导致后面的人分不到钱。要保证每人至少 0.01 元,就必须给后续每个人预留 0.01 元,也就是当前最大可取值是剩余金额 - (剩余人数 - 1) * 0.01。
纯随机不是不能用,而是需要额外约束,写起来反而更绕。主流做法直接换成二倍均值法。
2.2 二倍均值法的数学直觉
二倍均值法的规则只有一句话:每次随机的范围是[0.01, 剩余金额 / 剩余人数 * 2],取一个随机数作为当前红包金额。
为什么是 2 倍?因为如果每次都在[0, 剩余均值]里取,期望是剩余均值的一半,会导致金额越来越小、分布偏斜。取[0, 2倍均值]时期望正好等于剩余均值,长期看每个人的期望金额相同,分布更接近真实红包的「有高有低但不会太离谱」。
举个具体数字:100 元 10 人,第一次均值 10 元,随机范围[0.01, 20],假设取到 15;剩 85 元 9 人,均值 9.44,范围[0.01, 18.89];以此类推。你会发现大额不一定出现在第一个,也可能出现在中间,这就是「手气最佳」随机性的来源。
2.3 用 JavaScript 写出第一版可运行代码
下面这版是能直接跑的,我把它写成纯函数,方便你复制到任何环境测试。
/** * 二倍均值法拆分红包 * @param {number} total 总金额,单位:分 * @param {number} count 红包个数 * @returns {number[]} 每个红包的金额,单位:分 */ function splitRedPacket(total, count) { // 参数校验:总额和个数必须是正整数,且总额至少能覆盖每人一分 if (!Number.isInteger(total) || !Number.isInteger(count)) { throw new Error('total 和 count 必须是整数(单位:分)'); } if (count <= 0) { throw new Error('红包个数必须大于 0'); } if (total < count) { throw new Error('总金额不足以每人至少一分'); } const result = []; let restAmount = total; // 剩余金额,单位分 let restCount = count; // 剩余人数 while (restCount > 1) { // 当前最大可取值 = 剩余金额 - 给后面每人预留 1 分 const max = restAmount - (restCount - 1); // 二倍均值上限,但不能超过 max const upper = Math.min(Math.floor((restAmount / restCount) * 2), max); // 随机区间 [1, upper],单位分 const amount = Math.floor(Math.random() * (upper - 1 + 1)) + 1; result.push(amount); restAmount -= amount; restCount -= 1; } // 最后一个人拿走剩余全部,保证总额精确 result.push(restAmount); return result; } // 测试:100 元 10 人 const packets = splitRedPacket(10000, 10); console.log(packets); console.log('总额校验:', packets.reduce((a, b) => a + b, 0));逻辑说明:整个循环里restAmount和restCount同步递减,每次只处理「当前这个人拿多少」,最后一个人直接兜底。upper用Math.min做了双重约束,既不超过二倍均值,也不超过「给后面留够一分钱」的硬上限。
参数说明:total和count都要求整数,单位是分。这是血泪经验——用元做浮点运算,后面必然遇到0.1 + 0.2 !== 0.3的问题。Math.floor保证取整,Math.random()返回[0, 1),乘上区间长度再加 1 得到[1, upper]的整数。
2.4 为什么单位要用「分」而不是「元」
JavaScript 的 Number 是双精度浮点,0.1 + 0.2得到0.30000000000000004。红包金额如果全程用元,累加校验时经常出现「总额差一分」的玄学问题。把单位统一成分,所有运算都是整数,reduce求和必然精确等于total。
如果你必须对外返回元,只在最后一步除以 100,并且用toFixed(2)格式化。注意toFixed返回的是字符串,别拿它继续参与运算。
3. 把算法接进业务:并发、幂等与预分配
3.1 并发抢红包为什么会超发
上面的函数是「抢的时候现算」。如果 10 个人同时请求,每个请求都读到「剩余 100 元 10 人」,各自算出一个金额,最后总额可能变成 200 元。这就是典型的读-算-写竞态。
常见做法有两种。第一种是预分配:红包创建时就把 10 个金额算好,存进一个列表,抢的时候用原子操作弹出。第二种是加锁:抢的瞬间对红包记录加行锁或分布式锁,算完再释放。预分配更适合高并发,因为抢的动作变成了一次pop,没有计算过程。
3.2 预分配版本的实现
/** * 预分配红包:创建时算好所有金额 * @param {number} total 总金额,单位分 * @param {number} count 个数 * @returns {number[]} 打乱后的金额列表 */ function preSplit(total, count) { const list = splitRedPacket(total, count); // 打乱顺序,避免「先抢的金额一定大」的规律被用户察觉 for (let i = list.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [list[i], list[j]] = [list[j], list[i]]; } return list; } // 模拟并发弹出:用数组模拟队列 const queue = preSplit(10000, 10); function grab() { if (queue.length === 0) return null; return queue.shift(); // 真实环境用 Redis LPOP 或数据库原子更新 }逻辑说明:preSplit先调用核心算法,再做一次 Fisher-Yates 洗牌。洗牌这步很关键——二倍均值法生成的序列本身有「前大后小」的轻微趋势,不打乱的话,先抢的人平均拿得更多,容易被用户总结出规律。
参数说明:洗牌循环从后往前,j的范围是[0, i],保证每个位置被交换的概率相等。真实业务里queue应该放在 Redis 的 List 或数据库里,用LPOP这类原子命令弹出,避免应用层并发问题。
3.3 幂等:同一个人不能抢两次
并发之外还有重复请求。用户手抖点两下,或者网络重试,同一个用户可能拿到两个红包。解决办法是在抢红包的入口做唯一约束:用红包ID + 用户ID作为唯一键,插入成功才继续,插入冲突直接返回已抢过的结果。
// 伪代码:数据库唯一索引兜底 async function grabRedPacket(packetId, userId) { try { // 唯一索引 (packet_id, user_id),重复插入会抛错 await db.insert('red_packet_record', { packet_id: packetId, user_id: userId }); } catch (e) { if (e.code === 'DUPLICATE_ENTRY') { return await db.findOne('red_packet_record', { packet_id: packetId, user_id: userId }); } throw e; } // 插入成功后再执行弹出金额的逻辑 return await popAmount(packetId); }逻辑说明:把「记录用户已抢」放在「弹出金额」之前,利用数据库唯一索引做幂等。即使两个请求同时到达,也只有一个能插入成功,另一个走冲突分支返回已有记录。
参数说明:packet_id和user_id的联合唯一索引是这套方案的核心,没有它幂等就不成立。注意插入和弹出金额之间如果服务崩溃,会出现「有记录没金额」的状态,生产环境需要用事务或补偿任务处理。
4. 避坑指南:金额、边界与测试的五个翻车现场
4.1 坑一:浮点误差导致总额差一分
现象:用元做单位,10 个红包加起来是 99.99 或 100.01,对账永远对不上。
原因:0.1 + 0.2 !== 0.3,浮点累加误差在多次运算后放大。
解决:全程用分做整数运算,只在展示层除以 100。如果历史数据已经是元,用Math.round(x * 100)转成分再算。
4.2 坑二:随机上限算错,最后一个人拿到负数
现象:偶尔最后一个红包金额是 0 或负数。
原因:upper没有用max约束,某次随机取到了超过「剩余金额 - 剩余人数」的值,导致后面不够分。
解决:upper = Math.min(二倍均值, restAmount - (restCount - 1)),两个上限都要卡。测试时把total设成count(每人正好一分),跑一万次看是否每次都返回全 1。
4.3 坑三:Math.random 的区间写错,永远取不到上限
现象:金额分布偏小,最大值总是差一点。
原因:Math.random() * upper得到[0, upper),取不到upper;如果写成Math.random() * upper + 1,又可能超过upper。
解决:要取[1, upper]的整数,正确写法是Math.floor(Math.random() * upper) + 1。注意这里upper已经是最大值,乘upper得到[0, upper),加 1 后是[1, upper]。
4.4 坑四:并发下预分配列表被重复消费
现象:两个人抢到同一个金额,或者总额超发。
原因:应用层用普通数组shift(),多个进程或线程同时操作,没有原子性。
解决:把列表放到 Redis,用LPOP;或者数据库里用UPDATE ... WHERE status = 'unused' LIMIT 1这类原子更新。应用层内存队列只适合单进程测试。
4.5 坑五:没做金额下限校验,出现 0 元红包
现象:用户抢到 0.00 元,投诉「假红包」。
原因:随机下限写成了 0,或者取整时Math.floor把 0.9 分变成了 0。
解决:下限固定为 1 分,取整用Math.floor后加 1,保证最小是 1。测试用例里必须包含total === count的极端情况。
5. 验证与调优:用统计眼光看你的红包分布
写完算法别急着上线,先跑一批数据看分布。我一般会做三件事:总额校验、均值校验、极值校验。
// 跑 10000 次,统计分布特征 function stressTest(total, count, times) { let min = Infinity, max = -Infinity, sum = 0; for (let i = 0; i < times; i++) { const packets = splitRedPacket(total, count); const s = packets.reduce((a, b) => a + b, 0); if (s !== total) throw new Error('总额校验失败:' + s); min = Math.min(min, ...packets); max = Math.max(max, ...packets); sum += s; } console.log('总额恒等于:', total); console.log('历史最小金额:', min, '分'); console.log('历史最大金额:', max, '分'); console.log('平均总额:', sum / times); } stressTest(10000, 10, 10000);这段代码的价值在于:s !== total一旦触发,说明你的整数运算有漏洞;min如果出现 0,说明下限没卡住;max如果接近total,说明二倍均值上限失效。跑一万次不报错,基本可以认为算法层面是稳的。
调优方向有两个。如果觉得金额太平均,可以把二倍均值改成 1.5 倍或 2.5 倍,倍数越大方差越大;如果觉得大额太集中,可以在预分配后做分段洗牌,而不是全局洗牌。参数没有标准答案,取决于你的业务想要「刺激」还是「温和」。
最后说个我自己的习惯:每次改完红包算法,我都会把total === count、count === 1、total极大这三种边界各跑一遍,再跑一次一万次压力测试。红包这东西,用户对金额的敏感度远超你的想象,差一分钱都能被截图发出来。希望帮到你。
本文还有配套的精品资源,点击获取