时间、逻辑时钟、全局 ID 与复制:顺序证据从哪里来
贯通物理时钟漂移、Lamport happened-before、逻辑版本、分布式 ID、复制位点与并发冲突,避免把时间戳误当全局顺序。
Lamport 的 Time, Clocks, and the Ordering of Events 定义 happened-before 与逻辑时钟,说明分布式事件天然形成偏序。
物理时钟只能给出带误差的时间估计
机器时钟由振荡器推进,通过 NTP 等协议校正,会漂移、渐进调整甚至向后跳。虚拟机暂停、宿主机迁移和长 GC 也会让进程观察到异常间隔。用 wall clock 判断租约过期、生成单调 ID 或比较跨节点事件先后,都必须处理误差和回拨。持续时间用单调时钟,审计时间保留来源与时区,跨节点排序不能只做 timestamp 排序。
两个日志时间分别为 10:00:00.100 与 10:00:00.101,不足以证明前者导致后者,因为两台机器的误差可能大于一毫秒。物理时间适合业务展示、保留期和近似窗口;因果关系要由消息、版本或事务依赖传播。
Lamport 时钟编码 happened-before 的必要方向
同一进程事件按程序顺序递增;发送消息携带当前逻辑值;接收者把本地值更新为 max(local, remote)+1。若 a happened-before b,则 C(a) 小于 C(b);反过来 C(a) 小于 C(b) 不证明因果,两个并发事件也可能被任意排成大小。加入 nodeId 可以构造确定全序,但这个全序是裁决规则,不是现实因果。
向量时钟为多个参与者保存分量,能识别某版本支配、相等或并发,却随参与者数增长。数据库版本号、Kafka offset、Outbox sequence 都只在各自日志或聚合范围内有序。跨分片排序必须明确使用因果 token、协调序列器还是接受偏序。
分布式 ID 首先解决唯一性
时间型 ID 常由时间片、节点标识和序列组成,能近似递增并降低索引随机写,但需要节点 id 唯一、序列溢出处理和时钟回拨策略。回拨时继续发号可能重复;等待时钟追上会阻塞;切换逻辑 epoch 或 fencing 可以继续但改变排序语义。ID 生成服务也需容量、租约和灾难恢复。
UUID 类随机标识减少协调但不提供业务顺序;数据库 sequence 提供单点或分段序列;号段模式批量领取范围,服务重启会产生空洞。唯一 ID 允许有空洞,不能被误用为连续账本。若业务需要按实体版本拒绝旧写,应单独保存 expectedVersion,而不是比较两个全局 ID 的大小。
复制位置是恢复证据
主从复制把日志位置传播到副本,读者可携带最低位点要求:副本追到该位置才允许读取。异步复制降低写延迟却在故障切换时可能丢失已返回但未复制的尾部;同步 quorum 增强持久性却提高协调成本。故障时必须区分“旧主有独占写入”“新主已接受新 epoch”与“副本只是延迟”,并使用 term/epoch 阻止旧主继续提交。
用证据边界拆开“不知道”和“做不到”
结果未知必须持久化 operationId、目标实体、预期版本和查询入口。自动重试只有在动作尚未开始或目标以相同 id 幂等时安全。用异常类名直接判断“肯定没执行”会在网络断点后制造重复提交。
状态机必须拥有终态、租约和人工出口
每个非终态都要有下一次唤醒来源:消息重投、租约到期、协调器扫描、对账任务或人工队列。只有状态而没有唤醒,流程会永久卡住;只有重试而没有终态,会永久自旋。
时间相关代码必须主动经历回拨与暂停
测试把 wall clock 从 1000 回拨到 997,ID 生成器不得继续使用相同节点与序列发号;租约判断不能因回拨延长旧资格;审计排序要能以逻辑版本解释冲突。再注入进程暂停超过租约,验证恢复线程携带旧 epoch 的写被权威存储拒绝。
复制切换实验保存旧主最后位点、新主选举 epoch 与客户端最低读取位点。若新主缺少已返回成功的尾部,必须按既定 RPO 报告并进入修复,不能用新 wall-clock 时间覆盖旧事实来掩盖缺口。
用两个 Java 状态模型固定分布式不变量
下面的 Java 17 模型不模拟真实网络或共识实现,而是把本篇关键版本、租约、状态和容量关系变成确定输出。随后用真实数据库、Broker、锁服务或多进程环境注入延迟、重复、分区与崩溃。
javac --release 17 -Xlint:all -Werror examples/backend-development/distributed-systems/time-id-replication/LamportClockDemo.java examples/backend-development/distributed-systems/time-id-replication/SnowflakeRollbackDemo.java
java -cp examples/backend-development/distributed-systems/time-id-replication LamportClockDemo
java -cp examples/backend-development/distributed-systems/time-id-replication SnowflakeRollbackDemoeventA=1 send=2 receive=3 happenedBefore=true concurrentComparison=UNKNOWN
lastMillis=1000 nowMillis=997 clockRollback=true idIssued=false waitOrFence=true输出变化时必须解释哪条一致性、epoch、幂等或容量不变量被修改。真实实验还要验证结果未知、旧所有者恢复、状态清理和人工修复路径。
容量、观测与安全要围绕协调成本
同步协调增加往返、日志刷盘、锁持有和 quorum 等待;异步收敛增加积压、版本、去重和对账存储。容量模型至少包含峰值操作率、参与者数、每次尝试、超时窗口、重试放大、状态保留、复制倍数与恢复净速率。平均值无法覆盖分区热点和长尾暂停。
反向实验要打在决定落盘的前后
故障注入必须有范围、自动停止和数据清理。生产演练从只读、单租户、单分片和低比例开始,保留旁路与回滚。完成标准不是组件重新可达,而是业务不变量保持、未知结果已对账、积压按预测下降且旧所有者无法继续写。
演进从缩小协调域开始
小规模优先单库本地事务、模块化单体和单一调度 owner。增长后先按业务冲突域分片、用批量与读模型减少跨域协调,再引入 Outbox、租约或 Saga。不要为了“分布式化”把一个本地不变量拆成跨服务事务。
任何新协调机制都写决策记录:解决什么不变量、网络分区时拒绝什么、权威证据在哪、超时后如何查询、状态保留多久、容量上限、故障演练与移除路径。系统复杂度只有在可验证收益覆盖恢复成本时才值得增加。
数据模型要为并发和恢复保留足够字段
协调表不能只有 status。至少记录 operationId、entityKey、expectedVersion、owner、epoch/fence、attempt、deadline、nextAttemptAt、lastErrorClass、resultVersion 与 updatedAt;不同主题裁剪不需要的字段,但任何参与正确性判断的值都必须持久化。状态更新使用条件写,例如只有当前 epoch 与 expectedVersion 匹配才从 EXECUTING 转 COMMITTED,避免迟到线程覆盖新决定。
替代方案要比较协调域而不是比较组件列表
团队门禁要阻止不可恢复的捷径
代码审查要求状态转换与 SQL 条件同时出现,集成测试要求在决定落盘前后杀进程,发布检查要求新旧版本互读,运行看板要求展示最老非终态而不只展示总数。分布式正确性不是某次设计评审的结论,而是持续被反例验证的工程属性。
