如果网络会骗你,时钟也会骗你

单机程序写了几年,什么 bug 都见过——NullPointerException、死循环、线程不安全——但至少有一个信念是牢不可破的:调用一个方法,它要么返回结果,要么抛异常,不会凭空消失。

// 单机世界——确定性
boolean ok = service.deductStock(productId, 5);
if (ok) {
    orderMapper.insert(order);  // 扣成功了才下单
}

这段代码在单机上运行了成千上万次——从来没出过问题。if/else 的逻辑像物理定律一样可靠。

然后某天系统拆成了微服务。扣库存从本地方法调用变成了远程 RPC 调用:

// 分布式世界——不确定性
boolean ok = rpcService.deductStock(productId, 5);
// 这一行代码可能:
//   - 正常返回 true
//   - 正常返回 false
//   - 抛异常
//   - 永远不返回——线程一直卡着
//   - 扣库存成功——但响应包在网络丢了——你以为失败了
if (ok) {
    orderMapper.insert(order);
}

从那一刻起——之前所有关于"确定性"的直觉——全部失效。

📌 前置知识:本文不需要任何分布式系统经验,但建议有基本的 TCP/HTTP 通信认知(知道"请求-响应"模式即可)。如果写过 Spring Boot 项目,理解 RPC 调用的概念,阅读体验会更好。


一、单机世界 vs 分布式世界——一张图看懂差异

单机程序中——所有事情都发生在一个进程里。方法调用是在同一块内存里跳转指令,操作系统保证要么执行完成、要么异常退出——不存在"不确定有没有执行"这种状态。

分布式系统完全不同。两个服务之间的通信——说白了就是两台机器之间发网络包。而网络——从来就不是为确定性设计的。

单机世界
JVM 进程
├── Service → → 直接方法调用 → → → Service B
├── 线程共享堆内存
├── 异常一定被捕获
└── 结果:确定
分布式世界
机器 A (192.168.1.10)
├── order-service → → 网络包 → → → 机器 B (192.168.1.20)
├── 网线/交换机/WiFi
├── 包可能丢在路上
└── 结果:不确定

单机方法的调用栈——参数在寄存器里、返回值在栈帧里——CPU 一个时钟周期就能跳过去。分布式调用的"参数"要序列化成字节流、经过操作系统的协议栈、穿过不知道多少台交换机——每一步都可能出错。

问题来了:到底有多少种"出错"的方式?


二、网络的不确定性——发了不等于到了

2.1 三种崩溃方式:丢包、延迟、分区

flowchart TD
    start["order-service\n发起 RPC 调用"]:::startEnd

    start --> choice{"网络发生了什么?"}:::condition

    choice -->|"正常"| ok["响应到达\n✅ 期望的状态"]:::data
    choice -->|"丢包"| loss["请求包或响应包\n在网络中丢失\n❌ 发送方永远不知道结果"]:::reject
    choice -->|"延迟"| delay["包在路上\n10ms / 500ms / 30s\n⏳ 发送方只能等待"]:::highlight
    choice -->|"分区"| partition["网络被切断\n两边都认为对方挂了\n❌ 各自为战"]:::reject

classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;

先说丢包。TCP 有重传机制——丢了就重发——为什么还有问题?因为重传解决不了"响应丢了"的情况。

请求到达 → 服务端执行成功 → 扣了库存 → 发送响应 → 响应包丢了

发送方视角:超时没收到响应——重试——又扣了一次库存——超卖

⚠️ 新手提示:RPC 框架里的"超时重试"默认是不安全的。扣库存、支付、发券这类写操作——除非服务端实现了幂等——不然重试就是事故。这也是为什么 Dubbo 的 Failover 策略默认只对读操作安全。

再说延迟。延迟的麻烦不在于慢——而在于你不知道它有多慢。一个线程等 RPC 响应等了 30 秒——这 30 秒里线程池可能在累积新请求——最后雪崩。Sentinel 的线程数限流、Dubbo 的超时与熔断——本质上都是在对抗延迟的不确定性。

最后是分区——最棘手的一种。网络断开——order-service 和 product-service 各自正常运行——但无法通信。两边都不知道对方还活着。

网络分区示意
机器 A
order-service 正常运行
认为 "product-service 挂了"
开始走降级逻辑
机器 B
product-service 正常运行
认为 "order-service 没发请求"
库存数据还在更新
交换机/网线故障——两台机器都正常工作——但彼此不知道

2.2 两将军问题——理论上就不可能

丢包、延迟、分区都可以通过"重试+超时"来缓解——能不能设计一种协议——在不可靠的信道上100% 达成一致

答案是:不能。这就是两将军问题(Two Generals’ Problem)。

问题的设定很简单:两支军队分别从两个方向包围一座城市——必须同时进攻才能获胜——但信使穿越敌人的领地可能被抓——怎么保证两军都同意进攻时间?

