高并发红包系统设计:防超发、削峰与异步入账实战
红包系统是后端并发场景里最典型的练习题。它不是单一的接口优化而是从发红包、抢红包、拆红包到余额入账一整条链路都要在峰值流量下保持稳定。很多人看到“千万并发”这四个字就只盯着 QPS实际上真正要回答的问题是突发流量打过来时红包会不会超发用户会不会重复领取账目能不能对平失败数据能不能自动补偿。这篇文章适合后端开发、准架构师也适合正在准备高并发面试的人。最值得关注的核心不是“把服务节点多部署几个”而是超发控制怎么做、流量怎么削峰、异步任务怎么保证不丢不重、压测时到底看哪些指标。下面按实际落地顺序拆一遍。1. 先把红包系统的业务链路拆清楚再谈并发优化很多人把红包系统想成“一个抢红包接口”这其实是最大的误区。真正上生产环境时一次用户操作会被拆成多个前后关联的步骤每一步的流量特征不一样优化方式也不一样。1.1 红包系统的几个核心状态任何一个红包活动都可以抽象成下面几个状态创建红包用户发一个红包系统生成红包主单设置总金额、总个数、有效期。拆分子红包按平均金额或随机金额把总红包拆成 N 份生成子红包记录。抢红包用户在业务入口点击“抢”服务端判断红包是否还有剩余、用户是否已经抢过。拆红包确认当前用户能拿到哪一档金额这一步会真的扣减剩余库存。入账把用户抢到的金额写入账户余额或零钱明细并留下流水记录。查询展示用户查看红包详情、领取列表、手气最佳等信息。这里面“创建红包”和“查询展示”的并发压力并不高充其量是读多写少。真正的热点集中在“抢红包”和“拆红包”尤其是拆红包那一刻所有用户都往同一个红包的库存上打。只要这个点不做特殊设计数据库行锁、缓存热 Key、接口超时就会全部爆出来。1.2 抢和拆为什么要分开很多初版实现会把“抢”和“拆”放在同一个接口里用户点击一次后端直接完成判断、扣减和入账。流量低的时候没问题一旦进入大促画面这个接口会同时承担两类工作高频判断判断红包是否可抢、用户是否已抢、活动是否过期。高频写操作扣减库存、生成领取记录、更新余额。把这两件事混在一起会导致一个用户的失败影响另一个用户。比如库存扣减成功但入账超时用户界面显示“已抢到”余额却没变。与其这样不如把“抢”做成一个轻量级的判定动作把“拆”和“入账”放到后续链路里执行。在生产实践中我一般这样做用户点击后先通过 Redis 判断红包状态。如果可抢立刻用 Lua 脚本扣减红包库存并把当前用户写入一个“已领取”集合。返回“抢到”的即时结果。同时发送一条消息到消息队列消费者拿到消息后做异步入账。这样做的原因是用户侧最关心的反馈是“我到底有没有抢到”这个反馈必须快。而真正把金额入账的动作不需要用户盯着等可以放到异步任务里慢慢处理。1.3 流量模型差异决定架构选型红包系统中存在三种完全不同的流量读流量详情页、榜单、领取列表属于典型读多写少适合加缓存。瞬时写流量同一时间大量用户抢同一个红包写集中在少数几个 Key 上。异步流量入账、对账、通知这些任务可以排队但不能丢。理解了这三种流量就能理解为什么红包系统不能只靠数据库为什么 Redis 和消息队列是标配。2. 防超发为什么第一道防线要放在 Redis 里红包系统最不能容忍的就是超发。一个 100 元的红包最多只能发出 100 元哪怕只多发出 0.01 元后续对账都是事故。所以防超发不是性能问题是资金安全问题。2.1 数据库库存扣减为什么扛不住热点最早期的方案通常会直接操作数据库表用一条 update 语句做条件扣减update red_packet_stock set remain_amount remain_amount - #{money} where packet_id #{packetId} and remain_amount #{money}这条语句在单红包、低并发下是能用的。但红包系统的高并发场景是同一时刻几千甚至几万人同时抢同一个红包所有这些请求都会打到同一行记录上。数据库对这行的 update 是串行加锁的。结果就是请求大量堆积锁等待超时连接池被占满最终表现就是接口大面积报错。即使你把数据库连接池调大也只是让更多请求卡在锁上没有真正提升吞吐。所以红包库存的第一道防线不能只靠数据库行锁。2.2 用 Lua 脚本保证扣减原子性业界的常规做法是在 Redis 层维护红包的剩余金额和剩余个数用 Lua 脚本完成判断和扣减的原子操作。Redis 单线程执行脚本Lua 脚本在执行期间不会被其他命令打断。这就避免了“先查询再扣减”的竞态问题。一个简化版的思路是这样发红包时在 Redis 里写入剩余金额 key 和剩余个数 key。抢红包时执行 Lua 脚本检查剩余个数是否大于 0、剩余金额是否够本次发放。如果条件满足扣减金额和个数返回成功。Lua 脚本示意-- KEYS[1] 红包剩余金额 -- KEYS[2] 红包剩余个数 -- ARGV[1] 本次要发放的金额 local amount tonumber(redis.call(get, KEYS[1]) or 0) local count tonumber(redis.call(get, KEYS[2]) or 0) local money tonumber(ARGV[1]) if count 0 and amount money then redis.call(decrby, KEYS[1], money) redis.call(decr, KEYS[2]) return 1 end return 0这只是一个核心片段真实实现里还要考虑子红包金额如何生成、用户是否已领取、过期时间设置等问题。更稳妥的做法是发红包时直接把 N 个子红包的金额放进 Redis List抢红包时用 Lua 从 List 里 pop 一条。这样就不存在“每次算金额”的问题金额在发红包那一刻已经固定下来了。如果使用随机红包常见做法是二倍均值法每次按剩余金额除以剩余个数的两倍取随机。但随机金额本身不能在高并发请求里再计算它必须提前生成并保存好。2.3 Redis 异常时怎么兜底把第一道防线放在 Redis 里很多人会担心一个问题Redis 宕机了怎么办预扣数据丢了怎么办。这个担心是对的。所以红包系统不能只依赖 Redis数据库还是要承担最终账本职责。一个可行的兜底方案是Redis 层负责高并发下的快速扣减确保不超发。数据库表负责记录每一笔领取流水作为最终账本。异步任务把 Redis 的扣减结果和数据库流水做比对。如果 Redis 发生主从切换或数据丢失可以基于数据库流水重新构建某个红包的剩余库存再回写 Redis。这里要先明白一点Redis 即使出问题也不能让用户“凭空多拿钱”。兜底逻辑的目标不是保证 Redis 24 小时不宕机而是保证账目恢复时能对平。所以每个红包主单必须保留创建时的完整信息包括总金额、总个数、已经领取的钱数、已经领取的人数。这样无论 Redis 里剩多少都可以用数据库流水计算出来。3. 削峰限流和异步化千万流量不是一台机器扛出来的当瞬时流量冲到千万级任何单点应用都会被压垮。红包系统的另一个核心设计是削峰把“瞬时高峰”切成一段一段可处理的流量。3.1 入口限流先挡住无效请求每次红包活动里真正能抢到红包的用户只是少数但大多数用户都会在活动开始那一刻疯狂点击。如果所有请求都穿透到业务层甚至打到数据库系统必然被打垮。所以在最外层就需要做限流。常见做法包括网关层限流按接口维度设置令牌桶或滑动窗口超出的请求直接返回“活动太火爆”。用户维度限流同一个用户在短时间内只能放行一次抢红包请求其他点击直接忽略或返回重复提示。接口线程池隔离把抢红包接口和普通业务接口放到不同的线程池避免一个高峰接口拖垮整个应用。限流不是把正常用户挡在外面而是把无效请求挡在外面。比如用户连续点了十次只需要让第一次请求进入业务逻辑剩下九次可以在网关或者前置校验里直接短路。3.2 异步化拆分拆红包和入账解耦刚才提到“抢”和“拆”可以合并也可以拆开。更彻底的做法是拆红包成功后就立刻返回入账动作放到消息队列里异步消费。这样做的好处是用户响应时间短不需要等数据库写入完成。高峰期的写流量可以被队列缓冲数据库不会瞬间被打满。入账失败可以进入重试队列不影响用户抢红包的结果。一个常见的流程是这样用户请求到达进入抢红包接口。接口检查用户是否已领取。用 Lua 脚本从 Redis 里 pop 一个子红包金额。写入一条领取记录状态为“待入账”。发送 MQ 消息消息内容包含红包 ID、用户 ID、金额。接口直接返回“抢到了”。消费者收到消息后把金额写入用户余额更新领取记录状态。这里面消费者需要注意消费速度。不要一上来就开几百个消费者线程同时写数据库。数据库写入是有瓶颈的建议先用小批量消费者跑观察数据库负载和消息积压情况再逐步增加。3.3 队列消费速率和数据库写入节奏实际落地时我一般会关注三个参数消息积压数如果积压越来越多说明消费速度跟不上生产速度。数据库写入耗时单条入账写操作如果超过 50ms就要小心锁和 IO 问题。消费失败率失败率突然升高通常是数据库连接异常或数据格式不对。控制写入节奏的方式有很多最简单的是控制消费者的并发线程数。比如先从 10 个线程开始观察数据库 CPU 和磁盘 IO如果负载不高再逐步加到 20 个、50 个。也可以把多条入账消息合并成批量写。给每个用户单独生成一条明细然后一次性批量插入这样能明显降低数据库压力。4. 幂等、超时和资金安全高并发最容易翻车的地方高并发系统还有一个隐形杀手就是重复请求。一个用户网络不好前端自动重试或者网关超时重发都可能导致同一条领取请求被处理两次。如果不做幂等用户就会领到两笔钱。4.1 重复请求是怎样产生的重复请求不一定来自恶意用户更多时候是正常网络行为用户点击后没有立刻收到结果又点了一次。客户端设置了超时重试但第一次请求其实已经成功了。网关或消息队列在消费时发生重试导致同一条消息被消费两次。所以幂等不是一个可选项而是必须项。4.2 幂等键怎么设计才稳最简单的幂等键是“用户 ID 红包 ID 领取场景”。在领取记录表里给这个组合加唯一索引。重复请求第一次插入成功第二次插入时因为唯一索引冲突直接返回“已领取”。这里最容易踩的坑是只靠用户 ID 做幂等。一次活动、多个红包的情况下用户 ID 相同不代表是同一次领取必须带上红包 ID。还有更复杂的情况同一个用户在同一个红包里已经领取过但系统异常用户订单状态还是“待入账”。这时候即使有唯一索引也可能出现“领取记录存在但状态没更新”的中间态。所以幂等不仅要防止重复插入还要处理状态机。比如“待入账”变成“入账中”再变成“入账成功”整个过程要保证同一个用户同一个红包只能成功流转一次。4.3 事务边界和对账机制资金相关操作最怕跨事务。比如“扣减红包库存”和“增加用户余额”如果放在同一个分布式事务里性能会很差而且一旦一个节点失败整个链路都会阻塞。更稳妥的方式是采用最终一致性在本地事务里写入资金流水并更新领取记录状态。把更新余额的操作放到异步任务里。异步任务执行成功后更新流水状态。定时任务扫描长时间未成功的流水触发补偿。这样即使某个环节失败也可以通过流水状态找回数据。对账机制也不能省。每天凌晨跑一次对账把红包主单的总金额、总个数、已领取金额、已领取人数和数据库流水汇总做比对。一旦发现不一致就触发告警再由人工或补偿任务处理。5. 压测验证别在低并发下自我感动很多人做红包系统只是把流程跑通了就觉得自己已经“扛住高并发”。但真实环境的问题只有在高并发压测下才会暴露。5.1 从单机到集群的压测步骤压测不要直接上千万并发那是浪费资源也得不到有效结果。正确顺序应该是先单机单接口压测确认服务的处理上限。再单机混合链路压测模拟抢红包、拆红包、入账的真实流程。然后集群压测验证负载均衡和缓存是否有效。最后做全链路压测把网关、应用、Redis、数据库、MQ 都纳入测试范围。压测工具有很多比如 JMeter、wrk、Locust也可以使用公司内部的压测平台。工具不是重点重点是测试时要尽量贴近真实请求。建议先用少量并发跑通脚本比如 100 并发确认脚本参数都正常。然后逐步增加到 500、1000、2000每次增加后观察几分钟记录指标。5.2 读哪些指标才能判断系统是否可靠压测时不要只盯 TPS。TPS 再高如果错误率很高也没有意义。我一般重点看这几个指标P99 响应时间99% 的请求耗时是多少反映了用户真实体感。错误率超过 0.5% 就要认真排查超过 1% 基本不可接受。Redis 的 CPU 和内存如果 Redis 先被打爆说明缓存设计或 Key 拆分需要优化。数据库连接池占用连接池满通常是慢 SQL 或死锁的前兆。MQ 积压数如果积压持续增长说明消费者处理速度不足。线程池活跃线程数活跃线程长期接近 maximumPoolSize说明服务已经快承受不住了。判断一个红包系统是否扛住了千万并发不是看某台机器 CPU 是多少而是看整个链路在压力下是否还能保持稳定。5.3 压测中常见的六类问题压测过程中最容易发现的问题我归纳成六类热 Key 问题所有请求都打到同一个红包的 Redis Key 上单个 Redis 节点 CPU 被打满。连接池耗尽应用线程等待数据库连接或 Redis 连接耗时直线上升。慢 SQL库存更新或流水插入出现锁等待。消息积压入账消费者处理不过来了。日志阻塞压测时日志量暴增磁盘 IO 成为瓶颈。反向超时上游网关超时时间设置太短服务还没来得及处理就被中断。这些问题在低并发下很难复现但一到高峰就会集中爆发。压测的目标就是提前把这些问题找出来。6. 回到千万并发架构选型的核心取舍最后说回“千万并发”这个词。真正要做到千万用户同时抢同一个红包单靠任何一项技术都不够。Redis 解决了热点扣减问题MQ 解决了流量削峰问题数据库解决最终账本问题幂等机制保证重复请求不影响资金安全定时任务负责兜底对账。每一项都不是孤立存在的。6.1 每个组件的职责边界Redis扛住瞬时热点写流量用 Lua 脚本保证扣减原子性。MQ承接拆红包后的入账动作把高峰写流量切成平稳消费。数据库保存红包主单、领取流水和用户余额作为最终账本。定时任务处理超时未入账、数据不一致、对账差异。缓存承载红包详情、领取列表等读多写少的请求。这个职责分法可以复制到大多数高并发交易场景里。6.2 真正要盯的关键链路如果只让你盯一条链路那就是扣减库存和入账是否最终一致。扣减库存用的是 Redis 预扣入账用的是 MQ 异步写库。这两者之间天然存在时间差。任何一个环节丢了数据都会导致用户“扣了钱但没入账”或者“没扣钱却拿到了钱”。所以日志必须完整每个用户在每个红包上的领取记录都要带上请求 ID、红包 ID、用户 ID、金额、状态和时间。压测时也要专门做“异常流量测试”比如消费失败后重试、Redis 主从切换后恢复观察数据是否还能对平。6.3 给中小团队的落地建议如果你所在团队的技术栈比较简单没有现成的 MQ 和分布式事务组件也不用一上来就追求最复杂的架构。可以先做这些事抢红包接口用 Redis Lua 做防超发。领取记录表加唯一索引做幂等。入账操作放到本地消息表或简单的延迟任务里异步执行。每天跑一次对账任务比对红包总账和流水明细。这套方案不能算最极致但能把“超发”“重复领取”“账不平”这几个核心风险控制住。等流量真正起来后再逐步引入 MQ、全链路压测和更细的监控。红包系统的高并发设计归根到底是取舍问题用 Redis 换瞬时性能用 MQ 换削峰能力用异步任务换吞吐用幂等和定时对账换资金安全。每一样都会增加复杂度但每一样都在防住一类真实事故。真正落地时最该盯住的不是功能列表而是数据一致性、失败补偿和压测结果。