租约失效后为什么还需要 Fencing Token,如何防止旧 Worker 写回?
未标记
考查重点:旧持有者隔离与单调令牌。租约到期只让新 Worker 获得资格,旧 Worker 停顿恢复后仍可能写;每次接管发单调递增 Fencing Token,下游拒绝小于已见代次的写入,保护必须由资源端执行
本题目录
参考答案
核心回答
租约到期只表示协调系统可以把执行资格交给新的 Worker,不会让旧 Worker 的线程、计算或在途请求自动消失。旧 Worker 可能在长时间暂停后恢复,继续把过期结果写回;在客户端写前再检查一次租约,也可能在检查与写入之间再次发生暂停。
Fencing Token 给每次新取得的执行资格分配单调递增的代次。所有受保护写入都携带代次,由资源端记录已接受的最大代次,原子地拒绝更旧的请求。这样,新的代次在资源端生效后,旧 Worker 即使恢复运行,也无法用旧代次覆盖新结果。Martin Kleppmann 的分布式锁分析。
一次迟到写怎样被挡住
- Worker A 获得代次
41,读取数据后发生长时间暂停。 - A 的租约失效,Worker B 获得代次
42。 - B 携带
42写入,资源端保存数据并将最大已接受代次更新为42。 - A 恢复后携带
41写入,资源端发现41 < 42,拒绝这次迟到写。
关键在第三、四步的资源端校验。如果资源只记最大已见代次,它在尚未收到新代次之前,并不知道旧租约是否已经到期;这套机制保证的是代次生效后的旧写隔离,而不是向所有资源自动广播租约失效。
实现时要守住哪些条件
- 代次可靠递增。 新持有者得到的代次要大于旧持有者,协调服务重启或故障切换后也不能回退。随机
UUID可以区分持有者,却不能表达新旧顺序;普通本地时间戳也不能直接承担这种保证。 - 比较与写入原子执行。 资源端把代次比较、业务更新和最大代次记录放在同一个条件更新或事务中。若先查询代次,再无条件写入,仍会出现并发空隙。
- 覆盖实际写入路径。 数据库、对象存储或外部接口必须各自支持相应校验;只在任务表检查一次,不能保护随后无条件发出的外部请求。失去资格后停止后续工作有助于减少浪费,但已经发出的请求仍需要接收端保护。
同一持有者可能在同一代次内执行多次合法写入,因此“拒绝更小代次”与“每次写入都要求代次严格变大”不同;是否允许同代次的重复请求,还要结合操作序号或幂等键设计。Hazelcast 的 Fencing Tokens 说明。
当前尝试校验和幂等键有什么区别
如果任务所有权和结果都在同一个数据库中,也可以保存当前 attempt_id,写回时以 任务标识 + 当前状态 + attempt_id 作为条件,或在锁行事务中检查后保存。接管时替换当前标识,旧标识便无法通过等值校验。这可以隔离该数据库中的旧尝试,但随机标识并不因此变成能被不同下游比较先后的单调 Fencing Token。
幂等键则用于识别“同一个业务操作重试了几次”。同代次的请求仍可能重复,外部调用也可能成功后丢失响应,所以旧写隔离不等于业务只执行一次。接收端不支持代次校验时,应按其能力使用条件版本、幂等键等机制,并明确保护范围。
InterviewGuideJava
核心回答
InterviewGuide 的简历分析任务通过数据库中的状态和随机 attemptId 识别当前执行者。消费者原子地把任务从 PENDING 改成 PROCESSING,并记录本次尝试;超时恢复或重试会清除旧尝试,新消费者重新领取时生成新的标识。心跳、完成、失败和重试重置均检查当前尝试,分析结果保存也在数据库事务中验证所有权。
这里采用的是当前尝试的等值校验,能阻止失去执行权的旧消费者写入分析结果或改动新尝试的状态。attemptId 是随机 UUID,并不是面向外部资源的单调递增 Fencing Token;它也无法撤销已经发给模型的分析请求。
怎样领取与恢复任务
AnalyzeStreamConsumer.tryMarkProcessing() 生成 UUID,调用 tryMarkAnalyzeProcessing()。只有数据库中仍为 PENDING 的任务才能更新成功,受影响行数为 1 时才继续分析;重复消息或其他消费者不会仅凭消息到达就取得业务执行权。消费者领取、条件领取。
心跳通过 PROCESSING + analyzeAttemptId 条件更新进展时间,更新失败就抛出执行权失效异常。当前简历分析链在下载解析完成、模型分析返回后报告心跳,并非持续运行独立心跳线程。长调用可能超过无进展阈值,因此阈值需要考虑调用和重试的总耗时。业务阶段与心跳、恢复配置说明。
恢复调度器扫描无进展任务,以状态和时间条件将过期的 PROCESSING 重置为 PENDING、清除旧 attemptId,再补投消息。旧消费者仍可能继续计算,但它持有的旧标识已经无法匹配当前数据库状态。恢复调度、恢复条件更新。
为什么保存前再检查仍然有用
仅靠模型返回后的心跳仍不够:消费者可能刚通过心跳检查,就再次暂停。saveAnalysisIfOwned() 会在事务内通过 findByIdForUpdate() 锁住简历行,重新检查状态和 attemptId,通过后才保存分析结果。这次校验与结果落库受同一个事务保护,恢复调度器不能在二者之间替换任务所有权。锁行查询、事务内验证与结果保存。
完成、失败以及重试重置也带尝试条件。旧消费者失权后,它的重试重置更新不到当前任务,就不会重新投递旧尝试;旧的完成或失败回调也不会覆盖新尝试。条件状态更新、重试处理。
分析结果保存与随后标记 COMPLETED 是两个独立事务,不能把这条链描述为“结果、终态和消息确认一次原子提交”。它保护的是相应数据库写回;模型可能被重复调用,端到端也不具备外部副作用只发生一次的保证。消费执行顺序。
怎样验证旧结果确实被隔离
可以让尝试 A 停在模型调用中,触发恢复并让 B 取得新 attemptId,再释放 A,检查 A 的心跳与结果写回被拒绝;还要覆盖“A 心跳成功后暂停、B 接管、A 再保存”的时序,以验证最终事务检查。分别核对分析记录、任务状态和外部模型调用次数,才能区分旧结果隔离与重复计算。
参考资料
- 国内:JavaGuide:分布式锁:参考过期、续约、安全释放与写入隔离的不同职责,区分随机持有者标识和有序代次。
- 国内:JavaGuide:分布式协调详解:参考租约用于回收资格、资源端原子校验代次,以及幂等键与旧写隔离的区别。
- 国内:阿里云:Java SDK 条件更新:参考服务端在列值条件满足时才更新数据的机制,解释当前尝试的等值校验。
- 国内:美团技术团队:分布式系统互斥性与幂等性问题的分析与解决:参考重复消息与接口重试需要独立处理幂等,不能由互斥或执行资格推导业务只执行一次。
- 国外:Martin Kleppmann:How to do distributed locking:参考暂停、网络延迟导致的迟到写,以及资源端拒绝旧代次的时序。
- 国外:Hazelcast:FencedLock:参考会话失效后旧持有者仍可能运行、单调代次需要传给受保护资源,以及锁操作语义与外部写入保护的范围。

