news 2026/10/14 15:22:18

高并发分布式扣减库存:Redis 预扣 + 本地扣减 + 消息幂等表终极方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发分布式扣减库存:Redis 预扣 + 本地扣减 + 消息幂等表终极方案

在电商系统的所有核心链路中,商品库存扣减绝对是那块最硬、最难啃的硬骨头。

如果直接用数据库事务加排他锁SELECT stock FROM sku WHERE id = ? FOR UPDATE,在双 11 秒杀的万级瞬时并发下,几万个线程同时死锁竞争同一行数据的行级锁,数据库的 CPU 会在 2 秒内飙到 100%,连接池瞬间枯竭,导致整个交易库发生连锁雪崩。

后来大家学聪明了,改用 Redis 分布式锁,或者直接用 Redis 的DECRBY在缓存中扣减。然而在真实的大促中,单纯依赖 Redis 同样危机四伏:

  • Redis 预扣成功了,下游订单服务网络超时失败,库存怎么回滚?
  • 消息异步发送去更新数据库,消费者重复消费了两次,导致库存被多扣了一次(超扣);
  • Redis 发生主从切换,部分在旧 Master 上扣减的数据还没异步复制到 Slave,新 Master 登基后库存数据出现回退,直接酿成严重超卖!

今天我们就抛弃那些玩具式的 Demo,拿出一套在千万级大促中历经考验的工业级库存架构:Redis Lua 原子预扣 + RocketMQ 异步削峰 + 数据库本地扣减与幂等表对账。

一、三层扣减架构与数据流转

要兼顾“微秒级超高并发”与“金融级数据不丢不超卖”,唯一的出路就是动静分离、两阶段异步化:

[用户秒杀请求] | v +-------------------------------------------------------------+ | 第一阶段:Redis 集群 (微秒级内存预扣) | | - 执行原子 Lua 脚本: 校验防超卖并预扣库存 | | - 记录用户维度幂等防重标志 | +-------------------------------------------------------------+ | (扣减成功) v +-------------------------------------------------------------+ | 第二阶段:RocketMQ 事务消息 / 可靠削峰投递 | | - 发送库存扣减最终凭据 (包含 requestId, skuId, count) | +-------------------------------------------------------------+ | (异步顺序消费) v +-------------------------------------------------------------+ | 第三阶段:MySQL 核心数据库 (本地落库持久化) | | - 开启本地事务 | | - 插入 stock_deduct_idempotent 幂等表 (唯一键约束) | | - UPDATE sku_stock SET available = available - ? ... | | - 事务提交,物理生效 | +-------------------------------------------------------------+

二、第一阶段:Redis Lua 脚本原子预扣

在 Redis 层面,绝不能用普通的get然后set,甚至不能单用decr(因为扣到负数再补加会有并发空档)。必须使用单次往返的原子 Lua 脚本:

-- keys: [1] sku_stock_key, [2] user_deduct_limit_key -- args: [1] deduct_count, [2] max_per_user local stockKey = KEYS[1] local limitKey = KEYS[2] local deductCount = tonumber(ARGV[1]) local maxPerUser = tonumber(ARGV[2]) -- 1. 检查用户是否已经超出限购 local userBought = tonumber(redis.call('GET', limitKey) or "0") if userBought + deductCount > maxPerUser then return -2 -- 超出个人限购 end -- 2. 检查剩余库存是否充足 local currentStock = tonumber(redis.call('GET', stockKey) or "0") if currentStock < deductCount then return -1 -- 库存不足 end -- 3. 原子扣减库存并累加用户购买记录 redis.call('DECRBY', stockKey, deductCount) redis.call('INCRBY', limitKey, deductCount) -- 设置用户限购缓存过期时间(如大促结束时) redis.call('EXPIRE', limitKey, 86400) return 1 -- 扣减成功

在 Java 端,通过 Spring Boot 封装调用该脚本:

