上一篇【第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脚本的缓存与复制


更多推荐