图解商品超卖的解决方案

1,061 阅读6分钟

背景

商品超卖问题在电商平台中尤为常见,尤其在秒杀、促销等高并发场景下,用户下单的速度远快于系统更新库存的速度,可能导致库存被超卖。

本文将详细探讨几种常见的商品超卖解决方案。

解决方案

假设我们有一个商品库存表 inventory,表结构如下:

CREATE TABLE inventory (
  product_id INT PRIMARY KEY,
  stock INT,
  version INT
);

1 update 语句限制

如果你不需要先查询商品信息(如库存),那么可以直接进行 update 操作扣减库存,但是必须通过设置数据库库存字段 stockunsign 类型,让其无法为负数,可直接执行:

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 库存,实现库存同步。

总结

商品超卖问题是电商平台中面临的一个重要挑战。不同的解决方案各有优缺点,选择适合的方案取决于系统的具体场景和需求。

在高并发场景下,分布式锁和队列削峰是较为通用且有效的方案,而悲观锁、乐观锁和数据库事务适合在特定条件下使用。

通过结合多种方案,开发者可以有效地避免商品超卖问题,提高系统的稳定性和用户体验。