《Java 并发编程·性能诊断与事故》11.2 分布式锁的租约与续期

分布式锁的本质是一段有期限的授权,看门狗只能降低概率,不能消除“租约过期但旧持有者仍在写”。本节用不依赖 Redis/ZooKeeper/etcd 的单机 ReentrantLock 加人造时钟复现旧持有者复活,实测 fencing token 把过期写入挡在存储侧,并给出时钟漂移、看门狗失效与续期失败的边界。

承接的问题

11.1 的锁都在一个 JVM 或一个数据库里,等待图是共享的。跨进程之后,连“谁持锁”都要靠外部系统裁决,于是出现一类反复发生的事故:

  • 日志里 A、B 两个实例都打印了“获取锁成功”,两边都在写同一行库存,最后超卖或互相覆盖;
  • 锁的 TTL 是 30 秒,GC 停顿或网络分区之后,A 的锁过期了,B 合法接管,A 恢复后继续按“我还持有锁”的假设写完剩余逻辑;
  • 看门狗每 10 秒续期一次,监控显示续期成功率 100%,但数据仍然被覆盖;
  • 时钟回拨之后,一部分客户端认为锁已过期提前抢锁,另一部分认为还有效继续写。

核心结论:分布式锁不是互斥锁,是“有期限的授权”。只要“判断是否过期”和“执行写入”不是同一个原子动作,就存在一个窗口:授权已经失效,持有者还不知道。看门狗只能缩小这个窗口,不能消除它;真正能兜底的是 fencing token——让存储侧拒绝过期持有者的写入。

本节不依赖 Redis、ZooKeeper、etcd 的任何客户端。实验用单机 ReentrantLock 保护租约状态、用人造时钟模拟“所有线程都停住了这么久”,完整可运行(不依赖 Redis/ZooKeeper/etcd),输出为本机真实结果。数据口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8;load average 70–170,计时为单次运行、随调度波动、不作基准。

租约不是锁,是一段有期限的授权

一个租约至少包含四个字段:持有者、过期时间、单调递增的 token、以及“由谁裁决过期”。前三者在客户端和服务端各存一份,第四者只能是服务端(或数据库)的时钟。

字段谁写客户端能否自行延长过期判据
持有者抢锁时写入否服务端记录
过期时间服务端按 TTL 计算只能通过续期请求服务端时钟
fencing token服务端每次授予时自增否存储侧比较
续约(看门狗)客户端定时发起是,但可能失败服务端重新计算

看门狗的定位要说清楚:它是一个降低“无锁窗口”概率的优化,不是正确性保证。它和被保护的进程同生共死,下面四种情况下它一定失效:

故障看门狗为什么救不了
长时间 STW / Full GC进程内所有线程都停了,定时任务同样停
网络分区,租约服务不可达续期请求发不出去或回不来,服务端按过期处理
进程假死、CPU 饥饿续期线程拿不到 CPU,和业务线程一起饿
租约服务主从切换新主可能还没看到旧租约,出现短暂双主

所以 TTL 的选取不是“够用就行”,而要覆盖最坏停顿:TTL ≥ 2 × 最大停顿 + 2 × 网络 RTT + 安全余量,续期间隔取 TTL / 3。这个公式给的是下界,真实值要靠压测和 GC 日志里的最大停顿来定。

三种常见存储的租约语义差异很大,选型时要看的是“过期由谁裁决”,而不是 API 名字:

存储过期裁决会话/租约失效后的行为天然有 fence 吗
Redis(SET NX PX)Redis 单机时钟主从切换可能丢锁或短暂双持有否,需要自己生成自增 token
ZooKeeper 临时顺序节点会话超时会话断开后节点删除,顺序号可当 fence是,zxid/顺序号单调
etcd leaseetcd 时钟 + 续约续约停止即过期,revision 单调是,revision 可当 fence

三种都只能保证“在它们各自的裁决下互斥”,都挡不住“客户端已经拿到 result 但写入晚到”。所以 fencing 不是某一种存储的补丁,而是所有租约方案的必备层。本节实验不依赖以上任何一种。

实验:人造时钟下“旧持有者复活”

下面这段代码用一把 ReentrantLock 保护租约状态,用可手动推进的 ManualClock 模拟时间跳跃。advance(250) 的含义是“这 250 毫秒里没有任何线程在跑”——对应 STW 或分区期间的进程内视角。

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;

public final class LeaseLab {

