# 第 35 章 微服务架构与治理


> 拆分把进程内约 100 ns 的调用换成了同机房 0.1~0.5 ms 的网络调用；本章所有治理手段，都是在替这笔三个数量级的账还债。

## 学习目标

读完本章，你应当能够：

1. 用团队规模、部署冲突频率、瓶颈独立性三个可观测判据判断拆分时机，给出微服务与单体的双向论证与"过早拆分"的反例；
2. 画出客户端发现与服务端发现两条路径，说明注册中心 `AP`/`CP` 取舍、健康检查的两类误判与 `Eureka` 自我保护的触发条件；
3. 列出七种负载均衡策略的判据，用算例证明最少连接数为什么常优于轮询，说清四层与七层的差异，并画出熔断器三态迁移图、给出六组参数的语义；
4. 辨析熔断与限流、`Sentinel` 与 `Hystrix`、线程池隔离与信号量隔离、幂等与并发控制，算出重试放大倍数，并设计从 `TraceID` 传播、采样到日志聚合的可观测链与跨服务排障流程。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| `CAP` 的准确表述、`BASE`、一致性哈希与虚拟节点、限流算法 | 见第 34 章；本章只用它们解释注册中心取舍与灰度分流 |
| 缓存三件套、消息队列中 MQ 视角的幂等 | 见第 27 章与第 33 章；本章讲接口层幂等与读降级 |

## 35.1 微服务与单体的取舍

### 35.1.1 双向论证：收益在组织层，代价在运行层

| 维度 | 收益 | 必须能逐条说出的代价 |
|---|---|---|
| 部署 | 单服务独立发布，不必整包上线 | 一次改动常跨 3~5 个仓库，版本兼容与发布顺序成为新成本 |
| 技术栈 | 新服务可选更合适的语言/存储 | 多套构建、依赖、监控、告警体系，运维成本随服务数近似线性增长 |
| 扩容 | 只对瓶颈服务扩容 | 容量规划从一个池子变成 N 个池子，下游要能接住上游扩容 |
| 故障 | 单服务崩溃不必然拖垮全局 | 局部失败成为常态：每处调用点都要处理超时、重试、部分失败 |
| 数据 | 每个服务自选存储模型 | 本地事务原子性消失，跨服务一致性只能靠柔性事务（见第 34 章） |
| 调试 | 单服务代码库小、可独立测试 | 请求跨多个进程，本地无法复现，定位依赖链路追踪与日志聚合 |

量化：进程内调用约 100 ns，同机房网络往返（RTT）约 0.1~0.5 ms，相差 10³~10⁴ 倍，还额外承担序列化与"对端可能不响应"的不确定性；可用性按乘法衰减，5 个可用性 99.99% 的服务串联后约 `0.9999⁵ ≈ 99.95%`，年停机从约 53 分钟涨到约 4.4 小时。

> [!IMPORTANT]
> 只答"解耦、可独立部署"是半个答案。必须给出代价：**分布式复杂性、运维成本、调试困难、数据一致性**，并指出这些代价不会被框架消灭，只会被基础设施吸收一部分。

### 35.1.2 拆分时机：三个可观测判据

1. **团队规模**：同一代码库上并行开发超过约 8~10 人（两个披萨团队量级）时，评审、合并、发版排期的协调成本超过开发成本本身，此时按业务能力（business capability）切出端到端拥有制的服务；
2. **部署冲突频率**：统计"我改了 A 模块却与 B 模块互相阻塞发布、必须一起回归"的次数，高到需要排队发版就是硬信号；
3. **瓶颈独立性**：只有订单导出需要 10 倍机器、其余模块负载平稳，拆出去单独扩容的收益可量化；若所有模块负载同步涨落，拆了只是把进程边界搬了个家。

**过早拆分的反例**：3 人维护 2 万行 CRUD，按技术分层拆成 12 个服务。本地启动要拉起 12 个进程，改一个字段提交 5 个仓库，一次联调的时间超过写代码的时间，而"独立部署"收益为零（只有一个团队、一条流水线）；原本本地事务能保证的一致性，现在必须用分布式事务或补偿实现——**把可控的复杂度换成了不可控的复杂度**。

> [!WARNING]
> "越细越好、越小越好，符合单一职责"是错误回答。面试官会追问："12 个服务的超时预算怎么分？跨服务分页怎么做？"正确顺序是**先有稳定模块边界，再按边界拆服务**；边界未稳就拆，得到的是"分布式大泥球"（distributed monolith）——强耦合且仍必须一起发布。

### 35.1.3 模块化单体作为中间态

模块化单体（modular monolith）= 单进程部署 + 单一数据库 + **编译期强制的模块边界**：用 Maven/Gradle 子模块或包可见性隔离，跨模块只走显式接口；用架构守护测试（如 ArchUnit 规则）在 CI 中禁止订单模块直连库存模块的内部 DAO；模块间只传 DTO，不共享实体（entity）。价值是**边界能低成本反复试错**：拆错了回退只是重构包结构，而不是撤销一批线上服务。演进时务必**不共享数据库**，否则耦合只是从代码搬到了表上。

## 35.2 服务注册与发现

### 35.2.1 客户端发现与服务端发现

```
客户端发现（Eureka + Ribbon）：调用方拉取/订阅实例列表 → 自己按 LB 策略选实例 → 直连调用（少一跳，每种语言一套 SDK）
服务端发现（Kubernetes Service + kube-proxy/Ingress）：调用方只访问虚拟地址 orders.svc:8080
   → 节点内核按 iptables/IPVS 规则 DNAT 到真实 Pod（Endpoints 由 apiserver 维护，客户端零改造）
   代价是多一跳或一次 DNAT，虚拟 IP 让抓包与可观测性变差
```

| 对比项 | 客户端发现 | 服务端发现 |
|---|---|---|
| 代表实现 | `Eureka`+`Ribbon`、`Nacos`+客户端 SDK | `Kubernetes` Service+`kube-proxy`、`Ingress`/`Gateway`、`Consul`+`Envoy` |
| 客户端要求 | 需要注册中心 SDK、健康感知与 LB 算法 | 只需一个稳定地址，任何语言零改造 |
| 变更延迟 | 客户端缓存列表，实例变化有秒级延迟 | 控制面推送 `Endpoints`，延迟取决于探针周期与同步 |

> [!NOTE]
> 被追问"服务端发现看不到真实实例，链路怎么还原？"时，答案是发现层与追踪层无关：`TraceID` 靠请求头传播，与 IP 是虚拟还是真实无关，只需在 `span` 上额外记录虚拟服务名与真实 Pod 标识（如 `k8s.pod.name`）。

### 35.2.2 注册中心的 `AP` 与 `CP` 取舍

