1. Redis脚本功能概述
Redis从2.6版本开始内置了Lua脚本引擎,这为Redis带来了革命性的能力扩展。脚本功能主要解决了两个核心问题:原子性执行多个命令和复杂计算下推到数据层。
在实际生产环境中,我们经常遇到需要原子性执行多个Redis命令的场景。比如在秒杀系统中,需要先检查库存,然后扣减库存,最后生成订单。如果这三个操作不是原子性的,就可能出现超卖问题。脚本功能完美解决了这类需求。
关键提示:Redis脚本执行是单线程的,这意味着脚本执行期间不会被打断,保证了操作的原子性。但这也意味着长时间运行的脚本会阻塞整个Redis实例。
1.1 Lua环境初始化
当Redis服务器启动时,会创建一个专门的Lua环境,这个环境经过特殊定制以满足Redis的需求:
- 基础库加载:包括string、table、math等基础库
- 禁用危险函数:如loadfile等可能引入安全风险的函数
- 添加Redis专用API:包括redis.call()、redis.pcall()等
- 伪客户端创建:用于执行脚本中的Redis命令
-- 示例:Redis为Lua环境添加的专用函数 redis.call('SET', 'key', 'value') -- 执行Redis命令 redis.log(redis.LOG_NOTICE, 'log message') -- 记录日志1.2 脚本安全性设计
Redis对脚本执行做了严格的安全限制:
- 无副作用:脚本必须是纯函数,相同输入总是产生相同输出
- 随机数控制:使用固定种子,除非显式调用math.randomseed
- 命令限制:禁止在随机命令后执行写操作
- 输出排序:对包含随机性的读命令结果进行排序
这些限制确保了脚本在复制和持久化时的行为一致性,是Redis集群和持久化机制可靠运行的基础。
2. 脚本命令详解
2.1 EVAL命令
EVAL是执行Lua脚本的核心命令,其基本语法为:
EVAL script numkeys key [key ...] arg [arg ...]参数说明:
- script:Lua脚本代码
- numkeys:键名参数的数量
- key:键名参数,通过KEYS数组访问
- arg:附加参数,通过ARGV数组访问
-- 示例:键和参数的使用 EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second -- 返回:["key1","key2","first","second"]2.1.1 执行流程
- 脚本编译:Redis会将脚本编译为Lua函数
- 键名提取:根据numkeys提取键名到KEYS数组
- 参数传递:剩余参数放入ARGV数组
- 脚本执行:在Lua环境中运行编译后的函数
- 结果返回:将Lua返回值转换为Redis协议格式
性能提示:EVAL每次都会编译脚本,对于频繁执行的脚本,建议使用EVALSHA或SCRIPT LOAD+EVALSHA组合。
2.2 EVALSHA命令
EVALSHA通过脚本的SHA1摘要来执行之前加载过的脚本,避免了重复传输脚本内容:
EVALSHA sha1 numkeys key [key ...] arg [arg ...]典型使用模式:
# 先加载脚本获取SHA1 SCRIPT LOAD "return redis.call('GET', KEYS[1])" # 返回:"a5a06e9b6b82d0a1e6d1d3d3cce3e3e3e3e3e3e3" # 使用SHA1执行脚本 EVALSHA a5a06e9b6b82d0a1e6d1d3d3cce3e3e3e3e3e3e3 1 mykey2.2.1 缓存机制
Redis维护了一个脚本缓存,存储最近执行过的脚本。当缓存满时,会根据LRU算法淘汰旧脚本。可以使用SCRIPT FLUSH清空缓存。
# 查看脚本是否缓存 SCRIPT EXISTS sha1 [sha1 ...] # 清空脚本缓存 SCRIPT FLUSH2.3 SCRIPT命令组
除了LOAD和FLUSH,SCRIPT命令还提供以下子命令:
- SCRIPT KILL:终止正在执行的脚本
- SCRIPT DEBUG:设置调试模式(YES/SYNC/NO)
重要安全提示:SCRIPT KILL只能终止没有执行过写操作的脚本。如果脚本已经执行了写操作,必须等待其完成或重启Redis实例。
3. 高级脚本技巧
3.1 错误处理
Redis提供了两种执行命令的方式:
- redis.call():命令出错时抛出Lua错误
- redis.pcall():命令出错时返回错误表
-- 使用pcall进行错误处理 local ok, result = redis.pcall('GET', 'nonexistent-key') if not ok then redis.log(redis.LOG_WARNING, 'Error: '..result) return nil end return result3.2 数据类型转换
Redis和Lua之间的数据类型转换规则:
| Redis类型 | Lua类型 |
|---|---|
| integer | number |
| string | string |
| list | table |
| set | table |
| hash | table |
特殊值处理:
- Redis的nil回复 → Lua的false
- Lua的false → Redis的nil bulk reply
3.3 性能优化
- 管道化:在脚本中执行多个命令避免网络往返
- 本地变量:复用计算结果减少命令调用
- 条件逻辑:在Lua中处理复杂条件减少Redis调用
-- 优化示例:批量处理 local users = redis.call('SMEMBERS', 'users') local result = {} for i, user in ipairs(users) do result[i] = redis.call('HGETALL', 'user:'..user) end return result4. 实战应用案例
4.1 分布式锁实现
-- 获取锁 local key = KEYS[1] local value = ARGV[1] local ttl = ARGV[2] local ok = redis.call('SETNX', key, value) if ok == 1 then redis.call('PEXPIRE', key, ttl) return 1 end return 0 -- 释放锁 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end4.2 限流器实现
local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = redis.call('GET', key) if current and tonumber(current) >= limit then return 0 end redis.call('INCR', key) if current == nil then redis.call('PEXPIRE', key, window) end return 14.3 秒杀系统扣减库存
local productKey = KEYS[1] local orderKey = KEYS[2] local userId = ARGV[1] local quantity = tonumber(ARGV[2]) local stock = tonumber(redis.call('GET', productKey)) if stock < quantity then return 0 end redis.call('DECRBY', productKey, quantity) redis.call('HSET', orderKey, userId, quantity) return 15. 调试与问题排查
5.1 脚本调试技巧
- 使用redis.log()输出调试信息
- 分阶段测试复杂脚本
- 使用SCRIPT DEBUG模式单步调试
-- 调试日志示例 redis.log(redis.LOG_NOTICE, 'Debug info: '..tostring(some_value))5.2 常见错误
- 脚本超时:默认5秒,可通过lua-time-limit配置
- 内存不足:控制脚本复杂度,避免大结果集
- 语法错误:提前在本地Lua环境测试
- 数据类型不匹配:注意Lua和Redis类型转换
5.3 性能监控
- 使用SLOWLOG监控慢脚本
- 使用INFO命令查看脚本相关统计
- 监控内存使用情况
# 查看慢查询日志 SLOWLOG GET 10 # 查看脚本统计信息 INFO commandstats | grep eval6. 最佳实践
- 保持脚本简短:建议不超过4000字符
- 参数化脚本:通过ARGV传递变量
- 避免阻塞操作:如KEYS、长时间循环
- 合理使用缓存:对高频脚本使用EVALSHA
- 错误处理完备:检查所有可能失败的操作
生产环境建议:所有脚本应该经过代码审查,并在测试环境充分验证后再部署到生产环境。