跳转到内容
seren-spark

08 原子性、事务与分布式锁

原子性、事务与锁:各自保证到哪里

Section titled “原子性、事务与锁:各自保证到哪里”

学习目标 · 不把 Pipeline、事务、Lua 和分布式锁混为一谈,能识别超时、重试和锁过期的风险。

工具解决什么做不到什么
单条命令,如 INCR一条读改写操作不可被其他命令插入不防客户端重复请求,不保证故障后持久保存
Pipeline不逐条等响应,减少往返和系统调用开销不保证整批原子执行;不能依赖前一条返回再决定后一条
MULTI / EXEC + 可选 WATCH把排队命令作为一组执行;WATCH 做乐观冲突检测不是数据库回滚事务;执行时某条报错不撤销其他条
Lua在服务端执行短小的读取、判断、写入逻辑不包含 MySQL 事务;报错不会回滚已发生的写入

在专用学习实例的 redis-cli 中执行:

SET learn:tx:n 0
SET learn:tx:text abc
MULTI
INCR learn:tx:n
INCR learn:tx:text
SET learn:tx:done yes
EXEC
MGET learn:tx:n learn:tx:done

EXEC 的第二项报“值不是整数”错误,但第一项 INCR 和第三项 SET 已执行,最终读到 1 和 yes。排队阶段的语法错误会导致 EXEC 放弃事务,与这里的执行阶段错误不同。不要用它直接代替 MySQL 转账事务。

WATCH 需要在同一连接里观察、读取、排队与 EXEC。窗口内被监视的 Key 被修改,EXEC 返回空结果,客户端应有界重试,而不是无限循环。Java 中连续调用模板不一定自动帮你绑定这段连接语境,应使用 Spring 的 SessionCallback 等正确方式。

8.3 一个读结果决定写操作时,用 Lua

Section titled “8.3 一个读结果决定写操作时,用 Lua”

秒杀库存演示只保留最小扣减,不把它包装成完整订单系统。库存初始化一次;并发请求只执行 EVAL,不能每次请求重新 SET 库存。

SET learn:stock:101 2
EVAL "local s=tonumber(redis.call('GET',KEYS[1])); if not s or s<=0 then return 0 end; redis.call('DECR',KEYS[1]); return 1" 1 learn:stock:101

在初始库存为 2 时执行 EVAL 三次,预期 1、1、0,最后库存为 0。这个例子保证单实例这段脚本执行时不被其他命令插入,不包含一人一单、订单持久化、客户端重复请求、故障恢复或补偿。真实系统还需要数据库条件更新、唯一约束、订单幂等和可靠事件处理。不要由“Lua 不超卖”推出“整个秒杀系统永远不超卖”。

8.4 分布式锁:其他实例也要遵守的短期租约

Section titled “8.4 分布式锁:其他实例也要遵守的短期租约”

Java 的 synchronized 只约束当前 JVM。多个实例争抢同一资源,可以把 Redis 中的 Key 用作短期租约。加锁需要“不存在才写”和“过期时间”一次完成,Value 是本次持有者的唯一令牌。

SET learn:lock:product:101 token-A-unique NX PX 10000

这里的 token-A-unique 只用于演示;真实程序每次尝试生成唯一随机令牌,不能共用固定值。返回 OK 才获得锁,nil 表示有人持有;超时意味着结果未知,不能直接认为拿到锁。锁不是无限有效的,任务可能暂停到租约过期。

-- Lua · 解锁:比较令牌与删除必须一起执行
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

执行方式: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 也可能表示锁已过期。

时间 ↓
请求 A
请求 B
①
获得锁 token-A,随后停顿
—
②
租约到期
获得同一 Key,值为 token-B
③
恢复后直接 DEL 会误删 B 的锁
仍在执行业务
图 05 · 令牌校验解决误删,但无法阻止过期后的 A 继续写下游。

8.5 Redisson 帮你封装机制,不替你定义正确性

Section titled “8.5 Redisson 帮你封装机制,不替你定义正确性”

Redisson 的 RLock 封装了可重入、等待和续期等机制。以下是方法片段,项目需已配置匹配版本的 RedissonClient;主线无需额外安装它即可理解。不要把 Spring Data 的 Lettuce 配置自动当作 Redisson 配置。

// Java · 方法片段;import org.redisson.api.RLock;
// import java.util.concurrent.TimeUnit;
// redisson 是已配置的 RedissonClient;方法声明 throws InterruptedException
RLock lock = redisson.getLock("shop:lock:product:101");
boolean acquired = lock.tryLock(200, 3000, TimeUnit.MILLISECONDS);
if (!acquired) {
    throw new IllegalStateException("busy, retry later");
}
try {
    rebuildCache(); // 你的有界缓存重建方法
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

示例显式给了 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