一、为什么是Lua?
从Redis 2.6.0版本开始,它就内置了Lua解释器,这带来了几个非常直接的好处:

- 零依赖:不像Ja va要加Ma ven依赖,也不像Python要pip install各种库,什么都不用装,拿来就用。
- 开箱即用:只要Redis版本是2.6及以上,直接用
EVAL命令就能跑起来。
Lua在Redis里扮演的是胶水语言的角色,用来把多个Redis命令和一些业务判断逻辑打包成一个整体,一次性的交给Redis服务端执行。
二、核心语法速查(Ja va开发者视角)
1. 大小写敏感
Lua严格区分大小写。return写成Return是绝对不行的,KEYS也不能写成keys。这一点和Ja va完全一致。
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. 逻辑运算符
和Ja va的对照关系:
| Ja va | 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 <脚本内容>
中间那个数字是干什么的?
它实际是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.call vs redis.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实例的主线程,不影响你的Ja va应用、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被回滚),无法多抢。
七、Ja va实战:怎么在代码里用Lua
1. 引入依赖(Spring Boot项目)
org.springframework.boot spring-boot-starter-data-redis
这自带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 SECKILL =
new DefaultRedisScript<>(SECKILL_SCRIPT, Long.class);
3. 执行脚本
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Long doSeckill(String stockKey, String usersKey, String userId) {
List 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 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 NOSA VE 是最后手段
end
Redis 7.0+有lua-time-limit保护(默认5秒),但建议不要测试这个底线。
5. 合理拆分脚本
不要把一个大而全的脚本用于所有场景。按职责拆分开——扣库存一个脚本、发优惠券一个脚本、记录日志一个脚本,保持原子性边界最小化。
6. 充分测试边界条件
- 库存为0时
- 参数为空时
- 参数非法时(传了非数字)
- 并发压测验证原子性
九、总结:Lua在Redis中的独特定位
| 维度 | 常规编程语言(Ja va/Python) | Redis Lua 脚本 |
|---|---|---|
| 执行位置 | 客户端 | 服务端(靠近数据) |
| 网络开销 | N 次往返 | 1 次 |
| 原子性 | 需借助事务/分布式锁 | 天然原子性 |
| 逻辑能力 | 无限 | 受限(但够用) |
| 复杂度 | 灵活,但容易出错 | 简单,脚本短小即安全 |
一句话总结:Redis Lua脚本解决的问题很明确——“需要在一次往返中,原子性地执行多条Redis命令,同时还要夹杂业务判断”。它不是用来取代Ja va的,而是让Ja va应用在处理某些Redis操作时,更安全、更高效。