sequenceDiagram
    participant G1 as 将军 A
    participant M1 as 信使
    participant G2 as 将军 B

    Note over G1,G2: 第一轮:A 提议"明天 6:00 进攻"
    G1->>M1: 明天 6:00 进攻——同意吗?
    M1->>G2: 传递消息
    G2->>M1: 同意——明天 6:00
    M1->>G1: 传递确认

    Note over G1,G2: 等一下——A 不知道 B 是否收到了自己的确认\n如果携带确认的信使被抓——B 出发了——A 没有

    rect rgb(255, 205, 210)
        Note over G1,G2: 第二轮:A 发送"我收到你的同意了"
        G1->>M1: 收到——确认
        M1->>G2: 传递确认的确认

        Note over G1,G2: 等一下——B 不知道 A 是否收到了"确认的确认"\n又回到了起点
    end

    rect rgb(255, 243, 224)
        Note over G1,G2: 第三轮/第四轮/第五轮……\n每次都需要多一轮确认来确保上一轮被收到\n无限递归——永远不会终止
    end

无论发多少轮确认消息——总存在"最后一轮可能丢失"——因此无法在有限轮次内达成绝对一致。这个结论不是"目前还没有好办法"——是数学上已经证明的——在有消息丢失的信道上——完全一致性不可能达到。

📌 前置知识:两将军问题的严格表述是——在不可靠通信环境(消息可能丢失但不被篡改)中——两个节点无法在有限轮次内就某个值达成确定性一致。多数派协议(Raft/Paxos)通过"接受不确定性"——只要多数节点同意就算一致——绕过了这个理论限制。这也是后续第三篇文章重点展开的内容。

这个问题直接宣告了一个残酷的事实:在分布式系统中——绝对一致是不可能的——你只能选择"接受低概率的不一致"或者"牺牲可用性来等"。


三、时间的不确定性——时钟不可信

3.1 你以为 System.currentTimeMillis() 返回的是什么

任何写过 Java 的人都知道这行代码:

long now = System.currentTimeMillis();
// 返回:从 1970-01-01T00:00:00Z 到现在的毫秒数
// 看起来是"客观时间"——对吧?

问题是——这个值来自哪里?

计算机主板上有一颗石英晶振——每秒振荡 32,768 次(或者 1,000,000 次)——操作系统数振荡次数——换算成秒。一块普通晶振的精度大约是 20 ppm(parts per million)——听起来很精确?

20 ppm 意味着每天漂移 1.7 秒——一个月漂移 50 秒。

两台机器的时钟——经过一周后
🕐 机器 A
2023-01-08 12:00:00.000
晶振精度 15 ppm
一周漂移:+9 秒
🕑 机器 B
2023-01-08 11:59:48.000
晶振精度 25 ppm
一周漂移:-12 秒
⚠ 两台机器相差 12 秒——谁是对的?

两台服务器部署在同一天——一周后——它们的时钟已经差了 10 ~ 20 秒。两个不同的"现在"——哪个才是真的?

⚠️ 新手提示:在你的开发机上——时钟通常很准——因为操作系统会通过 NTP(Network Time Protocol)定期校准。所以平时开发时你感觉不到时钟漂移。生产环境也会做 NTP 校准——但问题在于 NTP 校准本身也是不确定的——下一节展开。

3.2 NTP 校准——你问时间这件事本身就有时间

NTP 的工作原理看起来简单:客户端问一个权威时间服务器"现在几点"——服务器回答——客户端调整自己的时钟。

但"问时间"这个动作本身就需要时间。

sequenceDiagram
    participant C as 客户端 (172.16.1.10)
    participant S as NTP 服务器 (time.apple.com)

    Note over C: 客户端本地时间:t1 = 1000
    C->>S: 请求——"现在几点?"\n(发送时间戳 t1)

    Note over S: 服务器在 t2 = 1005\n收到请求\n(这个 t2 是服务器时间)

    S->>C: 回复——"现在 t2=1005, t3=1006"\n(服务器收发时间戳)

    Note over C: 客户端在 t4 = 1010\n收到回复\n(这个 t4 是客户端本地时间)

    Note over C,S: 往返时间 = (t4 - t1) = 10\n服务器处理时间 = (t3 - t2) = 1\n网络传输时间 ≈ (10 - 1) / 2 = 4.5\n\n客户端推算当前服务器时间 = 1006 + 4.5 ≈ 1010.5\n客户端调整时钟到大约 1010.5

    Note over C: ⚠ 但这里有个致命假设:\n往返的网络延迟是对称的\n——去的时间和回来的时间一样\n实际网络:去 2ms, 回 8ms → 偏差 6ms\n算出来的时间错了

NTP 假设网络延迟对称——去和回一样快。但真实网络从来不对称。交换机缓冲、路由策略、链路负载——都可能导致"去 2ms 回来 8ms"。这个不对称本身就在几十毫秒级别。

📌 前置知识:为什么不对称会导致误差?假设实际去 2ms、回 8ms——客户端却假设各 4.5ms——那么它推算的"服务器当前时间"就比实际早了 2.5ms。对于需要微秒级精度的场景(分布式事务的时间戳排序、金融交易)——这个误差足以引发"哪个操作先发生"的错误判断。

