1. 高并发场景下的库存扣减难题
在电商、秒杀等业务场景中,库存管理是最核心也是最容易出现问题的环节之一。最近我在处理一个线上促销活动时,遇到了典型的"Redis库存扣减成功但数据库更新失败"的分布式事务问题。当QPS突破5000时,系统开始出现库存不一致的情况——Redis中的库存数已经减少,但数据库中的实际库存却没有变化。
这种情况如果持续发生,会导致严重的超卖问题。想象一下,1000个用户成功抢购了商品,但由于数据库更新失败,实际库存只减少了100个,这意味着有900个订单将无法履约。这不仅会造成经济损失,更会严重影响用户体验和平台信誉。
2. 技术方案选型与对比
2.1 常见解决方案分析
面对这个问题,业界通常有几种解决方案:
-
同步双写方案:
- 先更新数据库,再更新Redis
- 优点:强一致性
- 缺点:数据库压力大,性能瓶颈明显
-
异步消息队列:
- 通过消息队列保证最终一致性
- 优点:解耦,吞吐量高
- 缺点:实现复杂,延迟较高
-
Redis事务方案:
- 使用Redis事务保证原子性
- 优点:性能好
- 缺点:无法跨数据源
-
分布式事务方案:
- 如TCC、SAGA模式
- 优点:强一致性
- 缺点:实现复杂,性能损耗
2.2 最终方案选择
经过性能测试和业务评估,我们选择了Redis+Lua+本地消息表的混合方案。这个方案的核心思想是:
- 使用Redis+Lua保证原子扣减
- 通过本地消息表实现最终一致性
- 引入定时任务进行数据校对
3. 核心实现细节
3.1 Redis库存扣减实现
我们使用Lua脚本保证原子性操作:
lua复制-- KEYS[1]: 库存key
-- ARGV[1]: 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock >= tonumber(ARGV[1]) then
return redis.call('DECRBY', KEYS[1], ARGV[1])
else
return -1
end
PHP调用示例:
php复制$script = <<<LUA
...上述Lua脚本...
LUA;
$sha = $redis->script('load', $script);
$result = $redis->evalSha($sha, ['inventory:product_123'], [1]);
3.2 数据库更新与回滚机制
当Redis扣减成功后,我们需要确保数据库也能成功更新。这里采用本地消息表方案:
- 创建本地消息表:
sql复制CREATE TABLE inventory_transaction (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id VARCHAR(64) NOT NULL,
quantity INT NOT NULL,
status TINYINT DEFAULT 0 COMMENT '0-待处理,1-已处理,2-已回滚',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_product (product_id),
INDEX idx_status (status)
);
- PHP业务逻辑:
php复制// 开启数据库事务
$db->beginTransaction();
try {
// 1. 记录事务消息
$db->insert('inventory_transaction', [
'product_id' => $productId,
'quantity' => $quantity
]);
// 2. 更新数据库库存
$affected = $db->update(
'products',
['stock' => new Expression('stock - ?', [$quantity])],
['id' => $productId, 'stock >= ?' => $quantity]
);
if ($affected === 0) {
throw new Exception('库存不足');
}
// 3. 标记事务完成
$db->update('inventory_transaction',
['status' => 1],
['product_id' => $productId]
);
$db->commit();
} catch (Exception $e) {
$db->rollBack();
// 触发Redis库存回滚
$redis->incrBy("inventory:$productId", $quantity);
throw $e;
}
4. 异常处理与数据一致性保障
4.1 定时补偿任务
为了防止极端情况下消息丢失,我们设置了定时任务进行数据校对:
php复制// 每分钟执行一次
$transactions = $db->select(
'inventory_transaction',
['id', 'product_id', 'quantity'],
['status' => 0, 'created_at < ?' => date('Y-m-d H:i:s', time() - 60)]
);
foreach ($transactions as $tx) {
try {
$db->beginTransaction();
// 尝试更新数据库库存
$affected = $db->update(
'products',
['stock' => new Expression('stock - ?', [$tx['quantity']])],
['id' => $tx['product_id'], 'stock >= ?' => $tx['quantity']]
);
if ($affected > 0) {
$db->update('inventory_transaction',
['status' => 1],
['id' => $tx['id']]
);
} else {
// 库存不足,回滚Redis
$redis->incrBy("inventory:{$tx['product_id']}", $tx['quantity']);
$db->update('inventory_transaction',
['status' => 2],
['id' => $tx['id']]
);
}
$db->commit();
} catch (Exception $e) {
$db->rollBack();
// 记录日志,下次重试
}
}
4.2 监控与告警
我们建立了完善的监控体系:
- Redis与数据库库存差异监控
- 长时间未完成的事务监控
- 补偿任务执行情况监控
当发现异常时,会立即触发告警,便于人工介入处理。
5. 性能优化实践
在高并发场景下,我们还做了以下优化:
- Redis分片:将库存数据分散到多个Redis实例,减轻单节点压力
- 库存预热:活动开始前将库存加载到Redis
- 本地缓存:在PHP层增加本地缓存,减少Redis访问
- 批量处理:对补偿任务进行批量处理,提高效率
6. 踩坑经验分享
在实际落地过程中,我们遇到了几个典型问题:
-
Lua脚本超时:
- 问题:当Redis负载高时,Lua脚本执行可能超时
- 解决:简化脚本逻辑,设置合理的超时时间
-
补偿任务雪崩:
- 问题:大量失败事务同时触发补偿,导致系统过载
- 解决:引入随机延迟,错峰执行
-
ABA问题:
- 问题:库存先减后加,可能导致数据不一致
- 解决:使用版本号控制,确保操作的顺序性
-
监控遗漏:
- 问题:初期监控不完善,问题发现不及时
- 解决:建立多维度的监控体系
这套方案经过多次大促考验,在QPS超过1万的场景下仍能保持数据一致性。核心在于:
- Redis保证高性能扣减
- 本地消息表确保可靠性
- 补偿机制处理异常情况
- 完善监控快速发现问题