为什么需要Redis Lua脚本
在日常的Redis开发中,我们经常遇到需要执行多个命令且保证原子性的场景。比如限流器需要先读取计数器、判断是否超限、再递增计数器——这三步必须在同一个原子操作中完成,否则并发请求可能导致超限。虽然Redis提供了MULTI/EXEC事务机制,但事务无法在执行过程中进行条件判断,你无法在事务中间根据某个Key的值决定是否继续执行。
Lua脚本完美解决了这个问题。Redis内置了Lua解释器,允许你将多个命令打包成一个原子操作执行,同时支持条件分支、循环、变量赋值等完整的编程逻辑。从Redis 2.6.0版本开始支持EVAL命令至今,Lua脚本已经成为Redis高级使用的核心能力之一。
本文将深入剖析Redis Lua脚本的底层机制、原子性保证、性能优化策略,以及生产环境中的最佳实践和常见陷阱。
EVAL命令与脚本执行机制
EVAL基本语法
Redis通过EVAL命令执行Lua脚本,基本格式如下:
1 EVAL script numkeys key [key ...] arg [arg ...]
其中
1 | script |
是Lua脚本字符串,
1 | numkeys |
表示KEYS数组的长度,后面的
1 | key |
参数通过
1 | KEYS |
表在脚本中访问,最后的
1 | arg |
参数通过
1 | ARGV |
表访问。一个简单的示例:
1
2
3
4
5
6 -- 设置一个带过期时间的计数器,并返回当前值
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
调用方式:
1 EVAL "local current = redis.call('INCR', KEYS[1]) ..." 1 rate_limit:user123 60
这里
1 | KEYS[1] |
是
1 | rate_limit:user123 |
,
1 | ARGV[1] |
是
1 | 60 |
。
redis.call与redis.pcall的区别
在Lua脚本中调用Redis命令有两种方式:
- redis.call():执行命令,如果Redis命令返回错误,Lua脚本会立即终止并将错误返回给客户端
- redis.pcall():执行命令,如果Redis命令返回错误,错误会被捕获为Lua表返回,脚本继续执行
1
2
3
4
5
6
7
8
9 -- call方式:遇到错误直接抛出
local val = redis.call('GET', KEYS[1]) -- 如果Key不存在返回false
-- pcall方式:错误被捕获
local result = redis.pcall('HGET', KEYS[1], ARGV[1])
if type(result) == 'table' and result.err then
-- 处理错误
return {err = result.err}
end
在生产环境中,当你不确定某个操作是否可能返回错误但仍希望继续执行时,应该使用
1 | redis.pcall() |
。大多数确定性场景下推荐
1 | redis.call() |
,让错误尽早暴露。
KEYS与ARGV的访问规则
Lua脚本中的KEYS和ARGV是两个数组,索引从1开始(Lua语言特性)。一个关键原则是:所有Key名称必须通过KEYS参数传入,而不是硬编码在脚本中或通过ARGV传入。这个规则与Redis Cluster有关——集群需要根据Key计算Slot来决定将请求路由到哪个节点,如果Key名称隐藏在脚本逻辑中,集群无法正确路由。
1
2
3
4
5
6
7
8 -- 错误做法:Key名称硬编码
redis.call('GET', 'user:1001')
-- 错误做法:Key名称通过ARGV传入
redis.call('GET', ARGV[1])
-- 正确做法:Key名称通过KEYS传入
redis.call('GET', KEYS[1])
在单节点模式下,上述三种方式都能工作,但一旦迁移到集群环境,前两种方式会报错
1 | CROSSSLOT Keys in request don't hash to the same slot |
。养成通过KEYS传Key的习惯至关重要。
脚本的原子性保证
单线程模型的原子性
Redis采用单线程模型处理命令请求(Redis 6.0+的网络I/O虽然多线程,但命令执行仍然是单线程)。这意味着当一个Lua脚本在执行时,Redis不会切换去处理其他客户端的请求。从外部视角看,整个Lua脚本的执行是一个不可分割的原子操作。
这个特性使得Lua脚本非常适合实现需要原子性的复合操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 -- 分布式限流器:滑动窗口实现
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local key = KEYS[1]
-- 移除过期的条目
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
-- 获取当前窗口内的请求数量
local count = redis.call('ZCARD', key)
if count < limit then
-- 添加当前请求
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('EXPIRE', key, window / 1000)
return 1 -- 允许请求
else
return 0 -- 拒绝请求
end
如果没有Lua脚本的原子性保证,在ZREMRANGEBYSCORE和ZCARD之间可能有其他客户端的请求插入,导致限流判断不准确。
原子性的边界与注意事项
虽然Lua脚本的执行是原子的,但这并不意味着可以无限制地利用这个特性:
- 阻塞问题:Lua脚本执行期间,Redis完全阻塞,其他所有客户端的请求都在排队等待。一个执行5秒的Lua脚本会让所有客户端等待5秒。
- 超时限制:Redis配置了
1lua-time-limit
(默认5000毫秒),脚本执行超过此时间后Redis开始接受BUSY错误响应,但脚本本身不会被强制终止。
- 不可中断:一旦Lua脚本开始执行,只能通过
1SCRIPT KILL
(仅对没有写操作的脚本有效)或
1SHUTDOWN NOSAVE来终止,后者会导致数据丢失。
因此,生产环境中的Lua脚本必须尽量简短高效,避免长时间阻塞Redis。
EVALSHA与脚本缓存机制
脚本的SHA1校验和
每次调用EVAL命令都需要将完整的Lua脚本字符串发送给Redis,对于较长的脚本,这会造成明显的网络开销。Redis提供了EVALSHA命令来优化这个问题。
Redis会对每个Lua脚本计算SHA1校验和,并在内部缓存脚本内容。EVALSHA的用法是:
1
2
3
4 -- 首先用EVAL执行脚本,Redis会自动缓存
EVAL "return redis.call('GET', KEYS[1])" 1 mykey
-- 后续可以用SHA1校验和代替完整脚本
EVALSHA a42023b9c6d743ce587bcd7283b08a2a6c34b816 1 mykey
你可以在首次使用时用
1 | SCRIPT LOAD |
命令预先加载脚本到缓存:
1
2 SCRIPT LOAD "return redis.call('GET', KEYS[1])"
-- 返回: "a42023b9c6d743ce587bcd7283b08a2a6c34b816"
脚本缓存的生命周期
理解脚本缓存的生命周期对于生产环境非常重要:
| 操作 | 对缓存的影响 |
|---|---|
| SCRIPT LOAD | 将脚本加入缓存 |
| EVAL / EVALSHA | 如果脚本不在缓存中,EVAL会自动加入;EVALSHA会报NOSCRIPT错误 |
| SCRIPT FLUSH | 清空所有缓存脚本 |
| Redis重启 | 缓存完全丢失 |
| 内存淘汰 | 脚本缓存不受maxmemory影响,不会被淘汰 |
当EVALSHA遇到
1 | NOSCRIPT |
错误时,客户端应该自动回退到EVAL重新提交完整脚本。主流的Redis客户端库(Jedis、Lettuce、redis-py、go-redis等)都内置了这个重试逻辑。
生产环境的脚本预热策略
在应用启动时,建议预先加载所有需要的Lua脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 # Python示例:使用redis-py
import redis
r = redis.Redis(host='localhost', port=6379)
# 应用启动时注册脚本
rate_limit_script = r.register_script("""
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, now .. ':' .. math.random(1000000))
redis.call('EXPIRE', KEYS[1], window / 1000)
return 1
end
return 0
""")
# 调用时自动使用EVALSHA,失败自动回退EVAL
result = rate_limit_script(keys=['rate:user123'], args=[60000, 100, int(time.time()*1000)])
1 | register_script |
方法在首次调用时使用EVAL,后续自动切换到EVALSHA,并在遇到NOSCRIPT错误时自动重试。这是最推荐的客户端使用模式。
生产级实战案例
案例一:分布式可重入锁
基于SETNX的简单分布式锁存在不可重入的问题——同一个线程获取锁后再次获取会死锁。使用Lua脚本可以实现可重入锁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 -- 加锁脚本
-- KEYS[1]: 锁的Key
-- ARGV[1]: 唯一标识(线程ID + 序号)
-- ARGV[2]: 过期时间(毫秒)
if redis.call('exists', KEYS[1]) == 0 then
-- 锁不存在,直接加锁
redis.call('hset', KEYS[1], ARGV[1], 1)
redis.call('pexpire', KEYS[1], ARGV[2])
return 1
end
if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
-- 同一个持有者重入
redis.call('hincrby', KEYS[1], ARGV[1], 1)
redis.call('pexpire', KEYS[1], ARGV[2])
return 1
end
return 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 -- 解锁脚本
-- KEYS[1]: 锁的Key
-- ARGV[1]: 唯一标识
-- ARGV[2]: 过期时间(用于看门狗续期)
if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if counter > 0 then
-- 重入次数未归零,续期
redis.call('pexpire', KEYS[1], ARGV[2])
return 0
else
-- 重入次数归零,释放锁
redis.call('del', KEYS[1])
return 1
end
这种基于Hash结构的可重入锁正是Redisson框架的核心实现原理。加锁和解锁操作分别由一个Lua脚本完成,保证了原子性。
案例二:库存扣减的防超卖
电商秒杀场景下,库存扣减是典型的并发问题。直接使用DECR可能导致库存为负数,使用GET+DECR的非原子操作则可能导致超卖。Lua脚本是解决这个问题的标准方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 -- 库存扣减脚本
-- KEYS[1]: 库存Key
-- ARGV[1]: 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then
return -1 -- 商品不存在
end
local deduct = tonumber(ARGV[1])
if stock >= deduct then
redis.call('DECRBY', KEYS[1], deduct)
return stock - deduct -- 返回剩余库存
else
return -2 -- 库存不足
end
这里先读取库存判断是否充足,再扣减,两步在一个原子操作中完成,杜绝了超卖的可能性。注意返回值的设计:正数表示剩余库存,-1表示商品不存在,-2表示库存不足,调用方可以根据不同错误码执行不同的业务逻辑。
案例三:消息去重与幂等消费
消息消费场景中,同一条消息可能被投递多次。利用Lua脚本结合SET NX实现幂等消费:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 -- 幂等消费脚本
-- KEYS[1]: 消息ID集合的Key
-- ARGV[1]: 消息ID
-- ARGV[2]: 去重窗口时间(秒)
local exists = redis.call('SISMEMBER', KEYS[1], ARGV[1])
if exists == 1 then
return 0 -- 消息已处理过
end
redis.call('SADD', KEYS[1], ARGV[1])
-- 首次添加时设置过期时间
local ttl = redis.call('TTL', KEYS[1])
if ttl == -1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1 -- 首次处理
这个脚本判断消息ID是否已存在,不存在则添加并返回1,存在则返回0。整个操作是原子的,即使在并发场景下也不会出现同一条消息被重复处理的情况。
Lua脚本中的常见陷阱与排坑指南
陷阱一:浮点数精度问题
Redis中Lua的数字类型默认是双精度浮点数,但Redis的INCRBY等命令只接受整数。当你在Lua中进行数学运算时,要注意精度问题:
1
2
3
4
5
6
7 -- 错误:浮点数传给只接受整数的命令
local val = 3.14
redis.call('INCRBY', KEYS[1], val) -- 会报错:value is not an integer
-- 正确:确保使用整数
local val = math.floor(3.14)
redis.call('INCRBY', KEYS[1], val)
同样,
1 | redis.call |
返回的整数在Lua中也是数字类型,大整数(超过2^53)可能丢失精度。如果需要处理大整数,应该以字符串形式处理。
陷阱二:nil值与false的混淆
Redis中Key不存在时返回nil,但在Lua中nil和false是不同的值。当
1 | redis.call('GET', KEYS[1]) |
对不存在的Key执行时,Lua中返回的是
1 | false |
而非
1 | nil |
:
1
2
3
4
5
6
7
8
9
10
11 local val = redis.call('GET', KEYS[1])
-- 正确判断Key是否存在
if val == false then
return 'key not found'
end
-- 错误判断(val是false不是nil)
if val == nil then
return 'this will never be true for missing keys'
end
这是Lua脚本中最常见的Bug来源之一。在Redis的Lua实现中,nil只能作为数组中的空洞出现,而Redis返回的空值统一转换为false。
陷阱三:随机性命令与副本一致性
Redis 3.2+引入了
1 | redis.replicate_commands() |
(在Redis 7.0中已废弃,因为默认行为已改变),用于控制脚本中写命令的复制方式。在默认的整脚本复制模式下,如果脚本中包含了
1 | RANDOMKEY |
、
1 | SRANDMEMBER |
、
1 | TIME |
等非确定性命令,Redis会拒绝将脚本写入AOF文件或复制到从节点。
解决方案是在脚本开头调用
1 | redis.replicate_commands() |
切换到逐命令复制模式,但这在Redis 7.0+中已不再是必需的。
陷阱四:脚本中的循环失控
Lua脚本中的循环如果处理大量数据,很容易导致Redis长时间阻塞。一个典型案例是用KEYS命令遍历所有Key并在Lua中逐个处理:
1
2
3
4
5 -- 危险!可能遍历数百万个Key
local keys = redis.call('KEYS', 'user:*')
for i, key in ipairs(keys) do
redis.call('DEL', key)
end
正确做法是在应用层使用SCAN迭代处理,或者将大批量操作拆分为多个小批次脚本执行。
性能优化与监控
脚本执行时间监控
Redis提供了
1 | LATENCY |
命令来监控Lua脚本的执行延迟:
1
2
3
4
5
6 -- 查看Lua相关的延迟事件
LATENCY LATEST command
LATENCY HISTORY command
-- 查看最近100毫秒以上的慢日志
SLOWLOG GET 10
结合
1 | slowlog-log-slower-than |
配置(默认10000微秒),可以捕获执行缓慢的Lua脚本。建议在生产环境中将此值调低到1000微秒,以便及时发现性能问题。
脚本缓存命中率
通过
1 | INFO |
命令可以查看脚本相关的统计信息:
1
2
3
4 INFO stats | grep script
-- 输出示例:
-- script_cached:15 缓存中的脚本数量
-- script_executions:125340 脚本总执行次数
如果发现EVAL的使用频率远高于EVALSHA,说明客户端可能没有正确利用脚本缓存,需要检查客户端配置。
优化建议总结
- 控制脚本长度:单个脚本的执行时间应控制在1毫秒以内,绝对不要超过
1lua-time-limit
- 使用EVALSHA:减少网络传输开销,客户端库的register_script模式是最佳选择
- 避免大Key操作:脚本中对大Hash、大List的操作时间与数据量成正比
- 减少redis.call调用次数:每次redis.call都有内部函数调用开销,能用Pipeline替代的场景优先用Pipeline
- 预热脚本缓存:应用启动时SCRIPT LOAD所有脚本,避免首次EVAL的大包传输
Redis 7.0+的Function机制
Redis 7.0引入了Redis Function,这是Lua脚本机制的进化版本。Function将脚本以函数库的形式持久化在Redis中,解决了原EVAL机制脚本是临时的、重启后丢失的问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 -- 创建一个函数库
FUNCTION LOAD """
#!lua name=mylib
redis.register_function{
function_name='sliding_window_rate_limit',
callback=function(keys, args)
local window = tonumber(args[1])
local limit = tonumber(args[2])
local now = tonumber(args[3])
redis.call('ZREMRANGEBYSCORE', keys[1], '-inf', now - window)
local count = redis.call('ZCARD', keys[1])
if count < limit then
redis.call('ZADD', keys[1], now, now .. ':' .. math.random(1000000))
redis.call('EXPIRE', keys[1], window / 1000)
return 1
end
return 0
end,
flags={ 'no-writes' }
}
"""
-- 调用函数
FCALL sliding_window_rate_limit 1 rate:user123 60000 100 1700000000000
Function相比EVAL的主要改进:
| 特性 | EVAL | Function |
|---|---|---|
| 持久化 | 重启丢失 | AOF/RDB自动持久化 |
| 复制 | 整脚本或逐命令 | 函数库级别复制 |
| 组织方式 | 独立脚本 | 函数库,可包含多个函数 |
| 版本管理 | 无 | 函数库名作为命名空间 |
| 并发控制 | flags可选 | 原生flags支持 |
如果你使用Redis 7.0+,建议新项目优先采用Function机制。对于已有项目,EVAL/EVALSHA仍然完全兼容且长期支持,无需紧急迁移。
总结与选型建议
Redis Lua脚本是一项强大但需要谨慎使用的能力。核心原则总结如下:
- 何时使用Lua脚本:需要原子性的复合操作、条件判断+写操作、自定义数据结构操作
- 何时不使用Lua脚本:简单的单命令操作、需要处理大量数据的批处理、执行时间可能超过1毫秒的复杂逻辑
- 务必通过KEYS传Key:为将来的集群迁移留好退路
- 始终使用EVALSHA:利用客户端库的自动重试机制
- 脚本是双刃剑:原子性保证的同时也意味着阻塞,必须控制执行时间
- Redis 7.0+优先考虑Function:持久化和版本管理是显著改进
正确使用Lua脚本可以让你的Redis应用更高效、更可靠,但滥用则可能带来严重的性能问题。理解底层机制,遵循最佳实践,才能在生产环境中充分发挥Lua脚本的价值。
汤不热吧