| 注册中心 | 模型 | 实现基础 | 分区时的行为 | 适合的场景 |
|---|---|---|---|---|
| `Eureka` | `AP` | 节点对等复制，无 `leader` | 各节点继续接受注册与查询，可能返回互不一致的列表 | 只做服务发现 |
| `ZooKeeper` | `CP` | `ZAB`，多数派写入 | 少数派一侧整体不可用（会话超时后临时节点被删） | 选主、命名服务、元数据 |
| `etcd` | `CP` | Raft（见第 34 章） | 少数派拒绝读写，`watch` 事件有延迟 | `Kubernetes` 控制面、租约 |

取舍的本质是**分区时更怕哪一种错误**：`AP` 的风险是流量打到已死实例（可用性问题，由客户端重试与熔断兜底）；`CP` 的风险是注册中心整体拒绝服务，客户端连一份可用列表都拿不到（可用性直接归零）。所以服务发现偏 `AP`，选主与分布式锁必须 `CP`，不存在"又快又一致"的选项（`Nacos` 用临时/持久实例来分别对应这两种语义）。

### 35.2.3 健康检查的两种方式与各自的误判

| 方式 | 机制 | 误摘好实例 | 漏留坏实例 |
|---|---|---|---|
| 心跳续约 | 客户端周期续约，超 TTL 未续约即剔除，判定延迟约为 TTL 的 1~3 倍 | 网络抖动、长 GC 停顿、容器被 CPU 限流导致续约迟到 | 进程活着但业务不可用（依赖库挂、线程池耗尽），续约照常成功 |
| 主动探测 | 注册中心/编排系统主动调健康端点，延迟约为探测周期 × 失败阈值 | 探测端点自身慢导致超时、慢启动实例被过早判死 | 只探存活不探依赖，或探测命中缓存返回 `200` |

`Kubernetes` 把主动探测分成三种：`livenessProbe` 失败**重启容器**（死锁类不可自愈故障）；`readinessProbe` 失败**从 `Endpoints` 摘除**但不重启（依赖暂时不可用、预热中）；`startupProbe` 在成功前不执行 liveness 检查，避免"启动慢 → 被判死 → 循环重启"。下线必须走优雅停机：**先注销 → 等待一个不小于调用方列表缓存刷新时间的窗口 → 处理完在途请求 → 关闭**；顺序颠倒就会必现"实例已销毁、调用方还在发请求"的错误。

衡量摘除策略只有一个锚点：**摘除延迟 × 流量 = 打到坏实例上的错误请求数**。注册中心注销慢、调用方列表缓存久、连接池里还有旧连接，任一处变慢都会直接体现为用户可见的错误率。

### 35.2.4 `Eureka` 的自我保护：保留过期实例是有意设计

`Eureka` 服务器每分钟比较"本应收到的续约数"（阈值 = 注册实例数 × 每分钟续约次数 × 0.85，`renewalPercentThreshold` 默认 `0.85`）与上一分钟实际收到的续约数；**实际值低于阈值的 0.85 倍时触发自我保护：不再剔除任何过期实例，只记录告警**。原因是分区时 `Eureka` **无法区分**"客户端进程死了"与"客户端网络断了"：若按 TTL 大批剔除，客户端在恢复前拿到的列表会迅速变空，服务端发现模式下调用方直接判定"无可用实例"，全站失败。保留过期实例的代价是部分请求打到确实已死的实例，由**客户端的连接超时、重试与熔断**兜底——用少量请求失败换整体可用。

> [!CAUTION]
> 关闭 `enable-self-preservation` 或把 `renewalPercentThreshold` 调到极低不是"提高准确性"，而是把一次可恢复的网络抖动放大成全站故障。答"它就是防止误杀、缺陷是保留坏实例"只说了一半，必须补上"删与不删的代价各由谁承担"。

## 35.3 负载均衡策略与四七层差异

### 35.3.1 七种策略与判据

| 策略 | 决策依据 | 失效场景 | 典型位置 |
|---|---|---|---|
| 轮询（round robin） | 序号递增 | 实例性能不均则慢实例被压垮 | 客户端 SDK、四层 |
| 加权轮询 | 静态权重 | 权重固定，实际负载漂移后不调整 | 客户端 SDK、七层 |
| 随机 | 伪随机数 | 小样本下短周期分布不均 | 客户端 SDK |
| 加权随机 | 权重 + 随机 | 同随机，大流量下等价于加权轮询 | 客户端 SDK |
| 最少连接数 | 当前在途请求数最少 | 需准确统计在途数；共同依赖变慢时无效 | `LVS` `lc`、七层 |
| 最短响应时间 | 在途数 × 平均响应时间 | 指标是滑动平均，对慢实例滞后 | `Envoy` `least_request` 变体 |
| 一致性哈希 | 请求键哈希到环 | 热点键导致倾斜，需虚拟节点 | 客户端 SDK、七层 |

选型判据：实例同构用轮询/随机；预知异构用加权轮询；负载漂移且请求耗时长用最少连接；需要会话或本地缓存亲和用一致性哈希。

### 35.3.2 "最少连接"为什么常优于轮询

```
三台单核实例：A 单请求 2 ms（约 500 QPS），B、C 单请求 20 ms（各约 50 QPS）；入口 450 QPS
轮询（各 150 QPS）：A 用掉 0.30 s CPU/秒（30%，大量空闲）；B、C 各 3.00 s CPU/秒（300%，远超单核）
                    → 队列无界增长 → 响应变慢 → 上游超时 → 触发重试 → 流量再放大（见 35.5.2）
最少连接（稳态）：B、C 各被压在 50 QPS（满负荷）；A 承担剩余 350 QPS（低于其 500 能力）→ 有稳定工作点
机制：在途请求数天然反映实例快慢，流量被自动抽走；推广：轮询的总利用率由最慢实例决定（ρ = λ/(3 × μ_min)），
      最少连接使各实例在途数趋同，等价于按服务率分配到达率，总容量接近 Σμᵢ
```

不要把它说成万能：收敛需要时间（慢实例要先堆积出差异），请求耗时极短且均匀时与轮询无差别；**当所有实例因共同依赖（如数据库）同时变慢时它也无能为力**，那属于熔断与限流的职责。

### 35.3.3 四层与七层：不止是"快慢"的区别

