黑马点评
约 1083 字大约 4 分钟
2025-07-24
秒杀系统
在你的秒杀场景下,是如何解决超卖问题的?
在秒杀这种高并发场景下,库存的准确性至关重要,超卖问题是必须解决的核心挑战。解决超卖问题的关键在于,如何在高并发环境下对库存进行原子性的扣减操作。
我主要通过以下几种方式来综合解决:
前端层面拦截:
- 通过 JS 或前端页面,对秒杀按钮进行置灰处理。当用户点击一次后,按钮变为不可用,防止用户因网络延迟而重复点击,向后端发送大量无效请求。这是一种初步的流量削减手段。
后端数据库层面控制:
这是解决超卖问题的根本。主要利用数据库的事务和锁机制。
使用乐观锁 (Optimistic Locking):
原理:在商品库存表中增加一个
version
字段(版本号)。流程:
查询库存时,将
version
字段一同查出:SELECT stock, version FROM products WHERE id = ?
。在应用程序中判断库存是否足够。
如果库存足够,执行更新操作,更新条件中必须包含版本号:
UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock > 0 AND version = ?
。
效果:如果多个线程同时执行更新,只有第一个线程的
UPDATE
语句能成功(因为它的version
匹配),其他线程会因为version
不匹配而更新失败(返回影响行数为 0)。更新失败的线程可以进行重试或直接告知用户秒杀失败。优点:乐观锁避免了使用数据库的排他锁,减少了线程阻塞,在高并发读多写少的场景下性能较好。
使用悲观锁 (Pessimistic Locking):
原理:在事务中,通过
SELECT ... FOR UPDATE
语句对库存记录行加排他锁。流程:
SQL
START TRANSACTION; SELECT stock FROM products WHERE id = ? FOR UPDATE; -- 在应用代码中判断库存是否足够 -- 如果足够,则执行 UPDATE UPDATE products SET stock = stock - 1 WHERE id = ?; COMMIT;
效果:当一个事务通过
FOR UPDATE
锁住某行记录后,其他任何试图修改或锁定该行的事务都会被阻塞,直到前一个事务提交或回滚。这保证了同一时间只有一个线程能操作库存,从而避免了超卖。缺点:悲观锁会长时间持有锁,导致其他线程等待,降低了并发性能。在秒杀场景下,如果锁竞争激烈,可能会造成大量线程阻塞,甚至导致数据库连接池耗尽。
使用 Redis 原子操作:
为了进一步提升性能,可以将库存预热到 Redis 中,利用 Redis 的单线程特性和原子操作来扣减库存。
DECR
命令:系统启动时,将商品库存加载到 Redis 中,例如
SET stock:product_id 100
。用户秒杀时,执行
DECR stock:product_id
命令。DECR
是原子操作。如果执行后返回值大于等于0
,则表示抢购成功;如果小于0
,则表示库存已空,抢购失败。对于已经失败的请求,需要立即执行INCR
将库存加回来。
Lua 脚本:
为了将“判断库存并扣减”这个组合操作变为原子性,可以使用 Lua 脚本。
Lua
local stock = redis.call('get', KEYS[1]) if tonumber(stock) > 0 then redis.call('decr', KEYS[1]) return 1 else return 0 end
通过
EVAL
命令执行此脚本,可以保证整个操作的原子性,避免了DECR
后判断的非原子性问题。
在我的项目中,采用了**“Redis 预减库存 + 数据库乐观锁”**的组合方案:
流量入口层:使用 Redis 的原子操作(如 Lua 脚本)进行库存预减。绝大部分请求在 Redis 层就被过滤掉了,只有成功抢到资格的请求才能进入后续流程。
持久化层:成功通过 Redis 预减库存的请求,会进入创建订单的流程。在数据库层面,通过乐观锁来扣减真实库存,这作为最后一道防线,确保了数据库层面库存的最终准确性,并解决了 Redis 可能因宕机导致的数据丢失问题。