在电商系统的所有核心链路中,商品库存扣减绝对是那块最硬、最难啃的硬骨头。
如果直接用数据库事务加排他锁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 小时,通过预热脚本从 MySQL 读取最新的物理库存,全量覆盖同步到 Redis。
- 异步对账 Job(Reconciliation):后台低频巡检任务不断拉取
stock_deduct_idempotent幂等表中的总扣减量,与 MySQL 中total_stock - available_stock进行累加比对,同时比对 Redis 里的数值。一旦发现偏差超过容忍阈值,立刻触发钉钉告警并发出修复补偿; - 超时未支付自动回滚闭环:在秒杀场景下,通常设定“用户成功预占库存后,必须在 15 分钟内完成支付”。如果用户超时未支付,系统通过监听 RocketMQ 的延迟消息触发关单动作。关单逻辑同样需要先在 MySQL 中将订单置为关闭,然后通过原子 Lua 脚本将 Redis 库存执行反向
INCRBY加回,同时删除该用户的限购记录,释放被占用的库存名额,实现完整的状态机闭环; - 已售罄标志本地短路拦截(Sold-Out Short-Circuit):当某个热点单品的库存被预扣至 0 之后,如果外部仍有每秒数万的并发请求涌入,即便 Redis 执行 Lua 判定为库存不足,依然会浪费大量的网络 I/O。更彻底的做法是:在 Redis 判定库存归零的瞬间,通过 Redis Pub/Sub 或配置中心广播向所有应用节点下发
sold_out = true信号。网关应用在本地内存感知到售罄后,后续针对该商品的请求直接在内存层快速失败,返回“手慢了已抢光”,完全阻断请求向下透传。
五、架构师心法
在高并发架构中,不要试图用单一套技术去同时解决吞吐量和强一致性。
Redis 是锋利的开路先锋,用来在上游抗住海啸般的 QPS;
MySQL 和唯一键幂等表是坚不可摧的厚重装甲,用来在下游保证每一分钱、每一个件货的准确无误。
两相结合,中间以消息队列做削峰缓冲,这就是现代电商大促能够笑傲洪峰的终极底牌。