08 原子性、事务与分布式锁
原子性、事务与锁:各自保证到哪里
Section titled “原子性、事务与锁:各自保证到哪里”学习目标 · 不把 Pipeline、事务、Lua 和分布式锁混为一谈,能识别超时、重试和锁过期的风险。
8.1 一张表分清四种工具
Section titled “8.1 一张表分清四种工具”| 工具 | 解决什么 | 做不到什么 |
|---|---|---|
| 单条命令,如 INCR | 一条读改写操作不可被其他命令插入 | 不防客户端重复请求,不保证故障后持久保存 |
| Pipeline | 不逐条等响应,减少往返和系统调用开销 | 不保证整批原子执行;不能依赖前一条返回再决定后一条 |
| MULTI / EXEC + 可选 WATCH | 把排队命令作为一组执行;WATCH 做乐观冲突检测 | 不是数据库回滚事务;执行时某条报错不撤销其他条 |
| Lua | 在服务端执行短小的读取、判断、写入逻辑 | 不包含 MySQL 事务;报错不会回滚已发生的写入 |
8.2 亲眼看到“事务不回滚”
Section titled “8.2 亲眼看到“事务不回滚””在专用学习实例的 redis-cli 中执行:
EXEC 的第二项报“值不是整数”错误,但第一项 INCR 和第三项 SET 已执行,最终读到 1 和 yes。排队阶段的语法错误会导致 EXEC 放弃事务,与这里的执行阶段错误不同。不要用它直接代替 MySQL 转账事务。
WATCH 需要在同一连接里观察、读取、排队与 EXEC。窗口内被监视的 Key 被修改,EXEC 返回空结果,客户端应有界重试,而不是无限循环。Java 中连续调用模板不一定自动帮你绑定这段连接语境,应使用 Spring 的 SessionCallback 等正确方式。
8.3 一个读结果决定写操作时,用 Lua
Section titled “8.3 一个读结果决定写操作时,用 Lua”秒杀库存演示只保留最小扣减,不把它包装成完整订单系统。库存初始化一次;并发请求只执行 EVAL,不能每次请求重新 SET 库存。
在初始库存为 2 时执行 EVAL 三次,预期 1、1、0,最后库存为 0。这个例子保证单实例这段脚本执行时不被其他命令插入,不包含一人一单、订单持久化、客户端重复请求、故障恢复或补偿。真实系统还需要数据库条件更新、唯一约束、订单幂等和可靠事件处理。不要由“Lua 不超卖”推出“整个秒杀系统永远不超卖”。
8.4 分布式锁:其他实例也要遵守的短期租约
Section titled “8.4 分布式锁:其他实例也要遵守的短期租约”Java 的 synchronized 只约束当前 JVM。多个实例争抢同一资源,可以把 Redis 中的 Key 用作短期租约。加锁需要“不存在才写”和“过期时间”一次完成,Value 是本次持有者的唯一令牌。
这里的 token-A-unique 只用于演示;真实程序每次尝试生成唯一随机令牌,不能共用固定值。返回 OK 才获得锁,nil 表示有人持有;超时意味着结果未知,不能直接认为拿到锁。锁不是无限有效的,任务可能暂停到租约过期。
执行方式:EVAL "if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) end return 0" 1 learn:lock:product:101 token-A-unique。先用错误令牌执行,预期 0 且锁仍存在;用正确令牌预期 1。要在 10 秒租约内测试,否则 0 也可能表示锁已过期。
8.5 Redisson 帮你封装机制,不替你定义正确性
Section titled “8.5 Redisson 帮你封装机制,不替你定义正确性”Redisson 的 RLock 封装了可重入、等待和续期等机制。以下是方法片段,项目需已配置匹配版本的 RedissonClient;主线无需额外安装它即可理解。不要把 Spring Data 的 Lettuce 配置自动当作 Redisson 配置。
示例显式给了 3 秒 leaseTime,不依赖 watchdog 自动续期。省略固定 leaseTime 的相关重载可以使用 watchdog,具体看所用版本文档;GC 暂停、网络中断、续期失败仍可能让锁过期。isHeldByCurrentThread() 与 unlock 之间也有到期窗口,解锁可能抛异常;要保留原业务异常并记录释放失败,不能把这段简写当成完整生产封装。线程中断应传播或恢复中断标记,不无声吞掉。
对支付、库存等严格正确性,依靠数据库约束、幂等和必要的下游版本校验;fencing token 的核心是下游拒绝旧持有者的写入。知道这一边界即可,第一轮不必深入 Redlock/MultiLock 算法。
- 用 SETNX 后再 EXPIRE;进程中途退出可能留下长期锁。
- 在 Java 中 GET 比较后再 DEL;两次调用之间锁可能已经换了主人。
- 以为续期和主从故障切换能保证永远只有一个执行者;异步复制丢锁也可能产生并发持有。
执行事务错误例子并记录结果;验证错误令牌不会解锁。用时间线解释“锁过期后 A、B 同时执行,但 A 没有误删 B 的锁”为什么仍然危险。
自测与答案 · Lua 原子性是否等于出错全部撤销?
不等于。Lua 的这类原子保证是脚本执行期间不会被其他客户端命令插入;如果脚本先写入再发生运行错误,已执行的写入不自动回滚,应先验证类型和参数再写。锁令牌只保护释放动作;过期持有者仍可能修改下游,因此高正确性业务还要让下游拒绝过时操作或使用数据库约束。
依据:事务与 WATCH · Pipeline · Lua · 分布式锁 · Redisson RLock