03-Lua脚本编程

98 阅读9分钟

Lua 脚本编程核心

Redis 中的 Lua 脚本编程完全指南

1. Lua 语言基础

1.1 Lua 简介

  • 轻量级脚本语言:小巧、快速
  • 嵌入式语言:易于嵌入其他程序
  • 动态类型:变量无需声明类型
  • 自动垃圾回收:自动内存管理

1.2 基本语法

变量和数据类型
-- 注释:单行注释
--[[
    多行注释
]]

-- 变量声明(默认全局变量)
name = "Redis"
age = 10

-- 局部变量(推荐)
local count = 0
local flag = true

-- 数据类型
-- 1. nil(空值)
local x = nil

-- 2. boolean(布尔)
local isActive = true
local isDeleted = false

-- 3. number(数字)
local integer = 42
local float = 3.14

-- 4. string(字符串)
local str1 = "hello"
local str2 = 'world'
local str3 = [[多行
字符串]]

-- 5. table(表,唯一的数据结构)
local arr = {1, 2, 3}          -- 数组(索引从 1 开始)
local dict = {name="Alice", age=25}  -- 字典
local mixed = {1, 2, key="value"}    -- 混合

-- 6. function(函数)
local function add(a, b)
    return a + b
end
运算符
-- 算术运算符
local a = 10 + 5   -- 加
local b = 10 - 5   -- 减
local c = 10 * 5   -- 乘
local d = 10 / 5   -- 除
local e = 10 % 3   -- 取模
local f = 2 ^ 3    -- 幂运算

-- 关系运算符
local x = 10 == 5  -- 等于
local y = 10 ~= 5  -- 不等于
local z = 10 > 5   -- 大于
local w = 10 < 5   -- 小于
local v = 10 >= 5  -- 大于等于
local u = 10 <= 5  -- 小于等于

-- 逻辑运算符
local p = true and false  -- 与
local q = true or false   -- 或
local r = not true        -- 非

-- 字符串连接
local str = "Hello" .. " " .. "World"
控制结构
-- if-else
if condition then
    -- 代码
elseif another_condition then
    -- 代码
else
    -- 代码
end

-- while 循环
local i = 1
while i <= 10 do
    print(i)
    i = i + 1
end

-- repeat-until 循环
local j = 1
repeat
    print(j)
    j = j + 1
until j > 10

-- for 循环(数值)
for i = 1, 10 do
    print(i)
end

for i = 1, 10, 2 do  -- 步长为 2
    print(i)
end

-- for 循环(迭代器)
local arr = {10, 20, 30}
for index, value in ipairs(arr) do
    print(index, value)
end

local dict = {name="Alice", age=25}
for key, value in pairs(dict) do
    print(key, value)
end
函数
-- 函数定义
function add(a, b)
    return a + b
end

-- 局部函数
local function multiply(a, b)
    return a * b
end

-- 多返回值
function divide(a, b)
    if b == 0 then
        return nil, "division by zero"
    end
    return a / b, nil
end

local result, err = divide(10, 0)
if err then
    print(err)
end

-- 可变参数
function sum(...)
    local total = 0
    for _, v in ipairs({...}) do
        total = total + v
    end
    return total
end

print(sum(1, 2, 3, 4, 5))  -- 15
Table(表)
-- 创建表
local t = {}

-- 数组操作(索引从 1 开始)
local arr = {10, 20, 30}
print(arr[1])  -- 10
arr[4] = 40

-- 字典操作
local dict = {}
dict.name = "Alice"
dict["age"] = 25
print(dict.name)   -- Alice
print(dict["age"]) -- 25

-- 遍历数组
for i, v in ipairs(arr) do
    print(i, v)
end

-- 遍历字典
for k, v in pairs(dict) do
    print(k, v)
end

