分布式锁与唯一 ID:多实例部署后的新问题

2 分钟阅读
·

系列目录

  1. 从单线程到线程池:云盘转 Java 后的第一堂并发课
  2. 线程池不是 new 出来就完事:参数、队列与快慢接口隔离
  3. 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上
  4. ConcurrentHashMap 与锁:文件元数据的并发读写
  5. Future 与 CountDownLatch:一个接口聚合一堆下游
  6. 用 Kafka 做异步化与削峰:从热点上报到全局事务
  7. JVM 内存与 GC:一次线上 Full GC 排查记录
  8. 日志不是越多越好:logback 实践与线上问题定位
  9. 高性能 IO:流对流的失败率、NIO 与零拷贝
  10. 压测与容量评估:IM 融合前要扛多少流量?
  11. Tair 缓存的穿透、雪崩与可用性
  12. 分布式锁与唯一 ID:多实例部署后的新问题(本篇)
  13. 高可用的四件兵器:超时、重试、限流与降级

接手 Java 项目前,我已有 Node.js、C# 等后端开发经验,也预期到多实例部署会改变并发控制和 ID 生成的前提。因此在项目扩容前,我先查了分布式锁、缓存原子操作和发号方案,并将这些检查项带到实现和评审中。第 11 篇的互斥重建使用 tair putIfAbsent 实现锁,当时已记录它需要补充安全条件;本文集中说明这些条件,以及云盘当时的处理方式。

IM 融合前后,云盘从几台机器扩到几十台机器。扩容增加了机器和负载均衡,也要求重新检查依赖单机内存、单机时钟或单机执行顺序的代码。分布式锁能否提供跨实例互斥,取决于协调服务的语义和故障前提。唯一 ID 则需要先定义生成协议和唯一性范围。调用形式之外,还要核对存储服务的原子操作、一致性、TTL,以及业务写入自身的约束。

多实例下需要处理的两个问题

第一个问题是定时任务重复运行。云盘有清理过期分享、统计、对账等定时任务。单机部署时只有一个调度器;多实例部署后,每台机器都会加载同一份调度配置,同一个任务会由多个实例触发。

清理任务可以处理目标已不存在的情况,重复执行通常可接受。统计任务重复执行会使报表数字翻倍,对账消息重复发送会让下游收到重复告警。根因是每个实例独立调度同一个任务。

第二个问题是同一目录被并发修改。用户两端同时操作时,web 端往目录 X 移动文件、PC 端往目录 X 上传新文件,两个请求可能落到不同机器。单机时,JVM 内的锁能让到达同一进程的请求串行执行;多实例时,每个 JVM 只看到自己的锁对象,两个请求都可能通过校验后写库。

DB 唯一索引可以拒绝重复的唯一键写入,覆盖范围有限。例如,移动前检查“目标目录不能是自己的子孙”时,两个事务可能都基于旧状态完成检查。此类读后写校验需要事务隔离、数据库锁、可序列化约束,或与业务模型匹配的并发控制。唯一索引不能保护所有业务不变量。

机器数从 1 变为 N 时,即使业务代码未改动,这些问题也会出现。单独查看任一实例的日志时,请求可能都显示正常。

synchronized 的作用范围

第 4 篇讨论过 synchronized 和读写锁。它们只协调同一个 JVM 进程中的线程:锁对象位于该进程堆内,其他 JVM 无法访问或识别它。扩容后,只有请求恰好进入同一实例时,JVM 锁才能产生互斥。

JVM 堆内缓存、AtomicIntegerstatic 单例状态也有相同范围。每个实例各自保存一份缓存,即使通过 Kafka 消息驱动失效,仍可能存在传播窗口。AtomicIntegerstatic 状态只在当前进程内有效。多实例部署前,需要逐项确认这些状态的作用域是否仍满足业务要求。

跨实例互斥要求所有实例访问同一个协调服务,该服务还要为获取、续期和释放提供所需的原子语义。tair 是当时已有的组件。若 putIfAbsent 在目标 key 的写入路径上以预期的一致性和原子性执行,它可以作为获取锁的一步。DB 唯一约束插入一条持锁记录也能表示占用,但吞吐、故障恢复和事务边界需要单独评估。当时只在低频路径上使用了这种方式。

多实例通过 tair 仲裁互斥的时序

tair 分布式锁需要满足的条件

