背景
商品超卖问题在电商平台中尤为常见,尤其在秒杀、促销等高并发场景下,用户下单的速度远快于系统更新库存的速度,可能导致库存被超卖。
本文将详细探讨几种常见的商品超卖解决方案。
解决方案
假设我们有一个商品库存表 inventory,表结构如下:
CREATE TABLE inventory (
product_id INT PRIMARY KEY,
stock INT,
version INT
);
1 update 语句限制
如果你不需要先查询商品信息(如库存),那么可以直接进行 update 操作扣减库存,但是必须通过设置数据库库存字段 stock 为 unsign 类型,让其无法为负数,可直接执行:
UPDATE inventory
SET stock = stock - 1
WHERE product_id = ?;
如果库存为 0 了,那么是会执行失败抛出错误的,这样就避免了超卖的情况。
2. 悲观锁+数据库事务
前面举的第一个方案,是当业务逻辑不需要先查询商品信息的情况,那如果需要先查询商品信息呢?可以使用下面方案。
方案概述:
悲观锁假设最坏情况,即在读数据时总会有其他线程来修改数据,因此每次操作前都会加锁,以确保操作期间数据不会被其他线程修改。数据库级别的行锁、表锁通常就是悲观锁的典型应用。
流程:
- 用户A和用户B同时请求购买商品。
- 用户A通过开启数据库事务,查询商品并锁定商品的库存行:
SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE;
- 用户 A 更新库存:
UPDATE inventory SET stock = stock - 1 WHERE product_id = ?;
- 用户 B 因为无法获取锁,需等待或失败(注意,这里可能会导致长事务,拖累性能):
SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE;
优缺点:
- 优点:保证数据的绝对一致性,避免并发修改引起的超卖问题。
- 缺点:在高并发场景下,可能会引发锁竞争严重和长数据库事务,会导致系统响应缓慢。
3. 乐观锁
方案概述:
乐观锁与悲观锁相反,它假设并发冲突不会发生,因此在操作数据时不会加锁。每次更新数据时,都会检查数据在此期间是否被修改过。如果数据被修改过,则认为更新失败,需要重新操作。
实现流程:
- 用户发起订单请求。
- 系统读取库存数据,并记录版本号或时间戳。
- 在操作库存数据时,检查记录的版本号或时间戳是否未改变。
- 如果未改变,执行库存扣减操作;否则返回失败,提示用户重新尝试。
假设用户A和用户B同时请求购买同一商品。
- 用户A读取库存和版本号:
SELECT stock, version FROM inventory WHERE product_id = ?;
- 用户A尝试更新库存:
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = ? AND version = ?;
- 用户B尝试更新库存,如果版本号已经变更,则更新失败:
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = ? AND version = ?; // version变更了,这里找不到对应version,修改失败
优缺点:
- 优点:不用开启事务,不用加写锁,性能较高,适合读多写少的场景。
- 缺点:在高并发场景下,冲突较多时会导致大量重试,影响用户体验。
4. 分布式锁(Redis)
方案概述:
在分布式环境中,传统的数据库锁往往无法满足要求。这时,可以使用分布式锁来控制并发访问。
Redis 是常用的分布式锁实现工具,通过 SETNX(set if not exist)命令可以实现简单而有效的分布式锁。这是利用了 redis setnx 命令的单线程执行的特性,确保所有操作在同一时刻,只有一个操作在执行。
实现流程:
- 用户发起订单请求。
- 服务器通过 Redis 向指定的商品库存申请锁。
- 如果获取锁成功,服务器执行库存扣减操作;否则返回失败。
- 完成操作后,释放锁。
比如,用户A和用户B同时请求购买限量商品。
- 系统先为请求竞争一把锁,使用
setnx命令,若成功,则说明申请到锁了。由于单线程执行的原因,只能有一个用户持有锁,在锁没有释放期间,其他用户无法拿到锁。
SETNX inventory:product_id:reserved 1
- 拿到锁,去修改数据库,扣减库存:
UPDATE inventory SET stock = stock - 1 WHERE product_id = ?;
- 扣减库存后,删除锁:
DEL inventory:product_id:reserved
优缺点:
- 优点:可以很好地解决分布式系统下的超卖问题,性能优异。
- 缺点:需要注意锁的过期时间与防止死锁的处理。
5. 队列削峰
方案概述:
在高并发场景下,系统可以通过消息队列将用户请求按顺序排队处理,避免瞬时高并发对数据库造成压力。这个方法被称为削峰填谷,适合在流量高峰时保护后端服务。
实现流程:
- 用户发起订单请求。
- 系统将请求放入消息队列。
- 工作线程按顺序从队列中取出请求,依次处理库存扣减操作。
- 完成后将结果返回给用户。
优缺点:
- 优点:可以有效降低高并发对系统的冲击,保护数据库。
- 缺点:由于操作是异步的,用户可能需要等待较长时间才能得到响应。
6 提前将库存写入 Redis 的方式
方案概述:
利用 redis 单线程执行的特性,将库存数量事先放进 redis 中,key 为商品 id,value 为对应的库存数量。
等到请求来了,就先去扣减 redis 中对应商品 id 的库存,成功则返回下单成功给用户,否则失败。最后再去扣减 Mysql 库存,实现库存同步。
总结
商品超卖问题是电商平台中面临的一个重要挑战。不同的解决方案各有优缺点,选择适合的方案取决于系统的具体场景和需求。
在高并发场景下,分布式锁和队列削峰是较为通用且有效的方案,而悲观锁、乐观锁和数据库事务适合在特定条件下使用。
通过结合多种方案,开发者可以有效地避免商品超卖问题,提高系统的稳定性和用户体验。