拆分把进程内约 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.10.5 ms,相差 10³10⁴ 倍,还额外承担序列化与"对端可能不响应"的不确定性;可用性按乘法衰减,5 个可用性 99.99% 的服务串联后约 0.9999⁵ ≈ 99.95%,年停机从约 53 分钟涨到约 4.4 小时。

重要

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

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

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

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

警告

“越细越好、越小越好,符合单一职责"是错误回答。面试官会追问:“12 个服务的超时预算怎么分?跨服务分页怎么做?“正确顺序是先有稳定模块边界,再按边界拆服务;边界未稳就拆,得到的是"分布式大泥球”(distributed monolith)——强耦合且仍必须一起发布。

35.1.3 模块化单体作为中间态

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

35.2 服务注册与发现

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

1
2
3
4
客户端发现(Eureka + Ribbon):调用方拉取/订阅实例列表 → 自己按 LB 策略选实例 → 直连调用(少一跳,每种语言一套 SDK)
服务端发现(Kubernetes Service + kube-proxy/Ingress):调用方只访问虚拟地址 orders.svc:8080
   → 节点内核按 iptables/IPVS 规则 DNAT 到真实 Pod(Endpoints 由 apiserver 维护,客户端零改造)
   代价是多一跳或一次 DNAT,虚拟 IP 让抓包与可观测性变差
对比项客户端发现服务端发现
代表实现Eureka+Ribbon、Nacos+客户端 SDKKubernetes Service+kube-proxy、Ingress/Gateway、Consul+Envoy
客户端要求需要注册中心 SDK、健康感知与 LB 算法只需一个稳定地址,任何语言零改造
变更延迟客户端缓存列表,实例变化有秒级延迟控制面推送 Endpoints,延迟取决于探针周期与同步

注意

被追问"服务端发现看不到真实实例,链路怎么还原?“时,答案是发现层与追踪层无关:TraceID 靠请求头传播,与 IP 是虚拟还是真实无关,只需在 span 上额外记录虚拟服务名与真实 Pod 标识(如 k8s.pod.name)。

35.2.2 注册中心的 AP 与 CP 取舍

注册中心模型实现基础分区时的行为适合的场景
EurekaAP节点对等复制,无 leader各节点继续接受注册与查询,可能返回互不一致的列表只做服务发现
ZooKeeperCPZAB,多数派写入少数派一侧整体不可用(会话超时后临时节点被删)选主、命名服务、元数据
etcdCPRaft(见第 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 大批剔除,客户端在恢复前拿到的列表会迅速变空,服务端发现模式下调用方直接判定"无可用实例”,全站失败。保留过期实例的代价是部分请求打到确实已死的实例,由客户端的连接超时、重试与熔断兜底——用少量请求失败换整体可用。

小心

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

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

35.3.1 七种策略与判据

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

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

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

1
2
3
4
5
6
三台单核实例: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 三态与迁移条件

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

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

重要

熔断器不等于"失败就拒绝"。核心是在统计窗口内用失败率/慢调用比例判断下游是否整体不可用,并且必须有 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过大等于立刻恢复全量,过小则统计不显著

提示

被问"阈值怎么定"时不要只报数字,要报方法:阈值从下游容量反推(下游只能接 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 的机制差异

维度HystrixSentinel
隔离实现默认线程池隔离,每个依赖一个独立池(默认核心 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 超时的层次与重试放大

1
2
3
4
5
6
7
8
入口总预算 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。

警告

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

35.6 降级与兜底

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

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

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

小心

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

35.7 链路追踪与日志聚合

35.7.1 TraceID/SpanID 的生成与传播

1
2
3
4
5
用户请求 → 入口(网关)生成 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 配置。

注意

追问"采样标志位放在 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 一次拉全。

1
2
3
4
5
6
// 网关过滤器:入口生成 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)则客户端超时重发会生成新串,去重表拦不住,幂等性直接失效。核心原因:幂等键必须在客户端重试时可复现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 幂等落地:唯一索引 + 状态机,两条防线各解决一类重复
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 时跨机时序结论不可信;容器环境要注意看宿主机时钟而非容器内时钟。

1
2
3
# 按 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 最有效的手段;只读降级演练——把写操作临时降级为只读沙箱,在生产流量下验证读路径与新版本逻辑。

提示

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

本章小结

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

自测题

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

答案与解析

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

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

答案与解析

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

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

答案与解析

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

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

答案与解析

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

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

答案与解析

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

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
“你们为什么把单体拆成微服务?拆完付出了什么代价?”双向论证与拆分时机错误回答:“为了解耦、独立部署,微服务是趋势”——只有收益没有代价。加分点:给出团队规模/部署冲突/瓶颈独立三个判据和一次过早拆分的反例与代价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 秒再试”——缺少滑动窗口、最小样本量、慢调用比例与受控探测。加分点:画出三态迁移图并强调同时看 RT35.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 章(消息队列视角的幂等与削峰)。