ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

红包系统高并发架构设计:Redis原子扣减与最终一致性实践

红包系统高并发架构设计:Redis原子扣减与最终一致性实践 红包系统大概是后端高并发场景里最经典的“综合题”之一它既有瞬时读压力又有严格的写一致性要求还牵扯金额精度、幂等、防超发、最终落库等多个容易翻车的细节。很多同学在面试时能说清楚秒杀方案的原理但真正要设计一个红包系统时往往卡在“金额怎么拆”“并发怎么扣”“数据库怎么扛”这三件事上。本文会从一个可落地的角度把红包系统从发红包到抢红包、从 Redis 预扣减到数据库最终一致性的完整链路拆开讲并给出 Java 代码、Lua 脚本、SQL 表结构和常见排错思路。无论你是准备高并发面试还是想在项目中做一次架构升级这篇文章都值得认真看完。1. 红包系统的架构挑战1.1 红包业务核心链路红包系统的核心链路比普通下单系统要复杂一点因为它同时包含“写热点”和“读热点”。先看一条完整流程用户 A 发起发红包请求传入总金额、红包个数、留言等信息。后端生成红包 ID按规则把总金额拆成若干份并保存红包主记录。用户 B、C、D 等好友打开红包详情看到“手气最佳”“已抢完”等状态。用户点击抢红包后端要完成资格校验、库存扣减、金额分配、结果返回。异步把领取记录写入数据库更新红包剩余金额和个数。用户查看钱包流水确认金额入账。这里的第 4 步是整个系统的核心压力点。普通业务的并发大多是“读多写少”可以靠缓存扛住但红包系统是“读写都集中在一个资源上”而且这个资源不能超卖。1.2 高并发场景下的三个核心问题第一个问题是写热点集中。一个热门红包可能在几十秒内被几十万人点击所有请求都会落到同一个红包 ID 上。如果直接操作数据库哪怕只查一次余额数据库也会瞬间被打满。第二个问题是超发与重复领取。金额和数量是有限的用户并发点击时系统必须保证“只能抢到一次”且“总领取数不能超过总个数”。一旦超发就是 P0 级资损事故。第三个问题是最终一致性。为了让用户响应足够快我们一定会把扣减动作放到 Redis 里做。但 Redis 是内存存储不能完全替代数据库。如何把 Redis 的扣减结果可靠地同步到数据库并且保证对账一致是架构设计里最容易被忽略的部分。1.3 红包系统的衡量指标在设计系统之前要先明确验收标准。红包系统不能只盯着 QPS还需要关注以下指标指标说明QPS / TPS抢红包接口每秒能处理多少请求响应时间P99 响应时间需要控制在几百毫秒内成功率用户抢红包请求的成功比例资损率超发、少发、金额不平的资产损失比例理想情况为 0一致性Redis 中的数据与数据库最终一致允许短时间延迟可用性高流量下系统不雪崩、不宕机2. 总体架构设计2.1 分层架构红包系统的架构可以拆成四层来看。接入层负责负载均衡、限流、黑白名单、基本参数校验。比如同一个用户对同一个红包的请求频率异常时可以在接入层直接拦截。应用层负责业务逻辑包括发红包、拆红包、抢红包、查详情。应用层不直接承担库存扣减而是把高频操作委托给 Redis。缓存层使用 Redis 保存红包的拆分结果、剩余数量、已抢用户集合等热点数据。这是整个系统扛住高并发的核心。存储层使用 MySQL 保存红包主表、领取记录表、账户流水表。写入通过消息队列异步完成避免高并发直接打到数据库。整体数据流向可以简化为客户端 → 接入层 → 应用层 → Redis原子扣减 ↓ 消息队列 ↓ 存储层异步落库应用层收到请求后先打 RedisRedis 扣减成功后再发消息给 MQ消费端再把数据写入数据库。这样用户请求链路里没有任何数据库同步操作响应速度会快很多。2.2 技术选型说明技术选型没有绝对标准我按常见的生产环境组合来介绍Redis承担红包预拆分数据的存储和扣减使用 Redis Cluster 部署。Spring Boot提供业务接口和消息消费者。RocketMQ / Kafka异步削峰把领取记录、入账消息批量写入数据库。MySQL 分库分表存储红包主表和领取明细。定时任务做对账和补偿。这里要注意Redis 承担的是“预扣减”不是“最终记账”。最终以数据库流水为准Redis 只是为了让扣减动作足够快。3. 数据建模与红包拆分算法3.1 金额单位选择很多新手会把金额设计成 double 或 BigDecimal这是第一个坑。浮点数在计算机中无法精确保存比如0.1 0.2的精度问题在金额计算中是不能接受的。BigDecimal 虽然精度高但计算效率低且不同语言之间传输容易出现格式问题。更常见的做法是金额统一用整数单位为“分”。比如 10 元保存为10001 元保存为100。这样既避免了浮点误差也方便用 long 类型存储和计算Redis 中传递也更简单。3.2 红包拆分算法红包拆分的时机也很重要。不要在用户抢红包时才实时计算金额而是发红包时一次性把金额拆好存到 Redis 中。用户抢的时候只是从池子里“取一个”这样扣减逻辑就变成了简单的原子弹出操作。拆分算法有很多种比如二倍均值法、线段切割法、随机金额法。二倍均值法是实现最简单、分布也比较合理的一种。下面是一个以“分”为单位的 Java 实现// 文件路径src/main/java/com/example/redpacket/util/RedPacketSplitUtil.java public class RedPacketSplitUtil { /** * 拆分红包 * * param totalFen 总金额单位分 * param totalCount 红包个数 * param minFen 单个红包最小金额单位分 * return 拆分后的金额列表 */ public static ListLong split(long totalFen, int totalCount, long minFen) { if (totalCount 0) { throw new IllegalArgumentException(红包个数必须大于0); } if (totalFen minFen * totalCount) { throw new IllegalArgumentException(总金额不足以分配); } ListLong result new ArrayList(totalCount); long remain totalFen; int remainCount totalCount; while (remainCount 1) { // 最大金额 剩余金额 - 给后续每个人保留的最小金额 long max remain - minFen * (remainCount - 1); // 当前均值 long avg remain / remainCount; // 二倍均值上限 long upper Math.min(max, avg * 2 1); // 在当前可分配范围内随机 long amount ThreadLocalRandom.current().nextLong(minFen, upper 1); result.add(amount); remain - amount; remainCount--; } // 最后一份直接给剩余金额 result.add(remain); // 打乱顺序避免前面的人固定拿大红包 Collections.shuffle(result); return result; } }这段代码有几个关键点每次随机前先计算max保证给后面的人至少留下最低金额。通过avg * 2 1控制单个红包不会太大让整体金额分布更均匀。最后一个红包用剩余金额兜底保证所有金额被完整分配。最后shuffle打乱顺序避免“越早抢金额越大”。用一次示例运行验证假设总金额是 1000 分10 元拆成 10 个红包ListLong list RedPacketSplitUtil.split(1000, 10, 1); System.out.println(list); System.out.println(list.stream().mapToLong(Long::longValue).sum());可能的输出结果如下每次运行结果不同[96, 123, 151, 89, 107, 74, 112, 98, 81, 69] 总和1000可以看出每个红包都在合理范围内总和也严格等于 1000 分。这个拆分结果可以直接写入 Redis。3.3 数据库表结构设计数据库表不需要承担高并发扣减但它是最终数据一致性的依据所以表结构要设计得足够严谨。红包主表保存红包的基本信息和剩余数据CREATE TABLE t_red_packet ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, packet_id VARCHAR(64) NOT NULL COMMENT 红包编号, user_id BIGINT NOT NULL COMMENT 发红包用户ID, total_amount BIGINT NOT NULL COMMENT 总金额单位分, total_count INT NOT NULL COMMENT 总个数, remain_amount BIGINT NOT NULL COMMENT 剩余金额单位分, remain_count INT NOT NULL COMMENT 剩余个数, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0发放中 1已抢完 2已过期, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, UNIQUE KEY uk_packet_id (packet_id) ) COMMENT 红包主表;领取记录表保存每个用户抢到的金额CREATE TABLE t_grab_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, packet_id VARCHAR(64) NOT NULL COMMENT 红包编号, user_id BIGINT NOT NULL COMMENT 用户ID, amount BIGINT NOT NULL COMMENT 领取金额单位分, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0待入账 1已入账 2失败, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 领取时间, UNIQUE KEY uk_packet_user (packet_id, user_id) ) COMMENT 红包领取记录表;这里的uk_packet_user唯一键非常重要它从数据库层面防止同一个用户重复领取同一个红包。即使消息队列重复投递消费者重复执行插入也会因为唯一键冲突而失败。4. 高并发抢红包核心实现4.1 为什么选择 Redis Lua先思考一个问题如果不使用 Redis直接用数据库扣减会发生什么用户点击抢红包后应用层先SELECT remain_count FROM t_red_packet WHERE packet_id ?判断还有余量再执行UPDATE ... SET remain_count remain_count - 1。在高并发下这两个操作之间会产生时间差大量请求读到同一个余量最终导致超卖。解决超卖的关键在于原子性。Redis 是单线程执行命令的Lua 脚本在 Redis 中执行时不会被其他命令插队。所以把“判断余量、扣减金额、记录用户”这三个动作放进同一个 Lua 脚本里就能从根上避免超发。4.2 红包预初始化发红包接口在完成金额拆分后需要把拆分结果预加载到 Redis 中。对应的 Java 代码如下// 文件路径src/main/java/com/example/redpacket/service/RedPacketService.java public void preload(String packetId, ListLong amountList, int totalCount) { // 红包金额列表先放入 slot 0单个 slot 演示更方便理解 String listKey red:packet: packetId :slot:0; String[] amounts amountList.stream() .map(String::valueOf) .toArray(String[]::new); redisTemplate.opsForList().rightPushAll(listKey, amounts); // 保存红包总数抢红包脚本中需要用来判断是否抢完 String countKey red:packet: packetId :count; redisTemplate.opsForValue().set(countKey, String.valueOf(totalCount)); // 设置过期时间防止长期不领的红包占用内存 redisTemplate.expire(listKey, Duration.ofDays(1)); redisTemplate.expire(countKey, Duration.ofDays(1)); }key 的设计要遵循“业务前缀 红包ID”的规则方便后续排查和清理。同一个红包的金额列表可以看作一个池子用户每次抢红包就从这个池子里弹出一个金额。4.3 抢红包 Lua 脚本抢红包的核心脚本如下-- 抢红包原子脚本 -- KEYS[1]红包金额列表 key -- KEYS[2]已抢用户集合 key -- KEYS[3]抢到总数 key -- KEYS[4]红包完成标记 key -- ARGV[1]用户ID -- ARGV[2]红包总个数 -- 1. 判断用户是否已经抢过 local isExist redis.call(sismember, KEYS[2], ARGV[1]) if isExist 1 then return -1 end -- 2. 从红包列表中弹出一个金额 local amount redis.call(rpop, KEYS[1]) if not amount then return -2 end -- 3. 记录用户已抢 redis.call(sadd, KEYS[2], ARGV[1]) -- 4. 累计已抢数量 local doneCount redis.call(incr, KEYS[3]) -- 5. 如果已经抢完打上完成标记 if doneCount tonumber(ARGV[2]) then redis.call(set, KEYS[4], 1) end return tonumber(amount)脚本返回值语义如下-1当前用户已经抢过重复请求。-2红包金额已为空来晚了。正数抢到的金额单位为分。这里有一个容易被忽略的细节第 1 步和第 2 步必须放在同一个 Lua 脚本里执行。如果分开执行可能出现两个请求同时通过“是否已抢过”的判断然后都去弹出金额同一个用户抢到两份。4.4 Spring Boot 调用示例在 Spring Boot 中可以通过DefaultRedisScript来执行上述 Lua 脚本。// 文件路径src/main/java/com/example/redpacket/service/RedPacketService.java private static final DefaultRedisScriptLong GRAB_SCRIPT new DefaultRedisScript(); static { GRAB_SCRIPT.setScriptText( local isExist redis.call(sismember, KEYS[2], ARGV[1])\n if isExist 1 then return -1 end\n local amount redis.call(rpop, KEYS[1])\n if not amount then return -2 end\n redis.call(sadd, KEYS[2], ARGV[1])\n local doneCount redis.call(incr, KEYS[3])\n if doneCount tonumber(ARGV[2]) then redis.call(set, KEYS[4], 1) end\n return tonumber(amount) ); GRAB_SCRIPT.setResultType(Long.class); } public Long grab(String packetId, String userId) { ListString keys Arrays.asList( red:packet: packetId :slot:0, red:packet: packetId :users, red:packet: packetId :count, red:packet: packetId :done ); Long totalCount getRedPacketTotalCount(packetId); if (totalCount null) { return -2L; } return redisTemplate.execute(GRAB_SCRIPT, keys, userId, String.valueOf(totalCount)); }调用方只需要判断返回值返回-1提示“你已经抢过这个红包了”。返回-2提示“手慢了红包已被抢完”。返回正数说明抢成功此时可以把结果返回给用户同时发送消息队列通知异步落库。4.5 热点 Key 优化多 Slot 拆分先说明一个问题上面示例中所有请求都打到red:packet:{packetId}:slot:0这一个 key 上在高并发下会形成热点 Key。比如一个红包有 10 万人同时抢Redis 单实例收到 10 万个写请求这个 key 所在的节点会成为整个集群的瓶颈。优化方向是把同一个红包的金额列表拆成多个 slotred:packet:{packetId}:slot:0 // 第一部分金额 red:packet:{packetId}:slot:1 // 第二部分金额 red:packet:{packetId}:slot:2 // 第三部分金额 ...初始化时把拆分好的金额分到多个 slot 中。抢红包时应用层先根据红包 ID 随机选一个 slot再对选中的 slot 执行上面的 Lua 脚本。这样做的好处是一个红包的流量被分散到多个 Redis key 上单 key 的压力大幅降低。同时因为金额总数在初始化时已经固定多个 slot 扣减的总和仍然等于总金额不会出现超发。需要注意的是多 slot 方案下“红包是否抢完”的判断要从全局视角来看。可以在每次抢完后递增全局计数当计数达到总个数时再通过消息队列触发红包完结状态更新。5. 削峰、幂等与最终一致性5.1 消息队列异步落库抢红包接口如果同步写数据库数据库很快就会被压垮。正确的做法是应用层在 Redis 中完成扣减后立即返回结果给用户同时发送一条消息到 MQ由 MQ 消费者异步写入数据库。消息内容至少包含public class GrabMessage { private String packetId; private Long userId; private Long amount; private LocalDateTime grabTime; }消费者收到消息后执行两个操作插入t_grab_detail领取记录。更新t_red_packet主表的剩余金额和剩余个数。这里的关键是消费端必须做幂等。因为 MQ 可能重复投递消息如果消费者重复插入领取记录就会出现同一条记录被插入多次。依靠t_grab_detail表的唯一键uk_packet_user(packet_id, user_id)重复插入会直接报主键冲突我们可以捕获这个异常并记录日志避免相同记录重复入账。5.2 幂等与防重设计幂等要从两个层面来做。第一层是 Redis 层。Lua 脚本中的sismember和sadd就是第一道防重屏障。同一用户同一红包的第二次请求会在 Lua 脚本里直接返回-1。第二层是数据库层。即使 Redis 主从切换、消息重复投递、应用重启等异常发生数据库的唯一键也能兜住重复数据。这里要特别提醒不要只做 Redis 层防重也不要只做数据库层防重。Redis 层防重是为了拦截高并发下的重复流量数据库层防重是为了保证最终数据的正确性两者不能互相替代。5.3 对账与补偿Redis 中的数据是内存态最终必须与数据库保持一致。但在极端情况下可能会出现数据不一致比如Redis 扣减成功但 MQ 消息发送失败。MQ 消费失败且重试次数超过上限。应用在发送消息前宕机。所以系统必须有一个对账补偿任务。常见做法是定时扫描 Redis 中已抢用户集合的大小。对比数据库t_grab_detail中该红包的实际领取人数。如果 Redis 数量大于数据库数量说明有领取记录没有落库触发补偿写入。如果数据库数量大于 Redis 数量说明存在异常数据需要人工介入排查。补偿任务的执行频率可以设置为每分钟一次对账粒度可以细化到红包维度或用户维度。6. 常见问题与排查思路下表整理了几个红包系统最容易出现的问题以及对应的排查方向。问题现象常见原因解决思路红包被抢超判断和扣减分成两步执行中间产生并发窗口将判断、扣减、记录放到同一个 Redis Lua 脚本中同一用户重复领取缺少防重判断或防重判断与扣减不原子Redis Set 防重 数据库唯一键双重保障Redis 热点 Key所有请求都打到一个红包 key 上红包金额拆分为多个 slot数据库压力过大抢红包后同步写库MQ 异步落库批量写入金额总和对不上使用 double 存储金额或拆分算法有误金额统一用“分”整数表示拆分后做一次总量校验消息重复消费MQ 重试机制触发多次投递消费端幂等利用唯一键去重红包实际被抢完但界面仍显示可抢Redis 已完成标记未及时更新数据库状态收尾消息异步更新红包主表状态并做对账补偿如果线上出现超发或金额不平第一件事不是改代码而是先停止入口流量并备份现场数据。然后结合日志、MQ 消息、Redis 快照和数据库流水逐步回放整个抢红包流程找到不一致发生的具体环节。7. 最佳实践与工程建议7.1 上线前的性能压测红包系统上线前一定要做专门的压测不要只在联调环境里跑通接口就认为没问题。这里给出一个 wrk 压测示例命令wrk -t8 -c500 -d60s --latency http://127.0.0.1:8080/api/redpacket/grab?packetIdP20250101001userIdU000001但需要注意上面的命令是所有请求都使用同一个userId实际上测的是幂等逻辑而不是真实的高并发抢红包能力。生产压测应该构造海量不同用户 ID让每个用户只抢一次。比如提前在数据文件里准备几十万用户 ID从文件读取后动态拼接 URL模拟真实用户行为。7.2 监控指标红包系统需要重点监控以下指标Redis内存使用率、QPS、慢查询、主从同步延迟、热点 Key 访问量。MQ消息积压数量、消费失败率、重试次数。数据库慢 SQL、连接池使用率、主从延迟。接口请求量、成功率、P99 响应时间、限流触发次数。对账任务每日对账差异笔数、补偿成功笔数、人工处理笔数。当一个红包系统出现异常时通常最早暴露问题的不是业务接口而是监控大盘上的指标曲线。7.3 生产变更注意事项红包系统涉及资金和账户数据生产变更必须谨慎修改扣减逻辑前先在测试环境模拟高并发压测确认没有超发。上线采用灰度发布先切 5% 流量观察 Redis 和数据库指标。数据库表结构变更需要先在预发环境验证避免锁表影响线上读写。执行任何数据库更新前确认操作命令、影响范围并提前备份相关数据。对账任务发现差异时不要直接修改数据库先在测试环境复现并确认根因。8. 总结与后续优化方向红包系统绝对不是“两张表 一个更新语句”那么简单。真正要扛住高并发需要把红包拆分为预置金额池用 Redis Lua 保证扣减的原子性用消息队列削峰落库再用唯一键和定时任务保证最终一致性。这套设计不是只适用于红包场景。电商秒杀、优惠券领取、活动抽奖本质上都是同一个套路先剥离热点数据再用原子操作扣减最后异步化落库。掌握这一套思路对做高并发系统设计会有很大帮助。从实际业务出发后续优化方向还有很多比如接入风控识别机器人刷红包、增加预算控制策略、通过本地缓存降低热点读压力、引入单元化部署提升容灾能力。但不管怎么优化都要始终记住一条原则高并发下用户体验可以短暂延迟但资金数据一分都不能错。先把原子扣减、异步落库、对账补偿这三件事做扎实再去追求更高的 QPS 才有意义。
返回列表