| 对比项 | 四层（L4，传输层） | 七层（L7，应用层） |
|---|---|---|
| 能看到什么 | IP、端口、`TCP` 标志位，**看不到 `HTTP` 内容** | 方法、路径、`Host`、头、Cookie、`gRPC` 方法名 |
| 路由能力 | 只能按 IP/端口分发 | 按路径/域名/头部路由，支持灰度分流 |
| `TLS` 卸载位置 | 只能透传（`TLS` 在后端终止），除非用 `SNI` 按域名转发 | 在负载均衡器处终止 `TLS`，后端走明文或重新加密 |
| 连接语义 | 一个客户端连接通常绑定一个后端连接，连接数被放大 | 前端连接与后端连接池解耦，可复用长连接 |
| 超时语义 | 只有连接级（握手、空闲）超时 | 请求级超时、每请求重试、每跳超时预算 |

追问两例：后端要拿真实客户端 IP，需 `DR` 模式加 `toa` 或 `proxy_protocol`；`TLS` 在网关终止后，后端只能靠 `X-Forwarded-Proto` 判断原始协议，且必须只信任网关写入的值。

### 35.3.4 `LVS`/`Nginx`/`Envoy` 的定位

`LVS`（内核四层，`DR`/`TUN`/`NAT` 模式，`DR` 只改 MAC、回包不经过转发器）是入口抗量的第一层，百万级连接只做转发；`Nginx` 是用户态七层反向代理（另有 `stream` 模块做四层），擅长路径分流、限流、静态缓存，配置以文件为单位重载；`Envoy` 是用户态七层代理，配置通过 `xDS` **动态下发**且原生支持熔断、重试预算、追踪上报，所以成为服务网格的数据面（sidecar）。典型串接是 `DNS`/`Anycast` → `LVS`（四层抗量）→ 网关（七层路由与治理）→ 实例/sidecar：**越往上能力越强、可承载的并发量级越低，因此是分工而非替代关系**。

## 35.4 熔断器

### 35.4.1 三态与迁移条件

```
                窗口请求数 ≥ 最小样本数 且 失败率 ≥ 阈值
┌──────────┐                                       ┌────────────┐
│  CLOSED  │ 统计窗口、放行全部请求                 │    OPEN    │ 不发起真实调用
└──────────┘                                       └────────────┘
    ▲ 探测成功数 ≥ 成功阈值，成功后清空窗口             │ 等待 waitDurationInOpenState
    │                  任一探测失败 → 立刻回 OPEN  ┌────────────┐
    └──────────────────────────────────────────────│ HALF_OPEN  │ 只放行 N 个探测
                                                   └────────────┘
状态迁移：CLOSED --失败率越阈值--> OPEN --等待时长结束--> HALF_OPEN --探测成功--> CLOSED
```

三个状态各回答一个问题：`CLOSED` 看失败率是否越阈值（**必须有最小样本量门槛**，否则启动期一次失败就熔断）；`OPEN` 的拒绝时长是"给下游留的恢复时间"（太短会反复打满未恢复的下游，太长则流量回不来）；`HALF_OPEN` 用足够小又足够多的探测请求受控验证。

> [!IMPORTANT]
> 熔断器不等于"失败就拒绝"。核心是**在统计窗口内用失败率/慢调用比例判断下游是否整体不可用**，并且必须有 `HALF_OPEN` 这个受控探测阶段；少了它，熔断就退化成一次性开关。

### 35.4.2 关键参数

| 参数 | 语义 | 典型默认值（官方文档口径） | 设错的后果 |
|---|---|---|---|
| 滑动窗口类型 | 计数窗口（最近 N 次）或时间窗口（最近 N 秒，内部分桶） | `Resilience4j` 支持 `COUNT_BASED`/`TIME_BASED`；`Hystrix` 为 10 s 滚动窗口 | 低流量服务用计数窗口凑不满样本；时间窗口在流量突变时统计滞后 |
| 窗口大小 / 最小样本数 | 判定所需的最小观测集 | `Resilience4j`：`slidingWindowSize=100`、`minimumNumberOfCalls=100`；`Hystrix`：`requestVolumeThreshold=20` | 太小则抖动误熔断，太大则反应迟钝 |
| 失败率阈值 | 窗口内异常调用占比上限 | 两者默认均 `50%` | 设成 10% 会在正常抖动下反复熔断 |
| 慢调用比例阈值 + 慢调用判定 | 超过固定 RT 的调用占比上限 | `Resilience4j`：`slowCallRateThreshold=100%`、`slowCallDurationThreshold=60 s`；`Sentinel` 用 RT 降级规则 | 只看异常率会漏掉"不报错但极慢"这一最常见的真实故障 |
| `Open` 持续时长 | 拒绝流量、等下游恢复的时间 | `Resilience4j`：`waitDurationInOpenState=60 s`；`Hystrix`：`sleepWindowInMilliseconds=5000` | 太短则反复击穿未恢复的下游，太长则恢复慢 |
| `Half-Open` 探测数 / 成功阈值 | 允许通过的探测请求数与恢复条件 | `Resilience4j`：`permittedNumberOfCallsInHalfOpenState=10` | 过大等于立刻恢复全量，过小则统计不显著 |

> [!TIP]
> 被问"阈值怎么定"时不要只报数字，要报方法：阈值从**下游容量**反推（下游只能接 1000 QPS 而正常给它 800 QPS，就该先靠限流挡在前面），熔断阈值取"正常抖动不会触发"的水平，通常失败率 `50%` + 最小样本 `20` 起步，再用线上误报率调优。

### 35.4.3 熔断与限流的区别

| 对比项 | 限流（rate limiting） | 熔断（circuit breaking） |
|---|---|---|
| 保护对象 | **保护自己**，不被超过自身容量的流量打垮 | **保护调用方**不被下游拖死，并给下游留恢复时间 |
| 触发依据 | 与结果无关的输入量：QPS、并发数 | 调用**结果**：失败率、慢调用比例 |
| 拒绝后的行为 | 排队、丢弃、返回 `429`/`503` | 快速失败（fail fast）+ 走降级（fallback） |
| 恢复方式 | 流量降到阈值以下即自动恢复 | 必须经过 `Open` 等待 + `Half-Open` 探测，是显式状态机 |

一句话：**限流是"我的门有多宽"，熔断是"对面那扇门还能不能走"**。只有限流时，下游整体故障会让调用方线程耗在等待上直至线程池耗尽（级联失败）；只有熔断时，下游健康但容量不足不会触发任何保护（没有错误可统计）。

### 35.4.4 `Sentinel` 与 `Hystrix` 的机制差异

| 维度 | `Hystrix` | `Sentinel` |
|---|---|---|
| 隔离实现 | 默认**线程池隔离**，每个依赖一个独立池（默认核心 10 线程） | **滑动窗口统计**（`LeapArray`，默认 1 s 窗口切 2 个桶）+ 信号量式并发数/线程数控制 |
| 超时控制 | 调用方持有 `Future`，超时返回兜底（默认 1000 ms） | 依赖底层客户端自身的超时；流控可按线程数限制 |
| 判定规则 | 滚动窗口 + 三态（`requestVolumeThreshold=20`、`errorThresholdPercentage=50`、`sleepWindow=5000 ms`） | 流控（QPS/线程数）、降级（RT/异常比例/异常数）、系统自适应保护 |
| 项目状态 | 已进入维护模式，官方不再增加新功能并推荐替代品（如 `Resilience4j`） | 仍在演进 |

