在后端开发中,Redis几乎是每个项目的标配。除了常用的GET、SET、INCR等基础命令,面对复杂的业务逻辑时,我们常会遇到一个经典的并发陷阱。
例如这样一个场景:
- 从 Redis 读取一个值(比如库存)。
- 在应用内存中判断是否大于 0。
- 如果大于 0,执行减 1 操作,再将结果写回 Redis。
请注意: 在高并发场景下,这会导致典型的竞态条件 (Race Condition)!最终结果可能是商品超卖,库存扣减远少于实际售出数量。
传统的解决方案,如加分布式锁,虽然可行但引入了额外的复杂性和开销。有没有更优雅高效的方案呢?答案是:Redis Lua 脚本。
💡 为什么要使用 Lua 脚本?
在 Redis 中嵌入 Lua 脚本主要带来三大核心优势:
- 原子性 (Atomicity) ⚛️: Redis 将整个 Lua 脚本作为一个单命令执行。在脚本执行期间,服务器不会处理其他任何命令,这为实现复杂的原子操作提供了天然的保障。
- 减少网络开销 (Performance) ⚡: 原本需要多次网络往返(Get -> 逻辑处理 -> Set)的操作,现在只需发送一次脚本,极大减少了 RTT (Round Trip Time)。
- 复用性 (Reusability) 🔄: 脚本可以被预加载并常驻 Redis 内存,客户端后续只需通过其 SHA1 摘要调用(使用
EVALSHA 命令),进一步提升了效率。
💻 核心语法:EVAL 指令
执行 Lua 脚本的基础 Redis 命令是 EVAL。
基础格式
EVAL script numkeys key [key ...] arg [arg ...]
- script: Lua 脚本代码本身。
- numkeys: 指定后续
key 参数的数量。这对于 Redis Cluster 模式下的正确路由至关重要。
- key: 键名参数,在脚本中通过
KEYS 全局数组访问(索引从 1 开始)。
- arg: 非键名参数,在脚本中通过
ARGV 全局数组访问。
Hello World 示例
一个简单的示例,演示如何拼接 Key 和参数:
> EVAL "return 'Hello ' .. KEYS[1] .. ' ' .. ARGV[1]" 1 myname world
"Hello myname world"
解析:
"return ...": Lua 脚本内容。
1: 表示后面紧跟的 1 个参数是 Key (myname)。
world: 作为额外参数,对应 Lua 脚本中的 ARGV[1]。
🛠️ Lua 与 Redis 的交互
在脚本内部,主要通过两个函数来调用 Redis 命令:
redis.call(): 如果执行的 Redis 命令报错,脚本会停止并抛出错误(推荐使用)。
redis.pcall(): 如果命令报错,会捕获错误并以 Lua 表的形式返回,脚本会继续执行。
示例:实现条件性 INCR
假设我们需要给 user:100:score 增加分数,但要求仅在该 Key 存在时才执行操作:
-- Lua 脚本逻辑
if redis.call("EXISTS", KEYS[1]) == 1 then
return redis.call("INCRBY", KEYS[1], ARGV[1])
else
return nil
end
Redis 命令行调用:
# 假设 Key 不存在,返回 nil
EVAL "..." 1 user:100:score 10
(nil)
# 先设置 Key
SET user:100:score 50
# 再执行脚本
EVAL "..." 1 user:100:score 10
(integer) 60
🔥 实战场景一:分布式限流器 (Rate Limiter)
这是 Lua 脚本最经典的应用场景之一。
需求: 限制每个 IP 地址每秒只能请求某个 API 接口 5 次。
逻辑:
- key 不存在?设置 key 值为 1,并设置过期时间为 1 秒。
- key 存在且值 < 5?将值自增 1。
- key 存在且值 >= 5?拒绝本次请求。
如果使用传统方式(GET -> 判断 -> INCR -> EXPIRE),在高并发下极易被突破限制。使用 Lua 脚本可以原子性地完成整个逻辑。
Lua 脚本代码
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire_time = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return 0 -- 超过限制,拒绝
end
current = redis.call('INCR', key)
if tonumber(current) == 1 then
-- 首次访问,设置过期时间
redis.call('EXPIRE', key, expire_time)
end
return 1 -- 允许访问
调用方式
# key: rate:limit:192.168.1.1
# limit: 5 次
# expire: 1 秒
EVAL "..." 1 rate:limit:192.168.1.1 5 1
🔐 实战场景二:安全释放分布式锁
使用 Redis 实现分布式锁(通常基于 SET key value NX EX)时,锁的释放是一个关键风险点。
错误做法: 锁持有者直接使用 DEL key 删除锁。
风险: 线程 A 的锁因执行超时而自动过期,线程 B 成功获取了新锁。此时 A 执行完毕,调用 DEL 命令,意外删除了 B 持有的锁。
正确做法: 释放锁时,先验证锁中的 Value(如 UUID/Token)是否与当前持有者匹配,匹配成功才执行删除。这是一个典型的“检查并设置”操作。
Lua 脚本代码
-- KEYS[1]: 锁的 key
-- ARGV[1]: 加锁时设置的唯一标识 (UUID/Token)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
此脚本原子性地完成了“验证”与“删除”两个步骤,彻底避免了误删其他客户端持有的锁,是实现分布式锁的安全关键。
⚠️ 最佳实践与避坑指南
Lua 脚本功能强大,但使用时需遵循以下准则,以免对 Redis 服务造成负面影响:
- 脚本应短小精悍 ⏱️: Redis 采用单线程模型。如果 Lua 脚本执行时间过长(例如包含复杂循环或耗时操作),会阻塞整个 Redis 实例,导致其他所有请求延迟。务必保持脚本逻辑简洁高效。
- 明确区分 KEYS 与 ARGV 🏷️:
- 不要在脚本内部硬编码 Key(例如
redis.call("GET", "user:1"))。
- 所有需要操作的 Key 必须通过
KEYS 数组传递。
- 原因:这是 Redis Cluster 模式正常运行的前提。Cluster 根据传入的 Key 计算 Slot,从而将请求路由到正确的节点。不传递 Key 会导致脚本在 Cluster 模式下执行失败。
- 预加载脚本 (SCRIPT LOAD) 💾: 在生产环境中,应避免每次执行都发送完整的 Lua 脚本字符串。
- 使用
SCRIPT LOAD "lua code..." 命令预加载脚本,并获取其 SHA1 摘要。
- 在应用层缓存此 SHA1 值。
- 后续执行时,使用
EVALSHA <sha1> ... 命令。这也是大多数 Redis 客户端 SDK 的默认行为。
- 警惕脚本死循环 🔄: 脚本中的死循环是致命的。对于未执行写操作的只读脚本,可以使用
SCRIPT KILL 命令终止。但如果脚本已经执行了写操作(如 SET, INCR),则只能通过 SHUTDOWN NOSAVE 重启 Redis 服务来恢复。因此,充分的测试至关重要。
📝 总结
掌握 Redis Lua 脚本是后端工程师处理复杂并发场景的重要技能。
- 它原子性地解决了竞态条件问题。
- 通过减少网络往返,大幅提升了性能。
- 是实现分布式限流器、安全分布式锁、秒杀库存扣减等场景的利器。
深入理解并合理运用 Lua 脚本,能让你的数据库与中间件方案更加健壮和高效。