CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
事务与 Lua — CodeRoadMap
事务与 Lua:原子操作与可靠的分布式锁
这一课你将能做到
使用 MULTI/EXEC 组织事务,并用 WATCH 实现「检查后执行」
用 EVAL 编写并运行 Lua 脚本,理解它为什么是原子的
手写一套带持有者校验的分布式锁(加锁、解锁、看门狗思路)
为什么要学
前面的课程里我们两次欠下同一笔账:
判题限流的「计数 + 首次设过期」两条命令,中间进程崩溃可能让键永不过期;
List 做可靠队列时,「检查 processing 队列 + 搬任务」需要不可分割。
还有新场景:判题服务部署了多台机器,同一时刻两台机器不能重复消费同一个任务、不能对同一用户重复扣减每日额度。这需要一把分布式锁 ——不同机器上的程序,通过 Redis 这个「中间人」达成互斥。
这些需求的共同名字叫原子性 :一组操作要么都做完,要么像没开始,中间不能被任何人插进来。Redis 提供两种工具:事务(MULTI/EXEC)和 Lua 脚本。
先建立图像
普通执行:命令A ── 别人的命令X 插进来!── 命令B ← 两步之间状态可能变
事务: MULTI → 命令A 入队 → 命令B 入队 → EXEC
Redis:A、B 连着执行,期间不接受插队
Lua: EVAL "一段脚本" key数 key... 参数...
脚本像一条「超级命令」,从头到尾独占执行
跟着做
步骤 1:第一个事务 MULTI/EXEC
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET judge:slot:1001 locked
QUEUED
127.0.0.1:6379(TX)> EXPIRE judge:slot:1001 30
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 1
QUEUED 表示命令只是排队;EXEC 时所有命令依次执行并逐条返回结果。放弃队列用 DISCARD。
步骤 2:事务的关键认知——不支持回滚
这是 Redis 事务和数据库事务最大的不同:
命令语法错误 (入队时就能发现),整个 EXEC 被拒绝,一条都不执行;
命令运行期错误 (比如对字符串 LPOP),出错的那条失败,其余命令照常执行且不会回滚 。
所以 Redis 事务保证的是「命令打包、隔离执行」,不是数据库式 ACID。你需要在入队前自己保证类型、参数正确。
步骤 3:WATCH——乐观锁
想做「这个名额还在,我才占用」,先 WATCH 监视键:
127.0.0.1:6379> SET quota:course:7 2
OK
127.0.0.1:6379> WATCH quota:course:7
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECR quota:course:7
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 1
如果在 WATCH 之后、EXEC 之前,别的客户端改动了 quota:course:7,EXEC 直接返回 nil——一个命令都没执行。客户端收到 nil 后应该重新读值、判断、重试整段逻辑,这就是乐观并发控制(CAS 思路)。
模拟「处理中队列的超时任务回收」:WATCH 监视任务键,只有它仍处于 processing 才搬回主队列,发现被别人动过就放弃重试。
步骤 4:Lua 脚本入门 EVAL
127.0.0.1:6379> EVAL "return 'hello redis'" 0
"hello redis"
EVAL 格式:EVAL 脚本 键的数量 键1 键2 ... 参数1 参数2 ...。脚本里用 KEYS[1] 取键、ARGV[1] 取参数。注意下标从 1 开始。
步骤 5:原子限流脚本 把「计数、首次设过期、判断阈值」一次做完。新建脚本:
127.0.0.1:6379> EVAL "local c = redis.call('INCR', KEYS[1]); if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; if c > tonumber(ARGV[2]) then return 0 else return 1 end" 2 ratelimit:judge:user:1001 placeholder 10 5
(integer) 1
为了清晰,写成多行也可以(redis-cli 直接粘贴):
local c = redis.call('INCR', KEYS[1])
if c == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if c > tonumber(ARGV[2]) then
return 0 -- 0 表示被限流
else
return 1 -- 1 表示放行
end
执行:EVAL "上面的脚本" 1 ratelimit:judge:user:1001 10 5。连续第 6 次调用时返回 0。计数与过期在同一次执行中完成,崩溃也不会留下永不过期的键。
步骤 6:EVALSHA——先加载再调用 脚本长时每次都传全文浪费带宽。先加载得到 SHA 摘要:
127.0.0.1:6379> SCRIPT LOAD "return 42"
"1fa00e76656cc152ad327c134676d5cbe5854749"
127.0.0.1:6379> EVALSHA 1fa00e76656cc152ad327c134676d5cbe5854749 0
(integer) 42
应用启动时加载脚本、运行时用摘要调用;收到 NOSHASTERROR(实例重启后缓存清空)再重新 LOAD 即可。
步骤 7:手写分布式锁 加锁:值放「锁的持有者标识」(UUID),并强制过期,防止持有者宕机后死锁:
127.0.0.1:6379> SET lock:judge:task:8801 550e8400-e29b-41d4-a716-446655440000 NX EX 30
OK
NX 保证只有一个客户端成功;EX 30 保证锁最多存在 30 秒。返回 OK 才进入临界区(执行判题),nil 说明锁被占,稍后重试。
解锁的两个坑:直接 DEL 可能删掉别人的锁(你的锁 30 秒过期了,别人刚拿到,你却来删)。所以「比对持有者 + 删除」必须用 Lua 原子完成:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:judge:task:8801 550e8400-e29b-41d4-a716-446655440000
(integer) 1
锁超时但业务没做完怎么办?生产中由「看门狗」线程在持有期间周期性续期(EXPIRE 前检查自己仍持有);或直接把锁时间设得明显大于业务耗时。Redis 集群环境还要知道主从切换极端情况下锁可能丢失,要求绝对正确可参考 Redlock 思想,面试了解即可。
讲清楚要点
MULTI/EXEC 是「打包 + 隔离」,不是数据库事务 :无回滚,运行期错误不影响其他命令。
WATCH 是乐观锁 :被监视键在EXEC 前被改动,整批放弃(返回 nil),客户端重试。
Lua 脚本整体原子执行 :执行期间不会插入其他命令,适合「读-判断-写」的复合原子操作;脚本应短而快,长脚本会阻塞所有人。
所有脚本操作键必须走 KEYS/ARGV :不要把键名硬编码拼接进脚本正文,这在集群中才能正确路由(集群只允许脚本操作同一槽的键,多键用 hash tag)。
分布式锁三要素 :互斥(NX)、防死锁(过期)、防误删(持有者标识 + Lua 比对删除)。
redis.call 与 redis.pcall :前者遇错直接抛出,后者捕获错误返回错误表,按需选择。
你可能会懵的地方
既然 Lua 更强大,为什么还要 MULTI?
简单批量排队用 MULTI 直观、零脚本维护成本;一旦需要条件判断、循环、中间结果参与决策(限流、解锁),才需要 Lua。
WATCH 监视有过期时间的键,到期自动删除会触发放弃吗?
会。键过期被删除也视为被修改,监视它的事务会收到放弃信号,重试即可。
锁的过期时间设多长合适?
略大于临界区的正常最长耗时,并配合看门狗续期。设太短业务没做完锁就丢;设太长持有者宕机后别人等待久。
脚本里怎么返回多个值?
return {值1, 值2};需要区分类型可用 redis.call 的原始返回,客户端会转成对应数组。
Redis 函数(FUNCTION,7.0)和 EVAL 什么关系?
FUNCTION 支持具名、持久化管理的脚本,比 EVALSHA 更适合长期维护。初学掌握 EVAL/EVALSHA,进阶再迁移到 FUNCTION。
错法对照 错法 问题 正解 以为 EXEC 会回滚错误命令 错误命令失败,其余照执行 入队前保证参数和类型正确 解锁直接 DEL 可能删掉别人的锁 GET 比对 UUID,Lua 原子删 加锁不设过期 持有者宕机,永久死锁 SET NX EX 必须带时间 脚本里写死键名 集群无法正确路由 键全部通过 KEYS 传入 在 Lua 里跑复杂循环几秒 阻塞所有客户端 脚本短小,重活留在应用层 WATCH 后 EXEC 返回 nil 不重试 业务静默失败 重新读状态、判断并重试
卡点急救
EXEC 返回 nil :不是出错,是被 WATCH 的键发生了变化,重走整个流程。
EVAL 报 wrong number of args :检查键的数量是否和 KEYS 使用一致、ARGV 下标是否错位(下标从 1 开始)。
分布式锁出现两人同时进入 :检查是否所有入口都走同一把锁、值是否唯一、过期是否过短导致提前释放。
面试常问
Redis 事务和数据库事务的区别?
Redis 事务保证命令顺序打包、执行期间不被插入,但不支持回滚:语法错误全拒绝,运行期错误只失败当前命令;配合 WATCH 可做乐观锁。
Redis 分布式锁怎么实现?要注意什么?
SET key 唯一标识 NX EX 超时 加锁;释放时用 Lua 先 GET 比对标识再 DEL;业务时间长用看门狗续期。还要考虑主从切换丢锁(Redlock 思路)和不可重入问题。
为什么释放锁要用 Lua 而不是 GET 后再 DEL?
GET 与 DEL 两步之间锁可能过期并被别人拿到,此时 DEL 会误删他人锁。Lua 把比对和删除合成原子操作。
自测三问
MULTI 事务里一条命令运行期类型出错,其他命令会怎样?
写出原子限流 Lua 脚本的逻辑骨架,并说明 0 和 1 的含义。
分布式锁的三个要素分别用什么机制保证?为什么解锁不能直接 DEL?
小结一句话 MULTI 负责打包隔离、WATCH 负责乐观检查、Lua 负责把「读-判断-写」压成一条原子命令,而分布式锁就是「NX 互斥 + EX 防死锁 + 标识比对防误删」。
延伸