如何在高并发下减扣库存?
这是一个电商的常见场景,目前互联网大厂对其都有非常成熟的解决方案。我写这篇文章只是给出一些我自己的思考。
假设我们现在有一张库存的关系型表格:INVENTORY,其结构如下:
ID | PRODUCT_ID | STOCK |
---|---|---|
主键 | 商品唯一标识 | 库存(个) |
很显然,程序对于商品的库存减扣步骤应该是:
- 查询当前库存。
- 确认其减扣目标数量后库存仍然大于等于 0。
- 将减扣后的数据在表中
UPDATE
。
但是在这个过程中需要避免并发情况下多线程对同一个数据的修改发生冲突。在这里我们讨论四种解决方案,其中前三种依靠 MySQL 数据库本身完成,最后一种依靠 Redis 实现分布式锁。
乐观锁
可以使用原子性更新加上乐观锁的方式来防止减扣结果的错误。我们在 INVENTORY 表的基础上加上一个字段:VERSION 来标识版本,实现一个简单的乐观锁。
-- 先查询当前版本号
SELECT version, stock FROM INVENTORY WHERE product_id = #{product_id};
-- 带版本号的更新
UPDATE INVENTORY
SET
stock = stock - #{deduct_num},
version = version + 1
WHERE
product_id = #{product_id}
AND version = #{queried_version}
AND stock >= #{deduct_num};
在该方案中,需要考虑重试的机制。如果 SQL 执行失败要有限地重试。
悲观锁
我们可以使用非常严格的悲观锁来进行更新,但是这种方法的性能极低,高并发下也很容易导致数据库的死锁。总的来说不推荐该方法。
START TRANSACTION;
-- 加行级排他锁
SELECT * FROM INVENTORY WHERE product_id = #{product_id} FOR UPDATE;
UPDATE INVENTORY
SET stock = stock - #{deduct_num}
WHERE product_id = #{product_id};
COMMIT;
## 分段锁 上面悲观锁的方式性能太低,我们可以对此进行一定的优化。为 INVENTORY 表增加一个 BUCKET_ID 字段,把库存拆分为多个桶,在高并发负载均衡的情况下可以一定程度上减少冲突。
-- 将库存拆分为多个桶(bucket)
UPDATE INVENTORY
SET stock = stock - #{deduct_num}
WHERE
product_id = #{product_id}
AND bucket_id = #{hash(order_id)}
AND stock >= #{deduct_num}
Redis 分布式锁
加锁
SET lock_key unique_value NX PX 10000
- NX 表示只有当 lock_key 不存在的时候才加锁。
- PX 10000 表示设置过期时间为 10s。为了避免客户端发生异常或者宕机时无法释放锁。
解锁
在解锁时有两个操作:判断加锁的线程是不是自己和解锁(删除 KEY)。这时候我们使用 Lua 脚本来保证原子性。
if redis.call("get", KEYS[1]==ARGV[1]) then
return redis.call("del", KEYS[1])
else
return 0
end
一般来说还会设置一个 watch dog 的线程。该线程会每隔几秒就检查获取锁的线程是否还活着,如果活着则为锁延长过期时间。这是为了避免加锁进程未在过期时间内顺利完成任务。
总结
乐观锁。
本质上使用版本号来完成一次 CAS 的操作。
悲观锁。
使用
SELECT ... FOR UPDATE
语句来进行数据的锁定。实际上由于其性能较低且容易导致数据库死锁,并不会轻易使用该方法。分段锁。
将库存数据分成多个桶,每次减扣的时候随机选择一个桶的数据来进行减扣。需要定期库存合并检查。
Redis 分布式锁。
借助 Redis 实现一个分布式锁来避免线程并发的操作。要注意 Redis 中锁的释放以及过期时间的设定。
在实际的应用中其实会有多个方法的组合,借助消息队列进行削峰填谷的处理,以及进行预扣等操作来缩小执行窗口减少冲突等方案。