第 11 篇互斥重建里的锁,若直接推广为通用工具,第一版如下:

// 错误示范:看似能用,实则三个坑全踩了
if (tairPutIfAbsent(lockKey, "1", 30)) {   // 抢到锁,TTL 30 秒
    try {
        doSomething();                     // 临界区:定时任务 / 改目录
    } finally {
        tairDelete(lockKey);               // 释放锁
    }
}

**锁超时。**TTL 30 秒没有依据。任务执行超过 TTL 时,例如数据量增长、下游变慢或 Full GC 导致停顿,锁会过期,其他实例可以获取同一把锁,两个持有者会重叠执行。延长 TTL 可以降低这一概率,同时会增加持锁实例宕机后的等待时间。TTL 需要在互斥窗口与故障恢复时间之间取舍,不能保证临界区一定在到期前执行完。

**续期的条件和限制。**执行时间无法预估的任务可以续期:持锁方在锁未过期且仍持有同一 token 时,原子地延长 TTL。进程停止后没有续期者,锁会在 TTL 后过期。续期操作本身也必须校验 token;只刷新 key 的 TTL 可能给后来的持有者续期。

当时只在执行时间不可控的长任务上做了简单续期,每过三分之一 TTL 刷一次,短任务没有做。一次 Full GC 停顿可能超过 TTL,后台续期线程也可能无法及时执行。续期能降低锁过期的概率,无法证明旧持有者已经停止执行。

**释放时误删别人的锁。**持锁者 A 卡顿超过 TTL 后,锁过期;B 获取锁并开始执行;A 恢复后进入 finally 并直接删除 key,就会删除 B 的锁。后续实例可以再次获取锁,B 与新实例的临界区会重叠。

锁值需要保存每次获取时生成的 token。下面代码保留了当时的实现。它在删除前检查 token,但这三个操作并不原子:

String token = instanceId + ":" + UUID.randomUUID();  // 只有我能造出来的值
if (tairPutIfAbsent(lockKey, token, LOCK_TTL)) {
    try {
        doSomething();
    } finally {
        // 只删自己的锁:先读出来比对,是自己的才删
        String v = tairGet(lockKey);
        if (token.equals(v)) {
            tairDelete(lockKey);
        }
    }
}

“先 get,再比较,再 delete”仍然不安全。比较完成后,锁可能过期并由另一实例获取,旧持有者接着执行的删除会再次删除新锁。可靠的解锁需要由服务端在同一次原子操作中完成“值仍等于 token”与“删除”。若存储服务或客户端没有条件删除能力,需要使用其支持的服务端脚本、事务或条件写入机制,或者更换协调方案。

即使获取、续期和条件删除都原子,TTL 到期后恢复的旧持有者仍可能继续执行。对会写入数据库、消息系统或外部服务的临界区,可为每次成功获取锁分配单调递增的 fencing token,并由被写入的资源拒绝较小 token 的写入。下游实际校验 fencing token 后,它才可以阻止过期持有者覆盖较新的结果。缺少这项校验时,锁无法单独保证写入顺序。

关键写入仍应由幂等记录、唯一约束或事务规则保护。锁可以降低并发冲突和重复工作的频率,业务正确性还需要独立的数据约束。

唯一 ID 的方案与云盘的选择

多实例部署还需要定义标识生成方式。单机时代 fileId 使用 DB 自增主键。事件表主键、消息唯一 ID、上传任务 ID 等跨实例、跨服务流转的标识,需要明确唯一性范围,并让各实例按同一协议生成。

  1. **DB 自增。**实现简单,能在单个数据库序列的语义内提供唯一的递增值。每次取号都需要访问数据库,数据库或序列会成为容量与可用性的依赖。分库、主从切换或多序列时,还要重新定义全局唯一和顺序语义。
  2. **号段模式。**DB 中维护发号表,各实例通过原子更新或事务领取不重叠的号段,例如一千个号。实例在段内本地递增,用完再领取。这样减少了取号请求数。实例宕机时未使用的号会被跳过,跳号通常不影响唯一性。不同实例同时使用不同号段时,按业务请求时间观察到的 ID 不保证严格递增;它只保证号段不重叠和段内递增。
  3. **UUID。**UUID 可以在本地生成,不需要中心发号服务。原文所说的 36 个字符是常见文本表示的长度;若以二进制形式保存,长度不同。随机型 UUID 的索引顺序分散,用它作为 InnoDB 聚簇主键可能增加页分裂和索引空间开销,影响取决于 UUID 版本、存储格式、写入量和表结构,不能由 UUID 这一名称直接推出。
  4. **雪花算法(snowflake 一类)。**常见实现把时间戳、机器位和序列号编码进 64 位整数。本地生成速度高,通常按生成器的时间顺序递增。全局顺序取决于时钟、机器位和实现细节。机器位必须在并发实例间不冲突;时钟回拨时,系统必须等待、拒绝发号、使用逻辑时钟或采取其他策略。实现未正确处理回拨或序列溢出时,可能生成重复 ID。

