Appearance
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 已存在 → 什么都不做,返回 01.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 601.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
endjava
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 -- 限流
end2.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,失败后回退到 EVAL2.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:核心对比
| 维度 | SETNX | Lua 脚本 |
|---|---|---|
| 能力 | 只做一件事:不存在则设置 | 能做任何事:判断、循环、计算、多步操作 |
| 原子性 | 单条命令天然原子 | 整个脚本执行期间原子 |
| 返回值 | 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 是工具箱——能用锤子解决的问题别开工具箱,但工具箱能解决锤子解决不了的所有问题。
