在高并发电商或金融系统中,库存扣减是技术架构里最敏感的一环。直接操作数据库的UPDATE语句,在流量峰值时很容易因为行锁竞争导致性能急剧下降,甚至引发超卖。很多人第一时间想到用Redis来抗住并发,但简单的Redis读写操作并不能天然保证原子性。如果在Redis里先GET库存,判断充足后再SET回去,这个“读-判断-写”的间隙在高并发下就是超卖的根源。真正能在Redis层面安全、高效完成库存扣减的方案,是借助Lua脚本实现的原子操作。

为什么简单的Redis操作无法保证库存安全

假设我们在Redis里存了一个键stock:1001,值为100。常规思维是:先用GET获取当前库存,如果大于0,就用DECR或者SET减去购买数量。问题在于,这两个命令是分开执行的。当100个请求几乎同时到达,它们可能都读到了库存为1,然后都判定可以扣减,最终导致库存变成负数。这不是Redis的缺陷,而是任何非原子化“检查后执行”模式的通病。Redis本身是单线程执行命令的,但多个命令之间会插入其他客户端的命令,所以必须把“判断”和“扣减”打包成一个不可分割的单元。

Lua脚本如何实现原子库存扣减

Redis从2.6版本开始内置了Lua解释器,通过EVAL命令可以执行一段脚本。在执行脚本期间,Redis不会处理其他任何命令,整个脚本就像一个原生的原子命令。这意味着我们可以把库存查询、数量判断、实际扣减全部写进一段Lua代码里,一次性发送给Redis执行。这样就不存在并发窗口,从根本上杜绝了超卖。

一个典型的原子扣减脚本逻辑是这样的:先通过KEYS[1]获取传入的库存键,再用ARGV[1]获取扣减数量。调用redis.call('GET', key)拿到当前库存值,转为数字后判断是否大于等于扣减数量。如果满足条件,就调用redis.call('DECRBY', key, quantity)执行扣减并返回剩余库存;如果不满足,直接返回-1表示库存不足。整个过程没有停顿,没有上下文切换,天然线程安全。

-- 原子扣减库存脚本
local key = KEYS[1]
local quantity = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', key) or 0)

if current >= quantity then
    return redis.call('DECRBY', key, quantity)
else
    return -1
end

这段脚本的妙处在于,它把业务判断下沉到了Redis服务端。客户端只需要提交键和数量,不再需要先读后写,也不需要分布式锁。在高并发场景下,分布式锁会引入额外的锁竞争和超时管理成本,而Lua脚本的方案几乎没有额外开销,执行时间通常在微秒级别。

处理库存键不存在的情况

线上环境里,库存键可能因为过期、未初始化或者误删而不存在。如果直接执行GET,返回的是false,在Lua里转为数字就是0,扣减会失败。这符合“宁可少卖不可超卖”的原则。但有时候业务上需要更精细的处理,比如区分“库存不足”和“商品不存在”。我们可以让脚本在键不存在时返回-2,在库存不足时返回-1,扣减成功返回剩余数量。这样调用方可以根据返回值做不同的业务处理,比如触发告警或者异步初始化库存。

local key = KEYS[1]
local quantity = tonumber(ARGV[1])
local exists = redis.call('EXISTS', key)

if exists == 0 then
    return -2  -- 键不存在
end

local current = tonumber(redis.call('GET', key))
if current >= quantity then
    return redis.call('DECRBY', key, quantity)
else
    return -1  -- 库存不足
end

这种多返回值的约定让脚本具备了更强的业务表达能力,也方便在监控系统中区分不同异常类型。

脚本缓存与EVALSHA优化

每次扣减都通过EVAL命令发送完整脚本,网络传输开销会随着脚本长度增加而变大。Redis提供了SCRIPT LOAD命令,可以把脚本预先加载到服务端并返回一个SHA1摘要,之后用EVALSHA加摘要来执行。这样客户端只需要传递40字节的哈希值和参数,带宽消耗大幅降低。在实际生产环境中,通常在应用启动时就把脚本加载好,运行时只用EVALSHA调用。如果因为Redis重启导致脚本缓存丢失,会收到NOSCRIPT错误,此时再回退到EVAL重新注册即可。

很多Redis客户端库已经封装了这个逻辑。比如Java的Jedis和Lettuce,Python的redis-py,都支持传入脚本字符串,由客户端自动管理缓存和重试。但理解这个机制有助于排查线上偶发的NOSCRIPT异常,也能在自研组件时做出更合理的设计。

带版本号的库存扣减与CAS模式

在某些场景下,库存扣减需要配合版本号或时间戳来防止ABA问题。比如运营人员在后台修改了库存,而一个旧的扣减请求还在路上,就可能基于过时的数据进行操作。我们可以在库存键之外再维护一个版本键,或者使用Hash结构同时存储库存量和版本号。脚本在执行时检查传入的版本号是否与当前版本一致,只有匹配时才执行扣减并更新版本。

local key = KEYS[1]
local version_key = KEYS[2]
local quantity = tonumber(ARGV[1])
local expected_version = tonumber(ARGV[2])

