Redis 教程:使用 Lua 脚本实现原子操作

· Redis笔记 Redis redis-cli 缓存 入门笔记 NoSQL · 0 阅读 · 约 6 分钟

在 Redis 中,多条命令之间可能被其他客户端插入执行。如果需要把一组操作作为一个整体原子执行,Lua 脚本是最常用的方案。Redis 会单线程地执行整段脚本,期间不会插入其他命令。

为什么用 Lua 脚本

  • 保证原子性:脚本执行期间相当于一个不可分割的事务
  • 减少网络往返:多条命令一次请求完成
  • 可以用命令结果做条件判断,实现"先读后写"的逻辑

EVAL 命令基本用法

redis-cli EVAL "return 1" 0

EVAL 的第一个参数是脚本内容,第二个参数是脚本涉及的 key 数量,之后依次是 KEYS 和 ARGV。

redis-cli EVAL "return {KEYS[1], ARGV[1]}" 1 mykey hello

在脚本中通过 redis.call() 调用 Redis 命令:

redis-cli EVAL "redis.call('SET', KEYS[1], ARGV[1]); return redis.call('GET', KEYS[1])" 1 greeting "你好"

redis.call() 出错会中断脚本,如果想忽略错误可以使用 redis.pcall()。

实战示例

1. 简单限流器

-- KEYS[1]: 计数器 key
-- ARGV[1]: 限流次数
-- ARGV[2]: 过期时间(秒)
local current = tonumber(redis.call('INCR', KEYS[1]))
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
    return 0
end
return 1

调用方式:

redis-cli EVAL "$(cat ratelimit.lua)" 1 rate:user:1001 100 60

2. 释放分布式锁(校验持有者)

-- KEYS[1]: 锁 key
-- ARGV[1]: 锁的唯一标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

先用 SET lock:order1 uuid NX EX 10 加锁,释放时把 uuid 作为 ARGV[1] 传入,避免误删别人的锁。

EVALSHA 与脚本缓存

每次 EVAL 都要传输整段脚本,脚本较长时开销大。Redis 提供 SCRIPT LOAD 把脚本缓存到服务端,返回一个 SHA1 值,之后用 EVALSHA 只传哈希即可。

redis-cli SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 输出类似 "a5260dd66ce02462c5b5231c977b769b48d1b7d9"
redis-cli EVALSHA a5260dd66ce02462c5b5231c977b769b48d1b7d9 1 mykey

注意:Redis 重启或执行 SCRIPT FLUSH 后缓存会丢失,客户端一般要实现"先 EVALSHA,返回 NOSCRIPT 错误再降级 EVAL"的逻辑。

Java 客户端示例

以 Jedis 为例:

try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
    String script =
        "local current = redis.call('INCR', KEYS[1]) " +
        "if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end " +
        "return current";
    Object result = jedis.eval(
        script,
        1,
        "rate:user:1001",
        String.valueOf(60)
    );
    System.out.println("当前计数:" + result);
}

Spring Data Redis 中可以用 DefaultRedisScript:

DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
Long count = stringRedisTemplate.execute(redisScript, List.of("rate:user:1001"), "60");

注意事项

  • Lua 脚本执行期间会阻塞其他命令,脚本逻辑要尽量短,避免慢脚本拖垮 Redis
  • 脚本只能操作在 KEYS 中声明的 key,这是为了保证在 Redis Cluster 下能正确定位到槽位
  • 脚本中的 key 与参数一定要通过 KEYS 和 ARGV 传入,不要硬编码
  • 部分命令(如 SUBSCRIBE)不能在脚本中使用,具体以官方文档为准