ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

仿微信红包系统实战:高并发下的金额拆分与数据一致性设计

仿微信红包系统实战:高并发下的金额拆分与数据一致性设计 简介仿微信红包前端实现资源包面向HTML/CSS/JavaScript学习者与需要完成互动类小项目的开发者提供一套可直接运行的仿微信红包页面。项目从HTML表单构建、CSS动画与响应式布局到JavaScript事件监听、随机金额分配、领取状态管理及转发诱导均有所涉及可作为课程设计或前端练手的起点。压缩包共17个文件大小约230KB其中hb.html为功能入口skin.css负责视觉样式4个js脚本引入jQuery、swiper等插件处理交互与动效8张png及2张jpg提供界面素材1个gif用于演示反馈文件分工明确、便于替换。代码中通过prompt()接收红包金额与数量、利用Math.random()实现随机分配、用对象或数组记录领取状态同时兼顾了转发分享、浏览器兼容和基本安全防护。该资源已有930人浏览学习整体轻量且结构完整配合预览中的UI素材适合作为理解前端互动设计和二次开发的基础。1. 为什么一个发红包功能值得单独写一篇先说结论你看到的那个简单红包界面背后藏着的是一整套分布式系统工程问题。如果只是做个玩具Demo前端抛个金额、后端存个记录那确实三五百行代码就搞定。但如果你想让功能真正可用——支持多人同时抢、金额不超发、账目总账对得上、数据库扛得住——你要面对的其实是三个经典难题金额拆分算法、并发资源竞争、数据一致性。仿微信红包这个项目我前后做了两版。第一版花了一个周末把功能跑通了感觉没什么了不起。第二版是在线上真实流量下被用户教做人的那一次我花了整整三周重写才把并发、超发、重复入账这些坑一个个填平。这篇就把完整的实现思路和踩坑记录写出来适合那些已经有后端基础、想真正理解红包系统本质的开发者。我不会贴完整工程代码那没意义。我会把核心模块的设计逻辑讲透表结构为什么这样建、金额为什么这样拆、Redis和数据库在抢红包时各自扮演什么角色、压测中哪些参数最容易被忽视。你看完可以直接照抄也可以根据业务场景改造成积分红包、优惠券抢购、限量秒杀——底层思路完全一致。2. 红包系统整体架构与核心业务流程拆解2.1 红包的完整生命周期一个红包从发出去到彻底消失中间经历的环节比你想象的多。发红包侧用户做一笔从账户余额转入红包池的操作。这个操作包含冻结余额、生成红包记录、根据算法拆分金额出明细、设置过期时间。抢红包侧用户点击红包系统判断状态、分配一个金额、入账到用户余额、返回金额给前端。最后还有收尾环节超过24小时未抢完的金额退回原账户。实际上线时红包状态比这个还要复杂。我最终维护了一张红包主表和一张红包明细表外加一张账户流水表三张表配合才能覆盖整个生命周期。2.2 预览红包的金额显示模块微信红包有个暧昧的设计抢之前看不到自己具体能拿多少但能看到还剩多少名额、多少金额。这意味着前端需要一个轻量查询接口实时返回红包的剩余总额和剩余数量。这个接口的QPS会比抢红包接口高一整个量级因为用户会反复刷新、反复确认。尤其是大群里有人发红包之后几十上百人同时戳进来预览如果每次都查数据库MySQL扛不住的。我的方案是用Redis的Hash结构单独维护一份红包实时状态字段就两个totalAmount和remainSize。每次发红包时初始化每次抢红包时扣减预览接口只读Redis不碰数据库。实测下来单机Redis能撑住每秒超过10万次的只读请求数据库的压力几乎为零。这里有个细节Redis中的金额必须用整数分存储绝不能直接用浮点元。原因很简单后面拆红包算法计算时整数运算永远不会出现精度的边际误差。2.3 抢红包的行为决策链条一个用户点击红包之后系统要按顺序做如下判断该红包是否存在、是否已过期当前用户是否已经抢过这个红包防止重复抢红包是否还有剩余名额是否还有剩余金额可分配分配一个金额入账用户余额回写红包明细前四个是判断后三个是动作。最容易出问题的地方在于判断和动作之间不是原子操作必须靠数据库行锁或分布式锁来保证并发安全。我第一版就是在这里踩了大坑没有做任何并发控制两个请求同时通过第3步的判断结果红包只剩最后一个名额两个人却都能抢到最终总分配金额超出了红包总额。虽然我用了MySQL事务但默认的REPEATABLE READ隔离级别下两个事务同时读到剩余1个名额然后各自插入明细事务都能提交成功——这就是典型的并发幻读导致的数据超发。3. 红包金额拆分算法的核心要点3.1 剩余金额随机法最容易想到的方案最直觉的做法是每次从剩余金额中随机取一部分保证每个人拿到的金额都在0.01到剩余平均值的两倍之间。公式大概是max 剩余金额 / 剩余名额 * 2 当前金额 随机(0.01, max)这个算法叫剩余金额随机法又称二倍均值法。优点是省空间不用一次性算出所有明细缺点是金额分布不均匀前几个抢的人有巨大优势后面的人可能只拿到几分钱。尤其是最后一个红包不管你前面怎么随机最后一个人永远拿剩余的全部金额。这会导致先抢的人均值偏高、后抢的人均值偏低和微信红包给人的体感并不完全一致。3.2 预生成模式把计算提前到发红包时我第二版改成了预生成模式。用户点击发红包后后端一次性把金额拆分好每一份对应一个明细ID存入Redis队列。抢红包时从队列里弹出一个预先算好的金额不做实时计算。这样做有几个好处拆分逻辑只执行一次耗时可控在毫秒级用户发红包时等的时长可接受抢红包时只需要List的POP操作耗时极短单个请求的Redis交互降到了最低金额分布可以严格控制比如限制最小值0.01、最大值不超过人均的5倍体感更接近真实微信红包代价是空间复杂度高一点但一个红包最多也就几百个明细存几百条Redis字符串记录内存开销完全可以忽略。3.3 保底策略所有人都能分到一分钱微信红包有硬性规则每个红包至少0.01元。如果你用的是无约束随机算法最后一轮可能出现剩余金额不够分给所有人的情况。所以预生成拆分时我在算法里加了兜底逻辑先给所有人分配一个0.01元的基本盘再把剩余金额按照随机权重分配出去这样保证红包必拆完、每人必有一份、不会出现余额不足导致红包作废的线上事故。4. 并发抢红包的关键实现Redis MySQL协同方案4.1 直接改数据库为什么必挂新手最容易犯的错误是在MySQL里做扣减UPDATE red_envelope SET remain_size remain_size - 1 WHERE id #{id} AND remain_size 0这个UPDATE语句的原子性由MySQL的行锁保证逻辑上确实不会超发。但问题是高并发下所有请求都会阻塞在这个行锁上MySQL的处理能力上限大约在每秒一千到三千次更新这还是理想情况。一旦群里有500人同时点击一个红包大量请求排队响应耗时指数上升用户侧的表现就是红包一直打不开。这还只是单机数据库。如果数据库做了主从复制读写分离的场景下红包状态读的是从库从库同步延迟导致读到旧值问题更复杂。4.2 抢红包接口的完整执行链路我的最终方案是Redis做前置判断、MySQL做最终记账两者各司其职第一步抢占名额。使用Redis的LPOP操作直接从预生成金额列表中弹出一个金额。LPOP是原子操作多个客户端并发访问时Redis内部通过单线程事件循环保证每个元素只会被弹出一次不可能出现两个人拿到同一个金额的情况。第二步加分布式锁防重复。以红包ID 用户ID作为Key尝试SETNX一个短生命周期锁。设置成功说明用户第一次抢设置失败说明已经抢过了直接返回你已经抢过这个红包。锁上设置3秒过期时间防止极端情况下的死锁。第三步写数据库。拿到金额后在同一事务里插入红包明细记录包含红包ID、用户ID、金额、时间、更新红包主表的剩余字段、在用户余额流水表中插入一条入账记录。第四步失败回滚。如果Redis里抢到了金额但数据库写入失败必须要做补偿——把金额重新塞回Redis队列并把分布式锁释放掉保证用户还能再抢一次。这个回滚逻辑最开始我没做上线后遇到过一次数据库连接池满导致大量用户金额丢失的事故补上后才安心。伪代码大致是这样简化版public GrabResult grabRedEnvelope(Long redEnvelopeId, Long userId) { // 1. 尝试加锁防止同一用户重复抢 boolean locked redis.setnx( red:lock: redEnvelopeId : userId, 1, 3, TimeUnit.SECONDS ); if (!locked) { return GrabResult.alreadyGrabbed(); } try { // 2. 从预生成金额队列中弹出一个金额原子操作 Object money redis.lpop(red:envelope: redEnvelopeId :list); if (money null) { return GrabResult.finished(); } // 3. 数据库写入明细、更新余额 try { transaction(() - { redEnvelopeDetailDao.insert( new RedEnvelopeDetail(redEnvelopeId, userId, (Integer) money) ); redEnvelopeDao.decreaseRemain(redEnvelopeId, 1, (Integer) money); userAccountDao.increaseBalance(userId, (Integer) money); }); } catch (Exception e) { // 4. 数据库失败补偿回滚 redis.rpush(red:envelope: redEnvelopeId :list, String.valueOf(money)); throw e; } return GrabResult.success((Integer) money); } finally { redis.del(red:lock: redEnvelopeId : userId); } }这个方案不是不依赖数据库事务而是让数据库事务面向的并发量被Redis提前削峰削掉了一截真正能走到数据库写入的请求只有成功抢到名额的那几个。4.3 为什么我不用纯Redis方案有一个取巧的思路是让红包系统完全跑在Redis里金额也存Redis用户抢完只记录在Redis后续再异步同步到数据库。我不推荐这个方案原因是钱这个东西必须选一个可靠的、有持久化能力的地方作为最终依据。Redis的AOF和RDB持久化虽然能恢复但总存在丢失最近一小段数据的窗口。对普通业务还好一旦涉及资金账目审计和差错处理会非常麻烦。所以我的底线是Redis只做热路径加速数据库永远是最终事实来源。哪怕极端情况下Redis数据丢失了最多就是用户需要重新抢一次数据库永远不会出现账目不平的问题。5. 数据库设计的坑与红线5.1 三张核心表的字段设计红包主表记录红包的元信息。字段包括CREATE TABLE red_envelope ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 红包ID, user_id bigint(20) NOT NULL COMMENT 发红包用户ID, total_amount int(11) NOT NULL COMMENT 总金额单位分, remain_amount int(11) NOT NULL COMMENT 剩余金额单位分, total_size int(11) NOT NULL COMMENT 红包总个数, remain_size int(11) NOT NULL COMMENT 剩余红包个数, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0-可抢 1-已抢完 2-已过期 3-已退回, expire_time datetime NOT NULL COMMENT 过期时间, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_status_expire (status, expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT红包主表;红包明细表记录每一次被抢的记录这是对账的核心CREATE TABLE red_envelope_detail ( id bigint(20) NOT NULL AUTO_INCREMENT, red_envelope_id bigint(20) NOT NULL COMMENT 红包ID, user_id bigint(20) NOT NULL COMMENT 领取用户ID, amount int(11) NOT NULL COMMENT 领取金额单位分, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0-正常 1-退款, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_red_envelope_id (red_envelope_id), UNIQUE KEY uk_envelope_user (red_envelope_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT红包明细表;账户流水表记录资金流转用于审计和异常排查。所有入账、退票、过期回退都存在里面做到每一分钱都有据可查。5.2 金额为什么用分存金额存储有一个铁律数据库里绝对不用浮点类型存钱。浮点数在二进制中无法精确表示大部分十进制小数0.1加0.2得到的是0.30000000000000004这种误差在资金系统中是不可接受的。正确做法是用整数存储最小货币单位。人民币就存分所有涉及金额加减的地方都用整数运算展示时再除以100转成元。Java端的Long、MySQL端的BIGINT都可以我在示例中用INT是因为单红包上限通常远小于2100万分约21万元够用。如果你的业务可能有大额红包建议直接用bigint。有个隐蔽的坑在JSON序列化前端拿到金额字段是100展示时直接拼接1.00元但如果前后端约定不统一比如前端以为单位是元会把100直接展示成100元那就是严重的资损事故。我一般在接口层的DTO里显式定义金额单位说明字段或者统一用BigDecimal转元再返给前端。5.3 唯一索引是防重复抢的最后一道防线即使Redis锁出现了极端异常比如锁提前过期导致同一用户两次进入抢红包方法数据库层的唯一索引uk_envelope_user(red_envelope_id, user_id)也能兜底。这个索引会在第二次插入明细时直接报DuplicateKeyException事务回滚用户的余额不会被二次增加。这是我最深的一个教训永远不要相信任何中间件层面的并发控制数据库约束才是最后的仲裁者。6. 过期回退与对账机制6.1 24小时未抢完的钱怎么退微信红包的规则是24小时未抢完自动退回。这个逻辑最简单的实现是定时任务扫描主表中status0且过期时间小于当前时间的红包把剩余金额退回发红包用户账户更新红包状态为3。这里有一个细节如果定时任务和抢红包操作并发执行可能出现红包恰好被最后一人在过期临界点抢走、而定时任务又触发了退回两边各操作一次导致金额错乱。我的处理方式是在退回前使用数据库行锁重新检查状态-- 事务中先锁定该红包行 SELECT * FROM red_envelope WHERE id #{id} AND status 0 FOR UPDATE; -- 然后判断是否过期再执行退回逻辑SELECT ... FOR UPDATE会把该行锁定其他尝试更新该行的事务必须等待。这样无论锁拿到时红包处于什么状态后续的操作者都能在最准确的状态上做判断。6.2 对账检查的周期与策略我上线后的固定节奏是跑一个每日对账任务逻辑很简单检查每个红包的明细表金额之和是否等于红包总额检查主表剩余金额 明细表已抢金额是否等于总金额检查账户流水表中与红包相关的所有操作余额变动是否守恒对账任务一旦发现异常就告警并把相关红包ID、用户ID打入日志表人工介入处理。虽然这个方案会多写不少代码但没有这套机制线上出了账目问题你根本没法向用户解释更没法修复。7. 性能调优与线上压测中踩过的坑7.1 压测结果让我重新审视连接池配置第一次做压测1000并发打进来数据库连接池直接被打满大量请求报Connection is not available, request timed out错误。排查后发现问题出在Tomcat默认连接池最大连接数配的是100而红包明细插入、主表更新、余额流水插入这三步在同一个事务里每个抢红包请求需要占用一个数据库连接长达几十毫秒。1000个并发同时涌进来连接池瞬间见底。调整思路不只是调大连接池那只是个临时方案我对事务内的SQL做了归一化把三条INSERT/UPDATE合并成两条明细插入必须保留但主表的剩余金额更新可以在事务提交前批量异步合并。不过坦白说这个优化复杂度高最终落地时还是选择了简单方案——调大连接池到300同时把事务内的SQL执行时间压到10毫秒以内整体可用性就有了保障。7.2 Redis连接复用与原子操作还有一个容易被忽略的性能杀手是Redis连接的反复创建销毁。很多人图省事在每次抢红包方法里都Jedis的new对象但连接建立的握手开销在高并发下非常致命。我换成Lettuce连接池后单个抢红包接口的RT从平均15ms降到了4ms左右。另外一个大优化是把LPOP和SETNX两步操作尽量合并成Lua脚本执行减少一次网络RTT在高并发下的性能提升肉眼可见。7.3 热点Key的隐患预生成的金额列表是一串固定数组所有用户抢同一个红包时都去操作同一个Redis Key这个Key就是典型的热点Key。Redis单实例每秒能处理的命令大约10万级单个红包如果达到几十万的抢入量还是可能触到单Key瓶颈。对这个项目来说常规微信群红包的规模远达不到这个量级所以我没有进一步做Key分片。但如果你要做短视频直播间的福袋抢红包、甚至春晚级的红包雨就必须考虑把同一个红包的队列拆成多个子队列每个队列分布到不同Redis实例上用一致性哈希路由。那涉及的是另一套架构了。7.4 红包展示页面的缓存策略前面提到的预览接口除了Redis状态缓存我还在前端做了3秒的本地缓存。实测中一个用户会在一分钟内重复刷新预览接口十几次本地缓存能把Redis的读压力再降一个量级。如果本地缓存过长会出现红包明细页面显示已被抢完但用户实际还能抢的情况。所以前端过期时间定3秒比较合适既减小请求压力又能保证用户看到的剩余名额基本是真实的。8. 一点经验总结与扩展思考这个项目让我最大的收获不是学会了Redis的API而是理解了缓存与数据库的一致性在真实业务里面要如何取舍。你把Redis当作挡在数据库前面的一层可信代理而不是真金白银的存储那系统就怎么设计都不会乱账。沿着这个思路你还能顺带做很多扩展。比如把红包记录改造成优惠券池把抢红包改成秒杀优惠券逻辑完全一样预生成券码、排队弹出、数据库落单。再比如把金额字段换成积分就能做抽奖、签到奖励、积分任务这套底层设计几乎可以复用到所有有限的资源被多人竞争获取的场景。最后分享一个很小的实操技巧在所有涉及金额的接口返回值里我统一设计了快照字段记录该操作发生时的红包剩余状态。这看起来有点冗余但排查线上用户说金额不对这种问题时它能让你直接定位到用户操作当刻的数据状态省下大量扯皮时间。做资金类的功能宁可数据多存一点也不要回头什么都查不到。本文还有配套的精品资源点击获取
返回列表