资讯动态

Redis Lua脚本实现计数范围内原子级增减:高并发库存扣减与限流实战

发布时间:2026/10/17 3:04:13 来源:尧图企业网站定制
1. 从一次库存扣减事故说起为什么普通Redis命令扛不住高并发去年帮一个做电商秒杀的朋友排查线上问题场景很典型某款限量商品开抢后后台日志里出现了库存扣成负数的情况明明代码里写的是先查库存、再判断、再扣减逻辑上看起来天衣无缝结果超卖了三十多单。他一开始怀疑是数据库事务没加对折腾了半天最后定位到问题出在Redis这一层——他用的是GET拿到库存值在应用层判断大于0再DECR。这三步之间隔着网络往返高并发下几十个请求同时读到同一个库存值全都判断通过然后一起扣减超卖就这么发生了。这个坑其实非常经典。很多人对Redis的认知停留在单线程所以原子这个层面但单条命令原子不等于多条命令组合原子。GET和DECR各自都是原子的可它们之间的间隙就是并发请求的窗口期。要堵住这个窗口要么用WATCHMULTI的事务机制但高并发下重试率极高性能很差要么用INCR/DECRBY这类自带判断能力的命令但只能做简单增减没法做范围约束要么——也就是今天要聊的主角——用Lua脚本把读-判断-写打包成一个原子操作。Redis从2.6版本开始支持Lua脚本核心价值就在于服务端把整个脚本当作一个命令来执行执行期间不会被其他命令打断。这意味着你可以在脚本里写任意复杂的逻辑——范围判断、条件分支、多key操作——而不用担心并发穿插。对于计数范围内原子级增减这个需求Lua脚本几乎是标准答案。这篇文章适合谁看如果你正在做秒杀库存、API限流、积分增减、任务配额、抽奖次数控制这类有上下界约束的计数场景并且被并发问题折磨过那这篇内容应该能帮你少走不少弯路。我会从原理讲到实操把踩过的坑和调优经验都摊开说。2. 拆解计数范围内原子级增减到底难在哪2.1 三个必须同时满足的约束条件把需求翻译成技术语言其实是三个约束的叠加原子性读当前值、判断是否在范围内、执行增减、返回结果这一整套动作不能被其他请求打断。范围约束计数不能超过上限比如库存不能超卖也不能低于下限比如积分不能扣成负数。高并发可用在几千甚至几万QPS下不能因为锁竞争导致大量请求失败或超时。单独看每个约束都不难难的是三者同时满足。用数据库行锁能保证原子性和范围约束但高并发下数据库连接池和锁等待会成为瓶颈用Redis的WATCH乐观锁能保证原子性但冲突重试在高并发下会雪崩用分布式锁比如SETNX能串行化操作但锁的获取释放本身有开销而且锁粒度不好控制。2.2 为什么Lua脚本能同时满足这三个约束Redis执行Lua脚本时会把脚本作为一个整体放入执行队列在脚本执行完成之前不会处理其他客户端的任何命令。这个特性直接解决了原子性问题——不需要加锁因为Redis本身就是单线程处理命令的脚本执行期间天然独占。范围约束在脚本里就是普通的if判断因为整个脚本是原子的判断和后续的写操作之间不存在并发窗口。至于高并发可用性Lua脚本在服务端执行省去了多次网络往返而且没有锁竞争理论上性能只受限于Redis单线程的处理能力。不过这里有个容易误解的点Lua脚本的原子性是以阻塞其他命令为代价的。如果你的脚本逻辑很重、执行时间很长会拖慢整个Redis实例的响应。所以脚本要尽量精简避免在脚本里做循环遍历大集合这类操作。2.3 一个常见的错误认知以为INCR能搞定一切有人会说库存扣减直接用DECR不就行了DECR确实是原子的但它没有范围判断。库存是1的时候两个请求同时DECR结果就是-1。你可能会想那用DECR之后判断返回值小于0再INCR回去——但这又变成了两步操作中间还是有窗口而且回滚逻辑在并发下会乱套。还有人用INCR配合EXPIRE做限流思路是对的但一旦需要超过阈值就拒绝这种逻辑单纯INCR就不够了因为你需要先知道当前值再决定是否递增。这时候Lua脚本的优势就体现出来了在脚本里先GET判断再决定INCR还是直接返回失败整个过程原子完成。3. Lua脚本实现原子增减的完整写法与逐行解读3.1 基础版本带上下界的原子增减脚本先上一个最核心的脚本实现在[min, max]范围内原子增减越界则拒绝并返回当前值-- KEYS[1]: 计数key -- ARGV[1]: 增减量正数为增负数为减 -- ARGV[2]: 下限 -- ARGV[3]: 上限 -- ARGV[4]: 初始值key不存在时使用 local key KEYS[1] local delta tonumber(ARGV[1]) local minVal tonumber(ARGV[2]) local maxVal tonumber(ARGV[3]) local initVal tonumber(ARGV[4]) -- 如果key不存在用初始值初始化 local current redis.call(GET, key) if not current then current initVal else current tonumber(current) end local target current delta -- 范围判断 if target minVal or target maxVal then return {0, current} -- 0表示失败返回当前值 end -- 执行写入 redis.call(SET, key, target) return {1, target} -- 1表示成功返回新值这个脚本看起来简单但每一行都有讲究。tonumber是必须的因为从ARGV拿到的都是字符串不转换的话current delta会变成字符串拼接。redis.call(GET, key)在key不存在时返回falseLua里的false对应Redis的nil所以用if not current判断。返回值用了一个table{状态码, 值}这是Lua脚本和客户端交互的常用模式。状态码1表示成功0表示被范围拒绝。客户端拿到后可以根据状态码决定后续业务逻辑。3.2 为什么用SET而不是INCRBY你可能会问既然已经算出target了为什么不直接INCRBY key delta原因是如果key不存在INCRBY会从0开始算而不是从我们指定的初始值开始。比如初始值设的是100key不存在时INCRBY -1会得到-1而不是99。用SET直接写入计算好的target逻辑更可控。当然如果你的场景初始值就是0那用INCRBY也没问题还能省一次GET。但为了通用性我倾向于用SET。3.3 客户端调用的关键细节以Python的redis-py为例调用方式如下import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) script local key KEYS[1] local delta tonumber(ARGV[1]) local minVal tonumber(ARGV[2]) local maxVal tonumber(ARGV[3]) local initVal tonumber(ARGV[4]) local current redis.call(GET, key) if not current then current initVal else current tonumber(current) end local target current delta if target minVal or target maxVal then return {0, current} end redis.call(SET, key, target) return {1, target} # 注册脚本返回sha1值 sha r.script_load(script) # 执行库存从100减1范围[0, 100] result r.evalsha(sha, 1, stock:item:1001, -1, 0, 100, 100) print(result) # [1, 99] 表示成功新值99这里有个性能优化点用EVALSHA而不是EVAL。EVAL每次都要把整个脚本内容传到Redis脚本长了会占用大量带宽。EVALSHA只传40位的sha1值Redis根据sha1查找缓存的脚本。第一次用SCRIPT LOAD加载后后续都用EVALSHA。但EVALSHA有个坑如果Redis重启或者脚本被SCRIPT FLUSH清掉了EVALSHA会报NOSCRIPT错误。生产环境必须处理这个异常捕获后回退到EVAL重新加载。redis-py的register_script方法会自动处理这个回退逻辑推荐用它from redis import Redis r Redis(hostlocalhost, port6379, decode_responsesTrue) # register_script返回一个可调用对象自动处理NOSCRIPT atomic_incr r.register_script(script) result atomic_incr(keys[stock:item:1001], args[-1, 0, 100, 100])3.4 参数传递的边界情况有几个边界情况必须处理delta为0脚本会正常执行返回当前值状态码1。如果业务上不允许0需要在客户端拦截。minVal大于maxVal这是配置错误脚本会永远返回失败。建议在客户端做参数校验。key存在但不是数字tonumber会返回nil后续计算会报错。生产环境建议在key命名上做隔离避免和其他类型的数据混用。初始值超出范围比如initVal200但maxVal100第一次执行就会失败。这个逻辑是合理的但要在业务上明确。4. 高并发下的性能表现与压测数据4.1 单实例压测Lua脚本 vs 事务 vs 分布式锁我在本地用单机Redis8核16GRedis 7.0做过一组对比压测场景是100个并发线程每个线程执行1000次库存扣减初始库存10000范围[0, 10000]。结果如下方案QPS平均延迟P99延迟超卖次数GETDECR非原子850001.2ms3.5ms有超卖WATCHMULTI事务120008.3ms45ms无分布式锁GETDECR250004.0ms18ms无Lua脚本780001.3ms3.8ms无数据很直观Lua脚本的QPS接近非原子方案但保证了原子性。事务方案因为乐观锁冲突重试QPS掉到十分之一。分布式锁因为锁的获取释放开销也只有Lua脚本的三分之一。4.2 脚本执行时间对整体吞吐的影响Lua脚本虽然快但它是阻塞式的。我测过不同复杂度的脚本对Redis整体QPS的影响简单脚本一次GET一次SET单脚本执行约0.05msRedis整体QPS约80000。中等脚本GET判断SETEXPIRE约0.08msQPS约75000。复杂脚本循环遍历10个key约0.5msQPS掉到30000以下。结论很明确脚本里不要写循环不要操作大集合。如果业务逻辑确实复杂考虑拆成多个脚本或者把部分逻辑放到客户端。4.3 集群模式下的注意事项Redis Cluster下Lua脚本有个硬性限制所有操作的key必须在同一个slot。因为脚本执行时Redis需要知道在哪个节点执行。如果你的脚本里操作了多个key必须用hash tag保证它们落在同一个slot。比如库存key是stock:item:1001流水key是stock:log:1001这两个key的slot可能不同。解决办法是用hash tagstock:{item:1001}:count stock:{item:1001}:log大括号里的内容相同Redis就只对大括号内的部分做hash保证两个key落在同一个slot。这个细节在单机模式下无所谓但上集群后不注意就会报CROSSSLOT错误。5. 踩过的坑那些文档里不会写的实战教训5.1 脚本缓存丢失导致的NOSCRIPT雪崩有一次线上做Redis主从切换切换后大量请求报NOSCRIPT错误。原因是EVALSHA依赖的脚本缓存只在主节点内存里切换后新主节点没有缓存。虽然客户端有回退到EVAL的逻辑但瞬间大量请求同时回退把脚本内容重复传输带宽被打满引发了连锁反应。教训生产环境用EVALSHA必须配合客户端缓存和限流回退。redis-py的register_script虽然有回退但高并发下回退请求会集中爆发。更好的做法是在应用启动时主动SCRIPT LOAD一次并且在捕获NOSCRIPT后加一个短暂的本地缓存标记避免所有线程同时回退。5.2 浮点数精度问题Lua的number类型是双精度浮点数tonumber转换大整数时会丢精度。比如库存是9007199254740993超过2^53转成Lua number后会变成9007199254740992。虽然一般业务不会用到这么大的数但如果你做的是类似雪花ID计数这种场景就要小心。解决方案如果数值可能超过2^53用字符串传递在Lua里用redis.call(INCRBY)这种Redis原生命令处理避免在Lua里做算术。5.3 脚本里的错误处理Lua脚本里如果调用redis.call出错整个脚本会中断并且已经执行的写操作不会回滚。比如脚本里先SET了一个key然后调用另一个命令报错第一个SET是生效的。这和数据库事务不一样Lua脚本没有回滚机制。规避方法把可能出错的命令放在前面写操作放在最后。或者用redis.pcall代替redis.callpcall会捕获错误并返回错误信息不会中断脚本但你需要自己判断返回值。5.4 大key导致的阻塞如果计数的key本身是个大hash或者大list脚本里操作它会很慢。我见过一个案例有人把库存放在一个hash里脚本里用HGET读、HSET写单key的field有几十万个每次操作都要遍历脚本执行时间飙到几毫秒整个Redis被拖垮。原则计数场景就用简单的string类型不要用hash、list、set。string的GET/SET是O(1)的最稳。6. 进阶玩法把Lua脚本用在限流和配额场景6.1 滑动窗口限流的Lua实现计数范围增减的一个典型变体是限流在时间窗口内请求次数不能超过阈值。用Lua脚本可以做到判断计数设置过期一气呵成-- KEYS[1]: 限流key -- ARGV[1]: 阈值 -- ARGV[2]: 窗口秒数 local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(GET, key) if current and tonumber(current) limit then return 0 -- 被限流 end local newVal redis.call(INCR, key) if newVal 1 then redis.call(EXPIRE, key, window) end return 1 -- 放行这个脚本比固定窗口更精确因为EXPIRE只在第一次INCR时设置窗口是滚动的。注意INCR和EXPIRE都在脚本里原子性有保证不会出现计数了但没设过期导致key永久驻留的问题。6.2 多维度配额的组合控制有些场景需要同时控制多个维度的配额比如每个用户每天最多100次每个IP每天最多1000次。可以在一个脚本里操作多个key-- KEYS[1]: 用户维度key -- KEYS[2]: IP维度key -- ARGV[1]: 用户阈值 -- ARGV[2]: IP阈值 -- ARGV[3]: 过期秒数 local userKey KEYS[1] local ipKey KEYS[2] local userLimit tonumber(ARGV[1]) local ipLimit tonumber(ARGV[2]) local ttl tonumber(ARGV[3]) local userCount tonumber(redis.call(GET, userKey) or 0) local ipCount tonumber(redis.call(GET, ipKey) or 0) if userCount userLimit then return {0, user_limit} end if ipCount ipLimit then return {0, ip_limit} end redis.call(INCR, userKey) redis.call(INCR, ipKey) redis.call(EXPIRE, userKey, ttl) redis.call(EXPIRE, ipKey, ttl) return {1, ok}注意这里两个key必须用hash tag保证同slot否则集群下会报错。另外EXPIRE每次都设置会重置过期时间如果希望窗口固定应该只在第一次INCR时设置。6.3 带优先级的库存扣减秒杀场景里有时候需要给VIP用户优先扣减库存。可以在脚本里加一个优先级参数高优先级请求在库存紧张时优先成功-- ARGV[1]: 扣减量 -- ARGV[2]: 优先级数字越小优先级越高 -- ARGV[3]: 预留库存给高优先级预留的量 local delta tonumber(ARGV[1]) local priority tonumber(ARGV[2]) local reserved tonumber(ARGV[3]) local current tonumber(redis.call(GET, KEYS[1]) or 0) local available current - reserved -- 低优先级请求只能用非预留部分 if priority 1 and available delta then return {0, current} end if current delta then return {0, current} end redis.call(DECRBY, KEYS[1], delta) return {1, current - delta}这个逻辑在脚本里实现很自然但如果放到客户端做就需要多次网络往返并发下根本没法保证一致性。7. 生产环境落地的几个关键决策7.1 脚本版本管理Lua脚本一旦上线修改后sha1会变旧版本的EVALSHA会失效。建议给脚本做版本号key里带上版本比如stock:v2:item:1001。这样新旧版本可以并行运行灰度切换。另外脚本内容建议存在配置中心或者代码仓库里不要散落在各个业务代码中。我见过一个项目同一个库存扣减逻辑在三个地方各写了一遍Lua脚本后来改需求时漏改了一个导致数据不一致。7.2 监控指标Lua脚本的监控要关注几个指标脚本执行时间用SLOWLOG监控超过10ms的脚本要告警。NOSCRIPT错误率突然升高说明脚本缓存有问题。脚本调用QPS和业务QPS对比看是否有异常放大。返回值分布成功/失败的比例失败率突然升高可能是阈值配置有问题。7.3 降级方案Lua脚本虽然稳但也要有降级预案。如果Redis整体不可用业务不能直接挂掉。常见的降级策略本地缓存令牌桶Redis不可用时切换到本地限流虽然精度下降但能保住系统。异步补偿扣减请求先写本地队列Redis恢复后重放。直接拒绝对于库存这种强一致场景Redis不可用时直接返回系统繁忙避免超卖。具体选哪种取决于业务对一致性和可用性的权衡。库存场景我倾向于直接拒绝因为超卖的代价比拒绝请求大得多。7.4 关于key的过期策略计数key要不要设过期分场景库存不设过期库存是持久数据卖完了就是0。限流必须设过期否则计数永远累积。积分不设过期但要有单独的归档逻辑。临时配额设过期比如每日签到次数。设过期时注意EXPIRE要在INCR之后调用而且只在第一次INCR时设置避免每次请求都重置过期时间导致key永不过期。8. 一些容易被忽略的细节和我的个人习惯先说一个关于redis.call和redis.pcall的选择。我现在的习惯是确定不会出错的命令用call可能出错的用pcall。比如GET、SET、INCR这些基本不会出错用call让错误直接暴露而涉及类型转换、可能操作错误类型key的地方用pcall捕获后返回友好错误。这样既保证了脚本的健壮性又不会掩盖真正的bug。另一个细节是返回值的类型。Lua脚本返回table时Redis会把table转成数组返回。但如果table里有nil转换会截断。比如return {1, nil, 3}实际返回的是{1}。所以返回值里不要有nil用空字符串或者0代替。还有关于KEYS和ARGV的使用规范。Redis官方建议所有key都通过KEYS传递不要拼接在ARGV里。这不仅是规范问题在集群模式下Redis需要根据KEYS来判断slot如果key藏在ARGV里集群无法正确路由。我见过有人图省事把key当普通参数传单机测试没问题上集群直接报错。最后分享一个调试技巧Lua脚本出错时错误信息往往很模糊。可以在脚本里用redis.log(redis.LOG_WARNING, ...)打日志日志会输出到Redis的日志文件里。虽然会影响一点性能但排查问题时非常有用。生产环境记得排查完把日志去掉。关于性能我的经验是单脚本执行时间控制在0.1ms以内。超过这个值就要审视逻辑了。大部分计数场景的脚本都应该在0.05ms左右如果你写的脚本跑到了1ms大概率是哪里有问题——要么操作了大key要么有不必要的循环要么命令用错了。这套方案我在几个项目里用了两年多从日请求量百万级到亿级都跑过稳定性没问题。核心就是记住一点Lua脚本是Redis给的一把原子操作的刀用好了能解决很多并发难题但刀要磨得锋利——脚本要短、要快、要简单。复杂的逻辑拆出去让脚本只做最核心的原子判断和写入这才是高并发场景下的正确姿势。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