    /** 人造单调时钟,单位毫秒;advance 表示所有线程都停住了这么久。 */
    static final class ManualClock {
        private final AtomicLong millis = new AtomicLong();
        long now() { return millis.get(); }
        void advance(long delta) { millis.addAndGet(delta); }
    }

    record Lease(String client, long fence, long expiresAt) {}

    /** 租约服务:单机锁保护 owner/fence/expiresAt,语义对齐 SET NX PX。 */
    static final class LeaseService {
        private final ReentrantLock guard = new ReentrantLock();
        private final ManualClock clock;
        private String owner;
        private long fence;
        private long expiresAt = -1;

        LeaseService(ManualClock clock) { this.clock = clock; }

        Lease acquire(String client, long ttlMillis) {
            guard.lock();
            try {
                if (owner != null && clock.now() < expiresAt) {
                    return null;                 // 仍被有效持有
                }
                owner = client;
                fence++;
                expiresAt = clock.now() + ttlMillis;
                return new Lease(client, fence, expiresAt);
            } finally {
                guard.unlock();
            }
        }

        /** 只有“还是我、且没过期”才允许续期;看门狗走的就是这条路。 */
        boolean renew(Lease lease, long ttlMillis) {
            guard.lock();
            try {
                if (!lease.client().equals(owner) || lease.fence() != fence) {
                    return false;                // 已被别人接管
                }
                if (clock.now() >= expiresAt) {
                    return false;                // 已过期,续期窗口关闭
                }
                expiresAt = clock.now() + ttlMillis;
                return true;
            } finally {
                guard.unlock();
            }
        }
    }
}

存储侧只认递增的 token:写入请求必须带上 fence,小于等于已见最大值的直接拒绝。

/** 存储侧:只认递增的 fence,小于等于已见最大值的写入一律拒绝。 */
static final class FencedStore {
    private long lastFence;
    private String value = "<empty>";
    private int staleRejected;

    synchronized boolean write(long fence, String v) {
        if (fence <= lastFence) {
            staleRejected++;
            return false;
        }
        lastFence = fence;
        value = v;
        return true;
    }

    synchronized String value() { return value; }
    synchronized int staleRejected() { return staleRejected; }
}

private static void resurrectionDemo(boolean useFence) {
    ManualClock clock = new ManualClock();
    LeaseService svc = new LeaseService(clock);
    FencedStore store = new FencedStore();

    Lease a = svc.acquire("A", 100);
    store.write(a.fence(), "A-old");            // A 已写过一次

    clock.advance(250);                         // A 停住:STW / 分区,看门狗也停
    Lease b = svc.acquire("B", 100);            // B 合法接管
    boolean bOk = store.write(b.fence(), "B-new");

    boolean aOk = useFence
            ? store.write(a.fence(), "A-stale") // A 复活,带旧 fence
            : writeUnfenced(store, "A-stale");

    System.out.printf("useFence=%-5s owner=%s A.fence=%d B.fence=%d bWrote=%s aWrote=%s final=%s rejected=%d%n",
            useFence, svc.owner(), a.fence(), b.fence(), bOk, aOk, store.value(), store.staleRejected());
}

本机真实输出(两段合起来是三个独立实验,各自一次运行):

--- 1. 租约 + 续期(看门狗)---
acquire fence=1 renewed=true serverOwner=A
now=110 localRecordExpiry=100 serverExpiry=130 validByRecord=false validByServer=true
--- 2. 旧持有者复活:无 fencing vs 有 fencing ---
useFence=false owner=B A.fence=1 B.fence=2 bWrote=true aWrote=true final=A-stale rejected=0
useFence=true  owner=B A.fence=1 B.fence=2 bWrote=true aWrote=false final=B-new rejected=1
--- 3. 客户端时钟漂移导致的误判 ---
clientDeadline=100 serverNow=150 clientNow=-50 clientThinksValid=true serverSaysValid=false

第二段是核心:没有 fencing 时,A 的过期写入把 B 刚写的数据覆盖成 A-stale,日志里两个客户端“都成功”;有 fencing 时,存储侧发现 1 <= 2,直接拒绝,最终值保持 B-new,并把 rejected 加一。

第一段还暴露了一个小陷阱:续期成功后,服务端过期时间从 100 延到 130,但客户端手里的 Lease 记录仍然是 100。如果客户端用本地记录判断“我还能写多久”,会在第 100 毫秒就误判为过期。续期接口必须返回新的过期时间,或者让客户端只信服务端的裁决。

fencing token:把唯一性搬到存储侧

