第 35 章 微服务架构与治理
拆分把进程内约 100 ns 的调用换成了同机房 0.1~0.5 ms 的网络调用;本章所有治理手段,都是在替这笔三个数量级的账还债。
学习目标
读完本章,你应当能够:
- 用团队规模、部署冲突频率、瓶颈独立性三个可观测判据判断拆分时机,给出微服务与单体的双向论证与"过早拆分"的反例;
- 画出客户端发现与服务端发现两条路径,说明注册中心
AP/CP取舍、健康检查的两类误判与Eureka自我保护的触发条件; - 列出七种负载均衡策略的判据,用算例证明最少连接数为什么常优于轮询,说清四层与七层的差异,并画出熔断器三态迁移图、给出六组参数的语义;
- 辨析熔断与限流、
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 拆分时机:三个可观测判据
- 团队规模:同一代码库上并行开发超过约 8~10 人(两个披萨团队量级)时,评审、合并、发版排期的协调成本超过开发成本本身,此时按业务能力(business capability)切出端到端拥有制的服务;
- 部署冲突频率:统计"我改了 A 模块却与 B 模块互相阻塞发布、必须一起回归"的次数,高到需要排队发版就是硬信号;
- 瓶颈独立性:只有订单导出需要 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 客户端发现与服务端发现
| |
| 对比项 | 客户端发现 | 服务端发现 |
|---|---|---|
| 代表实现 | Eureka+Ribbon、Nacos+客户端 SDK | Kubernetes Service+kube-proxy、Ingress/Gateway、Consul+Envoy |
| 客户端要求 | 需要注册中心 SDK、健康感知与 LB 算法 | 只需一个稳定地址,任何语言零改造 |
| 变更延迟 | 客户端缓存列表,实例变化有秒级延迟 | 控制面推送 Endpoints,延迟取决于探针周期与同步 |
注意
被追问"服务端发现看不到真实实例,链路怎么还原?“时,答案是发现层与追踪层无关: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 大批剔除,客户端在恢复前拿到的列表会迅速变空,服务端发现模式下调用方直接判定"无可用实例”,全站失败。保留过期实例的代价是部分请求打到确实已死的实例,由客户端的连接超时、重试与熔断兜底——用少量请求失败换整体可用。
小心
关闭 enable-self-preservation 或把 renewalPercentThreshold 调到极低不是"提高准确性",而是把一次可恢复的网络抖动放大成全站故障。答"它就是防止误杀、缺陷是保留坏实例"只说了一半,必须补上"删与不删的代价各由谁承担"。
35.3 负载均衡策略与四七层差异
35.3.1 七种策略与判据
| 策略 | 决策依据 | 失效场景 | 典型位置 |
|---|---|---|---|
| 轮询(round robin) | 序号递增 | 实例性能不均则慢实例被压垮 | 客户端 SDK、四层 |
| 加权轮询 | 静态权重 | 权重固定,实际负载漂移后不调整 | 客户端 SDK、七层 |
| 随机 | 伪随机数 | 小样本下短周期分布不均 | 客户端 SDK |
| 加权随机 | 权重 + 随机 | 同随机,大流量下等价于加权轮询 | 客户端 SDK |
| 最少连接数 | 当前在途请求数最少 | 需准确统计在途数;共同依赖变慢时无效 | LVS lc、七层 |
| 最短响应时间 | 在途数 × 平均响应时间 | 指标是滑动平均,对慢实例滞后 | Envoy least_request 变体 |
| 一致性哈希 | 请求键哈希到环 | 热点键导致倾斜,需虚拟节点 | 客户端 SDK、七层 |
选型判据:实例同构用轮询/随机;预知异构用加权轮询;负载漂移且请求耗时长用最少连接;需要会话或本地缓存亲和用一致性哈希。
35.3.2 “最少连接"为什么常优于轮询
| |
不要把它说成万能:收敛需要时间(慢实例要先堆积出差异),请求耗时极短且均匀时与轮询无差别;当所有实例因共同依赖(如数据库)同时变慢时它也无能为力,那属于熔断与限流的职责。
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 的拒绝时长是"给下游留的恢复时间”(太短会反复打满未恢复的下游,太长则流量回不来);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 的机制差异
| 维度 | 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 超时的层次与重试放大
| |
正确的重试有四条要求:只在最外层重试,或每跳只重试一次并带上"已重试次数"头,超过预算即放弃;只对可重试错误重试(连接拒绝/超时、读超时、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 的生成与传播
| |
进程内用线程上下文(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 一次拉全。
| |
调用链恰好回答排障最需要的四个问题:哪一跳最慢(同一链路内 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)则客户端超时重发会生成新串,去重表拦不住,幂等性直接失效。核心原因:幂等键必须在客户端重试时可复现。
| |
“幂等"与"并发控制"的区别:幂等解决”同一个请求重复到达"(时间上先后多次、内容相同,结果必须与只执行一次相同,判据是"重复");并发控制解决"不同请求同时修改同一资源"(如并发加购不能丢失更新,判据是"并发")。二者互不蕴含:SET stock = 10 幂等但并发下丢更新(要并发控制);stock = stock - 1 无并发问题时仍会被重复提交多扣(要幂等)。带绝对版本号的乐观锁同时提供并发控制与"重复提交被拒"的效果,因此常被误认为幂等方案——它其实是靠"版本不匹配"拒绝了第二次提交,前提是客户端重发时携带同一个版本号。
35.11 微服务的调试与排障
跨服务问题的定位流程(不要一上手就翻单机日志):① 从用户反馈的请求拿到入口 traceId,在日志聚合里拉取该链路全部日志并按时间排序;② 在链路追踪里按 span 时长找最慢的一跳,并检查是否出现同名 span 的多次出现(说明发生重试);③ 判断是单实例问题还是整体问题,把同一接口按实例维度看错误率与 P99,集中在某实例就去查该实例的 CPU/GC/连接池/磁盘;④ 检查是否被资源池限制(HikariCP 等待、线程池队列堆积、下游限流 429);⑤ 对比失败与成功请求的差异维度(用户、地域、实例、依赖版本),这一步经常直接暴露灰度引入的回归。
时间同步(NTP)为什么是前提:span 时长由各机器自己的时钟算出,若两台机器偏差超过调用跨度,就会出现"子 span 在父 span 之前结束"这类不可能的时序,把责任定位到错误的一跳,日志聚合按时间排序也会错乱。经验判据是偏差超过 100 ms 时跨机时序结论不可信;容器环境要注意看宿主机时钟而非容器内时钟。
| |
本地难以复现时的三件套:影子流量(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 秒再试”——缺少滑动窗口、最小样本量、慢调用比例与受控探测。加分点:画出三态迁移图并强调同时看 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 |
延伸阅读
W3CRecommendation: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模型与Zuul1.x 的比较、GatewayFilter与限流集成。- 配套章节:第 34 章(
CAP、一致性哈希、限流算法、分布式锁理论)、第 27 章(Redis命令与锁实现细节)、第 33 章(消息队列视角的幂等与削峰)。