`Hystrix` 进维护模式的根本原因不是功能不足，而是"**每个依赖一个线程池**"这个抽象在大规模场景成本过高：线程是有限资源，一次调用多花约 1~5 μs 的上下文切换，线程池数量与依赖数成正比，最终线程数成为容量瓶颈（20 个依赖 × 10 线程 = 200 线程）。后续方案都改为同一线程内做滑动窗口统计与信号量限流。要讲清线程池隔离买到了什么：调用发生在**另一个线程**上，调用方线程可以用 `Future.get(timeout)` 精确超时并释放自己——**即使被调代码不支持中断**，调用方也能脱身；代价是工作线程可能仍被阻塞在不可中断的系统调用（如未设读超时的 `socket read`）上，"丢弃"只是调用方视角的丢弃，线程没被回收，要真正回收须关闭连接或设置 `SO_TIMEOUT`。而信号量隔离省掉了这次调度，但**无法主动超时中断**，只能依赖 `socket`/`RPC` 超时。

## 35.5 隔离、超时与重试放大

### 35.5.1 线程池隔离与信号量隔离

| 维度 | 线程池隔离 | 信号量隔离 |
|---|---|---|
| 资源隔离程度 | 彻底：一个依赖独占一个池，排队不影响其他依赖 | 只限制并发数，调用跑在调用方线程上，仍共享调用方线程资源 |
| 额外开销 | 每次调用多一次线程调度（约 1~5 μs）与队列操作 | 一次 `acquire`/`release`，接近零开销 |
| 超时中断 | **支持**：调用方超时即可返回兜底，即使下游不支持中断 | **不支持**：只能依赖 `socket`/`RPC` 超时 |
| 适用场景 | 外部网络调用、需要兜底超时的第三方或跨机房调用 | 本地内存缓存、极快的内部调用、依赖数量很多时 |

舱壁模式（`Bulkhead`）的思想是"一个舱进水不该沉船"，落地就是每个依赖拥有独立资源池。它与熔断的时间方向不同：**舱壁是事前的**（限制同时在飞的调用数），**熔断是事后的**（观察到失败率过高才停止调用）。二者必须共存：只有舱壁时，持续失败的依赖会反复消耗配额；只有熔断时，在窗口凑满前的那段时间已足以耗尽全局线程池。

### 35.5.2 超时的层次与重试放大

```
入口总预算 3000 ms（用户可接受的极限）
 └─ 网关 2900 ms ─→ 服务 A 2500 ms ─→ 服务 B 1500 ms ─→ 服务 C 800 ms ─→ 数据库/缓存 500 ms
对齐原则：读超时 ≈ 下游 P99.9 × 1.5 且 < 上游剩余预算；连接超时按"握手 1 个 RTT + 余量"给：
   同机房 RTT 0.1~0.5 ms → 500~1000 ms；跨大洲 RTT 约 150 ms → 至少 1~2 s
重试放大：链路 3 层（A→B→C），每层失败时最多尝试 3 次（含首次）且都无条件重试
  A 试 3 次 → 3 个对 B 的请求；每个 B 请求再试 3 次 → 9 个对 C 的请求；每个 C 请求再试 3 次 → 27 个
  放大倍数 = 3³ = 27；入口 1000 QPS（下游正故障）→ 最下游承受 27000 QPS
  若"重试 3 次"指 1 次首发 + 3 次重试 = 4 次尝试，则放大为 4³ = 64 倍
```

正确的重试有四条要求：**只在最外层重试，或每跳只重试一次并带上"已重试次数"头**，超过预算即放弃；**只对可重试错误重试**（连接拒绝/超时、读超时、`503`、`429`），对 `4xx` 业务错误绝不重试；**必须幂等**（见 35.10 节）；**必须配退避与抖动**。固定间隔重试会把随机的失败**同步成一次脉冲**：1000 个客户端在第 0 毫秒同时失败，若无抖动会在第 100 毫秒一起重试，下游在刚恢复的瞬间再次被击穿。**指数退避（exponential backoff）**让第 n 次重试等待 `base × 2^n`（如 100/200/400 ms）；**抖动（jitter）**把等待随机化，常用满抖动 `sleep = random(0, min(cap, base × 2^n))`，上例中它把 1000 个请求铺到 0~100 ms 区间，瞬时峰值降到约 1/10。

> [!WARNING]
> "加了重试能提高成功率"是典型错误回答。面试官会追问："三层链路、每层重试 3 次，最下游是多少倍流量？"答不出 `3³ = 27` 就说明没把重试当成**流量放大操作**。正确表述：重试是可用性工具，但必须配重试预算（如重试量不超过总请求的 10%）与退避抖动，否则它就是故障放大器。

## 35.6 降级与兜底

降级的本质是**在容量或依赖不可用时，用"功能少一点"换"核心链路还活着"**，必须按层次设计，而不是随处 `try/catch` 返回 `null`。

| 层次 | 做法 | 代价与风险 |
|---|---|---|
| 读降级 | 返回缓存、默认值、空集合、静态兜底页 | 数据陈旧（必须设陈旧上限）；"空集合"可能被理解为"没有数据" |
| 写降级 | 同步写转异步（落 MQ 削峰，见第 33 章）、关闭非必要写 | 延迟变大，失败需要补偿 |
| 功能降级 | 关闭非核心功能：评论、点赞、个性化排序 | 用户可感知，需要明确告知 |
| 依赖降级 | 弱依赖失败即忽略（日志、埋点、推荐），强依赖失败即快速失败 | 强/弱依赖判断错误会丢掉核心数据 |

触发条件有三类：**熔断器打开**；**超时率/错误率越过阈值**（如 1 分钟内超时率 > 10%）；**人工开关**（大促预案）。"优雅降级"的三条原则：**核心链路不可降级**（下单、支付的资金流不能降级成"稍后确认"）；**每次降级必须有监控与告警**，否则降级会长期无声地停在那里；**降级开关必须能秒级生效且可灰度**，所以要放配置中心（见 35.9 节）。

> [!CAUTION]
> 把"降级成空列表"当万能兜底最危险：用户看到的"没有商品"和"系统故障"是两种体验，前者会被当成业务事实（甚至投诉"我的订单为什么不见了"）。降级要么返回有语义的兜底数据（"热门推荐"而非空白），要么明确返回可重试的错误。

