大流量下库存扣减的数据库瓶颈:Redis分片缓存解决方案
引言
为什么库存扣减如此重要?
在电商、秒杀、抢购等互联网业务场景中,库存扣减是整个交易链路中最核心、最关键的环节之一。一个看似简单的"减库存"操作,背后却隐藏着巨大的技术挑战。
案例启示:
2018年某电商平台双11大促期间,一款爆款商品在开售30秒内,库存从10万件变成负数,导致超卖3万多笔订单。最终平台不得不赔偿违约金,品牌方声誉受损,用户信任度大幅下降。这个惨痛的教训告诉我们:在高并发场景下,如果库存扣减处理不当,后果不堪设想。
库存扣减的三大核心挑战:
- 高并发处理能力 - 秒杀场景可能在几秒内涌入数十万甚至上百万的请求,系统需要具备足够的吞吐能力
- 数据准确性保证 - 绝对不能出现超卖现象(卖出数量超过实际库存),这是业务红线
- 极致的用户体验 - 响应时间要足够短,避免用户等待焦虑,提升转化率
传统方案的困境
在最开始的电商系统中,我们通常会这样实现库存扣减:
-- 第一步:查询库存
SELECT stock FROM product WHERE id = 1;
-- 第二步:判断库存是否充足
-- 第三步:如果充足,更新库存
UPDATE product SET stock = stock - 1 WHERE id = 1;
这种方案在低并发场景下看似没问题,但一旦流量上去,各种问题就会暴露无遗:
问题一:并发冲突导致超卖
当多个请求同时执行时,由于没有加锁,可能会出现"读取-修改-写入"的竞态条件。例如两个请求同时读取到库存为10,都认为自己可以扣减1件,最终库存只减少了1,但卖出了2件。
问题二:数据库成为性能瓶颈
MySQL单表的QPS通常在500-1000左右,即使使用索引优化,面对万级以上的并发也会力不从心。数据库连接池会被耗尽,大量请求超时失败,用户看到"系统繁忙,请稍后重试"的提示,体验极差。
问题三:锁机制降低并发能力
为了解决超卖问题,我们可能会使用悲观锁(SELECT FOR UPDATE)或乐观锁(CAS)。这些方案虽然解决了超卖问题,但大大降低了并发能力,因为串行化了请求处理。
一、传统方案的深度分析
1.1 数据库库存扣减的本质
在深入讨论Redis方案之前,我们先深入理解传统数据库方案的本质问题和局限。
最原始的实现方式:
// JDBC方式实现库存扣减
public boolean deductStock(Long productId, Integer quantity) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
// 1. 查询库存
PreparedStatement queryStmt = conn.prepareStatement(
"SELECT stock FROM product WHERE id = ?"
);
queryStmt.setLong(1, productId);
ResultSet rs = queryStmt.executeQuery();
if (!rs.next()) {
return false; // 商品不存在
}
int currentStock = rs.getInt("stock");
// 2. 判断库存是否充足
if (currentStock < quantity) {
conn.rollback();
return false; // 库存不足
}
// 3. 更新库存
PreparedStatement updateStmt = conn.prepareStatement(
"UPDATE product SET stock = ? WHERE id = ?"
);
updateStmt.setInt(1, currentStock - quantity);
updateStmt.setLong(2, productId);
updateStmt.executeUpdate();
conn.commit();
return true;
} catch (Exception e) {
if (conn != null) {
conn.rollback();
}
throw new RuntimeException("扣减库存失败", e);
} finally {
if (conn != null) {
conn.close();
}
}
}
这种方式的问题:
整个操作包含三个独立的数据库操作:SELECT、应用层判断、UPDATE。在高并发场景下,多个线程可能同时执行到SELECT阶段,读取到相同的库存值,然后都认为自己可以扣减,导致超卖。
1.2 并发冲突的详细分析
让我们通过一个详细的时序图来理解并发冲突是如何发生的:
时间线 请求A 请求B 数据库存量
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
T1 读取库存=10 ──────────────────────── 10
T2 ──────────────────────── 读取库存=10 10
T3 判断: 10 >= 1 ✓ ──────────────────────── 10
T4 ──────────────────────── 判断: 10 >= 1 ✓ 10
T5 执行: UPDATE stock=9 ──────────────────────── 9
T6 ──────────────────────── 执行: UPDATE stock=9 9
T7 返回成功 ──────────────────────── 9
T8 ──────────────────────── 返回成功 9
结果:两个请求都成功,但库存只减少了1!这就是超卖问题的本质。
实际生产环境的影响:
假设一款iPhone有1000件库存,同时有10000个用户抢购:
- 预期结果:前1000个用户抢购成功,库存归零,后9000个用户看到"库存不足"
- 实际结果:由于并发冲突,可能出现1200个用户抢购成功,超卖200件
超卖200件意味着:
- 需要向200个用户退款并赔偿违约金
- 品牌方信誉受损
- 可能面临法律风险
- 用户流失率上升
1.3 悲观锁方案:SELECT FOR UPDATE
为了解决并发冲突,最直观的方案是使用数据库的行锁:
-- 悲观锁方案
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
-- 应用层判断库存是否充足
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;
SELECT FOR UPDATE 的工作原理:
- 当事务A执行 SELECT FOR UPDATE 时,MySQL会对该行加排他锁(X锁)
- 其他事务试图对同一行执行 SELECT FOR UPDATE 时,会被阻塞
- 只有当事务A提交或回滚后,锁才会释放,其他事务才能继续执行
Java实现:
public boolean deductStockWithPessimisticLock(Long productId, Integer quantity) {
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false);
// 使用FOR UPDATE加行锁
PreparedStatement queryStmt = conn.prepareStatement(
"SELECT stock FROM product WHERE id = ? FOR UPDATE"
);
queryStmt.setLong(1, productId);
ResultSet rs = queryStmt.executeQuery();
if (!rs.next()) {
conn.rollback();
return false;
}
int currentStock = rs.getInt("stock");
if (currentStock < quantity) {
conn.rollback();
return false;
}
// 更新库存
PreparedStatement updateStmt = conn.prepareStatement(
"UPDATE product SET stock = stock - ? WHERE id = ?"
);
updateStmt.setInt(1, quantity);
updateStmt.setLong(2, productId);
updateStmt.executeUpdate();
conn.commit();
return true;
} catch (Exception e) {
if (conn != null) {
conn.rollback();
}
throw new RuntimeException("扣减库存失败", e);
} finally {
if (conn != null) {
conn.close();
}
}
}
悲观锁方案的优缺点:
| 优点 | 缺点 |
|---|---|
| 实现简单,容易理解 | 并发能力低,请求串行化 |
| 可以有效防止超卖 | 数据库连接占用时间长 |
| 数据一致性有保障 | 容易产生死锁 |
| 适用于低并发场景 | 高并发时响应时间长 |
1.4 乐观锁方案:CAS(Compare And Swap)
另一种思路是乐观锁,不直接加锁,而是通过版本号机制来检测冲突:
-- 建表时增加version字段
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
stock INT,
version INT DEFAULT 0,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 乐观锁扣减
UPDATE product
SET stock = stock - 1,
version = version + 1
WHERE id = 1
AND version = 5; -- 假设读取时version是5
工作原理:
- 先查询商品信息和当前版本号:
SELECT id, stock, version FROM product WHERE id = 1 - 应用层判断库存是否充足
- 执行UPDATE时,在WHERE条件中带上版本号
- 如果UPDATE影响行数为0,说明版本号已变化,需要重试
Java实现:
public boolean deductStockWithOptimisticLock(Long productId, Integer quantity) {
int maxRetries = 3; // 最大重试次数
for (int i = 0; i < maxRetries; i++) {
try {
// 1. 查询当前库存和版本号
Product product = productMapper.selectById(productId);
if (product == null) {
return false; // 商品不存在
}
if (product.getStock() < quantity) {
return false; // 库存不足
}
// 2. 使用版本号更新
int rows = productMapper.deductStockWithVersion(
productId,
product.getVersion(),
quantity
);
if (rows > 0) {
return true; // 更新成功
}
// rows == 0 说明版本号已变化,重试
Thread.sleep(10); // 短暂等待后重试
} catch (Exception e) {
// 记录日志,继续重试
}
}
return false; // 重试次数用尽,失败
}
MyBatis Mapper:
<update id="deductStockWithVersion">
UPDATE product
SET stock = stock - #{quantity},
version = version + 1
WHERE id = #{productId}
AND version = #{version}
</update>
乐观锁方案的优缺点:
| 优点 | 缺点 |
|---|---|
| 无锁竞争,吞吐量较高 | 冲突率高时需要重试 |
| 死锁风险低 | 高并发下CPU消耗高 |
| 适用于读多写少场景 | 无法解决ABA问题 |
1.5 数据库方案的根本瓶颈
通过前面的分析,我们可以看到无论是悲观锁还是乐观锁,都存在一个根本性的限制:
数据库的本质限制:
- 磁盘I/O限制 - 即使使用SSD,磁盘I/O仍然是瓶颈
- 锁竞争 - 行锁、表锁、间隙锁等都会限制并发
- 连接数限制 - 数据库连接池大小有限制
- 事务开销 - ACID保证带来的额外开销
MySQL的性能极限:
根据官方测试数据和业界实践:
- 单表QPS上限: 500-1000(简单查询)
- 单表写入QPS: 200-500(带索引)
- 并发连接数: 默认151,最大可调至10000
- 行锁等待时间: 高并发下可达数秒
结论:
数据库方案无论怎么优化,其QPS上限也就是几百到一千左右。对于秒杀场景动辄数万甚至数十万的QPS需求,数据库方案是根本无法满足的。
1.6 性能对比数据
让我们通过一个直观的对比来看传统方案与Redis方案的差距:
详细对比数据:
| 对比维度 | 传统方案(悲观锁) | 传统方案(乐观锁) | Redis方案 | 提升倍数 |
|---|---|---|---|---|
| QPS性能 | 200 | 600 | 100,000+ | 100-500倍 |
| 响应时间 | 500ms | 150ms | <1ms | 100-500倍 |
| 并发能力 | 受DB连接限制 | 中等 | 无上限 | 水平扩展 |
| 超卖控制 | 无超卖 | 无超卖 | 0%超卖 | 完美解决 |
| 扩展方式 | 垂直扩展 | 垂直扩展 | 水平扩展 | 成本低 |
| 运维成本 | 高 | 中 | 低 | 降低70% |
| 可靠性 | 单点故障风险 | 单点故障风险 | 高可用集群 | 大幅提升 |
实战案例对比:
某电商秒杀活动,1000件iPhone 15 Pro,10万用户同时抢购:
| 方案 | 成功处理QPS | 平均响应时间 | 超卖数量 | 用户体验 |
|---|---|---|---|---|
| 直接操作DB | 300 | 2秒 | 超卖50件 | 大量超时 |
| 悲观锁 | 200 | 3秒 | 0件 | 严重拥堵 |
| 乐观锁 | 500 | 500ms | 0件 | 部分重试 |
| Redis方案 | 80000 | 2ms | 0件 | 秒级响应 |
二、Redis缓存解决方案
2.1 为什么选择Redis?
Redis是解决库存扣减问题的理想选择,这得益于其独特的架构和特性:
Redis的核心优势:
- 纯内存操作 - 所有数据存储在内存中,读写速度极快
- 单线程模型 - 避免了多线程竞争和上下文切换开销
- I/O多路复用 - 高效处理大量并发连接
- 丰富的数据结构 - String、Hash、Set、List等满足不同需求
- 原子操作 - 单个命令是原子的,无需额外加锁
- Lua脚本支持 - 多个操作可以原子执行
- 持久化机制 - RDB和AOF两种方式保证数据安全
- 分布式能力 - 主从复制、哨兵、集群等完善方案
Redis与MySQL的性能对比:
| 操作 | MySQL | Redis | 性能差异 |
|---|---|---|---|
| 简单查询 | 10-50ms | 0.1-1ms | 10-50倍 |
| 带条件更新 | 20-100ms | 0.1-1ms | 20-100倍 |
| 事务操作 | 50-200ms | 1-5ms | 10-50倍 |
| 单机QPS | 500-1000 | 100,000+ | 100倍+ |
2.2 整体架构设计
采用Redis缓存方案后,整个系统的架构需要重新设计:
系统分层详解:
第一层:客户端层
- Web前端:Vue/React/Angular单页应用
- 移动端:iOS/Android原生应用
- 小程序:微信/支付宝小程序
第二层:负载均衡层
- Nginx反向代理:实现负载均衡和静态资源缓存
- CDN加速:静态资源分发到边缘节点
- API网关:统一入口、限流、鉴权、熔断
upstream backend {
server 192.168.1.101:8080;
server 192.168.1.102:8080;
server 192.168.1.103:8080;
}
server {
listen 80;
server_name api.example.com;
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
第三层:应用层
- Spring Boot应用集群:水平扩展,无状态设计
- 限流组件:Guava RateLimiter或Sentinel
- 熔断降级:Hystrix或Resilience4j
第四层:缓存层(核心)
- Redis分片集群:按商品ID分布库存
- 本地缓存:Caffeine作为二级缓存
- 缓存预热:系统启动时加载热点数据
第五层:消息层
- RabbitMQ/Kafka:异步同步Redis与MySQL
- 消息持久化:保证消息不丢失
- 死信队列:处理异常情况
第六层:持久层
- MySQL集群:主从复制,读写分离
- 分库分表:按业务维度拆分
- 数据归档:历史数据迁移
2.3 Redis分片策略
为什么需要分片?单个Redis节点虽然性能强大,但在极端场景下仍有瓶颈:
单Redis节点的限制:
- 内存限制 - 单机内存有限(如64GB)
- CPU限制 - 单核CPU处理能力有上限
- 网络带宽限制 - 单网卡带宽有限
- 单点故障风险 - 一台机器故障影响全部商品
分片策略设计:
分片公式:shardIndex = productId % shardCount
示例(5个分片):
商品ID=1 → 分片0 → Redis节点0
商品ID=2 → 分片1 → Redis节点1
商品ID=3 → 分片2 → Redis节点2
商品ID=4 → 分片3 → Redis节点3
商品ID=5 → 分片4 → Redis节点4
商品ID=6 → 分片0 → Redis节点0
...
分片的优势:
| 优势 | 说明 | 效果 |
|---|---|---|
| 负载均衡 | 商品均匀分布到各节点 | 单节点压力降低N倍 |
| 高并发 | N个节点并行处理 | QPS提升N倍 |
| 易扩展 | 增加节点即可扩容 | 无需停机 |
| 高可用 | 单节点故障只影响部分商品 | 整体可用性提高 |
Key命名规范:
库存Key:inventory:shard:{shardIndex}:product:{productId}:stock
用户Set:inventory:shard:{shardIndex}:product:{productId}:users
订单Hash:inventory:shard:{shardIndex}:product:{productId}:orders
示例:
inventory:shard:0:product:1:stock = "1000"
inventory:shard:0:product:1:users = {"user1", "user2", ...}
inventory:shard:0:product:1:orders = {"order1": "user1:1:1234567890", ...}
Java实现分片计算:
@Component
@ConfigurationProperties(prefix = "redis.sharding")
public class RedisShardingConfig {
private int nodeCount = 5; // 分片数量
public int calculateShard(Long productId) {
return (int) (productId % nodeCount);
}
public String buildStockKey(Long productId) {
int shard = calculateShard(productId);
return String.format("inventory:shard:%d:product:%d:stock", shard, productId);
}
public String buildUserSetKey(Long productId) {
int shard = calculateShard(productId);
return String.format("inventory:shard:%d:product:%d:users", shard, productId);
}
public String buildOrderHashKey(Long productId) {
int shard = calculateShard(productId);
return String.format("inventory:shard:%d:product:%d:orders", shard, productId);
}
// getter/setter省略
}
配置文件:
redis:
sharding:
node-count: 5 # 分片数量
2.4 分片扩容方案
当业务增长时,可能需要增加分片数量。以下是几种扩容方案:
方案一:停机扩容(简单)
- 停止写入操作
- 将所有Redis数据导出
- 按新的分片规则重新分配数据
- 更新应用配置
- 启动服务
优点:实现简单 缺点:需要停机
方案二:在线扩容(推荐)
步骤:
1. 新增Redis节点
2. 双写机制:同时写入旧分片和新分片
3. 数据迁移:后台逐步迁移数据
4. 读取切换:优先读新分片,未命中则读旧分片
5. 下线旧节点
双写实现:
public void setStock(Long productId, Integer stock) {
// 旧分片(3个)
int oldShard = productId % 3;
String oldKey = "inventory:shard:" + oldShard + ":product:" + productId;
// 新分片(5个)
int newShard = productId % 5;
String newKey = "inventory:shard:" + newShard + ":product:" + productId;
// 同时写入两个分片
redisTemplate.opsForValue().set(oldKey, stock);
redisTemplate.opsForValue().set(newKey, stock);
}
三、核心实现:Lua脚本原子扣减
3.1 为什么必须使用Lua脚本?
Redis的单个命令(如DECR、INCR)是原子的,但我们的库存扣减逻辑通常涉及多个步骤:
库存扣减的完整流程:
- 检查用户是否已购买(防止重复购买)
- 检查库存是否充足
- 扣减库存
- 记录购买用户(防止重复购买)
- 记录订单信息
如果使用多个Redis命令:
// ❌ 错误示例:不是原子操作
public boolean deduct(String productId, String userId, int qty) {
// 步骤1:检查用户是否已购买
Boolean isMember = redisTemplate.opsForSet().isMember(userSetKey, userId);
if (isMember) {
return false;
}
// 步骤2:检查库存
String stockStr = redisTemplate.opsForValue().get(stockKey);
if (Integer.parseInt(stockStr) < qty) {
return false;
}
// 步骤3:扣减库存
redisTemplate.opsForValue().increment(stockKey, -qty);
// 步骤4:记录用户
redisTemplate.opsForSet().add(userSetKey, userId);
// 步骤5:记录订单
redisTemplate.opsForHash().put(orderHashKey, orderNo, orderInfo);
return true;
}
问题分析:
上述代码虽然逻辑正确,但存在严重的并发问题:
时间线 线程A 线程B
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
T1 检查用户: 未购买 ───────────────────
T2 ─────────────────── 检查用户: 未购买
T3 检查库存: 库存=10 ───────────────────
T4 ─────────────────── 检查库存: 库存=10
T5 判断: 10 >= 1 ✓ ───────────────────
T6 ─────────────────── 判断: 10 >= 1 ✓
T7 扣减库存: 库存=9 ───────────────────
T8 ─────────────────── 扣减库存: 库存=8
T9 记录用户 ───────────────────
T10 ─────────────────── 记录用户(同一用户!)
结果:同一用户成功购买两次!
Lua脚本的原子性保证:
Redis保证Lua脚本的执行是原子的:
- 脚本执行期间,不会插入其他命令
- 脚本要么全部执行成功,要么完全不执行
- 不存在竞态条件
3.2 库存扣减Lua脚本详解
完整Lua脚本:
-- ===================== 参数说明 =====================
-- KEYS[1]: 库存Key(如:inventory:shard:0:product:1:stock)
-- ARGV[1]: 扣减数量
-- ARGV[2]: 用户ID
-- ARGV[3]: 订单号
-- ARGV[4]: 时间戳
-- ===================== 变量定义 =====================
local stockKey = KEYS[1] -- 库存Key
local userSetKey = stockKey .. ':users' -- 用户集合Key
local orderHashKey = stockKey .. ':orders' -- 订单Hash Key
local qty = tonumber(ARGV[1]) -- 扣减数量
local userId = ARGV[2] -- 用户ID
local orderNo = ARGV[3] -- 订单号
local timestamp = ARGV[4] -- 时间戳
-- ===================== 第一步:检查用户是否已购买 =====================
-- 使用SISMEMBER命令检查用户是否在购买集合中
if redis.call('SISMEMBER', userSetKey, userId) == 1 then
return -1 -- 返回-1表示用户已购买
end
-- ===================== 第二步:检查库存是否存在 =====================
local stock = tonumber(redis.call('GET', stockKey))
if stock == nil then
return -2 -- 返回-2表示商品不存在
end
-- ===================== 第三步:检查库存是否充足 =====================
if stock < qty then
return 0 -- 返回0表示库存不足
end
-- ===================== 第四步:扣减库存 =====================
-- 使用DECRBY命令原子性地扣减库存
redis.call('DECRBY', stockKey, qty)
-- ===================== 第五步:记录购买用户 =====================
-- 将用户ID添加到Set中,防止重复购买
redis.call('SADD', userSetKey, userId)
-- ===================== 第六步:记录订单信息 =====================
-- 将订单信息存储到Hash中
-- 格式:orderNo -> userId:quantity:timestamp
local orderInfo = userId .. ':' .. qty .. ':' .. timestamp
redis.call('HSET', orderHashKey, orderNo, orderInfo)
-- ===================== 返回成功 =====================
return 1 -- 返回1表示扣减成功
脚本执行流程图:
输入参数
│
├─ KEYS[1] = "inventory:shard:0:product:1:stock"
├─ ARGV[1] = "1" (购买数量)
├─ ARGV[2] = "user123" (用户ID)
├─ ARGV[3] = "order456" (订单号)
└─ ARGV[4] = "1699900000" (时间戳)
│
▼
┌─────────────────────────────────┐
│ 检查用户是否已购买 │
│ SISMEMBER userSetKey userId │
└──────────────┬──────────────────┘
│
┌──────┴──────┐
│ │
是 否
│ │
▼ ▼
返回 -1 ┌─────────────────────────┐
(已购买) │ 检查库存是否存在 │
│ GET stockKey │
└────────────┬────────────┘
│
┌──────┴──────┐
│ │
不存在 存在
│ │
▼ ▼
返回 -2 ┌─────────────────────────┐
(商品不存在) │ 检查库存是否充足 │
│ stock >= qty ? │
└────────────┬────────────┘
│
┌──────┴──────┐
│ │
不足 充足
│ │
▼ ▼
返回 0 ┌───────────────────┐
(库存不足) │ 扣减库存 │
│ DECRBY stockKey │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ 记录购买用户 │
│ SADD userSetKey │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ 记录订单信息 │
│ HSET ordersHash │
└─────────┬─────────┘
│
▼
返回 1 (成功)
3.3 库存回滚Lua脚本
当订单取消或超时未支付时,需要回滚库存:
-- ===================== 参数说明 =====================
-- KEYS[1]: 库存Key
-- ARGV[1]: 回滚数量
-- ARGV[2]: 用户ID
-- ARGV[3]: 订单号
local stockKey = KEYS[1]
local userSetKey = stockKey .. ':users'
local orderHashKey = stockKey .. ':orders'
local qty = tonumber(ARGV[1])
local userId = ARGV[2]
local orderNo = ARGV[3]
-- ===================== 第一步:删除订单记录 =====================
redis.call('HDEL', orderHashKey, orderNo)
-- ===================== 第二步:移除用户购买记录 =====================
redis.call('SREM', userSetKey, userId)
-- ===================== 第三步:恢复库存 =====================
redis.call('INCRBY', stockKey, qty)
-- ===================== 返回成功 =====================
return 1
3.4 Java实现详解
完整的Service实现:
package com.redis.demo.service;
import com.alibaba.fastjson2.JSON;
import com.redis.demo.config.RedisShardingConfig;
import com.redis.demo.dto.DeductRequest;
import com.redis.demo.dto.DeductResponse;
import com.redis.demo.entity.Inventory;
import com.redis.demo.mapper.InventoryMapper;
import com.redis.demo.mq.message.InventoryDeductMessage;
import com.redis.demo.mq.producer.RabbitMQProducer;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 库存服务 - Redis分片缓存实现
*/
@Slf4j
@Service
public class InventoryService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private InventoryMapper inventoryMapper;
@Autowired(required = false)
private RabbitMQProducer rabbitMQProducer;
@Resource
private RedisShardingConfig redisShardingConfig;
/**
* Lua脚本: 原子性库存扣减
*/
private static final String DEDUCT_LUA_SCRIPT =
"local stockKey = KEYS[1]\n" +
"local userSetKey = stockKey .. ':users'\n" +
"local qty = tonumber(ARGV[1])\n" +
"local userId = ARGV[2]\n" +
"local orderNo = ARGV[3]\n" +
"\n" +
"-- 检查用户是否已购买\n" +
"if redis.call('SISMEMBER', userSetKey, userId) == 1 then\n" +
" return -1\n" +
"end\n" +
"\n" +
"-- 检查库存\n" +
"local stock = tonumber(redis.call('GET', stockKey))\n" +
"if stock == nil then\n" +
" return -2\n" +
"end\n" +
"\n" +
"if stock < qty then\n" +
" return 0\n" +
"end\n" +
"\n" +
"-- 扣减库存\n" +
"redis.call('DECRBY', stockKey, qty)\n" +
"-- 记录用户\n" +
"redis.call('SADD', userSetKey, userId)\n" +
"-- 记录订单\n" +
"redis.call('HSET', stockKey .. ':orders', orderNo, userId .. ':' .. qty .. ':' .. ARGV[4])\n" +
"\n" +
"return 1";
/**
* Lua脚本: 回滚库存
*/
private static final String ROLLBACK_LUA_SCRIPT =
"local stockKey = KEYS[1]\n" +
"local userSetKey = stockKey .. ':users'\n" +
"local qty = tonumber(ARGV[1])\n" +
"local userId = ARGV[2]\n" +
"local orderNo = ARGV[3]\n" +
"\n" +
"redis.call('HDEL', stockKey .. ':orders', orderNo)\n" +
"redis.call('SREM', userSetKey, userId)\n" +
"redis.call('INCRBY', stockKey, qty)\n" +
"\n" +
"return 1";
private DefaultRedisScript<Long> deductScript;
private DefaultRedisScript<Long> rollbackScript;
@PostConstruct
public void init() {
// 初始化扣减脚本
deductScript = new DefaultRedisScript<>();
deductScript.setScriptText(DEDUCT_LUA_SCRIPT);
deductScript.setResultType(Long.class);
// 初始化回滚脚本
rollbackScript = new DefaultRedisScript<>();
rollbackScript.setScriptText(ROLLBACK_LUA_SCRIPT);
rollbackScript.setResultType(Long.class);
log.info("Lua脚本初始化完成");
}
/**
* 扣减库存
*/
public DeductResponse deduct(DeductRequest request) {
long startTime = System.currentTimeMillis();
DeductResponse response = new DeductResponse();
response.setSuccess(false);
try {
// 1. 计算分片
int shard = calculateShard(request.getProductId());
response.setShardIndex(shard);
// 2. 构建Key
String stockKey = buildStockKey(request.getProductId());
// 3. 检查缓存是否存在
String cachedStock = stringRedisTemplate.opsForValue().get(stockKey);
if (StringUtils.isBlank(cachedStock)) {
// 缓存不存在,从数据库加载
Inventory inventory = inventoryMapper.selectByProductId(request.getProductId());
if (inventory == null) {
response.setErrorMessage("商品不存在");
return response;
}
// 初始化缓存
stringRedisTemplate.opsForValue().set(stockKey,
String.valueOf(inventory.getAvailableStock()),
24, TimeUnit.HOURS);
cachedStock = String.valueOf(inventory.getAvailableStock());
}
// 4. 执行Lua脚本扣减库存
Long result = stringRedisTemplate.execute(
deductScript,
Collections.singletonList(stockKey),
String.valueOf(request.getQuantity()),
String.valueOf(request.getUserId()),
request.getOrderNo(),
String.valueOf(System.currentTimeMillis())
);
// 5. 处理结果
if (result == null || result == -2L) {
response.setErrorMessage("商品库存数据异常");
return response;
}
if (result == -1L) {
response.setErrorMessage("用户已购买,不能重复购买");
return response;
}
if (result == 0L) {
String remaining = stringRedisTemplate.opsForValue().get(stockKey);
response.setRemainingStock(remaining != null ? Integer.parseInt(remaining) : 0);
response.setErrorMessage("库存不足");
return response;
}
// 6. 扣减成功
String remaining = stringRedisTemplate.opsForValue().get(stockKey);
response.setRemainingStock(Integer.parseInt(remaining));
response.setSuccess(true);
// 7. 发送MQ消息异步同步数据库
if (rabbitMQProducer != null) {
InventoryDeductMessage message = InventoryDeductMessage.builder()
.productId(request.getProductId())
.userId(request.getUserId())
.orderNo(request.getOrderNo())
.quantity(request.getQuantity())
.shardIndex(shard)
.timestamp(System.currentTimeMillis())
.build();
rabbitMQProducer.sendInventoryDeductMessage(message);
}
log.info("库存扣减成功 - 商品ID:{}, 用户ID:{}, 数量:{}, 剩余库存:{}, 分片:{}, 耗时:{}ms",
request.getProductId(), request.getUserId(), request.getQuantity(),
response.getRemainingStock(), shard,
System.currentTimeMillis() - startTime);
} catch (Exception e) {
log.error("库存扣减异常", e);
response.setErrorMessage("系统异常: " + e.getMessage());
} finally {
response.setCostTime(System.currentTimeMillis() - startTime);
}
return response;
}
/**
* 回滚库存
*/
public boolean rollback(Long productId, Long userId, String orderNo, Integer quantity) {
try {
String stockKey = buildStockKey(productId);
stringRedisTemplate.execute(
rollbackScript,
Collections.singletonList(stockKey),
String.valueOf(quantity),
String.valueOf(userId),
orderNo
);
log.info("库存回滚成功 - 商品ID:{}, 用户ID:{}, 订单号:{}, 数量:{}",
productId, userId, orderNo, quantity);
return true;
} catch (Exception e) {
log.error("库存回滚失败", e);
return false;
}
}
/**
* 计算Redis分片
*/
private int calculateShard(Long productId) {
return (int) (productId % redisShardingConfig.getNodeCount());
}
/**
* 构建库存Key
*/
private String buildStockKey(Long productId) {
int shard = calculateShard(productId);
return String.format("inventory:shard:%d:product:%d:stock", shard, productId);
}
/**
* 查询库存(优先从缓存)
*/
public Integer getStock(Long productId) {
String stockKey = buildStockKey(productId);
String cached = stringRedisTemplate.opsForValue().get(stockKey);
if (StringUtils.isNotBlank(cached)) {
return Integer.parseInt(cached);
}
// 缓存不存在,查询数据库
Inventory inventory = inventoryMapper.selectByProductId(productId);
if (inventory != null) {
// 回写缓存
stringRedisTemplate.opsForValue().set(stockKey,
String.valueOf(inventory.getAvailableStock()),
24, TimeUnit.HOURS);
return inventory.getAvailableStock();
}
return 0;
}
/**
* 预热库存缓存
*/
public void warmUpCache() {
log.info("开始预热库存缓存...");
try {
List<Inventory> inventories = inventoryMapper.selectAll();
if (inventories == null || inventories.isEmpty()) {
log.warn("没有库存数据需要预热");
return;
}
int successCount = 0;
int failCount = 0;
for (Inventory inventory : inventories) {
try {
String stockKey = buildStockKey(inventory.getProductId());
stringRedisTemplate.opsForValue().set(stockKey,
String.valueOf(inventory.getAvailableStock()),
24, TimeUnit.HOURS);
successCount++;
} catch (Exception e) {
log.error("预热商品{}库存失败", inventory.getProductId(), e);
failCount++;
}
}
log.info("库存缓存预热完成 - 成功:{}, 失败:{}", successCount, failCount);
} catch (Exception e) {
log.error("预热库存缓存异常", e);
}
}
}
四、缓存与数据库双写一致性
4.1 一致性挑战分析
使用Redis缓存后,我们面临一个核心问题:如何保证Redis缓存与MySQL数据库的数据一致性?
双写一致性困境:
场景:用户购买商品,需要同时更新Redis和MySQL
方案一:先更新Redis,再更新MySQL
问题:如果MySQL更新失败,Redis已经扣减,数据不一致
方案二:先更新MySQL,再更新Redis
问题:如果Redis更新失败,MySQL已经扣减,数据不一致
方案三:同时更新(使用分布式事务)
问题:性能极差,无法满足高并发需求
4.2 异步双写方案
我们采用异步双写方案,核心思想是:
- 写流程:Redis扣减 → 立即返回用户 → MQ异步同步MySQL
- 读流程:先读Redis → 缓存命中返回 → 缓存未命中读MySQL并回写Redis
写流程详解:
// 完整的写流程
public DeductResponse deduct(DeductRequest request) {
// 第1步:Redis扣减(核心路径,<1ms)
Long result = executeLuaScript(request);
if (result != 1) {
// 扣减失败,直接返回
return buildFailResponse(result);
}
// 第2步:立即返回用户(不等待MySQL)
DeductResponse response = buildSuccessResponse();
response.setCostTime(System.currentTimeMillis() - startTime);
// 第3步:异步发送MQ消息(非阻塞)
CompletableFuture.runAsync(() -> {
try {
rabbitMQProducer.sendInventoryDeductMessage(message);
} catch (Exception e) {
// 发送失败,记录日志,后续补偿
log.error("发送MQ消息失败", e);
}
});
return response;
}
读流程详解:
// 完整的读流程
public Integer getStock(Long productId) {
String stockKey = buildStockKey(productId);
// 第1步:先读Redis
String cached = redisTemplate.opsForValue().get(stockKey);
if (StringUtils.isNotBlank(cached)) {
return Integer.parseInt(cached); // 缓存命中,直接返回
}
// 第2步:缓存未命中,读MySQL
Inventory inventory = inventoryMapper.selectByProductId(productId);
if (inventory != null) {
// 第3步:回写Redis(Cache Aside模式)
redisTemplate.opsForValue().set(stockKey,
String.valueOf(inventory.getAvailableStock()),
24, TimeUnit.HOURS);
return inventory.getAvailableStock();
}
return 0;
}
4.3 消息队列实现
RabbitMQ配置:
@Configuration
public class RabbitMQConfig {
// 交换机
@Bean
public DirectExchange inventoryExchange() {
return new DirectExchange("inventory.deduct.exchange", true, false);
}
// 队列
@Bean
public Queue inventoryDeductQueue() {
return QueueBuilder.durable("inventory.deduct.queue")
.withArgument("x-dead-letter-exchange", "inventory.dlt.exchange")
.build();
}
// 绑定
@Bean
public Binding inventoryBinding() {
return BindingBuilder.bind(inventoryDeductQueue())
.to(inventoryExchange())
.with("inventory.deduct");
}
// 死信交换机
@Bean
public DirectExchange inventoryDltExchange() {
return new DirectExchange("inventory.dlt.exchange", true, false);
}
// 死信队列
@Bean
public Queue inventoryDltQueue() {
return QueueBuilder.durable("inventory.dlt.queue").build();
}
// 死信绑定
@Bean
public Binding inventoryDltBinding() {
return BindingBuilder.bind(inventoryDltQueue())
.to(inventoryDltExchange())
.with("inventory.deduct");
}
}
生产者实现:
@Component
@Slf4j
public class RabbitMQProducer {
@Autowired
private RabbitTemplate rabbitTemplate;
public void sendInventoryDeductMessage(InventoryDeductMessage message) {
try {
rabbitTemplate.convertAndSend(
"inventory.deduct.exchange",
"inventory.deduct",
JSON.toJSONString(message)
);
log.info("MQ消息发送成功 - 订单号:{}", message.getOrderNo());
} catch (Exception e) {
log.error("MQ消息发送失败 - 订单号:{}", message.getOrderNo(), e);
throw e;
}
}
}
消费者实现:
@Component
@Slf4j
public class InventoryDeductConsumer {
@Autowired
private InventoryMapper inventoryMapper;
@RabbitListener(queues = "inventory.deduct.queue")
public void onMessage(String message, Channel channel, long deliveryTag) {
try {
InventoryDeductMessage msg = JSON.parseObject(message, InventoryDeductMessage.class);
// 使用乐观锁更新数据库(带重试)
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
int rows = inventoryMapper.deductStock(
msg.getProductId(),
msg.getQuantity()
);
if (rows > 0) {
// 更新成功,确认消息
channel.basicAck(deliveryTag, false);
log.info("数据库更新成功 - 订单号:{}", msg.getOrderNo());
return;
}
} catch (Exception e) {
if (i == maxRetries - 1) {
throw e;
}
Thread.sleep(100);
}
}
} catch (Exception e) {
log.error("消息处理失败", e);
try {
// 拒绝消息,重新入队
channel.basicNack(deliveryTag, false, true);
} catch (Exception ex) {
log.error("NACK失败", ex);
}
}
}
}
4.4 一致性保障机制
为了确保最终一致性,我们实现了以下机制:
1. Redis事务保障
使用Lua脚本保证Redis操作的原子性,无需额外的事务控制。
2. MQ可靠投递
- 消息持久化:队列和消息都设置为持久化
- ACK机制:消费成功后手动确认
- 重试策略:消费失败自动重试
spring:
rabbitmq:
publisher-confirm-type: correlated # 发布确认
publisher-returns: true # 发布返回
listener:
simple:
acknowledge-mode: manual # 手动确认
retry:
enabled: true
max-attempts: 3
initial-interval: 1000ms
3. 幂等设计
使用唯一订单号防止重复处理:
-- 数据库表增加唯一约束
CREATE TABLE inventory_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) UNIQUE NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
4. 定时对账
@Component
@Slf4j
public class ReconciliationTask {
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void reconcile() {
log.info("开始执行库存对账任务");
// 1. 获取Redis中的所有订单
// 2. 获取MySQL中的所有订单
// 3. 比对差异
// 4. 生成差异报告
// 5. 自动修复或人工介入
}
}
5. 补偿机制
发现不一致时自动触发修复:
public void compensateInventory(Long productId) {
// 1. 获取Redis库存
String redisStock = getStockFromRedis(productId);
// 2. 获取MySQL库存
int dbStock = getStockFromDB(productId);
// 3. 判断差异
int diff = dbStock - Integer.parseInt(redisStock);
if (diff != 0) {
// 4. 以MySQL为准,修复Redis
log.warn("发现库存差异 - 商品ID:{}, Redis:{}, MySQL:{}, 差异:{}",
productId, redisStock, dbStock, diff);
// 修复Redis
setStockToRedis(productId, dbStock);
}
}
五、完整库存扣减流程
详细步骤说明:
-
用户发起请求 - 用户点击"立即购买"按钮,前端发送POST请求到后端API
-
请求参数校验 - 校验商品ID、用户ID、购买数量等参数的合法性
-
计算Redis分片 - 根据商品ID使用哈希算法计算该商品在哪个Redis分片
-
检查缓存 - 检查Redis中是否有该商品的库存缓存
-
缓存不存在 - 从MySQL加载库存并初始化到Redis,设置24小时过期
-
执行Lua脚本 - 原子性地检查用户、库存并扣减,整个过程在Redis内部完成
-
返回用户 - 立即返回结果给用户,总耗时<1ms
-
发送MQ消息 - 异步发送库存变更消息到RabbitMQ
-
消费者处理 - MQ消费者监听队列,异步更新MySQL数据库
-
重试机制 - 更新失败自动重试,最多3次
六、实战案例:电商秒杀场景
6.1 业务背景
某电商平台准备举办一场iPhone 15 Pro秒杀活动:
活动参数:
- 商品数量:1000件iPhone 15 Pro
- 参与用户:预计10万用户同时抢购
- 性能要求:QPS要求50000+,响应时间<10ms
- 业务要求:不允许超卖,同一用户限购1件
6.2 技术架构设计
Redis分片架构:
分片数量:5个主节点,每个配置1个从节点
分片算法:productId % 5
Key命名:inventory:shard:{0-4}:product:{productId}:stock
Redis节点分布:
├── 分片0: 192.168.1.101:6379 (主) + 192.168.1.106:6379 (从)
├── 分片1: 192.168.1.102:6379 (主) + 192.168.1.107:6379 (从)
├── 分片2: 192.168.1.103:6379 (主) + 192.168.1.108:6379 (从)
├── 分片3: 192.168.1.104:6379 (主) + 192.168.1.109:6379 (从)
└── 分片4: 192.168.1.105:6379 (主) + 192.168.1.110:6379 (从)
应用服务器集群:
服务器数量:10台
配置:8核16G
JVM堆:8GB
线程池:500+
部署方式:Docker容器
负载均衡:Nginx
6.3 核心代码实现
限流组件:
@Component
public class RateLimiter {
// 每秒发放10000个令牌
private final RateLimiter limiter = GuavaRateLimiter.create(10000.0);
public boolean tryAcquire() {
return limiter.tryAcquire();
}
}
// Controller中使用
@PostMapping("/seckill/{productId}")
public Result seckill(@PathVariable Long productId, @RequestParam Long userId) {
// 第1步:限流
if (!rateLimiter.tryAcquire()) {
return Result.error("请求过于频繁,请稍后重试");
}
// 第2步:扣减库存
DeductRequest request = DeductRequest.builder()
.productId(productId)
.userId(userId)
.quantity(1)
.orderNo(generateOrderNo())
.build();
DeductResponse response = inventoryService.deduct(request);
if (response.isSuccess()) {
return Result.success("抢购成功");
} else {
return Result.error(response.getErrorMessage());
}
}
降级方案:
public DeductResponse deduct(DeductRequest request) {
try {
// 优先使用Redis扣减
return deductByRedis(request);
} catch (RedisConnectionException e) {
log.warn("Redis故障,降级到MySQL - {}", e.getMessage());
// Redis故障,降级到MySQL(串行扣减)
return deductByDB(request);
}
}
private DeductResponse deductByDB(DeductRequest request) {
// 使用分布式锁保证串行执行
String lockKey = "lock:product:" + request.getProductId();
try {
// 获取分布式锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 执行数据库扣减
return inventoryMapper.deductStock(request);
} else {
return DeductResponse.fail("系统繁忙,请稍后重试");
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
6.4 实施效果
性能对比:
| 指标 | 优化前(直接DB) | 优化后(Redis方案) | 提升 |
|---|---|---|---|
| QPS | 500 | 80000+ | 160倍 |
| 响应时间 | 200ms | 3ms | 98% |
| 超卖率 | 2% | 0% | 完美解决 |
| 成功率 | 85% | 99.9% | 17% |
| CPU使用率 | 95% | 40% | 降低58% |
| 数据库连接数 | 800/800 | 50/800 | 降低94% |
用户满意度提升:
- 页面加载时间从2秒降至<100ms
- 抢购成功率从85%提升至99.9%
- 客服投诉量下降90%
七、生产环境部署
7.1 Redis集群部署
Docker Compose部署:
version: '3.8'
services:
redis-node-0:
image: redis:7-alpine
container_name: redis-node-0
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
ports:
- "7000:6379"
volumes:
- redis-node-0-data:/data
redis-node-1:
image: redis:7-alpine
container_name: redis-node-1
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
ports:
- "7001:6379"
volumes:
- redis-node-1-data:/data
redis-node-2:
image: redis:7-alpine
container_name: redis-node-2
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
ports:
- "7002:6379"
volumes:
- redis-node-2-data:/data
redis-node-3:
image: redis:7-alpine
container_name: redis-node-3
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
ports:
- "7003:6379"
volumes:
- redis-node-3-data:/data
redis-node-4:
image: redis:7-alpine
container_name: redis-node-4
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
ports:
- "7004:6379"
volumes:
- redis-node-4-data:/data
volumes:
redis-node-0-data:
redis-node-1-data:
redis-node-2-data:
redis-node-3-data:
redis-node-4-data:
7.2 应用配置
application.yml:
server:
port: 8080
tomcat:
threads:
max: 500
min-spare: 50
spring:
redis:
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 10
max-wait: 1000ms
cluster:
nodes:
- 192.168.1.101:7000
- 192.168.1.102:7001
- 192.168.1.103:7002
- 192.168.1.104:7003
- 192.168.1.105:7004
max-redirects: 3
rabbitmq:
host: 192.168.1.201
port: 5672
username: admin
password: admin123
publisher-confirm-type: correlated
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
retry:
enabled: true
max-attempts: 3
datasource:
url: jdbc:mysql://192.168.1.301:3306/inventory?useUnicode=true&characterEncoding=utf8
username: root
password: root123
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 3000
idle-timeout: 600000
# 自定义配置
redis:
sharding:
node-count: 5
八、总结
本文详细介绍了在大流量场景下使用Redis分片缓存解决库存扣减数据库瓶颈的完整方案。