
上一篇【第56篇】Redis事务的ACID分析——它到底算不算ACID事务下一篇【第58篇】EVALSHA与脚本管理——Redis脚本的缓存与复制如果你用过JavaScript那Lua对你来说就像一个失散多年的远房表亲——语法相似但又不完全一样功能强大但又带着点异域风情。Redis把Lua脚本集成进来可以说是它做过的最聪明的决定之一——因为Lua脚本让Redis从数据结构服务器升级成了可编程数据结构服务器。上篇我们分析了Redis事务的ACID特性发现它的原子性是个短板。今天登场的Lua脚本正是弥补这个短板的利器。Redis集成Lua的意义你可能会问Redis已经有事务了为什么还要搞Lua脚本两个核心原因1. 复杂操作原子化Redis事务MULTI/EXEC最大的问题是不能做条件判断。你没法在事务里说如果counter 0就DECR否则什么都不做。Lua脚本可以。# 事务无法做条件判断MULTI GET counter# 拿到值了但你没法判断...DECR counter# 不管counter是什么值都会执行EXEC# Lua脚本可以做条件判断EVAL local val redis.call(GET, counter) if tonumber(val) 0 then redis.call(DECR, counter) return 1 else return 0 end 02. 减少网络往返网络往返对比 普通命令模式: Lua脚本模式: Client ──── GET key ──── Server Client ──── EVAL script ──── Server Client ─── value ────── Server Client ─── result ────────── Server Client ──── SET key ──── Server Client ─── OK ────────── Server 1次网络往返搞定一切 Client ──── INCR key ─── Server Client ─── 2 ─────────── Server 4次网络往返 1次网络往返如果你的应用服务器和Redis之间有2ms的网络延迟4次命令就是8ms而Lua脚本只要2ms。在微服务架构中这个差距可能更大。EVAL命令语法EVAL是执行Lua脚本的大门语法如下EVAL script numkeys key[key...]arg[arg...]看着有点复杂我们来拆解一下EVAL 语法分解 EVAL return redis.call(SET, KEYS[1], ARGV[1]) 1 mykey myvalue ──── ──────────────────────────────────────────── ─ ───── ──────── │ │ │ │ │ │ │ │ │ └─ ARGV[1] myvalue │ │ │ └──────── KEYS[1] mykey │ │ └──────── numkeys 1 │ └─────────────────────────────────────────── scriptLua代码 └─────────────────────────────────────────────────── EVAL命令各参数含义参数说明示例scriptLua脚本代码return 1 1numkeysKEYS数组的长度2key [key …]传入的键名通过KEYS[]访问key1 key2arg [arg …]附加参数通过ARGV[]访问arg1 arg2⚠️ 注意强烈建议所有key都通过KEYS[]传入而不是在脚本里硬编码。原因有二一是Redis Cluster要求脚本中访问的key必须在同一个slot上通过KEYS[]传入可以让Redis做正确性检查二是硬编码key的脚本无法复用。一个完整的示例# 设置key并设置过期时间EVALredis.call(SET, KEYS[1], ARGV[1]); redis.call(EXPIRE, KEYS[1], ARGV[2]); return OK1mykey myvalue60这段脚本做了两件事设置mykey的值为myvalue并设置60秒过期。一次网络往返原子执行。redis.call vs redis.pcall在Lua脚本中调用Redis命令有两种方式方式错误处理返回值redis.call(cmd, ...)抛出错误脚本终止命令返回值redis.pcall(cmd, ...)返回错误对象脚本继续命令返回值或error对象# redis.call: 遇到错误直接炸EVAL redis.call(SET, foo, bar) redis.call(INCR, foo) -- foo是字符串INCR会报错 redis.call(SET, baz, qux) -- 这行不会执行 return done 0# → (error) ERR value is not an integer or out of range# → 整个脚本回滚foo和baz都没设置成功# redis.pcall: 遇到错误不炸返回error对象EVAL redis.call(SET, foo, bar) local result redis.pcall(INCR, foo) -- 返回error对象 redis.call(SET, baz, qux) -- 继续执行 return {type(result), result} -- 可以检查result的类型 0# → 注意pcall不会让脚本终止但Redis 5.0中pcall返回error后脚本中的# 写操作不会被回滚这是和call的关键区别⚠️ 注意在Redis中如果Lua脚本通过redis.call()触发了错误导致脚本终止脚本中所有已经执行的写命令都会被回滚从Redis 3.2开始。这和MULTI/EXEC事务的运行时错误不回滚形成了鲜明对比——Lua脚本的原子性比事务更强实际开发中redis.call用得更多因为大多数情况下我们希望出错就停止。redis.pcall适合需要自己做错误处理的场景。KEYS[]和ARGV[]数组KEYS和ARGV是Lua脚本和外界通信的桥梁EVALreturn {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}2key1 key2 arg1 arg2注意Lua的数组索引从1开始不是0这是Lua最让程序员抓狂的设计之一-- Lua数组索引从1开始KEYS[1]-- 第一个keyKEYS[2]-- 第二个keyARGV[1]-- 第一个附加参数ARGV[2]-- 第二个附加参数-- 遍历所有keyfori1,#KEYSdoredis.call(GET,KEYS[i])end数组索引起始用途建议KEYS[]1传入key名称必须用KEYS传入keyARGV[]1传入其他参数用ARGV传入value、条件值等⚠️ 注意千万别在脚本里硬编码key名。比如redis.call(GET, mykey)就很危险——在Redis Cluster中如果脚本访问了不在同一slot的key会直接报错。正确做法是通过KEYS[]传入key让Redis做正确性校验。Lua脚本示例原子性getset-if-not-exists这是一个经典的实战案例只在key不存在时设置值。虽然Redis有SETNX命令但如果我们需要更复杂的逻辑比如设置值后还要做一些额外的操作SETNX就不够用了。# 需求如果key不存在设置为指定值并返回1否则返回0# 而且设置成功后还要记录一条操作日志EVAL if redis.call(EXISTS, KEYS[1]) 0 then redis.call(SET, KEYS[1], ARGV[1]) redis.call(LPUSH, KEYS[2], ARGV[2]) return 1 else return 0 end 2mykey oplog myvalueset mykey脚本执行流程 ┌─────────────────────────────────────┐ │ EXISTS mykey → 0 (不存在) │ │ │ │ │ ▼ │ │ SET mykey myvalue → OK │ │ │ │ │ ▼ │ │ LPUSH oplog set mykey → 1 │ │ │ │ │ ▼ │ │ return 1 │ │ │ │ ✓ 三步操作原子执行 │ │ ✓ 不可能被其他客户端打断 │ │ ✓ 要么全部成功要么全部回滚 │ └─────────────────────────────────────┘如果用MULTI/EXEC实现同样的逻辑抱歉做不到——因为事务里没有if/else。Lua沙箱环境Redis中的Lua运行在一个严格的沙箱中你不能为所欲为禁止的功能类别禁止的内容原因文件操作io.open(),io.read()安全性系统调用os.execute(),os.exit()安全性文件操作file:read(),file:write()安全性模块加载require()安全性网络操作socket相关安全性允许的功能类别可用的内容说明标准库string,table,math,bit基础库位操作bit.tobit(),bit.band()位运算cjsoncjson.decode(),cjson.encode()JSON处理Redis 2.6cmsgpackcmsgpack.pack(),cmsgpack.unpack()MessagePackRedis 2.6Redis命令redis.call(),redis.pcall()调用Redis命令Redis状态redis.log(),redis.sha1hex()日志和哈希随机数math.random()但有特殊限制⚠️ 注意Lua脚本中禁止使用math.random的seed函数也不允许调用redis.rand()等随机命令如SRANDMEMBER、RANDOMKEY因为在主从复制中主从节点的随机结果可能不同会导致数据不一致。Redis 3.2引入了redis.replicate_commands()来部分解决这个问题。全局变量保护Redis默认禁止在Lua脚本中创建全局变量EVALmyvar 123; return myvar0# → (error) SCRIPTING: Attempt to create global variable myvar你必须使用local变量localmyvar123-- 正确returnmyvar这是为了防止不同脚本之间的变量污染。Lua脚本的原子性这是Lua脚本最核心的卖点——整个脚本是原子执行的。Lua 脚本执行模型 Client-A: EVAL script1 ... ──────── 执行中...其他客户端等待 │ Client-B: GET key ────────────── 等待...等待...等待... │ Client-C: SET key value ─────── 等待...等待...等待... │ Client-A: EVAL script1 完成 ─── ──── │ │ Client-B/C: 排队执行... ─────────── │在Lua脚本执行期间Redis不会处理任何其他客户端的命令。这和EXEC一样都是单线程模型带来的天然隔离。但Lua脚本比事务更强的原子性体现在特性MULTI/EXECLua脚本运行时错误不回滚跳过出错命令整体回滚条件逻辑不支持支持复杂计算不支持支持⚠️ 注意Lua脚本的原子性是把双刃剑。如果你的脚本执行时间太长超过lua-time-limit默认5秒其他客户端就会被饿死全部阻塞等待。所以Lua脚本必须短小精悍绝对不能有耗时操作。EVALSHA和SCRIPT LOAD每次EVAL都要把完整的Lua脚本传给Redis如果脚本很长网络开销就很大。EVALSHA就是来解决这个问题# 第一步加载脚本获取SHA1校验和SCRIPT LOADreturn redis.call(SET, KEYS[1], ARGV[1])# → 55b22c0c6ba3a7e0f0a6d8d0b6f0e1c2d3b4a5c6# 第二步用SHA1校验和执行脚本EVALSHA 55b22c0c6ba3a7e0f0a6d8d0b6f0e1c2d3b4a5c61mykey myvalue# → OKEVAL vs EVALSHA EVAL: Client ──── [完整Lua脚本 参数] ──── Server (脚本可能几百字节到几KB) 网络传输开销大 EVALSHA: Client ──── [40字符SHA1 参数] ──── Server (只需要40字节) 网络传输开销小如果EVALSHA的SHA1在Redis中找不到可能因为重启导致脚本缓存丢失Redis会返回NOSCRIPT错误客户端需要降级为EVAL重新发送完整脚本。EVALSHA abc123...1mykey myvalue# → (error) NOSCRIPT No matching script. Please use EVAL instead.# 降级处理EVALreturn redis.call(SET, KEYS[1], ARGV[1])1mykey myvalue# → OK⚠️ 注意好的客户端库如Jedis、Lettuce、redis-py都已经内置了EVALSHA的降级逻辑——先尝试EVALSHA收到NOSCRIPT错误后自动改用EVAL。你不需要自己处理这个降级过程。返回值类型转换规则Lua脚本返回值和Redis类型之间有自动转换规则这个规则有点反直觉Lua类型Redis返回示例numberIntegerreturn 1→(integer) 1stringBulk Stringreturn hello→hellotable数组Array/Multi Bulkreturn {1,2,3}→1) 1 2) 2 3) 3trueInteger 1return true→(integer) 1falseNullreturn false→(nil)nilNullreturn nil→(nil)几个容易踩的坑# 坑1Lua的浮点数会被截断为整数EVALreturn 3.140# → (integer) 3 ← 小数部分丢了# 坑2返回false得到nil不是0EVALreturn false0# → (nil) ← 不是0也不是false字符串# 坑3Lua table的坑——如果有nil间隙后续元素会被截断EVALreturn {1, nil, 3}0# → 1) (integer) 1 ← 只返回到nil之前的元素⚠️ 注意如果你需要返回浮点数必须先转成字符串return tostring(3.14)→3.14。Redis协议本身不支持浮点数只有String类型能承载。实战案例用Lua实现分布式限流器这是一个经典的生产级案例令牌桶限流器。我们需要确保检查令牌数量和扣减令牌是原子操作否则就会出现超卖问题。需求描述每个用户每分钟最多允许60次请求使用滑动窗口算法原子操作检查扣减必须一气呵成Lua脚本实现# 限流器Lua脚本EVAL local key KEYS[1] -- 限流key如 rate_limit:user:123 local limit tonumber(ARGV[1]) -- 最大请求数 local window tonumber(ARGV[2]) -- 时间窗口秒 local now tonumber(ARGV[3]) -- 当前时间戳毫秒 -- 获取当前计数 local current tonumber(redis.call(GET, key) or 0) if current limit then -- 未超限计数1 redis.call(INCR, key) -- 如果是第一次设置添加过期时间 if current 0 then redis.call(EXPIRE, key, window) end return 1 -- 允许访问 else -- 超限拒绝 return 0 -- 拒绝访问 end 1rate_limit:user:12360601685100000000更精确的滑动窗口实现上面的实现是固定窗口存在边界问题。下面是更精确的滑动窗口实现EVAL local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local now tonumber(ARGV[3]) -- 移除过期的请求记录 redis.call(ZREMRANGEBYSCORE, key, 0, now - window * 1000) -- 获取当前窗口内的请求数 local count redis.call(ZCARD, key) if count limit then -- 添加当前请求 redis.call(ZADD, key, now, now .. : .. math.random(1000000)) -- 设置过期时间防止key永远存在 redis.call(EXPIRE, key, window) return 1 else return 0 end 1rate_limit:user:12360601685100000000滑动窗口限流器工作原理 时间轴: ──────────────────────────────► │← 60秒窗口 →│ │ │ 旧请求(x) [x x x x x x] [当前请求] │ │ │ count 6 │ │ limit 60 │ │ 6 60 │ │ → 放行 │ 当count ≥ limit时: [x x x x x x x x ... x] [当前请求] 60个请求已存在 → 拒绝这个实现的关键是使用了Sorted Set以时间戳作为score这样可以精确地移除过期请求、统计当前窗口内的请求数。整个移除过期→统计→添加的过程是原子执行的不会出现并发问题。Java客户端调用示例// Spring Data Redis 调用Lua脚本Stringscriptlocal key KEYS[1] ...;DefaultRedisScriptLongredisScriptnewDefaultRedisScript();redisScript.setScriptText(script);redisScript.setResultType(Long.class);LongresultredisTemplate.execute(redisScript,Collections.singletonList(rate_limit:user:userId),60,60,System.currentTimeMillis());if(result1){// 允许访问}else{// 限流拒绝}Lua脚本 vs 事务对比综合以上内容来个终极对比对比维度MULTI/EXECLua脚本推荐选择原子性弱运行时错误不回滚强整体回滚Lua条件判断不支持支持Lua循环不支持支持Lua网络往返2次1次Lua可读性高中事务调试难度低高事务学习成本低中事务执行时间不可控可控但可能很长看场景集群兼容好需注意key同slot看场景选择建议简单的批量操作 → MULTI/EXEC需要条件判断 → Lua脚本需要强原子性 → Lua脚本需要减少网络往返 → Lua脚本只是做简单的CAS → WATCH MULTI本章小结Lua 脚本核心要点 ┌──────────────────────────────────────────────┐ │ EVAL script numkeys key... arg... │ │ │ │ 核心优势: │ │ ✓ 原子执行比事务更强 │ │ ✓ 条件判断if/else │ │ ✓ 循环操作for/while │ │ ✓ 减少网络往返1次EVAL搞定 │ │ │ │ 注意事项: │ │ ✗ 不能执行耗时操作阻塞其他客户端 │ │ ✗ key必须通过KEYS[]传入Cluster兼容 │ │ ✗ 返回浮点数要先转字符串 │ │ ✗ 数组索引从1开始不是0 │ └──────────────────────────────────────────────┘Lua脚本是Redis最强大的特性之一它让Redis从一个数据结构服务器变成了可编程数据结构服务器。掌握Lua脚本你就掌握了Redis的高级玩法。下一篇我们将深入探讨EVALSHA的原理和脚本管理的最佳实践——包括脚本缓存、调试、在主从复制中的行为等等。上一篇【第56篇】Redis事务的ACID分析——它到底算不算ACID事务下一篇【第58篇】EVALSHA与脚本管理——Redis脚本的缓存与复制