
很多人第一次听到“Redis脚本”这个词以为是在命令行里批量执行redis-cli命令其实它指的是把一段 Lua 代码交给 Redis 服务端去原子执行。我第一次认真研究它是线上限流老是在整点失效排查到最后发现是“判断执行”两步之间的竞态窗口在作祟而业务层加锁、换事务都救不了最终是脚本把这两步焊死在 Redis 内部才解决。这篇文章就从那次事故讲起把 Redis 脚本的用法、原理、坑和工程化落地都过一遍适合正在做缓存治理、分布式锁或者想给 Redis 操作加原子性的同学。1. 从一次缓存穿透事故说起为什么非要用Redis脚本1.1 那条“先查后写”的代码当时的情况是服务端有一个热点数据查询接口代码逻辑非常典型先查 Redis没命中再查数据库最后写回缓存。在低并发下一切正常但整点流量一冲Redis 里那条 key 恰好失效几百个并发请求同时发现缓存为空全都去查数据库数据库连接池瞬间满了。这事的根源不在 Redis 慢而是这段代码在并发下有一个天然的竞争窗口if (redisTemplate.opsForValue().get(key) null) { // 这里停顿一下其它线程也判断为null String value loadFromDb(key); redisTemplate.opsForValue().set(key, value); }两个线程同时判断 key 不存在然后同时回源数据库这就是常见的缓存穿透。解决方式有很多种当时我试过加本地锁但每台机器只锁自己进程集群多节点下还是漏试过用 JVM 级分布式锁但为了一个缓存读取求一把分布式锁成本和收益完全不匹配。直到我翻 Redis 文档看到脚本功能才意识到这类“先读后判断再写”的操作最合适的做法就是交给 Redis 内部的 Lua 脚本去执行。1.2 为什么MULTI/EXEC事务救不了场很多同学第一反应是Redis 不是有事务吗用MULTI/EXEC不就行了这里有个很经典的误区。Redis 的MULTI/EXEC只保证“你在事务里写的命令一条不落、不会被其它请求插队地执行”但它不保证“事务里后续命令能根据前一条命令的返回值做判断”。也就是说MULTI队列里的命令是预先排好的你在EXEC之前根本不知道GET会返回什么自然没法决定要不要继续执行SET。真要在事务里加条件就得用WATCH实现乐观锁但在高并发下冲突率很高WATCH一旦发现 key 被改动就放弃整个事务业务端只能重试。咱们拿缓存穿透那个例子看WATCH key - GET key - 如果为空 - MULTI - SET - EXEC这个流程在两个线程同时WATCH同一个 key 时必然有一个会失败重试。短平快的操作重试几次无所谓但如果后面跟着查数据库这种昂贵的步骤重试代价就太大了。Redis 脚本不一样它把整段 Lua 代码作为一个整体在服务端一次性执行完。脚本里面可以先用redis.call(get, KEYS[1])拿到当前值再用if判断要不要写整个过程不会被其它命令插入。这本质上就是“在 Redis 进程内做原子操作”比客户端事务干净得多。1.3 脚本原子性的来源单线程事件循环Redis 的命令执行模型是单线程的这个“单线程”很多人只当做一个性能特征其实它也是脚本能保证原子性的基石。Redis 的事件循环在同一个时刻只会处理一条命令或一个脚本脚本执行期间它不会去处理其它客户端发来的任何命令所以脚本里一串get/set/decr操作对这个 Redis 实例上的所有客户端来说都是“不可分割”的。代价当然也有脚本是阻塞执行的脚本跑多久其它请求就等多久。所以官方一直强调脚本要短小精悍不要在里面做复杂计算更不要有循环等待。这个特性我在后面踩坑部分还会细说这里先记住一个结论Redis 脚本解决的是“多个 Redis 操作需要原子执行”的业务问题不是用来替代应用层做重量级计算的。2. EVAL命令的完整调用链路Redis脚本到底怎么跑起来的2.1 EVAL、EVALSHA与SCRIPT LOAD的正确姿势Redis 执行脚本的核心命令有三个EVAL、SCRIPT LOAD、EVALSHA。EVAL是直接执行脚本源码格式是这样的EVAL return redis.call(set, KEYS[1], ARGV[1]) 1 mykey hello这里1表示后面 KEYS 数组的长度KEYS[1]对应mykeyARGV[1]对应hello。执行成功后mykey的值就是hello。为什么要单独区分 KEYS 和 ARGV主要为了在 Redis Cluster 模式下服务端能通过KEYS数组算出哈希槽从而确定这条脚本应该转发到哪个节点执行。所以正规的脚本里凡是涉及 key 名的地方都应该从 KEYS 数组取严禁写死在脚本内容里。SCRIPT LOAD会把脚本编译后缓存到 Redis 实例返回一个 SHA1 值SCRIPT LOAD return redis.call(set, KEYS[1], ARGV[1]) # 输出一段40位十六进制字符串例如 7a1f...之后用EVALSHA加这个 SHA1 值执行就不用每次传输脚本源码了EVALSHA 7a1f... 1 mykey hello在高并发场景下每次都传完整 Lua 源码会浪费带宽也是不必要的开销正确的做法是在项目启动时SCRIPT LOAD一次之后业务请求全部走EVALSHA。这一套在 5.1 节我会给出一个工程化的落地流程。2.2 KEYS、ARGV的边界以及脚本里能调用的命令我在项目里看到过一种危险写法脚本里面直接写死 key 名-- 错误示例Cluster下会直接报错 local value redis.call(get, user:1234:name) return value这种脚本在单机 Redis 能跑但在 Cluster 环境下EVAL命令进入集群后Redis 需要根据 key 定位节点脚本里的 key 不在KEYS数组里集群就不知道该把脚本转发到哪个节点最终会报CROSSSLOT错误。所以脚本里的 key 一定要通过KEYS[i]从外部传入。脚本内部可以调用绝大多数 Redis 命令常见的有两种调用方式redis.call(get, KEYS[1])命令执行出错时直接抛异常终止脚本redis.pcall(get, KEYS[1])命令执行出错时不终止脚本把错误对象作为返回值返回举个简单的计数限流脚本感受一下 KEYS 和 ARGV 的配合local count redis.call(incr, KEYS[1]) if count 1 then redis.call(expire, KEYS[1], ARGV[1]) end return count这个脚本的含义是对 key 做自增如果是第一次自增说明是窗口内的第一个请求就给它设置过期时间。这样即使某个客户端在中途崩溃不再继续发请求key 也会在窗口结束后自动消失不会长期占内存。注意这里expire的时长放在ARGV里传而不是写死就是为了让同一个脚本可以被多个限流策略复用。2.3 Lua与Redis的类型转换最容易踩的隐性坑脚本的返回值会从 Lua 类型转换成 Redis 类型再通过网络返回给客户端。如果不了解这套映射很容易出现“脚本逻辑没问题但客户端拿到 null 或者类型不对”的诡异 Bug。这里有一张我在团队内部分享时常用的对照表Lua 类型Redis 返回类型说明numberinteger整数直接返回数字stringbulk string字符串客户端最常见tablemulti-bulk对应数组返回trueinteger 1布尔真转为数字1falsenil布尔假转为 nil注意不是 0nilnilLua 的 nil 会让 Redis 返回空最坑的就是false和nil的转换。很多人在 Lua 里写if ... then return true else return false end结果客户端拿到的不是true/false而是1和nil。如果你的业务判断依赖false那这里就直接出错了。还有一种情况脚本返回一个空 table{}Redis 会返回空数组但如果脚本返回nilRedis 在某些客户端里会被解析成null。两者在业务上往往有不同含义例如“没有匹配到任何记录”和“操作失败”。写脚本时最好明确约定查不到返回空数组异常返回 nil让客户端能够正确区分。3. 实战落地分布式锁、滑动窗口限流与库存防超卖3.1 分布式锁的解锁为啥必须交给脚本很多人用 Redis 做分布式锁加锁时知道用SET key value NX PX 30000这种原子命令但解锁时容易偷懒写成了两步操作if (redis.get(key).equals(token)) { redis.del(key); }在低并发下看起来没问题但仔细推演一下就露馅了线程 A 拿到锁设置 30 秒过期A 业务执行超过 30 秒锁自动过期此时线程 B 加锁成功拿到了同一个 keyvalue 是 B 的 tokenA 终于执行完进入解锁逻辑get 到当前的 value 是 B 的 token和自己持有的 token 相等于是执行 del——把 B 的锁给删了。这就是经典的“误删他人锁”。要修复必须把“判断 token 是否匹配”和“删除 key”合并成一个原子操作唯一合适的手段就是脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end调用时把锁 key 放在KEYS[1]把当前线程持有的唯一 token 放在ARGV[1]。token 一般是 UUID 之类全局唯一的值删除前先在服务端校验校验通过才删从根上杜绝了误删。同样道理锁的续期也需要脚本。框架层实现看门狗续期时通常会检查锁是否仍然归自己所有是才续期。一个简化版的原型是这样的if redis.call(hexists, KEYS[1], ARGV[1]) 1 then return redis.call(expire, KEYS[1], ARGV[2]) else return 0 end这里把锁设计成 Hash 结构field 存持有者标识。续期前先确认持有者没变再刷新过期时间。真正的 Redisson 实现比这复杂但核心思路完全一致先判断后操作全程脚本保证原子。3.2 滑动窗口限流的经典脚本我之前直接用INCR EXPIRE做过固定窗口限流但固定窗口有个明显的缺陷窗口边界上会出现“双倍流量”问题。比如限制每分钟 100 次第 59 秒来了 100 次第 60 秒窗口重置又来 100 次实际上 2 秒内放过了 200 次。要更平滑就得用滑动窗口。滑动窗口最自然的实现是用 ZSET把每个请求的时间戳作为 score 存进去查询时先清掉窗口外的旧记录再统计窗口内还剩多少个请求。这段逻辑用 Redis 脚本执行很顺畅local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local limit tonumber(ARGV[3]) redis.call(zremrangebyscore, KEYS[1], 0, now - window) local current redis.call(zcard, KEYS[1]) if current limit then redis.call(zadd, KEYS[1], now, now) redis.call(expire, KEYS[1], window) return 1 end return 0通过redis-cli --eval本地调试时注意 KEYS 和 ARGV 之间用逗号隔开redis-cli --eval ./sliding_window.lua rate:user:1001 , 1735689600 60 100逗号前面是 KEYS 数组逗号后面是 ARGV 数组。这个脚本在单机上单线程跑所有判断和执行在一个原子操作里完成不会有两个请求同时通过检查再同时写入限流误差只在 ZSET 清理和计数这两步之间交给脚本后就完全消除了。3.3 库存扣减防超卖的多key组合方案秒杀场景下的库存扣减是另一个高频需求。最原始的写法是先查库存大于 0 就减一但两个线程同时查到库存为 1同时执行减一最后库存变成 -1超卖了。把扣减逻辑放进脚本后可以一次性完成检查、扣减、记录明细三件事local stock tonumber(redis.call(get, KEYS[1]) or 0) local qty tonumber(ARGV[1]) if stock qty then return 0 end redis.call(decrby, KEYS[1], qty) redis.call(hincrby, KEYS[2], ARGV[2], qty) return 1这里用了两个 keyKEYS[1]是库存 keyKEYS[2]是扣减明细的 Hash keyARGV[2]是用户标识扣了多少就记录在 Hash 的对应 field 里。返回 1 表示扣减成功返回 0 表示库存不足。需要特别提醒的是脚本里一旦出现多个 key在 Redis Cluster 环境下就必须保证这些 key 落在同一个哈希槽否则会抛CROSSSLOT错误。解决方法是在 key 里嵌入同一个哈希标签比如把库存 key 设计成stock:{product:1001}扣减明细 key 设计成orders:{product:1001}{}内的内容参与哈希计算两个 key 就会落到同一个槽。4. 我把脚本跑进生产环境后才踩到的坑4.1 一段sleep代码引发的实例级阻塞脚本的原子性是把双刃剑。我在测试环境验证一个耗时逻辑时偷懒在脚本里写了一个redis.call(time)再加上循环等待结果那个脚本跑了接近 30 秒。期间整个 Redis 实例像是被按了暂停键所有客户端命令全部排队监控面板上延迟直接拉满。后来查文档才确认Redis 默认的lua-time-limit是 5 秒。超过 5 秒后Redis 不会主动断掉脚本而是开始对其他客户端返回BUSY错误。此时分两种情况处理脚本还没执行任何写操作可以用SCRIPT KILL强制终止脚本已经写了数据杀掉它会导致主从不一致Redis 宁可不服务也不会让你杀只能用SHUTDOWN NOSAVE重启实例。那次之后我给自己定了三条纪律脚本里不允许出现循环等待、sleep、长链遍历这类操作单个脚本的操作次数控制在个位数以内所有数据的处理都放到应用层算好再传进去上线前用redis-benchmark或者压测脚本把最坏情况的执行耗时测一遍。Redis 脚本不是万能计算容器它的定位是“原子操作”不是“函数计算”。这个定位想清楚很多性能事故都能避免。4.2 Cluster模式下CROSSSLOT报错的排查过程迁移到 Redis Cluster 之后我第一次把一个涉及两个 key 的脚本跑上去立刻收到一串报错CROSSSLOT Keys in request dont hash to the same slot当时有点懵单机上跑得好好的怎么集群就不认了。排查后发现原因集群模式下EVAL脚本里的所有 KEYS 都必须属于同一个哈希槽Redis 会先对 KEYS 数组里的每个 key 计算哈希槽不一致就拒绝执行。解决方案有两个方向。第一调整 key 设计在 key 里嵌入哈希标签。比如原来用的是cart:1001和order:1001改成cart:{1001}和order:{1001}{}里的内容才会参与哈希计算两个 key 就有了同样的槽位。第二重新审视业务逻辑如果两个 key 确实没有“必须同时操作”的强关联就拆成两个脚本分别执行不要为了省一次网络往返硬凑在一起。需要补充的是在主从复制或 AOF 持久化环境中脚本执行时 Redis 会记录脚本源码而非逐条记录写命令这是为了保证从节点和主节点执行结果一致。这也是为什么脚本必须“确定性”不能依赖时间、随机数等不确定因素否则主从数据会对不上。4.3 返回值类型错位引发的诡异业务Bug还有一个很难排查的坑来自脚本返回值和客户端类型绑定不一致。Spring Data Redis 里用DefaultRedisScript的时候必须显式声明返回类型DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(scripts/limit.lua)); script.setResultType(Long.class);有次同事把ResultType设置成String.class脚本里返回的是数字结果是啥不报错客户端拿到null。因为是 null业务代码里走入了“空值”分支导致限流白名单全部失效线上放进来一大批流量。这种问题最坑的是它不会抛异常只有观测数据才能发现不对劲。脚本返回值类型设置有一条基本规则返回整数用Long.class返回字符串数组用List.class返回单个字符串用String.class。实在不确定返回什么就先用命令行EVAL跑一遍看看 Redis 实际返回的类型再去对应客户端的类型系统。5. 脚本的工程化管理与调优建议5.1 预加载脚本避免每次传源码项目里的脚本不应该散落在各种业务代码的字符串常量里。我的做法是把脚本文件统一放在 resources 目录下比如src/main/resources/scripts/每个脚本一个.lua文件文件名就是它的功能名。启动时用一个专用组件扫描所有脚本文件逐个执行SCRIPT LOAD把 SHA1 缓存起来供业务调用。命令行演示一下加载流程# 进入 scripts 目录逐个加载 redis-cli SCRIPT LOAD $(cat scripts/incr_then_expire.lua) # 返回 7a1f2e... redis-cli SCRIPT LOAD $(cat scripts/unlock.lua) # 返回 8b3c4d...之后客户端调用EVALSHA 7a1f2e... 1 rate:user:1001 60就可以了。脚本内容发生变化时SHA1 会跟着变新加载的脚本会覆盖旧缓存老 SHA 自然失效不需要手动清理。要注意的一点是Redis 重启后脚本缓存会被清空所以加载动作必须在每次项目启动时重新执行不能只做一次。5.2 NOSCRIPT回退策略别让重启打脸如果你在高并发下用的是EVALSHA就要处理一个边界情况Redis 重启后脚本缓存清了但客户端手里还拿着旧的 SHA1。此时执行EVALSHA会返回错误信息NOSCRIPT No matching script。比较稳妥的调用策略是先EVALSHA如果捕获到NOSCRIPT错误就改回EVAL传源码执行并且顺手再执行一次SCRIPT LOAD把缓存补上。Spring Data Redis 的DefaultRedisScript内部其实已经做了类似封装它会在首次执行时生成 SHA1后续优先走 EVALSHA发现NOSCRIPT后自动回退。这也是为什么我建议团队尽量别自己封装脚本调用工具直接用框架现成的至少这些边界情况框架作者已经替你踩过一遍了。5.3 本地调试、慢日志与脚本回归测试最后说几条日常开发的小技巧。调试脚本最方便的是redis-cli --eval它支持从文件读脚本还能把 KEYS 和 ARGV 分开传。前面限流脚本已经演示过这里再强调一下格式里的逗号逗号左边是 KEYS 列表右边是 ARGV 列表逗号本身不是参数只是分隔符。脚本上线前我建议至少做三件事在独立 Redis 实例上用测试数据跑一遍把“key 不存在”“达到阈值”“超过阈值”“并发同时调用”四个边界场景都覆盖到压测环境里看SLOWLOG GET确认单个脚本的执行耗时稳定在毫秒级以下把每个脚本的输入输出、幂等性说明、涉及哪些 key写成注释挂在脚本文件头部和业务代码一样走代码评审。我曾经在评审中发现同事的限流脚本里早期版本没有expirekey 一旦达到限流阈值就永远留在 Redis 里导致后续所有请求都被限死。这种逻辑问题单靠压测很难暴露只有把脚本当作一级代码来 review 才能提前拦住。对于已经上线的脚本监控方面只需要盯住 Redis 的busiest_evals相关指标和慢日志但凡出现单个脚本执行时间明显上涨先查脚本涉及的 key 数量是不是变大了再查是不是有突发流量导致 ZSET 数据量膨胀。脚本写得好不好最终都会在慢日志里现原形。就我个人的习惯来说Redis 脚本在我的项目里被当作“数据库存储过程”来管理文件统一存放预处理加载SHA1 缓存版本入库。虽然比随手写几行SET NX麻烦但线上出过一次故障就知道这套流程省下的不仅仅是排查时间更是整个团队的睡眠质量。如果你正准备把脚本引入自己的项目建议从分布式锁的解锁逻辑开始改那是最小成本、最大收益的第一步。