而且——NTP 服务器可能不可达。网络分区发生时——连 NTP 包都发不出去——时钟只能靠本机晶振硬撑。时间越长——漂移越大。

3.3 时间戳不再是可靠的仲裁者

在单机世界里——“哪个操作先发生"由时间戳决定:

操作 A:2023-01-08 12:00:00.100 → 插入数据
操作 B:2023-01-08 12:00:00.200 → 更新数据
B 在 A 之后——毫无疑问

在分布式世界里——两台机器的时钟相差 12 秒——“先发生"失去了意义:

机器 A:2023-01-08 12:00:00.010 → order-service 创建订单
机器 B:2023-01-08 11:59:50.020 → payment-service 收到支付回调

机器 B 的时间戳比机器 A 早了 10 秒——但实际上是支付在订单之后发生的
如果用时间戳排序——支付会在订单前面——逻辑完全反了
flowchart TD
    subgraph realOrder["实际发生顺序"]
        r1["① order-service 创建订单"] --> r2["② product-service 扣库存"] --> r3["③ payment-service 创建支付"]
    end

    subgraph clockOrder["按各节点本地时钟排序"]
        c1["order: 12:00:00.010\n机器 A 时钟快"] --> c2["payment: 11:59:50.020\n机器 B 时钟慢"] --> c3["stock: 12:00:01.050\n机器 C 时钟中等"]
    end

    realOrder -.->|"❌ 矛盾"| clockOrder

classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
    class c1,c2,c3 process
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;

这就是为什么在分布式系统中——不能直接用物理时间戳来判断事件顺序(这也是 Logical Clock / Vector Clock 被发明的原因——留到后续文章展开)。


四、两个不确定性叠加——为什么分布式系统"总是要做取舍”

现在把网络不确定性和时间不确定性放在一起——看看会发生什么:

场景:order-service 调用 product-service 扣库存

网络层不确定:
  → 消息可能丢了——你不知道扣了还是没扣
  → 消息可能延迟——你不知道要等多久
  → 网络可能分区——你不知道对方还活着吗

时间层不确定:
  → 你不知道"超时"到底该设多少——设短了误判——设长了雪崩
  → 两个节点看到的"当前时间"不一致——谁的结果是旧的
  → NTP 校准可能滞后——时钟在不知不觉中漂移

这两个不确定性相互放大——网络的延迟影响你对时间的判断——时间的偏差影响你对网络状态的判断。

flowchart TD
    net["网络不可靠\n丢包 / 延迟 / 分区"]:::reject
    clock["时钟不可靠\n漂移 / NTP 不对称 / 闰秒"]:::reject

    net --> q1{"消息发出去了\n但没收到回复\n怎么办?"}:::condition
    clock --> q2{"设置超时时间\n但两个节点时钟偏差未知\n超时该设多少?"}:::condition

    q1 --> q3{"重试?\n可能重复执行\n可能超卖"}:::condition
    q2 --> q3

    q3 --> tradeoff["⚡ 必须做选择"]:::highlight
    tradeoff --> a["方案 A:等足够久\n——可用性降低"]:::data
    tradeoff --> b["方案 B:快速重试\n——可能不一致"]:::data
    tradeoff --> c["方案 C:什么都不做\n——放弃这个请求"]:::data

classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;

这就是为什么 CAP 定理不是"三选二"的游戏——而是网络和时间的双重不确定性迫使你在"等精确答案"和"快速给大致答案"之间选边站。

当你理解了这两个不确定性——Nacos 为什么同时提供 AP 和 CP 两种模式、RocketMQ 事务消息为什么需要回查机制、Sentinel 为什么用滑动窗口而不是精确计数——这些设计决策就不再神秘。


五、总结——这篇讲了什么

分布式系统的所有复杂设计——都源于两个基本事实:网络会丢包、时钟会漂移。

不确定性根源导致的问题对应中间件设计
网络不可靠丢包、延迟、分区——物理链路不可控无法区分"对方挂了"和"消息丢了”Nacos 心跳+保护阈值、Dubbo 超时重试、Sentinel 熔断
时钟不可靠晶振漂移、NTP 不对称、网络延迟无法用时间戳仲裁"先发生"分布式事务用 XID/全局锁替代时间戳排序

两个不确定性叠加——产生了一个不可回避的现实:在分布式系统中——任何涉及"等待远程响应"的操作——都存在不确定性——你无法 100% 确定操作的结果——只能在"等更久"和"接受可能出错"之间做取舍。

下一篇——从 CAP 定理出发——用 Nacos、Redis、Kafka 这几个你每天用的中间件——看它们具体是怎么做取舍的。


📖 本系列导航

  • 本文:第一篇——网络与时间的不确定性(打基础)
  • 第二篇:CAP 定理与一致性模型——Nacos AP/CP 双模、Redis/Kafka 的一致性抉择
  • 第三篇:Raft 选举与心跳——Nacos/Dubbo 中的故障检测与领导者选举
  • 第四篇:流控算法——Sentinel 滑动窗口、令牌桶与 Dubbo 负载均衡