fencing 的全部要求可以归纳成四条,缺一条就失效:

  1. token 由租约服务在授予时生成,单调递增,作用域是锁的 key,不是客户端本地计数。客户端自增的 token 在旧持有者复活后可能刚好比新持有者大。
  2. 写入必须携带 token,包括数据库更新、消息发送、缓存失效、对象存储写入。只保护数据库、不保护消息,旧持有者仍能把过期事件投递出去。
  3. 存储侧必须原子地比较并拒绝。SQL 里最直接的写法是带上条件:
UPDATE book
   SET stock = stock - 1,
       fence = ?
 WHERE isbn = ?
   AND fence < ?;

影响行数为 0 就表示 token 过期,按冲突处理。也可以用唯一索引 (key, fence) 或 Redis 的 Lua 比较脚本,但判据一样:比较和写入必须在同一个原子操作里,不能先查后写。

  1. token 要能持久化并参与后续读取。如果只写不读,下一个持有者拿到旧数据时无法判断它是不是过期版本;至少要在行上保留最大 token,或者把 token 写进版本号列。

回答一个常见疑问:fencing 能不能替代幂等?不能。它解决的是“过期写入被接受”,不解决“同一次写入被执行两次”。重试、消息重复投递仍然需要幂等键,两者是叠加关系。

时钟:别用客户端时钟判断租约

第三段实验把人造时钟往前推 150 毫秒,同时假设客户端在获取租约之后被 NTP 回拨了 200 毫秒:

Lease lease = svc.acquire("A", 100);
long clientDeadline = lease.expiresAt();   // 获取时两端时钟一致,本地记下绝对截止点
serverClock.advance(150);                  // 服务端:已过期
long clientOffset = -200;                  // 之后 NTP 把客户端时钟回拨 200ms
long clientNow = serverClock.now() + clientOffset;
boolean clientThinksValid = clientNow < clientDeadline;   // true
boolean serverSaysValid = serverClock.now() < lease.expiresAt();  // false

本机输出 clientThinksValid=true serverSaysValid=false:客户端认为自己还有 50 毫秒,服务端早已判定过期。结论只有一条:

  • 过期判断只在服务端做,客户端不能用本地墙钟推断“锁还有效”;
  • 需要测本地耗时时用 System.nanoTime()(单调时钟),不要用 System.currentTimeMillis();
  • 服务端返回的过期时间只能作为“估算剩余时间”用于日志和告警,不能作为写入前的放行条件。

跨机器的时钟漂移不会因为用了 NTP 就消失:NTP 的阶跃(step)会让时钟瞬间前后跳,这正是上面实验建模的场景。

续期失败之后

看门狗续期失败时,正确的动作不是重试到成功,而是立即停止写入并退出临界区:

续期返回 false 或超时
  -> 设置本地 leaseLost 标志(volatile)
  -> 业务线程在每个写操作前检查该标志,发现丢失立即中止
  -> 回滚未提交的事务 / 走补偿
  -> 不再尝试 reclaim,重新排队抢锁

看门狗本身要写在独立的调度器里,并且一旦续期失败就停止尝试:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

interface LeaseStore {
    Lease acquire(String key, long ttlMillis);
    boolean renew(Lease lease, long ttlMillis);
    void release(Lease lease);
}

record Lease(String key, String owner, long fence, long expiresAt) {}

final class LeaseGuard implements AutoCloseable {
    private final LeaseStore store;
    private final Lease lease;
    private final long ttlMillis;
    private final AtomicBoolean lost = new AtomicBoolean();
    private final ScheduledExecutorService renewer =
            Executors.newSingleThreadScheduledExecutor(r ->
                    Thread.ofPlatform().name("lease-renewer").daemon().unstarted(r));

    LeaseGuard(LeaseStore store, Lease lease, long ttlMillis) {
        this.store = store;
        this.lease = lease;
        this.ttlMillis = ttlMillis;
        long period = Math.max(1, ttlMillis / 3);
        renewer.scheduleAtFixedRate(() -> {
            if (lost.get()) {
                return;                          // 已经丢了,不再续
            }
            if (!store.renew(lease, ttlMillis)) {
                lost.set(true);                  // 业务线程看到后必须立即停写
            }
        }, period, period, TimeUnit.MILLISECONDS);
    }

    boolean lost() { return lost.get(); }

    @Override
    public void close() {
        renewer.shutdownNow();
        store.release(lease);
    }
}

