【Redis从入门到精通】第57篇:Lua脚本——Redis里跑JavaScript的表亲
上一篇【第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
" 0
2. 减少网络往返
网络往返对比
普通命令模式: 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
│ └─────────────────────────────────────────── script(Lua代码)
└─────────────────────────────────────────────────── EVAL命令
各参数含义:
| 参数 | 说明 | 示例 |
|---|---|---|
| script | Lua脚本代码 | "return 1 + 1" |
| numkeys | KEYS数组的长度 | 2 |
| key [key …] | 传入的键名,通过KEYS[]访问 | key1 key2 |
| arg [arg …] | 附加参数,通过ARGV[]访问 | arg1 arg2 |
⚠️ 注意:强烈建议所有key都通过KEYS[]传入,而不是在脚本里硬编码。原因有二:一是Redis Cluster要求脚本中访问的key必须在同一个slot上,通过KEYS[]传入可以让Redis做正确性检查;二是硬编码key的脚本无法复用。
一个完整的示例:
# 设置key并设置过期时间
EVAL "redis.call('SET', KEYS[1], ARGV[1]); redis.call('EXPIRE', KEYS[1], ARGV[2]); return 'OK'" 1 mykey myvalue 60
这段脚本做了两件事:设置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脚本和外界通信的"桥梁":
EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 key1 key2 arg1 arg2
注意Lua的数组索引从1开始,不是0!这是Lua最让程序员抓狂的设计之一:
-- Lua数组索引从1开始!
KEYS[1] -- 第一个key
KEYS[2] -- 第二个key
ARGV[1] -- 第一个附加参数
ARGV[2] -- 第二个附加参数
-- 遍历所有key
for i = 1, #KEYS do
redis.call('GET', KEYS[i])
end
| 数组 | 索引起始 | 用途 | 建议 |
|---|---|---|---|
| KEYS[] | 1 | 传入key名称 | 必须用KEYS传入key |
| ARGV[] | 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
" 2 mykey oplog myvalue "set 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() |
位运算 |
| cjson | cjson.decode(), cjson.encode() |
JSON处理(Redis 2.6+) |
| cmsgpack | cmsgpack.pack(), cmsgpack.unpack() |
MessagePack(Redis 2.6+) |
| Redis命令 | 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脚本中创建全局变量:
EVAL "myvar = 123; return myvar" 0
# → (error) SCRIPTING: Attempt to create global variable 'myvar'
你必须使用local变量:
local myvar = 123 -- 正确
return myvar
这是为了防止不同脚本之间的变量污染。
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/EXEC | Lua脚本 |
|---|---|---|
| 运行时错误 | 不回滚,跳过出错命令 | 整体回滚 |
| 条件逻辑 | 不支持 | 支持 |
| 复杂计算 | 不支持 | 支持 |
⚠️ 注意:Lua脚本的原子性是把双刃剑。如果你的脚本执行时间太长(超过lua-time-limit,默认5秒),其他客户端就会被"饿死",全部阻塞等待。所以Lua脚本必须短小精悍,绝对不能有耗时操作。
EVALSHA和SCRIPT LOAD
每次EVAL都要把完整的Lua脚本传给Redis,如果脚本很长,网络开销就很大。EVALSHA就是来解决这个问题:
# 第一步:加载脚本,获取SHA1校验和
SCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])"
# → "55b22c0c6ba3a7e0f0a6d8d0b6f0e1c2d3b4a5c6"
# 第二步:用SHA1校验和执行脚本
EVALSHA 55b22c0c6ba3a7e0f0a6d8d0b6f0e1c2d3b4a5c6 1 mykey myvalue
# → OK
EVAL vs EVALSHA
EVAL:
Client ──── [完整Lua脚本 + 参数] ────> Server
(脚本可能几百字节到几KB)
网络传输开销大
EVALSHA:
Client ──── [40字符SHA1 + 参数] ────> Server
(只需要40字节)
网络传输开销小
如果EVALSHA的SHA1在Redis中找不到(可能因为重启导致脚本缓存丢失),Redis会返回NOSCRIPT错误,客户端需要降级为EVAL重新发送完整脚本。
EVALSHA abc123... 1 mykey myvalue
# → (error) NOSCRIPT No matching script. Please use EVAL instead.
# 降级处理
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue
# → OK
⚠️ 注意:好的客户端库(如Jedis、Lettuce、redis-py)都已经内置了EVALSHA的降级逻辑——先尝试EVALSHA,收到NOSCRIPT错误后自动改用EVAL。你不需要自己处理这个降级过程。
返回值类型转换规则
Lua脚本返回值和Redis类型之间有自动转换规则,这个规则有点"反直觉":
| Lua类型 | Redis返回 | 示例 |
|---|---|---|
| number | Integer | return 1 → (integer) 1 |
| string | Bulk String | return "hello" → "hello" |
| table(数组) | Array/Multi Bulk | return {1,2,3} → 1) 1 2) 2 3) 3 |
| true | Integer 1 | return true → (integer) 1 |
| false | Null | return false → (nil) |
| nil | Null | return nil → (nil) |
几个容易踩的坑:
# 坑1:Lua的浮点数会被截断为整数
EVAL "return 3.14" 0
# → (integer) 3 ← 小数部分丢了!
# 坑2:返回false得到nil,不是0
EVAL "return false" 0
# → (nil) ← 不是0也不是"false"字符串
# 坑3:Lua table的坑——如果有nil间隙,后续元素会被截断
EVAL "return {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
" 1 rate_limit:user:123 60 60 1685100000000
更精确的滑动窗口实现
上面的实现是固定窗口,存在边界问题。下面是更精确的滑动窗口实现:
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
" 1 rate_limit:user:123 60 60 1685100000000
滑动窗口限流器工作原理
时间轴: ──────────────────────────────►
│← 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脚本
String script = "local key = KEYS[1] ...";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
Long result = redisTemplate.execute(
redisScript,
Collections.singletonList("rate_limit:user:" + userId),
60, 60, System.currentTimeMillis()
);
if (result == 1) {
// 允许访问
} else {
// 限流拒绝
}
Lua脚本 vs 事务对比
综合以上内容,来个"终极对比":
| 对比维度 | MULTI/EXEC | Lua脚本 | 推荐选择 |
|---|---|---|---|
| 原子性 | 弱(运行时错误不回滚) | 强(整体回滚) | 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脚本的缓存与复制
更多推荐
所有评论(0)