local current_version = tonumber(redis.call('GET', version_key) or 0)
if current_version ~= expected_version then
    return -3  -- 版本冲突
end

local current_stock = tonumber(redis.call('GET', key) or 0)
if current_stock >= quantity then
    redis.call('DECRBY', key, quantity)
    redis.call('INCR', version_key)  -- 更新版本号
    return current_stock - quantity
else
    return -1
end

这种CAS(Compare And Swap)模式在需要严格一致性的业务中很有价值,比如秒杀活动里防止因为缓存回滚或人工干预导致的超卖。代价是多了一个键的读写和版本管理逻辑,性能依然很高,但设计复杂度有所上升。

扣减与订单记录的联动

有些系统要求库存扣减的同时记录一条预订单信息,方便后续对账。理论上可以把写订单的操作也放进Lua脚本,但这样做会让脚本变得臃肿,而且Redis不是持久化存储的最佳选择。更合理的做法是:脚本只负责原子扣减并返回结果,业务层拿到成功结果后,再异步写入数据库或消息队列。如果后续写库失败,可以发起补偿流程把库存加回去。这种“先扣Redis,后异步持久化”的模式在互联网大厂中非常普遍,兼顾了性能和数据最终一致性。

补偿脚本同样需要原子化,避免加库存时出现并发问题。可以直接复用扣减脚本的思路,只是把DECRBY换成INCRBY,并且通常不需要判断上限。但要注意补偿的幂等性,防止重复回滚导致库存虚高。

多库存池与批量扣减

电商场景里经常有多个仓库共享一个SKU的库存,用户下单时需要按一定策略从不同仓库扣减。这可以通过一次Lua脚本操作多个键来实现。比如传入多个库存键和对应的扣减数量,脚本先检查所有仓库的库存是否都充足,如果任意一个不足就整体失败,全部充足则逐个扣减。这种多键事务在Lua脚本里是天然原子的,避免了跨键操作的部分成功问题。

批量扣减的另一个应用是购物车结算。用户一次购买多种商品,每种商品扣减不同数量。可以把所有商品ID和数量作为参数传入,脚本遍历执行,任何一步失败就返回错误并终止。虽然Redis单线程执行,但Lua脚本里应避免过长的循环,否则会阻塞其他命令。对于一次几十个商品的批量操作,执行时间仍然可控;如果商品数量上百,建议分批执行或拆解为多个小脚本。

限流与防刷的脚本化实现

库存扣减的Lua脚本还可以顺带实现限流逻辑。比如同一个用户一秒内只能发起一次扣减请求,可以在脚本里检查用户频率键。如果频率键存在就拒绝扣减,否则设置频率键的过期时间并执行扣减。这种组合操作如果放在业务层,需要多次Redis调用,而在Lua里一次完成,既简洁又高效。防刷和库存保护合二为一,特别适合秒杀场景下的恶意请求拦截。

监控与告警的关键指标

上线Lua脚本扣减方案后,需要关注几个核心指标。一是脚本执行耗时,可以通过Redis的SLOWLOG监控,正常应该在1毫秒以内。二是EVALSHA的NOSCRIPT错误次数,如果频繁出现说明脚本缓存管理有问题。三是库存扣减返回-1和-2的比例,前者代表正常售罄,后者可能暗示数据初始化异常。四是Redis的内存使用和键数量,避免版本键或频率键无限制增长。这些指标接入监控系统后,可以第一时间发现脚本逻辑缺陷或资源瓶颈。

与分布式锁方案的对比

有人可能会问,用分布式锁保护库存读写不也能解决并发问题吗?确实可以,但分布式锁在高并发下有明显的性能代价。获取锁需要额外的网络往返,锁的续期和释放逻辑复杂,一旦出现死锁或锁过期,可能导致库存数据错乱。Lua脚本的方案不需要锁,执行路径更短,吞吐量更高。在真实的压测对比中,Lua原子扣减的QPS通常是分布式锁方案的3到5倍。除非业务逻辑极其复杂、必须跨多个外部系统协调,否则优先选择Lua脚本是更优解。

生产环境部署的注意事项

首先,Redis的持久化策略要配合好。RDB快照可能会丢失最近几秒的数据,如果Redis突然宕机,内存中的库存状态可能回退。对于库存这种核心数据,建议开启AOF持久化并设置为everysec模式,在性能和安全之间取得平衡。其次,脚本里不要使用随机数或获取当前时间等非确定性命令,否则会导致主从复制不一致。Redis在执行脚本时会禁止这类命令,如果强行使用会报错。最后,做好降级预案。当Redis不可用时,系统应能自动切换到数据库扣减或直接拒绝服务,避免雪崩。

Redis Lua原子库存扣减是一个经过大规模生产验证的成熟方案。它用极小的复杂度换来了极高的并发安全性,是高性能库存系统的基石。理解其原理和边界,能帮助开发者在面对流量洪峰时更有底气,也能在面试和系统设计中展现出扎实的工程能力。