一、为什么是 Lua?
Redis 从 2.6.0 版本开始内置了 Lua 解释器。这意味着:
- 零依赖:不需要像 Java 加 Maven 依赖、Python pip 安装库那样做任何前置准备。
- 开箱即用:只要 Redis 版本 ≥ 2.6,直接用
EVAL命令就能跑。
Lua 在 Redis 里的角色是胶水语言,用它把多个 Redis 命令和业务判断逻辑打包成一个整体,交给 Redis 服务端一次性执行。
二、核心语法速查(Java 开发者视角)
1. 大小写敏感
Lua 严格区分大小写。return 不能写成 Return,KEYS 不能写成 keys。这和 Java 一致。
2. 变量与local
local count = 10 -- 局部变量,强烈推荐 global_var = "hello" -- 全局变量,危险,会污染环境
铁律:所有变量都用 local 声明。 这既是为了作用域隔离,也是为了性能。
3. 常用类型
Lua 是动态类型语言,变量没类型,值有类型。
| 类型 | 写法 | 备注 |
|---|---|---|
| 数字 | local n = 10 | |
| 字符串 | local s = "hello" | 拼接用 .. |
| 布尔 | local ok = true | 只有 false 和 nil 为假,0 和空字符串都为真! |
| 空 | local x = nil | |
| 数组 | local arr = {"a", "b"} | 索引从 1 开始 |
| 字典 | local map = {name="Tom"} | 访问:map.name 或 map["name"] |
4. 逻辑运算符
和 Java 的对照:
| Java | Lua |
|---|---|
| !a | not a |
| a && b | a and b |
| a || b | a or b |
Lua 的 and/or 是短路求值,返回值本身而不是强制转 boolean。常用于设默认值:
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”。
示例:
EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 key1 key2 arg1 arg2
- 2 表示前 2 个是 KEY:key1, key2
- 剩下的全是 ARGV:arg1, arg2
为什么必须写这个数字?
- 集群路由:Redis Cluster 需要知道哪些是 key,才能校验它们是否在同一个 slot,并正确路由。
- 代码可读性:显式声明让脚本意图清晰,不用猜。
- 安全校验:Redis 可在执行前做静态分析。
即使脚本完全不用 KEYS,也必须写 0:
EVAL "return 'hello'" 0
脚本里只有 KEYS 和 ARGV 两个预置数组吗?
是的。 Redis 的 Lua 沙箱里,除了 KEYS 和 ARGV,没有任何其他外部数据来源。你需要的一切数据,都得在脚本里通过 redis.call 获取。
四、Redis 与 Lua 的联动机制
执行流程(四个步骤)
- 解析参数:Redis 根据 numkeys 把参数切分成 KEYS 和 ARGV。
- 注入沙箱:启动 Lua 解释器,创建隔离环境,注入 KEYS 和 ARGV。
- Lua 执行,回调 Redis:脚本通过 redis.call 或 redis.pcall 向宿主 Redis 发请求。脚本暂停→Redis 执行命令→返回结果给脚本→脚本继续。
- 返回结果:Lua 脚本执行完,结果转成 Redis 协议返回给客户端。
redis.callvsredis.pcall
- redis.call('GET', KEYS[1]):执行 Redis 命令,出错会中断脚本并抛错。
- redis.pcall('GET', KEYS[1]):执行 Redis 命令,出错不中断,以 table 形式返回错误信息。
用 pcall 可以让你在脚本内部做错误处理:
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 把它当成一个不可分割的“超级命令”。
不能保证什么
没有事务回滚。脚本执行中途如果服务器崩溃,已执行的写操作不会自动回滚。
示例:
redis.call('SET', 'key1', 'a') -- 执行了
-- 此时断电
redis.call('SET', 'key2', 'b') -- 没执行
结果:key1 是新值,key2 还是老样子,数据处于不一致状态。
解决方案
把所有校验逻辑放在脚本最前面,写入操作集中在最后几行,缩小风险窗口:
-- 先做全部检查(不改变数据)
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(错误方案)
stock = redis.get("stock:001")
if int(stock) > 0:
redis.decr("stock:001")
问题:A 和 B 同时 get 到 1,各自判断 >0 后各自执行 decr,结果库存变 -1,超卖。
用 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 项目)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
这自带 Lettuce 客户端,原生支持 Lua。
2. 定义脚本(推荐用DefaultRedisScript)
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. 执行脚本
@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 哈希调用:
// 加载脚本
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. 永远设置超时
// 应用层设置网络超时
@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:
EVAL "..." 2 {order}:stock {order}:users
{order} 告诉 Redis,只对花括号里的部分做哈希计算,保证这两个 key 落在同一个 slot。
4. 脚本里不要有死循环
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 操作时,更安全、更高效。