-- 表的长度
print(#arr)  -- 数组长度

-- 表操作函数
table.insert(arr, 50)       -- 插入
table.remove(arr, 1)        -- 删除
table.concat(arr, ",")      -- 连接
table.sort(arr)             -- 排序
字符串操作
-- 字符串长度
local str = "hello"
print(#str)  -- 5
print(string.len(str))  -- 5

-- 字符串连接
local result = "hello" .. " " .. "world"

-- 字符串查找
local pos = string.find("hello world", "world")  -- 7

-- 字符串替换
local new_str = string.gsub("hello world", "world", "Lua")

-- 字符串截取
local sub = string.sub("hello world", 1, 5)  -- hello

-- 大小写转换
print(string.upper("hello"))  -- HELLO
print(string.lower("HELLO"))  -- hello

-- 字符串格式化
local formatted = string.format("Name: %s, Age: %d", "Alice", 25)

2. Redis 中的 Lua

2.1 为什么使用 Lua?

  1. 原子性:Lua 脚本作为一个整体执行,不会被其他命令打断
  2. 减少网络往返:复杂逻辑在服务器端执行
  3. 可复用:脚本可以被缓存和复用
  4. 灵活性:实现复杂的业务逻辑

2.2 EVAL 命令

EVAL script numkeys key [key ...] arg [arg ...]

# script: Lua 脚本
# numkeys: key 的数量
# key: Redis 键名(通过 KEYS[1], KEYS[2] 访问)
# arg: 参数(通过 ARGV[1], ARGV[2] 访问)
示例
# 简单示例:设置并返回值
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "myvalue"

# 获取并递增
EVAL "local val = redis.call('GET', KEYS[1]); return val" 1 mykey

# 条件判断
EVAL "
local current = redis.call('GET', KEYS[1])
if tonumber(current) > tonumber(ARGV[1]) then
    return 1
else
    return 0
end
" 1 counter 100

2.3 Redis API

-- 调用 Redis 命令
redis.call('SET', 'key', 'value')
redis.pcall('SET', 'key', 'value')  -- 错误不会中断

-- call vs pcall
-- call: 命令错误会中断脚本
-- pcall: 命令错误返回错误对象,脚本继续

-- 示例
local result = redis.call('GET', 'key')
if result then
    return result
else
    return "key not found"
end

-- 返回值转换
-- Redis 命令返回值自动转换为 Lua 类型
-- Lua 返回值自动转换为 Redis 协议

2.4 EVALSHA(脚本缓存)

# 1. 加载脚本,返回 SHA1 值
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 返回:5332031c6b470dc5a0dd9b4bf2030dea6d65de91

# 2. 使用 SHA1 执行脚本
EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 1 mykey

# 3. 检查脚本是否存在
SCRIPT EXISTS sha1 [sha1 ...]

# 4. 清空脚本缓存
SCRIPT FLUSH

# 5. 杀死正在执行的脚本
SCRIPT KILL

3. 实战案例

3.1 分布式锁

加锁脚本
-- lock.lua
local key = KEYS[1]        -- 锁的键名
local value = ARGV[1]      -- 锁的值(唯一标识)
local ttl = tonumber(ARGV[2])  -- 过期时间

-- SET NX EX
local result = redis.call('SET', key, value, 'NX', 'EX', ttl)

if result then
    return 1  -- 加锁成功
else
    return 0  -- 加锁失败
end
解锁脚本
-- unlock.lua
local key = KEYS[1]
local value = ARGV[1]

-- 检查锁是否是自己的
local current = redis.call('GET', key)
if current == value then
    redis.call('DEL', key)
    return 1  -- 解锁成功
else
    return 0  -- 不是自己的锁
end
使用示例
# 加锁
EVAL "local key = KEYS[1]; local value = ARGV[1]; local ttl = tonumber(ARGV[2]); return redis.call('SET', key, value, 'NX', 'EX', ttl) and 1 or 0" 1 lock:order:1 uuid123 10

# 解锁
EVAL "local key = KEYS[1]; local value = ARGV[1]; local current = redis.call('GET', key); if current == value then redis.call('DEL', key); return 1 else return 0 end" 1 lock:order:1 uuid123

3.2 限流(令牌桶)

-- rate_limit.lua
local key = KEYS[1]          -- 限流键名
local limit = tonumber(ARGV[1])  -- 限制次数
local window = tonumber(ARGV[2]) -- 时间窗口(秒)

-- 获取当前计数
local current = redis.call('INCR', key)

-- 第一次设置过期时间
if current == 1 then
    redis.call('EXPIRE', key, window)
    return 1  -- 允许
end

-- 检查是否超过限制
if current <= limit then
    return 1  -- 允许
else
    return 0  -- 拒绝
end
使用示例
# 限制用户每分钟最多 100 次请求
EVAL "
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)

if current == 1 then
    redis.call('EXPIRE', key, window)
    return 1
end

if current <= limit then
    return 1
else
    return 0
end
" 1 rate:user:123 100 60

3.3 扣减库存

-- deduct_stock.lua
local key = KEYS[1]           -- 库存键名
local quantity = tonumber(ARGV[1])  -- 扣减数量

-- 获取当前库存
local stock = tonumber(redis.call('GET', key))

if not stock then
    return -1  -- 商品不存在
end

if stock < quantity then
    return 0  -- 库存不足
end

-- 扣减库存
redis.call('DECRBY', key, quantity)
return 1  -- 扣减成功
使用示例
# 扣减库存
EVAL "
local key = KEYS[1]
local quantity = tonumber(ARGV[1])

local stock = tonumber(redis.call('GET', key))

if not stock then
    return -1
end

if stock < quantity then
    return 0
end

redis.call('DECRBY', key, quantity)
return 1
" 1 stock:product:101 5

# 返回值:
# -1: 商品不存在
#  0: 库存不足
#  1: 扣减成功

3.4 排行榜(Sorted Set + 分页)

-- leaderboard.lua
local key = KEYS[1]
local page = tonumber(ARGV[1])
local page_size = tonumber(ARGV[2])

-- 计算起始位置
local start = (page - 1) * page_size
local stop = start + page_size - 1

-- 获取排行榜
local result = redis.call('ZREVRANGE', key, start, stop, 'WITHSCORES')

return result

3.5 缓存更新(先删除后更新)

-- cache_update.lua
local cache_key = KEYS[1]
local db_key = KEYS[2]
local new_value = ARGV[1]

-- 1. 删除缓存
redis.call('DEL', cache_key)

-- 2. 更新数据库(假设已在外部更新)
-- 这里只是标记需要更新

-- 3. 设置新缓存
redis.call('SET', cache_key, new_value, 'EX', 3600)

return 1

3.6 延时队列

-- delay_queue.lua
local queue_key = KEYS[1]
local current_time = tonumber(ARGV[1])

-- 获取到期的任务
local tasks = redis.call('ZRANGEBYSCORE', queue_key, 0, current_time, 'LIMIT', 0, 100)

if #tasks == 0 then
    return {}
end

-- 删除已获取的任务
for i, task in ipairs(tasks) do
    redis.call('ZREM', queue_key, task)
end

return tasks

3.7 防止缓存击穿(互斥锁)

-- cache_mutex.lua
local cache_key = KEYS[1]
local lock_key = KEYS[2]
local lock_value = ARGV[1]
local ttl = tonumber(ARGV[2])

-- 1. 尝试获取缓存
local cache = redis.call('GET', cache_key)
if cache then
    return {1, cache}  -- 缓存命中
end

-- 2. 尝试获取锁
local lock = redis.call('SET', lock_key, lock_value, 'NX', 'EX', 10)
if lock then
    return {2, nil}  -- 获取锁成功,需要查询数据库
else
    return {3, nil}  -- 获取锁失败,需要等待
end

4. 最佳实践

4.1 性能优化

-- 1. 使用局部变量
local redis_call = redis.call  -- 缓存函数引用

-- 2. 批量操作
local results = {}
for i = 1, 100 do
    results[i] = redis_call('GET', 'key' .. i)
end

-- 3. 避免大量数据返回
-- ❌ 不推荐
local all_data = redis_call('ZRANGE', 'large_set', 0, -1)

-- ✅ 推荐:分批获取
local batch_size = 100
local results = redis_call('ZRANGE', 'large_set', 0, batch_size - 1)

4.2 错误处理

-- 使用 pcall 捕获错误
local ok, result = pcall(redis.call, 'GET', 'key')
if not ok then
    -- 处理错误
    return {err = result}
end

return result

4.3 调试技巧

-- 使用 redis.log() 记录日志
redis.log(redis.LOG_WARNING, "Processing key: " .. KEYS[1])

-- 日志级别
-- redis.LOG_DEBUG
-- redis.LOG_VERBOSE
-- redis.LOG_NOTICE
-- redis.LOG_WARNING

4.4 注意事项

  1. 避免耗时操作:Lua 脚本会阻塞 Redis
  2. 避免随机性:不要使用 math.random()
  3. 避免全局变量:使用局部变量
  4. 限制脚本大小:建议小于 1KB
  5. 使用 EVALSHA:缓存脚本提高性能

5. 调试和测试

5.1 本地测试

# 使用 redis-cli 测试
redis-cli --eval script.lua key1 key2 , arg1 arg2

# 示例
redis-cli --eval lock.lua lock:test , uuid123 10

5.2 脚本调试

-- 在脚本中添加日志
redis.log(redis.LOG_WARNING, "Debug: value = " .. tostring(value))

-- 查看 Redis 日志
tail -f /var/log/redis/redis.log

5.3 性能测试

# 使用 redis-benchmark 测试
redis-benchmark -n 10000 EVAL "return redis.call('GET', 'key')" 0

6. 常用 Lua 脚本模板

6.1 限流模板

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local current = redis.call('INCR', key)
if current == 1 then
    redis.call('EXPIRE', key, window)
end

return current <= limit and 1 or 0

6.2 分布式锁模板

-- 加锁
local key = KEYS[1]
local value = ARGV[1]
local ttl = tonumber(ARGV[2])
return redis.call('SET', key, value, 'NX', 'EX', ttl) and 1 or 0

-- 解锁
local key = KEYS[1]
local value = ARGV[1]
local current = redis.call('GET', key)
if current == value then
    redis.call('DEL', key)
    return 1
end
return 0

6.3 原子递增/递减模板

local key = KEYS[1]
local delta = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
local min = tonumber(ARGV[3])

local current = tonumber(redis.call('GET', key)) or 0
local new_value = current + delta

if new_value > max then
    return {0, current}  -- 超过最大值
elseif new_value < min then
    return {0, current}  -- 低于最小值
end

redis.call('SET', key, new_value)
return {1, new_value}

7. 高频问题

Q1: Redis 为什么使用 Lua?

答案

  1. 原子性:脚本作为整体执行
  2. 减少网络往返:复杂逻辑服务器端执行
  3. 可复用:脚本可缓存
  4. 灵活性:实现复杂业务逻辑

Q2: EVAL 和 EVALSHA 的区别?

答案

  • EVAL:每次传输脚本内容
  • EVALSHA:使用脚本的 SHA1 值,脚本已缓存

Q3: Lua 脚本如何保证原子性?

答案: Redis 执行 Lua 脚本时,会阻塞其他命令,直到脚本执行完成。

Q4: Lua 脚本有什么限制?

答案

  1. 不能使用随机函数(影响主从一致性)
  2. 不能有耗时操作(阻塞 Redis)
  3. 不能访问外部资源(网络、文件)

Q5: 如何优化 Lua 脚本性能?

答案

  1. 使用局部变量
  2. 使用 EVALSHA 缓存脚本
  3. 减少数据传输
  4. 避免大量循环

总结

Lua 脚本核心:

  1. ✅ Lua 基础语法
  2. ✅ Redis Lua API
  3. ✅ 实战案例(限流、锁、库存等)
  4. ✅ 最佳实践
  5. ✅ 调试和测试

应用场景

  • 分布式锁
  • 限流算法
  • 原子操作
  • 复杂业务逻辑

推荐学习