云盘当时的选择是:主键 ID 使用号段,消息与事件 ID 复用同一套发号。作出这个选择前,我比较了已有依赖和需要验证的前提。DB 已是强依赖,增加一张发号表没有引入新组件;号段的领取和使用路径便于排查;雪花算法减少 DB 往返的收益在当时的量级下不突出,而机器位分配和时钟回拨的处理方案尚未验证。UUID 作为主键的文本存储方式也不符合当时对索引的考虑。这是基于当时约束的选择,不代表所有场景的最优方案。

改造定时任务和写路径

**定时任务使用分布式锁。**调度任务入口先尝试获取任务名对应的锁;未获取到锁时,该实例跳过本轮执行。TTL 应根据任务历史耗时和允许的恢复时间设置,长任务需要带 token 校验的续期。锁只能减少同一时段的重复执行,无法提供“恰好一次”执行保证。统计和对账任务仍应能识别重复处理或补偿遗漏执行。

**关键写路径使用与数据约束匹配的并发控制。**目录移动、重命名等操作需要根据实际读写集合选择锁粒度。只按目标目录 ID 加锁时,涉及源目录、目标目录和目录树关系的操作可能仍遗漏冲突;同时获取多把锁还要规定稳定顺序,避免死锁。目录树校验还需要由数据库事务、版本检查或其他并发控制维护相应不变量。

写路径使用幂等时,请求携带客户端生成的操作 ID,数据库用唯一键记录该操作及其处理结果。首次请求在事务中写入业务数据和幂等记录;重复请求命中同一操作 ID 后,读取并返回已保存的结果。仅检查“重复插入影响行数为 0”无法返回上次结果,也不能覆盖第一次请求尚未完成时的并发重试。幂等记录、业务写入和结果状态需要定义一致的事务边界。

锁用于降低并发修改的机会,幂等记录和 DB 约束用于处理重复请求与最终写入结果。锁过期、持有者停顿或协调服务故障时,后两者仍是必要的保护。

**清查单机假设。**当时把 synchronized、JVM 缓存和 static 状态列成清单,逐项确认它们在多实例后是否仍成立。还检查了用 System.currentTimeMillis 拼随机数生成 ID 的写法。时间值会受同毫秒并发和时钟回拨影响,随机部分也只有在生成算法、熵源和碰撞处理满足要求时才可作为唯一性依据;这种拼接方式本身没有定义跨实例的唯一性协议。

定时任务重复执行的告警后来消失。目录并发写的问题在加入锁、幂等处理和数据约束后没有再复现。这是当时的线上观察,不能据此证明所有并发路径都已正确处理。

锁服务不可用时的处理

这套方案依赖 tair 在部署与故障场景下提供的 putIfAbsent、TTL、条件续期和条件删除语义。仅知道单次 API 的原子性还不够,还需要确认主从切换、网络分区、超时重试和副本一致性时的行为。当时没有完整验证 tair 集群一致性和故障恢复的细节,因此不能宣称该锁在所有故障场景下都保持互斥。

锁服务不可用时,定时任务可以跳过本轮,以避免无法协调时并发执行。写路径若把锁设为强制前置条件,会随锁服务不可用而拒绝写入;业务若允许继续写入,则需要依靠幂等、DB 约束和事务规则维持数据正确性。降级策略需要按任务可补偿性和写操作的不变量分别确定。

下一篇 高可用的四件兵器:超时、重试、限流与降级 汇总系列中出现的可用性措施,说明它们分别处理的故障条件和限制。

参考资料

  • 公司 tair 使用文档(只描述使用方式与边界)
  • 《Java并发编程实战》(Brian Goetz)相关章节的延伸思考

536 字 · 58 段落
ximing

Written by ximingFollow onGitHub

相关文章