Redis Lua 脚本:从入门到避坑指南

一、为什么是 Lua?

Redis 从 2.6.0 版本开始内置了 Lua 解释器。这意味着:

  • 零依赖:不需要像 Java 加 Maven 依赖、Python pip 安装库那样做任何前置准备。
  • 开箱即用 :只要 Redis 版本 ≥ 2.6,直接用 EVAL 命令就能跑。

Lua 在 Redis 里的角色是胶水语言,用它把多个 Redis 命令和业务判断逻辑打包成一个整体,交给 Redis 服务端一次性执行。


二、核心语法速查(Java 开发者视角)

1. 大小写敏感

Lua 严格区分大小写return 不能写成 ReturnKEYS 不能写成 keys。这和 Java 一致。

2. 变量与 local

lua 复制代码
local count = 10          -- 局部变量,强烈推荐
global_var = "hello"      -- 全局变量,危险,会污染环境

铁律:所有变量都用 local 声明。 这既是为了作用域隔离,也是为了性能。

3. 常用类型

Lua 是动态类型语言,变量没类型,值有类型。

类型 写法 备注
数字 local n = 10
字符串 local s = "hello" 拼接用 ..
布尔 local ok = true 只有 falsenil 为假,0 和空字符串都为真!
local x = nil
数组 local arr = {"a", "b"} 索引从 1 开始
字典 local map = {name="Tom"} 访问:map.namemap["name"]

4. 逻辑运算符

和 Java 的对照:

Java Lua
!a not a
a && b a and b
`a

Lua 的 and/or 是短路求值,返回值本身而不是强制转 boolean。常用于设默认值:

lua 复制代码
local count = redis.call('GET', KEYS[1]) or 0

5. 常用内置函数

函数 作用 示例
tonumber(str) 字符串转数字 tonumber("123") → 123
tostring(val) 转字符串 tostring(100) → "100"
type(val) 判断类型 type(nil) → "nil"
string.sub(s, i, j) 截取子串 string.sub("hello", 1, 3) → "hel"
string.len(s) 字符串长度 string.len("hi") → 2
math.abs(n) 绝对值 math.abs(-5) → 5
math.floor(n) 向下取整 math.floor(3.9) → 3
math.ceil(n) 向上取整 math.ceil(3.1) → 4
math.max(a,b) 最大值 math.max(1,5) → 5

重要ARGV 里的参数全部是字符串,需要用 tonumber() 转成数字才能做算术运算。


三、EVAL 命令详解

命令格式

复制代码
EVAL <脚本内容> <key的数量> <key列表...> <arg列表...>

中间那个数字是干什么的?

它是 KEYS 和 ARGV 的分割标识,告诉 Redis "前面 N 个参数是 KEY,剩下的全是 ARGV"。

示例:

redis 复制代码
EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 key1 key2 arg1 arg2
  • 2 表示前 2 个是 KEY:key1, key2
  • 剩下的全是 ARGV:arg1, arg2

为什么必须写这个数字?

  1. 集群路由:Redis Cluster 需要知道哪些是 key,才能校验它们是否在同一个 slot,并正确路由。
  2. 代码可读性:显式声明让脚本意图清晰,不用猜。
  3. 安全校验:Redis 可在执行前做静态分析。

即使脚本完全不用 KEYS,也必须写 0

redis 复制代码
EVAL "return 'hello'" 0

脚本里只有 KEYS 和 ARGV 两个预置数组吗?

是的。 Redis 的 Lua 沙箱里,除了 KEYSARGV,没有任何其他外部数据来源。你需要的一切数据,都得在脚本里通过 redis.call 获取。


四、Redis 与 Lua 的联动机制

执行流程(四个步骤)

  1. 解析参数 :Redis 根据 numkeys 把参数切分成 KEYS 和 ARGV。
  2. 注入沙箱:启动 Lua 解释器,创建隔离环境,注入 KEYS 和 ARGV。
  3. Lua 执行,回调 Redis :脚本通过 redis.callredis.pcall 向宿主 Redis 发请求。脚本暂停→Redis 执行命令→返回结果给脚本→脚本继续。
  4. 返回结果:Lua 脚本执行完,结果转成 Redis 协议返回给客户端。

redis.call vs redis.pcall

  • redis.call('GET', KEYS[1]):执行 Redis 命令,出错会中断脚本并抛错
  • redis.pcall('GET', KEYS[1]):执行 Redis 命令,出错不中断,以 table 形式返回错误信息

pcall 可以让你在脚本内部做错误处理:

lua 复制代码
local res = redis.pcall('GET', KEYS[1])
if type(res) == 'table' and res['err'] then
    return "出错了"
end

整个脚本执行期间,其他命令必须排队

这是真的,而且是所有命令都排队。 原因是 Redis 的单线程模型:主线程在跑 Lua 时,不可能同时处理其他请求。

后果:脚本必须快(毫秒级),否则整个 Redis 会被阻塞,导致服务雪崩。

边界:这只阻塞这一个 Redis 实例的主线程,不影响你的 Java 应用、MySQL、Nginx 等其他进程。


五、原子性:最大的优势与最大的陷阱

能保证什么

绝对的执行原子性:脚本一旦开始,绝不会被其他命令插队。Redis 把它当成一个不可分割的"超级命令"。

不能保证什么

没有事务回滚。脚本执行中途如果服务器崩溃,已执行的写操作不会自动回滚。

示例:

lua 复制代码
redis.call('SET', 'key1', 'a')  -- 执行了
-- 此时断电
redis.call('SET', 'key2', 'b')  -- 没执行

结果:key1 是新值,key2 还是老样子,数据处于不一致状态。

解决方案

把所有校验逻辑放在脚本最前面,写入操作集中在最后几行,缩小风险窗口:

lua 复制代码
-- 先做全部检查(不改变数据)
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) <= 0 then
    return -1
end
-- 检查通过后再集中写入
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 1

六、经典实战:秒杀场景的并发控制

不用 Lua(错误方案)

python 复制代码
stock = redis.get("stock:001")
if int(stock) > 0:
    redis.decr("stock:001")

问题 :A 和 B 同时 get 到 1,各自判断 >0 后各自执行 decr,结果库存变 -1,超卖

用 Lua(正确方案)

lua 复制代码
-- KEYS[1]: 库存key, KEYS[2]: 已抢用户set, ARGV[1]: 用户ID
local stock = redis.call('DECR', KEYS[1])
if stock < 0 then
    redis.call('INCR', KEYS[1])  -- 回滚
    return -1  -- 卖完
end
redis.call('SADD', KEYS[2], ARGV[1])  -- 记录用户
return stock

效果:A 的脚本执行期间,B 的脚本完全进不来。A 执行完,B 再执行时看到的库存已经是 0(或 -1 被回滚),无法多抢。


七、Java 实战:怎么在代码里用 Lua

1. 引入依赖(Spring Boot 项目)

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

这自带 Lettuce 客户端,原生支持 Lua。

2. 定义脚本(推荐用 DefaultRedisScript

java 复制代码
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;

// 放在静态变量里,避免重复创建
private static final String SECKILL_SCRIPT = 
    "local stock = redis.call('DECR', KEYS[1]) " +
    "if stock < 0 then " +
    "    redis.call('INCR', KEYS[1]) " +
    "    return -1 " +
    "end " +
    "redis.call('SADD', KEYS[2], ARGV[1]) " +
    "return stock";

private static final RedisScript<Long> SECKILL = 
    new DefaultRedisScript<>(SECKILL_SCRIPT, Long.class);

3. 执行脚本

java 复制代码
@Autowired
private StringRedisTemplate stringRedisTemplate;

public Long doSeckill(String stockKey, String usersKey, String userId) {
    List<String> keys = Arrays.asList(stockKey, usersKey);
    Object[] args = { userId };
    
    return stringRedisTemplate.execute(
        SECKILL,   // 脚本对象
        keys,      // KEYS 列表
        args       // ARGV 列表
    );
}

4. 用 EVALSHA 优化性能

每次传完整脚本有网络开销。可以提前加载脚本到 Redis,后续用 SHA1 哈希调用:

java 复制代码
// 加载脚本
String sha1 = stringRedisTemplate.getConnectionFactory()
    .getConnection().scriptLoad(SECKILL_SCRIPT.getBytes());

// 用 SHA1 执行
DefaultRedisScript<Long> cachedScript = new DefaultRedisScript<>();
cachedScript.setSha1(sha1);
cachedScript.setResultType(Long.class);

stringRedisTemplate.execute(cachedScript, keys, args);

Spring 的 DefaultRedisScript 默认行为 :第一次执行时用 EVAL,失败则尝试 EVALSHA,透明处理缓存,一般无需手动干预。


八、工程化避坑清单(重要!)

1. 脚本必须短小精悍

  • 禁止 :大循环、复杂正则、遍历大集合(如 SMEMBERS 百万级 Set)。
  • 原则:只做快速判断 + 少量原子操作,耗时控制在毫秒级。

2. 永远设置超时

java 复制代码
// 应用层设置网络超时
@Bean
public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<?, ?> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    // 关键:设置命令超时,避免被慢脚本卡死
    template.setDefaultTimeout(Duration.ofSeconds(2));
    return template;
}

3. 集群模式下,所有 KEY 必须在同一个 slot

Redis Cluster 要求 Lua 脚本操作的所有 key 必须在同一节点。常用的技巧是用 hash tag

redis 复制代码
EVAL "..." 2 {order}:stock {order}:users

{order} 告诉 Redis,只对花括号里的部分做哈希计算,保证这两个 key 落在同一个 slot。

4. 脚本里不要有死循环

lua 复制代码
while true do
    -- Redis 主线程会卡死,只能靠 `SCRIPT KILL` 强制中断
    -- 如果脚本已经执行过写命令,SHUTDOWN NOSAVE 是最后手段
end

Redis 7.0+ 有 lua-time-limit 保护(默认 5 秒),但仍然不建议测试这个底线。

5. 合理拆分脚本

不要把一个大而全的脚本用于所有场景。按职责拆分:扣库存一个脚本、发优惠券一个脚本、记录日志一个脚本。保持原子性边界最小化。

6. 充分测试边界条件

  • 库存为 0 时
  • 参数为空时
  • 参数非法时(传了非数字)
  • 并发压测验证原子性

九、总结:Lua 在 Redis 中的独特定位

维度 常规编程语言(Java/Python) Redis Lua 脚本
执行位置 客户端 服务端(靠近数据)
网络开销 N 次往返 1 次
原子性 需借助事务/分布式锁 天然原子性
逻辑能力 无限 受限(但够用)
复杂度 灵活,但容易出错 简单,脚本短小即安全

一句话总结:Redis Lua 脚本解决的是"需要在一次往返中,原子性地执行多条 Redis 命令且夹杂业务判断"这个特定问题。它不是用来取代 Java 的,而是让你的 Java 应用在处理某些 Redis 操作时,更安全、更高效。


记录时间:2026-06-10

核心语言环境:Java + Spring Boot + Redis

相关推荐
IT大白鼠1 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz1 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙1 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除1 天前
redis--018
redis
两锭子1 天前
Redis基础
redis
烟沙九洲1 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索1 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞1 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路2 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog2 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis