Lua 入门:写给 Redis 脚本使用者的极简教程
- 约 9 分钟读完 - 全文 3531 字Lua 入门:写给 Redis 脚本使用者的极简教程
Redis 从 2.6 开始支持 Lua 脚本,核心目的是把多个命令打包成原子操作。本文面向已经会用 Redis、但还没写过 Lua 脚本的同学,只讲最常用、最实用的部分,学完就能写出常见的限流、加锁、库存扣减脚本。
一、为什么 Redis 需要 Lua
Redis 单条命令是原子的,但多条命令组合起来就不是了。比如经典的「查库存 → 判断 → 扣减」:
GET stock:1001 # 返回 10
# 业务代码判断 10 > 0
DECR stock:1001 # 返回 9并发时两个客户端可能都读到 10,然后都执行 DECR,导致超卖。
Lua 脚本在 Redis 里作为一个整体执行,中间不会被其他命令打断,天然解决了这类问题。而且脚本在 Redis 服务端执行,还能减少网络往返。
二、本地 Lua 环境准备
2.1 安装 Lua
macOS:
brew install lua
lua -vUbuntu / Debian:
sudo apt update
sudo apt install lua5.1
lua5.1 -vWindows:
从 Lua 官方 下载源码或预编译二进制,也可以直接用 Lua for Windows。
Docker(最省事):
docker run --rm -it nickblah/lua:5.1 lua -v2.2 第一个本地 Lua 程序
创建 hello.lua:
print("hello, lua")运行:
lua hello.lua2.3 用 Redis 验证脚本
如果你本地有 Redis,可以直接用 redis-cli 跑脚本文件:
redis-cli --eval myscript.lua key1 key2 , arg1 arg2注意 , 前后都有空格:, 左边是 KEYS,右边是 ARGV。
2.4 编辑器推荐
- VS Code:插件
Lua(sumneko)或Lua Language Server。 - IDEA / WebStorm:安装
EmmyLua插件。 - Neovim:
lua_ls+lspconfig。
三、Hello World:第一个 Redis Lua 脚本
EVAL "return 'hello, redis lua'" 0EVAL是执行 Lua 脚本的命令。- 后面的
0表示脚本用到的 key 数量是 0。 return把结果返回给客户端。
带 key 和参数的版本:
EVAL "return redis.call('GET', KEYS[1])" 1 user:1
# 等同于 GET user:1
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 user:1 alice
# 等同于 SET user:1 aliceKEYS[1]对应第 1 个 key,KEYS[2]对应第 2 个,以此类推。ARGV[1]对应第 1 个额外参数。- 所有 ARGV 都是字符串,如果需要数字,要在 Lua 里做类型转换。
四、Lua 基础:Redis 场景够用的子集
4.1 变量与类型
Lua 是动态类型语言,常用类型有:nil、boolean、number、string、table。
local name = "alice" -- 字符串
local age = 30 -- 数字,不区分 int / float
local ok = true -- 布尔
local empty = nil -- 空注意:
- 局部变量用
local,否则会成为全局变量。Redis 脚本里建议一律用local。 - 条件判断时,
0和空字符串''都是true,只有nil和false是false。
local a = 0
if a then
return "yes" -- 会执行这里
end4.2 字符串与数字转换
local s = "42"
local n = tonumber(s) -- 转成数字 42
local s2 = tostring(n) -- 转成字符串 "42"
local a = "10"
local b = "3"
local c = a + b -- 13,自动把字符串当数字在 Redis 脚本里,更推荐显式转换,避免意外:
local count = tonumber(redis.call('GET', KEYS[1])) or 04.3 表(table):数组 + 字典
表是 Lua 唯一的数据结构,既能当数组用,也能当字典用。
-- 数组(下标从 1 开始)
local arr = {"a", "b", "c"}
return arr[1] -- "a"
-- 字典
local dict = {name = "alice", age = 30}
return dict.name -- "alice"遍历数组:
local arr = {"a", "b", "c"}
for i, v in ipairs(arr) do
print(i, v)
end
-- 输出:
-- 1 a
-- 2 b
-- 3 c遍历字典:
local dict = {name = "alice", age = 30}
for k, v in pairs(dict) do
print(k, v)
end4.4 条件判断
local n = 10
if n > 20 then
return "big"
elseif n > 5 then
return "medium" -- 会返回这个
else
return "small"
end4.5 循环
-- for 循环
for i = 1, 5 do
print(i)
end
-- while 循环
local i = 1
while i <= 5 do
print(i)
i = i + 1
endRedis 脚本里循环要慎用,避免执行时间过长阻塞主线程。
4.6 函数
local function add(a, b)
return a + b
end
return add(1, 2) -- 3五、Lua 原理浅析
理解 Lua 的运行机制,能帮你写出更稳的 Redis 脚本。
5.1 解释器与字节码
Lua 不是纯解释执行。它会把源码编译成字节码(bytecode),然后在 Lua 虚拟机(VM)上运行。
luac -o hello.luac hello.lua -- 编译成字节码
lua hello.luac -- 运行字节码Redis 内部集成的也是 Lua VM,脚本第一次执行时会先编译成字节码,之后再执行就复用这个结果。所以长脚本第一次跑会慢一点,后续重复执行会快一些。
5.2 内存回收机制
Lua 使用自动垃圾回收(Garbage Collection, GC),主要策略是增量式标记-清除(incremental mark-and-sweep)。Lua 5.4 还引入了分代 GC 模式。
对 Redis 脚本来说,关键点是:
- 脚本运行期间创建的 table、string 等临时对象,由 Lua GC 自动回收。
- Redis 脚本通常很短,GC 压力不大。
- 不要在脚本里构造超大 table 或缓存大量中间结果,否则可能触发 GC 暂停,阻塞 Redis。
5.3 Table 是核心
Lua 的 table 本质上是一个哈希表 + 数组的混合结构:
- 用连续整数索引时,底层按数组优化,访问快。
- 用字符串或其他类型当 key 时,底层是哈希表。
这也是 Lua 能用 table 模拟数组、字典、对象、模块的原因。
5.4 Redis 中的 Lua 沙箱
Redis 对 Lua 做了严格限制:
- 不能加载外部库(如
io、os大部分功能被禁用)。 - 不能发起网络请求。
- 不能访问文件系统。
- 只能调用 Redis 提供的 API(如
redis.call、redis.log)。
这个沙箱保证了脚本安全,但也意味着你不能直接在脚本里读系统时间(os.time 被禁用),需要把时间作为 ARGV 传进去。
六、Redis 脚本核心 API
6.1 redis.call 与 redis.pcall
redis.call(...):调用 Redis 命令,出错时直接抛出 Lua 错误,脚本会终止。redis.pcall(...):调用 Redis 命令,出错时返回错误对象,脚本继续执行。
-- call 版本:key 不存在会报错
local v = redis.call('GET', 'non_existent_key')
-- pcall 版本:可以捕获错误
local ok, err = pcall(redis.call, 'GET', 'non_existent_key')
if not ok then
return "error: " .. tostring(err)
end一般推荐:
- 简单脚本用
redis.call,逻辑更清晰。 - 需要容错时用
redis.pcall或外层pcall。
6.2 返回值
Lua 脚本可以返回单个值、数组或表:
-- 返回单个值
return redis.call('GET', KEYS[1])
-- 返回多个值
return {redis.call('GET', KEYS[1]), redis.call('GET', KEYS[2])}Redis 会把 Lua 的 table 转成数组返回给客户端。
6.3 KEYS 与 ARGV 的规范
Redis Cluster 要求脚本里用到的 key 都必须放在 KEYS 中,这样 Redis 才能判断脚本是否跨 slot。ARGV 里只能放非 key 参数。
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue这里 mykey 是 key,myvalue 是 value。
七、实战:常见 Redis Lua 模式
7.1 限流(滑动窗口)
限制某个用户在 60 秒内最多请求 100 次:
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 窗口大小,毫秒
local limit = tonumber(ARGV[2]) -- 限制次数
local now = tonumber(ARGV[3]) -- 当前时间戳,毫秒
-- 清理窗口外的请求记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. "_" .. count)
redis.call('PEXPIRE', key, window)
return 1 -- 允许通过
else
return 0 -- 拒绝
end调用:
EVAL "...脚本..." 1 rate:user:1 60000 100 16900000000007.2 分布式锁(可重入简化版)
local key = KEYS[1]
local token = ARGV[1]
local ttl = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current == false or current == token then
redis.call('SET', key, token, 'PX', ttl)
return 1
else
return 0
end7.3 库存扣减(防超卖)
local key = KEYS[1]
local delta = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', key)) or 0
if stock < delta then
return -1 -- 库存不足
end
redis.call('DECRBY', key, delta)
return stock - delta7.4 批量删除符合某种前缀的 key
注意:生产环境慎用 KEYS 命令,这里仅作语法示例,大数据量请用 SCAN。
local pattern = ARGV[1]
local keys = redis.call('KEYS', pattern)
for i, k in ipairs(keys) do
redis.call('DEL', k)
end
return #keys7.5 条件更新(CAS 简化版)
local key = KEYS[1]
local expected = ARGV[1]
local newValue = ARGV[2]
local current = redis.call('GET', key)
if current == expected then
redis.call('SET', key, newValue)
return 1
else
return 0
end八、调试、常见坑与最佳实践
8.1 调试脚本
- 先用本地 Redis 跑:
redis-cli --eval script.lua key1 key2 , arg1 arg2- 注意
,前后有空格,,左边是 KEYS,右边是 ARGV。
- 注意
- 打印日志:Lua 脚本里不能直接用
print输出到客户端,但可以用redis.log(redis.LOG_NOTICE, msg)写到 Redis 日志。
redis.log(redis.LOG_NOTICE, "current stock: " .. tostring(stock))- 本地先用
lua命令跑语法:不依赖 Redis 也能检查基础语法错误。
8.2 常见踩坑清单
| 坑 | 说明 | 建议 |
|---|---|---|
| ARGV 全是字符串 | 数字运算前必须 tonumber() |
每个数字参数都显式 tonumber() |
| 全局变量 | 忘了 local 可能污染全局环境,Redis 甚至会杀掉脚本 |
所有变量一律加 local |
| 脚本执行时间 | 循环、大 key 操作会阻塞 Redis 主线程 | 脚本尽量短,避免复杂循环 |
| 集群 key 位置 | 所有 key 必须写在 KEYS 里,跨 slot 会报错 |
业务 key 设计时确保同一 slot |
| 脚本缓存 | 长脚本每次 EVAL 都传输浪费带宽 | 生产环境用 EVALSHA 或客户端自动缓存 |
nil 中断数组 |
table 中间出现 nil 会导致 # 长度计算出错 |
避免在数组中间塞 nil |
pairs 遍历顺序 |
pairs 不保证顺序,ipairs 才按数组顺序 |
需要顺序时用 ipairs 或自己维护索引 |
| 字符串拼接性能 | .. 在循环里反复拼接是 O(n²) |
用 table.concat 批量拼接 |
redis.call 报错 |
命令执行失败会直接抛错终止脚本 | 需要容错时改用 redis.pcall |
| 缺失 key 的返回值 | GET 不存在的 key 返回 false,不是 nil |
判断时用 == false 或 if not current |
| 写命令必须确定性的 | 主从复制/持久化要求相同的脚本产生相同结果 | 不要用随机数、TIME、SRANDMEMBER 等做写决策 |
| 浮点数比较 | 0.1 + 0.2 ~= 0.3 |
用整数存储金额/库存,避免浮点 |
8.3 性能建议
- 脚本尽量短小,只处理一个原子逻辑单元。
- 不要在 Lua 里做复杂计算或大量循环。
- 大 key 操作(如
HGETALL几十万字段)不要在脚本里做。 - 热 key 尽量缩短 TTL 或做拆分,避免单脚本长时间占用。
- 对执行时间没把握的脚本,先用
redis-cli --latency-history或SLOWLOG观察影响。
九、总结
Redis Lua 脚本不需要你成为 Lua 专家,掌握下面几点就能覆盖 80% 的场景:
- 本地能跑 Lua,能写
.lua文件并用redis-cli --eval测试。 local变量、number/string/table三种类型。tonumber()/tostring()转换,ipairs()/pairs()遍历,table.concat拼接。- Lua 是编译成字节码在 VM 上运行,GC 自动回收,Redis 里是沙箱环境。
redis.call()调用命令,KEYS[n]和ARGV[n]传参,集群 key 必须放 KEYS。- 脚本整体原子执行,适合「读 → 判断 → 写」的复合操作。
- 注意 ARGV 是字符串、缺失 key 返回
false、避免长脚本阻塞、写命令要确定性。
限流、分布式锁、库存扣减、条件更新这些高频场景,本质上都是同一个模式:先读数据,在脚本里做判断,再决定写什么。把这一点练熟,Redis Lua 就入门了。
如果你在 Redis Lua 脚本里踩过什么坑,欢迎在评论区分享。