资讯动态

千万并发红包系统架构:Redis+Lua原子操作与异步落账实践

发布时间:2026/9/7 17:45:40 来源:尧图企业网站定制
每年春节、双十一这类大促节点红包系统都是后端团队绕不开的一道考题。打开某个 App点开红包的瞬间服务端同时要做几件看似简单、实则很容易出错的事判断用户有没有资格、库存还剩多少、这次能分到多少钱、这笔钱最终怎么落到用户账户里。如果只是做一个给几十个人玩的群红包MySQL 里加个事务就够了。但要面对千万级并发——准确说是短时间内上百万甚至几百万次瞬时点击——问题就完全变了数据库连接会被打满Redis 里同一个 key 的写操作会被串行执行锁竞争、热 key、超时重试、重复入账每一个坑都可能让活动当天翻车。这篇文章不打算复制一套面试八股而是把红包系统从需求拆到架构再从架构聊到落地细节。重点是回答一个问题千万并发场景下红包系统真正扛的是什么以及我们到底用什么手段去扛。先说结论千万级红包系统扛住的不是“每秒多少请求”这个数字而是通过分层设计把强一致操作压缩到一个足够小的范围内再用异步和最终一致性消化剩余压力。后面的内容都是在为这条主线展开。1. 红包系统真正要解决的不是“并发”而是“一致性”1.1 “千万并发”到底意味着什么先做一次数量级换算。假设一场红包活动在开抢后 1 分钟内涌入了 600 万次请求平均每秒就是 10 万 QPS如果峰值是均值的 3 倍瞬时可能到 30 万 QPS。30 万 QPS 是什么概念一台能稳定抗住 1000 QPS 的普通 MySQL 实例在这个流量下连 1% 的请求都处理不了。而且这还只是入口层的数字——如果每个请求背后都要同步写数据库哪怕把所有连接池、读写分离都用上大概率也会直接把库打挂。很多没有真正做过高并发系统的人会把“并发高”等同于“机器多”。但并发一上来最先暴露的往往不是 CPU而是三个更具体的问题连接数。数据库、Redis、Tomcat 连接池都有上限连接被占满新请求全部排队。锁竞争。同一个资源被大量线程同时争抢越锁越慢。重复操作。用户等不到响应就重试重试又变成新的压力。所以第一件事是不要试图让数据库去扛“打开红包”这个请求。数据库擅长的是少量、准确、可回滚的事务而不是海量、瞬时、可以重试的点击。红包系统要做的是把流量在到达数据库之前一层一层削掉只把最终的“账”交给数据库。1.2 从发红包到入账业务里藏着三个不同的问题拆开看红包链路至少包含三个阶段抢用户点击后判断是否有库存、是否有资格决定这次点击“成不成功”。拆从总金额里分出一部分给这个用户生成一个具体金额。入账把金额写到领取记录表里更新用户余额让结果最终生效。三个阶段对一致性要求和响应速度的要求完全不同。“抢”和“拆”必须快用户体验上基本要在几十毫秒内拿到结果。慢一点可能还能接受但绝不能因为数据库锁等待而把请求拖到秒级。而“入账”可以慢甚至可以延迟几分钟——只要最终账是对的。红包金额一旦生成理论上就是一个不可变事实用户看到多少就是多少。剩下的只是把它持久化、累加到余额的过程天然适合放到异步链路里执行。这里就引出了本文最重要的架构判断把“抢拆”放在一个超快的原子路径里通常是 Redis Lua把“入账”放到消息队列后面去执行数据库只承担最终一致的写入。这就是红包系统能够扛住千万并发的底层逻辑其他所有设计都是在为这条线服务。2. 第一层抢红包的库存控制为什么是 Redis Lua2.1 先理解一个“错误但直观”的实现很多第一次做红包系统的人自然想到的流程是这样的从数据库查询这个活动的剩余红包数。如果还有剩余把数量减一写一条领取记录。返回成功。这个流程在低并发下没问题但一旦多个请求同时进入就会产生经典的超卖问题两个请求同时查到“剩余 1”同时减一于是两个人都领到了同一个红包。要解决超卖要么给数据库记录加锁要么用UPDATE ... SET count count - 1 WHERE count 0这样带条件的原子更新。数据库原子更新确实能解决超卖但问题在于每一次抢都要操作数据库连接池会成为瓶颈行锁竞争也会随并发上升而急剧恶化。在 10 万 QPS 的压力下这几乎必然把数据库拖垮。所以实战里抢红包的库存扣减会前移到 Redis 完成数据库只做异步落账。2.2 Redis Lua 为什么是标准答案Redis 本身是单线程执行命令绝大多数操作天然不存在多线程竞争问题。这里的“单线程”是核心优势但也意味着我们不能写出任何“多步非原子”的代码。如果先GET再DECR两步之间可能有别的请求插入库存还是会超扣。正确的做法是在 Redis 里完成“检查、扣减、记录”的原子操作。Lua 脚本恰好满足这个需求。Redis 执行 Lua 脚本时是原子的脚本执行过程中不会有其他命令插入。我们可以把“判断是否已抢、检查剩余库存、弹出一个金额、扣减数量、标记用户”全部写进一个脚本一次网络往返完成既快又原子。下面是一个常见的最小示意脚本-- KEYS[1] 红包金额列表预拆分后的金额元素是整数单位是“分” -- KEYS[2] 剩余数量 key -- KEYS[3] 已抢用户集合 key -- KEYS[4] 待异步落账的 pending key -- ARGV[1] user_id -- ARGV[2] activity_id -- 1. 幂等检查这个用户是否已经在集合中 if redis.call(sismember, KEYS[3], ARGV[1]) 1 then return -1 end -- 2. 库存判断 local remain tonumber(redis.call(get, KEYS[2]) or 0) if remain 0 then return -2 end -- 3. 弹出一个金额单位分 local amount redis.call(rpop, KEYS[1]) if not amount then return -2 end -- 4. 扣减剩余数量 redis.call(decr, KEYS[2]) -- 5. 记录已抢 redis.call(sadd, KEYS[3], ARGV[1]) -- 6. 写入待落账记录 redis.call(hset, KEYS[4], ARGV[1], amount) return tonumber(amount)脚本的返回值需要约定清楚正数是拆到的金额单位分-1 表示重复抢-2 表示已经抢完。实际项目中可以在网关层把 -1、-2 映射成用户友好的文案例如“你已经领过了”“手慢了红包抢完了”。这里有一个容易忽略的细节如果 Redis 是 Cluster 模式Lua 脚本里访问的所有 key 必须落在同一个 hash slot。常见做法是给 key 加统一的 hash tag例如red:{activityId}:list red:{activityId}:count red:{activityId}:users red:{activityId}:pending这样activityId相同的 key 会落到同一个 slot脚本才能正常执行。很多新人第一次把脚本从单机 Redis 搬到 Cluster就在这一步踩坑。2.3 热 key 和串行瓶颈库存分桶看到这里有人会问Lua 虽然原子但所有请求都在操作同一个 activityId 的 keyRedis 单线程执行不还是把所有请求串行化了吗确实如此。Redis 的原子执行意味着同一时刻只有一个请求能处理这个红包的库存数据。单节点 Redis 执行一个简单 Lua 脚本每秒通常能做到几万到十几万次具体取决于脚本复杂度、网络开销和机器规格。但如果峰值是 30 万 QPS一个 key 就是瓶颈。破解办法是“分桶”。把一个活动的红包金额列表拆成多个相互独立的子库存每个子库存有各自的列表和剩余数量 key。例如拆 512 个桶red:{activityId}:bucket:0:list red:{activityId}:bucket:0:count red:{activityId}:bucket:1:list red:{activityId}:bucket:1:count ...用户来拆红包时用user_id % bucket_count决定去哪个桶然后只操作那一个桶的 Lua 脚本。这样同一时刻最多有 bucket_count 个请求并行处理不同的桶总吞吐量近似乘以桶的数量。分桶的代价是复杂度上升需要保证所有桶的金额加起来等于红包总额。需要监控每个桶的剩余量部分桶可能提前耗尽。活动结束后的对账要按桶聚合做再做汇总。但对于千万级并发的活动这个代价是值得的。否则一旦 Redis 命令执行成为瓶颈再好的业务逻辑也顶不住入口流量。另外分桶方式不只一种。用user_id % bucket_count的好处是同一个用户永远落到同一个桶配合集合型 key 可以顺便保证“一个用户只能抢一次”。还有一种做法是请求进来时随机选桶但这样需要额外维护一个全局“已抢用户”集合反而让脚本变复杂不推荐作为首选。2.4 库存不足时的拒绝策略库存不足时脚本返回 -2但业务层面的表现还需要设计。一种常见做法是在 Redis 维护一个“活动已结束”标记 key一旦某个活动兑完网关直接拦截不再把请求发给 Lua 脚本直接返回“手慢了”。这能大幅减少打到 Lua 脚本的无效流量。另一个细节是客户端的超时和重试。红包场景里用户点击后没有立刻看到结果通常会再点一次。服务端必须保证“用户重试时不会重复扣减”。上面脚本里的sismember是幂等第一道防线。更完整的方案是维护一个用户维度的结果映射例如red:{activityId}:result:{userId}把最终金额存下来重试时直接返回已有结果而不是返回“你已经领过了”让用户困惑。注意千万并发场景下任何“先查再写”的逻辑都可能在中间被打断。要么把所有判断放进原子脚本要么接受最终一致。不要自己写既有 GET 又有 DECR 的代码。3. 第二层拆红包的金额分配预拆分还是实时计算3.1 拼手气红包的常见分配算法写一个取随机数并保证所有人加起来等于总数的逻辑看起来简单实际有很多边界每人最少要拿 0.01 元不能出现负数最后一个人要拿干净剩余金额分布还要尽量合理。最常用的算法之一是“二倍均值法”。思路很直观每次在(0, 剩余均值 * 2)的范围内随机一个金额同时保证剩余金额还能分给后面的人。下面是一个示意实现import random def split_double_average(total_cents: int, count: int) - list[int]: if total_cents count: raise ValueError(总金额不足无法保证每人至少1分) result [] remain total_cents for i in range(count, 0, -1): if i 1: result.append(remain) break avg remain // i # 最大金额不能超过剩余均值两倍也要给后面的人留够 1 分 max_cents min(avg * 2, remain - (i - 1)) amount random.randint(1, max_cents) result.append(amount) remain - amount return resultremain - (i - 1)这个边界很关键它保证后面每个人至少拿到 1 分避免前面的人把金额抽走太多导致后面的人无钱可分。除了二倍均值法还可以用“线段切割法”把总金额看成一条线段随机生成 n-1 个切点切成 n 段。这个方式可以生成更极端的分布比如某个人独得大头。选哪种算法取决于你希望红包金额分布是均匀温和还是刺激有落差。3.2 预拆分把随机和计算前置到“发红包”时刻刚才的 Lua 脚本里提到了“红包金额列表”这就是预拆分方案。用户在 App 里发一个总金额 1000 元、数量 100 个的拼手气红包时服务端不需要等有人来抢才开始生成金额。它可以在发红包这一瞬间用分配算法把 100 个金额全部算好按“分”为单位存进 Redis 的 list。用户来拆红包时Lua 脚本直接用rpop弹出一个现成金额。这样做的好处很明显金额计算被前置到发红包时刻高峰期不产生计算压力。rpop是 O(1) 操作性能极好。每个金额在发出前已经确定不会因为并发而产生两笔相同金额的“重复分配”。代价是内存。100 万个红包每个金额存成类似188的短字符串加上 Redis 的链表节点开销一个活动可能在几十 MB 到上百 MB 量级。单个活动还能接受但如果同时有大量高金额、高数量的活动就必须做容量评估提前规划分桶和内存预算。从工程实践看高并发红包活动普遍倾向预拆分。因为它把最重的“计算 一致性”问题从高峰时段转移到了发红包的低峰时刻这是很划算的取舍。3.3 理解边界并不是所有红包都要预拆分预拆分不是银弹。如果红包是“定时开奖”类型或者总量不大、并发一般完全可以在开奖时实时生成金额配合一个分布式锁就能搞定。这样内存成本低逻辑也简单。如果金额计算还需要依赖业务规则比如不同用户概率不同那么预拆分可能反而限制灵活性。选型时要同时考虑四个因素红包数量、并发峰值、Redis 内存预算、金额生成规则复杂度。不要照搬任何单一方案。4. 第三层数据库落账、幂等和最终一致性4.1 为什么数据库不能放在同步路径上回到最开始的判断抢和拆要在几十毫秒内返回而数据库事务在海量流量下很难保持这么短的响应时间。所以正确的链路是用户请求 → API 网关 → Redis Lua完成抢和拆 → 返回金额 → 投递 MQ → 消费者异步写数据库 → 用户余额到账一条典型记录表可以这样设计CREATE TABLE red_envelope_receive ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, amount_cents INT NOT NULL COMMENT 金额单位分, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-已创建, 1-已入账, 2-对账异常, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_activity_user (activity_id, user_id), KEY idx_status_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT红包领取记录;uk_activity_user唯一索引是幂等的最强防线同一个用户对同一个活动只能插入一次数据库层面的重复写入会被直接拒绝。4.2 消费者侧批量、唯一约束和重试MQ 消费者做落账时不要一条一条同步插。常见做法是按固定条数或固定时间攒批比如每 500 条或每 500ms 批量写入一次。用INSERT IGNORE、ON DUPLICATE KEY UPDATE或者先查唯一键再做插入保证重复消息不会产生重复记录。消费失败时进入重试队列超过最大重试次数进入死信队列等待人工或对账任务处理。这里最容易踩的坑是Lua 脚本里已经把用户标记为“已抢”但 MQ 消息丢失或消费者一直失败用户侧看到的是“领取成功”后台却查不到记录钱也没到账。所以消息投递必须可靠并且必须配套对账。另一个常见问题是“用户抢到了但是网络超时客户端重试第二次请求来到服务端”。如果 Lua 脚本只返回 -1客户端会把它当成“已领过”处理用户可能以为自己没抢到。更合理的做法是前面提到的结果映射 key重试时返回第一次拆到的金额保证体验一致。4.3 对账和补偿账不平才是最大的事故红包系统涉及钱最终必须证明两个等式成立发出总金额 领取记录金额之和红包总数量 领取记录条数对账任务可以这样设计定期扫描每个 activity 的总额、总数量。按 activity 分组统计数据库里sum(amount_cents)和count(*)。对比 Redis 中尚未落账的 pending 数据把缺失的记录补齐。发现差异时根据 MQ 持久化消息重建发放记录消息也没有的进入人工核查流程。对账不是“出了事再查”而是平时就要高频跑。大活动期间可以每 5 分钟跑一次活动结束后再做全量终检。5. 千万级流量前的限流、降级、压测和排查5.1 入口限流先把不该进来的请求挡在外面有了 Redis Lua不代表入口层可以什么都不做。一个红包活动在开抢瞬间往往会伴随大量异常流量羊毛党、脚本点击、重复刷新。常见策略包括网关层按接口、按用户、按 IP 设置 QPS 阈值。对同一设备、同一用户做窗口期频控。活动专用的“预检”接口先返回活动是否结束减少无效请求。限流不是拒绝用户而是保护核心链路。被限流的请求可以返回“系统繁忙请稍后再试”至少不能让它打到 Lua 脚本更不能打到数据库。5.2 降级预案Redis 挂了怎么办这是红包系统最需要提前回答的问题。如果 Redis 不可用最错误的做法是让请求继续往数据库打那会造成雪崩。更稳妥的做法是网关层开启熔断直接返回“活动太火爆”。运营后台提供一键下线入口。已发出的红包等 Redis 恢复后再继续处理已生成但还没入账的记录由对账任务补齐。另一个细节是预热活动开始前把红包数据提前加载到 Redis不要在开抢瞬间才做冷加载。第一次高并发波动如果恰好撞上冷加载Redis 的 CPU 和内存都会被打到异常。5.3 全链路压测不要等活动当天才做千万并发级别的系统不压测等于盲飞。压测时要重点看几个指标打开红包接口的平均耗时和 P99。Redis 单节点 CPU、内存占用和慢命令。MQ 的堆积速度消费者是否跟得上。数据库连接池利用率、慢 SQL 数量、主从延迟。压测通过标准可以这样定在预估峰值 QPS 的 1.5~2 倍压力下P99 小于 100ms系统无 OOMMQ 堆积能在 10 分钟内消化完数据库无持续慢查询。达不到标准就继续调优直到达标为止。注意全链路压测要选在流量低峰或隔离环境避免影响线上用户。压测数据要有独立标识压测产生的红包记录要在结束后清理干净。5.4 红包链路出问题时的排查顺序红包系统的问题经常跨多个组件排查时要有一个固定顺序否则很容易在错误层浪费时间先看用户现象是抢不到、拆不了、金额不对还是提示到了账没到不同现象对应不同链路。再看 Redis用hget red:{activityId}:pending {userId}看用户是否已经拿到金额金额是多少用sismember red:{activityId}:users {userId}看用户是否已经被标记。再看 MQ查消息是否投递成功、消费者是否消费成功、有没有重试和死信。再看数据库查red_envelope_receive里有没有这条记录status 是什么。最后看对账任务如果前四层都查不到问题跑一次对账脚本看差异记录落在哪个组件。这个顺序的本质是先查最接近业务结果的地方再逐层回溯到数据源头。大多数红包问题最后都出在三类地方Redis 里已经扣了但 MQ 消息丢了、消费者重复消费但幂等逻辑没处理好、对账任务没有及时运行导致差异积累。6. 红包系统留给所有高并发业务的通用框架6.1 四步拆解法拆完红包系统你会发现一个可以复用到其他高并发场景的框架识别强一致路径。哪些操作必须在一个原子操作内完成通常是“检查资格、扣减库存、登记结果”这三件事。把强一致操作放到最快且支持原子的组件里。对红包来说是 Redis Lua对其他业务可能是 Redis、分布式锁或幂等表。把非强一致操作异步化。落账、通知、对账、统计全部放进 MQ 或任务系统用最终一致兜底。为每个环节设计降级预案。Redis 挂了怎么办、MQ 堆积怎么办、数据库扛不住怎么办提前写清楚而不是事故发生时现场开会。6.2 如果你现在要做红包系统最该先做什么如果你不是要给春晚级活动做架构而是想真正落地一套红包系统我建议的顺序是先做最小闭环发红包 → 预拆分金额 → Redis Lua 抢/拆 → MQ 消费者落库 → 对账脚本。用几万 QPS 压测跑通这个闭环看哪里先成为瓶颈。只有确认瓶颈在 Redis 单 key 或单节点时再做分桶。在最终上线前先完善监控和对账。钱相关的系统宁可功能少也不能账不平。这个顺序能帮你避开最大陷阱一上来就照搬大促级架构结果发现大部分组件都用不上反而被过度设计困住。6.3 边界不是所有红包业务都需要这套方案文章写到这里仍然要给方案画清楚边界。如果你的红包只面向几十人、几百人用 MySQL 事务加一个简单的金额随机算法完全够用不需要引入 Redis、MQ、对账任务。这套方案的真正适用场景是“短时间内流量峰值极高、对丢单极敏感、金额规模很大”的营销活动。另外真正的资金系统还需要叠加风控、审计、预算校验、双人复核等安全措施。本文只讨论通用红包系统的技术骨架不构成资金安全设计建议。任何涉及真实资金的系统都要找专业的支付、账务和安全团队做评审。最后回到最初的问题千万并发怎么扛答案不是某一个神秘组件而是一整套分层设计。用 Redis 的原子能力扛住瞬时流量用 MQ 削峰填谷用数据库做最终落账用对账保证资金正确。红包系统之所以值得反复拆解不是因为它用了多玄的算法而是因为它把“快”和“准”这对天然矛盾用工程手段平衡得很好。理解这一点比记住任何一张架构图都更重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价