## 35.7 链路追踪与日志聚合

### 35.7.1 `TraceID`/`SpanID` 的生成与传播

```
用户请求 → 入口（网关）生成 TraceID（128 bit，32 位十六进制）+ 自己的 SpanID(S1, parent=null)
  ──▶ HTTP 头 traceparent: 00-T-S1-01 → 服务 A 生成 S2(parent=S1)，不生成新 traceId
  ──▶ 调 B 时写 traceparent: 00-T-S2-01 → 服务 B 生成 S3(parent=S2)
  ──▶ 发消息时把 T 与 S3 放进消息 Header（Kafka/AMQP headers）→ 消费者生成 S4(parent=S3)
约束：跨进程只传 traceId 与上游 spanId；本进程 spanId 必须本地生成；传播格式必须全链路统一
```

进程内用线程上下文（`ThreadLocal`/`Context`）传递，**跨线程池必须显式透传**（包装 `Runnable`、可继承上下文，或用 `TransmittableThreadLocal` 这类方案），否则异步任务的 `span` 会挂到错误的父节点上——这是"链路里少了一段"的最常见原因。

### 35.7.2 `OpenTelemetry` 与 `W3C Trace Context`

`W3C Trace Context` 是标准传播格式：`traceparent: 00-<32 位十六进制 trace-id>-<16 位十六进制 parent-id>-<01 采样标志>`，另有 `tracestate` 承载厂商扩展；它的价值是跨厂商、跨语言不丢链路，而 `B3`（`X-B3-TraceId` 等私有头）需要网关做格式转换。`OpenTelemetry`（`OTel`）的定位是**统一的采集标准**：一套 API/SDK + 传输协议 `OTLP` + `Collector`（接收、处理、导出），同时覆盖 `traces`/`metrics`/`logs` 三类信号；应用只依赖 `OTel` API，替换后端（`Jaeger`/`Zipkin`/商业 `APM`）只改 `Collector` 配置。

> [!NOTE]
> 追问"采样标志位放在 `traceparent` 里意味着什么"：意味着**采样决定在链路头部一次性做出**（头部采样），下游只能继承；要做尾部采样，就必须由 `Collector` 缓冲整条链路，在导出前再决定丢弃。

### 35.7.3 采样率的取舍：头部采样与尾部采样

| 维度 | 头部采样（head-based） | 尾部采样（tail-based） |
|---|---|---|
| 决策时机与难度 | 请求入口一次性决定，全链路一致；一个概率/规则即可 | 收齐一条 `trace` 的全部 `span` 后决定；需要缓冲，且同一 `traceId` 的 `span` 必须按哈希汇聚到同一 `Collector` |
| 异常链路保真 | 低采样率下罕见错误常被整体丢掉 | 可按"有错误/超时/P99 慢"的规则**100% 保留** |
| 资源代价与适用 | 写入量线性可控，适合流量极大、只看整体分布 | 需缓冲内存与等待时间，适合要求"每条错误链路都能查"的核心系统 |

算账：1000 QPS × 每请求 10 个 `span` × 每个 `span` 约 1 KB = 10 MB/s（约 864 GB/天）；1% 头部采样后约 100 KB/s（约 8.6 GB/天），成本可接受但只能看到千分之一的异常；尾部采样若缓冲 10 秒，`Collector` 峰值内存约 `10 MB/s × 10 s = 100 MB`。

### 35.7.4 与日志聚合配合，以及调用链能回答什么

入口处用日志框架的 `MDC`（`Mapped Diagnostic Context`）写入 `traceId`，日志模板输出 `%X{traceId}`，同一请求的全部日志即可用一个 `traceId` 一次拉全。

```java
// 网关过滤器：入口生成 traceId 放进 MDC，同线程后续日志自动携带
String traceId = incoming == null ? TraceId.generate() : incoming; // 上游已有时必须沿用，否则链路断成两段
MDC.put("traceId", traceId);
try { chain.doFilter(request, response); } finally {
    MDC.remove("traceId"); // 必须清理：容器线程复用，不清理会让下个请求带上别人的 traceId
}
```

调用链恰好回答排障最需要的四个问题：**哪一跳最慢**（同一链路内 `span` 时长最长者）；**谁在重试**（同一父 `span` 下出现多个同名子 `span`）；**超时发生在哪一层**（出现未结束的 `span` 或错误标记的那一跳）；**扇出是否被放大**（一个入口 `span` 下挂了多少下游 `span`，直接对应 35.5.2 的重试放大）。

## 35.8 网关的职责边界

| 该放在网关 | 为什么 | 不该放在网关 | 为什么 |
|---|---|---|---|
| 路由与协议转换 | 与业务无关，集中一处可统一治理 | 重业务逻辑（计价、库存判断） | 网关是**全站单点放大区**，一处 bug 影响全部流量 |
| 鉴权与令牌校验 | 统一入口收口，避免每个服务重复实现 | 复杂聚合（拼装 10 个服务响应） | 聚合需要业务语义与多次失败处理，应放 BFF |
| 限流与熔断 | 入口兜底，保护后端整体容量 | 长事务、跨服务分布式事务 | 网关要求无状态、快速失败，无法承载事务生命周期 |
| 灰度分流、`WAF`、跨域、追踪入口 | 分流与安全依赖全局视角，在最外层统一做 | 重试写操作 | 网关不知道业务是否幂等，重试会造成重复写 |

**`Spring Cloud Gateway` 与 `Zuul` 的差异**在并发模型而非功能清单：`Zuul` 1.x 基于 `Servlet` 阻塞模型，一个请求占一个容器线程，数千长连接下线程成为瓶颈（每线程约 1 MB 栈，且不能无限加）；`Spring Cloud Gateway` 基于 `Reactor`/`Netty` 响应式模型，**少量事件循环线程服务大量连接**，等待下游时不占线程。代价是编程模型受限：**在响应式网关里做任何阻塞调用（`JDBC`、同步 `HTTP`、`Thread.sleep`）都会阻塞 `event loop`，从而拖死该线程上的所有连接**。

## 35.9 配置中心与灰度发布

配置中心必须同时做到**实时推送**（长轮询、`WebSocket`、`gRPC stream`，让降级与灰度开关秒级生效）与**本地快照**（拉取后落地本地文件，配置中心不可达时用快照启动）。后者是可用性优先的取舍：**配置中心是强依赖，但不该是启动阻塞点**，否则一次配置中心故障会连带全部服务无法启动，比配置暂时陈旧危险得多。配置还要有版本、审计与一键回滚。

