Skip to content

Redis SETNX 与 Redis Lua

  SETNX 和 Lua 是 Redis 中实现原子操作的两大核心手段。SETNX 是 Redis 内置的原子命令,Lua 是 Redis 内嵌的脚本引擎——两者都利用 Redis 单线程特性保证原子性,但能力边界完全不同。

一、SETNX:最轻量的原子操作

1.1 是什么

SETNX = SET if Not eXists

含义:key 不存在时才设置,存在则什么都不做

SETNX lock:order:1001 uuid-12345

  - 如果 lock:order:1001 不存在 → 设置成功,返回 1
  - 如果 lock:order:1001 已存在 → 什么都不做,返回 0

1.2 为什么是原子的

  SETNX 是 Redis 的一条命令,Redis 单线程执行命令,一条命令执行完才执行下一条。所以 SETNX 的"判断是否存在 + 设置"在执行期间不会被其他命令打断。

不是原子的做法(两步操作):

  GET lock:order:1001  → 返回 nil(不存在)
  ... 这个间隙,另一个请求也执行了 GET,也返回 nil ...
  SET lock:order:1001 uuid-12345 → 两个请求都设置成功了!

SETNX 一步搞定:

  SETNX lock:order:1001 uuid-12345 → 原子操作,只有一个能成功

1.3 核心使用场景

场景一:分布式锁

java
public boolean tryLock(String lockKey, String lockValue, int expireSeconds) {
    // SETNX + EXPIRE 合并为一条命令(Redis 2.6.12+)
    Boolean success = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(expireSeconds));
    return Boolean.TRUE.equals(success);
}
为什么需要 SETNX + EXPIRE 合并?

  ❌ 错误写法:
  SETNX lock:order:1001 uuid-12345
  EXPIRE lock:order:1001 30
  → 如果 SETNX 成功后、EXPIRE 执行前,程序崩溃了,锁永远不释放

  ✅ 正确写法(Redis 2.6.12+):
  SET lock:order:1001 uuid-12345 NX EX 30
  → 设置值和过期时间是原子操作,不会出现"设了锁但没设过期时间"

场景二:去重 / 幂等

java
public boolean isFirstRequest(String requestId) {
    // 请求 ID 不存在 → 第一次请求
    // 请求 ID 已存在 → 重复请求
    return Boolean.TRUE.equals(
        redisTemplate.opsForValue()
            .setIfAbsent("request:" + requestId, "1", Duration.ofMinutes(5))
    );
}

场景三:限流计数器初始化

bash
# 初始化限流计数器(只在第一次请求时创建)
SETNX rate_limit:user:1001 0
EXPIRE rate_limit:user:1001 60

1.4 SETNX 的局限

SETNX 只能做一件事:判断 key 是否存在,不存在则设置

以下场景 SETNX 无能为力:

  ❌ 判断 value 的值再决定是否操作(比如库存 > 10 才扣减)
  ❌ 先 GET 再 SET(不是原子操作)
  ❌ 多个 key 的联合操作(同时操作两个 key)
  ❌ 复杂的条件判断 + 多步操作
  ❌ 需要返回计算结果(SETNX 只返回 0 或 1)

  这些场景必须用 Lua 脚本

二、Redis Lua:终极原子操作武器

2.1 是什么

  Redis 从 2.6 版本开始内嵌了 Lua 5.1 解释器。你可以把一段 Lua 代码发送给 Redis,Redis 把它当作一个整体原子执行——脚本执行期间,其他命令全部排队等待。

Redis 执行 Lua 脚本的过程:

  客户端发送 EVAL "script" 1 key1 arg1


  Redis 收到 EVAL 命令


  Redis 暂停处理其他所有命令(单线程特性)


  Lua 解释器执行脚本(可以调用 redis.call() 执行任意 Redis 命令)


  脚本执行完毕,返回结果


  Redis 恢复处理其他命令

2.2 为什么 Lua 脚本是原子的

  核心原因:Redis 单线程 + Lua 脚本不可中断。

