bigywater.tech
分布式事务 · 17

分布式锁与事务的关系

分布式锁和分布式事务常被混在一起说。锁能防住并发的读改写,可它的 TTL 会过期、持有者会被冻住,过期之后会怎样?锁能不能保证多步写入要么都做要么都不做?这篇用真实的 Redis、MySQL 和 SIGSTOP / kill -9,把这几个问题一个个量出来。

699 → 0
800 次读改写更新:不加锁丢的次数 → 加 Redis 锁丢的次数
2 → 1
持有者被冻住 1 秒、锁过期后,两个客户端各加 1 的计数器最终值(写前检查也一样)
100
持锁转账进程 kill -9 后,两个账户合计少掉的钱(1900,应为 2000)

锁保护读改写

实测 · 本机执行输出
  写法                        | 最终值(应为 800) | 丢失的更新 | 总用时    | 操作/秒
  --------------------------+-------------+-------+--------+-----
  不加锁(SELECT 再 UPDATE)      | 101         | 699   | 0.12 s | 6657
  Redis 锁 + SELECT 再 UPDATE | 800         | 0     | 0.86 s | 928 
  一条原子 UPDATE v = v + 1     | 800         | 0     | 0.09 s | 9274

不加锁丢 699 次更新,加 Redis 锁一次不丢,但比数据库原子 UPDATE 慢约 10 倍。

锁会过期

实测 · 本机执行输出
  没有任何防护(读 → 写):
         -3 ms  [A] LOCKED fence=1
         -3 ms  [A] READ v=0
        498 ms  [B] LOCKED fence=2
        498 ms  [B] READ v=0
        499 ms  [B] WROTE v=1
       1004 ms  [A] WROTE v=1
    两个客户端各加了 1 次,计数器应该是 2,实际是 1 —— 一次更新悄悄丢了,两边都以为自己是在锁保护下写的
  写之前检查“锁还是不是我的”,检查通过后被冻住:
       -307 ms  [A] LOCKED fence=1
       -307 ms  [A] READ v=0
         -1 ms  [A] CHECK lock still mine = True
         -1 ms  [A] CHECK_PASSED
        196 ms  [B] LOCKED fence=2
        196 ms  [B] READ v=0
        197 ms  [B] WROTE v=1
       1006 ms  [A] WROTE v=1
    两个客户端各加了 1 次,计数器应该是 2,实际是 1 —— 一次更新悄悄丢了,两边都以为自己是在锁保护下写的

持有者被冻住、锁到期后,别的客户端拿到锁并写入;持有者醒来不知道锁已失效,照样写,更新被悄悄覆盖。写前检查锁也堵不住,检查与写之间有空隙。

fencing token

实测 · 本机执行输出
         -4 ms  [A] LOCKED fence=1
         -3 ms  [A] READ v=0
        502 ms  [B] LOCKED fence=2
        502 ms  [B] READ v=0
        503 ms  [B] WROTE v=1 fence=2 受影响行数=1
       1006 ms  [A] WROTE v=1 fence=1 受影响行数=0 —— 被存储端拒绝(令牌太旧)
    计数器 = 1,存储里记录的最大令牌 = 2:A 的写入被明确拒绝(它知道自己失败了,可以重来),而不是悄悄覆盖 B 的结果

加锁时拿一个单调递增的令牌,存储端在写入时比较,旧令牌的写入被明确拒绝。

锁不是事务

实测 · 本机执行输出
  持锁的转账进程在“已扣款、未入账”时被 kill -9;此刻账户: [(1, 900), (2, 1000)] ,锁 lock:xfer 还剩 790 ms
  1 秒后:别的进程拿到了锁(返回令牌 成功),账户仍是 [(1, 900), (2, 1000)],两个账户的钱合计 1900(应为 2000) —— 锁到期只是放行了别人,扣掉的 100 没有谁去回滚

持锁者崩溃后锁到期放行别人,已完成的那一步不会回滚;多步写入的原子性靠事务或补偿。

吞吐对比

实测 · 本机执行输出
  写法                                   | 操作/秒(3 轮中位数) | 3 轮范围       | 延迟中位数   | 延迟 p99   | 最终值(应为 2400)
  -------------------------------------+--------------+-------------+---------+----------+-------------
  Redis 锁 + SELECT 再 UPDATE            | 3512         | 3199 ~ 3551 | 0.25 ms | 41.81 ms | 2400        
  SELECT ... FOR UPDATE 再 UPDATE(一个事务) | 6172         | 5552 ~ 6806 | 1.11 ms | 3.15 ms  | 2400        
  一条原子 UPDATE v = v + 1                | 9123         | 7159 ~ 9434 | 0.81 ms | 1.47 ms  | 2400        
  数据库自己的行锁 / 原子更新比外面再套一个 Redis 锁便宜,而且不会有“锁过期”的问题;外部锁主要用在被保护的资源没有这种原子能力的时候

数据库行锁 / 原子更新比再套一层 Redis 锁快,也没有锁过期的问题。

分步演示

分布式锁
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MySQL 26.7.0 与 PostgreSQL 16.15(Homebrew,每个实验自己 initdb、自己起实例,端口 9150 以上,只监听 127.0.0.1)/ Python 3.14.8 + PyMySQL 1.2.3 + psycopg 3.3.6 / Redis 8.10.2 / Kafka 4.3.1 / RabbitMQ 4.3.6 / etcd 3.7.2(按各篇注明用到哪些);不连任何外部服务,不碰本机已有的数据库实例;故障用 kill -9 进程、让连接断开或在代码里的指定位置抛异常来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。本篇用 MySQL 26.7.0(ctr、acct 两张表)和自己起的 Redis 8.10.2;锁是 SET key token NX PX ttl,解锁用 Lua 比较令牌再删;“GC 停顿”用对客户端子进程发 SIGSTOP / SIGCONT 模拟;吞吐是 8 个线程、3 轮取中位数,Redis 锁是自旋加锁(间隔 2 ms)
    • 没有验证:Redlock 等多节点 Redis 锁方案;etcd / ZooKeeper 的租约锁(它们同样有过期问题);Redis 主从切换时锁丢失;Redis 锁用在真实网络(跨机)下的延迟;真实 GC 停顿的时长分布(这里是 1 秒的人为停顿)
    京ICP备2026067502号-1京公网安备11010502063487号