时间、逻辑时钟、全局 ID 与复制位置
订单号 102 可以先于订单号 101 提交。数据库分配号码、事务写入成功、消息到达消费者,分别发生在不同位置;用同一个“大数在后”规则排序,会把这些顺序混在一起。
时间与顺序分别回答什么问题
四种用途需要四类信息
什么时候发生:墙上时钟、时区、时钟误差
等待了多久:同一运行环境中的单调经过时间
为什么先后发生:程序顺序、消息依赖、逻辑时钟
哪个对象与哪个版本:业务 ID、实体 version、日志位置墙上时钟把时间映射到可展示的日期与时刻,适合账单期限、审计展示和跨系统粗粒度检索。单调时钟提供计算间隔的参考,适合请求预算、限速窗口和耗时统计。逻辑时钟随事件推进,适合携带依赖。ID 用来区分对象,版本或日志位点用来定位某个对象状态。
一种数字可以兼有几项用途,但需要实现明确承诺。例如 UUIDv7 含有时间字段,便于近似排序;它仍不记录两个事务谁先提交。数据库 sequence 生成较大号码,也不要求拿到较小号码的事务先完成。
墙上时钟会被校正
机器振荡器会漂移,校时服务需要估计网络往返与本机误差,并以渐进调整或跳变方式修正。时间显示有很高的小数精度,不意味着两台机器已经同步到这个精度。若两台主机的误差区间重叠,仅比较日志上的时间戳,无法确定两次事件的真实先后。
虚拟机暂停、进程长时间未获得 CPU、容器与宿主时钟的关系也要区分:进程没运行期间,操作系统时钟可能继续推进。恢复后看到大间隔,可能是实际经过了很久,而非计时器故障。是否包含系统休眠时间还取决于操作系统选择的时钟,不能对所有平台一概推断。
已有 chrony 服务的 Linux 主机可先做只读检查:
chronyc tracking
chronyc sources -vtracking 中观察系统相对参考源的偏差、频率与同步状态,sources 中检查被选中的来源、可达性和偏差估计。Leap status 非正常或没有可用源时,先检查校时服务、上游网络和主机配置。不要在运行中的数据库上为验证回拨直接执行改系统时间的命令;测试应用分支可以注入独立时钟。chronyc 命令手册
Java 的截止时间保留在进程内
System.currentTimeMillis() 返回墙上时间;System.nanoTime() 用于测量同一 JVM 内的经过时间,原点任意,精度单位也不等于实际分辨率。不要把一个 JVM 的 nanoTime 绝对值传给另一台机器当截止时间。Java System API
long started = System.nanoTime();
long budget = java.util.concurrent.TimeUnit.SECONDS.toNanos(2);
// 每进入一个新步骤时重新计算,而不是为每一步重建两秒预算。
long elapsed = System.nanoTime() - started;
long remaining = budget - elapsed;
if (remaining <= 0) {
throw new java.util.concurrent.TimeoutException("request budget exhausted");
}用差值判断经过时间可以避免直接构造绝对截止值时的加法溢出;预算也应限制在该计时方法可正确表示的间隔内。跨服务传递通常使用剩余预算,接收方再以自己的单调时钟计时。采用绝对墙上截止时间的协议,则还需说明允许的时钟偏差和偏差超限后的处理。
超时后线程或下游动作可能继续运行。计时只负责判断预算,还要把取消、资源释放和未知结果查询接到相应操作,见超时、重试与幂等。
消息怎样建立逻辑顺序
happened-before 是偏序
Lamport 的 happened-before 关系由三条规则组成:同一进程内先发生的事件在后发生事件之前;消息发送在对应接收之前;上述关系具有传递性。两个事件之间如果两个方向都没有这项关系,就称为并发。这里的并发描述缺少因果依赖,不要求它们在墙上时间上恰好同时发生。Lamport 原论文
进程 A:本地事件 a1 ── 发送 a2 ────────────────── 本地事件 a3
╲
消息: ╲ 携带逻辑值 2
╲
进程 B:本地事件 b1 ───────── 接收 b2 ── 本地事件 b3
a2 → b2;a1 → a2 → b2 → b3
a3 与 b2 是否有依赖,要看是否还有其他消息,而非只看画面位置。每个进程保存一个逻辑计数。发生本地或发送事件时加一,接收消息时取本地与消息计数的最大值再加一:
public synchronized long tick() {
return value = Math.incrementExact(value);
}
public synchronized long receive(long remote) {
if (remote < 0) throw new IllegalArgumentException("negative logical time");
return value = Math.incrementExact(Math.max(value, remote));
}完整字段声明与实现位于实验包的 Clocks.Lamport。同步保护同一计数器的并发更新,incrementExact 在溢出时失败,而不是静默绕回较小值。
如果 a → b,则逻辑值 L(a) < L(b);反过来不成立。两个互不通信的进程也会各自把计数递增到不同大小。附加 node ID 可以把同值事件稳定排序,但那是确定性裁决规则,不是新发现了一条因果关系。
向量时钟保留更多依赖
向量时钟为各参与者保存计数分量。某节点的本地事件递增自己的分量;发送时携带整个向量;接收时逐分量取最大,再递增接收节点的分量。
| 两个向量 | 比较结果 | 可以怎样处理 |
|---|---|---|
[2,0] 与 [2,1] | 前者逐分量不大于后者,至少一项更小 | 在完整跟踪的因果模型中,后者包含前者的依赖 |
[2,0] 与 [1,1] | 两边各有一项更大 | 并发更新,需要合并或暴露冲突 |
[2,1] 与 [2,1] | 相等 | 依赖摘要相同,不能据此合并不同业务事件 ID |
参与者越多,向量存储和传输成本越大;节点加入、退出、重启也需要稳定身份或相应裁剪协议。实际系统常把跟踪范围限定到文档、分片或活跃副本,而不是为整个互联网维护一张向量。
HLC 把物理近似与逻辑分量组合
混合逻辑时钟 HLC 常用 (physicalPart, logicalPart) 表示时间。物理时钟向前时推进第一分量;同一物理刻度内,或收到大于本地时间的远端事件时,通过逻辑分量维持依赖顺序。比较两个 HLC 时先比较第一分量,相等再比较第二分量。
它保留类似 Lamport 的单向因果条件,并使值尽量接近物理时间;单凭 HLC 大小仍不能识别所有并发事件。HLC 也不会单独提供共识、全局事务隔离或有界时钟误差,具体数据库还需配套协议。HLC 原论文
逻辑元数据必须随实际依赖传播。如果应用读取一个值后通过另一条未携带上下文的消息产生新写,接收方就不知道这条额外依赖;算法只能追踪被建模和传递的信息。
唯一标识怎样生成,怎样验证
从用途选择号码格式
| 方案 | 生成位置与顺序特点 | 主要代价 |
|---|---|---|
| UUIDv4 | 本地随机生成,基本不依赖协调 | 随机插入的索引局部性较弱,唯一性是概率保证 |
| UUIDv7 | 高位包含毫秒时间,其余字段按规范生成 | 同毫秒、回拨和跨节点排序取决于实现;时间字段可能暴露近似生成时间 |
| 数据库 sequence | 由数据库分配号码,可缓存 | 有空洞,号码顺序与事务提交顺序分离 |
| 号段 | 服务批量领取互不重叠范围后本地分配 | 未用完范围会留下空洞;范围分配与恢复必须防重复 |
| Snowflake 类 | 相对时间、节点号、序列号组合 | 节点身份、回拨、序列耗尽、进程重启均影响唯一性 |
唯一 ID 不应被用作授权凭证。即使不可预测,后端仍须检查对象归属。对账或财务编号需要连续编号时,应将其与普通技术主键分开,使用专门的分配与冲销规则。
UUIDv7 的字段与排序
RFC 9562 定义的 UUIDv7 共 128 位,结构为:
UUIDv7 / 128 bits
├── unix_ts_ms:48 位毫秒时间
├── version:4 位,值为 7
├── rand_a:12 位
├── variant:2 位
└── rand_b:62 位除版本与 variant 外,剩余字段可以按规范安排随机位、亚毫秒部分或计数器。时间在高位,使不同时间段的 ID 具有排序局部性;“同一毫秒内严格递增”则需要具体实现额外处理。回拨、计数器溢出与分布式节点分别有自己的规则。RFC 9562
PostgreSQL 18 提供 uuidv4() 和 uuidv7(),后者组合毫秒时间、亚毫秒时间和随机部分。Java 的 UUID.randomUUID() 生成的是 v4,不能仅把字段中的版本位改成 7 就得到符合该算法的生成器。PostgreSQL UUID 函数、Java UUID API
从下载包运行六个实验
使用 Linux、Bash、unzip 和可用的 Docker Engine/Compose。下载分布式状态实验包,在新目录中解压;工程包含时间、事务、事件与租约四组实验,以下命令只运行 TimeTest。
mkdir ds14-state-work
unzip distributed-state-lab.zip -d ds14-state-work
cd ds14-state-work/distributed-state
docker version
docker compose version
docker compose up -d --wait --wait-timeout 90
mkdir -p .m2两个 PostgreSQL 服务固定为 18.6,没有发布宿主端口。镜像初始化阶段先以 root 准备数据目录,再由数据库用户运行;Java 以数据库普通角色 lab 连接,测试口令只用于这个独立网络,不应照搬到共享环境。宿主用户必须获准访问 Docker daemon。
Maven 固定为 3.9.12,编译目标 Java 17;运行可选择 Temurin 17 或 25。下面使用 25,构建进程明确采用宿主 UID/GID,缓存与输出写到刚解压、由该用户拥有的目录:
docker run --rm --network ds14-state-network \
--user "$(id -u):$(id -g)" -e HOME=/tmp -e MAVEN_CONFIG=/m2 \
--entrypoint mvn \
--mount "type=bind,source=$PWD,target=/work" \
--mount "type=bind,source=$PWD/.m2,target=/m2" \
-w /work maven:3.9.12-eclipse-temurin-25 \
-B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp -Dtest=TimeTest testJDBC 驱动固定为 42.7.13,JUnit 为 6.0.3。驱动支持条件与下载入口见pgJDBC。依赖或镜像不可达时使用批准的 Maven 私服、代理或已校验离线包;不要关闭 TLS。若缓存没有写权限,先修正本实验目录的属主,不把构建改成默认 root。
正常输出包括:
send=2 receive=3
allocated=1,2 firstVisible=2 rolledBack=3 next=4
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0第一行来自两个实际执行任务间传递的逻辑值;测试还检查向量的支配/并发关系。第二行来自三个数据库连接:前两个执行事务,第三个独立观察已提交数据。号码在测试新建的专用表中从 1 开始。
回拨与节点重复分别怎样出错
实验中的 Clocks.TimeId 用 41 位相对毫秒、10 位节点号和 12 位序列构造非负 long;它只实现单进程内的发号与拒绝分支:
long now = relativeMillis.getAsLong();
if (now < last) throw new IllegalStateException("clock moved backwards");
int nextSequence = now == last ? sequence + 1 : 0;
if (nextSequence >= 4096) throw new IllegalStateException("sequence exhausted");
last = now;
sequence = nextSequence;
return (now << 22) | ((long) node << 12) | sequence;完整方法另有范围检查与同步。last 是上次使用的时间片;同片段递增序列,新片段从 0 开始。原始 Snowflake 项目的组合思路与服务定位可查官方归档仓库。
rollbackAndOverflowStopIssuing 把输入从 1000 改成 997,检查生成器抛出回拨错误;恢复到 1001 后消耗 4096 个序列,再检查耗尽分支,时间片前进后恢复发号。测试没有修改宿主系统时间。
duplicateNodeGeneratorsCollide 更直接:同时创建两个节点号为 7、时间为 1000、序列初始为 0 的实例,它们生成完全相同的 ID。只要节点号分配或重启恢复失效,位拼接本身就救不了唯一性。
生产服务需要将节点号租约、旧进程停写与状态恢复纳入同一设计。回拨时可以停止发号并告警、等待已知小偏差恢复,或采用有明确定义的逻辑推进策略;不能只更换一个未参与编码的“epoch”字符串。选择等待还要给等待设上限,防止耗尽业务线程。
sequence 分配与提交交错
sequenceOrderDiffersFromCommitOrder 的事务次序如下:
连接 A:BEGIN → 分配 1 → 插入未提交 ───────────────── COMMIT
连接 B: 分配 2 → 插入并提交
连接 C: SELECT 只看到 2在普通 MVCC 查询中,未提交的 1 不可见,已经提交的 2 可见。A 后来提交,查询才会同时看到两行。随后 A 分配 3 又回滚,B 的下一次分配得到 4;回滚不会把 nextval 消耗的值归还给其他事务。PostgreSQL sequence 函数
因此,轮询任务若用 WHERE id > last_seen_id 直接推进到 2,可能永久跳过稍后提交的 1。解决方式要匹配数据源:使用可靠的提交日志/CDC,或者为待处理行建立显式状态、领取与重试机制。单纯把自增 ID 改成 UUIDv7,仍没有处理提交交错。
可在本实验数据库中单独观察 UUID 类型,无需输出其中可还原的日历时间:
docker compose exec -T a psql -U lab -d lab -v ON_ERROR_STOP=1 \
-c "SELECT uuid_extract_version(uuidv4()) AS v4, uuid_extract_version(uuidv7()) AS v7;"结果应为 4 和 7。一次生成一千个值没有碰撞,只是这次调用结果;长期唯一性仍来自算法与运行条件。
用日志位置判断复制与恢复
位点要带上日志身份
| 标识 | 有序范围 | 搬到其他范围前需要什么 |
|---|---|---|
实体 version | 同一个业务实体 | 同时携带实体键与版本生成规则 |
| Kafka offset | 同一 topic-partition | 不能与其他分区的 offset 当作全局顺序比较 |
| PostgreSQL LSN | 相应 WAL 历史中的字节位置 | 确认数据库集群和时间线历史 |
| etcd revision | 同一集群 KV 存储历史 | 恢复/迁移后重新确认集群身份与历史关系 |
业务 ID 可以跨系统流转,复制位点则总有来源。恢复出一个新集群,或者主备切换进入新时间线后,不能继续只比较两个数字大小;还要知道它们是否来自可比较的历史。
在 PostgreSQL 主库查看当前 WAL 位置:
docker compose exec -T a psql -U lab -d lab -v ON_ERROR_STOP=1 \
-c 'SELECT pg_current_wal_lsn();'LSN 是类似 0/… 的十六进制位置,不是业务版本。当前实例中的其他事务也会产生 WAL,因此读到的位置可以比刚才业务提交所需的位置更靠后。
在实际备库上,则分别观察 WAL 接收与重放:
SELECT pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn();这条 SQL 需要连接备库;当前状态实验包中的 a/b 是两个独立主实例,不能把 b 当成 a 的备库。真实流复制环境与暂停重放实验见分片、复制与故障切换。上述函数的语义与权限见PostgreSQL 管理函数。
接收位置已前进、重放位置未前进,意味着 WAL 可能已经到达,但查询仍看不到其更新。需要读己之写时,应用可以携带所需位置,在属于同一历史的备库重放到目标后再查询;等待超过预算则回主或明确失败。不要使用接收位置替代查询可见性。
常见异常的处理
耗时出现负值或请求预算突然变长。 先定位计时使用的是 currentTimeMillis 还是同进程 nanoTime 差值,检查是否每层重新创建预算,再查看 chronyc tracking。修复后用可注入时钟触发回拨分支,确认耗时与预算逻辑不依赖墙上时间倒退;系统校时问题另由主机运维修复。
ID 重复。 按完整 ID 拆出时间片、节点号和序列,检查是否两个实例共享节点号、进程重启遗失 last、回拨后重新从序列 0 发号。先停止有冲突的发号实例,保留数据库唯一约束;核实尚未提交请求和已存在对象,再恢复单一有效持有者。不能通过删除唯一索引让请求继续。
号码有空洞。 查询生成方式、sequence cache 和回滚路径。如果使用普通技术主键,空洞通常无需填补;重新使用号码可能把迟到请求指向不同对象。若业务要求连续账本编号,应设计独立规则,不修补通用 sequence。
副本仍读不到已提交数据。 先确认连接目标处于 recovery,再比较来源一致的接收与重放位置。接收落后查复制连接、网络和主库 WAL 保留;接收已到但应用落后查暂停状态、冲突和重放资源。修复后等待既定目标位置并读取目标业务行,而非只看副本端口恢复。
清理本地数据库
完成实验和查询后,在解压目录检查资源名称,再关闭容器:
docker compose ps
docker compose down数据卷默认保留,后续可重新启动查询。确认只包含本轮实验数据、不再需要时,可在同一目录执行 docker compose down -v,删除 ds14-state 专用数据库卷;这会永久清除实验表,不应用于生产项目。
权威资料与规范地址
时钟与因果
- Java System:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/System.html
- Lamport 原论文:https://www.microsoft.com/en-us/research/publication/time-clocks-ordering-events-distributed-system/
- HLC 原论文:https://cse.buffalo.edu/tech-reports/2014-04.pdf
- chronyc:https://chrony-project.org/doc/4.6.1/chronyc.html
ID、数据库与复制位置
- RFC 9562:https://www.rfc-editor.org/rfc/rfc9562.html
- Java UUID:https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/UUID.html
- Snowflake 原始项目:https://github.com/twitter-archive/snowflake
- PostgreSQL UUID:https://www.postgresql.org/docs/18/functions-uuid.html
- PostgreSQL sequence:https://www.postgresql.org/docs/18/functions-sequence.html
- PostgreSQL 管理函数:https://www.postgresql.org/docs/18/functions-admin.html
- pgJDBC:https://jdbc.postgresql.org/download/