Redis 主线程:
  ┌────────────────────────────────────────────┐
  │  ... 处理命令 A ...                         │
  │  ... 处理命令 B ...                         │
  │  → 收到 EVAL 命令 ←                        │
  │  ┌──────────────────────────────────┐       │
  │  │ Lua 脚本执行(原子)              │       │
  │  │   redis.call('GET', KEYS[1])     │       │
  │  │   redis.call('INCR', KEYS[2])    │  ← 这期间,其他任何命令都不会执行
  │  │   redis.call('SET', KEYS[3], x)  │       │
  │  └──────────────────────────────────┘       │
  │  → EVAL 返回 ←                             │
  │  ... 处理命令 C ...                         │
  └────────────────────────────────────────────┘

  这和 MySQL 的事务不同——MySQL 事务可以回滚,Redis Lua 脚本不支持回滚。脚本执行到一半如果出错,已经执行的命令不会撤销。但因为 Redis 单线程,脚本执行期间数据不会被其他命令修改,所以逻辑上是原子的

2.3 核心使用场景

场景一:分布式锁的安全释放

lua
-- unlock.lua
-- 判断锁的 value 是否匹配,匹配才释放(防止误删别人的锁)
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
java
public void unlock(String lockKey, String lockValue) {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setScriptText(
        "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('DEL', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end");
    script.setResultType(Long.class);

    redisTemplate.execute(script,
        Collections.singletonList(lockKey), lockValue);
}
为什么释放锁需要 Lua?

  ❌ 两步操作不是原子的:
  GET lock:order:1001 → 返回 uuid-12345(匹配)
  ... 锁刚好过期,另一个请求获取了锁,设置了 uuid-67890 ...
  DEL lock:order:1001 → 删了别人的锁!

  ✅ Lua 脚本一步完成:
  判断 GET 结果 == 传入的 value → 匹配则 DEL → 原子操作,不会误删

场景二:库存扣减(判断 + 扣减)

lua
-- deduct_stock.lua
local stock = redis.call('GET', KEYS[1])
if not stock then
    return -1  -- 库存未初始化
end

stock = tonumber(stock)
local quantity = tonumber(ARGV[1])

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

redis.call('DECRBY', KEYS[1], quantity)
return 1  -- 扣减成功

场景三:限流(滑动窗口)

lua
-- sliding_window_rate_limit.lua
local key = KEYS[1]
local window = tonumber(ARGV[1])  -- 窗口大小(毫秒)
local limit = tonumber(ARGV[2])   -- 限流阈值

-- 获取当前时间戳(毫秒)
local now = redis.call('TIME')
now = now[1] * 1000 + math.floor(now[2] / 1000)

-- 移除窗口外的过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 统计窗口内的请求数
local count = redis.call('ZCARD', key)

if count < limit then
    -- 放行:记录本次请求
    redis.call('ZADD', key, now, now .. '-' .. math.random())
    redis.call('PEXPIRE', key, window)
    return 1
else
    return 0  -- 限流
end

场景四:令牌桶限流

lua
-- token_bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])   -- 桶容量
local rate = tonumber(ARGV[2])       -- 每秒生成令牌数
local now = tonumber(ARGV[3])        -- 当前时间(秒)

-- 获取当前桶状态
local tokens = tonumber(redis.call('HGET', key, 'tokens')) or capacity
local last_time = tonumber(redis.call('HGET', key, 'last_time')) or now

-- 计算新增令牌数
local delta = math.max(0, now - last_time)
tokens = math.min(capacity, tokens + delta * rate)

-- 尝试消费 1 个令牌
if tokens >= 1 then
    tokens = tokens - 1
    redis.call('HSET', key, 'tokens', tokens)
    redis.call('HSET', key, 'last_time', now)
    return 1  -- 放行
else
    return 0  -- 限流
end

2.4 EVALSHA 优化(脚本缓存)

java
// 每次 EVAL 都要发送完整脚本,浪费带宽
// Redis 会对脚本做 SHA1 哈希,后续可以用 EVALSHA 直接调用

// 第一步:加载脚本
String sha1 = redisTemplate.getConnectionFactory()
    .getConnection().scriptLoad(script.getBytes());

