1. 为什么单靠 Redis 原生命令搞不定“范围内计数”1.1 从一次线上超卖事故说起先说一个我亲身踩过的坑。某次大促活动业务方要求“每个用户最多领 3 张券领完即止”。当时图省事直接用INCR加一个if判断先GET当前值判断小于 3 就INCR。压测阶段一切正常结果上线后瞬间涌进来几千个并发请求库存直接超发了两百多张。问题出在哪GET和INCR是两条独立命令中间存在时间窗口。A 请求读到 2B 请求也读到 2两个都判断“小于 3 成立”然后各自INCR最终值变成 4。这就是典型的读-改-写竞态。很多人第一反应是加分布式锁。锁当然能解决问题但代价是加锁、解锁两次网络往返锁本身还有超时、续期、误删等一堆边界情况。在 QPS 上万的计数场景里锁的开销和复杂度都不划算。这时候Redis Lua 脚本就成了最优雅的答案——它把“读、判断、写”三步打包成一个原子操作在 Redis 单线程模型里一次性执行完中间不可能被其他命令插入。1.2 什么叫“计数范围内原子级增减”标题里这个说法拆开看有三层含义我逐个解释清楚不然后面写脚本容易跑偏。第一层是“计数范围”。不是简单的自增自减而是有一个上下界约束。比如库存不能减到负数积分不能超过某个上限用户领券数量不能突破阈值。这个范围可能是[0, max]也可能是[min, max]甚至可能是动态变化的。第二层是“原子级”。整个“检查范围 增减 返回结果”必须是一个不可分割的操作。要么全部成功要么全部不执行绝不能出现“检查通过了但没加上”或者“加上了但超出了范围”这种中间态。第三层是“增减”。注意是增减不只是自增。实际业务里扣减库存、退还额度、双向调整都是常见需求脚本要能同时支持正负方向的变更。把这三层合起来本质就是在一个带边界约束的计数器上做一次线程安全的 CASCompare-And-Swap操作。Redis Lua 脚本恰好提供了实现这个语义的完美土壤。1.3 为什么是 Lua而不是 WATCH/MULTIRedis 其实提供了WATCHMULTIEXEC这套乐观锁机制理论上也能实现原子计数。但实测下来它在高并发下表现很差WATCH的键一旦被其他客户端修改整个事务就会失败重试。并发越高冲突越频繁重试次数呈指数级上升形成“活锁”式的性能塌陷。Lua 脚本则是另一条路。Redis 保证脚本执行期间不会插入其他命令天然具备原子性而且不需要重试。脚本在服务端一次执行完客户端只等一个结果。从网络开销看原本需要 3 次往返GET、判断、INCR的操作压缩成 1 次EVALSHA延迟直接砍掉三分之二。提示Lua 脚本的原子性来自 Redis 的单线程执行模型脚本执行期间会阻塞其他命令。所以脚本必须写得足够短绝不能在里面做循环遍历大集合这类耗时操作否则会拖垮整个实例。2. 脚本设计的核心思路与关键取舍2.1 把“判断逻辑”下沉到服务端传统写法把判断放在应用层服务端只负责存储。这种分层在低并发下没问题但高并发下应用层读到的值随时可能过期。Lua 脚本的思路是把判断逻辑下沉到数据所在的节点让“读”和“写”发生在同一个原子上下文里。这有点像数据库里的存储过程。业务逻辑离数据越近一致性越容易保证网络往返也越少。代价是脚本的调试和版本管理会麻烦一些但对计数这种逻辑固定的场景完全值得。2.2 返回值设计让调用方一眼看懂结果脚本的返回值设计很关键直接决定应用层代码好不好写。我见过一些脚本只返回一个数字调用方根本分不清“是扣减后的值”还是“失败的错误码”。我的做法是返回一个结构化的结果至少包含三部分是否成功、当前值、失败原因。在 Lua 里返回复杂结构通常用 table 转成数组。比如返回{1, 5}表示成功且当前值为 5返回{0, 3}表示失败且当前值为 3。应用层拿到后按位置解析即可。这种设计比返回字符串好解析比返回多个键值对省空间。2.3 参数传递KEYS 和 ARGV 的边界Redis 对 Lua 脚本有个硬性约定所有键名必须通过KEYS数组传入其他参数通过ARGV传入。这不是形式主义而是因为 Redis 集群模式下需要根据KEYS来计算数据分片。如果键名藏在ARGV里集群会直接报错。所以脚本设计时KEYS[1]放计数器键名ARGV放增减量、上下界这些参数。这个约定必须严格遵守否则单机测试通过一上集群就崩。2.4 边界条件的处理哲学范围检查有两个容易忽略的细节。一是等于边界算不算通过。比如库存为 1扣减 1 后为 0这是合法的但如果要求“扣减后必须大于 0”那就不合法。这个语义必须在脚本里明确不能含糊。二是增减量本身可能越界。比如一次扣减 100但当前只有 50这时候是直接失败还是扣到 0 为止两种策略都有业务场景我的建议是脚本只做“全有或全无”的严格判断部分扣减的逻辑放到应用层去拆解保持脚本语义的纯粹性。3. 完整脚本实现与逐行拆解3.1 基础版脚本带上下界的原子增减先上核心脚本这是整个方案的心脏。我把它命名为range_counter.lua逻辑是读取当前值加上增量判断结果是否落在[min, max]区间内是则写回并返回成功否则返回失败。-- KEYS[1]: 计数器键名 -- ARGV[1]: 增减量正数为增负数为减 -- ARGV[2]: 下界含 -- ARGV[3]: 上界含 -- ARGV[4]: 初始值键不存在时使用 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}这段脚本看着简单但每一行都有讲究。tonumber是必须的因为 Redis 传进来的参数都是字符串不转换直接比较会得到诡异的结果。GET返回的是字符串也要转数字。SET而不是INCRBY是因为我们要写入的是计算后的目标值而不是在旧值上叠加这样逻辑更清晰。3.2 为什么用 SET 而不是 INCRBY有人会问既然已经算出target为什么不直接INCRBY delta原因是INCRBY会无条件执行而我们需要的是“校验通过才写”。如果先INCRBY再判断越界就得在越界时再减回去这中间又产生了新的竞态窗口。用SET一次性写入目标值语义最干净。代价是SET会覆盖键上可能存在的过期时间。如果计数器需要 TTL得在脚本里补一个EXPIRE或者改用SET带KEEPTTL参数的写法。这个细节后面在“避坑”部分会专门讲。3.3 进阶版支持 TTL 和首次初始化实际业务里计数器往往需要自动过期比如“每日签到次数”这种按天重置的场景。下面这版在基础版上加了 TTL 支持并且只在键首次创建时设置过期时间避免每次操作都刷新 TTL 导致计数器永不过期。-- KEYS[1]: 计数器键名 -- ARGV[1]: 增减量 -- ARGV[2]: 下界 -- ARGV[3]: 上界 -- ARGV[4]: 初始值 -- ARGV[5]: TTL 秒数0 表示不过期 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 ttl tonumber(ARGV[5]) local current redis.call(GET, key) local isNew false if not current then current initVal isNew true 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) if isNew and ttl 0 then redis.call(EXPIRE, key, ttl) end return {1, target}关键改动是isNew标志。只有键是新建的时候才设置 TTL后续操作不碰过期时间。这样计数器的生命周期从第一次操作开始计算符合“每日重置”这类业务直觉。如果每次操作都EXPIRE那计数器就永远不会过期了这是个很隐蔽的坑。3.4 脚本加载与调用的正确姿势脚本写好后不能每次都用EVAL传整段代码那样会浪费带宽。正确做法是先用SCRIPT LOAD把脚本加载到 Redis拿到 SHA1 摘要之后用EVALSHA调用。# 加载脚本返回 SHA1 redis-cli SCRIPT LOAD $(cat range_counter.lua) # 假设返回 a1b2c3d4... # 调用脚本 redis-cli EVALSHA a1b2c3d4... 1 counter:user:1001 -1 0 3 0 86400参数依次是SHA1、键数量、键名、增减量、下界、上界、初始值、TTL。返回{1, 2}表示成功且当前值为 2返回{0, 3}表示失败且当前值为 3。注意EVALSHA在脚本未加载时会报NOSCRIPT错误。生产环境必须做好兜底捕获这个错误后自动回退到EVAL重新加载。很多客户端库已经内置了这个逻辑但自己封装时千万别漏。4. 高并发下的性能表现与压测数据4.1 单机压测脚本 vs 分布式锁我在一台 4 核 8G 的测试机上做过对比压测用redis-benchmark模拟 100 个并发客户端每个客户端执行 10000 次操作。结果如下方案QPS平均延迟P99 延迟GET INCR 两步780001.28ms4.2ms分布式锁方案210004.76ms18.5msLua 脚本方案620001.61ms5.8msLua 脚本的 QPS 比两步操作略低因为脚本执行本身有解析开销但差距只有 20% 左右。而分布式锁方案的 QPS 直接掉到三分之一P99 延迟更是高出三倍。这个数据很能说明问题在计数这种短操作场景锁的代价远大于脚本。4.2 脚本执行时间的影响因素Lua 脚本的执行时间主要取决于脚本里的 Redis 命令数量。上面这个脚本只有一次GET、一次SET偶尔一次EXPIRE执行时间在微秒级。但如果脚本里塞了KEYS *或者遍历大集合执行时间会飙升到毫秒甚至秒级直接阻塞整个实例。我给自己定的红线是单个脚本内的 Redis 命令不超过 5 条且绝不使用KEYS、SMEMBERS这类可能返回大量数据的命令。计数脚本天然满足这个约束所以性能很稳。4.3 集群模式下的注意事项Redis 集群把数据分散在多个节点上Lua 脚本涉及的所有键必须落在同一个槽slot。计数场景通常只操作一个键天然满足。但如果脚本里要同时操作多个计数器就得用hash tag强制它们落到同一节点比如counter:{user1001}:a和counter:{user1001}:b大括号里的内容相同就会分到同一槽。这个细节在单机测试时完全暴露不出来一上集群就报CROSSSLOT错误。我建议在脚本设计阶段就把键名规范定好别等到上线才发现。5. 常见问题排查与避坑清单5.1 返回值解析踩过的坑Lua 的return {1, target}在 Redis 里会被转成数组返回但有个坑Lua 的 table 下标从 1 开始返回给客户端后变成从 0 开始的数组。所以客户端拿到的result[0]是成功标志result[1]是当前值。这个偏移量我第一次写的时候搞反了排查了半天。另外如果target是浮点数Redis 返回时会转成整数截断小数部分。计数场景一般用整数但如果涉及金额分润这种需要小数的场景得改用字符串返回或者放大成整数比如以分为单位再处理。5.2 键不存在时的初始化陷阱脚本里用initVal兜底是个好习惯但要小心initVal本身越界的情况。比如初始值设为 5但上界是 3那第一次操作就会失败。这种配置错误应该在应用层启动时就校验别等到运行时才发现。还有一种情况是键被误删。如果计数器键因为内存淘汰策略被驱逐下次操作会重新用initVal初始化导致计数“归零”。对于不能丢失的计数要么关闭该键的淘汰要么用持久化方案兜底。5.3 脚本缓存失效的处理EVALSHA依赖脚本缓存但 Redis 重启、SCRIPT FLUSH、主从切换都可能导致缓存丢失。生产环境必须实现自动重载捕获NOSCRIPT异常调用SCRIPT LOAD重新加载再重试一次EVALSHA。def eval_script(redis_client, script, keys, args): sha hashlib.sha1(script.encode()).hexdigest() try: return redis_client.evalsha(sha, len(keys), *keys, *args) except redis.exceptions.NoScriptError: redis_client.script_load(script) return redis_client.evalsha(sha, len(keys), *keys, *args)这段 Python 代码是我实际项目里用的封装逻辑很简单但很关键。没有这层兜底线上偶发的脚本缓存丢失会导致一批请求直接失败。5.4 常见问题速查表现象可能原因排查方向返回NOSCRIPT错误脚本缓存丢失检查是否实现自动重载集群报CROSSSLOT多键未用 hash tag检查键名是否同槽计数结果比预期大脚本未做范围校验检查边界判断逻辑计数器永不过期每次操作都刷新 TTL改为仅首次设置 TTL脚本执行超时脚本内有耗时命令检查是否用了 KEYS 等返回值解析错位Lua 下标从 1 开始检查客户端索引偏移5.5 几个我踩过的实操心得心得一脚本要版本化。Lua 脚本一旦上线修改后 SHA1 会变旧脚本还留在缓存里。我习惯在脚本注释里写版本号配合配置中心管理避免“改了脚本但线上跑的还是旧版”这种乌龙。心得二压测要覆盖边界。正常路径的压测谁都会做但真正出问题的是边界刚好等于上界、刚好等于下界、增减量为 0、键不存在。这些 case 我都在测试用例里单独覆盖上线前跑一遍心里才踏实。心得三监控脚本执行时间。Redis 的SLOWLOG会记录慢查询但默认阈值是 10ms对脚本来说太宽松了。我一般把阈值调到 1ms这样任何脚本性能退化都能第一时间发现。心得四别在脚本里做业务判断。脚本只负责“原子地检查范围并增减”至于“这个用户有没有资格领券”这类业务规则应该放在应用层。脚本越纯粹复用性越强也越不容易出 bug。6. 从计数延伸到更通用的原子操作模式6.1 这套模式还能解决什么问题范围计数只是 Lua 脚本原子操作的一个缩影。同样的思路可以套用到很多场景带条件的原子更新比如“仅当状态为待支付时才改为已支付”、原子性的多键操作比如“扣库存的同时记录流水”、限流器比如“每秒最多 100 次超出则拒绝”。限流器其实就是范围计数的一个变体键是“某接口某秒的计数”上界是 100每次请求增减量为 1越界就拒绝。用同一套脚本稍作调整就能实现比引入专门的限流中间件轻量得多。6.2 什么时候不该用 Lua 脚本Lua 脚本不是银弹。如果操作涉及大量数据读写或者逻辑复杂到需要几十行代码就不适合放在脚本里因为会长时间阻塞 Redis。这种情况下应该考虑用 Redis 的模块系统或者干脆把逻辑放到应用层配合其他一致性方案。另外如果计数器需要持久化到数据库做对账脚本只能保证 Redis 内的原子性跨存储的一致性还得靠应用层的补偿机制。别指望一个脚本解决所有问题。6.3 一个我常用的封装思路最后分享一个我在多个项目里复用的封装思路把脚本、SHA1、参数校验、异常重载打包成一个AtomicCounter类对外只暴露incr(key, delta)和decr(key, delta)两个方法。调用方完全不用关心 Lua 的存在就像用一个普通的计数器一样。class AtomicCounter: def __init__(self, redis_client, min_val, max_val, init_val, ttl0): self.redis redis_client self.min_val min_val self.max_val max_val self.init_val init_val self.ttl ttl self.script SCRIPT_CONTENT self.sha None def _ensure_script(self): if not self.sha: self.sha self.redis.script_load(self.script) def incr(self, key, delta1): self._ensure_script() args [delta, self.min_val, self.max_val, self.init_val, self.ttl] try: return self.redis.evalsha(self.sha, 1, key, *args) except redis.exceptions.NoScriptError: self.sha self.redis.script_load(self.script) return self.redis.evalsha(self.sha, 1, key, *args)这层封装的价值在于业务代码里看不到任何 Lua 细节参数校验、脚本重载、返回值解析全部内聚在类里。新人接手时只需要知道“这是个带范围的原子计数器”不用去啃脚本。这种“把复杂度关进笼子”的做法是我这些年做基础设施封装最大的体会。后续如果要做更复杂的原子操作比如“批量检查多个计数器并全部成功才提交”可以在这个基础上扩展用MULTI配合多个脚本调用或者写一个更复杂的脚本一次性处理。但核心思路不变把需要原子性的判断和写入尽量下沉到数据所在的那一层。