Lua 脚本是 Redis 原子性的灵魂。为什么 Redis 的事务不靠谱?为什么限流、分布式锁、防超卖都要靠 Lua?这篇把 Lua 在 Redis 里的作用讲透。
一、为什么要用 Lua 脚本?
1.1 先看一个真实痛点
假设你要做"限流"——控制某个接口 1 秒内最多被调用 10 次。用 Redis 实现:
1. 用 INCR 给计数器加1
2. 判断加完的值是否超过10
这两个操作,如果分开执行:
服务A:INCR counter → 得到11
服务A:准备判断 11 > 10(但还没判断)
服务B:INCR counter → 得到12
服务A:判断 11 > 10,返回"超限"
服务B:判断 12 > 10,返回"超限"
好像没问题?但如果要求是原子的——“加1和判断必须在同一瞬间完成,不能被其他请求插队”——单条命令没问题,但多条命令之间就可能被插队,结果就错了。
1.2 Redis 事务(MULTI)为什么不靠谱
Redis 有事务命令 MULTI / EXEC,但它的"事务"和我们理解的不一样:
- Redis 事务只能保证命令按顺序执行不被插队(不会像多线程那样穿插)
- 但不支持回滚!如果中间某条命令执行失败,前面成功的不会撤销
比如:
MULTI
INCR counter # 成功
DECR non_exist # 报错(key类型不对)
EXEC
# 结果是:counter 已经+1了,但不会回滚
所以在 Redis 里,靠事务做"要么全部成功要么全部失败"是不可靠的。
1.3 Lua 脚本才是正解
Lua 脚本是一个整体发送给 Redis,Redis 会一次性执行完整个脚本,执行期间不会被其他请求打断——天然原子。
服务A:执行整个Lua脚本(内部先INCR再判断)
↓ 脚本执行期间,Redis不处理任何其他请求
服务B:等待
服务A:脚本执行完,得到结果
Lua 脚本执行的特性:
这就是为什么 Redis 的限流、分布式锁、防超卖这些需要"多步原子操作"的场景,全部用 Lua 脚本实现。
二、Lua 脚本基础语法
2.1 最简单的 Lua
Lua 脚本本身语法很简单,先看几个基础点:
— 这是注释
— 定义变量
local name = "小明"
— 条件判断
if name == "小明" then
return "认识你"
else
return "不认识"
end
2.2 在 Redis 里执行
用 EVAL 命令执行 Lua 脚本:
EVAL "return 'hello redis'" 0
# 返回:hello redis
0 表示后面没有 KEYS 参数。
2.3 在 Lua 里调用 Redis 命令
用 redis.call() 调用 Redis 命令:
EVAL "return redis.call('set', 'name', '小明')" 0
# 执行了 SET name 小明
EVAL "return redis.call('get', 'name')" 0
# 返回:小明
也可以在脚本里组合多条命令,实现原子操作:
EVAL "
redis.call('set', 'count', 0)
redis.call('incr', 'count')
redis.call('incr', 'count')
return redis.call('get', 'count')
" 0
# 返回:2
整个脚本一次性执行完,中间任何请求都无法插队。
二-补充、Lua 脚本里的数据类型
写 Redis Lua 脚本时,要注意 Lua 和 Redis 之间数据类型的转换,这是新手经常踩坑的地方。
Redis 返回给 Lua 的类型
| 整数 | number |
| 字符串 | string |
| nil(不存在) | false |
| 错误 | 抛异常(可用 pcall 捕获) |
特别注意:Redis 的 nil(key不存在)在 Lua 里是 false。 如果你直接对 false 做算术运算会报错,要判断一下:
— 错误的写法:key不存在时 redis.call('get',…) 返回 false
— local stock = tonumber(redis.call('get', KEYS[1])) — 如果key不存在,tonumber(false)报错
— 正确的写法:先判断是否存在
local stock = redis.call('get', KEYS[1])
if stock == false or stock == nil then
stock = 0 — 给个默认值
else
stock = tonumber(stock)
end
Lua 返回给 Redis 的类型
| number | 整数 |
| string | 字符串 |
| false/nil | nil |
| table | 数组 |
常见坑: 脚本里对字符串做算术运算,要用 tonumber() 转成数字,否则会报错或得到错误结果:
local count = redis.call('incr', KEYS[1])
— incr 返回的是 number,可以直接比
if count > tonumber(ARGV[1]) then
return 0
end
如果 redis.call 返回的是字符串(比如 get),一定要先 tonumber() 再比较。
三、KEYS 和 ARGV 参数
3.1 为什么要传参数
直接把 key 写死在脚本里,每次都要重新写脚本,不方便。更关键的是,Redis 主从复制和集群模式下,要求脚本里的 key 通过参数传进去,Redis 才能知道操作了哪些 key(集群才能路由到正确的节点)。
标准写法是把 key 用 KEYS[] 引用,参数用 ARGV[] 引用。
3.2 EVAL 传参
EVAL "
redis.call('set', KEYS[1], ARGV[1])
return redis.call('get', KEYS[1])
" 1 mykey "小明"
拆解:
- 1 表示有1个 key 参数
- mykey 传给 KEYS[1]
- "小明" 传给 ARGV[1]
下标从1开始,不是0! 这是 Lua 和很多语言不一样的地方,别搞错。
3.3 多个 key 和参数
EVAL "
redis.call('set', KEYS[1], ARGV[1])
redis.call('set', KEYS[2], ARGV[2])
return redis.call('mget', KEYS[1], KEYS[2])
" 2 key1 key2 val1 val2
# KEYS[1]=key1 KEYS[2]=key2 ARGV[1]=val1 ARGV[2]=val2
四、经典应用:分布式锁的原子释放
上一章分布式锁里提到过:释放锁要"先判断是不是自己的锁,再删除",这两步必须原子。
用 Lua 脚本实现:
— 判断锁的值是不是自己的,是才删除
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
为什么必须用 Lua? 因为"get 比较 + del 删除"是两个操作,如果分开执行:
服务A get lock → 得到自己的值 → 准备del
(这时lock过期,服务B拿到锁,值变成B的)
服务A del lock → 把服务B的锁删了!
用 Lua 把比较和删除包在一个脚本里,Redis 一次性原子执行,中间不可能被插队,就不会误删。
在 Java 里执行:
DefaultRedisScript<Long> script = new DefaultRedisScript<>(
"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
Long.class);
Long result = redisTemplate.execute(
script,
Arrays.asList("lock"), // KEYS[1]
myValue // ARGV[1]
);
五、经典应用:限流(令牌桶)
限流是 Lua 的经典应用。控制某个 key 在一段时间窗口内的访问次数。
5.1 思路
用"滑动窗口"思路:每次请求来,给当前时间戳对应的计数加1,同时清理掉窗口之外的老计数,判断窗口内总数是否超限。
用 Redis 实现最简单的方式——固定时间窗口:每个单位时间一个 key,过期自动清理。
— 简单限流:每分钟最多N次
— KEYS[1] = 限流的key(如 user:9527:api)
— ARGV[1] = 窗口秒数
— ARGV[2] = 最大次数
— 计数器加1
local current = redis.call('incr', KEYS[1])
— 第一次访问,设置过期时间(窗口)
if current == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
— 判断是否超限
if current > tonumber(ARGV[2]) then
return 0 — 拒绝
else
return 1 — 放行
end
这个脚本完整实现了"1秒内最多N次"的限流,而且因为整个脚本原子执行,不会并发出错。
为什么要用 Lua 而不是分开命令? 因为"incr 计数 + expire 设过期 + 判断超限"要保证原子性,分开写在高并发下会算出错误结果。
5.2 更精确的令牌桶
Redis 官方提供的令牌桶限流脚本(完整版),用 ZSET 记录请求时间戳:
— KEYS[1] = 限流key
— ARGV[1] = 窗口毫秒数
— ARGV[2] = 最大请求数
— 当前时间
local now = redis.call('TIME')[1] * 1000
— 窗口起始时间
local window_start = now – tonumber(ARGV[1])
— 清理窗口外的过期请求
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)
— 加入当前请求
redis.call('ZADD', KEYS[1], now, now)
— 统计窗口内请求数
local count = redis.call('ZCARD', KEYS[1])
if count > tonumber(ARGV[2]) then
return 0
else
— 设置过期时间,防止ZSET无限增长
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]) / 1000)
return 1
end
这是生产级限流的经典写法,靠 ZSET 按时间戳排序,滑动窗口统计。
六、经典应用:防超卖
秒杀场景的核心问题——多个请求同时扣库存,怎么保证不超卖?
用 Lua 脚本原子扣库存:
— 原子扣减库存,防止超卖
— KEYS[1] = 库存key
— ARGV[1] = 扣减数量
— 当前库存
local stock = tonumber(redis.call('get', KEYS[1]))
— 库存不够,返回-1表示失败
if stock < tonumber(ARGV[1]) then
return –1
end
— 扣减库存
redis.call('decrby', KEYS[1], ARGV[1])
— 返回扣减后的库存
return stock – tonumber(ARGV[1])
为什么能防超卖? 因为"get 读库存 → 判断够不够 → decrby 扣减"这三步在 Lua 脚本里是原子的——同一时刻只有一个请求在执行这个脚本,其他请求都在等待。不会有两个人同时读到"还有1个库存"然后都扣成功。
如果分开写(先 get 再 decrby):
服务A get → 库存1
服务B get → 库存1 ← 并发读到同样
服务A decrby → 库存0
服务B decrby → 库存-1 ← 超卖了!
这就是秒杀系统必须用 Lua 的原因。
七、在代码里执行 Lua(Java/Python)
Java (Spring Data Redis)
// 定义脚本
DefaultRedisScript<Long> stockScript = new DefaultRedisScript<>();
stockScript.setResultType(Long.class);
stockScript.setScriptText(
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if stock < tonumber(ARGV[1]) then return -1 end " +
"redis.call('decrby', KEYS[1], ARGV[1]) " +
"return stock – tonumber(ARGV[1])");
// 执行
Long result = redisTemplate.execute(
stockScript,
Arrays.asList("stock:1001"), // KEYS[1]
1L // ARGV[1] 扣减数量
);
if (result >= 0) {
System.out.println("扣减成功,剩余库存:" + result);
} else {
System.out.println("库存不足");
}
Python (redis-py)
import redis
r = redis.Redis(decode_responses=True)
# 脚本:原子扣库存
script = """
local stock = tonumber(redis.call('get', KEYS[1]))
if stock < tonumber(ARGV[1]) then
return -1
end
redis.call('decrby', KEYS[1], ARGV[1])
return stock – tonumber(ARGV[1])
"""
# 注册脚本,返回一个可调用的对象
deduct = r.register_script(script)
# 调用:KEYS=['stock:1001'], ARGV=[1]
result = deduct(keys=["stock:1001"], args=[1])
if result >= 0:
print(f"扣减成功,剩余库存:{result}")
else:
print("库存不足")
八、Lua 脚本的注意事项
8.1 脚本要尽量短
Lua 脚本执行期间,Redis 不处理其他请求(单线程)。如果脚本太复杂、太耗时,会阻塞整个 Redis,影响所有请求。
原则:脚本只做必要的原子操作,复杂的业务逻辑放应用层做。
8.2 不能用不确定的命令
脚本里不能用会产生随机结果的命令(比如 TIME、RANDOMKEY),否则主从复制时数据会不一致。
8.3 集群模式下 key 要在同一个 slot
集群模式下,脚本操作的所有 key 必须落在同一个哈希槽(slot),否则报错。这就是为什么要用 KEYS[] 传参数。
本篇小结
- Lua 脚本让 Redis 多条命令原子执行,中间不会被其他请求插队
- Redis 事务不支持回滚,Lua 才是真正的原子方案
- 用 redis.call() 在脚本里调 Redis 命令,KEYS[] 和 ARGV[] 传参数(下标从1开始)
- 分布式锁释放、限流、防超卖,三大经典场景全靠 Lua 保证原子性
- 脚本要短,别阻塞 Redis;集群下 key 要在一个 slot
- 下一篇讲 Redis 高可用:主从复制和哨兵集群
网硕互联帮助中心






评论前必须登录!
注册