| 灰度维度 | 实现 | 风险 |
|---|---|---|
| 按流量比例 | 网关按比例/一致性哈希抽样分流 | 纯随机比例会让同一用户在不同请求上命中不同版本，体验不一致 |
| 按用户标签 | 白名单、用户 ID 哈希、地域、设备、会员等级 | 依赖标签质量，小流量业务可能长期覆盖不到足够样本 |
| 按实例分组 | 给实例打标（如 `version=canary`），网关按标签路由 | 同组实例爆炸半径大，且新旧版本必须双向兼容 |

**蓝绿（blue-green）与金丝雀（canary）**：蓝绿是**两套完整环境**，切换瞬间完成（权重 0 → 100），回滚同样瞬间切回，代价是资源翻倍且切换瞬间是全量风险；金丝雀是**同一环境内逐步扩大比例**（1% → 5% → 50% → 100%），资源占用低、风险递增可控，代价是新旧版本长期共存，接口与数据结构必须双向兼容。

**回滚能力比发布能力更重要**：发布是计划内动作，回滚多发生在凌晨故障中，此时人的判断力最差，流程必须能在无人决策下执行；回滚要求"上一个稳定版本的镜像还在 + 数据库结构双向兼容"，而数据库变更通常不可逆，所以规范必须规定**数据库变更向前兼容**：先加列（新旧代码都容忍）→ 双写 → 迁数据 → 再删旧列，每一步都能独立回滚。

## 35.10 接口幂等性的落地

`HTTP` 方法语义是速查条目：`GET`/`HEAD` 安全且幂等；`PUT`/`DELETE` 幂等；`POST` 非幂等；`PATCH` 不保证。但**方法语义不等于实现保证**——`POST` 创建订单仍必须自己实现幂等。

| 方案 | 机制 | 强项 | 落地要点与陷阱 |
|---|---|---|---|
| 幂等键 + 唯一索引 | 请求带幂等键，落库时插去重表，唯一键冲突即"已处理" | 与业务在同一本地事务内，最可靠 | **幂等键必须由业务单据号构成**，不能用随机串 |
| `Token` 机制 | 先申请一次性 `Token`，提交时校验并删除 | 防重复提交、防重放 | 校验与删除必须原子完成（命令细节见第 27 章） |
| 乐观锁版本号 | `UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?` | 解决并发覆盖（丢失更新） | 影响行数为 0 时是"被别人改过"还是"自己重复提交"，语义要区分 |
| 状态机 + 分布式锁 | 状态只允许单向迁移，`UPDATE ... WHERE status = '待支付'` 返回 0 行即已处理 | 天然幂等 + 顺序保证 | 锁只用于串行化迁移（实现细节见第 27 章） |

**幂等键的设计是最高频追问点**：用业务单据号（订单号、支付流水号、外部请求号）组成键（如 `支付:{订单号}:{渠道}`），重试时键自然相同，第二次被唯一索引拦下；用随机串（如 `UUID`）则客户端超时重发会生成新串，去重表拦不住，**幂等性直接失效**。核心原因：**幂等键必须在客户端重试时可复现**。

```java
// 幂等落地：唯一索引 + 状态机，两条防线各解决一类重复
public PaymentResult pay(String orderNo, String channel, String idemKey) {
    try {
        idempotentMapper.insert(new IdempotentRecord(idemKey, "PROCESSING")); // 幂等键用业务单据号组合
    } catch (DuplicateKeyException e) {
        return loadPreviousResult(idemKey); // 不是第一次请求：读回上次结果，绝不重复扣款
    }
    // 状态机单向迁移：影响行数为 0 说明已被并发请求推进，直接返回幂等结果而不是抛错
    if (orderMapper.updateStatus(orderNo, Status.WAIT_PAY, Status.PAID) == 0) return loadPreviousResult(idemKey);
    doChannelPay(orderNo, channel);
    return PaymentResult.success(orderNo);
}
```

**"幂等"与"并发控制"的区别**：幂等解决"**同一个请求**重复到达"（时间上先后多次、内容相同，结果必须与只执行一次相同，判据是"重复"）；并发控制解决"**不同请求**同时修改同一资源"（如并发加购不能丢失更新，判据是"并发"）。二者互不蕴含：`SET stock = 10` 幂等但并发下丢更新（要并发控制）；`stock = stock - 1` 无并发问题时仍会被重复提交多扣（要幂等）。带绝对版本号的乐观锁**同时**提供并发控制与"重复提交被拒"的效果，因此常被误认为幂等方案——它其实是靠"版本不匹配"拒绝了第二次提交，前提是客户端重发时携带同一个版本号。

## 35.11 微服务的调试与排障

跨服务问题的定位流程（不要一上手就翻单机日志）：① 从用户反馈的请求拿到入口 `traceId`，在日志聚合里拉取该链路全部日志并按时间排序；② 在链路追踪里按 `span` 时长找最慢的一跳，并检查是否出现同名 `span` 的多次出现（说明发生重试）；③ 判断是单实例问题还是整体问题，把同一接口按实例维度看错误率与 P99，集中在某实例就去查该实例的 CPU/GC/连接池/磁盘；④ 检查是否被资源池限制（`HikariCP` 等待、线程池队列堆积、下游限流 `429`）；⑤ 对比失败与成功请求的差异维度（用户、地域、实例、依赖版本），这一步经常直接暴露灰度引入的回归。

**时间同步（`NTP`）为什么是前提**：`span` 时长由各机器自己的时钟算出，若两台机器偏差超过调用跨度，就会出现"子 `span` 在父 `span` 之前结束"这类不可能的时序，把责任定位到错误的一跳，日志聚合按时间排序也会错乱。经验判据是**偏差超过 100 ms 时跨机时序结论不可信**；容器环境要注意看宿主机时钟而非容器内时钟。

```bash
# 按 traceId 拉全链路日志（已由 MDC 写入）；时钟偏差超过 100 ms 时 span 时序结论作废
grep -h "<traceId>" /var/log/app/*.log | sort -k1,2; chronyc tracking | grep -E 'System time|Last offset'
grep -c "call=inventory.deduct" trace-export.jsonl   # 同名 span 出现多次，说明该跳在重试
```

**本地难以复现时的三件套**：**影子流量（shadow traffic）**——在生产入口按比例复制流量到新版本，读路径直接对比返回差异，写路径必须隔离（打标不落真实库或写影子表），否则污染生产数据；**链路重放（replay）**——记录入口请求与下游响应，本地按录制回放依赖，把"偶发"变成"必现"，这是解决时序类 bug 最有效的手段；**只读降级演练**——把写操作临时降级为只读沙箱，在生产流量下验证读路径与新版本逻辑。

