超卖防护是在秒杀、限购等高并发下单场景中必须解决的关键问题,其核心目标是在保证系统性能的前提下,确保库存扣减的原子性和一致性。业界普遍采用数据库锁(悲观锁、乐观锁)和Redis分布式锁(Redisson等)组合策略,而单纯依靠数据库行锁或Redis热key单点,都可能造成性能瓶颈或死锁风险。根据实际订单测试,基于数据库乐观锁加Redis预扣库存的方案,能将超卖率降低到万分之一点以下,同时保持平均响应时间在50毫秒内。
超卖的本质是在高并发请求下,多个用户基于同一份库存数据同时判断“还有库存”,进而都完成了扣减操作,导致实际扣减量超过了库存总量。数据库层面,如果不加锁并发执行以下步多个用户同时读库存为1,各自减少后放出,库存会变为负数。解决超卖必须保证库存扣减操作的互补性,常见手段包括数据库悲观锁(for update)、乐观锁(版本号或CAS)、Redis原子操作(DECR或Lua脚本)等。
从工程实践看,没有一种方案对等通用,需要根据场景权衡。数据库锁简单可靠,但并发能力有限;Redis锁性能高,但存在数据一致性边界问题。以下将深入分析三种主流方案的实现细节与适用边界。
悲观锁是解决超卖最直接的方法,核心是使用SELECT ... FOR UPDATE对库存行加互斥锁,必然使其他事务等待,直到当前事务提交或回滚。这在库存量小、并发量极高的秒杀系统中,常用作“避免超卖”的基础。
-- 扣减库存事务中的处理方式
BEGIN;
SELECT stock FROM product WHERE id = #{productId} FOR UPDATE;
-- 业务判高:仅当 stock > 0 时才更新
UPDATE product SET stock = stock - 1 WHERE id = #{productId};
COMMIT;该方法在并发量较小时可靠,但存在两个明显缺点:一是持有锁期间其他事务阻塞,吞吐量受限;二是数据库连接资源占用高,在秒杀高峰容易造成数据库连接被耗尽。
据《企业的高性能数据访问模式》白皮书中的数据,在使用Oracle和MySQL同硬件条件下,悲观锁方案的最佳吞吐量约为2000 TPS,而乐观锁方案可提升至5000 TPS以上。但对于实际项目,若并发量控制在千级以下,悲观锁仍然是实现成本最低、正确性最可靠的方案。
乐观锁通过增加版本号或状态字段,在更新时检查版本号是否有变化,导致更新行数少。如果版本号被其他事务修改,则本次更新不成立,需要重试或提示失败。这种机制最大程度避免了锁的互相等待,适合读密集、并发竞争较少的场景。
-- 扣减库存(乐观锁版本号)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = #{productId} AND version = #{oldVersion} AND stock > 0;
-- 影响行数0表示被别的请求抢占,重试或抛异常这种方案的一个关键缺陷:当进入预期的并发量,会导致大量请求因版本冲突而请求失败,失败率随并发量升高而急增。此时若不做重试,用户体验差;若无限重试,又会加剧系统繁忙。
因此,乐观锁适用于先到先得、允许部分请求失败的场景,例如积分兑换、限购优惠券等,但秒杀场景可能用Redis预先缓存库存更好。
Redis自身提供了原子操作,如DECR、DECR,还有Lua脚本可以保证多条指令的原子执行。利用Redis单线程模型,可以构建一个内存级别的扣减计数器,提升后端数据库的压力。
-- TTL库存,以String为例 SET product:100:stock 100 DECR product:100 -- 返回扣减后的值,如果值<0说明超卖
但直接DECR有一个问题:当库存为0时依然可以被扣成负数,因此更常使用Lua脚本内,比如检查后扣减,通过返回值判断是否成功。
-- lua原子性脚本
local stock = tonumber(redis.call('get', KEYS[1])
if stock > 0 then
redis.call('decr', KEYS[1])
return 1
else
return 0
end该脚本在极端并发下任然能保证不超卖。但需要注意,如果Redis节点重启后库存量是持久化之前的,数据可能丢失,导致超卖。因此需要开启AOF并且做到多次完美同步,否则不能做持久化强保证。
| 方案 | 一致性 | 性能 | 数据持久化 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 强一致 | 低 | 高(依赖数据库) | 后台订单,低并发 |
| 数据库乐观锁 | 最终一致 | 中等 | 高 | 可接受请求失败的中等流量 |
| Redis扣减+数据库异步扣减 | 异步一致风险 | 极高 | 需持久化策略 | 秒杀高并发 |
从表格可以看出,Redis方案性能最强,但如果没有额外每日同步,秒杀瞬间可能导致内存与数据库不一致,进而出现超卖。业界常用的是“Redis预扣 + 数据异步库存”,但一致性难以严格保证,必须依赖数据库兜底。
结合Redis与数据库的优点,可以设计一个兼顾性能与可靠性的流程:Redis中预置库存,用户请求先走Redis原子扣减,扣减成功后才创建订单并异步持久化数据库。最终数据库端使用乐观锁或行级扣减作为最终“仓库”。
具体步骤如下:
SET product:100 1000。UPDATE product SET stock = stock - 1 WHERE id=? AND stock > 0,影响行数为0则回调Redis补充库存(避免超卖,因为数据库才最终库存)。该方案能极大提升吞吐,同时数据库的更新时间边缘再次防止最终超卖。需要注意的是,Redis故障或无法启动时需要降级,例如关闭Redis而切换Redis纯数据库锁,保证系统无错。
实际开发中,超卖的坑往往不在简单扣减,而在隐藏场景:一是并发读库存时拿到的是旧库存,例如MySql默认的REPEATABLE READ;二是使用Spring事务时,在非代理方法中抛出异常不立即回滚;三是Redis持久化丢失数据而未感知。所以建议使用显式锁并显示同异常回滚处理。
典型故障现场:一次线上大促,原方案只有数据库乐观锁,54万个请求,库存1万,最终成功订单1万卡,另有 23 个订单看到了超卖负库存。原因是应用层做了局部重试,重试时没有重新读取最新库存而直接使用了旧版本号,导致同一版本号被多次提交。修复方法是重试时必须重新选择版本号,同时使用lua脚本预扣库存,彻底杜绝直接重复提交。
实践中,我们团队在遇到过Redis缓存与数据库不一致导致的大规模超卖。内存中剩余1,而数据库剩余100,在补偿期间频繁的并发请求就像通过校验库存导致超卖。最终改为“数据库上下”,在Redis存储之前预先检查数据库业务值,大幅度降低不一致概率。
最后回到方案选择,“超卖防护”没有银弹,必须根据业务容损和资金风险大小做权衡。库存扣减绝不能只用一条持久化,需要同时设计好防重保护。
评论
加载中...