有四个容易做错的点:

  • 续期“成功”不等于还持有。 续期响应可能在服务端处理完之后延迟到达,客户端收到 true 时租约可能又过期了。所以续期成功也只能延长“大概率安全”的窗口,最终仍要靠 fencing。
  • 不要在续期失败后继续跑完当前批次。 批量任务最容易犯这个错:循环里只检查一次锁,续期失败后仍把剩下的 900 条处理完。
  • 续期线程不能和业务线程共用同一个有界线程池。 池满时续期任务排队,看起来“还在续”,实际一次都没发出去;看门狗要用独立的单线程调度器。
  • release 必须比较 fence。 旧持有者恢复后调用 close(),如果无条件删除租约,会把新持有者的锁删掉。上面的 LeaseGuard.close() 直接调 release,真实实现里 LeaseStore.release 要按 lease.fence() 做条件删除,服务端只在自己还是持有者时才删。

在 libraryd-conc 里落地

conc-core 里把租约抽象成一个接口,测试时替换成人造时钟实现:

组件职责关键约束
LeaseStoreacquire(key, ttl) / renew(lease, ttl) / release(lease)返回的 Lease 必须带单调 fence
LeaseGuard业务侧包装:进入前 acquire,退出时 release,后台续期续期失败置 leaseLost,写操作前检查
FencedRepository所有写 SQL 带 AND fence < ?影响行数为 0 视为 CONFLICT
FakeLeaseStoreconc-lab 里的测试替身,用 ManualClock能精确复现“过期但旧持有者仍写”

领域记录不变:Book(String isbn, String title, int stock) 的字段不增加 fence,fence 放在独立的借阅状态表或 PrepareResult 里,避免污染领域模型。冲突仍然映射到既有的 ErrorCode.CONFLICT。

验证分三层,缺一层就会把问题留到线上:

  1. 单测:用 FakeLeaseStore + ManualClock 复现“过期后旧持有者仍在写”,断言存储拒绝且 rejected == 1;
  2. 故障注入:在 staging 对持锁进程执行 kill -STOP,停住的时间超过 TTL,再 kill -CONT 恢复;断言它后续的写入被拒绝、数据仍是新持有者的值;
  3. 指标:leaseLost 置位次数、fence 拒绝次数、续期失败次数都要打点告警。拒绝次数长期为 0 不代表安全,可能只是还没发生过长停顿。

常见错误与边界

  • 把 TTL 设得很长来“避免过期”。 持有者崩溃后锁要等 TTL 才释放,期间所有请求排队;正确做法是短 TTL + 续期 + fencing。
  • 用客户端时间做过期判断。 时钟回拨会同时造成提前抢锁和过期后继续写。
  • 只在数据库加 fence,消息不带。 过期持有者仍然能把旧事件投递到下游。
  • token 用 System.currentTimeMillis() 生成。 时钟回拨会让 token 不单调;必须由服务端用单调计数器生成。
  • 续期失败后继续处理当前批次。 这是分布式任务重复执行最常见的直接原因。
  • 以为有了分布式锁就不需要唯一索引。 锁服务抖动、主从切换时唯一索引仍是最后一道防线。

什么时候不要用分布式锁: 操作本身可以做成幂等(带幂等键的写入、状态机 CAS、唯一索引插入)时,锁是多余的一层,只会增加一个故障点。什么时候不要用长租约: 临界区执行时间不可预测(依赖外部 IO、批量任务)时,租约要么频繁过期、要么长时间阻塞别人;应该把任务拆小,或者改成“领取任务 + 幂等执行”的模型。

小结

分布式锁的失效不是“锁实现有 bug”,而是“授权有期限”这个前提被忽略。看门狗在 STW、网络分区、CPU 饥饿、主从切换时都会失效,本机实验用 250 毫秒的时钟跳跃复现了这个窗口:A 的租约过期、B 合法接管、A 恢复后继续写。

fencing token 把正确性从“时间判断”搬到“存储比较”:无 fencing 时过期写入覆盖了新值(final=A-stale),有 fencing 时被拒绝(final=B-new,rejected=1)。配套的三件事是 token 由服务端单调生成、所有下游都携带、存储侧原子比较。下一节回到单机,处理另一类“看起来是锁、其实是粒度”的问题:热点计数怎么定位、分段和分片怎么选、p99 到底有没有变好。

阅读导航:上一节 11.1 数据库行锁与 Java 锁的叠加 | 下一节 11.3 锁粒度、热点与拆分

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收