package com.yali.stock.redis; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; import java.util.List; @Slf4j @Component @RequiredArgsConstructor public class RedisStockDeductService { private final StringRedisTemplate redisTemplate; private final DefaultRedisScript<Long> stockDeductScript; /** * 执行原子预扣 * 返回码:1-成功,-1-库存不足,-2-超出个人限购 */ public boolean preDeduct(String skuId, Long userId, int count, int limitPerUser) { String stockKey = "stock:sku:" + skuId; String limitKey = "stock:limit:" + skuId + ":" + userId; Long result = redisTemplate.execute( stockDeductScript, List.of(stockKey, limitKey), String.valueOf(count), String.valueOf(limitPerUser) ); if (result == null || result != 1L) { log.warn("Redis预扣库存未通过, skuId: {}, userId: {}, 结果码: {}", skuId, userId, result); return false; } return true; } }

三、第三阶段:MySQL 幂等表与扣减事务

消息消费者从 RocketMQ 拉取到预扣凭据后,落到持久化数据库层。

为了绝对防止消息被 MQ 重试时造成多次扣减,数据库必须使用本地事务内的幂等防重表。

1. 数据库建表设计

-- 库存流水幂等表 CREATE TABLE stock_deduct_idempotent ( deduct_request_id VARCHAR(64) NOT NULL COMMENT '防重请求唯一ID(如 order_id+sku_id)', sku_id BIGINT NOT NULL COMMENT '商品SKU ID', deduct_count INT NOT NULL COMMENT '扣减数量', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (deduct_request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存扣减幂等流水表'; -- 真实库存表 CREATE TABLE sku_stock ( sku_id BIGINT NOT NULL, total_stock INT NOT NULL, available_stock INT NOT NULL, version INT NOT NULL DEFAULT 0, PRIMARY KEY (sku_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. 本地事务中强行锁定并幂等落库

package com.yali.stock.dao; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @RequiredArgsConstructor public class DatabaseStockConsumerService { private final JdbcTemplate jdbcTemplate; /** * 保证物理数据库绝对不超卖且严格幂等 */ @Transactional(rollbackFor = Exception.class) public void executePhysicalDeduct(String requestId, Long skuId, int count) { // 1. 尝试向幂等流水表插入记录(利用数据库唯一主键硬约束防重) try { String insertSql = "INSERT INTO stock_deduct_idempotent (deduct_request_id, sku_id, deduct_count) VALUES (?, ?, ?)"; jdbcTemplate.update(insertSql, requestId, skuId, count); } catch (DuplicateKeyException e) { // 唯一键冲突,说明该请求已经被成功消费过了,直接静默退出(实现严格幂等) log.warn("检测到重复扣减消费请求,已被幂等表拦截, requestId: {}", requestId); return; } // 2. 执行安全的减库存 SQL(带底线约束条件) String updateSql = "UPDATE sku_stock SET available_stock = available_stock - ? WHERE sku_id = ? AND available_stock >= ?"; int affectedRows = jdbcTemplate.update(updateSql, count, skuId, count); if (affectedRows == 0) { // 物理库库存不够,发生严重的数据不一致漂移,抛出异常触发告警人工对账 log.error("致命异常:物理数据库库存不足!skuId: {}, requestId: {}", skuId, requestId); throw new IllegalStateException("物理库存不足,触发事务回滚并报警"); } log.info("物理数据库库存扣减成功, skuId: {}, count: {}, requestId: {}", skuId, count, requestId); } }

四、极端边界:Redis 挂掉或主从切换怎么办?

在架构评审时,老练的评委总会问一句:“如果 Redis 节点突然宕机,主从异步复制丢失了最近 50ms 的扣减指令,你们怎么发现和修复?”

我们通过两项后置机制来为系统兜底:

  1. 大促前库存打平对账:每次大促前 1 小时,通过预热脚本从 MySQL 读取最新的物理库存,全量覆盖同步到 Redis。
  2. 异步对账 Job(Reconciliation):后台低频巡检任务不断拉取stock_deduct_idempotent幂等表中的总扣减量,与 MySQL 中total_stock - available_stock进行累加比对,同时比对 Redis 里的数值。一旦发现偏差超过容忍阈值,立刻触发钉钉告警并发出修复补偿;
  3. 超时未支付自动回滚闭环:在秒杀场景下,通常设定“用户成功预占库存后,必须在 15 分钟内完成支付”。如果用户超时未支付,系统通过监听 RocketMQ 的延迟消息触发关单动作。关单逻辑同样需要先在 MySQL 中将订单置为关闭,然后通过原子 Lua 脚本将 Redis 库存执行反向INCRBY加回,同时删除该用户的限购记录,释放被占用的库存名额,实现完整的状态机闭环;
  4. 已售罄标志本地短路拦截(Sold-Out Short-Circuit):当某个热点单品的库存被预扣至 0 之后,如果外部仍有每秒数万的并发请求涌入,即便 Redis 执行 Lua 判定为库存不足,依然会浪费大量的网络 I/O。更彻底的做法是:在 Redis 判定库存归零的瞬间,通过 Redis Pub/Sub 或配置中心广播向所有应用节点下发sold_out = true信号。网关应用在本地内存感知到售罄后,后续针对该商品的请求直接在内存层快速失败,返回“手慢了已抢光”,完全阻断请求向下透传。

五、架构师心法

在高并发架构中,不要试图用单一套技术去同时解决吞吐量和强一致性。
Redis 是锋利的开路先锋,用来在上游抗住海啸般的 QPS;
MySQL 和唯一键幂等表是坚不可摧的厚重装甲,用来在下游保证每一分钱、每一个件货的准确无误。
两相结合,中间以消息队列做削峰缓冲,这就是现代电商大促能够笑傲洪峰的终极底牌。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 15:21:22

C++酒店点菜系统:从课程设计到可运行的餐饮管理框架

简介&#xff1a;一份面向C学习者的酒店点菜系统课程设计资源&#xff0c;覆盖权限管理、点餐、订单、结账与菜谱评分等核心模块&#xff0c;适合完成实训作业或理解面向对象与文件操作的综合实践。压缩包共19个文件&#xff0c;包含4个cpp源文件、3个头文件、4个目标文件、菜单…

作者头像 李华
网站建设 2026/10/14 15:20:46

Fisher线性判别:从几何直觉到可解释分类的完整实践

1. 项目概述&#xff1a;为什么Fisher线性判别至今仍是分类入门必修课你打开任何一本经典模式识别教材&#xff0c;翻到“监督学习”章节&#xff0c;大概率会在前三分之一的位置撞见Fisher线性判别&#xff08;Linear Discriminant Analysis, LDA&#xff09;——不是因为它多…

作者头像 李华
网站建设 2026/10/14 15:20:25

PaddleSeg人像抠图模型C++完整部署实战:推理管线与避坑指南

简介&#xff1a;百度人像抠图C模型部署完整包&#xff0c;是一套基于PaddleSeg PP-HumanSeg v2人像分割方案的端到端部署工程&#xff0c;面向需要集成人像抠图、背景分割能力的C开发者和算法部署工程师&#xff0c;主要解决模型推理、依赖库集成和工程化落地等实际难题。该模…

作者头像 李华
网站建设 2026/10/14 15:19:38

超越常规极限:Whodb Impeccable Skill 中 overdrive 界面增强命令的方法论

数据库客户端后端前端CLI桌面应用数据可视化AI 应用 【免费下载链接】whodb Where data access meets operational intelligence 项目地址&#xff1a; https://gitcode.com/gh_mirrors/wh/whodb 点击查看 免费下载 whodb 仓库的 .agents/ 目录下内置了一套名为 Impeccable 的…

作者头像 李华
网站建设 2026/10/14 15:19:35

【Redis进阶篇|第四篇】redis实现分布式锁

1. 什么是分布式锁分布式锁是一种用于协调多个分布式节点之间互斥访问共享资源的机制。在单机环境下&#xff0c;我们可以通过操作系统提供的锁&#xff08;如 synchronized、ReentrantLock&#xff09;来保证同一进程内多个线程对共享资源的互斥访问&#xff1b;但在分布式系统…

作者头像 李华