Redis 事务、Lua 脚本和分布式锁的实现原理和使用场景是什么?
Redis 事务、Lua 脚本和分布式锁是 Redis 面试中出现频率最高的三个高级特性,很多候选人只能说出命令用法,却讲不清背后的原理和边界,面试官一追问就卡壳。下面逐一拆解。
Redis 事务的原理与局限
Redis 事务通过 MULTI、EXEC、DISCARD、WATCH 四个命令协作完成。MULTI 开启事务后,后续命令进入队列而非立即执行;EXEC 一次性提交队列中的所有命令;DISCARD 放弃事务;WATCH 实现乐观锁,监控 key 是否在事务提交前被修改。
bashWATCH balance MULTI DECRBY balance 100 INCRBY expense 100 EXEC
如果 WATCH 监控的 key 在 EXEC 之前被其他客户端修改,整个事务会被丢弃,返回 nil。
事务的核心特点:
- 命令按顺序执行,不会被其他客户端插入,这是隔离性的保证。
- 不支持回滚。如果队列中某条命令执行失败(比如对字符串执行 LPUSH),其余命令照常执行。Redis 官方的设计哲学是:命令失败属于编程错误,应在开发阶段发现,不值得为此牺牲性能。
- 无法使用中间结果。事务中的命令不能引用前一条命令的返回值,这极大限制了事务的表达能力。
这些局限正是 Lua 脚本存在的理由。
追问:Redis 事务为什么不支持回滚?
Redis 作者 antirez 的原话是:回滚需要保存命令执行前的状态,这会引入与 AOF 持久化类似的复杂度,而命令失败本质是 bug,不应该出现在生产环境。所以 Redis 选择不支持回滚,换来更简单、更快的实现。
Lua 脚本为什么能替代事务
Lua 脚本在 Redis 服务端以原子方式执行,执行期间不会处理其他客户端的命令。和事务相比,Lua 脚本的核心优势在于可以读取中间结果并做条件判断。
bashEVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue
先用 SCRIPT LOAD 获取脚本 SHA1 校验和,后续用 EVALSHA 执行,避免每次传输完整脚本:
bashSCRIPT LOAD "return redis.call('SET', KEYS[1], ARGV[1])" # 返回 sha1sum EVALSHA <sha> 1 mykey myvalue
几个典型的 Lua 脚本应用场景:
原子性 CAS 操作——只有当值等于预期时才更新:
lualocal current = redis.call('GET', KEYS[1]) if current == ARGV[1] then redis.call('SET', KEYS[1], ARGV[2]) return 1 else return 0 end
滑动窗口限流器——一次执行完成过期清理、计数检查、记录添加三个步骤:
lualocal key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) redis.call('ZREMRANGEBYSCORE', key, '-inf', window) local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, window, ARGV[3]) redis.call('EXPIRE', key, math.ceil((window - tonumber(ARGV[4])) / 1000)) return 1 else return 0 end
Lua 脚本需要注意三点:执行时间不能太长,否则阻塞整个 Redis 实例(默认 5 秒超时);不能使用随机函数(如 math.random),否则主从复制结果不一致;不能执行阻塞命令(如 BLPOP)。
追问:Lua 脚本出错会怎样?
Lua 脚本运行时出错会立即停止,但已执行的 Redis 命令不会被撤销——这点和事务一致,都不满足严格意义上的原子性。从外部看,脚本的执行是不可分割的;从内部看,部分成功部分失败是可能的。
分布式锁的三种实现与踩坑
分布式锁要解决的核心问题:在多进程、多机器环境下,保证同一时刻只有一个客户端能操作共享资源。
SETNX + EXPIRE 的问题
最早的做法是先 SETNX 获取锁,再 EXPIRE 设置过期时间。这两步不是原子操作——如果 SETNX 成功后客户端崩溃,锁永远不释放,造成死锁。
SET NX EX(推荐的基础方案)
Redis 2.6.12 起 SET 命令支持 NX 和 EX 参数,一条命令完成加锁和设置过期时间:
javapublic boolean tryLock(String key, String value, int expireSeconds) { String result = jedis.set(key, value, "NX", "EX", expireSeconds); return "OK".equals(result); }
释放锁必须用 Lua 脚本保证原子性——先判断值是否为自己持有,再删除:
luaif redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end
为什么不能直接 DEL?因为锁可能已经过期并被其他客户端获取,直接 DEL 会删掉别人的锁。
追问:主从切换导致锁丢失怎么办?
考虑这个时序:客户端 A 获取锁 → 主节点写入成功但尚未同步到从节点 → 主节点宕机 → 从节点升为主节点 → 客户端 B 获取同一把锁 → 两个客户端同时持有锁。
Redlock 算法就是为解决这个问题设计的。
Redlock 算法
Redlock 向 N 个(通常 5 个)独立的 Redis 实例获取锁,只要在大多数实例(≥3)上成功,且总耗时未超过锁的有效期,就认为加锁成功。这依赖的是时钟同步和多数派决策,不依赖主从复制。
Redlock 的争议:Martin Kleppmann 在《How to do distributed locking》一文中指出 Redlock 依赖系统时钟,当时钟跳变时可能出错,建议使用 fencing token 方案。antirez 专门写了长文反驳。实际工程中,大多数团队选择接受 Redlock 的概率性安全保证,或直接使用单节点锁 + 幂等设计来规避风险。
Redisson 的工程级实现
Redisson 是 Java 生态最成熟的 Redis 分布式锁实现,提供了可重入锁、公平锁、读写锁、联锁等多种锁类型。核心设计:
看门狗机制:默认锁过期时间 30 秒,Redisson 会启动一个后台线程,每 10 秒(过期时间的 1/3)自动续期,直到显式释放或客户端宕机。这解决了长任务执行期间锁过期被其他客户端获取的问题。
javaRLock lock = redisson.getLock("myLock"); try { if (lock.tryLock(10, 30, TimeUnit.SECONDS)) { // 执行业务逻辑 } } finally { lock.unlock(); }
可重入性:Redisson 使用 Hash 结构存储锁信息,field 是客户端 ID + 线程 ID,value 是重入次数,加锁时重入次数 +1,解锁时 -1,减到 0 才真正释放。整个逻辑用 Lua 脚本保证原子性。
事务、Lua 脚本与分布式锁的选型
| 维度 | 事务 | Lua 脚本 | 分布式锁 |
|---|---|---|---|
| 原子性 | 命令不可插入,但不支持回滚 | 同事务 | 依赖实现方式 |
| 条件判断 | 不支持 | 完全支持 | 通过 Lua 脚本支持 |
| 网络开销 | 多次 RTT(除非 pipeline) | 一次 RTT | 多次 RTT |
| 典型场景 | 简单批量操作 | CAS、限流、复杂业务逻辑 | 跨进程互斥 |
选择原则:事务适合不需要中间结果的简单批量操作;Lua 脚本适合需要条件判断、依赖中间结果的场景;分布式锁解决的是跨进程互斥问题,本质上依赖 Lua 脚本保证操作的原子性。
面试中容易被追问的几个点
Redis 事务和 MySQL 事务有什么区别? Redis 事务不支持回滚,没有隔离级别,不保证持久性——它只是批量执行命令的机制,和 ACID 事务有本质区别。
Lua 脚本执行期间 Redis 宕机怎么办? 如果开启了 AOF 持久化,已执行的命令会被记录,重启后重放。如果没有持久化,数据丢失。关键点是 Lua 脚本的"原子性"只保证执行期间不被打断,不保证持久性和严格的原子性。
分布式锁的过期时间怎么设置? 太短容易导致任务未完成锁就被释放,太长会导致客户端宕机后其他客户端等待过久。Redisson 的看门狗机制是最佳实践,动态续期比固定过期时间更可靠。