资讯动态

Redis Lua脚本实现高并发计数范围内原子级增减

发布时间:2026/10/17 1:07:31 来源:尧图企业网站定制
1. 高并发计数场景的痛点与Lua脚本的破局思路做后端开发的朋友大概率都碰过这样的需求某个活动页面要限制每人每天最多领3张券某个接口要控制单用户在1分钟内最多请求100次某个库存扣减要保证不超卖。这些场景的共同点是什么读一个值、判断范围、再写回新值三步操作必须作为一个整体完成中间不能被打断。早期我试过最朴素的做法先GET拿到当前计数在应用层判断是否在范围内再INCR或SET回去。这个方案在低并发下跑得好好的一旦QPS上去问题就暴露了——两个请求同时读到count99都判断“没到100”然后都执行了加一最终计数变成101超了。这就是典型的竞态条件。有人会说用Redis的WATCH加事务不就行了我实测过WATCHMULTIEXEC在冲突时会返回nil需要应用层重试。高并发下重试率飙升大量请求空转吞吐量反而下降。而且WATCH是基于乐观锁的思路冲突越多性能越差不适合计数这种写操作密集的场景。那用分布式锁呢SETNX加锁、处理、解锁逻辑上没问题但每个计数操作都要经历加锁解锁两次网络往返锁本身还成了串行瓶颈。一个热点key上的计数所有请求排队等锁延迟直接拉满。真正的破局点在于把“读-判断-写”三步逻辑下沉到Redis服务端执行。Redis从2.6版本开始支持Lua脚本脚本在服务端原子执行执行期间不会被其他命令打断。这意味着你可以在脚本里完成取值、范围判断、增减、返回结果的全流程对外部调用者来说就是一个原子操作。这就是标题里说的“计数范围内原子级增减”的核心思路。这篇文章我会从设计思路、脚本编写、参数计算、实操踩坑几个维度把整套方案拆开讲透。适合正在做限流、配额、库存、积分等计数类需求的同学参考不管你是刚接触Redis的新手还是已经用过Lua脚本但没深挖的老手应该都能拿到可以直接抄作业的东西。2. 方案整体设计与核心技术选型拆解2.1 为什么是Lua脚本而不是其他方案先把可选方案摆出来对比一下这样你能清楚看到Lua脚本的定位。方案原子性性能实现复杂度适用场景应用层GET判断INCR不保证高低低并发、允许少量误差WATCH事务保证中低中冲突少的场景分布式锁保证低中高临界区逻辑复杂时Redis Lua脚本保证高中计数、限流、配额等Lua脚本的优势在于原子性由Redis单线程模型保证不需要额外加锁逻辑在服务端执行只消耗一次网络往返脚本可以缓存后续调用直接执行缓存后的SHA值省去传输脚本本身的开销。但要注意Lua脚本不是银弹。Redis执行Lua脚本时会阻塞其他命令所以脚本逻辑必须足够轻量。如果你在脚本里写了个循环跑几万次整个Redis实例都会被拖住。这一点后面讲避坑时会重点说。2.2 计数范围增减的语义定义“计数范围内原子级增减”这句话拆开看包含几个关键语义计数操作对象是一个数值型的key比如user:123:coupon:20240501。范围有一个上下界约束比如[0, 100]增减后不能越界。原子级取值、判断、写入三步不可分割。增减支持增加和减少两个方向且增减的步长可能不是1。这里有个容易混淆的点越界时是拒绝操作还是截断到边界两种语义都合理取决于业务。比如领券场景超过上限应该拒绝返回失败而积分扣减场景余额不足时可能希望扣到0为止。我的做法是在脚本里通过参数控制让调用方决定越界行为。2.3 脚本的输入输出设计一个健壮的计数脚本输入至少包含key名、增减步长、下界、上界、越界策略。输出则要告诉调用方操作是否成功、当前值是多少、如果失败是什么原因。我习惯用Redis的KEYS数组传key用ARGV数组传参数。虽然官方建议集群模式下所有key用同一个hash tag但计数场景通常只操作一个key所以这个问题不大。参数顺序我固定为ARGV[1]步长、ARGV[2]下界、ARGV[3]上界、ARGV[4]越界策略。这样脚本逻辑清晰调用方也不容易传错。返回值设计上我用一个数组返回第一个元素是状态码0成功、1越界拒绝、2key类型错误第二个元素是操作后的值。这样调用方一次拿到所有需要的信息不用再发一次GET。3. Lua脚本核心逻辑逐行拆解与实操要点3.1 脚本完整代码与逐段说明先上完整脚本然后逐段拆。-- KEYS[1]: 计数key -- ARGV[1]: 步长正数增加负数减少 -- ARGV[2]: 下界 -- ARGV[3]: 上界 -- ARGV[4]: 越界策略reject拒绝 / clamp截断 local key KEYS[1] local delta tonumber(ARGV[1]) local lower tonumber(ARGV[2]) local upper tonumber(ARGV[3]) local policy ARGV[4] -- 参数校验 if delta nil or lower nil or upper nil then return {2, 0} end if lower upper then return {2, 0} end -- 获取当前值key不存在时视为0 local current redis.call(GET, key) if current false then current 0 else current tonumber(current) if current nil then return {2, 0} end end -- 计算目标值 local target current delta -- 范围判断与处理 if target lower then if policy clamp then target lower else return {1, current} end elseif target upper then if policy clamp then target upper else return {1, current} end end -- 写回并设置过期时间可选 redis.call(SET, key, target) return {0, target}第一段是参数解析和校验。tonumber转换失败会返回nil这里必须判断否则后续比较会报错。lower upper这种明显不合法的参数直接拒绝避免脚本内部逻辑混乱。第二段获取当前值。这里有个细节GET一个不存在的key返回falseLua里的布尔假而不是nil。所以判断用current false。拿到值后转数字如果key里存的是字符串比如有人误用了SET key abctonumber返回nil脚本返回类型错误状态码。第三段计算目标值并做范围判断。clamp策略下把目标值截断到边界reject策略下直接返回当前值不动。注意返回的是current而不是target因为操作被拒绝了值没变。第四段写回。这里我用SET而不是INCRBY因为目标值已经算好了直接覆盖更直观。如果你担心并发问题其实不用担心整个脚本是原子的SET执行时不会有其他命令插进来。3.2 参数传递的坑与正确姿势调用脚本时很多人会踩参数类型的坑。Redis的EVAL命令接收的所有参数都是字符串Lua脚本里必须用tonumber转换。我见过有同学直接写if ARGV[1] 0 then结果字符串比较和数字比较结果不一样10 9在字符串比较下是false因为逐字符比较1小于9。这种bug很隐蔽测试时用小数字可能发现不了。另一个坑是key的命名。计数key最好带上业务前缀和维度信息比如counter:api:user:123:20240501。这样在Redis里用SCAN排查问题时一目了然。但要注意key不能太长Redis的key本身也占内存几百万个key的话每个key多10个字节就是几十MB的差距。还有过期时间的设置。计数场景通常有自然周期比如每天重置。我习惯在脚本里加一个可选的过期时间参数当key是新建的时候设置TTL。这样不用额外的EXPIRE命令也避免了忘记设置导致key永久堆积。3.3 脚本缓存与EVALSHA的正确使用每次调用都传完整脚本太浪费带宽Redis提供了SCRIPT LOAD和EVALSHA机制。SCRIPT LOAD把脚本加载到Redis返回一个SHA1值后续用EVALSHA加这个SHA值调用不用再传脚本内容。但这里有个生产环境的坑Redis重启后脚本缓存会丢失此时EVALSHA会返回NOSCRIPT错误。正确的处理姿势是应用启动时先SCRIPT LOAD拿到SHA值缓存起来调用时用EVALSHA如果捕获到NOSCRIPT错误重新SCRIPT LOAD再重试一次。很多Redis客户端库已经封装了这个逻辑比如Jedis的eval方法内部会先尝试EVALSHA失败后回退到EVAL。但如果你用的是原生命令或者某些轻量客户端需要自己处理。我建议在应用层做一个简单的封装把SHA值和重试逻辑都管起来。注意集群模式下SCRIPT LOAD需要在每个节点上执行或者使用EVAL让请求路由到对应节点时自动加载。不同客户端行为不一样上线前务必在测试环境验证。4. 完整实操流程与关键环节实现4.1 环境准备与基础验证假设你已经有一个可用的Redis实例版本建议5.0以上对Lua脚本的支持更完善。先用redis-cli连上去手动跑一遍脚本验证逻辑。redis-cli EVAL local c redis.call(GET, KEYS[1]); if c false then c 0 else c tonumber(c) end; local t c tonumber(ARGV[1]); if t tonumber(ARGV[2]) or t tonumber(ARGV[3]) then return {1, c} end; redis.call(SET, KEYS[1], t); return {0, t} 1 test:counter 5 0 100 reject这条命令对test:counter加5范围[0,100]越界拒绝。第一次执行返回{0, 5}再执行一次返回{0, 10}。你可以连续执行到超过100观察返回{1, 100}值不再变化。手动验证通过后把脚本保存成.lua文件用SCRIPT LOAD加载redis-cli SCRIPT LOAD $(cat counter.lua)返回的SHA值记下来后续用EVALSHA调用。4.2 应用层封装与参数计算以Java为例用Jedis客户端封装一个工具类。核心方法是incrWithinRange接收key、步长、上下界、策略返回操作结果。public class CounterService { private final JedisPool jedisPool; private final String scriptSha; public CounterService(JedisPool jedisPool, String script) { this.jedisPool jedisPool; try (Jedis jedis jedisPool.getResource()) { this.scriptSha jedis.scriptLoad(script); } } public CounterResult incrWithinRange(String key, long delta, long lower, long upper, String policy) { try (Jedis jedis jedisPool.getResource()) { try { ListLong result (ListLong) jedis.evalsha(scriptSha, Collections.singletonList(key), Arrays.asList(String.valueOf(delta), String.valueOf(lower), String.valueOf(upper), policy)); return new CounterResult(result.get(0), result.get(1)); } catch (JedisNoScriptException e) { // 脚本缓存丢失重新加载 scriptSha jedis.scriptLoad(script); return incrWithinRange(key, delta, lower, upper, policy); } } } }参数计算方面步长和边界的设定要根据业务来。比如限流场景步长是1下界0上界是限流阈值。配额场景步长可能是本次请求消耗的配额数上界是总配额。这里有个经验边界值最好用常量定义不要散落在代码各处否则改一个阈值要翻好几个文件。4.3 压测验证与性能数据光逻辑对还不够得压测看性能。我用redis-benchmark模拟过单key上跑这个脚本QPS能到8万左右本机环境Redis和压测工具同机。对比应用层GETINCR的方案QPS差不多但原子性有本质区别。压测时要注意热点key是瓶颈。所有请求打同一个keyRedis单线程处理QPS有上限。如果业务允许可以把计数key按用户ID哈希分片比如counter:api:user:{uid%16}这样16个key并行处理吞吐量能上去。但分片后范围判断就变成每个分片独立判断总范围需要应用层汇总逻辑会复杂一些。是否分片取决于你的QPS目标和业务容忍度。另一个压测指标是延迟。Lua脚本执行时间通常在微秒级但网络往返占大头。同机房内网调用P99延迟能控制在2ms以内。跨机房的话网络延迟会主导这时候要考虑是否把计数逻辑下沉到离用户更近的节点。5. 常见问题排查与避坑经验实录5.1 脚本执行超时与阻塞问题前面提过Lua脚本会阻塞Redis其他命令。如果脚本里有耗时操作比如大循环、复杂计算整个实例都会卡住。我遇到过有同学在脚本里用KEYS *遍历所有key做统计结果线上直接雪崩。排查这类问题可以看Redis的SLOWLOG里面会记录执行超过阈值的命令包括EVAL和EVALSHA。另外INFO commandstats能看到脚本调用的统计信息。如果发现脚本执行时间超过10ms就要警惕了。避坑原则脚本里只做O(1)或O(logN)的操作禁止遍历、禁止循环调用Redis命令。如果逻辑确实复杂拆成多个脚本或者放到应用层做。5.2 类型错误与数据污染计数key被其他业务误写成了字符串或哈希脚本里tonumber会失败。我建议在脚本里对类型错误返回明确的状态码应用层收到后打日志告警。同时计数key的命名要足够独特避免和其他业务冲突。还有一种情况是key被设置了过期时间但业务逻辑没考虑过期后的重置。比如每天限领3张券key在凌晨过期第二天第一个请求进来时key不存在脚本视为0这是符合预期的。但如果过期时间设错了比如设成了1小时那用户每小时都能重新领3张业务就出bug了。所以过期时间的设置要和业务周期严格对齐。5.3 集群模式下的key路由Redis Cluster把key按哈希槽分配到不同节点。如果脚本操作的多个key不在同一个槽会报CROSSSLOT错误。计数场景通常只操作一个key问题不大。但如果你的脚本里还操作了其他key比如同时更新计数和记录日志就要用hash tag强制路由到同一节点。hash tag的写法是用花括号包住key的一部分比如counter:{user:123}:daily和log:{user:123}:daily花括号里的内容相同两个key就会落到同一个槽。但要注意hash tag用多了会导致数据倾斜所有同tag的key都在一个节点上热点问题更严重。所以只在确实需要多key原子操作时才用。5.4 常见问题速查表问题现象可能原因排查方法解决方案返回NOSCRIPT错误脚本缓存丢失检查Redis是否重启重新SCRIPT LOAD应用层加重试计数结果超出预期竞态条件检查是否用了非原子方案改用Lua脚本脚本执行慢脚本内有循环或大key操作SLOWLOG、INFO commandstats拆分脚本禁止遍历集群报CROSSSLOT多key不在同一槽检查key命名使用hash tag类型错误key被其他业务污染检查key命名规范隔离key前缀加类型校验5.5 几个我踩过的坑第一个坑是参数顺序传错。有次我把上下界传反了脚本里lower upper直接返回错误但应用层没处理这个状态码导致计数一直不生效。后来我在应用层加了参数校验上下界传反直接抛异常不等到Redis才报错。第二个坑是忘记处理key不存在的情况。早期脚本里直接tonumber(redis.call(GET, key))key不存在时GET返回falsetonumber(false)返回nil后续计算全乱套。后来改成先判断false再赋0才稳定下来。第三个坑是压测时用了生产key。有次在测试环境压测但Redis实例和线上共用压测流量把线上计数key打满了导致真实用户领不到券。后来严格隔离环境测试用独立的Redis实例和key前缀。6. 进阶扩展与生产环境建议6.1 结合过期时间实现周期重置计数场景很多是按天、按小时重置的。除了在key上设TTL还有一种做法是在key名里带时间戳比如counter:user:123:2024050112表示12点这个小时的计数。这样每个周期一个key天然隔离不用担心TTL设置错误。缺点是key数量多但Redis扛几百万key没问题只要内存够。我倾向于key名带时间戳的方案因为逻辑更清晰排查问题时直接看key名就知道是哪个周期的数据。TTL方案虽然省key但过期时间一旦设错影响是全局的。6.2 监控与告警配置生产环境必须监控几个指标脚本执行次数、失败次数状态码非0的比例、P99延迟、Redis内存使用率。失败次数突然升高可能是业务量突增触发了限流也可能是key被污染。P99延迟升高可能是热点key或者Redis负载高。告警阈值根据业务定。比如失败率超过5%告警P99延迟超过10ms告警。这些指标可以通过Redis的INFO命令采集也可以在你的应用层埋点上报。6.3 什么时候不该用Lua脚本最后说个反向经验不是所有计数场景都适合Lua脚本。如果你的计数逻辑非常简单就是单纯加一不需要范围判断那直接用INCR就够了没必要上脚本。脚本的价值在于把多步逻辑原子化如果只有一步用原生命令更轻量。另外如果计数精度要求不高允许少量误差那用应用层方案加定期校准也能满足省去脚本的维护成本。技术选型要看业务需求不要为了用而用。我个人在实际操作中的体会是Lua脚本这套方案在计数类场景里确实稳但前提是脚本要写得足够简单、参数校验要足够严格、监控要足够到位。把这三点做到位高并发下的计数问题基本就不用再操心了。

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

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

免费获取报价 →
↑