> [!TIP]
> 收尾话术：把微服务治理归纳成三条链——**发现链**（注册发现 + 健康检查 + 负载均衡）、**保护链**（限流 + 熔断 + 隔离 + 超时重试预算 + 降级）、**观测链**（追踪 + 日志 + 指标 + 灰度对照）。任何治理问题先归到其中一条链，再往下说机制。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 拆分时机 | 由团队规模、部署冲突频率、瓶颈独立性三个可观测判据触发，先有稳定模块边界 | 认为"越小越好"，把 3 人项目拆成十几个服务 |
| 注册中心取舍 | `AP` 怕打到坏实例，`CP` 怕拿不到任何实例；服务发现偏 `AP` | 认为注册中心必须强一致才"正确" |
| 自我保护 | 分区时无法区分"进程死"与"网络断"，宁可保留过期实例也不清空列表 | 因"误报"关闭自我保护 |
| 负载均衡与四七层 | 实例同构用轮询、负载漂移用最少连接；`L4` 只转发，`L7` 能路由、卸载 `TLS`、复用连接但吞吐更低 | 认为 `L7` 只是 `L4` 的升级版，或轮询最公平 |
| 熔断与限流 | 限流保护自己，熔断保护调用方并给下游恢复时间；熔断是三态状态机且同时看失败率与 RT | 认为熔断就是"失败几次就拒绝"，或用一个替代另一个 |
| 幂等 | 幂等键用业务单据号（重试可复现），唯一索引兜底；幂等不等于并发控制 | 用随机 `UUID` 当幂等键 |

## 自测题

**Q1**（计算）链路为 网关 → 订单服务 → 库存服务 → 数据库，共 3 层跨进程调用，每层失败时最多尝试 3 次（含首次），入口 QPS 为 2000。求最坏情况下数据库承受的请求量，并说明为什么这个参数必须配重试预算。

<details><summary>答案与解析</summary>

放大倍数 `= 3 × 3 × 3 = 27`，数据库最坏承受 `2000 × 27 = 54000 QPS`。**放大是相乘的**，每一跳都在上一跳已放大的基数上再乘，链路层数决定指数；**放大与故障同时发生**，数据库正因过载而超时，重试把压力变成 27 倍常态，"短暂抖动"于是升级为"持续不可用"；**重试预算是解药**，如限定"重试量不超过总请求的 10%"，一旦窗口内重试占比越线就立刻停止重试。
常见错误：算成 `3 + 3 + 3 = 9` 或只算一层。面试官会追问："如果每层是 1 次首发 + 2 次重试，答案变吗？"（不变，总尝试数仍是 3）；"如果每层重试 3 次、共 4 次尝试呢？"答案是 `4³ = 64` 倍——考的是有没有把"重试次数"与"总尝试次数"分开定义。

</details>

**Q2**（辨析）"熔断和限流都是流量过大时保护系统，实现一个就够了。"请指出错在哪，并分别给出一个只有限流会失效、只有熔断会失效的场景。

<details><summary>答案与解析</summary>

错误在于**保护对象不同**：限流保护自己（依据是与结果无关的输入量：QPS、并发数），熔断保护调用方并把流量从故障下游撤离（依据是调用结果：失败率、慢调用比例），同时给下游留恢复时间。
**只有限流会失效**：下游整体故障（如库存服务数据库宕机，请求 1 s 后全部超时）。限流配额没被突破，请求照常打过去，调用方线程耗在等待上，最终自己的线程池/连接池被耗光——故障从下游传染到上游，即级联失败。**只有熔断会失效**：下游健康但容量有限（只能接 1000 QPS，上游给了 5000 QPS），没有错误、没有慢调用，熔断永不触发，下游被压死，必须靠限流。加分点：二者互补，再叠加舱壁与降级才构成完整保护链。

</details>

**Q3**（推导）`Eureka` 的自我保护触发后不再剔除续约过期的实例。请说明它在什么条件下触发、为什么是有意设计，以及"保留过期实例"的代价由谁承担。

<details><summary>答案与解析</summary>

触发条件：服务器比较"上一分钟实际收到的续约数"与阈值（注册实例数 × 每分钟续约次数 × 0.85）；实际值低于阈值的 0.85 倍即触发，此后不再剔除过期实例，只记录告警。
设计意图：分区时 `Eureka` **无法区分**"客户端进程真死了"与"客户端与注册中心之间的网络断了"。若按 TTL 大批剔除，客户端拿到的列表会迅速变空，服务端发现模式下调用方会判定"无可用实例"从而整体拒绝流量。**用"部分请求打到死实例"换"整体可用"**，与它选择 `AP` 而非 `CP` 的定位一致。代价由**客户端**承担：连接超时、重试、熔断是消化坏实例的机制，因此客户端侧配置不是可选项，而是该设计的必要组成部分。
常见错误：答"自我保护是误报，生产上应关掉"。面试官会追问："关掉之后一次网络抖动会发生什么？"——大批实例被摘除，恢复期列表不全、错误率飙升，抖动结束后实例重新注册又有延迟，故障时间被显著放大。

</details>

**Q4**（设计）一个下单接口需要幂等。请给出至少两种方案，说明各自防住哪类重复，并辨析"幂等"与"并发控制"。

<details><summary>答案与解析</summary>

**方案一：幂等键 + 唯一索引去重表。** 幂等键由业务单据号构成（如 `下单:{客户端请求号}`、`支付:{订单号}:{渠道}`）；第一次请求插去重表成功并执行业务，重复请求因唯一键冲突直接返回上次结果。它防住"同一业务请求的重复到达"（客户端超时重发、MQ 重复投递、网关重试）。要点：幂等键必须在客户端重试时可复现，所以**用业务单据号而非随机 `UUID`**，否则每次重发都是新键，去重表形同不存在。
**方案二：状态机单向迁移。** `UPDATE orders SET status = '已支付' WHERE order_no = ? AND status = '待支付'`，影响行数为 0 即表示已被处理或状态不允许，直接返回既有结果。它防住"同一状态迁移被执行多次"，并天然保证顺序合法性。
辨析：**幂等**处理"同一请求重复到达"，**并发控制**处理"不同请求同时修改同一资源"。`SET stock = 10` 幂等但并发下丢更新（要并发控制）；`stock = stock - 1` 无并发问题时仍会被重复提交多扣（要幂等）。乐观锁版本号常被误当成幂等方案，它实际是并发控制，靠版本不匹配拒绝第二次提交，前提是客户端重发携带同一版本号。追问方向：幂等记录的过期时间必须大于最大重试窗口；要求强一致时幂等记录不能与业务数据分库。

</details>

**Q5**（判断）"服务端发现（如 `Kubernetes` Service）对客户端完全透明，所以客户端不再需要任何负载均衡与重试逻辑。"判断正误并说明理由。