// 第二步:后续用 EVALSHA 调用(只传 SHA1,不传脚本内容)
DefaultRedisScript<Long> cachedScript = new DefaultRedisScript<>();
cachedScript.setSha1(sha1);
cachedScript.setResultType(Long.class);
redisTemplate.execute(cachedScript, keys, args);

// Spring Data Redis 会自动处理:先尝试 EVALSHA,失败后回退到 EVAL

2.5 Lua 脚本的注意事项

⚠️ 关键约束:

1. 不要写耗时脚本
   Redis 单线程,脚本执行期间所有命令排队
   默认超时 5 秒(lua-time-limit),超时会写日志但不中断

2. 所有 key 必须通过 KEYS 数组传入
   集群模式下,Redis 根据 key 做哈希路由
   脚本中硬编码的 key 可能导致"跨槽操作"错误

3. 不要写随机逻辑
   redis.call('TIME') 等随机操作会影响主从复制一致性
   主从复制是基于命令的,随机结果会导致主从数据不一致

4. 脚本不要依赖外部状态
   Lua 脚本只能访问 Redis 内部数据,不能访问外部 API、文件系统

5. 返回值的类型要一致
   Lua 的 table 转 Redis 的 array 时,下标必须从 1 开始且连续

三、SETNX vs Lua:核心对比

维度SETNXLua 脚本
能力只做一件事:不存在则设置能做任何事:判断、循环、计算、多步操作
原子性单条命令天然原子整个脚本执行期间原子
返回值0 或 1任意类型(数字、字符串、数组)
复杂度极低中等(需要学 Lua 语法)
性能极快(单条命令)快(但脚本越长越慢)
调试不需要调试调试困难,建议本地写好再上线
集群兼容完全兼容需要所有 key 在同一个 slot

四、选型决策

需要做的事情                    → 用 SETNX          → 用 Lua
─────────────────────────────────────────────────────────
只判断 key 是否存在            → ✅ SETNX          → 可以但没必要
分布式锁加锁                  → ✅ SET NX PX      → 可以但没必要
分布式锁释放                  → ❌ 不够用          → ✅ Lua(判断 value 再删)
库存扣减                      → ❌ 不够用          → ✅ Lua(判断 + 扣减)
限流(固定窗口)               → ✅ INCR + EXPIRE  → 可以
限流(滑动窗口 / 令牌桶)      → ❌ 做不了          → ✅ Lua
需要条件判断                  → ❌ 做不了          → ✅ Lua
多步操作                     → ❌ 做不了          → ✅ Lua
需要返回计算结果               → ❌ 只返回 0/1     → ✅ Lua

五、面试要点

Q1:SETNX 为什么是原子的?

  SETNX 是 Redis 的一条命令,Redis 单线程执行命令,一条命令执行完才执行下一条。所以"判断 key 是否存在 + 设置"这个过程不会被其他命令打断。

Q2:Lua 脚本为什么能保证原子性?

  Redis 单线程执行命令,EVAL 命令执行期间,Redis 不会执行其他任何命令。整个 Lua 脚本就像一个超长的原子命令,直到执行完毕才处理下一个请求。

Q3:SETNX 实现分布式锁有什么问题?

  三个问题:① SETNX 和 EXPIRE 是两步操作,不是原子的(需要用 SET key value NX PX 替代);② 释放锁时需要判断 value 是否匹配,防止误删别人的锁(需要 Lua 脚本);③ 锁过期时间不好设置,业务执行超时锁会提前释放(需要看门狗机制自动续期)。

Q4:Lua 脚本和 Redis 事务(MULTI/EXEC)有什么区别?

  ① Lua 脚本支持条件判断和循环,事务不支持;② Lua 脚本是一次网络往返,事务需要多次(MULTI、多条命令、EXEC);③ Lua 脚本可以缓存为 EVALSHA,减少网络传输;④ 两者都不支持回滚。

Q5:什么时候该用 Lua,什么时候该用 SETNX?

  一句话:只需要判断 key 是否存在 → SETNX;需要判断 + 操作 + 返回结果 → Lua。SETNX 是锤子,Lua 是工具箱——能用锤子解决的问题别开工具箱,但工具箱能解决锤子解决不了的所有问题。