<details><summary>答案与解析</summary>

错误。服务端发现只解决了**地址解析**（把"虚拟地址 → 实例"的复杂度收进基础设施），没有解决**调用语义与容错**：① **实例摘除有延迟**——从 `readinessProbe` 失败到 `Endpoints` 更新、转发规则生效、调用方连接池淘汰旧连接，中间存在秒级窗口，这期间请求仍可能落到正在关闭的实例；② **转发面不理解业务健康**——`kube-proxy` 不会因为某实例线程池耗尽而绕过它；③ **局部失败仍会发生**——节点故障导致的半开连接靠 `TCP` 自身重传退出很慢，必须有应用层超时与重试（且要幂等、退避、抖动）；④ **`DNAT` 破坏可观测性**——后端看到的源地址与真实调用方不同，需要 `X-Forwarded-*` 头或追踪链路还原。
正确表述：服务端发现降低了客户端的**接入**成本，但没有免除客户端的**容错**责任；基础设施管"地址的正确性"，应用管"调用的可靠性"。常见错误：把 `Kubernetes` Service 与 `Eureka` 当作可替换的同类产品，看不到它们对客户端代码的要求完全不同。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| "你们为什么把单体拆成微服务？拆完付出了什么代价？" | 双向论证与拆分时机 | 错误回答："为了解耦、独立部署，微服务是趋势"——只有收益没有代价。加分点：给出团队规模/部署冲突/瓶颈独立三个判据和一次过早拆分的反例与代价 | 35.1 |
| "服务注册发现用客户端发现还是服务端发现？为什么 `Eureka` 是 `AP` 而 `ZooKeeper` 是 `CP`？" | 发现模式与 `CAP` 取舍 | 错误回答："`ZooKeeper` 更可靠所以选它"——说不出分区时 `CP` 会让注册中心整体拒绝服务。加分点：指出服务发现更怕"拿不到实例列表" | 35.2 |
| "`Eureka` 的自我保护是 bug 吗？生产上要不要关掉？" | 健康检查与可用性取舍 | 错误回答："是误报，应该关掉以保证准确性"——说不出关闭后网络抖动会放大成全站故障。加分点：说明 0.85 阈值含义与"代价由客户端重试/熔断承担" | 35.2.4 |
| "四层和七层负载均衡有什么区别？`Envoy` 和 `Nginx` 怎么选？" | 协议层次与能力边界 | 错误回答："七层是四层的升级版，性能更好"——反了，`L7` 解析开销更大。加分点：说清 `TLS` 卸载位置、连接是否复用、超时是连接级还是请求级 | 35.3.3 |
| "熔断器有哪几个状态？失败率阈值设多少合适？" | 三态迁移与参数语义 | 错误回答："失败超过 3 次就熔断，等 5 秒再试"——缺少滑动窗口、最小样本量、慢调用比例与受控探测。加分点：画出三态迁移图并强调同时看 RT | 35.4 |
| "熔断和限流有什么区别？只做限流行不行？" | 保护对象辨析 | 错误回答："都是保护系统，做一个就行"——说不出只有限流时下游故障会经线程等待把上游拖死。加分点：给出两个"只有一种就失效"的场景 | 35.4.3 |
| "`Hystrix` 为什么用线程池隔离？它为什么被淘汰？" | 隔离策略与工程取舍 | 错误回答："线程池隔离能提高性能"——恰好相反，它多出约 1~5 μs 上下文切换。加分点：说清它买到的是"调用方可以超时脱身"，代价是每依赖一个池导致线程膨胀 | 35.4.4 |
| "超时应该怎么设？重试几次合适？" | 超时预算与重试放大 | 错误回答："设 3 秒、重试 3 次，成功率更高"——算不出 27 倍放大，也没有逐跳递减的预算。加分点：给预算表、只对可重试错误重试、退避抖动、重试预算封顶 | 35.5.2 |
| "线上一个接口偶尔超时，你怎么定位？" | 排障流程与观测体系 | 错误回答："先看日志 / 先重启试试"——缺少从入口 `traceId` 拉全链路、按实例维度对比、检查资源池这三步。加分点：主动提 `NTP` 偏差超过 100 ms 时跨机时序不可信 | 35.11 |
| "网关应该承担哪些职责？业务聚合能放网关吗？" | 职责边界 | 错误回答："鉴权、限流、熔断、聚合、事务都放网关"——忽略网关是全站单点放大区。加分点：聚合放 BFF、事务留在服务内，响应式网关里不能有阻塞调用 | 35.8 |
| "灰度发布怎么做？蓝绿和金丝雀的区别是什么？" | 灰度维度与回滚能力 | 错误回答："灰度就是按 1%、10%、100% 放量"——只有流量比例一个维度，也没有回滚设计。加分点：三个维度对比 + 数据库变更向前兼容 + 回滚可无人决策执行 | 35.9 |
| "下单接口怎么保证幂等？幂等键用什么？" | 幂等落地与键设计 | 错误回答："用 `UUID` 当请求号，存在 `Redis` 里判重"——客户端重发会生成新 `UUID`，判重失效。加分点：用业务单据号（重试可复现）+ 唯一索引，并辨析幂等与并发控制 | 35.10 |

## 延伸阅读

- `W3C` Recommendation：`Trace Context`（`traceparent`/`tracestate` 字段定义与采样标志位语义）。
- `OpenTelemetry` 官方文档：`Specification` 中的 `Tracing API`、`Context` 传播与 `Sampling` 章节，以及 `OTLP` 协议与 `Collector` 的 `tail_sampling` 处理器。
- `Resilience4j` 官方文档：`CircuitBreaker`（滑动窗口类型、阈值、`waitDurationInOpenState`、`permittedNumberOfCallsInHalfOpenState`）与 `Bulkhead`/`Retry`/`TimeLimiter` 模块。
- `Sentinel` 官方文档：滑动窗口统计实现（`LeapArray`）、流控与降级规则；`Netflix Hystrix` 官方 wiki：`Configuration` 页（线程池核心数、`requestVolumeThreshold`、`sleepWindowInMilliseconds`）与维护模式说明。
- `Kubernetes` 官方文档：`Service` 与 `kube-proxy` 的 `iptables`/`IPVS` 模式、`Ingress`/`Gateway API`、三种探针语义。
- `Spring Cloud Gateway` 官方文档：`Reactor`/`Netty` 模型与 `Zuul` 1.x 的比较、`GatewayFilter` 与限流集成。
- 配套章节：第 34 章（`CAP`、一致性哈希、限流算法、分布式锁理论）、第 27 章（`Redis` 命令与锁实现细节）、第 33 章（消息队列视角的幂等与削峰）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch35/  

