CHAPTER 10 / MICROSERVICE & CLOUD NATIVE

第 10 篇 · 微服务与云原生

本篇从发现/配置与治理走到 K8s/Mesh、SLO 与平台,再用 Spring Cloud 调用链把生产坑钉死:同步有预算,发布用错误预算说话。

骨架见 M09 / M06。本篇只补领域细枝,框在 核心方法论 · 开篇。

题目数:30 | 高级 ×9 | 架构 ×18 | 专家 ×3 | 预计复习:306 分钟
核心知识链

微服务/云原生主线是「发现与配置 → 入口与流量治理 → 运行时(K8s/Mesh)→ 发布/SLO/安全/多租户 → Spring Cloud 调用链深潜 → 平台与值班闭环」。

  1. 发现、配置与推送范式:注册中心 CAP 取舍、配置热更新、通知+二次拉取 / Watch / Pull 全景与本地快照兜底(C10.01/08/27/30)。
  2. 网关、限流熔断与超时预算:横切下沉、令牌桶/漏桶、舱壁;客户端→网关→下游超时拆解,防上游先断下游仍占资源(C10.02/03/29)。
  3. 云原生运行时与演进:容器/JVM 感知、探针与资源、Mesh 治理下沉、单体→微服务节奏、高可用与 Serverless 边界(C10.04/05/06/07/10)。
  4. 发布、一致、安全与可观测:灰度/蓝绿/GitOps、跨服务最终一致、零信任、多租户隔离、三支柱、CQRS/ES、错误预算驱动变更(C10.09/11–17)。
  5. Spring Cloud 深度(C10.18–25):@LoadBalanced 拦截链路、OpenFeign 动态代理+熔断、Gateway Filter/DirectBuffer、Bootstrap/@RefreshScope、Sleuth→Micrometer Tracing、Commons 抽象换注册中心、七大生产坑、Dubbo vs SC 选型。
  6. 热刷新规范、IDP 与值班:@RefreshScope vs @ConfigurationProperties、Golden Path/自助交付、配置刷新安全回滚(C10.26/28/30)。
工程化要点
  • 同步链路深度/超时/重试预算写入服务契约并可审批;网关与下游超时必须端到端可加总,防重试风暴。
  • 限流、熔断、舱壁与热点参数统一治理;配置变更可审批、可回滚,参数有依据与值班手册。
  • 金丝雀/灰度与自动熔断联动错误预算;SLO 燃尽则收缩发布,而不是只看功能清单。
  • K8s 探针、资源与 JVM/应用容量同一模型;配置中心抖动用本地快照兜底,刷新有「行为未变/不可退」核对清单。
  • Spring Cloud 变更按生产变更管控:LoadBalancer/Feign/Gateway/RefreshScope/Tracing 升级带金丝雀与回滚;常见坑(文件上传、WebClient 冲突、上下文丢失等)进回归用例。
  • 全链路 Trace(B3/W3C)、日志、指标默认接入;Boot 2→3 追踪迁移单独验收采样与 MDC 透传。
  • 平台 Golden Path(脚手架、流水线、可观测、权限)优先于工单式交付;例外可审批并登记。
  • 零信任与多租户隔离写入上线门禁;Mesh/网关策略变更与代码发布同级管控并可演练回滚。
本篇思维模式
  • 调用有预算:深度、超时、重试上限写进契约,防级联放大。
  • 错误预算发布:SLO 燃尽则收缩变更,功能完成度不能替代可靠性。
  • 面向 Commons 抽象:业务绑 Discovery/LoadBalancer 接口,注册中心可替换。
  • Gateway 非 Servlet:EventLoop 上 ThreadLocal 失效,上下文与堆外释放走 Reactor 语义。
  • 刷新是双刃剑:热更新能秒级改行为,也能秒级制造事故;能 rebind 就不要滥用 @RefreshScope。
  • 平台降认知负荷:Golden Path 优先,私建流水线与可观测栈是例外。
  • 边界大于框架:先谈数据归属与故障域,再谈 Spring Cloud 或 Dubbo。
C10.01
微服务架构的服务注册发现与配置中心如何选型?高级P0
本质问题

这道题考察对服务治理"地址发现"机制的 CAP 取舍理解,以及注册中心与配置中心"为何常合二为一又各有侧重"的工程逻辑。核心不是罗列组件,而是回答:注册中心为何优先选 AP 而非 CP?配置中心的推送机制如何权衡实时性与压力?选型时如何匹配团队技术栈与规模。

演进脉络

起源:硬编码 IP → DNS + 负载均衡(LVS/Nginx)。早期单体与集群用 DNS 解析 + 反向代理做流量分发,变更需人工改配置,无法支撑服务的动态上下线。

ZooKeeper(2008,Yahoo→Apache):通用分布式协调服务,基于 ZAB 共识(CP)。用临时顺序节点实现服务注册(客户端断开会话则节点消失)。Kafka 早期、Dubbo 2.x 默认用 ZK 做注册中心。问题:注册中心场景下"可用性比强一致更重要"——Leader 选举期间集群不可写,注册中心不可用会引发服务发现雪崩。

Eureka(2012,Netflix):专为服务注册设计的 AP 系统。去中心化对等节点,Peer-to-Peer 复制,允许分区内各节点数据不一致;客户端 30s 心跳 + 自我保护模式。Spring Cloud Netflix 1.x 时代的事实标准。(Eureka 2.x 开发中止,Spring Cloud Netflix 时代已过,当前推荐 Nacos 作为注册发现与配置中心)Eureka 1.x 作为演进历史对比保留。

etcd(2013,CoreOS)/ Consul(2014,HashiCorp):均 CP(Raft)。etcd 是 K8s 的元数据存储底座,通用 KV,可做注册中心但非其设计目标;Consul 集服务发现 + KV 配置 + 健康检查 + 多数据中心于一体,功能丰富但生态偏运维侧。

Nacos(2018,阿里):同时支持 AP(默认,Distro 协议)与 CP(Raft 协议)按需切换,临时实例走 AP、持久实例走 CP;并将注册中心与配置中心合二为一。Spring Cloud Alibaba 标配,国产化主流。

趋势:Service Mesh 时代,注册发现下沉到 Sidecar。现代 Istio 以 Istiod(一体化控制面;文献中的 Pilot/Citadel/Galley 为历史组件拆分叙事)从 K8s API Server、Consul、Nacos 等多种来源订阅服务端点,经 xDS 协议下发到 Envoy,应用代码完全无感知。注册发现从"应用 SDK 职责"演变为"平台能力"。

第一性原理

注册中心为何选 AP 而非 CP:服务发现允许短暂不一致(多返回一个已下线实例,调用失败重试即可,代价可控);但绝不能不可用(注册中心宕机 → 新请求无法寻址 → 雪崩)。AP 的代价是"可能拿到旧实例列表",通过客户端心跳 + 健康检查 + 调用重试容错,工程上可接受。这正是 Eureka/Nacos-AP 的设计动机。

Eureka 心跳与自我保护:客户端 30s 发一次心跳续约,90s 未续约则剔除;自我保护模式——当 15 分钟内心跳失败比例 > 85% 时,Eureka 认为可能发生网络分区,停止剔除实例(宁可保留过期实例也不误删),待网络恢复后再清理。这是 AP 优先可用性的体现。

Nacos AP(Distro)vs CP(Raft):

  • Distro(AP):各 Server 节点对等,每个节点负责一部分数据(按 hash 分片),处理本地写后异步同步给其他节点。临时实例(客户端心跳维持)走 Distro,高可用优先。
  • Raft(CP):多数派写一致。持久实例(服务端主动探测)走 Raft,强一致优先。Leader 选举期间不可写。

配置中心本质:集中式配置存储 + 变更推送(长轮询 / Watch)+ 版本/灰度/权限。推送机制是核心:

组件 推送机制 实时性 说明
Nacos 长轮询(服务端 hold ~29.5s) 秒级 变更立即返回,否则超时重发,压力可控
Apollo 长轮询 + 客户端定时拉取兜底 秒级 旁路部署,配置与注册分离
ZooKeeper Watch(服务端推送) 毫秒级 需处理连接断开重连与 Watch 一次性问题
Consul 长轮询 Watch(blocking query) 秒级 基于 index 的阻塞查询
Spring Cloud Config 需配合 Bus(MQ 广播)刷新 分钟级 Git 后端,无原生推送
flowchart TD P["Provider 服务提供者"] -->|"1、注册/心跳"| RC["注册中心集群"] C["Consumer 服务消费者"] -->|"2、拉取实例列表"| RC RC -.->|"3、推送变更(长轮询/Watch)"| C C -->|"4、软负载 + 重试"| P subgraph RC1["注册中心 CAP 取舍"] AP["AP: Eureka / Nacos-Distro
允许短暂不一致,可用性优先"] CP["CP: ZK / etcd / Consul / Nacos-Raft
强一致,Leader选举期间不可写"] end style AP fill:#ede9fe,stroke:#7c3aed style CP fill:#dbeafe,stroke:#2563eb
图 10.1-1 服务注册发现架构与 CAP 取舍
工程实践

选型决策:

  • 中小规模 + Spring Cloud Alibaba → Nacos(注册 + 配置一体,AP/CP 可切,开箱即用)。
  • 云原生 K8s 体系 → K8s 内置 Service + CoreDNS + Endpoints 做注册发现(平台能力),配置中心用 Nacos/Apollo 或 K8s ConfigMap。
  • Service Mesh → Istiod + xDS(现行控制面;Pilot 为历史组件名),应用无感知,注册来源可为 K8s/Nacos/Consul。
  • 老项目 Dubbo → ZK 或迁移到 Nacos(Dubbo 3 推荐 Nacos)。
  • 多语言 + 多数据中心 → Consul。

Nacos 服务注册 + 配置热更新示例:

// bootstrap.yml —— 注册中心 + 配置中心一体化
spring:
  application:
    name: order-service
  cloud:
    nacos:
      discovery:          # 服务注册(AP,临时实例)
        server-addr: 192.168.1.10:8848
        namespace: prod
        group: DEFAULT_GROUP
      config:             # 配置中心(长轮询推送)
        server-addr: 192.168.1.10:8848
        namespace: prod
        file-extension: yaml
        refresh-enabled: true

// 业务类注入配置,变更后自动刷新
@RefreshScope
@RestController
public class OrderController {
    @Value("${order.timeout:3000}")
    private int timeout;  // 配置中心改值后自动重新注入
}

优雅下线:实例停机时主动调 Nacos deregister API 注销 → 等 5-10s 让消费者刷新本地缓存 → 再关进程;结合 Spring Boot Actuator 的 /shutdown 与 K8s preStop hook 实现摘流后停机。

深度追问

追问 1:Eureka 自我保护机制的利弊?

利:网络分区时不误删存活实例,避免大面积路由失败。弊:分区期间不剔除已真正下线的实例,消费者拿到失效地址导致调用失败率上升。本质是"宁可错杀不出,不可错杀入"的可用性优先权衡。Nacos 的 AP 模式同样有临时实例心跳过期剔除,但持久实例走服务端主动探测,更精准。

追问 2:服务实例下线如何做到完全平滑?

注册中心注销到消费者缓存刷新存在时间差(Nacos 默认 1s 推送 + 客户端本地缓存)。需组合拳:① 进程停机前主动 deregister;② 网关/Consumer 关闭对该实例的流量(readiness 摘流);③ 等待一个刷新周期(如 10s);④ 进程优雅停机(拒绝新请求 + 等待在途请求完成);⑤ 客户端调用失败自动重试其他实例。

常见陷阱
易错点
  • 用 ZK 当注册中心却忽视 Leader 选举期间不可写:注册中心短暂不可用即可能引发雪崩,注册场景应优先 AP。
  • 把注册中心当配置中心用 / 反之:二者侧重点不同(注册重 AP 心跳,配置重推送与版本),Nacos 合一不等于功能等价。
  • 忽视推送延迟与一致性:长轮询有秒级延迟,强一致配置(如开关)不要假设毫秒级生效。
  • 多机房注册中心未做隔离或同步:跨机房延迟导致心跳误判,应单元化部署或配置就近注册。
  • 客户端本地无缓存兜底:注册中心宕机时消费者全瘫,必须本地持久化最近一次实例列表。
生产实践

上线门禁:金丝雀错误预算、依赖超时传导、配置回滚。表达提纲:调用链预算→治理参数→发布门禁。

P0 表达提纲

先给可观测证据与门禁,再谈实现细节;避免只报组件名。

C10.02
API 网关在微服务中的核心职责?高级P1
本质问题

网关是微服务边界的统一入口,考察"横切关注点下沉"的设计思想与网关选型演进。核心回答:网关应做什么(路由/鉴权/限流/熔断/协议转换/可观测)、不应做什么(业务逻辑),以及从 Zuul 到 Gateway 到 APISIX 的非阻塞与动态化演进动机。

演进脉络

Nginx(2004):反向代理 + 负载均衡,高性能但偏静态配置,非业务网关,常作为网关前置的流量层。

Zuul 1.x(2013,Netflix):Spring Cloud Netflix 早期标配。基于 Servlet 同步阻塞模型,每请求一线程,filter 链可扩展。简单易用,但高并发下线程膨胀,性能受限。Netflix 进入维护模式后,Spring Cloud 生态主流转向 Spring Cloud Gateway;APISIX/Envoy 则多用于独立 API 网关和 Mesh 入口。

Zuul 2.x(2018):Netty 异步非阻塞重写,性能大幅提升。但 Netflix 内部使用为主,社区未广泛普及,Spring 官方未集成,非社区主流方案,国内基本不采用。

Spring Cloud Gateway(2017):基于 WebFlux + Netty 异步非阻塞,Route(Predicate + Filter)模型清晰,取代 Zuul 1.x 成为 Spring Cloud 主推网关。与 Spring 生态深度集成。

Kong(2015):基于 OpenResty(Nginx + Lua),高性能插件化,API 管理能力强,多用于 APIM 平台。

APISIX(2019,Apache):基于 OpenResty/NGINX,动态路由(不重启生效),etcd 存储配置,插件生态丰富,云原生网关新贵。Higress(阿里,2022)基于 Envoy,Java/Wasm 插件,与 Service Mesh 同源。

趋势:① 网关与 Service Mesh 融合——Envoy 既是 Sidecar 也是 Ingress Gateway,统一数据面;② API 管理平台化(APIM),网关 + 计费 + 文档 + SDK 生成;③ WebAssembly 插件成为跨网关可移植扩展标准。

第一性原理

网关核心职责(横切关注点下沉):

  1. 路由转发:按 Path/Header/Host 将请求路由到后端服务。
  2. 协议转换:外部 HTTP/HTTPS ↔ 内部 RPC(Dubbo/gRPC),统一对外 REST。
  3. 负载均衡:从注册中心拉取实例,Spring Cloud Gateway 交由 ReactiveLoadBalancerClientFilter / Spring Cloud LoadBalancer 处理。
  4. 认证鉴权:统一 JWT/OAuth2 校验,透传用户身份。
  5. 限流熔断:入口限流保护后端,熔断防级联故障。
  6. 日志监控:访问日志、调用链 TraceId 注入、Metrics 上报。
  7. 请求/响应改写:参数透传、加签/验签、响应聚合。
  8. 灰度路由:按流量染色标签路由到灰度实例。

Spring Cloud Gateway 原理:Route = Predicate(匹配条件)+ Filter(处理逻辑)。请求进入后由 RoutePredicateHandlerMapping 评估 Predicate 并选中 Route;选中的 Route 内置 GatewayFilter 与全局 GlobalFilter 会按 order 合并成一个链。order 小的 Filter 在前置阶段先执行,响应阶段通常按相反方向回流;真正发起下游转发的是 NettyRoutingFilter(全局 Filter,位于链最末端、order = Ordered.LOWEST_PRECEDENCE)。它是 Reactor 异步非阻塞模型,但并非“无线程数瓶颈”,EventLoop、连接池和下游能力仍需压测规划。

flowchart LR Client["客户端"] -->|"HTTP/HTTPS"| GW["API 网关"] subgraph GW["网关 Filter 链 (异步非阻塞)"] direction TB F1["认证鉴权
JWT/OAuth2"] --> F2["限流熔断
Sentinel/Resilience4j"] F2 --> F3["路由匹配
Predicate"] F3 --> F4["协议转换
HTTP→RPC"] F4 --> F5["负载均衡
选实例"] F5 --> F6["日志监控
TraceId/Metrics"] end GW -->|"转发"| S1["order-service"] GW -->|"转发"| S2["user-service"] GW -->|"转发"| S3["payment-service"] style GW fill:#dbeafe,stroke:#2563eb
图 10.2-1 API 网关 Filter 链与核心职责

同步阻塞 vs 异步非阻塞:Zuul 1.x(已停更)每请求占用一个 Servlet 线程,后端慢则线程堆积;Gateway/APISIX 用少量 EventLoop 线程处理连接,慢后端通常表现为连接/订阅等待而不是立刻占满工作线程。但吞吐取决于 CPU、连接数、请求体、序列化、下游 RT 和限流策略,必须按目标版本压测,不能给出固定倍数。

动态路由:Spring Cloud Gateway 的官方扩展点是 RouteDefinitionWriter(保存/删除路由定义)与 RouteDefinitionLocator(提供路由定义);Spring Boot 提供 InMemoryRouteDefinitionRepository,也可自定义 RouteDefinitionRepository 接配置中心。配置中心推送后需要保存路由定义,并按所选组件的官方机制刷新缓存/发布刷新事件;路由和连接配置要可回滚。APISIX 等网关原生把控制面配置持久化到 etcd 并向数据面下发。动态路由是运维动机。

网关 底层模型 动态路由 生态 适用场景
Zuul 1.x(已废弃) Servlet 同步阻塞 弱 Spring Cloud Netflix(停更) 老项目维护,替代:Spring Cloud Gateway / APISIX
Spring Cloud Gateway WebFlux + Netty 异步 RouteDefinitionWriter/Locator 扩展 Spring Cloud 原生 Spring 技术栈中的原生网关
Kong OpenResty + Lua 支持 插件丰富,APIM 强 API 管理平台
APISIX OpenResty + etcd 原生动态 云原生,插件多 高性能云原生网关
Higress/Envoy Envoy(C++) xDS 动态 与 Service Mesh 同源 Mesh 一体化网关
工程实践

Spring Cloud Gateway 路由 + 限流 + 鉴权配置:

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service        # 负载均衡到注册中心实例
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=1               # 去掉 /api 前缀
            - name: RequestRateLimiter    # 令牌桶限流
              args:
                redis-rate-limiter.replenishRate: 100   # 每秒 100 令牌
                redis-rate-limiter.burstCapacity: 200   # 桶容量 200
                key-resolver: "#{@apiKeyResolver}"  # 按用户限流
            - name: JwtAuth                # 自定义 JWT 鉴权 Filter

// 自定义鉴权 Filter: 校验 JWT 后剥离外部身份头,只向下游注入可信身份
@Component
public class JwtAuthGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            String token = exchange.getRequest().getHeaders().getFirst("Authorization");
            if (!jwtUtil.verify(token)) {
                exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
                return exchange.getResponse().setComplete();
            }
            String userId = jwtUtil.parseUserId(token);
            ServerHttpRequest mutated = exchange.getRequest().mutate()
                .headers(headers -> headers.remove("X-User-Id"))
                .header("X-User-Id", userId)
                .build();
            // mutate() 会返回新 exchange;必须用它继续调用后续 Filter
            return chain.filter(exchange.mutate().request(mutated).build());
        };
    }
}

生产实践:① 网关无状态,多实例 + LB 水平扩展,路由定义走持久化配置中心或内部管理面,变更可审计、可回滚;② 限流/熔断规则下沉到 Sentinel 控制台统一管理;③ 网关层聚合监控(QPS/RT/错误率)+ TraceId 注入;④ 网关与后端服务间走内网 mTLS;⑤ 只在已认证边界注入身份头,并在入口剥离外部同名头,下游不得信任未经网关的网络来源带来的身份头。

深度追问

追问 1:网关如何避免单点故障?

网关无状态 → 多实例部署 → 前置 SLB(如 Nginx/云厂商 LB)做四层负载 → 任一网关实例宕机自动剔除。配置中心统一推送路由/限流规则保证一致性。容量规划上保留 50% 余量应对突发流量。

追问 2:网关与 Service Mesh 的 Ingress Gateway 边界?

传统网关(Gateway/APISIX)负责南北向(外部→内部)流量治理;Service Mesh Ingress Gateway(Envoy)既做南北向入口又与东西向 Sidecar 同源,统一数据面。趋势是用 Mesh Ingress 替代独立网关,减少组件、统一治理,但 APIM(计费/文档/SDK)能力仍需独立 APIM 平台补充。

常见陷阱
易错点
  • 网关里写业务逻辑:网关只做横切,业务下沉到服务,否则网关变单体瓶颈。
  • 同步阻塞网关扛高并发:Zuul 1.x(已停更)每请求一线程,后端慢即线程池打满。
  • 动态路由不刷新:改配置要重启网关,失去动态性。
  • 跨域/TraceId 未透传:网关未注入 TraceId 或被 CORS 拦截,全链路追踪断链。
  • 网关成为新单点:单实例或无 LB 兜底,网关宕机全站不可用。
C10.03
高并发系统的限流算法与熔断降级策略?高级P1
本质问题

考察流量治理三板斧——限流、熔断、降级的本质:保护系统不被过载拖垮、快速失败避免级联雪崩。核心区分限流算法的适用边界(漏桶平滑 vs 令牌桶突发)、熔断器状态机的恢复机制,以及 Hystrix→Resilience4j→Sentinel 的框架演进动机。

演进脉络

限流算法演进:计数器(固定窗口,临界点双倍流量问题)→ 滑动窗口(细分子窗口平滑)→ 漏桶(恒定速率出水,平滑输出)→ 令牌桶(按速率发令牌,桶可累积,允许突发)。本质是对"时间窗口内请求量"的不同近似与权衡。

Hystrix(2012,Netflix):熔断器状态机(Closed/Open/Half-Open)+ 线程池/信号量隔离 + 熔断后 fallback。Spring Cloud Netflix 1.x 标配。2018 年宣布进入维护模式(线程池隔离开销大、不支持热点参数限流)。新项目应按所用 Spring Boot 版本在 Resilience4j 与 Sentinel 之间做选型,而不是继续引入 Hystrix。

Resilience4j:常见的 Hystrix 替代方案。基于函数式编程 + Vavr 库,模块化设计(CircuitBreaker / RateLimiter / Bulkhead / Retry / TimeLimiter / Cache)。集成 starter 要按 Boot 主版本选择:resilience4j-spring-boot2 对应 Spring Boot 2.x,resilience4j-spring-boot3 对应 Spring Boot 3.x;不要在 Boot 3 项目中沿用 Boot 2 starter。

Sentinel(2017,阿里):以流量为切入点,限流/熔断/系统自适应保护(基于 Load1/CPU/RT)/热点参数限流。控制台实时规则推送,Spring Cloud Alibaba 标配。国内最流行。

趋势:流量治理与 Service Mesh Sidecar 融合(Envoy 自带熔断/限流,Istio 配置),应用无感知;开源生态转向 Sentinel + Mesh Sidecar 双层防护。

第一性原理

四种限流算法:

算法 原理 优点 缺点
固定窗口计数器 每个时间窗口内计数,超阈值拒绝 实现简单 窗口临界点双倍流量(如 100/s,0.9s-1.0s 来 100 + 1.0s-1.1s 来 100 = 0.2s 内 200)
滑动窗口 窗口细分为子窗口,按时间滑动统计 平滑临界突变 仍有微小不精确,内存略高
漏桶 请求入桶,恒定速率出水 输出绝对平滑 不允许突发,桶满即拒
令牌桶 按速率向桶发令牌,请求消耗令牌 允许突发(桶满可瞬时消耗) 突发可能压垮下游

漏桶 vs 令牌桶:漏桶恒定速率出水(平滑输出,不允许突发,适合保护脆弱下游);令牌桶按速率发令牌但桶可累积(允许短时突发,适合入口限流兼顾用户体验)。多数网关限流用令牌桶。

熔断器状态机:

flowchart LR Closed["Closed 闭合
正常放行
统计错误率/慢调用率"] -->|"错误率超阈值"| Open["Open 断开
快速失败 fallback"] Open -->|"等待熔断恢复时间
(如 10s)"| Half["Half-Open 半开
放少量探测请求"] Half -->|"探测成功率达阈值"| Closed Half -->|"探测仍失败"| Open style Closed fill:#dcfce7,stroke:#059669 style Open fill:#fee2e2,stroke:#dc2626 style Half fill:#fef3c7,stroke:#d97706
图 10.3-1 熔断器三态机:Closed→Open→Half-Open 自动恢复

Sentinel 滑动窗口实现:LeapArray 环形数组,默认 1 秒划分为 2 个 500ms 子窗口,每个 Window 统计 pass/block/rt。熔断策略支持慢调用比例(RT > 阈值的比例)与异常比例/异常数两种触发方式。

限流 vs 熔断 vs 降级:

  • 限流:控入口量,超出阈值拒绝,保护自身不被压垮。
  • 熔断:防下游故障级联,下游不可用快速失败,保护自身线程不被拖死。
  • 降级:熔断或限流后的兜底策略,返回默认值/缓存/简化逻辑,保证核心可用。
工程实践

Sentinel 限流 + 熔断规则示例:

// 资源定义 + 限流/熔断规则 (编程式,生产用控制台推送)
FlowRule flowRule = new FlowRule("createOrder");
flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
flowRule.setCount(100);                // QPS 100
flowRule.setLimitApp("default");
FlowRuleManager.loadRules(Collections.singletonList(flowRule));

// 熔断规则: 慢调用比例 (RT>200ms 占比>50% 且 QPS>=5 时熔断 10s)
DegradeRule degradeRule = new DegradeRule("queryUser")
    .setGrade(RuleConstant.DEGRADE_GRADE_RT)
    .setCount(200)                    // 慢调用阈值 200ms
    .setSlowRatioThreshold(0.5)       // 慢调用比例阈值 50%
    .setMinRequestAmount(5)           // 最小请求数 5
    .setTimeWindow(10);               // 熔断时长 10s
DegradeRuleManager.loadRules(Collections.singletonList(degradeRule));

// 业务方法用注解保护,配降级 fallback
@SentinelResource(value = "createOrder", blockHandler = "blockHandler", fallback = "fallback")
public Order createOrder(OrderRequest req) { /* ... */ }

public Order fallback(OrderRequest req, Throwable e) {
    return Order.degraded();  // 降级返回简化订单
}

Resilience4j 编程式组合示例:熔断打开时抛 CallNotPermittedException,限流器拒绝时抛 RequestNotPermitted。降级方法的入参要与业务方法对应,并追加异常参数:

import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.decorators.Decorators;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import java.time.Duration;
import java.util.List;

@Service
public class InventoryService {

    private final CircuitBreaker circuitBreaker = CircuitBreaker.of("inventory",
        CircuitBreakerConfig.custom()
            .failureRateThreshold(50)
            .slowCallDurationThreshold(Duration.ofMillis(500))
            .slowCallRateThreshold(50)
            .minimumNumberOfCalls(20)
            .slidingWindowSize(100)
            .waitDurationInOpenState(Duration.ofSeconds(10))
            .permittedNumberOfCallsInHalfOpenState(5)
            .build());

    private final RateLimiter rateLimiter = RateLimiter.of("inventory",
        RateLimiterConfig.custom()
            .limitForPeriod(100)
            .limitRefreshPeriod(Duration.ofSeconds(1))
            .timeoutDuration(0)
            .build());

    public OrderResult reserve(OrderRequest request) {
        return Decorators.ofSupplier(() -> callInventory(request))
            .withCircuitBreaker(circuitBreaker)
            .withRateLimiter(rateLimiter)
            .withFallback(List.of(
                    CallNotPermittedException.class,
                    RequestNotPermitted.class),
                cause -> fallback(request, cause))
            .decorate()
            .get();
    }

    // 方法名可自定义;关键是业务入参一致,并追加要处理的异常。
    private OrderResult fallback(OrderRequest request, Throwable cause) {
        return OrderResult.degraded(request.orderId(), cause);
    }
}

分布式限流:单机限流无法控全局总量,需集群限流——Sentinel Token Server 或 Redis + Lua 原子扣减令牌。注意中心化限流有网络开销,临界场景退化为本地限流 + 超额补偿。

深度追问

追问 1:单机限流 vs 集群限流如何选?

单机限流保护自身节点,无中心依赖、性能高,适合保护单实例不被压垮。集群限流控全局总量(如全局限 1000 QPS),需 Token Server/Redis,有网络开销与单点风险。实践:网关层集群限流控总量 + 服务层单机限流保节点。

追问 2:Hystrix 为何被淘汰?Resilience4j/Sentinel 强在哪?

Hystrix 线程池隔离开销大(每依赖一线程池)、2018 年宣布进入维护模式、不支持热点参数限流。Resilience4j 轻量函数式、模块化,且异常类型明确;Sentinel 流量视角更全(热点参数、系统自适应、集群限流)+ 控制台实时推送。选型还要看团队运维平台、Spring Boot 版本和多语言需求,不把国内生态完善等同于所有场景首选。

追问 3:令牌桶如何做预热/冷启动?大促开闸瞬时打满怎么办?

问题本质:令牌桶允许短时突发,但系统能力并不会瞬时拉满——缓存冷、连接池重建、Compaction 等都是后知后觉。若大促 0 点直接放满令牌,请求瞬时打满打穿系统。解法是让吞吐按"系统能力爬升曲线"平滑逼近目标:

  • Guava RateLimiter 两种实现:SmoothBursty(平滑突发)与 SmoothWarmUp(平滑预热)。核心是 storedPermits + nextFreeTicketMicros 微积分模型——SmoothWarmUp 把令牌桶的"冷却区"映射为一条斜率为 warmupPeriodMicros / (maxPermits · (1 - coldFactor)) 的爬升线,冷启动期放行速率从 QPS / coldFactor(默认 1/3)缓慢增长到满速,超过 1/2 容量才按满速放行;SmoothBursty 则提前把空闲期累积的令牌一次性放行("欠账"预支,阅后即爆)。
  • Sentinel WarmUp 限流:规则里配 warmUpPeriodSec + 冷启动因子,流量突增前 storedPermits 少,放行率低,随窗口滑动逐步提升至阈值;配套系统保护(基于 Load1/CPU/RT 自适应阈值)进一步防止"放行了但机器打不动"。
  • 场景:秒杀开闸、缓存预热中的发布了、K8s 优雅上线的 readinessProbe.pass + startPeriod 配合预热——都比直接满配放行更稳。

追问 4:削峰填谷 vs 直接拒绝?分布式限流的精度与代价怎么权衡?

削峰填谷:秒杀/预约下单等场景不追求实时返回,用的不是"拒绝"而是异步化排队——请求进队列(MQ/内存队列)平摊到秒级~分钟级慢慢消化,削掉峰值保护下游。这与限流的"快速失败"语义不同:限流是丢,削峰是延后。漏桶本质就是一种削峰(恒定速率出水)。选型:交互要求实时响应 → 限流拒绝/排队等待;可接受延迟(下单/报表/推送)→ MQ 异步削峰。

三种分布式限流落地对比:

方案 原理 原子性/精度 代价
Redis 固定窗口 INCR + EXPIRE(Lua 原子) 临界点双倍流量(窗口粒度粗) O(1),性能最高
Redis ZSET 滑窗 每次请求写入 ZADD score=时间戳,ZREMRANGEBYSCORE 清过期,ZCARD 计数 精确,但占用内存 = QPS·窗口时长(一条请求一条记录) O(logN),高 QPS 下内存暴涨,需定期清理
Redis 令牌桶(Lua) 脚本内维护 lastTicketTime + tokens,按速率补发 允突发、平滑,保留令牌桶全部语义 O(1),需保证 Lua 原子执行

实践口径:分布式限流命中 Redis 有网络开销与单点风险。双层限流——网关层集群限流控全局总量(Redis Lua 令牌桶),服务层单机限流兜底保节点(本地令牌桶无网络开销),两者按"内层更严"配置;量级极高时退化为纯本地限流 + 超额补偿,避免中心化成为瓶颈。

常见陷阱
易错点
  • 误以为令牌桶能无限突发:突发量受桶容量限制,超桶容量仍拒绝。
  • 熔断不配 fallback:熔断后直接抛异常,用户体验差,必须配降级逻辑。
  • 限流维度单一:只限 QPS 不限并发线程数,慢调用堆积仍会拖垮。
  • Half-Open 探测量过大:半开状态放太多请求,下游未恢复又被打挂。
  • 规则硬编码无动态推送:调阈值要重启,应用 Sentinel 控制台实时推送。
Sentinel 版本演进与集群限流生产部署

① Sentinel 版本演进时间线:

版本 年份 关键能力
1.4.0 2019 集群限流(Token Server 统一控全局 QPS 阈值)
1.5.0 2019 WebFlux / Reactor 响应式限流支持
1.6.0 2019 Spring Cloud Gateway / Zuul 网关流控、热点参数限流增强
1.8.x 2020 慢调用比例熔断重构、熔断与限流规则统一(至今仍是默认行为)
2.0.0-alpha 2023-02(预览,尚未 GA) 云原生方向(流量路由/负载均衡基础能力等);生产仍以 1.8.x 为主,勿当 GA 使用

② 集群限流生产部署要点:解决"单机限流控不住全局总量"——Sentinel Token Server 统一发令牌,全局 QPS 可控。

  • 两种部署模式:嵌入模式(独立进程内嵌 Token Server,无额外节点,适合小规模)vs 独立模式(独占 Token Server 集群,吞吐更高,适合大规模)。
  • HA 多实例:独立 Token Server 必须多实例部署避免单点;客户端配置多地址自动选主。
  • 降级兜底(生产必配):Token Server 不可用时自动降级为单机限流(failover),防止中心故障导致全部拒绝(雪崩放大器)。
  • 能力边界:集群限流只支持 QPS 阈值,不支持线程数;线程数限流只能单机维度。
生产踩坑

独立 Token Server 未做 HA 且未开 failover 降级 → 宕机时集群限流直接拒绝全部请求。务必多实例 + 开启本地降级兜底。

C10.04
容器化与 Kubernetes 如何部署 Java 应用?架构P1
本质问题

考察云原生部署模型与 JVM 在容器中的适配演进。核心回答:Docker 镜像分层与隔离原理、K8s 声明式编排模型(Pod/Deployment/Service/Ingress)、JVM 容器感知从 JDK 8 到 10+ 的演进,以及优雅停机与资源规划的工程实践。

演进脉络

部署形态演进:物理机 → 虚拟机(资源隔离)→ 容器(轻量隔离)→ 容器编排(K8s,集群调度)→ Serverless(免运维)。容器化的本质是把应用与依赖打包成可移植镜像,K8s 解决的是大规模容器的调度与生命周期管理。

Docker(2013):镜像分层(UnionFS 联合文件系统,每条指令一层可复用缓存)+ 容器隔离(namespace 隔离视图 + cgroups 限制资源)。

Kubernetes(2014,Google 基于 Borg):声明式 API + 控制循环(reconcile)——用户声明期望状态,Controller 持续调谐实际状态向期望收敛。成为容器编排事实标准。

K8s 版本演进关键里程碑:① 1.0(2015):首个稳定版,Pod/Service/ReplicationController 基础模型;② 1.8(2017):Workload API(Deployment/StatefulSet/DaemonSet)GA,确立现代声明式部署模型;③ 1.16(2019):重大 API 版本清理——extensions/v1beta1 等旧版本移除,统一迁移至 apps/v1,CRD v1 GA;④ 1.20→1.24(2020→2022):Dockershim 废弃(1.20)并移除(1.24),容器运行时从 Docker 切换至 containerd/CRI-O(CRI 标准),Docker 镜像仍可用但运行时不再依赖 dockershim;⑤ 1.25(2022):PodSecurityPolicy 移除,替代为 Pod Security Admission(命名空间级安全策略);⑥ 1.28~1.29(2023):原生 Sidecar 容器(1.28 Beta,1.29 起对应 feature gate 默认启用),Sidecar 容器与主容器生命周期解耦,启动/退出顺序可控;Node Swap 处于 Beta,启用方式仍受节点配置与版本约束;⑦ 1.31~1.33(2024~2025):AppArmor GA、User Namespaces Beta(安全增强)、Pod 垂直弹性伸缩(In-place Pod Resize,1.27 Alpha → 1.33 Beta,默认启用 InPlacePodVerticalScaling gate,仍非 GA)——不重启 Pod 即可调整 CPU/内存。K8s 保持约每年 3 个小版本、每版本约 1 年支持的发布节奏,需重点关注 API 版本废弃迁移与运行时变更两大破坏性变更方向。

JVM 容器感知演进(关键考点):

  • JDK 8u131 之前:JVM 看不到容器内存/CPU 限制,按宿主机总资源计算,容器内存远小于宿主机时 -Xmx 误设过大,直接被 OOM Killer 杀掉。
  • JDK 8u131 / 9:实验性 -XX:+UseCGroupMemoryLimitForHeap,需手动开启。
  • JDK 10:-XX:+UseContainerSupport 引入并默认开启,JVM 开始按容器配额计算可用处理器数和内存相关默认值。
  • JDK 11+:容器支持进入长期演进,后续版本逐步完善 cgroup v1/v2 与运行时约束识别;仍可用 -XX:MaxRAMPercentage 降低固定 -Xmx 带来的迁移成本。8u131/9 的实验开关只能看到部分限制,旧 JDK 8 的能力还依赖发行商和 backport 版本。

趋势:GraalVM Native Image(AOT 编译,启动和内存收益明显,但有反射/JNI/动态代理等兼容成本)+ Spring Boot 3 AOT;CRaC(Coordinated Restore at Checkpoint)是 OpenJDK CRaC 项目/支持该机制的 JDK 发行版能力,不是 JDK 21 的标准能力,快照恢复还要处理外部连接、文件句柄和密钥等状态。

第一性原理

Docker 镜像分层:Dockerfile 每条指令(FROM/RUN/COPY)生成一层只读层,多层叠加为镜像,容器运行时在最上层加可写层。分层复用使基础镜像只拉一次,多镜像共享。多阶段构建(multi-stage build)用构建阶段产物拷贝到运行镜像,大幅减小最终镜像体积。

K8s 核心对象:

对象 职责 关键点
Pod 最小调度单位 一个或多个紧密耦合容器,共享网络/存储 namespace
Deployment 副本控制 + 滚动更新 声明期望副本数,Controller 调谐;RollingUpdate 策略控制滚动节奏
Service 稳定网络端点 + 负载均衡 Label Selector 关联 Pod,ClusterIP/NodePort/LoadBalancer
Ingress 七层流量入口 Ingress API(API 本身仍在、功能已冻结)由各 Ingress Controller 实现 HTTP 路由与 TLS;社区版 ingress-nginx 控制器已于 2026-03 退役
ConfigMap/Secret 配置与敏感信息 挂载为环境变量或文件,Secret Base64 编码(非加密)
Probe 健康检查 livenessProbe(重启)/readinessProbe(摘流)/startupProbe(启动)

K8s 控制平面:API Server(唯一入口)+ etcd(状态存储,Raft)+ Scheduler(调度 Pod 到节点)+ Controller Manager(副本/节点/端点等控制循环)+ Kubelet(节点代理)+ Kube-proxy(Service 转发,早期 userspace 模式已移除;iptables/IPVS/nftables 等实现按集群版本和节点能力选择并压测)。

Ingress-NGINX 控制器已于 2026-03 退役(高频时效盲区):Kubernetes SIG Network 与安全响应委员会宣布社区维护的 ingress-nginx 控制器维护终止,继续使用将无法获得 CVE 修复、功能更新与社区支持。须澄清的误解:退役的是这个控制器,不是 Ingress API——Ingress API 仍然受支持且被广泛使用,只是功能已冻结。两条迁移路径:① 平移换控制器(如 Contour 等 Envoy 系实现),保留既有 Ingress YAML、只切换 ingressClass,迁移投入低到中,但所有 nginx.ingress.kubernetes.io/* 注解会失效,须逐条翻译或改用目标控制器 CRD;② 迁移到 Gateway API(上游 Ingress 的继任与演进),采用 GatewayClass / Gateway / HTTPRoute 的面向角色模型,把基础设施职责与应用路由显式分离,标准化流量拆分、高级 Header 匹配与安全的跨命名空间路由——迁移投入高(路由清单需重写),但长期有上游支持且能力内建。

落地建议与工具:先盘点现有 nginx.ingress.kubernetes.io/* 注解技术债,用官方 ingress2gateway(1.0 于 2026-03 发布,覆盖 30+ 常见 ingress-nginx 注解,并输出无法自动翻译项的告警供人工复核)生成 Gateway API 清单,再以「并行运行 + DNS 切换」灰度迁移非关键工作负载,避免就地替换。云厂商窗口各不相同(例如 AKS 对基于 ingress-nginx 的应用路由附加组件提供关键安全补丁至 2026-11,之后须迁移到其 Gateway API 实现),须以自用平台的公告为准,不能套用其他厂商时间表。

flowchart TB subgraph CP["控制平面 Control Plane"] API["API Server"] ETCD["etcd (Raft)"] SCHED["Scheduler"] CM["Controller Manager"] end subgraph Node1["Node 1"] K1["Kubelet"] KP1["Kube-proxy"] P1["Pod: order-service"] P2["Pod: user-service"] end subgraph Node2["Node 2"] K2["Kubelet"] KP2["Kube-proxy"] P3["Pod: order-service"] end API <--> ETCD SCHED --> API CM --> API K1 --> API K2 --> API KP1 -.-> P1 KP1 -.-> P2 KP2 -.-> P3 style CP fill:#dbeafe,stroke:#2563eb style Node1 fill:#f0f3f7,stroke:#6b7280 style Node2 fill:#f0f3f7,stroke:#6b7280
图 10.4-1 K8s 集群架构:控制平面 + 工作节点

JVM 容器感知原理:JVM 启动时读取 /sys/fs/cgroup/memory/memory.limit_in_bytes(cgroup v1)或 /sys/fs/cgroup/memory.max(v2)获取容器内存限制,按 MaxRAMPercentage(默认 25%)计算堆上限。Java 11+ 推荐用百分比而非固定 -Xmx,便于容器规格调整时自适应。

优雅停机流程(版本与平台相关):删除 Pod 时,K8s 会启动容器终止流程并受 terminationGracePeriodSeconds 限制。preStop hook 与 EndpointSlice/负载均衡控制面的摘流传播并发进行;hook 完成或超时后向主容器进程发送 SIGTERM;Spring Boot 配置 server.shutdown=graceful 后在 SIGTERM 阶段停止接新请求并等待在途请求,超时由 spring.lifecycle.timeout-per-shutdown-phase 控制。因此 preStop 的 sleep 是为控制面摘流和上游缓存刷新留传播窗口,不是“必然先完成摘流才收到 SIGTERM”的线性保证;SIGTERM 必须是容器主进程真实收到的信号,不要用包装脚本吞掉信号。

工程实践

多阶段构建 Dockerfile(减小镜像):

# 阶段 1: 构建
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline           # 先拉依赖,利用层缓存
COPY src ./src
RUN mvn package -DskipTests

# 阶段 2: 运行(小镜像)
FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /app/target/app.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]

K8s Deployment YAML(含 probe + 资源限制 + 优雅停机):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels: { app: order-service }
  template:
    metadata:
      labels: { app: order-service }
    spec:
      containers:
        - name: order-service
          image: registry.cn-hangzhou.aliyuncs.com/myorg/order-service:1.0.0
          resources:
            requests: { cpu: "500m", memory: "512Mi" }
            limits:   { cpu: "1000m", memory: "1Gi" }
          env:
            - name: JAVA_OPTS
              value: "-XX:MaxRAMPercentage=75.0 -XX:+UseZGC"
          ports: [{ containerPort: 8080 }]
          # 启动探针: 避免慢启动被 liveness 杀死
          startupProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            failureThreshold: 30
            periodSeconds: 10
          # 存活探针: 失败重启
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            periodSeconds: 10
          # 就绪探针: 失败摘流
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            periodSeconds: 5
          # 优雅停机: preStop 与控制面摘流并发;sleep 只留传播窗口
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 10"]
      terminationGracePeriodSeconds: 60
深度追问

追问 1:Java 启动慢在 Serverless/弹性伸缩场景如何解决?

① GraalVM Native Image:AOT 编译为原生可执行文件,启动毫秒级、内存几十 MB,但牺牲部分动态特性(反射需配置)、编译时间长;② CRaC:在支持该机制的 OpenJDK CRaC/发行版上,应用启动后打快照,下次从快照恢复,可显著缩短启动并保留部分运行时优化,但必须验证连接池、句柄、临时文件和安全状态的 restore 回调;③ AppCDS(类数据共享)减少类加载;④ Spring Boot 3 AOT 优化 Bean 装配。

追问 2:容器内存为何要给 JVM 留 headroom?

-Xmx 只控堆,堆外内存(DirectByteBuffer/NIO、线程栈、Metaspace、JIT CodeCache、GC 结构)不计入堆。若 -Xmx 设满容器 limit,堆外内存超出即被 OOM Killer 杀掉。实践:MaxRAMPercentage 设 70-75%,留 25-30% 给堆外与原生内存。

常见陷阱
易错点
  • -Xmx 设满容器内存:忽略堆外内存,被 OOM Killer 反复杀进程。
  • 不配 Probe 或配错:liveness 与 readiness 混用,启动慢被杀或故障未摘流。
  • 镜像过大:用全量 JDK 镜像(数百 MB),应多阶段构建 + JRE/Alpine + jib。
  • 忽略 terminationGracePeriodSeconds:默认 30s 不够在途请求处理完,强杀丢请求。
  • JDK 8 旧版本无容器感知:JVM 按宿主机算内存,容器内 OOM。
  • 忽视 K8s API 版本废弃迁移:extensions/v1beta1 等旧 API 在 1.16 移除、PodSecurityPolicy 在 1.25 移除,升级集群版本前需用 kubectl convert / deprecation 工具预检并迁移 YAML。
  • Dockershim 移除后仍用 Docker 运行时:K8s 1.24+ 不再内置 dockershim,需切换到 containerd/CRI-O 或安装 cri-dockerd 作为过渡,否则节点 Kubelet 启动失败。
  • 版本基线(2026):生产跟踪集群常落在受支持次新小版本;至 2026-08 官方线已推进到 1.37 一带(具体以所用发行版支持矩阵为准),升级前核对 API 废弃与准入控制器变更。
扩展:K8s Operator — 将运维知识编码为自定义控制器

本质:K8s Operator 是将人类运维专家知识编码为软件的架构模式——通过自定义控制器(Custom Controller)+ 自定义资源(CRD, CustomResourceDefinition)将复杂应用的部署、扩缩容、故障恢复、备份升级等运维操作自动化。核心思想是"声明式期望状态 + 控制循环协调(Reconcile Loop)"。

核心组件:

  • CRD(CustomResourceDefinition):扩展 K8s API,定义新的资源类型(如 MySQLCluster、KafkaTopic)。用户像写 Deployment YAML 一样写 CRD 实例。
  • Custom Controller:监听 CRD 变化,执行协调逻辑——比较期望状态(CRD 中声明的)与实际状态(集群中运行的),驱动实际状态向期望状态收敛。
  • Operator SDK / Kubebuilder:Red Hat Operator SDK 与 Kubernetes SIGs Kubebuilder 是两个主流框架,提供脚手架生成 Controller 骨架、CRD 定义、测试框架。

工作原理(Reconcile Loop):

// Operator 控制器核心:Reconcile 循环(伪代码)
func (r *MySQLClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 1. 读取 CRD 声明的期望状态
    var cluster mysqlv1.MySQLCluster
    r.Get(ctx, req.NamespacedName, &cluster)

    // 2. 读取实际状态(当前运行的 Pod/Service/PVC)
    pods := r.listPodsForCluster(cluster)

    // 3. 协调:实际 ≠ 期望 → 创建/删除/更新资源
    if len(pods) < cluster.Spec.Replicas {
        r.createMySQLPods(cluster, cluster.Spec.Replicas - len(pods))
    }

    // 4. 持续协调(周期性 Requeue)
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

典型 Operator 场景:

Operator 运维操作自动化 替代的人工操作
Strimzi Kafka Operator 创建集群、扩缩容 Broker、创建 Topic、配置 ACL 手动部署 Kafka + ZooKeeper + 配置 topic/partition
MySQL Operator (Oracle) 主从搭建、故障切换、备份恢复、版本升级 手动搭建主从 + MHA + xtrabackup
Prometheus Operator 部署监控实例、配置告警规则、管理 ServiceMonitor 手动部署 + 配置文件管理 + 滚动更新
ArgoCD / Flux (GitOps) 监听 Git 变更,自动同步到集群 手动 kubectl apply + 多环境配置管理
Chaos Mesh 声明式注入网络/Pod/IO 故障实验 手动模拟故障 + 记录实验结果

Operator vs Helm vs Deployment 对比:

  • Deployment:无状态应用部署。适合 Web 服务、API 服务。
  • Helm:模板化部署工具,一次性渲染 YAML。适合"部署即完成"的简单应用。
  • Operator:持续运行的控制器,不断协调状态。适合有状态应用(数据库/MQ/监控系统)——需要主从切换、数据迁移、版本灰度等复杂运维逻辑。

架构师视角:

  • 何时自研 Operator:当应用有复杂运维流程(主从切换、数据迁移、多步骤升级)且 Deployment/Helm 无法覆盖时。自研成本:1-2 人月(Operator SDK 脚手架 + Reconcile 逻辑 + 测试)。
  • 何时用社区 Operator:优先使用 CNCF/官方维护的 Operator(Strimzi/Prometheus Operator/ArgoCD),避免自研维护负担。
  • 注意:Operator 本身是单点——Controller 宕机后协调停止。生产环境需部署 Operator 副本(Leader Election 机制保证只有一个活跃 Controller)。

掌握要点:回答 K8s Operator 时需强调三点:① CRD 扩展了 K8s API 的能力边界(不只预置 Deployment/StatefulSet);② Reconcile Loop 是"期望状态 vs 实际状态"的持续协调,不是一次性部署;③ Operator = CRD + Custom Controller,本质是"将运维知识代码化",让 K8s 理解非原生应用的生命周期管理。

C10.05
Service Mesh(Istio) 如何解耦微服务治理?专家P1
本质问题

考察微服务治理"从库侵入到 Sidecar 平台下沉"的范式转移,以及完整的微服务架构演进。核心回答:Service Mesh 用 Sidecar 代理将服务发现、负载均衡、熔断限流、认证加密、可观测等治理能力从应用 SDK 剥离到基础设施层,实现语言无关、升级无侵入、统一治理。

演进脉络

完整演进:单体 → SOA → 微服务 → Service Mesh → Serverless,本质是治理能力从应用内逐步外移、平台化的过程。

  • 单体(Monolith):所有功能在一个进程,模块间本地调用,简单但迭代耦合、扩展受限。
  • SOA(面向服务架构):服务化拆分 + ESB(企业服务总线)集中编排与协议转换。ESB 是中心化瓶颈,治理逻辑在总线。
  • 微服务(第一代,库治理):Spring Cloud/Dubbo,治理能力(注册发现/熔断/限流/链路追踪)以 SDK 形式嵌入应用。痛点:多语言难(每语言一套 SDK)、升级侵入(改 SDK 要全量发版)、治理与业务代码耦合。
  • Service Mesh 第一代(Linkerd 2016):Sidecar 代理拦截服务间流量,治理逻辑下沉。2016 Buoyant 提出 Service Mesh 概念。
  • Istio(2017,Google/IBM/Lyft):数据面 Envoy + 控制面 Istiod。成为事实标准。
  • Serverless(FaaS):进一步下沉,应用只需写函数,扩缩容/路由/治理全由平台托管。
flowchart LR M["单体
进程内调用"] --> SOA["SOA
ESB 集中编排"] SOA --> MS["微服务(库治理)
SDK 嵌入
Spring Cloud/Dubbo"] MS --> SM["Service Mesh
Sidecar 下沉
Istio/Envoy"] SM --> SL["Serverless
平台全托管
FaaS+BaaS"] style SM fill:#ede9fe,stroke:#7c3aed
图 10.5-1 微服务架构演进:治理能力从应用内逐步外移到平台

趋势:① Ambient Mesh(Istio 1.24 GA,2024-11;1.22 仍为预览):去 Sidecar,用 ztunnel(L4 代理)+ waypoint(L7 代理)分层,大幅降低资源开销与延迟;② eBPF 加速数据面:Cilium Service Mesh 在内核态处理流量,绕过内核协议栈;③ 多集群 Mesh:跨集群统一治理与故障迁移。

第一性原理

Sidecar 模式:每个 Pod 注入一个 Envoy 代理容器,与应用容器同 Pod 共享网络 namespace。应用所有出入流量被 iptables 劫持到 Envoy,由 Envoy 完成负载均衡、熔断、限流、mTLS 认证、链路追踪、重试、超时等治理,应用代码完全无感知(仿佛直接调用对端)。

数据面 vs 控制面:

  • 数据面(Envoy):转发流量、执行治理策略。每个 Pod 一个,无状态。
  • 控制面(Istiod,一体化):现代 Istio 以 Istiod 单体承载发现、证书、配置校验与 xDS 下发;文献中的 Pilot/Citadel/Galley 是历史组件拆分叙事,生产按 Istiod 理解即可。Istiod 从 K8s API Server(及 Consul/Nacos 等)订阅端点,经 xDS 下发到数据面。

xDS 协议:Envoy 的动态配置 API——EDS(端点发现)、CDS(集群发现)、RDS(路由发现)、LDS(监听发现)。Istiod 推送配置,Envoy 热更新无需重启,实现动态路由与流量治理。

flowchart TB subgraph CP["控制面 Istiod(现行一体;Pilot/Citadel/Galley 为历史拆分叙事)"] Pilot["发现+路由
(历史名 Pilot)"] Citadel["mTLS 证书
(历史名 Citadel)"] Galley["配置校验
(历史名 Galley)"] end subgraph Node1["Pod A"] App1["App 进程"] Env1["Envoy Sidecar"] App1 -->|出流量| Env1 Env1 -->|入流量| App1 end subgraph Node2["Pod B"] App2["App 进程"] Env2["Envoy Sidecar"] App2 -->|出流量| Env2 Env2 -->|入流量| App2 end Env1 -.->|mTLS 加密流量| Env2 Pilot -->|xDS 推送| Env1 Pilot -->|xDS 推送| Env2 Citadel -->|证书分发| Env1 Citadel -->|证书分发| Env2 style CP fill:#ede9fe,stroke:#7c3aed style Node1 fill:#f0f3f7,stroke:#6b7280 style Node2 fill:#f0f3f7,stroke:#6b7280
图 10.5-2 Service Mesh 架构:Sidecar 拦截流量,控制面 xDS 下发配置

库治理 vs Sidecar 治理对比:

维度 库治理(Spring Cloud/Dubbo) Sidecar 治理(Istio)
语言支持 每语言一套 SDK,多语言难 语言无关,Sidecar 统一
升级侵入 改 SDK 要全量发版应用 升级 Sidecar 与应用解耦
治理与业务 治理代码耦合进应用 治理下沉,应用只关业务
性能开销 进程内调用,无额外跳 多一跳 Sidecar,延迟与资源增加
可观测 各 SDK 各自实现 统一指标/追踪/日志
学习运维成本 低 高(Istio 复杂)
工程实践

Istio 流量治理示例(VirtualService 路由 + DestinationRule 熔断 + 金丝雀):

# VirtualService: 90% 流量到 v1, 10% 到 v2 (金丝雀)
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: order-service
spec:
  hosts: [order-service]
  http:
    - route:
        - destination: { host: order-service, subset: v1 }
          weight: 90
        - destination: { host: order-service, subset: v2 }
          weight: 10
---
# DestinationRule: 定义版本子集 + 熔断(连接池/异常剔除)
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: order-service
spec:
  host: order-service
  subsets:
    - name: v1
      labels: { version: v1 }
    - name: v2
      labels: { version: v2 }
  trafficPolicy:
    connectionPool:
      tcp: { maxConnections: 100 }
      http: { http1MaxPendingRequests: 50, maxRequestsPerConnection: 10 }
    outlierDetection:           # 熔断: 异常实例自动剔除
      consecutive5xxErrors: 5
      interval: 30s
      baseEjectionTime: 60s
      maxEjectionPercent: 50

mTLS 服务间加密:Istio 自动为每个 Pod 签发证书,Sidecar 间流量自动 mTLS 加密,零代码改造实现全链路加密。配合 AuthorizationPolicy 实现基于身份的服务间访问控制(零信任网络)。

渐进式迁移:生产不要一次性全量上 Mesh。建议:① 先在测试集群验证;② 选非核心服务试点;③ 用 Istio 的 Permissive 模式(mTLS 可选)平滑过渡;④ 逐步开启 Strict 模式与策略。

深度追问

追问 1:Sidecar 的性能开销有多大?如何优化?

每个 Pod 多一个 Envoy 容器(内存几十~百 MB,CPU 占用),且流量多一跳(应用→Sidecar→网络→对端 Sidecar→对端应用),延迟增加约 1-3ms。Ambient Mesh 去掉每 Pod Sidecar,改用节点级 ztunnel(L4)+ 按需 waypoint(L7),资源开销降一个数量级;eBPF 在内核态处理进一步降延迟。资源规划上 Sidecar 通常占 Pod 总资源 20-30%。

Ambient 落地要点(实践):① 命名空间打标启用 Ambient(如 istio.io/dataplane-mode=ambient,以所用 Istio 版本文档为准);② ztunnel 以 DaemonSet 跑在节点,负责 L4 安全与 HBONE;③ 需要 L7 策略(HTTP 路由/鉴权/遥测细粒度)时再为服务挂 waypoint proxy,按需付费而非全量 Sidecar;④ 从 Sidecar 迁 Ambient 要验证 mTLS、AuthorizationPolicy 与现有 VirtualService 行为,灰度命名空间推进。新集群优先评估 Ambient;存量 Sidecar 可并存过渡。

追问 2:Service Mesh 会不会取代微服务框架(Spring Cloud)?

治理层(注册发现/熔断/限流/追踪)会被 Mesh 取代,但业务层框架(Spring Boot 的 Web/ORM/事务)不会被取代。未来趋势是 Spring Boot 只管业务,治理全交 Mesh。Dubbo 3 也支持与 Mesh 协同(Triple 协议 + 控制面对接)。

常见陷阱
易错点
  • Sidecar 资源未规划:默认 limit 过小导致 Envoy OOM,需显式配置 resources。
  • 全量上 Mesh 风险大:一次性注入所有 Pod,流量劫持配置错误可能全站断流。
  • 控制面 Istiod 单点:需多实例 + HA,否则 Istiod 宕机配置无法更新。
  • 忽视 Sidecar 与应用启动顺序:应用先于 Sidecar 启动可能导致流量丢失,需 holdApplicationUntilProxyStarts。
  • 误以为 Mesh 解决一切:Mesh 治理流量,不解决业务一致性/事务问题。
扩展补全

在掌握 Istio 基础流量治理(金丝雀路由、熔断、mTLS)后,补齐三大高频实战考点:流量镜像、故障注入、限流协同,并厘清 Istio 与 Spring Cloud Gateway 的治理边界。

  • 流量镜像(Mirroring):在 VirtualService 中配置 mirror 字段(如 mirror: { host: order-service, subset: v2 }),把线上真实流量实时复制一份发给新版本,新版本只接收、不响应。用途:灰度发布前的真实流量验证——新版拿真实请求验证处理能力,且不改变用户体验。注意:镜像流量是异步的,不阻塞主流量,镜像目标异常也不影响主链路;需用 mirrorPercentage 控制镜像比例,避免瞬间打垮新版本。
  • 故障注入(Fault Injection):HTTPAbort 模拟 HTTP 错误码(如 503),用于验证熔断降级逻辑是否生效;HTTPDelay 注入延迟(如 5s),用于验证超时与重试配置。本质区别:故障注入是主动注入错误来测试系统韧性(发布前演练 / 混沌测试),限流是被动防御(常态保护),两者目的不同、不可混为一谈。
  • 限流协同(Envoy Rate Limit Service):Istio 限流走 Envoy,支持本地限流(token bucket,local_rate_limit Filter)与全局限流(独立的 rate-limit 服务 + Redis 后端)。与 Spring Cloud Gateway 对比:Istio 在 Sidecar 层限流,对应用透明,无需业务改造;Gateway 在网关入口限流,需业务感知与路由配置。生产最佳实践是联合使用:Istio 做全局限流(按服务 / 命名空间设 QPS 上限,保护基础设施),应用内再做细粒度限流(按用户维度,保护业务逻辑)。

Istio vs Spring Cloud Gateway(SCG)治理能力对比:

维度 Istio Spring Cloud Gateway
限流位置 Sidecar(对应用透明) 网关入口
故障注入 原生支持(HTTPAbort / HTTPDelay) 需自行实现
流量镜像 原生支持 不支持
语言无关 是 仅 Java
深入解析

深挖点:为什么流量镜像"只收不应"仍能完成验证?因为镜像副本的价值在于用真实流量观测新版本的表现——是否抛异常、处理耗时、日志告警、依赖调用是否异常,全部通过可观测数据(指标 / 追踪 / 日志)验证,而非依赖镜像副本的响应。这也正是"镜像发布失败对用户零影响"的根本原因:主链路流量完全不受镜像结果左右。注意镜像只解决"验证"不解决"放量"——正式上线仍需配合金丝雀权重路由逐步切流,形成"镜像验证 → 金丝雀放量 → 全量"的完整灰度链路。

C10.06
单体到微服务的架构演进路径如何规划?架构P1
本质问题

考察架构演进的节奏把控与拆分方法论,反对"一步到位全拆"与"过度拆分"。核心回答:何时该拆(信号)、怎么拆(DDD 限界上下文)、用什么策略拆(绞杀者模式/模块化单体过渡),以及如何避免"分布式单体"陷阱。

演进脉络

架构形态演进:单体 → 模块化单体 → 服务化 → 微服务 → 领域微服务。这不是线性强制升级,而是按业务规模与团队规模匹配的渐进过程。

SOA vs 微服务:SOA 重 ESB 集中编排、粗粒度服务、企业级集成;微服务重去中心化、细粒度自治、独立部署。康威定律(Conway's Law):系统结构跟随组织沟通结构——微服务需配套小团队(两披萨团队),否则拆了也管不好。

拆分方法论演进:早期按技术层次拆(Web/Service/DAO 层服务,反模式)→ 按业务能力拆 → DDD 限界上下文驱动拆分(领域内聚、上下文边界即服务边界)。Eric Evans 2003 的 DDD 在微服务时代焕发第二春。

趋势:① 模块化单体(Modular Monolith)回归——先在单体内按模块严格解耦(独立模块+独立 schema),再按需物理拆分,避免过早分布式化;② 自治团队 + 领域驱动;③ 与 Serverless/Mesh 结合的细粒度服务。

第一性原理

何时拆(拆分信号):

  • 单体迭代瓶颈:改一处需全量回归测试、发布窗口冲突频繁。
  • 团队规模扩张:多团队改同一代码库,合并冲突严重。
  • 部署耦合:非核心变更导致核心模块跟着重启。
  • 技术栈诉求:不同模块需不同技术栈(如 AI 模块用 Python)。
  • 扩展差异:某模块需独立水平扩展(如秒杀),其他模块无需。

怎么拆(DDD 限界上下文):通过事件风暴梳理领域事件、命令、聚合,识别限界上下文边界。一个限界上下文对应一个服务,上下文内高内聚、上下文间低耦合。按业务能力拆,绝不按技术层次拆(Web 层服务、DAO 层服务是反模式,制造无意义网络调用)。

拆分策略:

  1. 绞杀者模式(Strangler Fig):新功能直接写新服务,旧功能逐步迁移到新服务,老单体逐步"绞杀"直至下线。借鉴 Martin Fowler 的绞杀者无花果比喻。优势:渐进、可回滚、风险可控。
  2. 模块化单体过渡:先在单体内按领域拆模块(独立包+独立 schema+接口调用),验证边界合理性,再物理拆分为独立服务。降低一次性拆分风险。
  3. 数据库先行 vs 服务先行:先拆服务再拆库风险极大(共享库形成分布式单体)。推荐"服务 + 库配套拆"——拆服务时同步拆分其独占数据,跨服务数据访问走 API。
flowchart LR subgraph S1["阶段1: 单体"] M1["单体应用
+ 共享大库"] end subgraph S2["阶段2: 模块化单体"] M2["模块化单体
模块间接口调用
仍共享库"] end subgraph S3["阶段3: 绞杀者迁移"] N1["新服务 order"] N2["新服务 user"] O1["旧单体(逐步萎缩)"] end subgraph S4["阶段4: 领域微服务"] MS1["order-service
+order库"] MS2["user-service
+user库"] MS3["payment-service
+pay库"] end S1 --> S2 --> S3 --> S4 style S4 fill:#dcfce7,stroke:#059669
图 10.6-1 单体到微服务渐进演进路线:模块化单体→绞杀者→领域微服务

分布式单体陷阱:拆了服务但① 共享同一数据库(数据耦合,改表牵连多服务);② 服务间强同步依赖链(A→B→C→D,一次请求穿四个服务,任一挂全挂);③ 必须一起部署(部署耦合未消除)。本质是有了分布式的成本,没享受分布式的收益。

工程实践

DDD 事件风暴划分限界上下文:① 召集领域专家与开发,梳理核心域/支撑域/通用域;② 用便利贴罗列领域事件(订单已创建、库存已扣减、支付已完成);③ 按事件流转识别聚合与上下文边界;④ 上下文映射(合作关系/客户-供应商/防腐层)。

数据库拆分三步法(避免分布式单体):

  1. 同步双写过渡:先在单库内按服务拆表,应用层双写保持兼容。
  2. 读切到新库:新服务读自己的库,写仍双写。
  3. 写切到新库 + 停旧写:新服务独立读写自己的库,通过领域事件(MQ)异步同步给旧系统,最终下线旧库访问。

服务间通信:同步用 OpenFeign/gRPC(需超时+熔断+重试),异步用领域事件 + MQ(解耦+削峰)。优先异步,降低同步依赖链。跨上下文数据一致性用 Saga/可靠消息最终一致,不追求强一致。

最佳实践

"两个披萨团队"原则:一个微服务由一个 6-10 人小团队完全负责(设计/开发/部署/运维),服务边界与团队边界对齐。若一个服务需多个团队协作,说明边界划得过粗;若一个团队管太多服务,说明划得过细。

深度追问

追问 1:微服务拆分粒度如何把握?

三准则:① 一个服务一个团队负责;② 可独立部署(不依赖其他服务同时发版);③ 领域内聚(一个限界上下文)。反对"一个表一个服务"(过细,分布式开销爆炸)与"一个大服务包揽多领域"(过粗,等于没拆)。Sam Newman 强调"先粗后细",从粗粒度服务开始,按需细化。

追问 2:微服务一定比单体好吗?

否。小团队、简单业务、迭代节奏慢的场景,单体更优(无分布式开销、开发调试简单、部署直接)。微服务的收益(独立扩展、技术异构、团队自治)伴随成本(分布式事务、网络故障、运维复杂、调试困难)。Martin Fowler 的"微服务溢价"指出:只有当单体痛到值得承担微服务复杂度时才拆。

常见陷阱
易错点
  • 一上来全拆:缺乏领域分析,按表/按层乱拆,制造分布式单体。
  • 分布式单体:拆了服务但共享库或强同步依赖,徒增复杂度。
  • 共享数据库:多服务读写同一库,数据耦合无法独立演进。
  • 同步调用链过长:A→B→C→D 穿透,任一故障全链失败。
  • 忽视康威定律:组织结构未调整就拆服务,团队边界与服务边界错配。
  • 无回滚预案的全量迁移:一次性切换,失败无法回退。
C10.07
高可用架构设计的核心原则与实践?架构P1
本质问题

高可用 = 冗余 + 故障隔离 + 故障转移 + 降级预案,考察"为失败而设计"(Design for Failure)的思维。核心回答:如何消除单点、如何隔离故障防扩散、如何自动转移、如何验证韧性(混沌工程),以及异地多活的单元化设计。

演进脉络

容灾形态演进:主备(冷备,故障手工切)→ 热备(实时同步,自动切换)→ 双活(两机房同时承载流量)→ 异地多活(多机房多活,单元化)→ 多云(跨云厂商容灾)。

验证手段演进:人工故障演练 → Chaos Monkey(2011,Netflix)随机杀实例 → ChaosBlade(阿里)故障注入工具 → Litmus(CNCF)混沌工程平台。从"被动等故障"到"主动注入故障验证韧性"。

趋势:① 单元化架构(Cell-based)成异地多活主流;② 混沌工程常态化(生产环境持续小规模注入);③ 多云/混合云容灾;④ 基于 SLO 的弹性预算(错误预算驱动发布节奏)。

第一性原理

高可用五大核心原则:

  1. 冗余(Redundancy):多副本、多机房、多可用区,消除单点。无状态服务多实例,有状态数据多副本(主从/集群)。
  2. 故障隔离(Isolation):舱壁模式(Bulkhead)——线程池/信号量隔离、集群分组(泳道)、机房隔离。防止一个故障耗尽共享资源拖垮全局。
  3. 故障转移(Failover):健康检查 + 自动切换。主从切换(MHA/MGR/Redis Sentinel)、负载均衡剔除故障实例、DNS 切换机房。
  4. 降级预案(Degradation):限流熔断、有损服务(关闭非核心如推荐)、兜底数据(缓存/默认值)。保证核心链路可用。
  5. 可观测(Observability):监控告警 + 全链路追踪 + 日志,故障快速发现定位。

RTO / RPO 指标:

  • RTO(Recovery Time Objective):恢复时间目标,故障到恢复服务的时间。RTO 越小要求自动切换能力越强。
  • RPO(Recovery Point Objective):数据恢复点目标,允许丢失的数据时间窗口。RPO=0 要求同步复制。
flowchart TB Goal["高可用目标: SLA 99.99%+
(年宕机 < 52.6 分钟)"] Goal --> R1["1.冗余
多副本/多机房/多AZ"] Goal --> R2["2.故障隔离
舱壁/泳道/机房隔离"] Goal --> R3["3.故障转移
健康检查+自动切换"] Goal --> R4["4.降级预案
限流熔断+有损服务"] Goal --> R5["5.可观测
监控+追踪+日志"] R1 --> MT["异地多活单元化
流量按用户路由单元"] R4 --> CH["混沌工程验证
ChaosBlade/Litmus"] style Goal fill:#dbeafe,stroke:#2563eb style MT fill:#dcfce7,stroke:#059669
图 10.7-1 高可用架构设计框架:五大原则 + 单元化 + 混沌验证

异地多活单元化架构:将用户按 ID 哈希划分到单元(Cell),每个单元自洽(应用+DB+缓存完整链路),单元内闭环,跨单元仅异步同步少量数据。某机房故障时,其单元流量切换到其他单元。阿里/美团大促核心方案。挑战:跨单元数据一致性(如全局唯一库存)、单元路由、单元间数据同步。

容灾方案 RTO RPO 成本 说明
冷备 小时~天级 大 低 备机不跑业务,故障手工切,数据可能丢
热备 分钟级 秒级 中 实时同步,自动切换,一主一备
双活 秒级 ≈0 高 两机房同时承载,负载均衡
异地多活 秒级 秒级 极高 多机房多活单元化,需处理跨单元一致性
多云 秒级 秒级 极高 跨云厂商,防云厂商级故障
工程实践

异地多活单元化设计要点:① 选路由维度(用户 ID 取模分单元);② 单元内闭环(同单元应用+DB+缓存);③ 跨单元数据同步(Binlog/DTS 异步,仅同步需全局共享的数据);④ 全局唯一资源(如库存)用中心化扣减或分片预留;⑤ 流量调度中心切流。

混沌工程实验流程(ChaosBlade 示例):① 定义稳态假设(如订单创建成功率 > 99.9%);② 选择故障注入维度(CPU 满载/网络延迟/磁盘满/Pod 杀死/依赖延迟);③ 在生产或预发小范围注入;④ 监控指标是否偏离稳态;⑤ 记录系统行为,修复缺陷;⑥ 常态化定期演练。

# ChaosBlade 注入故障: 给 order-service Pod 注入 500ms 网络延迟
blade create k8s container-network delay \
    --time 500 \
    --namespace prod \
    --labels app=order-service \
    --container-names order-service

# 恢复故障
blade destroy <experiment-id>

第三方依赖防护:对外部 API/中间件设置超时 + 熔断 + 降级 + 重试(有上限),避免外部抖动拖垮自身线程池。这是最常被忽视的高可用短板。

深度追问

追问 1:异地多活如何处理跨机房数据一致性?

核心策略:① 单元化——大部分数据在单元内闭环,无需跨机房一致;② 全局共享数据用中心化写入 + 异步同步到各单元(接受秒级延迟);③ 冲突解决——多单元写同一数据用 last-write-wins 或业务规则仲裁;④ 全局唯一性——用雪花算法/号段避免跨单元 ID 冲突;⑤ 跨单元强一致需求退化为单点写入(牺牲多活)。

追问 2:SLA 99.99% 意味着什么?如何达成?

99.99% = 年宕机 < 52.6 分钟。达成需:① 多机房多活(单机房故障不影响);② 全链路无单点(每层冗余);③ 自动故障转移(RTO 秒级);④ 充分降级预案;⑤ 混沌工程持续验证;⑥ 灰度发布 + 快速回滚控制变更风险。99.99% 以上每提升一个 9 成本指数级增长。

常见陷阱
易错点
  • 有冗余但无自动转移:主备部署但故障靠人切,RTO 无法保证。
  • 忽视第三方依赖可用性:自身高可用但依赖的短信/支付 API 抖动即雪崩,必须加超时熔断。
  • 降级预案从不演练:预案写在文档里,真故障时发现不生效或逻辑错。
  • 异地多活忽视数据一致:跨单元写冲突未处理,导致数据错乱。
  • 只防服务器故障忽视变更故障:大部分故障来自变更,需灰度+回滚+变更管控。
  • 监控盲区:只盯服务器指标,忽视业务指标(成功率/RT),故障发现滞后。
C10.08
微服务配置中心的设计与选型?高级P2
本质问题

配置中心 = 集中配置存储 + 变更推送 + 版本管理 + 灰度发布,考察配置治理的工程能力。核心回答:配置中心的核心能力矩阵、推送机制的权衡、配置热更新原理,以及 Nacos/Apollo/Spring Cloud Config 的选型差异。

演进脉络

配置管理演进:properties 文件(打包死)→ 环境变量(12-Factor)→ 配置中心(集中动态)→ 配置即代码(GitOps)。

Spring Cloud Config(2015):以 Git/SVN 为后端,天然版本管理。但无原生推送,需配合 Spring Cloud Bus(MQ 广播)刷新,延迟分钟级,运维链路长。

Apollo(携程,2016):旁路独立部署,多环境/多集群/多命名空间,长轮询实时推送(秒级),权限与审计完善,灰度发布内置。企业级配置中心标杆。

Nacos(2018):注册 + 配置一体,长轮询推送,与 Spring Cloud Alibaba 深度集成。中小团队一站式首选。

趋势:① GitOps(ArgoCD/Flux)——配置以代码形式存 Git,Controller 同步到 K8s,声明式 + 可审计 + 可回滚;② K8s ConfigMap + Operator 管理应用配置;③ 配置与密钥分离(Vault/KMS 管密钥)。

第一性原理

配置中心核心能力矩阵:

  1. 集中存储:多环境(dev/test/prod)、多集群、多命名空间隔离。
  2. 配置热更新:推送机制(长轮询/Watch),变更秒级生效,无需重启。
  3. 版本管理:每次变更版本化,支持 diff、回滚。
  4. 灰度发布:按 IP/标签灰度推送部分实例验证。
  5. 权限审计:配置修改的权限控制与操作审计。
  6. 容灾兜底:配置中心宕机时客户端用本地快照缓存继续运行。

推送机制对比(关键原理):

  • 长轮询(Nacos/Apollo):客户端发起 HTTP 请求,服务端hold 住连接(Nacos ~29.5s),期间配置变更则立即返回,否则超时返回后客户端重新发起。兼顾实时性与连接数。
  • Watch(ZK/Consul):服务端主动推送,更实时但需维护长连接与重连。
  • Bus 刷新(Config):MQ 广播刷新信号,依赖 MQ 可用性。
flowchart LR subgraph CC["配置中心集群"] Store["配置存储
(MySQL/etcd)"] Push["推送服务
(长轮询 hold 29.5s)"] end Admin["运维/平台"] -->|"改配置+灰度规则"| Store Store --> Push Push -.->|"变更立即返回
否则超时重发"| C1["App-1
本地快照"] Push -.->|"变更立即返回"| C2["App-2
本地快照"] C1 -->|"@RefreshScope
重新注入"| Biz1["业务 Bean"] C2 -->|"@RefreshScope
重新注入"| Biz2["业务 Bean"] C1 -.->|"容灾: CC宕机用快照"| Snap1[(本地snapshot)] style CC fill:#dbeafe,stroke:#2563eb
图 10.8-1 配置中心架构:长轮询推送 + 本地快照容灾 + 热刷新

Spring Boot 热更新原理:配置中心推送变更 → @RefreshScope 标注的 Bean 被销毁 → 下次访问时重新创建并注入新配置(基于 ContextRefresher + RefreshScope)。非 @RefreshScope 的 Bean 配置不会自动刷新(如 DataSource 连接池,需重启或单独处理)。

组件 推送 版本/回滚 灰度 权限审计 生态
Spring Cloud Config 需 Bus,弱 Git 原生 弱 Git Spring Cloud
Apollo 长轮询,强 完善 内置 完善 独立,多语言
Nacos 长轮询,强 有 有 基础 Spring Cloud Alibaba
K8s ConfigMap 需 reload GitOps — K8s RBAC 云原生
工程实践

Nacos 配置热更新 + 灰度发布:

// bootstrap.yml —— 配置中心
spring:
  cloud:
    nacos:
      config:
        server-addr: ${NACOS_ADDR}
        namespace: prod              # 环境隔离
        group: ORDER_GROUP           # 业务组隔离
        shared-configs:              # 共享配置(多个服务复用)
          - data-id: common.yaml
            refresh: true
        extension-configs:           # 扩展配置
          - data-id: redis.yaml
            refresh: true

// 需热更新的 Bean 加 @RefreshScope
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "order")
@Data
public class OrderConfig {
    private int timeout;
    private String retryStrategy;
}

// 监听配置变更(非 @RefreshScope 场景手动处理)
@Component
public class ConfigListener {
    @NacosConfigListener(dataId = "order-service.yaml")
    public void onChange(String config) {
        log.info("配置变更: {}", config);
        // 重建连接池/缓存等非 RefreshScope 资源
    }
}

配置灰度发布:Nacos 控制台/Beta 推送指定 IP;Apollo 按 IP/集群灰度。先灰度 1-2 台验证,再全量推送。敏感配置(数据库密码、密钥)不应明文存配置中心,需用 KMS/Vault 加密,配置中心只存密文。

深度追问

追问 1:配置中心自身高可用如何保证?

① 配置中心集群部署(Nacos 3+ 节点 AP,Apollo ConfigService 多实例);② 配置存 MySQL/etcd 高可用;③ 客户端本地快照兜底——配置中心全宕机时,客户端用上次拉取的本地快照继续启动与运行(Nacos/Apollo 均支持),保证配置中心故障不阻断业务;④ 推送链路降级。

追问 2:频繁热更新配置会触发什么问题?

@RefreshScope 频繁销毁重建 Bean 有开销,且重建期间请求可能拿到不一致状态。连接池类配置(如 maxPoolSize)热更新不会真正生效(连接池不会自动重建)。建议:① 区分静态配置(启动时读,需重启)与动态配置(运行时可变);② 控制热更新频率;③ 重资源用专门的 reload 机制而非 RefreshScope。

常见陷阱
易错点
  • 配置中心宕机全瘫:客户端无本地快照兜底,配置中心故障业务起不来。
  • 敏感配置明文存储:数据库密码、密钥直接存配置中心,需加密。
  • 频繁热更新触发频繁刷新:Bean 反复重建影响性能与一致性。
  • 配置无版本不可回滚:改错无法快速回退,必须选支持版本的配置中心。
  • 所有 Bean 都加 @RefreshScope:增加不必要的重建开销,只给需动态的加。
  • 环境隔离不到位:dev 配置误推 prod,配置中心需严格的 namespace/group 隔离。
Nacos 版本演进与 2.x gRPC 长连接 Push 模型

① Nacos 版本演进时间线:

版本 关键能力
1.0.0(2018 开源) 服务发现 + 配置管理双引擎,AP/CP 模式可切换(Distro 协议做 AP 临时实例、Raft 做 CP 持久实例)
1.2.0 配置支持 MySQL 持久化(生产必开,避免单机 Derby 丢数据)
2.0.0 通信层从 HTTP 短连接升级为 gRPC 双向长连接(端口 9848/9849),服务发现 + 配置推送全改主动 Push,性能与实时性质飞跃
2.5.x LTS 长期支持版本,生产推荐基线
3.0.0 注册中心元数据用 JRaft 强一致、引入 AI 配置治理等

② 1.x → 2.x Push 模型对比(高频考点):

维度 1.x 2.x
配置推送 HTTP 长轮询(客户端 hold ~29.5s,服务端变更后"通知",客户端再二次拉取完整配置) gRPC 长连接,服务端直接主动 Push变更(仍是"通知 + 二次拉取"二阶段范式,仅升级通知通道)
服务发现 UDP 推送变更 + 客户端 10s 定时拉取兜底(UDP 丢包靠定时拉取补偿) gRPC 双向长连接,实例变更服务端实时 Push
端口 8848(HTTP)+ UDP 8848(HTTP 兼容)+ 9848/9849(gRPC 主/测)
实时性 / 开销 依赖轮询,秒级延迟、空轮询浪费连接 毫秒级、无空轮询、长连接复用
易错点

误以为 2.x 是"服务端直推完整数据"——Nacos 配置推送本质仍是通知 + 二次拉取二阶段模型(gRPC 只替换"通知"通道,客户端收到通知后仍拉取最新快照);ZK/Etcd 类 CP 系统 Watch 事件才真正直推完整数据(见 C10.27)。

C10.09
灰度发布与蓝绿部署如何设计?架构P1
本质问题

考察发布工程的安全平滑发布策略与回滚能力。核心回答:蓝绿/金丝雀/灰度(流量染色)的区别与适用场景,全链路灰度的设计(标签透传),以及数据库变更如何灰度、回滚如何设计。

演进脉络

发布策略演进:停机发布(维护窗口,用户体验差)→ 蓝绿部署(双环境切换)→ 金丝雀发布(小流量验证)→ 灰度发布(流量染色精确路由)→ 全链路灰度(多服务协同灰度)。

金丝雀典故:矿工带金丝雀下井,金丝雀先死警示危险——引申为"先用小部分流量试探新版本"。

趋势:① 复杂微服务系统常用全链路灰度,但前提是入口、注册/配置面、RPC、MQ 和观测系统都支持标签语义;② 与 Service Mesh 结合时可用 VirtualService/HTTPRoute 表达权重和 Header 路由,是否可用取决于网格与 Gateway API 版本;③ 特征开关(Feature Flag)把业务放量的决策与代码发布解耦,但开关本身也需要生命周期治理。

第一性原理

三种发布策略本质对比:

策略 本质 流量切换 资源 回滚
滚动发布 按批次替换旧实例,新旧版本短期共存 批次逐步推进 通常只需一批额外容量 停止批次并回滚新镜像/重新发布旧版本;数据库必须兼容新旧实例
蓝绿部署 两套完整环境(蓝/绿),新版本部署到空闲环境,一次性切换 100% 切换 双倍 切回旧环境;LB/DNS 的传播速度决定实际切换时间
金丝雀发布 少量流量到新版本,观察指标逐步扩大比例 按比例(5%→20%→100%) 略多于 1 套 流量全切回旧版本
灰度发布 基于用户/地域/标签的流量染色路由,精确到用户维度 按规则(如 VIP 用户先灰度) 新旧并存 关闭灰度规则

全链路灰度:微服务下,一次请求经过多个服务,灰度标签必须在全链路透传——入口/网关染色(加 Header 如 X-Gray-Tag=v2)→ 服务 A 识别标签路由到 A 的 v2 → A 调 B 时透传标签 → B 路由到 B 的 v2 → 跨 MQ 也要透传。实现前提按平台分层确认:Spring Cloud 用注册中心元数据 + LoadBalancer 过滤;Dubbo 用 TagRouter/标签附件;Ingress/API Gateway 需要路由规则支持 Header/权重;Service Mesh 由 Sidecar 执行路由;MQ 要求生产者和消费者都携带并处理版本标签。此外,金丝雀/灰度若要求同一用户稳定命中同一版本,需要路由层支持会话亲和(sticky session)或基于用户标识的一致性哈希;这类能力取决于 Ingress/Service Mesh/LB 的具体实现,并非所有平台默认可用,规划时需先确认平台能力边界。缺少任一环,标签只是“可见”,不能保证“可路由”。

flowchart LR Client["客户端"] --> GW["API 网关"] GW -->|"染色: X-Gray=v2
(按用户/地域)"| Route{"路由判断"} Route -->|"灰度标签"| A2["order-service v2"] Route -->|"无标签"| A1["order-service v1"] A2 -->|"透传 X-Gray"| B2["user-service v2"] A1 --> B1["user-service v1"] A2 -.->|"MQ 透传标签"| C2["payment-service v2"] style A2 fill:#fef3c7,stroke:#d97706 style B2 fill:#fef3c7,stroke:#d97706 style C2 fill:#fef3c7,stroke:#d97706
图 10.9-1 全链路灰度:网关染色 + 标签透传 + 按版本路由

回滚机制:① 应用回滚——切流量回旧版本(秒级);② 数据库回滚——难点,需向前兼容设计(新增字段而非删除/改类型),避免 schema 回滚困难;③ 配置回滚——配置中心版本回退;④ 发布前必须验证回滚预案可行。

工程实践

网关流量染色 + Spring Cloud LoadBalancer 元数据路由:

// 网关 Filter: 按用户 ID 决定是否染色(如 1% 用户灰度)
@Component
public class GrayFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
        // X-User-Id 必须由认证边界产生;此处假设该 Filter 在可信身份注入之后执行。
        if (userId != null
                && (grayWhiteList.contains(userId) || Math.floorMod(userId.hashCode(), 100) == 0)) {
            ServerHttpRequest mutated = exchange.getRequest().mutate()
                .headers(headers -> headers.remove("X-Gray-Tag"))
                .header("X-Gray-Tag", "v2")
                .build();
            return chain.filter(exchange.mutate().request(mutated).build());
        }
        return chain.filter(exchange);
    }
    @Override public int getOrder() { return -100; }
}

全链路灰度标签透传:① 网关注入 X-Gray-Tag;② RPC 框架(Dubbo/Spring Cloud)从 Header/Attachment 读取标签,调下游时透传;③ 注册中心实例带版本元数据(v1/v2),消费者按标签选实例;④ 跨 MQ 也将标签塞入消息属性。Dubbo 用 TagRouter 实现,Spring Cloud 用 LoadBalancer 的 Metadata 过滤。

数据库灰度变更(向前兼容):扩展 → 双写 → 切读 → 收缩,并为每一步定义新旧应用兼容窗口。① 扩展:新版本只加字段(不删不改类型),旧版本兼容运行;② 双写:新旧字段同时写,并考虑失败补偿与对账;③ 切读:灰度流量读新字段验证;④ 收缩:全量切到新字段且旧版本退出后,再在下个变更窗口删除旧字段。灰度期间不得删除字段、收窄类型或做破坏性变更;索引和锁影响要提前在等价数据量上演练。

深度追问

追问 1:数据库 schema 变更如何灰度?

核心是向前兼容:灰度期间新旧版本同时运行,schema 必须两者兼容。① 只增不删(加字段、加表);② 改类型用新字段过渡(加 new_field 双写,切读后再处理旧字段);③ 删除字段分两步——先发版本停止读写该字段,确认所有旧实例退出后再删表。扩大列长/加索引的在线行为取决于 MySQL/存储引擎版本、算法和表规模,发布前必须用目标版本和真实数据量验证。禁止灰度期间做破坏性变更。

追问 2:灰度命中规则不一致怎么排查?

常见原因:① 标签在某跳 RPC 丢失(未透传 Header/Attachment);② 注册中心元数据未及时刷新,消费者仍路由旧版本;③ MQ 消费未透传标签导致下游处理版本错乱;④ 多入口(网关+定时任务)染色规则不统一。排查靠全链路日志打印灰度标签,定位断链点。建议统一封装灰度上下文工具,避免各服务手写透传。

常见陷阱
易错点
  • 蓝绿两套环境共享数据库:schema 变更两环境都受影响,失去隔离意义,蓝绿应各自独立库或只读共享。
  • 灰度标签丢失导致流量串版本:RPC/MQ 跳转未透传,灰度用户流量串到 v1。
  • 无监控盲目放量:不观察 RT/错误率就扩大灰度比例,问题扩散。
  • 不兼容的数据库变更:灰度期间删字段/改类型,旧版本直接报错。
  • 回滚未演练:真故障时回滚脚本失效或数据不一致。
  • 灰度范围与回滚速度矛盾:放太大回滚慢,太小验证不充分,需按指标阶梯放量。
扩展补全

心智模型:蓝绿的"环境"=无状态计算资源,数据库被独立剥离:蓝绿说的"两套环境"主要指无状态应用实例(容器/中间件),数据库作为有状态设施被统一剥离管理,业务数据逻辑主体只有一份(权威数据源),切换本质是 LB/网关把流量从蓝集群瞬间切到绿集群,连接串通常不变。即"空间换时间、非黑即白(0%↔100%)",致命约束是数据库写入不能灰度。

生产数据库隔离三模式:① 物理共享·逻辑共用(最常见)——蓝绿连同一库,隔离的是流量非数据,DB 是单点风险,Schema 变更须严格向后兼容;② 物理两份·逻辑同步(最安全)——绿库为蓝库实时从库(CDC/主从),权威源唯一(切前蓝、切后绿),另一份只是热备;③ 逻辑隔离·物理共享(影子表)——同实例不同表(order_blue/order_green),仅用于压测,违背业务数据唯一性,生产慎用。与 C10.09 的"共享库 pitfall"呼应:方案①下 Schema 变更两环境都受影响,失去隔离意义。

割接(Cutover)四步 + 脑裂风险

写权转移必须瞬间全量完成,绝不可按百分比切。标准步骤:① 停写:蓝环境先拒绝写(或短暂挂起)使蓝库数据静止;② 数据追平:等待最后几毫秒同步数据完全复制到绿库,两库一致;③ 一键全切:网关把 100% 流量瞬间蓝→绿;④ 提升绿库:切断蓝→绿复制链路,绿库提升为新独立主库。若让"部分流量写绿、另一部分写蓝",两库产生脑裂(split-brain),数据分裂成两份冲突主数据,双向同步极复杂且易丢数据,生产绝不允许。结论:写只能全切,读可按部分流量引入绿环境验证,但主库只有一个。

金丝雀 vs 灰度:包含关系与目的差异:金丝雀是灰度的子集(具体战术 vs 通用策略),金丝雀终点必全量,灰度不一定。① 金丝雀——核心目的系统稳定性探雷(关注 CPU/错误率),1% 跑完没问题就全上,回滚直接切走 1% 流量或杀 Pod,简单粗暴;② 灰度(广义)——核心目的业务策略与用户分流(关注点击率/转化率),按地域/设备/用户等级/Header 分流,可能永远停在 50% 做 A/B 测试(如新路开放 30% 对比老路,不打算全量)。回滚一致性:基于"用户 ID 哈希/地域"分流的灰度回滚须考虑"用户一致性"——如广东用户已在新页面下单,突然回滚其购物车新优惠券可能失效致客诉,需带数据迁移/补偿逻辑,比金丝雀复杂得多。终极定义:金丝雀="先少量试吃,没问题就全上"(版本发布的运维策略);灰度="把用户分组,各组走不同的路"(用户流量的路由策略)。

C10.10
Serverless 架构的适用场景与挑战?架构P1
本质问题

考察云计算演进终态 FaaS + BaaS 的边界与 Java 在 Serverless 中的适配。核心回答:Serverless 的本质(按需执行、按用量计费、免运维)、冷启动问题、Java 冷启动的挑战与解法(Native Image/CRaC),以及适用与不适用场景的清晰边界。

演进脉络

云计算演进:物理机(自建机房)→ IaaS(虚拟机,AWS EC2,管 OS 以下)→ PaaS(应用托管,Heroku/云引擎,管运行时以下)→ CaaS(容器即服务,K8s/容器服务)→ FaaS(函数即服务,只管代码)→ Serverless(FaaS + BaaS,全托管)。本质是运维边界不断上移,开发者只关注业务代码。

flowchart LR PM["物理机
全自管"] --> IAAS["IaaS
虚拟机
管OS以上"] IAAS --> PAAS["PaaS
应用托管
管应用以上"] PAAS --> CAAS["CaaS
容器编排
管容器以上"] CAAS --> FAAS["FaaS
函数即服务
只管代码"] FAAS --> SL["Serverless
FaaS+BaaS
按需按量计费"] style SL fill:#ede9fe,stroke:#7c3aed
图 10.10-1 云计算演进:运维边界上移,开发者只关注业务代码

AWS Lambda(2014):首个 FaaS,事件驱动,按执行时长+内存计费,毫秒级计费粒度。开启 Serverless 时代。

趋势:① FaaS + BaaS 融合——函数 + 托管数据库/存储/认证/消息,全栈 Serverless;② 边缘 Serverless(Cloudflare Workers/Deno Deploy),函数跑 CDN 边缘节点,超低延迟;③ Java 启动优化——GraalVM Native Image 与 CRaC 让 Java 跻身 Serverless;④ Serverless 容器(AWS Fargate/阿里云 SAE),兼顾容器灵活与 Serverless 免运维。

第一性原理

FaaS(函数即服务):开发者只写函数代码,平台负责扩缩容、路由、调度、计费。事件驱动(HTTP 请求/定时/消息/对象存储事件触发)、按需执行(无请求时不占资源)、按用量计费(调用次数 + 执行时长 × 内存)。

BaaS(后端即服务):数据库(DynamoDB/PlanetScale)、对象存储(S3)、认证(Auth0/Cognito)、消息(SQS/EventBridge)等托管服务,开发者直接调用 API,无需运维。

冷启动(Cold Start)——核心挑战:首次请求需① 分配容器/微虚机 → ② 加载运行时(JVM/Node)→ ③ 加载应用代码与依赖 → ④ 初始化框架与连接池。整个过程从几十毫秒(Node/Python)到数秒(Java)。Java 冷启动尤其严重:JVM 启动 + 类加载 + JIT 预热,传统 Java 框架(Spring Boot)冷启动 5-15 秒,不适合 FaaS。

Java Serverless 适配方案:

方案 原理 启动时间 代价
GraalVM Native Image AOT 编译为原生可执行文件,启动即就绪 毫秒级 编译慢、反射/动态代理需配置、生态适配(Spring Boot 3 AOT)
CRaC(OpenJDK 项目/支持该机制的发行版) 应用启动后打快照,下次从快照恢复 百毫秒级 非 JDK 21 标准能力;需适配 CRaC 接口、快照存储依赖
AppCDS 类数据共享,减少类加载开销 秒级 提升有限,需配合其他方案
轻量框架 Quarkus/Micronaut/Helidon,原生支持 Native 毫秒级(Native) 学习成本、迁移成本

计费模型:调用次数(如 ¥0.0001/次)+ 执行时长 × 内存(如 ¥0.0000001/GB·ms)。低频场景成本极低(空闲不计费),但高频常驻场景可能比自建更贵。

工程实践

适合场景:

  • 事件驱动:Webhook 回调、文件上传触发处理(S3 事件→图片压缩)、定时任务(Cron 触发)。
  • 突发流量:秒杀、促销,瞬时高并发由平台自动扩容,免容量规划。
  • 低频调用:日报生成、批处理,空闲不计费,成本极低。
  • 边缘计算:CDN 边缘跑函数,就近处理(鉴权/重写/A-B 测试)。
  • 异构剥离:把 AI 推理、图片处理等计算密集型逻辑剥离为函数,按需弹性。

不适合场景:

  • 长连接:WebSocket/长轮询,FaaS 有执行时长上限(如 15 分钟),不适合。
  • 有状态服务:函数实例无状态且会被回收,状态需外置到 Redis/DB。
  • 高频常驻:7×24 高 QPS 服务,按量计费比常驻实例贵得多。
  • 冷启动敏感:延迟严苛(< 50ms)的核心链路,冷启动抖动不可接受。
  • 复杂长流程:跨多次调用编排,需引入 Step Functions,复杂度高。

Java Serverless 实践建议:① 优先 Quarkus/Micronaut + Native Image 而非传统 Spring Boot,启动毫秒级;② 函数内避免重初始化(连接池用单例懒加载);③ 用 预置并发(Provisioned Concurrency)消除冷启动;④ 状态外置到 BaaS(DynamoDB/Redis);⑤ 跨函数编排用 Step Functions/EventBridge。

深度追问

追问 1:Serverless 一定省钱吗?

否。Serverless 省钱的前提是流量低频、波动大、空闲多(按用量计费,空闲不计费)。高频常驻服务(如核心交易链路 7×24 高 QPS),按量计费远超包年包月实例。成本拐点:当函数实例长期高负载时,自建/常驻容器更经济。需按流量模型测算,混合架构(核心常驻 + 边缘 Serverless)往往最优。

追问 2:Serverless 下本地状态与调试如何处理?

① 无状态设计——状态全部外置(DB/Redis/对象存储),函数纯计算;② 调试——本地用 SAM Local/Serverless Framework 模拟事件触发,云端用日志+分布式追踪定位;③ 测试——事件驱动需测试多种触发源,集成测试依赖 BaaS Mock。Serverless 调试体验弱于传统应用,需配套完善的可观测与 CI/CD。

常见陷阱
易错点
  • 忽视冷启动影响用户体验:首次请求延迟尖刺,核心链路需预热或预置并发。
  • 厂商锁定:深度依赖某云函数 API/触发器,迁移成本极高,需抽象事件层。
  • 调试与可观测困难:函数实例短暂、分布式,传统调试方式失效,需依赖日志与追踪。
  • 把高频常驻服务上 Serverless:成本反而更高,按量计费在高负载下远贵于包月。
  • 函数内持有状态:实例回收后状态丢失,必须无状态设计。
  • 忽视执行时长上限:超时被强制终止,长任务需拆分或用 Step Functions 编排。
C10.11
微服务间数据一致性如何保证?架构P0
本质问题

考察微服务架构下跨服务事务的设计哲学:单体时代的 ACID 在分布式环境下不可行,必须在强一致、可用性、性能之间做显式权衡。核心回答:优先用"最终一致 + 补偿/对账"替代分布式事务;必须强一致时用 TCC 或 XA;SAGA 模式适合长事务编排;消息队列是实现最终一致的最常用基础设施。

演进脉络

单体 ACID:一个数据库内用本地事务保证强一致,简单直接。拆分为微服务后,各服务有独立数据库,本地事务无法跨越服务边界。

2PC/3PC(刚性事务):两阶段提交,协调者锁定资源,性能差、阻塞、单点故障。微服务高并发场景下几乎不可用。

TCC(Try-Confirm-Cancel,2008):业务层实现三接口,预留资源 → 确认提交 / 取消释放。性能优于 2PC,但业务侵入大,需实现回滚逻辑。

SAGA(1987,2015 微服务复兴):将长事务拆为多个本地事务,每个有对应的补偿操作。正向成功则完成,某步失败则按相反顺序执行补偿。适合业务流程长、需人工介入的场景(如电商下单→支付→发货)。

Seata AT 模式(阿里,2019):对业务零侵入,代理数据源,解析 SQL 生成反向 SQL(undo_log),失败时自动回滚。像"自动化 TCC",但存在脏读风险(需全局锁)。

可靠消息最终一致:本地事务 + 发送消息(事务消息或本地消息表),消费者幂等处理。性能最好、最常用,但只保证最终一致。

Transactional Outbox + CDC(2020s 主流):业务事务同库写入 outbox 事件表,由 Debezium/Canal 等 CDC 捕获 binlog 投递到 MQ,避免“先发消息后写库/先写库后发消息”双写裂脑。相对手写本地消息表轮询,延迟更低、漏发更少。深度选型、幂等与乱序见 C09.16;本卡微服务一致性选型中,Outbox+CDC 应视为可靠消息族的默认现代形态。

第一性原理

CAP 约束下的微服务事务:微服务天然是分布式,P 不可选。强一致(CP)需牺牲可用性或性能;AP 场景用最终一致 + 补偿是更务实的选择。

TCC 核心:Try(预留资源,如冻结库存)→ Confirm(真正扣减,幂等)→ Cancel(释放预留,幂等)。Confirm/Cancel 需保证幂等性(网络重试可能导致多次调用)。优势:无全局锁、性能高;劣势:业务代码需拆为三接口,开发成本高。

SAGA 两种模式:

  • 协同式(Choreography):各服务完成本地事务后发送事件,触发下一个服务。去中心化,但链路长时难以追踪。
  • 编排式(Orchestration):由中央协调器(Saga Orchestrator)统一调度各步骤。易追踪、易控制,但协调器成为单点。

可靠消息模式:① RocketMQ 事务消息——半消息 → 本地事务 → commit/rollback 半消息;② 本地消息表轮询补发;③ Outbox + CDC(同库 outbox + Debezium/Canal → MQ,见 C09.16)。三者都要求消费者幂等;新系统优先评估 Outbox+CDC,避免自研轮询补洞。

工程实践

选型对比:

方案 一致性 侵入性 性能 适用
Seata AT 最终一致(全局锁防脏写) 低(代理数据源) 中 简单 CRUD 场景
TCC 强一致 高(三接口) 高 高并发、复杂资源预留
SAGA 最终一致 中(补偿接口) 高 长事务、业务流程
可靠消息 / Outbox+CDC 最终一致 低 极高 通用首选(Outbox 见 C09.16)

落地建议:① 默认用最终一致:优先 Outbox+CDC 或事务消息,消费者幂等;② 必须强一致时用 TCC(高性能)或 XA(简单);AT 提供最终一致,适合低并发最终一致场景;③ 长流程用 SAGA(如审批、物流);④ 所有补偿/消费接口必须幂等;⑤ 建立对账机制:日终比对各服务数据,发现不一致人工/自动修复。

设计原则

微服务事务设计第一原则:能不用分布式事务就不用。通过业务流程重构(如将同步调用改为异步通知)、数据冗余、状态机推进,大多数场景可避免分布式事务。

深度追问

追问 1:Seata AT 的脏读问题如何解决?

Seata AT 一阶段已提交本地事务,其他事务可能读到未全局提交的数据(若全局回滚则读到脏数据)。解法:① 全局锁:Seata 代理数据源时加行级全局锁,阻塞其他事务对同一数据行的写操作(防脏写);② @GlobalLock + SELECT FOR UPDATE:强制走 Seata 代理;③ 业务层容忍短暂不一致,或查询走快照/副本。

追问 2:SAGA 的补偿失败了怎么办?

补偿操作也可能失败(如网络超时、服务宕机)。解法:① 补偿必须幂等,允许重试;② 补偿失败进入人工干预队列,告警通知运维;③ 设计可回退的业务状态,避免补偿逻辑过于复杂;④ 预留对账修复机制,日终自动校验并修复不一致。

常见陷阱
易错点
  • 所有场景追求强一致:微服务下强一致代价极高,绝大多数场景最终一致即可。
  • 忽视补偿接口的幂等性:网络重试导致补偿执行多次,非幂等接口引发二次错误。
  • Seata AT 用于高频场景:全局锁导致吞吐量骤降,高并发应用 TCC 或消息。
  • SAGA 缺少监控:长事务执行到哪一步、哪一步失败,缺少可视化追踪。
生产量化证据(压测 / 命中率 / 容量)

选型结论:绝大多数场景用"最终一致 + 补偿/对账"即可,必须高性能强一致再上 TCC,严禁把 Seata AT 用于高频场景(全局锁会拖垮吞吐)。

指标 参考基准(业界常见量级) 说明
TCC 吞吐量 ~3 万 TPS(无全局锁,业务侵入高) 强一致首选,需实现 Try/Confirm/Cancel 三接口
本地消息表 ~2 万 TPS(异步解耦) 最终一致常用方案,依赖定时对账补差
Seata AT 吞吐量 ~1 万 TPS(全局锁瓶颈) 高并发下 RT P99 由 20ms 升至 200ms
Saga 长流程 中间态可见,单流程可编排 适合长事务,但补偿链路长、需人工干预兜底
故障案例(未幂等重试) 对账差异 X 笔/日(参考基准,需以自身生产数据替换) 网络分区下补偿未幂等导致重复扣款
关键结论

压测时重点验证:① AT 在高并发下的全局锁等待是否使 RT P99 翻倍;② 补偿/消费接口的幂等性(用故障注入模拟重试,观察对账差异是否归零)。以上量级均为业界参考基准,需以自身生产压测数据替换。

C10.12
云原生 CI/CD 与 GitOps 实践?高级P2
本质问题

考察云原生时代持续交付流水线的演进与 GitOps 范式。核心回答:从"人工部署"到"脚本自动化"再到"声明式 GitOps",本质是将基础设施与应用状态版本化、自动化、可审计;Git 成为唯一可信源(Single Source of Truth),K8s 成为目标状态执行引擎。

演进脉络

人工/脚本时代(2000s):手动上传 jar/war,SSH 执行命令。后用 Shell/Ansible 脚本化,但环境差异、配置漂移、回滚困难。

CI/CD 工具时代(2010s):Jenkins(2004,事实标准)→ GitLab CI(集成度高)→ CircleCI/Travis CI(SaaS 化)。流水线:代码提交 → 构建 → 单元测试 → 打包镜像 → 推送仓库 → 部署。但部署阶段仍常依赖脚本或人工审批。

容器化交付(2013+):Docker 镜像成为标准交付物,"一次构建,到处运行"。配合 K8s 声明式部署(Deployment/Service/Ingress),部署从"命令式"(告诉系统怎么做)变为"声明式"(告诉系统要什么状态)。

GitOps(2017,Weaveworks 提出):Git 仓库存储所有 K8s manifests 和配置,Operator(如 ArgoCD/Flux)监听 Git 变更,自动将集群状态同步到 Git 定义的目标状态。支持自动同步、差异检测、回滚(Git revert)。

趋势:① 不可变基础设施:镜像不可变,配置不可变,只替换不修改;② 策略即代码(Policy as Code):OPA/Gatekeeper 在部署前校验合规;③ Tekton:云原生 CI/CD 标准,K8s 原生 CRD 定义流水线。

第一性原理

GitOps 核心原则:

  1. 声明式:系统状态用 YAML/JSON 描述,存于 Git。
  2. 版本化与不可变:所有变更通过 Git commit,历史可追溯,回滚即 revert。
  3. 自动拉取:Operator 自动检测 Git 变更并同步到集群,而非外部系统推送。
  4. 持续协调:Operator 持续对比实际状态与期望状态,自动修复漂移(self-healing)。

ArgoCD 工作流:① Git 仓库定义 Application(源仓库 + 目标集群 + 路径);② ArgoCD 定时轮询或 Webhook 触发;③ 对比 live state 与 target state;④ 差异以可视化 UI 展示;⑤ 自动或手动同步(Sync);⑥ 支持 Kustomize、Helm、Jsonnet 等多方式管理 manifests。

CI vs CD 分离:CI(持续集成)负责构建、测试、打镜像、推仓库;CD(持续交付/部署)负责将镜像部署到环境。GitOps 更侧重 CD,CI 仍由 Jenkins/GitLab CI/Tekton 完成。分离后权限更清晰:CI 只需镜像仓库写权限,CD 只需集群权限。

工程实践

工具选型:

工具 定位 特点 适用
Jenkins CI 老牌 插件丰富、生态大、需维护 master 传统团队、复杂流水线
GitLab CI CI/CD 一体 与 Git 深度集成、YAML 定义、内置镜像仓库 GitLab 用户首选
Tekton 云原生 CI K8s CRD、可组合、标准化 K8s 原生团队
ArgoCD GitOps CD 声明式、多集群、可视化回滚 GitOps 首选
Flux GitOps CD CNCF、更轻量、GitOps 原生 轻量 GitOps

Java 云原生交付 checklist:① 镜像多阶段构建:maven 构建 → jre 运行时镜像,减少镜像体积;② JVM 容器感知:JDK 10 引入并默认开启 UseContainerSupport,JDK 8 需按发行商 backport/补丁版本确认;③ 健康探针:配置 liveness/readiness/startup probe;④ 配置外置:ConfigMap/Secret 挂载,不打包在镜像内;⑤ 镜像标签不用 latest:用 Git commit SHA 或语义化版本,确保可回滚。

深度追问

追问 1:GitOps 下 Secret 如何管理?

Git 存明文 Secret 不安全。方案:① Sealed Secrets:用集群公钥加密 Secret,仅集群内可解密,密文可存 Git;② External Secrets Operator:Git 只存引用,实际 Secret 从 Vault/AWS Secret Manager 动态拉取;③ SOPS + Mozilla/OpenPGP:加密 YAML 中的敏感字段。核心原则:Git 可存密文引用,不存明文凭证。

追问 2:ArgoCD 自动同步的风险?

自动同步(Auto-Sync)在 Git 被恶意修改时会直接应用到生产。缓解:① 分支保护 + Code Review:master/main 禁止直接推送;② 预同步钩子(PreSync):执行合规检查;③ 生产环境手动同步:ArgoCD 只自动同步 dev/test,prod 需人工点击;④ OPA/Gatekeeper:拦截不合规的部署(如 privileged 容器)。

常见陷阱
易错点
  • Git 存明文 Secret:凭证泄露风险极高。
  • 自动同步到生产:缺乏审批环节,故障恢复成本高。
  • 忽略镜像标签管理:用 latest 导致回滚时无法确定上一版本。
  • CI 和 CD 混为一谈:权限不分,CI 系统持有集群凭证,攻击面大。
  • 不设置资源限制:K8s 不限制 CPU/内存,一个 Pod 拖垮节点。
C10.13
微服务安全与零信任架构?架构P1
本质问题

考察微服务架构下的安全边界重塑:传统"内网可信、边界防护"模型在微服务动态扩缩容、多语言异构、跨集群部署下失效。核心回答:零信任(Never Trust, Always Verify)——每个请求无论来自内外网都必须认证、授权、加密;Service Mesh 通过 Sidecar 透明实现 mTLS 与访问控制是主流工程路径。

演进脉络

边界安全模型(Perimeter Security):防火墙 + VPN 划分内外网,内网默认可信。微服务时代,服务间调用频繁跨越 Pod/节点/集群,边界模糊,"内网可信"假设不再成立。

零信任(Zero Trust,2010 Forrester,2018 Gartner):默认不信任任何实体(人、设备、服务),每次访问都需身份验证、最小权限、持续验证。Google BeyondCorp(2014)是工程先驱。

微服务认证演进:Session/Cookie → API Key → OAuth2 / OpenID Connect(OIDC)→ SPIFFE/SPIRE(服务身份标准化)。服务间调用从"人认证"演进为"服务身份认证"。

mTLS(双向 TLS):不仅客户端验证服务端证书,服务端也验证客户端证书,实现双向身份认证 + 传输加密。Istio/Linkerd 通过 Sidecar 透明注入 mTLS,应用代码零改造。

趋势:① 身份标准化:SPIFFE 统一服务身份格式(SPIFFE ID),SPIRE 自动签发 SVID;② 策略即代码:OPA 统一授权策略;③ eBPF 安全可观测:内核级流量审计与威胁检测。

第一性原理

零信任三大原则:

  1. 永不信任,始终验证:不因为请求来自内网就放行。
  2. 最小权限:每个服务只拥有完成其职责所需的最小访问权限。
  3. 假设已被攻破:即使入侵者进入内网,也因缺乏凭证和权限而无法横向移动。

OAuth2 / OIDC 在微服务中的角色:

  • OAuth2:授权框架,解决"第三方应用如何安全访问用户资源",不定义 token 格式。
  • JWT(Bearer Token):常作为 Access Token,含用户身份与权限。
  • OIDC:基于 OAuth2 的身份层,标准化 ID Token(JWT),解决"用户是谁"。

Istio 安全模型:① 身份:Istiod 内建 CA(历史组件名 Citadel / istio-ca)为每个服务签发 X.509 证书,SPIFFE ID 作为身份标识;② 认证:PeerAuthentication(mTLS 严格/宽容模式)+ RequestAuthentication(JWT 校验);③ 授权:AuthorizationPolicy 定义 L4/L7 访问控制(基于 namespace/service/method/header);④ 加密:Sidecar 间自动 mTLS,应用无感知。

工程实践

分层安全策略:

层级 机制 工具
边缘 WAF / DDoS / Bot 防护 Cloudflare、阿里云 WAF
网关 认证、限流、TLS 终结 Spring Cloud Gateway、APISIX
服务间 mTLS + 服务身份 + 授权 Istio、Linkerd、SPIRE
应用内 RBAC / ABAC / 数据脱敏 Spring Security、OPA
数据层 加密、审计、最小权限 DB TLS、Vault

落地 checklist:① 网关统一认证:JWT/OIDC 在网关校验,下游服务只验签名或信任网关;② 服务间 mTLS:Istio PeerAuthentication 设为 STRICT;③ 细粒度授权:AuthorizationPolicy 限制服务 A 只能访问服务 B 的特定接口;④ Secret 管理:数据库密码、API Key 存 Vault,不硬编码;⑤ 安全扫描:镜像构建时 Trivy/Clair 扫描 CVE,禁止高危漏洞镜像上线。

零信任实施路径

不必一步到位。建议:① 先在外部访问实施 SSO + MFA;② 再在网关层统一 JWT 校验;③ 然后 Service Mesh 开启 mTLS(宽容模式 → 严格模式);④ 最后细粒度 AuthorizationPolicy + OPA。逐步缩小信任范围。

深度追问

追问 1:mTLS 的性能开销有多大?

TLS 握手有 CPU 开销(尤其是 RSA),但现代 CPU 的 AES-NI 加速后,对称加密开销很低(< 5%)。Istio 的 Sidecar 额外带来:① 网络多跳(应用 → Sidecar → Sidecar → 目标)增加延迟(通常 1~3ms);② 证书轮转时的短时 CPU 峰值。缓解:① 启用 TLS session resumption;② 使用 ECDSA 证书(比 RSA 快);③ 考虑 Ambient Mesh(Sidecar-less)减少代理开销。

追问 2:SPIFFE/SPIRE 解决了什么问题?

传统服务身份依赖 IP/主机名/证书 Common Name,动态环境下(Pod 重建、扩缩容)难以稳定标识。SPIFFE 定义标准服务身份 URI(spiffe://trust-domain/ns/default/sa/my-sa),SPIRE 自动为工作负载签发短期 X.509-SVID 或 JWT-SVID,实现动态、短期、可轮换的服务身份。Istio 身份体系已兼容 SPIFFE。

常见陷阱
易错点
  • 内网默认可信:微服务下内网横向移动风险极高,必须服务间认证。
  • JWT 长期不刷新:被盗后长期有效,应短 TTL + Refresh Token。
  • 只加密不授权:mTLS 验证了对端身份,但不代表可以访问所有接口,需 L7 授权。
  • Secret 硬编码:代码仓库泄露 = 凭证泄露,必须用 Vault/SealedSecret。
  • 忽略 Sidecar 权限:Istio Sidecar 以 root 运行或拥有过多 CAP,被攻破后危害大。
C10.14
多租户架构如何设计?架构P2
本质问题

考察 SaaS 场景下多个客户(租户)共享一套系统时的隔离、扩展与成本权衡。核心回答:多租户的维度不仅是"数据隔离",还包括资源隔离、性能隔离、故障隔离、运维隔离;从共享数据库到独立实例,隔离级别越高,成本越高,需在商业模型与技术实现间匹配。

演进脉络

独立实例(Dedicated Instance):每个租户一套独立应用+数据库。隔离最强、定制最灵活,但运维成本高、资源碎片化。适合大客户(KA)或强合规行业(金融、政府)。

共享应用 + 独立数据库(Shared App, Dedicated DB):应用层共享,数据库按租户分库。平衡隔离与成本,但 schema 变更需同步多库。

共享数据库 + 独立 Schema(Shared DB, Dedicated Schema):同一数据库内按租户分 schema。PostgreSQL/MySQL 均支持,但跨 schema 查询复杂,连接池需按 schema 路由。

共享数据库 + 共享 Schema + 租户ID字段(Full Sharing):所有租户数据在同表,用 tenant_id 区分。成本最低、扩展性最好,但隔离最弱,需严格防止数据泄漏(tenant_id 过滤遗漏 = 严重事故)。

趋势:云厂商(Salesforce、AWS、阿里云)采用混合模型——中小客户走共享模型,大客户独立实例自动迁移;Serverless 数据库(如 Aurora Serverless、PolarDB Serverless)让"按租户动态扩缩资源"成为可能。

第一性原理

隔离维度:

  • 数据隔离:租户 A 无法读到租户 B 的数据。通过分库/分表/行级过滤实现。
  • 性能隔离:租户 A 的突发流量不影响租户 B。通过限流(按租户配额)、资源池隔离、K8s ResourceQuota 实现。
  • 故障隔离:单个租户异常(如慢查询)不拖垮整个系统。通过熔断(按租户维度)、数据库连接池隔离、独立 Pod 实现。
  • 运维隔离:租户可独立备份、回滚、升级。独立实例天然支持,共享模型需逻辑隔离。

租户识别与路由:① 域名:tenantA.saas.com;② 请求头:X-Tenant-ID;③ JWT Claim:token 中携带租户标识。网关层解析后,通过 ThreadLocal/Context 传递到 DAO 层,自动拼接 tenant_id 过滤条件或路由到目标库。

Schema 变更策略:共享模型下,所有租户共用一套 schema,升级时需全量变更。工具:Flyway/Liquibase 按租户顺序执行;大表加字段用 Online DDL(pt-osc/gh-ost)避免锁表。

工程实践

模型对比:

模型 隔离性 成本 扩展性 适用
独立实例 最强 最高 中 大客户、强合规
共享应用+独立DB 强 高 中 中大型客户
共享DB+独立Schema 中 中 高 中型 SaaS
全共享(tenant_id) 弱 低 极高 中小客户、海量租户

Java 工程实现:① MyBatis 插件:拦截 SQL 自动追加 WHERE tenant_id = ?;② ShardingSphere:按 tenant_id 分库分表,透明路由;③ Spring 多数据源:按租户动态切换 DataSource;④ 网关限流:Sentinel 按租户维度限流;⑤ 审计日志:所有数据操作记录 tenant_id + 操作用户,满足合规。

混合模型最佳实践

不要试图用一种模型服务所有客户。设计"租户等级":免费/小客户走全共享;付费客户走共享DB+独立Schema;大客户/KA 走独立实例。架构支持租户等级的平滑迁移(如升级时自动迁库)。

深度追问

追问 1:如何防止 tenant_id 过滤遗漏导致数据泄露?

这是共享模型最大风险。防御:① DAO 层强制拦截:MyBatis Plugin / Hibernate Filter 自动注入 tenant_id,禁止直接执行无 tenant_id 的 SQL;② 代码审查 + 自动化测试:所有查询接口强制带租户参数;③ 行级安全(RLS):PostgreSQL 原生支持 RLS,数据库层强制过滤,即使 SQL 漏写也不泄露;④ 数据访问审计:异常查询(返回数据含多租户)实时告警。

追问 2:租户级别灰度发布怎么做?

独立实例:直接对某租户实例发布新版本。共享模型:① 功能开关(Feature Toggle):按租户 ID 开启新功能;② 蓝绿部署 + 租户路由:网关按租户将流量切到新版本 Pod;③ 数据库兼容变更:先加字段/表(兼容旧版),再切流量,后清旧逻辑。关键:共享模型下 schema 必须向前兼容。

常见陷阱
易错点
  • tenant_id 漏过滤:最常见也最致命的 SaaS 安全 bug。
  • 大租户影响小租户:缺少按租户限流与资源隔离,一个大客户流量打满数据库。
  • 全共享模型接大客户:大客户要求独立运维、独立备份,共享模型无法满足。
  • Schema 变更大面积失败:千级租户的数据库同时 DDL,锁表导致全局不可用。
  • 缺少租户级监控:无法定位"哪个租户"导致性能问题。
生产量化证据(压测 / 命中率 / 容量)

选型结论:隔离强度与成本成正比——中小租户走共享库 + tenant_id 控成本,大租户独立实例保隔离,并用租户级限流防止"吵闹邻居"拖垮全局。

指标 参考基准(业界常见量级) 说明
独立库/实例 单实例承载数百租户 隔离最强,运维与资源成本最高
共享库 + tenant_id 万级租户可水平扩展 跨租户查询/迁移复杂,依赖 tenant_id 强过滤
行级/Schema 折中 中型 SaaS 常用 隔离与成本折中,连接池按 schema 路由
吵闹邻居(无隔离) 慢查询拖垮 RT 30ms→500ms(参考基准,需以自身生产数据替换) 单租户耗尽连接池,波及其他租户
租户级限流后 隔离度 P99 差异 <20%(参考基准,需以自身生产数据替换) 100 租户混合并发压测目标
关键结论

压测时按"100 租户混合并发"构造吵闹邻居场景,验证租户级限流(每租户 QPS 上限)能否将 P99 差异压到 20% 以内。以上量级均为业界参考基准,需以自身生产压测数据替换。

C10.15
可观测性三大支柱与工程实践?专家P1
本质问题

考察云原生微服务场景下Metrics/Logging/Tracing 的工程体系化建设。核心回答:可观测性不是"三个系统各自搭建",而是以 TraceID 为纽带的三支柱联动;目标是"从现象(告警)到根因(代码行)"的分钟级定位能力,以及基于数据的容量规划与性能优化。

演进脉络

Metrics(指标):从 Nagios/Zabbix 到 Prometheus 的 Pull 模型革命。Prometheus 的时序数据库 + PromQL + Alertmanager 成为云原生标准。聚焦"系统健康度"的量化表达:RED(Rate/Errors/Duration)与 USE(Utilization/Saturation/Errors)。

Logging(日志):从 grep 到 ELK(Elasticsearch/Logstash/Kibana)再到 Loki(Grafana 推出,标签索引而非全文索引)。趋势:结构化 JSON + 日志与 Trace 关联。

Tracing(链路追踪):从 Google Dapper 到 OpenTracing 标准,再到 OpenTelemetry 统一 SDK。从"可选调试工具"演进为"生产必配基础设施"。

Profile(持续剖析,第四支柱):Go pprof、Java AsyncProfiler、Pyroscope,持续采集 CPU/Memory/锁火焰图,发现热点函数。2020s 后逐渐被纳入可观测体系。

统一平台:Grafana 从可视化工具演进为统一观测平台(Metrics+Logs+Traces+Profiles 统一查询);云厂商推出全托管方案(AWS CloudWatch、阿里云 SLS/ARMS、Datadog)。

第一性原理

Metrics 是聚合的、丢失细节的:告诉你"接口 P99 延迟 500ms",但不告诉你"哪次请求慢"。适合告警与趋势分析。

Logging 是离散的、高维的:告诉你"某次请求报了 NullPointerException",但不知道前后调用上下文。适合错误详情与审计。

Tracing 是关联的、有结构的:告诉你"请求 A 调用了 B(200ms)→ C(50ms)→ D(300ms),D 的 SQL 执行慢"。适合定位瓶颈与依赖分析。

三者联动的关键:TraceID。Metrics 的 Exemplar(Prometheus 2.26+)可将某个时间序列点关联到具体 Trace;日志中打印 TraceID;Trace Span 中关联日志偏移量。实现"告警 → Trace → Log → 代码"的连贯跳转。

SLO/SLI/Error Budget:可观测性的终极目标不是"收集所有数据",而是定义服务等级目标(SLO),用 Error Budget 驱动发布节奏与资源投入。

工程实践

Java 微服务可观测栈:

支柱 采集 存储 可视化/告警
Metrics Micrometer + Prometheus JMX Exporter Prometheus / VictoriaMetrics Grafana + Alertmanager
Logging Logback/Log4j2 + JSON Layout Loki / Elasticsearch / SLS Grafana / Kibana
Tracing OpenTelemetry Java Agent / SkyWalking Agent Jaeger / SkyWalking / Tempo Jaeger UI / SkyWalking UI
Profile AsyncProfiler / Pyroscope Agent Pyroscope / Parca Pyroscope UI / Grafana

落地 checklist:① Micrometer 统一埋点:Spring Boot 2.x+ 内置 Micrometer,自动暴露 JVM/HTTP/JDBC 指标;② 日志结构化:LogstashEncoder 输出 JSON,必含 timestamp/level/traceId/service/ thread;③ Agent 无侵入:OpenTelemetry Java Agent 自动埋点主流框架(Spring/WebClient/JDBC/MQ);④ 告警分层:P0(立即处理,如 ErrorRate > 1%)、P1(工作日处理)、P2(趋势关注);⑤ SLO 定义:如"登录接口 P99 < 200ms,可用性 > 99.9%"。

从告警到根因的标准流程

Alertmanager 收到告警 → Grafana 查看 Metrics 趋势 → 发现异常时间点的 Exemplar Trace → Jaeger 打开 Trace 定位慢 Span → 查看该 Span 关联的日志 → 定位具体异常代码行 → 修复 → 用 Error Budget 评估是否需要暂停发布。

深度追问

追问 1:Prometheus 的 Pull 模型有什么优缺点?高可用如何设计?

优点:① 被监控服务无需关心推送地址,只需暴露 /actuator/prometheus;② 利于服务发现(K8s 中自动发现 Pod);③ 单点采集失败不影响被监控服务。缺点:① 短生命周期 Job(如 CronJob)可能采集不到;② 跨防火墙/NAT 时采集端需能连到目标。高可用:① Thanos/Cortex/VictoriaMetrics:远程存储 + 全局查询视图;② 多 Prometheus 副本同时采集,Thanos Sidecar 上传 TSDB 到对象存储。

追问 2:Trace 采样率低导致漏掉关键请求,全量采集又太昂贵,如何平衡?

① 头部采样 + 尾部采样混合:入口处 5% 头部采样,同时将所有 Trace 暂存于 Collector,请求完成后根据条件(错误/延迟 > 阈值)触发尾部采样保留;② 基于优先级:核心链路(支付/下单)100% 采样,非核心(日志上报)0.1%;③ 动态调整:根据系统负载自动调整采样率(负载高时降低)。OpenTelemetry Collector 的 Tail Sampling Processor 原生支持。

常见陷阱
易错点
  • 三支柱数据孤立:Metrics/Log/Trace 无法互相关联,排查时需切换多个系统。
  • 过度告警:告警太多导致"狼来了",运维麻木。应基于 SLO 设定 Error Budget 告警。
  • 日志未结构化:grep 和正则解析效率低,无法被 Metrics/Trace 系统消费。
  • 忽视 Exemplar:只知道 P99 高,但不知道具体哪次请求高,无法深入 Trace。
  • 缺少业务指标:只监控 CPU/内存,不监控"订单成功率""支付延迟"等业务 SLI。
C10.16
CQRS 与 Event Sourcing 模式的架构设计?架构P1
本质问题

这道题考察对 CQRS(命令查询职责分离)与 Event Sourcing(事件溯源)两种架构模式及其结合的深度理解。核心要回答:① CQRS 读写模型分离的动机与代价——写模型面向业务不变量(聚合根),读模型面向查询性能(物化视图);② Event Sourcing 用不可变事件序列重建状态的原理,与传统"存当前状态"的根本差异;③ 两者结合(CQRS+ES)带来的审计、时间旅行、解耦收益与最终一致性、复杂度代价;④ 何时该用、何时坚决不该用。理解关键:CQRS/ES 不是银弹,是为复杂领域 + 高查询多样性 + 强审计场景设计的权衡方案。

演进脉络

CRUD 传统模式:单表/单模型同时承担读写,状态以"最新值"覆盖存储。简单直接,但读写需求冲突(写要规范化保一致性,读要反规范化保性能)时模型膨胀,且丢失历史状态。

CQRS 概念(Greg Young,2009):提出命令(写)与查询(读)分离为两个模型,写侧聚合根保证业务规则,读侧物化视图优化查询。两者通过事件/消息异步同步。Greg Young 强调"CQRS 不是架构,是设计模式"。

Event Sourcing(Martin Fowler 推广,2005+):状态不存当前值,而存一系列不可变领域事件,当前状态由事件回放(replay)得到。源于 DDD 聚合根思想,Fowler 在 EAA 模式中系统化。与 CQRS 天然契合:写侧产生事件,读侧订阅事件构建投影。

Axon Framework(2010+):Java 生态最成熟的 CQRS+ES 框架,原生支持聚合根、命令总线、事件总线、事件存储、投影重建,与 Spring Boot 集成。Allard Buijze 主导。

EventStoreDB(2012+):专为 Event Sourcing 设计的数据库,事件流为一等公民,支持订阅/投影/scavenging。区别于 Kafka(日志型,事件保留策略不同)。

Kafka 作为 Event Store:以 log compact topic 存事件流,配合 Kafka Streams 做物化视图(KTable)。适合事件量大、可重放的场景,但不适合作为业务一致性边界(无事务、无聚合级锁)。

CQRS+ES 在微服务中的实践:每个服务内用 CQRS+ES 管理自己聚合,跨服务用 Integration Event(非 Domain Event)通过消息总线广播最终一致。Axon Server、EventStoreDB Cloud、AWS EventBridge 等托管方案降低运维成本。

第一性原理

1. CQRS 核心:读写模型分离。写模型(Command 侧):面向业务不变量,聚合根(Aggregate Root)封装状态与规则,命令(Command)是意图表达(如 CreateOrderCommand),命令处理器校验后变更聚合。读模型(Query 侧):面向查询场景,物化视图(反规范化表/Document/搜索索引),专为查询优化,无业务逻辑。两者通过事件异步同步,写侧产生 OrderCreatedEvent,读侧订阅后更新视图。

2. Event Sourcing 核心:事件序列 = 真相之源。聚合状态不直接存储,而存为有序、不可变的事件流(如 OrderCreated → ItemAdded → OrderPaid → OrderShipped)。当前状态 = replay(events)(从空状态逐个应用事件)。Event Store 持久化事件流,按聚合 ID 分流。关键:事件一旦写入不可修改(immutable),错误用"补偿事件"修正。

3. Command Bus 分发:命令投递到 CommandBus,路由到对应 CommandHandler,处理器加载聚合(从事件回放)、执行命令、产生新事件、持久化。命令是请求意图,可能被拒绝(业务规则不符)。

4. Event Store 持久化:事件追加写(append-only),按聚合 ID + 版本号存储,版本号用于乐观并发控制(两个命令同时改同一聚合,后到的版本号不匹配被拒)。EventStoreDB/Axon Server/JDBC Event Storage Engine 均支持。

5. Projection/Read Model 同步:事件处理器(@EventHandler/@Projection)订阅事件,更新读模型(MySQL 表/ES 索引/Redis)。读模型可随时重建:清空后重放所有事件即可,这是 ES 的核心能力。

6. 最终一致性:写侧提交事件到读侧可见有延迟(毫秒~秒级),UI 需处理"写成功但读未更新"。并非 CQRS 必然最终一致——可同步更新读模型(同一事务),但失去读写独立扩展与解耦收益。

7. Snapshot 优化:聚合事件流过长时(如订单累计上万事件),每次回放代价大。Snapshot 定期保存聚合快照(每 N 个事件存一次当前状态),回放时从最近快照开始 + 后续增量事件。

Axon Framework + Spring Boot 示例(命令/聚合/事件/投影分离):

// 1. 命令(写侧意图,纯数据载体)
@Data @Builder
public class CreateOrderCommand {
    @TargetAggregateIdentifier
    private String orderId;
    private String userId;
    private BigDecimal amount;
}

// 2. 领域事件(已发生的事实,不可变)
@Data
public class OrderCreatedEvent {
    private String orderId;
    private String userId;
    private BigDecimal amount;
    private Instant createdAt;
}

// 3. 聚合根(写模型,封装业务规则,状态由事件重建)
@Aggregate
public class OrderAggregate {
    @AggregateIdentifier
    private String orderId;
    private OrderStatus status;

    @CommandHandler                      // 处理命令:校验 + 产生事件
    public OrderAggregate(CreateOrderCommand cmd) {
        if (cmd.getAmount().compareTo(BigDecimal.ZERO) <= 0)
            throw new IllegalArgumentException("金额必须大于0");
        AggregateLifecycle.apply(new OrderCreatedEvent(...));  // 发布事件
    }

    @EventSourcingHandler                  // 回放时重建状态
    public void on(OrderCreatedEvent e) {
        this.orderId = e.getOrderId();
        this.status = OrderStatus.CREATED;
    }
}

// 4. 投影(读模型,订阅事件构建查询视图)
@Component
public class OrderProjection {
    @EventHandler
    public void on(OrderCreatedEvent e) {
        // 写入读模型(反规范化表/ES),专为查询优化
        orderViewRepository.save(new OrderView(e.getOrderId(), e.getUserId(), e.getAmount()));
    }
}

// 5. 查询(读侧,直接查物化视图,无业务逻辑)
@QueryHandler
public List<OrderView> handle(FindOrdersByUserQuery q) {
    return orderViewRepository.findByUserId(q.getUserId);
}
工程实践

高级开发:① Axon Framework + Spring Boot 集成——@Aggregate/@CommandHandler/@EventHandler 注解驱动,axon-spring-boot-starter 自动装配 CommandBus/EventBus/EventStore;② Command/Event/Query 三分离——命令侧聚合根保业务规则,事件不可变,查询侧物化视图独立演进;③ 事件存储选型——开发用 Axon Server/JPA EventStorageEngine,生产用 EventStoreDB 或 Kafka。

架构师决策——何时用 CQRS+ES:

① 适用:复杂领域(金融交易、保险理赔,业务规则多、状态机复杂);强审计需求(合规要求保留所有变更轨迹);时间旅行调试(回放事件复现任意历史时刻);读写负载差异大(写少读多、查询维度多变,物化视图灵活扩展)。

② 不适用:简单 CRUD(管理系统增删改查,引入 CQRS 是过度设计);强一致性需求(读必须立即可见,无法容忍最终一致延迟);团队不熟 DDD(CQRS+ES 强依赖 DDD 聚合/领域事件建模,建模错误代价极高)。

③ 事件版本兼容(Upcasting):事件 schema 演进时,旧版本事件需"升级"为新版本。Upcaster 在事件反序列化阶段转换(v1 OrderCreatedEvent → v2 加字段),不可修改已存事件。Axon 提供 EventUpcaster 链式处理,EventStoreDB 支持 metadata 版本标记。规划时事件 schema 一旦发布即契约,新增字段用可选/默认值,破坏性变更用新事件类型。

④ 读模型重建:读模型损坏/schema 变更需重建——清空读存储 + 重放全部事件。事件量大时需并行重建(按聚合分片)+ 追平后切换(rebuild 期间旧视图继续服务,重建完成原子切换)。

架构决策红线

CQRS+ES 的复杂度是乘法级的:事件版本管理、读模型重建、最终一致性补偿、调试链路加长。决策前先问:"用普通 CRUD + 审计日志表 + 读写分离主从,能否满足 80% 需求?"能则不要上 CQRS+ES。它适合领域复杂度真正高且审计/回溯是硬需求的核心域(如支付、风控),而非通用技术方案。

深度追问

追问 1:CQRS 一定会引入最终一致性吗?

不一定。CQRS 本身只规定读写模型分离,不规定同步方式。① 异步同步(最终一致):写侧提交事件后,读侧通过消息异步更新,有延迟——这是常见形态,换来了读写独立扩展与解耦;② 同步同步(强一致):写侧在同一事务内更新读模型(如同一数据库事务写聚合 + 写读视图),读立即可见,但牺牲了独立扩展性,且读模型更新失败会回滚写操作;③ 同库不同表 + 事务:折中方案,读模型与写模型同库,本地事务保证一致,但仍耦合扩展。实践中多数 CQRS 选最终一致,因为强一致场景往往不需要 CQRS(读写同库即可)。最终一致需在 UI 层处理"写成功读未更新",常用做法是命令返回后前端轮询或服务端推送读模型版本号。

追问 2:Event Sourcing 如何处理事件模式(schema)变更?

核心原则:已存事件不可修改(immutable 是 ES 基石),变更通过读时转换解决。① Upcasting(向上转换):在事件反序列化阶段,用 Upcaster 把旧版本事件转成新版本结构(如 v1{amount} → v2{amount, currency:"CNY"} 加默认值)。Axon 的 EventUpcaster 链式处理,对业务代码透明;② 新增可选字段:向前兼容,旧消费者忽略新字段;③ 破坏性变更:发布新事件类型(如 OrderCreatedEventV2),聚合同时处理新旧事件,或做事件迁移(读旧流生成新流,切换后归档旧流,成本高慎用);④ 版本标记:事件 metadata 带 schema 版本号,反序列化时按版本路由 Upcaster。关键:事件 schema 设计要面向未来,字段语义明确、避免重载,新增用可选字段,重大变更用新事件类型而非改旧事件。

追问 3:CQRS+ES vs CDC(Canal/Debezium)有什么区别?

两者都能构建读模型/数据同步,但层次与语义不同。① CQRS+ES 是应用层模式:事件由领域模型显式产生(OrderCreatedEvent 是业务语义事件),聚合根是真相之源,事件是业务事实,可重建任意状态;② CDC 是基础设施层数据捕获:监听数据库 binlog/WAL,产生的是数据变更事件(UPDATE orders SET status='PAID'),反映"状态变了"而非"发生了什么业务事件",是状态变化的副作用投影。区别要点:CQRS+ES 事件含业务意图、可重建聚合、强 DDD 建模;CDC 事件是技术层面行级变更、无需改应用、对存量系统侵入小。选型:新系统且领域复杂用 CQRS+ES;存量系统改造/数据同步/读模型构建用 CDC(更轻量、无侵入)。两者可结合——应用层用 CQRS+ES,跨服务数据同步用 CDC。

常见陷阱
易错点
  • 过度使用 CQRS:简单 CRUD 场景硬上 CQRS+ES,引入事件版本/读模型重建/最终一致性一堆复杂度,投入产出严重失衡。应先 CRUD,领域复杂度真正显现再演进。
  • 忽略事件版本兼容:事件 schema 随意改字段,旧事件反序列化失败导致聚合回放崩溃。必须用 Upcaster 链 + 版本号,且"事件即契约"。
  • 误以为 ES 等于审计日志:审计日志是业务侧额外记录的"操作轨迹"(可删改);ES 事件是状态重建的真相之源(不可变、系统级必需)。ES 天然提供审计能力,但审计日志不等于 ES。
  • 忽略读模型重建成本:事件流积累到千万级,全量重建读模型耗时数小时,影响生产。需提前规划并行重建 + 蓝绿切换,或用 Snapshot 降低回放量。
  • 混淆 Domain Event 与 Integration Event:Domain Event 是聚合内部/服务内事件(含完整领域语义,可重建状态);Integration Event 是跨服务广播的集成事件(常做裁剪/扁平化,用于解耦通信)。混用会导致跨服务依赖聚合内部细节,破坏边界。
  • 事件中放当前状态而非变更:错误地把"订单当前全量"当事件存,丢失了"发生了什么"的语义,回放时无法正确重建,也失去 ES 审计价值。事件应是已发生的事实(过去时)。
C10.17
SLO/SLI 工程实践:如何用错误预算驱动发布与运维决策?架构P1
本质问题

这道题考察对 SLO/SLI 体系工程化落地的深度理解。核心要回答:① SLI(服务等级指标)是可量化的服务质量度量,SLO(服务等级目标)是 SLI 的目标值(如可用性 99.9%),Error Budget(错误预算)= 1 − SLO,三者把"系统可靠性"从模糊的"尽量高可用"变成可量化、可预算、可决策的工程契约;② Error Budget 的核心价值不是"出事后告警",而是"事前决策"——预算未耗尽时可激进发布新功能,预算耗尽则冻结发布、优先修稳定性,让可靠性与业务迭代速度形成可量化的博弈平衡;③ 基于 Burn Rate(预算消耗速率)的多窗口告警,能在 SLO 周期结束前提前预警,而非等月底才发现 SLO 未达标。理解关键:SLO 不是技术指标的堆砌,而是连接用户体验与工程决策的桥梁,SLI 必须从用户视角定义,SLO 必须由业务与技术共同承诺。

演进脉络

被动监控告警阶段:靠 CPU/内存/错误率阈值告警,指标与用户体验脱节,告警风暴(一个故障触发上百条告警)与漏报并存,"系统全绿但用户骂街"是常态。

SLA 合同阶段:运维与业务签订可用性 SLA(如 99.95%),但 SLA 是事后赔偿条款,无法指导事前决策,且"可用性"定义模糊(是 HTTP 5xx 还是用户感知失败?),沦为月底对账数字。

Google SRE Book(2016):系统化提出 SLI/SLO/Error Budget 方法论,将可靠性量化为"可消耗的预算",SRE 与产品团队共享预算驱动发布节奏——这是从"运维背锅"到"可靠性是工程契约"的范式转变。

SLO + Error Budget 落地:Burn Rate 多窗口告警(Google SRE Workbook)成为行业标准,实现"快燃快告警、慢燃慢告警",避免单一阈值告警的滞后性。

SLO 平台化:Sloth(Prometheus SLO 生成器)、Slothon、Nebula Cloud 等工具降低 SLO 落地门槛,把 Recording Rules 与告警规则从手写 YAML 变为声明式自动生成,解决"SLO 理论懂但落地难"。

OpenSLO 标准(2021):跨工具的 SLO 声明式规范,统一描述 SLI/SLO/Alerting,避免厂商锁定,让 SLO 定义可在 Sloth/PagerDuty/Datadog 间迁移。

2025 演进:基于 SLO 的自动伸缩与发布控制:Error Budget 实时驱动 HPA 扩容、流量切分与发布门禁(预算耗尽自动阻断 CI/CD 发布流水线),SLO 成为平台工程的"可靠性合约即代码",与 GitOps/Progressive Delivery 深度融合。

第一性原理

1. SLI 设计:SLI = 好的事件数 / 总事件数(比率型),必须基于用户视角而非纯技术指标。四类核心指标——① 可用性(成功请求数/总请求数,5xx 视为失败);② 延迟(如 P99 < 300ms 的请求占比);③ 吞吐量(QPS 是否达标);④ 准确性/正确性(如数据处理无错率、推荐召回准确率)。SLI 三要素:indicator(指标)、good events(好事件定义)、total events(总事件定义),三者必须精确无歧义。

2. SLO 设定:基于"用户期望 + 历史数据 + 业务需求"三角校准。用户期望决定下限(低于则用户流失),历史数据决定可达性(取 P95 实际表现再收紧),业务需求决定投入产出(核心链路 SLO 更严)。常见 SLO 如可用性 99.9%(每月允许 43.2 分钟不可用)。SLO 不应设为 100%(无预算=无创新空间),也不应照搬 SLA(SLO 应严于 SLA,留缓冲层)。

3. Error Budget 计算:Budget = 1 − SLO。如 SLO=99.9%,则每月预算 = 43200min × 0.1% = 43.2min。预算是"可消耗的不可靠性配额":故障消耗预算,正常消耗为零。预算的核心用途——① 驱动发布决策(有预算可激进发布,无预算冻结);② 量化可靠性投入(预算持续透支=需投入稳定性建设)。

4. Burn Rate(消耗速率):Burn Rate = 实际错误率 / 允许错误率。=1 表示按预算匀速消耗(一个月正好用完);>1 表示超速(如 14.4× 表示约 2% 时间耗尽整月预算)。Burn Rate 把"绝对错误率"转化为"相对预算消耗速度",让告警与 SLO 目标直接挂钩。

5. Multi-window Multi-burn-rate 告警:用长短两个窗口组合,短窗口防抖(避免瞬时尖峰误报)、长窗口防漏(避免慢性消耗漏报)。三档——① 高燃快告警:1h + 5m,Burn Rate ≥ 14.4(约 2% 预算/小时),触发 Page(立即人工介入);② 中燃:6h + 30m,Burn Rate ≥ 6(约 5% 预算/6h),触发 Ticket(工作时间内处理);③ 低燃慢告警:3d + 6h,Burn Rate ≥ 1(约 10% 预算/3d),触发趋势告警(提醒预算将耗尽)。两个窗口必须同时超阈值才告警,这是降噪关键。

6. SLO 层级:User Journey SLO(端到端用户旅程,如"下单→支付→出库"全链路成功率,最贴近业务)→ Service SLO(单个服务,如订单服务可用性)→ Resource SLO(底层资源,如 DB/缓存/消息队列)。上层 SLO 由下层支撑,但应自顶向下推导——先定义用户旅程 SLO,再分解到各服务 SLO,避免"每个服务都 99.99% 但用户体验 99%"的局部最优陷阱。

Prometheus Recording Rules 实现 SLI + 多窗口 Burn Rate 告警:

# === SLI 定义:可用性 = 1 - (5xx请求 / 总请求),按 5m 窗口聚合 ===
- record: job:slo_availability_error_ratio:rate5m
  expr: |
    sum(rate(http_requests_total{job="api",code=~"5.."}[5m]))
    /
    sum(rate(http_requests_total{job="api"}[5m]))

# === SLO: 可用性 99.9% → 允许错误率 0.001 ===
# === 多窗口 Burn Rate 告警:1h+5m 高燃(14.4x) ===
- alert: SLOHighErrorBudgetBurn
  expr: |
    (
      job:slo_availability_error_ratio:rate1h  > (14.4 * 0.001)
      and
      job:slo_availability_error_ratio:rate5m  > (14.4 * 0.001)
    )
  for: 2m
  labels:
    severity: page
  annotations:
    summary: "可用性 SLO 错误预算高速燃烧(1h+5m 均>14.4x),立即介入"

# === 中燃:6h+30m,6x,触发 Ticket ===
- alert: SLOMediumErrorBudgetBurn
  expr: |
    (
      job:slo_availability_error_ratio:rate6h  > (6 * 0.001)
      and
      job:slo_availability_error_ratio:rate30m > (6 * 0.001)
    )
  labels:
    severity: ticket
工程实践

高级开发:① Prometheus Recording Rules 实现 SLI——用 histogram_quantile(0.99, ...) 算延迟 P99,用 sum(rate(success))/sum(rate(total)) 算可用性,SLI 作为独立 Recording Rule 持久化(避免每次查询重算);② Grafana SLO 仪表盘——Error Budget 燃烧曲线(已耗/剩余预算)、Burn Rate 实时面板、SLO 达成率趋势图,让可靠性可视化;③ Alertmanager 多窗口告警——按上文规则配置三档告警,Page 走电话/短信、Ticket 走工单、趋势告警走 Slack 通知。

架构师决策:

① SLO 治理体系:建立 SLI 注册中心(每个服务的 SLI/SLO 统一登记),SLO 评审流程(季度 Review,根据历史数据校准 SLO 松紧),明确 SLO 责任人(Service Owner)与违约处理流程。SLO 不是"设完就忘",需定期校准。

② Error Budget 驱动的发布冻结机制:CI/CD 流水线对接预算查询接口,预算耗尽(剩余 < 0)自动阻断发布,仅放行修复类变更;预算临界(剩余 < 20%)降级为灰度发布。这把"是否敢发布"从拍脑袋变成数据决策,也化解了研发与运维的发布冲突。

③ SLO 平台化(Sloth/Slothon):用 slo.yaml 声明式定义 SLI/SLO,Sloth 自动生成 Prometheus Recording Rules 与多窗口告警规则,避免每个团队手写易错的 PromQL。OpenSLO 标准让定义跨工具可移植。

④ SLO 与容量规划结合:按 SLO 反推所需资源冗余——如可用性 99.95% 意味着单实例 MTTR 内需有备用容量接管流量,据此规划多活/弹性伸缩策略;SLO 退化趋势提前触发扩容或架构改造(如读模型分离、缓存前置)。

架构决策红线

SLO 的本质是用数据代替直觉做可靠性决策。落地时把握三个原则:① SLI 必须从用户旅程定义,而非"CPU 使用率 < 80%"这种与体验脱节的指标;② SLO 必须有 Error Budget 并真正用于决策,否则只是挂在墙上的数字;③ SLO 要持续校准——过松(长期 100% 达标)说明设得太宽浪费资源,过严(长期违约)说明脱离实际,需每季度基于真实数据调整。没有 Error Budget 决策闭环的 SLO 都是"装饰性 SLO"。

深度追问

追问 1:SLO 和 SLA 有什么区别?

两者层次与用途不同。SLA(Service Level Agreement,服务等级协议)是业务/法律契约:服务方与客户签订的承诺,违约需赔偿(如云厂商承诺 99.95% 可用性,不达标退还部分费用),是事后条款,定义偏粗、留有安全余量。SLO(Service Level Objective)是内部工程目标:团队自己设定的可靠性目标,用于驱动日常运维与发布决策,是事前目标,定义精确(明确 SLI 算法)、周期更短(通常按月/周)。关键关系:SLO 应严于 SLA——如 SLA 承诺 99.9%,内部 SLO 设 99.95%,留出缓冲层吸收波动,确保即便 SLO 偶尔违约也不触发 SLA 赔付。SLI 是两者的共同度量基础。

追问 2:Error Budget 用完后应该怎么做?

核心原则:预算耗尽则停止冒进,转向稳定性修复。① 冻结非必要发布——只允许修复类变更(bugfix/热修),冻结新功能发布,直到预算恢复(下个 SLO 周期或故障消除后预算回充);② 优先投入稳定性建设——团队精力从"交付新功能"切换到"修根因",做故障复盘、消除 Top N 不稳定因素、补充自动化预案;③ 与产品方对齐预期——把预算消耗可视化给产品方,共同决定是降低 SLO 目标(接受更低可靠性换迭代速度)还是投入更多稳定性资源。极端情况(连续违约)需上调 SLO 优先级,暂停新需求。切忌:预算耗尽仍强行发布,等于在透支用户信任,最终触发 SLA 违约。

追问 3:如何避免 SLO 过于宽松或过于严格?

靠数据驱动 + 持续校准。① 初始设定基于历史数据:取过去 3-6 个月实际表现,取 P95 性能作为初始 SLO(略严于现状,留改进空间),而非凭直觉拍数;② 定期校准(季度 Review):若 SLO 连续 3 个月 100% 达成且预算大量剩余→过松,应收紧 SLO(如 99.9%→99.95%)释放可靠性资源或提升用户体验;若连续违约→过严,需分析是 SLO 脱离实际还是系统真不稳定,前者下调 SLO,后者投入稳定性建设;③ 分层 SLO 区分核心与非核心:核心用户旅程设严 SLO(99.95%),非核心功能设宽 SLO(99%),避免一刀切导致资源浪费或核心链路被拖累。校准需业务与技术共同参与,SLO 是动态承诺而非一成不变。

常见陷阱
易错点
  • SLI 只选技术指标不选用户体验指标:用"CPU 使用率 < 80%""GC 时间 < 100ms"作为 SLI,与用户实际体验脱节——CPU 满但响应正常时不会告警,CPU 空但 P99 延迟超标时用户已受损。SLI 必须从用户旅程(请求成功率/端到端延迟)定义,技术指标仅作诊断辅助。
  • SLO 设为 100%(没有预算):追求"零故障"把 SLO 设成 100%,等于 Error Budget 恒为 0,任何一次小故障都"违约",导致团队永远在救火、永远冻结发布。100% SLO 既不现实(分布式系统必有故障)也无意义(无预算即无决策空间),应留出合理预算容忍可控故障。
  • 告警阈值单一(只用总预算消耗):只在月底看"SLO 是否达标"或只用单一阈值告警,导致要么滞后(月底才发现违约)要么误报(瞬时尖峰触发 Page)。必须用多窗口多档 Burn Rate,快燃快告警、慢燃慢告警,兼顾灵敏度与降噪。
  • 忽略 SLO 的长期维护和校准:上线时设了 SLO 就不再管,系统演进后 SLI 定义过时(如接口已下线但 SLI 还在算)、SLO 与实际能力脱节。SLO 需季度 Review + 持续校准,纳入服务生命周期管理,否则沦为"装饰性 SLO"。
  • 把 SLO 等同于监控告警:只把 SLO 用于配置告警,忽略 Error Budget 的决策价值(驱动发布冻结、容量规划)。没有决策闭环的 SLO 只是更高级的告警规则,未发挥"可靠性即预算"的工程价值。
C10.18
Spring Cloud 服务调用链路:为什么 @LoadBalanced 的 RestTemplate 能用服务名直接访问?架构P0
本质问题

这道题考察对 Spring Cloud 服务调用全链路的底层理解。核心要回答:打了 @LoadBalanced 注解的 RestTemplate/WebClient,之所以能直接用服务名(http://order-service/xxx)而非 IP:Port 访问,本质是 Spring 拦截器机制 + 客户端负载均衡 + 服务发现客户端三者的精妙组合。核心 4 个组件协同工作:① @LoadBalanced 注解(Qualifier 限定符,标记哪些 Bean 需要被增强);② LoadBalancerInterceptor(ClientHttpRequestInterceptor 实现,拦截 HTTP 请求);③ LoadBalancerClient(从 DiscoveryClient 拿到服务实例列表后按策略选一个);④ 底层 HTTP 客户端(URLConnection/HttpClient/OkHttp/Apache HC5,执行真正的 TCP 通信)。理解关键:@LoadBalanced 不是"魔法",而是 Spring 给特定 Bean 批量塞拦截器的 Qualifier 限定注解。

演进脉络

负载均衡框架演进:Ribbon(Spring Cloud Netflix 1.x,已废弃)——Netflix 开源,基于同步阻塞模型,核心接口 ILoadBalancer + IRule,内置轮询/随机/可用过滤/权重/响应时间加权等策略,Spring Cloud 2020.0.0(Ilford)起正式移除;→ Spring Cloud LoadBalancer(Spring Cloud Commons 重写)——基于 Reactor 响应式编程模型,取代 Ribbon,Spring Cloud 2021.x 起成为默认实现,核心抽象 ServiceInstanceListSupplier + ReactorLoadBalancer,默认 RoundRobin,支持按服务名单独配置策略。

HTTP 客户端演进:URLConnection(JDK 默认)——无连接池,单路由默认 5 连接,高并发瓶颈明显;→ Apache HttpClient 4——成熟稳定,连接池可配置;→ OkHttp——SPDY/HTTP/2 支持,API 简洁;→ Apache HttpClient 5 / Jetty Client——支持 HTTP/2,异步非阻塞模型更适合 WebFlux。

调用方式演进:RestTemplate(同步阻塞)——Spring 3.x 引入,经典但阻塞线程;→ WebClient(异步非阻塞,Spring WebFlux)——基于 Reactor,背压支持,适合高吞吐;→ RestClient(Spring 3.2+ 轻量 Fluent)——同步但 API 更现代,替代 RestTemplate 的新选择;→ OpenFeign(声明式)——写接口加注解即可,自动生成动态代理实现类,业务代码零 HTTP 细节。

第一性原理

1. @LoadBalanced 是 Qualifier 限定注解:打开源码可见 @LoadBalanced 本身被 @Qualifier 标注。Spring Cloud 的自动配置类 LoadBalancerAutoConfiguration 会收集所有带 @LoadBalanced 注解的 RestTemplate/WebClient.Builder Bean,给它们逐个添加 LoadBalancerInterceptor。没有这个注解的 RestTemplate 就是普通直连模板——这是"哪些该注入拦截器"的筛选机制。

2. 拦截器完整执行链路:ClientHttpRequestInterceptor#intercept 被触发 → 从原始 URL 提取 serviceId(如 http://order-service/api → serviceId=order-service)→ 调用 LoadBalancerClient.choose(serviceId) → 从 ServiceInstance 列表按策略(RoundRobin/Random/Weighted)选一个实例 → 将 URL 的 serviceId 替换为 host:port(如 10.0.1.5:8080)→ delegate 给底层 HTTP Client 执行真正请求。

flowchart TD A["@LoadBalanced RestTemplate
getForObject('http://order-service/api')"] --> B["LoadBalancerInterceptor
ClientHttpRequestInterceptor"] B --> C["LoadBalancerClient.choose('order-service')"] C --> D["ServiceInstanceListSupplier
从 DiscoveryClient / 静态配置拉实例列表"] D --> E["RoundRobinLoadBalancer
按轮询策略选 1 个 ServiceInstance"] E --> F["URL 重写: order-service → 10.0.1.5:8080"] F --> G["底层 HTTP Client
URLConnection / HC5 / OkHttp 执行请求"] style A fill:#ede9fe,stroke:#7c3aed style G fill:#dbeafe,stroke:#2563eb
图 10.18-1 @LoadBalanced RestTemplate 完整调用链路

3. Ribbon vs Spring Cloud LoadBalancer 核心对比:

维度 Ribbon(已废弃) Spring Cloud LoadBalancer
编程模型 同步阻塞(RxJava 适配层) Reactor 响应式(Mono/Flux)
核心接口 ILoadBalancer + IRule + IPing ReactorLoadBalancer + ServiceInstanceListSupplier
默认策略 ZoneAvoidanceRule(zone 感知 + 可用过滤) RoundRobinLoadBalancer(轮询)
实例获取 NIWSServerList(每 30s 拉取 + 过滤) ServiceInstanceListSupplier(支持缓存/懒加载/重试)
重试集成 Spring Retry + LoadBalancedRetryPolicy LoadBalancedRetryFactory + RetryableLoadBalancer
适用场景 Spring Cloud 2020 前老项目 Spring Cloud 2021+ / WebFlux 新项目
工程实践

① 给 RestTemplate 打 @LoadBalanced + 配置 HTTP 客户端连接池:

@Configuration
public class RestTemplateConfig {

    @Bean
    @LoadBalanced
    public RestTemplate loadBalancedRestTemplate() {
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        cm.setMaxTotal(200);
        cm.setDefaultMaxPerRoute(50);

        RequestConfig rc = RequestConfig.custom()
            .setConnectTimeout(2000)
            .setConnectionRequestTimeout(1000)
            .setSocketTimeout(5000)
            .build();

        CloseableHttpClient httpClient = HttpClients.custom()
            .setConnectionManager(cm)
            .setDefaultRequestConfig(rc)
            .build();

        return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient));
    }
}

② 自定义负载均衡策略(一致性哈希,按用户 ID Hash 路由到同实例):

@Configuration(proxyBeanMethods = false)
public class UserHashLoadBalancerConfig {

    @Bean
    public ReactorLoadBalancer<ServiceInstance> userHashLoadBalancer(
            Environment env,
            ServiceInstanceListSupplier supplier) {
        String name = env.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new UserHashLoadBalancer(supplier, name);
    }

    static class UserHashLoadBalancer implements ReactorLoadBalancer<ServiceInstance> {
        private final ServiceInstanceListSupplier supplier;
        private final String serviceId;

        @Override
        public Mono<Response<ServiceInstance>> choose(Request request) {
            return supplier.get().next().map(instances -> {
                if (instances.isEmpty()) return Response.empty();
                Object ctx = request.getContext();
                String userId = ctx != null ? ctx.toString() : UUID.randomUUID().toString();
                int idx = Math.abs(userId.hashCode() % instances.size());
                return Response.of(instances.get(idx));
            });
        }
    }
}

// 指定 user-service 使用该策略
@SpringBootApplication
@LoadBalancerClient(name = "user-service", configuration = UserHashLoadBalancerConfig.class)
public class OrderApplication { }

③ WebClient + @LoadBalanced 配置:注入 @LoadBalanced WebClient.Builder,构建时自动加 LoadBalancerExchangeFilterFunction,用法与 RestTemplate 同理但异步非阻塞。

深度追问

追问 1:@LoadBalanced 的 Qualifier 机制——Spring 怎么知道给哪些 RestTemplate 注拦截器?

关键在于 @LoadBalanced 元注解上标了 @Qualifier。Spring 的自动配置 LoadBalancerAutoConfiguration 中有一个字段:@LoadBalanced @Autowired(required=false) private List<RestTemplate> restTemplates = Collections.emptyList();。Spring 会把所有同时满足 @Autowired + @LoadBalanced 限定符的 RestTemplate Bean 注入到这个 List,然后通过 SmartInitializingSingleton 回调逐个给它们 add LoadBalancerInterceptor。不带 @LoadBalanced 的 RestTemplate 根本不会出现在这个 List 里——这就是精准筛选的本质。

追问 2:Ribbon 时代的 zone/availability 过滤策略,在 LoadBalancer 时代如何实现?

Spring Cloud LoadBalancer 通过自定义 ServiceInstanceListSupplier 装饰器实现。① Zone 偏好:用 ZonePreferenceServiceInstanceListSupplier(Spring Cloud LoadBalancer 内置),优先选同 zone 的实例;② 可用性过滤:自定义 HealthCheckServiceInstanceListSupplier(内置)定期 ping 实例健康端点,过滤掉不健康的;③ 自定义组合:用 DelegatingServiceInstanceListSupplier 链式装饰——先 zone 过滤 → 再健康过滤 → 再权重挑选。

追问 3:调用链里一个 ServiceInstance 全失败时的重试与 failover 如何配置?

用 Spring Retry + LoadBalancedRetryFactory 组合。① 引入 spring-retry 依赖;② 配置 spring.cloud.loadbalancer.retry.enabled=true;③ 自定义 LoadBalancedRetryFactory Bean,指定 RetryTemplate(最大重试次数 3、重试间隔指数退避、哪些异常触发重试:IOException/ConnectException/SocketTimeoutException);④ 重试时 RetryableLoadBalancer 会在每次重试前重新 choose 实例(默认换实例),实现 failover。注意:重试总耗时必须小于上层熔断超时,否则熔断先开会吞掉重试机会。

常见陷阱
易错点
  • WebFlux 项目里用同步 RestTemplate 引发虚拟线程 pinning 或阻塞 Netty EventLoop:WebFlux 默认 Netty EventLoop 线程极少(CPU 核数 × 2),用同步 RestTemplate 会把 EventLoop 阻塞住,导致整个网关/服务不可用。WebFlux 项目必须用 WebClient 或 RestClient(异步模式)。
  • 多 RestTemplate Bean 漏加 @LoadBalanced 或全部加导致直连失败:一个项目里常既有"跨服务调用(要负载均衡)"又有"调第三方外网 API(要直连)"。必须分开两个 Bean:一个打 @LoadBalanced + @Qualifier("lbRestTemplate"),另一个不加 + @Qualifier("directRestTemplate"),注入时按 Qualifier 区分。全部加 @LoadBalanced 会导致调外网时把域名当 serviceId 去注册中心查,报 No instances available。
  • HTTP 客户端连接池参数未配置,默认单路由 5 连接导致高并发瓶颈:RestTemplate 默认用 JDK URLConnection,单路由 5 连接、总连接数有限,QPS 一上来大量线程卡在 getConnection 上,RT 飙升。生产必须切换到 Apache HC5 或 OkHttp 并配 maxTotal=200+、defaultMaxPerRoute=50+。
  • 自定义 LoadBalancer 策略 Bean 漏标 @Configuration(proxyBeanMethods=false):Spring Cloud LoadBalancer 每个服务名对应一个独立的 ApplicationContext(子上下文),如果 proxyBeanMethods=true(默认),子上下文会尝试代理父上下文的 Bean,导致启动慢甚至 Bean 循环依赖。@Configuration(proxyBeanMethods=false) 是官方推荐写法。
生产实践

上线门禁:金丝雀错误预算、依赖超时传导、配置回滚。表达提纲:调用链预算→治理参数→发布门禁。

P0 表达提纲

先给可观测证据与门禁,再谈实现细节;避免只报组件名。

C10.19
OpenFeign 深度:@FeignClient 接口如何变成能发 HTTP 的"实现类"?动态代理 + 熔断 + 多参数坑高级P1
本质问题

这道题考察对 OpenFeign 声明式调用底层原理的掌握。核心要回答:为什么只写一个 @FeignClient 注解的接口(没有任何实现类),注入后就能直接发 HTTP 请求拿到结果?本质是 JDK Dynamic Proxy + Feign Contract 注解解析 + Spring Bean 自动注册 + 与客户端负载均衡/熔断框架集成四者的协同。Spring 启动时,@EnableFeignClients 扫描所有 @FeignClient 接口 → 为每个接口生成 JDK 动态代理 → 代理对象作为 Bean 注册到 Spring 容器 → 调用接口方法时,代理层把方法签名翻译成 HTTP 请求、经拦截器链、走负载均衡、发请求、解码响应、命中异常时走 FallbackFactory。理解关键:Feign 不是"生成代码"而是"运行时动态代理拦截每一次方法调用"。

演进脉络

Netflix Feign(2012,Netflix 内部):最初叫 feign,基于注解声明 REST 客户端,Java 6+ 兼容,集成 Ribbon + Hystrix,在 Spring Cloud Netflix 时代成为标配。

OpenFeign(2016,社区 fork):Netflix 将 Feign 捐献给社区,在 io.github.openfeign groupId 下继续开发。Spring Cloud 2020.x 起全面从 Netflix Feign 迁移到 OpenFeign 3.x,支持 HTTP/2、更灵活的 Contract、Micrometer Tracing。

Spring Cloud OpenFeign 4.x(Spring Boot 3.x / Java 17+):要求 Java 17 baseline,Jakarta EE 9+ 命名空间,整合 Spring Framework 6 的 HTTP Interface(@HttpExchange)新特性,原生支持 Observability,内置 Apache HC5 适配。

关键里程碑:① 支持 HTTP 客户端可插拔(URLConnection/Apache HC/OkHttp/Apache HC5);② 整合 Spring MVC 注解契约(SpringMvcContract,支持 @GetMapping/@PathVariable/@RequestHeader,不必用 Feign 原生 @RequestLine);③ Sentinel/Resilience4j Circuit Breaker 集成(Feign 级熔断,resourceName 格式 接口名#方法名(参数类型));④ Micrometer Tracing 支持(自动透传 TraceId 到下游)。

第一性原理

调用链完整路径(逐跳拆解):

  1. 业务代码 调用 userClient.getUser(123),userClient 本质是 JDK Dynamic Proxy 实例。
  2. JDK InvocationHandler(FeignInvocationHandler)按方法路由到对应的 MethodHandler(每个接口方法对应一个,缓存到 ConcurrentHashMap 里避免重复解析)。
  3. Contract 解析(SpringMvcContract):解析方法上的 Spring MVC 注解——@GetMapping("/users/{id}") 提取 HTTP 方法 + URL 模板,@PathVariable("id") 绑定参数到 URL 占位符,@RequestHeader("X-UserId") 绑定到请求头,@RequestBody 绑定到 Body。
  4. 生成 RequestTemplate:包含 URL、Method、Headers、Body、QueryParams。
  5. RequestInterceptor 拦截器链:依次执行所有注册的 RequestInterceptor——添加 TraceId(从 MDC)、添加用户 ID、添加灰度标签等。这是扩展的核心钩子。
  6. Client.execute:Feign 的 Client 接口,默认 URLConnection,可切 HC5/OkHttp。如果启用了 @LoadBalanced(Spring Cloud OpenFeign 默认集成),Client 内部会先走 LoadBalancerClient 选实例再发。
  7. 收到 Response:Decoder(默认 SpringDecoder,用 ObjectMapper 反序列化 JSON)把响应 Body 转成 Java 对象。
  8. 异常与 Fallback:若出现 DecodeException/IOException/CircuitBreakerOpenException,则调用 FallbackFactory.create(Throwable) 返回降级结果。
flowchart TD U["业务调用 userClient.getUser(123)"] --> P["JDK Dynamic Proxy
FeignInvocationHandler"] P --> M["MethodHandler(按方法缓存)
SpringMvcContract 解析注解"] M --> RT["RequestTemplate 构建
URL+Method+Headers+Body"] RT --> RI["RequestInterceptor 链
TraceId | 鉴权 | 灰度标签"] RI --> C["Client.execute(HTTP)
HC5 / OkHttp / 默认"] C --> LB["LoadBalancer 选实例
order-service → IP:Port"] LB --> R["后端服务响应 JSON"] R --> D["Decoder 反序列化
SpringDecoder(ObjectMapper)"] D --> E{"异常?"} E -->|否| OK["返回 Java 对象"] E -->|是| FF["FallbackFactory.create(t)"] FF --> FB["返回降级结果"] style P fill:#ede9fe,stroke:#7c3aed style LB fill:#dbeafe,stroke:#2563eb style FF fill:#fef3c7,stroke:#d97706
图 10.19-1 OpenFeign 调用链完整路径

OpenFeign + Sentinel 集成要点:Feign 级熔断的 resourceName 是 接口名#方法名(参数类型)(如 UserClient#getUser(Integer));fallback / fallbackFactory 必须实现被 @FeignClient 注解的接口,且必须是 Spring Bean(加 @Component);超时参数 connectTimeout/readTimeout 触发的是 IO 异常,优先被 FallbackFactory 捕获,所以 FallbackFactory 里要区分异常类型做细粒度降级。

工程实践

① @FeignClient 继承父接口模式(UserFeignClient extends UserApi):

// ---- 公共 API 模块(服务端+客户端共享)----
@RequestMapping("/api/users")
public interface UserApi {
    @GetMapping("/{id}")
    UserDTO getUser(@PathVariable("id") Integer id);

    @PostMapping("/search")
    List<UserDTO> search(@RequestParam("name") String name,
                        @RequestParam("age") Integer age);
}

// ---- 服务端 Controller(实现接口即可,注解已在父接口)----
@RestController
public class UserController implements UserApi {
    @Override
    public UserDTO getUser(Integer id) { /* ... */ }
}

// ---- 消费端 Feign Client(继承接口,零重复注解)----
@FeignClient(name = "user-service",
             fallbackFactory = UserClientFallbackFactory.class,
             configuration = FeignConfig.class)
public interface UserFeignClient extends UserApi { }

② RequestInterceptor 透传 TraceId + 用户 ID + 灰度标签:

@Component
public class TraceIdFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            template.header("X-Trace-Id", traceId);
        }
        String userId = UserContext.getUserId();
        if (userId != null) {
            template.header("X-User-Id", userId);
        }
        String grayTag = GrayContext.getTag();
        if (grayTag != null) {
            template.header("X-Gray-Tag", grayTag);
        }
    }
}

③ FallbackFactory 示例:区分异常类型返回不同降级文案:

@Component
public class UserClientFallbackFactory implements FallbackFactory<UserFeignClient> {
    private static final Logger log = LoggerFactory.getLogger(UserClientFallbackFactory.class);

    @Override
    public UserFeignClient create(Throwable cause) {
        return new UserFeignClient() {
            @Override
            public UserDTO getUser(Integer id) {
                log.error("用户服务调用失败 userId={}", id, cause);
                if (cause instanceof CircuitBreakerOpenException) {
                    throw new DegradeException("用户服务熔断中,请稍后重试");
                } else if (cause instanceof SocketTimeoutException) {
                    return UserDTO.defaultCached(id); // 读缓存兜底
                } else if (cause instanceof DecodeException) {
                    throw new BizException("用户服务响应格式异常");
                } else {
                    throw new BizException("用户服务暂时不可用");
                }
            }
        };
    }
}

④ 超时 + 重试配置:

# application.yml
feign:
  client:
    config:
      default:          # 全局默认
        connectTimeout: 1000  # TCP 连接超时 1s
        readTimeout:    6000  # 读超时 6s(必须大于后端接口 P99 + 重试总耗时)
        retryer: feign.Retryer.Default    # 内置重试: 初始100ms, 最大1000ms, 3次
      user-service:     # user-service 单独覆盖(优先级更高)
        connectTimeout: 800
        readTimeout:    3000
  httpclient:
    hc5:
      enabled: true             # 启用 Apache HC5,替换默认 URLConnection
      max-connections: 200
      max-connections-per-route: 50
深度追问

追问 1:Feign + Sentinel + LoadBalancer 三层超时如何设置?顺序和大小关系是什么?

必须按从小到大设,否则上层超时先触发导致下层机制失效。正确顺序与大小关系:
Feign connectTimeout(TCP 建连,~500-1000ms)
< Feign readTimeout(单次 HTTP 请求读超时,~3000-5000ms)
< Feign Retryer 总耗时(readTimeout × 重试次数 + 间隔,如 3s × 3 次 + 退避 = ~10s)
< Sentinel 超时(慢调用熔断阈值,~15s)
< Tomcat 线程超时 / 网关超时(~30s)
反例:如果 Sentinel timeout 设 2s 而 readTimeout 设 5s,那么接口耗时 3s 时还没等 HTTP 超时返回,Sentinel 先熔断了,接口正常返回也被算失败,导致误熔断雪崩。

追问 2:Fallback 与 FallbackFactory 的区别,为何推荐 FallbackFactory?

fallback 只能指定一个"降级实现类",拿不到异常对象,只能做"不管什么错都返回同一个兜底值"的粗粒度降级;fallbackFactory 是工厂接口,create(Throwable cause) 能拿到真正的异常对象,可以判断是 CircuitBreakerOpenException/SocketTimeoutException/DecodeException/FeignException$NotFound(404) 等不同异常做差异化降级——404 直接抛业务异常、超时读缓存兜底、熔断提示"稍后重试"。生产环境必须用 FallbackFactory,否则降级逻辑等于"蒙着眼睛兜底",排查问题时也拿不到降级触发的根因。

追问 3:继承方式多参数坑——父接口 @RequestParam 为什么必须显式写 value?

Java 编译时默认会把方法参数名擦除(除非开 -parameters 编译参数),所以 Spring 在运行时通过反射拿不到参数名"id",只能拿到 arg0、arg1。@RequestParam 如果不显式写 value = "id",Spring MVC Contract 在父接口里解析时,会尝试用参数名做 key,但拿到的是 arg0 而不是真实参数名,导致运行时报:Query parameter 'id' was not found among request parameters(编译不报错,运行才炸)。解决方案有两个:① 所有 @RequestParam/@PathVariable 都显式写 value 属性(最稳妥,兼容所有编译环境);② 开 -parameters 编译参数 + Spring Boot 3.x 原生支持参数名保留(但要确保公共 API 模块和消费端都开了,任何一端没开都会炸)。

常见陷阱
易错点
  • 继承接口多参数漏写 @RequestParam value,报 Query parameter not found:生产级踩坑率极高。父接口写 search(@RequestParam String name, @RequestParam Integer age) 看着对,运行时就报找不到参数。务必显式写 @RequestParam("name"),不要省 value。
  • Fallback/FallbackFactory 忘记加 @Component 或未在 @FeignClient 指定:两种典型错——① 降级类写了但没加 @Component,Spring 找不到 Bean 启动报 NoSuchBeanDefinitionException;② @FeignClient 上漏写 fallbackFactory = XxxFallbackFactory.class,等于降级根本没生效,异常直接抛出到前端。
  • 默认 URLConnection 无连接池,高并发 Socket exhausted:默认 Client.Default 用 JDK URLConnection,每个请求建一个 Socket 不池化。QPS 几百时出现大量 Cannot assign requested address(本地端口耗尽)或 Too many open files。生产必须切 feign.hc5.enabled=true 或 feign.okhttp.enabled=true 并配连接池。
  • ReadTimeout 设太长 + Fallback 不抛异常,调用方线程堆积锁死 Tomcat:ReadTimeout 设 30s,后端真卡死时 Feign 线程占 30s 才返回,期间 Tomcat 200 线程全被 Feign 调用占满,新请求排队,整个服务雪崩。Fallback 必须要么快速抛异常(让上游感知失败),要么返回兜底缓存值(毫秒级返回),绝不能让 Fallback 也慢。
C10.20
Spring Cloud Gateway 生产级调优:GlobalFilter 执行顺序、DirectBuffer 泄漏、连接池与响应上下文透传架构P1
本质问题

这道题直接考察"你是否在生产上踩过 Gateway 的坑"——不是背配置,而是能解释现象背后的根因。核心场景:为什么 Gateway 在 2k QPS 下出现 OutOfDirectMemoryError(堆外内存泄漏)?为什么自定义 GlobalFilter 里写回的响应头客户端收不到?为什么从 MDC 拿的 TraceId 在 Gateway 转发到下游时丢了?本质是 Reactor Netty 响应式模型 + Gateway Filter 执行顺序 + Netty 堆外内存生命周期 + Spring RequestContext 在异步线程传递失败四者之间的协同问题。理解关键:Gateway 不是 Servlet,它跑在 Netty EventLoop 上,ThreadLocal 在异步场景下完全失效,所有"存上下文/读上下文/释放内存"操作都必须走 Reactor Context 和 DataBuffer 引用计数,否则线上必出诡异问题。

演进脉络

Zuul 1.x(Servlet + 同步阻塞):Netflix 第一代网关,基于 Servlet + Tomcat,每请求一线程,线程池耗尽即雪崩,生产上 1k QPS 就需要大堆大机器。

Spring Cloud Gateway(2017,WebFlux + Netty 异步非阻塞):Spring 团队基于 Spring 5 WebFlux + Project Reactor + Netty 重写,少量线程(CPU 核数 × 2)支撑万级 QPS,Predicate + Filter 模型,Spring 生态无缝集成。

Gateway 3.x(Spring Boot 3.x / Spring 6.x):Java 17 baseline,支持 RFC 7808 Problem 格式响应,Spring Boot 3 Observability(Micrometer Tracing)开箱支持,Netty Native Epoll/kqueue 传输,AOT GraalVM Native Image。

生产调优演进路径(踩坑顺序):配置路由(写 spring.cloud.gateway.routes,能跑就行)→ 排内存泄漏(压测 2h 后 DirectBuffer OOM,查自定义 Filter 里 Body 读取没释放)→ 调连接池(Netty HttpClient 默认连接池撑不住,调 maxConnections + pendingAcquireTimeout)→ 定制 GlobalFilter(鉴权、灰度、审计、限流器,发现 order 设错被绕过)→ 上下文透传(TraceId/UserId 在 Netty 线程间传递丢了,MDC 换成 Reactor Context)。

第一性原理

1. GlobalFilter 执行顺序(order 值越小越先执行):

Filter 名称 order 值 职责
AdaptCachedBodyGlobalFilter -2147482648(Integer.MIN+888) 读取请求 Body 缓存,供后续 Filter 重读
(自定义鉴权 GlobalFilter 推荐放这里) ~ -100 ~ 10000 JWT 解析、权限校验、灰度打标(必须在路由前)
NettyWriteResponseFilter -1 将后端响应写回客户端
NettyRoutingFilter Ordered.LOWEST_PRECEDENCE(2147483647) 真正发请求到后端服务(转发核心,与 ForwardRoutingFilter 同为最低优先级)
ForwardRoutingFilter Ordered.LOWEST_PRECEDENCE(2147483647) 本地 forward,非外部调用

核心结论:鉴权 GlobalFilter 的 order 必须 < NettyRoutingFilter.order()(即 < LOWEST_PRECEDENCE = 2147483647),否则请求先被路由到后端,鉴权根本没执行,等于裸奔。NettyRoutingFilter 位于链最末端,与 ForwardRoutingFilter 同处最低优先级区间(均 = LOWEST_PRECEDENCE)、前者负责外部转发、后者负责本地 forward,二者通过 isAlreadyRouted 标记避免重复转发;鉴权/限流等必须在它之前(order 更小)完成。它(NettyRoutingFilter)位于出站的最末端——前面的 Filter 改完请求、鉴权通过、限流通过,最后一步才由它真正转发。

flowchart LR IN["请求进入
Netty EventLoop"] --> A["AdaptCachedBodyGlobalFilter
(-2147482648) 缓存Body"] A --> B["自定义 JwtAuthGlobalFilter
(order=-100) 解析JWT+鉴权+打标"] B --> C["自定义 GrayGlobalFilter
(order=-50) 灰度标签路由决策"] C --> D["GatewayMetricsFilter
(order=0) 记录指标开始时间"] D --> E["NettyWriteResponseFilter
(order=-1,后置写响应)"] E --> F["NettyRoutingFilter
(LOWEST_PRECEDENCE=2147483647) 真正转发到后端"] F --> OUT["后端服务 → 响应回流 → NettyWriteResponseFilter 写回"] style B fill:#ede9fe,stroke:#7c3aed style F fill:#dbeafe,stroke:#2563eb
图 10.20-1 Gateway GlobalFilter 执行顺序与推荐自定义位置

2. DirectBuffer(堆外内存)泄漏排查:出现 io.netty.util.internal.OutOfDirectMemoryError,三大原因:

  1. 请求 Body 读取后未释放:自定义 Filter 里调用 DataBufferUtils.join(bodyFlux) 或 DataBufferUtils.read 拿到了堆外 DataBuffer,用完没 DataBufferUtils.release(buffer),ByteBuf 引用计数 +1 永远不回收。
  2. AdaptCachedBodyGlobalFilter 重复缓存:Spring Cloud Gateway 旧版本 Bug(3.1.2 前),大文件上传时缓存 Body 多次,每次 +1 引用计数。
  3. Netty 发送失败后 ByteBuf 泄漏:客户端提前断连(RST),Netty 发送 ChannelFuture 失败时 Handler 未正确释放传入的 ByteBuf。

排查命令:① jcmd <pid> VM.native_memory summary(看 Internal 分类是否异常增长);② JVM 参数 -Dio.netty.leakDetection.level=paranoid(压测环境开,能捕捉到堆栈);③ Heap dump + Eclipse MAT 搜 PooledByteBuf / UnpooledByteBuf,看哪个泄漏对象最多,反向追引用链到自定义 Filter。

3. Reactor Netty HttpClient 连接池调优:核心参数——

  • maxConnections:最大连接数,生产按"后端实例数 × 单实例期望并发(~50)"设,2k QPS 通常 500-2000。
  • pendingAcquireTimeout:拿不到连接时的等待时间(默认 45s 太长!),压测建议 3000-5000ms,否则大量请求堆积在"等连接"上。
  • maxIdleTime:空闲连接最大存活,~30s,避免后端服务器主动断开后连接池里留脏连接。
  • connectTimeout:TCP 建连超时,~1000ms。
  • ReadTimeoutHandler:建立连接后读超时,~30s。

4. 响应上下文透传(TraceId / UserId 在 Reactor 中不丢):MDC 用的是 ThreadLocal,Netty EventLoop 会在异步操作后把同一个请求换到另一个线程执行,ThreadLocal 直接空。正确做法——① 用 Micrometer Tracing(Spring Boot 3 内置 Observation)自动跨 Reactor Context 传递;② 手动模式:ContextSnapshot.captureAll().updateContext(contextWrite) 在入口把 MDC 塞进 Reactor Context,出口 handle((signalType, contextView) -> { MDC.setContextMap(contextView.get(...)) }) 取出来写到响应头。

工程实践

① 自定义 JwtAuthGlobalFilter(order=-100,必须在 NettyRoutingFilter 之前):

@Component
@Order(-100)
public class JwtAuthGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String token = request.getHeaders().getFirst("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return unauthorized(exchange, "Missing token");
        }
        String userId = JwtUtils.parseUserId(token.substring(7));
        if (userId == null) {
            return unauthorized(exchange, "Invalid token");
        }

        ServerHttpRequest mutated = request.mutate()
            .header("X-User-Id", userId)
            .build();

        return chain.filter(exchange.mutate().request(mutated).build());
    }

    @Override
    public int getOrder() { return -100; }

    private Mono<Void> unauthorized(ServerWebExchange ex, String msg) {
        ex.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        ex.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = "{\"code\":401,\"msg\":\"" + msg + "\"}";
        DataBuffer buffer = ex.getResponse().bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return ex.getResponse().writeWith(Mono.just(buffer));
    }
}

② Reactor Netty HttpClient 定制化(调连接池、超时、启用 Epoll Native):

@Configuration
public class GatewayNettyConfig {

    @Bean
    public HttpClientCustomizer gatewayHttpClientCustomizer() {
        return httpClient -> httpClient
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
            .option(ChannelOption.TCP_NODELAY, true)
            .option(ChannelOption.SO_KEEPALIVE, true)
            .doOnConnected(conn -> conn
                .addHandlerLast(new ReadTimeoutHandler(30, TimeUnit.SECONDS))
                .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS)))
            .responseTimeout(Duration.ofSeconds(30))
            .runOn(LoopResources.create("gateway-epoll", Runtime.getRuntime().availableProcessors() * 2, true))
            .secure(spec -> spec.sslContext(
                SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)))
            .pool(poolSpec -> poolSpec
                .maxConnections(2000)
                .pendingAcquireTimeout(Duration.ofMillis(5000))
                .maxIdleTime(Duration.ofSeconds(30))
                .evictionInBackground(true)
                .evictionInterval(Duration.ofSeconds(60)));
    }
}

③ 生产 JVM 参数(Netty 堆外内存关键):

# 堆内存(不要给太大,Gateway 大量对象是堆外的)
-Xms2g -Xmx2g -XX:+UseG1GC

# Netty 堆外内存(生产至少预留 256M~1G,根据压测调整)
-Dio.netty.maxDirectMemory=512M

# Netty 内存分配器:pooled 池化(默认,但显式写)
-Dio.netty.allocator.type=pooled

# 泄漏检测级别:生产 simple / 压测 advanced / 排查 paranoid
-Dio.netty.leakDetection.level=simple

# Netty Native Epoll(Linux 生产开,性能 +10%~30%)
-Dio.netty.transport.noNative=false

# NIO 堆外内存映射限制,预防 MappedByteBuffer OOM
-XX:MaxDirectMemorySize=512M
深度追问

追问 1:NettyRoutingFilter 为什么 order 是 LOWEST_PRECEDENCE(最大)而不是最小?你写的鉴权 GlobalFilter 应该放在哪个 order 值之前?

Filter 链是"order 越小越先执行(pre 阶段)、响应时反向执行(post 阶段)"的双向链。NettyRoutingFilter 的 order = Ordered.LOWEST_PRECEDENCE(Integer.MAX_VALUE = 2147483647),与 ForwardRoutingFilter 同处最低优先级,位于出站的最末端动作——请求必须先被前面所有 Filter 处理完(Body 缓存好、鉴权过、限流过、URL 改写好、Header 注入完),最后一步才真正发出去。如果它放最前面(order 最小),请求一发出去,后面的鉴权 Filter 根本没机会执行。鉴权 GlobalFilter 的 order 必须小于 NettyRoutingFilter.getOrder(),即 < LOWEST_PRECEDENCE(< 2147483647),通常推荐 -100 ~ 10000 之间,离 NettyRoutingFilter 越远越靠前,确保不会被意外跳过。经验值:越基础的能力(JWT 鉴权、TraceId 注入)order 越小(越先执行),越业务化的能力(灰度路由、限流、审计)order 可以稍大。

追问 2:堆外内存 OOM 与堆内 OOM 的现象与排查路径差异?

堆内 OOM(Java heap space):现象——GC 次数频繁且耗时很长(G1 Young GC 从 50ms 变 2s+)、Old Gen 占用接近 -Xmx、jmap -histo:live 能看到某类业务对象(如 HashMap/byte[])数量爆炸。排查:jmap -dump:live,format=b 堆转储 + Eclipse MAT 看泄漏嫌疑报告。
堆外 OOM(OutOfDirectMemoryError / Native memory):现象——堆使用正常(老年代 < 60%)、但进程 RSS 持续飙升远超 -Xmx、jcmd VM.native_memory summary 中 Internal 类别占几 GB、dmesg 或 hs_err_pid 日志报 Direct Memory。排查:开 -Dio.netty.leakDetection.level=paranoid 复现看堆栈、用 pmap 看匿名内存段大小、Heap dump 搜 PooledByteBuf 反向找持有引用的类、用 perf/async-profiler 搜 allocateDirect 调用栈。根本差异:堆内 OOM 靠 GC 回收,堆外 OOM 靠显式 release() 回收,GC 管不到。

追问 3:在 GlobalFilter 中用 WebSession.getAttribute("user") 为什么总是 null,如何让 Spring Session Redis 在 WebFlux Gateway 中生效?

两个核心原因。① WebSession 在 Gateway 中默认是内存 Session(不会跨 Gateway 实例共享),且 Reactor 异步线程切换时 WebSession 的 Mono 订阅链没正确传递;② Spring Session Data Redis 对 WebFlux 支持需要显式配置响应式 Session Repository,光加 spring-session-data-redis 依赖没用。修复方案:① 引入 org.springframework.session:spring-session-data-redis + spring-boot-starter-data-redis-reactive(必须是 reactive 版,不是 Jedis/Lettuce 同步版);② 配置 spring.session.store-type=redis + spring.redis.* 连接;③ 在自定义 GlobalFilter 中先 exchange.getSession() 拿到 Mono<WebSession>,在它的 flatMap(session -> { ... session.getAttribute("user") ... }) 回调中用属性,不能在 filter 方法里直接拿(Session 是懒加载的 Mono,没订阅就是空)。

常见陷阱
易错点
  • GlobalFilter order 设错(0 或正数太大),鉴权被 NettyRoutingFilter 跳过:很多人写 @Order(0) 就觉得"很靠前",但如果中间有自定义的其他 Filter 用了 order=10 做路由改写,实际效果可能是"先路由后鉴权"。推荐 order=-100 起步,显式写 implements Ordered 而不是只写 @Order,并写单测打印实际 Filter 链顺序。
  • 压测环境只给 -Xmx 没给 -Dio.netty.maxDirectMemory,Netty 默认用堆 25% 的 Direct 内存导致 swap:io.netty.maxDirectMemory 不设时,Netty 默认取 JVM 最大堆内存的 25% 作为上限,且跟 JDK 的 MaxDirectMemorySize 可能不一致,导致 RSS 持续涨触发系统 swap,性能暴跌 10 倍。生产必须显式设两个参数一致:-Dio.netty.maxDirectMemory=512M -XX:MaxDirectMemorySize=512M。
  • 透传上下文用 ThreadLocal 而不是 Reactor Context,异步线程 traceId 丢了:Servlet 时代养成的 MDC+ThreadLocal 习惯在 Gateway(WebFlux+Netty)里 100% 失效——同一个请求可能被 3-4 个 EventLoop 线程接力处理,中间线程一切换 ThreadLocal 就空。必须用 contextWrite() 写入 Reactor Context,或者直接上 Spring Boot 3 Micrometer Tracing(自动搞定)。
  • 调用 DataBufferUtils.join(Flux<DataBuffer>) 读取 body 后忘记 release,导致 ByteBuf 引用计数 +1 泄漏:这是 Gateway 自定义 Filter 最常见的 OOM 根因。DataBufferUtils.join() 合并多个缓冲区时会把每个 buffer retain(),返回的合并后 buffer 引用计数 = 参与合并的 buffer 数。用完必须在 doFinally(signal -> DataBufferUtils.release(bodyBuffer)) 里释放,哪怕抛出异常也要释放。用 try-with-resources 模式或 Flux.usingWhen 模式能从代码层面避免漏释放。
C10.21
Bootstrap 上下文与 @RefreshScope 动态刷新代理原理架构P1
本质问题

为什么一定要写 bootstrap.yml 才能让配置中心生效?@RefreshScope 打了之后配置就真的会自动刷新?本质是 Spring Cloud 在 Boot 2.x/3.x 之前引入的 Bootstrap 上下文(父上下文)加载注册/配置 Bean,以及 @RefreshScope 是一个 Bean Scope 代理对象换 target 的机制。重点不是"能配置",而是你能解释清楚:配置中心的 Bean 凭什么在主应用 Bean 创建之前就连上远程拉配置?为什么 @Value 的字段改了配置中心要重新建 Bean 才能拿到新值?这两个问题不搞懂,Boot 2.x 升 3.x 时必然遇到"配置全失效"的事故。

演进脉络

Spring Cloud 早期(Boot 1.5 / 2.x,Dalston ~ 2021.x):引入 Bootstrap 上下文机制,通过 spring.factories 加载 BootstrapApplicationListener,父上下文提前加载注册/配置 Bean,与主 application.yml 分层加载,保证"Nacos 先连上、再创建业务 Bean"的顺序。

Spring Boot 3.0 / Spring Cloud 2022.0.x(Kilburn,2022-11/12,与 Boot 3.0 同期):停止自动创建 Bootstrap 上下文,配置中心集成(Nacos/Consul/Config Server)改为通过 spring.config.import 声明配置来源(如 spring.config.import=nacos:),不再自动扫描 bootstrap.yml;想恢复老行为需额外加依赖 spring-cloud-starter-bootstrap。注意:spring.config.import / ConfigData 这一套机制本身在 Spring Boot 2.4(2020)就已引入,这里只是"用 import 接管 bootstrap 职责"的迁移节点,并非新机制诞生点。

Spring Boot 3.2+(2024 ~ 至今):spring.config.import 支持多配置中心优先级与可选 required 标记;@RefreshScope 与 @ConfigurationProperties 都能实现配置热更新,但刷新路径不同——@ConfigurationProperties 走 ConfigurationPropertiesRebinder 重新绑定,@RefreshScope 走 RefreshScope.cache.clear() 重建 target,二者不可混为一谈。

第一性原理

① Bootstrap 上下文加载机制(Boot 2.x 时代):Spring Cloud BootstrapApplicationListener 在 ApplicationEnvironmentPreparedEvent 阶段,用一个名为 bootstrap 的 SpringApplication 先创建父上下文(Parent Context),加载 bootstrap.yml 配置与 BootstrapConfiguration 配置类——Nacos/Apollo 等注册/配置 Bean 先实例化放入父上下文。父上下文的 Bean 可被子上下文用 @Autowired 直接拿到,因此 NacosConfigService 才能在主应用 Bean 创建前连上配置中心拉取配置。

② Boot 3.x 时代替代方案:spring.config.import: + ConfigData 机制:Spring Boot 2.4 引入的 ConfigDataResolver / ConfigDataLocationResolver SPI,Spring Cloud Nacos 2022.0.x 实现了 NacosConfigDataLocationResolver,在 Environment 后置阶段加载远程配置,无需父上下文。配置示例:

# Boot 3.x application.yml(不需要 bootstrap.yml)
spring:
  config:
    import: "nacos:order-service-dev.yml?refresh-enabled=true&group=DEFAULT_GROUP"
  cloud:
    nacos:
      config:
        server-addr: nacos:8848
        namespace: dev

③ @RefreshScope 动态刷新原理:RefreshScope 是一个自定义 Scope(默认 singleton/prototype/request/session 之外),@RefreshScope 标注的 Bean 实际被 Spring 包装成 ScopedProxyFactoryBean 的代理对象。代理里持有一个 ObjectFactory,第一次调用 getObject 时创建 target 并缓存;当 ContextRefresher.refresh() 或 /actuator/refresh 端点被触发时:RefreshScope 会调用 cache.clear() 销毁所有被代理的 target,下次调用代理 getObject 时重新创建 target,新 target 拿到的 Environment 就是刷新过的。

flowchart TD A["@RefreshScope Bean 定义"] --> B["ScopedProxyFactoryBean(CGLIB 代理)"] B --> C["RefreshScope Cache"] C --> D["target Bean\n(第一次调用创建\n持有旧 Environment)"] E["/actuator/refresh\n或 ContextRefresher.refresh()"] --> F["RefreshScope.cache.clear()\n销毁 target"] F --> G["下一次方法调用"] G --> H["getObject() 创建新 target\n加载刷新后的 Environment 新值"] style B fill:#ede9fe,stroke:#7c3aed style F fill:#fef3c7,stroke:#d97706 style H fill:#dbeafe,stroke:#2563eb
图 10.21-1 @RefreshScope 代理 + cache.clear() 重建 target 流程图

④ @ConfigurationProperties 不需要 @RefreshScope:因为它的 Bean 是 ConfigurationPropertiesBindingPostProcessor 在绑定时每次重建,ConfigurationPropertiesRebinder 会在 Environment 刷新后重新绑定,所以注解 @ConfigurationProperties 的配置类自动刷新,无需额外打 @RefreshScope。

工程实践

① bootstrap.yml(Boot 2.x) vs application.yml(Boot 3.x spring.config.import) 两份等价示例:

维度 Boot 2.x bootstrap.yml Boot 3.x application.yml (spring.config.import)
文件名 必须 src/main/resources/bootstrap.yml src/main/resources/application.yml
触发机制 BootstrapApplicationListener 自动加载父上下文 ConfigDataLocationResolver SPI
配置写法 spring.cloud.nacos.config.server-addr spring.config.import=nacos:xxx.yml + nacos.*
额外依赖 spring-cloud-starter-alibaba-nacos-config 同上,不需要 spring-cloud-starter-bootstrap
恢复老行为 — 加 spring-cloud-starter-bootstrap 即可用 bootstrap.yml

② @RefreshScope + @Value 的 Controller + /actuator/refresh 触发:

@RestController
@RequestMapping("/api/order")
@RefreshScope
public class OrderController {

    @Value("${order.timeout:30}")
    private Integer orderTimeout;

    @GetMapping("/timeout")
    public Integer getTimeout() {
        return orderTimeout;
    }
}

# Nacos 改 order.timeout=60 后执行刷新
$ curl -X POST http://localhost:8080/actuator/refresh
# 返回 ["order.timeout"] → 表示这个 key 的 PropertySource 发生了变化
# 再访问 /api/order/timeout → 已变成 60

③ Nacos shared-configs / extension-configs 共享配置示例:

spring:
  cloud:
    nacos:
      config:
        server-addr: nacos:8848
        namespace: prod
        group: DEFAULT_GROUP
        # 共享配置(多个服务都用到的公共:数据源、Redis、MQ 配置等)
        shared-configs:
          - data-id: common-datasource.yml
            group: SHARED_GROUP
            refresh: true
          - data-id: common-redis.yml
            group: SHARED_GROUP
            refresh: true
        # 扩展配置(业务服务自己的额外配置)
        extension-configs:
          - data-id: order-service-ext.yml
            group: DEFAULT_GROUP
            refresh: true
深度追问

追问 1:@RefreshScope 的 Bean 在 refresh 时正被线程调用会怎样?

Spring Cloud 用 SerializationDelegate 做锁,旧对象用 soft reference 暂存,等待用完再回收,所以不会发生 NPE。具体来说:RefreshScope 的 cache 是 StandardScopeCache,内部 ConcurrentHashMap 存 name→BeanLifecycleWrapper;refresh 时 cache.clear() 会把 wrapper 从 map 里移除(不再有强引用),但正在执行中的线程已经拿到了旧 target 的引用,会继续跑完;当所有线程都释放旧对象后,soft reference 下次 GC 时回收。唯一风险是"refresh 瞬间 + 并发极高"时可能有极短暂的新旧对象并存,但不会抛 NPE,也不会拿半初始化的对象。

追问 2:Boot 3.x 去掉 Bootstrap 后如何让多个服务统一共享配置?

两种方式。① Nacos shared-configs + spring.config.import 组合:共享配置仍放 Nacos 的 shared-configs 里,每个服务 spring.config.import=nacos:common-datasource.yml,nacos:common-redis.yml,Spring Cloud LoadBalancer 先加载公共 import 再加载自身配置,覆盖顺序符合预期。② Spring Cloud Config Server + Git 共享文件:老架构可保留 Config Server,application.yml 里 spring.config.import=configserver:http://config-server:8888,Config Server 从 Git 拉公共 application-*.yml 与服务自己的 {service-name}-*.yml 合并返回。两者共同点:都不再需要 bootstrap.yml。

追问 3:Nacos Config 长轮询推送触发 refresh 但前端读值没变,常见原因?

三大根因。① 漏打 @RefreshScope:@Value 字段所在的类(Controller/Service)必须加 @RefreshScope,否则还是 singleton 旧对象,Environment 刷新了但字段没重新注入。② 用 static 字段保存旧值:有人把 @Value 注入的值存到 static 静态字段里,RefreshScope 重建实例也不会动 static 字段,永远读到旧值。③ 本地快照缓存优先级高于远程:Nacos 客户端 ${user.home}/nacos/config 有本地快照,配置中心临时宕机恢复后快照被覆盖逻辑有 Bug(老版本 1.x),需手动删快照或升级 Nacos Client 2.2.3+。

常见陷阱
易错点
  • 在 Boot 3.x 里写老版 bootstrap.yml 但没加 spring-cloud-starter-bootstrap 依赖,配置完全不生效:Spring Boot 3.0 默认不再扫描 bootstrap.yml,Nacos 配置直接跳过,服务启动时 @Value("${xxx}") 直接报 Could not resolve placeholder。升级第一步:要么把配置搬到 application.yml + spring.config.import,要么显式加 spring-cloud-starter-bootstrap 启动器。
  • @Value 字段所在的类忘了加 @RefreshScope,就以为能自动刷新:最常见的错误,改完 Nacos 配置 curl /actuator/refresh 返回了变更 key,但读接口还是旧值——因为 target Bean 根本没重建。记住规则:@Value → 必须 @RefreshScope;@ConfigurationProperties → 不需要,自动 rebind。
  • RefreshScope 代理对象在 @PostConstruct 中读取字段:@PostConstruct 执行时代理对象的 target 可能还没有完成第一次初始化,或者 target 的 @Value 注入时机尚未完成,读到 null。应在 InitializingBean.afterPropertiesSet() 或 ApplicationReadyEvent 里第一次读配置。
  • 自定义 Scope 刷新线程不考虑并发读写导致脏数据:有人自己实现 Scope 但没做 soft reference 暂存,cache.clear() 直接把正在用的 target 干掉,正在执行的方法下一行就拿不到字段值报 NPE。自定义 Scope 必须参照 RefreshScope 的 SerializationDelegate + BeanLifecycleWrapper 设计。
  • Boot 2.x→3.x 升级漏加 spring.config.import=nacos: 导致启动找不到 Nacos 服务:升级 checklist 第一条:凡是 bootstrap.yml 里的配置中心连接串,必须迁移到 application.yml 的 spring.config.import 声明。漏写的症状:服务能启动,但所有远程配置 key 都没加载,表现为 datasource URL 为空、Redis host 是 localhost。
C10.22
Sleuth→Micrometer Tracing 链路追踪演进:B3/W3C 传播、采样率、Brave/OTel 桥接与 MDC 透传专家P1
本质问题

Spring Cloud Sleuth 为什么在 Boot 3.0 突然"消失",而变成 Micrometer Tracing?本质是 Spring 社区在 2022 年把链路追踪 API 从 Spring Cloud 子项目下沉到 Spring Framework 级别的 Micrometer 观测抽象(Micrometer Metrics / Tracing / Logging 三合一),与 OpenTelemetry API 对齐。这里考察的是:你是否知道"为什么要做这次下沉"?是否亲自做过 Boot 2→3 的链路追踪升级?能否区分 Brave vs OTel 两套桥接的取舍?W3C TraceContext 跟 B3 到底谁是未来?生产 10k QPS 下 traceId 怎么保证异步线程不丢、采集量怎么不压爆 OTel Collector?这些都是"用过 APM"和"做过 APM 生产落地"的分水岭。

演进脉络

Spring Cloud Sleuth 1.x(2017,Dalston):基于 Brave 4.x,MDC 自动注入 traceId/spanId,默认 B3 传播格式,Zipkin 作为默认后端。

Spring Cloud Sleuth 2.x(2019 ~ 2020,Hoxton,对应 Boot 2.2/2.3):升级 Brave 5.x + Zipkin Reporter 2.x,增强 Reactor/异步自动传播;Sleuth 3.x(2020 ~ 2022,Ilford ~ 2021.0,对应 Boot 2.4~2.7):新增 spring-cloud-sleuth-otel 试验性桥接,但 OTel 的正式、长期支持路径是后续独立的 Micrometer Tracing,而非 Sleuth 自身。

Spring Boot 3.0(2022-11):Sleuth 停止维护,功能迁移到 Micrometer Tracing 1.0+(Spring 官方观测栈)+ Spring Cloud 2022.0.x 只保留少量桥接适配。关键收益:观测 API 从 Spring Cloud 下沉到 Micrometer,与 Metrics/Logging 三合一,不再需要 Spring Cloud 全家桶也能用到链路追踪(比如只写 Spring MVC 单体也能上)。

Micrometer Tracing 1.2+(2024):支持 Brave + OpenTelemetry 两套桥接实现,功能完全对齐。

2024~2025 Micrometer Tracing 1.2/1.3+(配套 OTel Java SDK 同期演进):W3C TraceContext 为默认传播格式(自 Micrometer Tracing 1.0 / Boot 3.0 起),B3 仍可通过配置切换用于老系统兼容。

flowchart LR A["Spring Cloud Sleuth 1.x\n(Brave 4.x + B3 + Zipkin)"] --> B["Sleuth 2.x\n(Brave 5.x + 试验性 OTel Bridge)"] B --> C["Micrometer Tracing 1.0\n(Spring Boot 3.0,Sleuth 停止维护)"] C --> D["Micrometer Tracing 1.2\n(Brave Bridge 与 OTel Bridge 并驾齐驱)"] D --> E["Micrometer Tracing 1.3 + OTel SDK 1.35\n(W3C TraceContext 默认、Metrcs/Tracing/Logging 三合一)"] style A fill:#e0e7ff,stroke:#4f46e5 style C fill:#ede9fe,stroke:#7c3aed style E fill:#dbeafe,stroke:#2563eb
图 10.22-1 Sleuth → Micrometer Tracing 演进路线图
第一性原理

① 核心抽象五件套:Tracer(创建 Span)、Span(一个调用段,带 name/tags/startTime/endTime)、CurrentTraceContext(ThreadLocal / Scope 存当前 Span)、Propagator(跨进程传播 Header)、SpanExporter(上报到 Zipkin/Jaeger/OTel Collector)。Brave 实现 vs OpenTelemetry 实现的取舍:

维度 Brave Bridge OpenTelemetry Bridge
背景 Twitter 开源,Java 原生 CNCF 跨语言标准
性能 更高(Java 原生优化) 略低(SDK 抽象 + 多语言兼容)
生态 Java 偏科,Zipkin 原生 全语言,Collector/Jaeger/Tempo/Prometheus 全兼容
启动开销 低 略高(Agent/SDK 初始化)
推荐场景 纯 Java 技术栈 + Zipkin 后端 多语言 + 统一 OTel Collector 架构

② 传播格式:W3C vs B3 vs B3 单头:

格式 Header 示例 是否默认 说明
W3C TraceContext(RFC) traceparent: 00-{traceId 32hex}-{spanId 16hex}-{flags 2hex}
tracestate: vendor1=xxx,vendor2=yyy
✅ Spring Boot 3.0+ 默认(Micrometer Tracing 1.0 起) RFC 标准,跨厂商兼容,flags 第二位是采样位。
B3 多头(Zipkin 老格式) X-B3-TraceId / X-B3-SpanId / X-B3-Sampled ❌ 旧系统兼容 Spring Boot 2.x Sleuth 默认,老系统对接常见。
B3 单头 b3: {traceId}-{spanId}-{sampled}-{parentSpanId} ❌ 把 5 个 Header 压成 1 个,减少跨服务 Header 数量。
Uber Jaeger uber-trace-id: traceId:spanId:parentId:flags ❌ Jaeger 早期自家格式,已全面转向 W3C。

③ 采样率配置:management.tracing.sampling.probability=0.1(10% 采样,生产推荐 1%~10% 依流量)。经验值:压测期 100%(定位全链路问题)→ 灰度期 10%(统计稳定)→ 稳定期 1%(降本);可用 AlwaysSampler(测试环境全采)或 NeverSampler(临时全关)。进阶可做基于错误的尾部采样(Tail-based Sampling):Collector 层把 status=ERROR 的 Span 补捞回来,保证错误链路 100% 可查,正常链路只采 1%。

④ MDC 透传原理:Micrometer Tracing 默认在 ObservationThreadLocalAccessor 中自动写入 traceId/spanId 到 SLF4J MDC,日志模板加 [%traceId/%spanId] 即可看到。但在异步线程(@Async / 线程池 / 虚拟线程)需用 Observation.wrap(Runnable) 或 ContextSnapshot 手动 capture + restore,否则 MDC 空 traceId。

⑤ Brave vs OTel 桥接依赖区别:两者使用的 API 完全相同(都走 Micrometer Tracing Tracer/Span),只是底层实现不同。Maven 二选一:① micrometer-tracing-bridge-brave(Brave + Zipkin Reporter)→ 后端接 Zipkin;② micrometer-tracing-bridge-otel(OTel SDK + OTLP Exporter)→ 后端接 OTel Collector → Jaeger / Tempo / Zipkin 均可。

工程实践

① application.yml 配置 Micrometer Tracing(W3C + 10% 采样 + OTLP Exporter):

management:
  tracing:
    enabled: true
    # 传播格式:W3C(生产推荐),老系统切 b3 或 b3-multi
    propagation:
      type: w3c
    # 采样率:生产 0.01(1%),压测期 1.0
    sampling:
      probability: 0.1
    # Brave 后端(Zipkin)示例:
    zipkin:
      tracing:
        endpoint: http://zipkin:9411/api/v2/spans
    # OTLP 后端(OTel Collector)示例:
    otlp:
      tracing:
        endpoint: http://otel-collector:4317

# 日志格式让每一条日志自包含 traceId/spanId
logging:
  pattern:
    console: [%d{HH:mm:ss.SSS}] [%traceId,%spanId] [%thread] %-5level %logger{36} - %msg%n

② 异步线程(@Async + 线程池)用 Observation.wrap 保 MDC 透传:

@Service
public class AsyncOrderService {

    private final ObservationRegistry observationRegistry;
    private final ExecutorService bizPool;

    // 构造注入 ObservationRegistry + 自定义线程池
    public AsyncOrderService(ObservationRegistry registry) {
        this.observationRegistry = registry;
        this.bizPool = new ThreadPoolExecutor(
            10, 50, 60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(1000),
            new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build());
    }

    @Async
    public void processAsync(Long orderId) {
        // 把当前调用链的 Observation 包装进 Runnable → 保证 biz-pool 线程里有 traceId
        Runnable task = Observation.createNotStarted("order.process", observationRegistry)
            .observe(() -> {
                // 这里 MDC 中已有 traceId/spanId,打日志能直接看到
                log.info("开始异步处理订单: {}", orderId);
                doHeavyWork(orderId);
            });
        bizPool.submit(task);
    }
}

③ Gateway Filter 注入 traceId 到响应头便于前端排障:

@Component
@Order(-90)
public class TraceIdInjectFilter implements GlobalFilter, Ordered {

    private final Tracer tracer;

    public TraceIdInjectFilter(Tracer tracer) { this.tracer = tracer; }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            Span currentSpan = tracer.currentSpan();
            if (currentSpan != null) {
                String traceId = currentSpan.context().traceId();
                exchange.getResponse().getHeaders().add("X-Trace-Id", traceId);
                exchange.getResponse().getHeaders().add(
                    "traceparent",
                    "00-" + traceId + "-" + currentSpan.context().spanId() + "-01"
                );
            }
        }));
    }

    @Override public int getOrder() { return -90; }
}
深度追问

追问 1:跨线程池(ScheduledExecutorService / Kafka 消费线程 / Reactor Schedulers)时 traceId 如何传递?

Micrometer Tracing 提供了统一的 Observation.registerThreadLocalAccessor() 机制 + Micrometer Context Accessor。具体分场景:① ScheduledExecutorService / 自定义线程池:用 ContextSnapshot.captureAll() 在提交任务前 capture,任务 Runnable 第一行 contextSnapshot.setThreadLocals() restore,finally 里 contextSnapshot.restoreThreadLocals() 复原;② Kafka 消费线程:给 ConcurrentKafkaListenerContainerFactory 配 ContainerProperties.setObservationEnabled(true),Spring Kafka 6.x 自动用 Micrometer Observation 包装 onMessage;③ Reactor Schedulers:Hooks.enableAutomaticContextPropagation()(Boot 3.2+ / Micrometer 1.12+ 一行搞定),不用手动 wrap。原则:优先用框架自带的 Observation 支持,其次才是手动 capture/restore。

追问 2:生产 10k QPS 全量采集压爆 OTel Collector,如何降本?

四层降本策略。① Agent 端采样:probability=0.01 只采 1%,量立刻降 100 倍;② Batch + 压缩:OTLP Exporter 默认已开 Batch(512 span / 5s flush / 10MB 内存上限)+ gzip 压缩,确认没被手动关掉;③ Collector 层尾部采样(Tail-based):Collector Pipeline 里加 tailsamplingprocessor,把 status_code=ERROR / latency>2s / http.status>=500 的 Span 100% 补捞,其余正常只保留 1%,保证线上有问题的链路一定查得到;④ 降 Tag 维度:自定义 tag 不要带 user_id、order_id 这种高基数字段(一天就几百万元素),放到 Span Event 或 baggage 里,少用 tag。OTel Collector 侧用 attributesprocessor 的 actions: delete 直接把不需要的高基数 key 在 Collector 端删掉。

追问 3:W3C traceparent flags = 00 / 01 的含义?B3 sampled 的 0 / 1 / d 含义?

W3C traceparent 格式:version-traceId-spanId-traceflags,共 4 段。traceflags 是 8 bit 的 2 位 hex:
· flags = 00 → 第 0 位(采样位)= 0 → 未采样,下游服务不要采这条链路。
· flags = 01 → 第 0 位 = 1 → 已采样,下游继续采样上报。
· 剩余 7 bit 预留,必须是 0,所以目前 flags 合法值只有 00/01。

B3 sampled 的取值:
· sampled = 1 → 已采样,对应 W3C flags=01。
· sampled = 0 → 未采样,对应 W3C flags=00。
· sampled = d(debug) → 调试强制采样,即使采样率 0% 也采这条,对应 W3C 的 tracestate 里 vendor 自定义标记,下游不能丢。

常见陷阱
易错点
  • Spring Boot 3.x 升级忘了把 spring-cloud-starter-sleuth 换成 micrometer-tracing-bridge-brave/otel,启动没报错但 traceId 全是空:Sleuth 在 Boot 3 里没有对应版本,spring-cloud-starter-sleuth 加了不会报 ClassNotFound,但它的 AutoConfiguration 不匹配 Boot 3 的条件注解,直接跳过。症状:日志 [%traceId] 位置是空的,Zipkin/Jaeger 一条数据都没有。升级必须把 pom 中 spring-cloud-starter-sleuth 删除,替换成 micrometer-tracing-bridge-brave(Zipkin 后端)或 micrometer-tracing-bridge-otel(OTLP 后端),两者二选一不要同时加。
  • 用自定义 ThreadPoolExecutor 没启用 Observation.wrap,异步任务日志里 traceId 缺失:@Async 默认用 Spring 的 SimpleAsyncTaskExecutor 是自动支持的,但自己 new ThreadPoolExecutor() 出来的线程池,Micrometer Tracing 不知道,里面跑的 Runnable 必须显式 Observation.wrap(runnable) 或 ContextSnapshot.captureAll() 提前 capture,否则就是空 traceId。
  • W3C 和 B3 混用:上游 W3C 下游 B3,导致跨服务链路断:比如老网关用 B3 多头,新订单服务用 Boot 3 W3C 默认,下游订单服务只看 traceparent 头,不看 X-B3-TraceId,两个服务各自生成 traceId,APM 里一条完整链路被切成两段。必须全链路统一一种传播格式,过渡期在网关层做一次 W3C↔B3 的 Header 转换。
  • 采样率设 1.0,生产日志爆炸:压测环境 100% 采样是对的,但是忘了灰度上线后改回 0.01,一夜之间 OTel Collector 磁盘写满、Jaeger 查询超时。采样率必须通过配置中心(Nacos/Apollo)动态可配,并且生产默认值就是 0.01。
  • management.tracing.enabled=false 但 Zipkin 依赖仍在,ZipkinAutoConfiguration 不生效,报错 endpoint 不存在:tracing.enabled=false 会让 Tracer Bean 不注册,但是 Zipkin 的 Reporter Bean 仍然尝试连接 management.zipkin.tracing.endpoint,启动时因没有 Tracer 上下文而报错。正确关闭链路追踪:tracing.enabled=false 同时把 micrometer-tracing-bridge-* 的依赖用 Maven profile 排除掉。
C10.23
Spring Cloud Commons 核心抽象如何让注册中心从 Eureka→Nacos→Consul 无缝切换架构P1
本质问题

为什么 pom.xml 把 spring-cloud-starter-netflix-eureka-client 改成 spring-cloud-starter-alibaba-nacos-discovery,代码里 @LoadBalanced RestTemplate、DiscoveryClient、LoadBalancer 的用法一行不用改?本质是 Spring Cloud Commons 定义了统一接口层,各注册中心组件(Eureka/Nacos/Consul)实现各自 AutoConfiguration,Bean 注入时 Spring 按条件装配,上层业务面向 Commons 抽象编程。这里考察的是:你是否真的理解"面向接口+条件装配"是怎么协作的?能否画出从业务代码到具体 Nacos/Eureka 实现的调用链路?能不能自己扩展一个 StaticDiscoveryClient 做本地测试?遇到 CompositeDiscoveryClient 多注册中心聚合时能排障。这些问题能区分"只会抄 pom 换依赖"和"真正懂 Spring Cloud 架构设计"两类候选人。

演进脉络

Spring Cloud Commons 1.0(2015,Angel 发行版):第一次提出核心抽象——DiscoveryClient / ServiceInstance / LoadBalancerClient,奠定"面向抽象编程、替换注册中心不改代码"的架构基调。

Spring Cloud Commons 3.x(Spring Cloud 2020.x / Ilford,移除 Ribbon):新增 Spring Cloud LoadBalancer(Reactor 实现)取代 Netflix Ribbon(已停止维护),抽象层增加 ReactorLoadBalancer / ServiceInstanceListSupplier 响应式接口。

Spring Cloud Commons 4.x(Boot 3.x / Spring 6.x):删除 Spring Cloud Netflix 大量已废弃接口(Ribbon/Hystrix/Archaius),抽象全面对齐 Spring 6,spring.factories 迁移到 AutoConfiguration.imports。

Spring Cloud 2023.x(Leyton)+ Commons 4.1.x(2024 ~ 至今):全面支持 GraalVM Native Image、CRaC Coordinated Restore at Checkpoint(秒级启动)、Micrometer Observability 内嵌 Trace/Metrics。

第一性原理

核心抽象 4 件套 + Spring Factories / AutoConfiguration.imports 机制:业务代码 → Commons 抽象层 → AutoConfiguration 条件装配 → 具体实现(Nacos/Eureka/Consul/Zookeeper)。

flowchart TD A["业务代码层\n@LoadBalanced RestTemplate\nDiscoveryClient.getInstances()\nLoadBalancer.choose()"] --> B["Spring Cloud Commons 抽象层\n【4 件套】\n① DiscoveryClient\n② ServiceInstance\n③ ReactorLoadBalancer\n④ @LoadBalanced + LoadBalancerInterceptor"] B --> C["AutoConfiguration 条件装配\n@ConditionalOnClass\n@ConditionalOnProperty\n@AutoConfigureBefore/After"] C --> D1["Nacos 实现\nNacosDiscoveryClient\nNacosDiscoveryAutoConfiguration\nAutoConfiguration.imports"] C --> D2["Eureka 实现\nEurekaDiscoveryClient\nEurekaClientAutoConfiguration\nspring.factories(Boot2)"] C --> D3["Consul 实现\nConsulDiscoveryClient\nConsulAutoConfiguration"] C --> D4["K8s 实现\nKubernetesDiscoveryClient\nSpring Cloud Kubernetes"] style A fill:#dbeafe,stroke:#2563eb style B fill:#ede9fe,stroke:#7c3aed style C fill:#fef3c7,stroke:#d97706
图 10.23-1 Spring Cloud Commons 抽象 → 条件装配 → 多注册中心实现 调用链

① DiscoveryClient(通用注册发现客户端):接口三大方法——getInstances(serviceId)(按服务名拿健康实例列表)、getServices()(拿注册中心里所有 serviceId)、description()(返回实现描述字符串)。典型实现类:
· NacosDiscoveryClient → getInstances() 直接调用 Nacos Client 的 NamingService.selectInstances() 拿健康实例;
· EurekaDiscoveryClient → 调 Eureka EurekaClient.getInstancesByVipAddress();
· CompositeDiscoveryClient → 聚合多个 DiscoveryClient,按 @Order 遍历,第一个有结果就返回。

② ServiceInstance(服务实例信息):接口方法 getServiceId() / getHost() / getPort() / isSecure()(https?)/ getUri() / getMetadata()。其中 getMetadata() 返回的 Map<String, String> 是灵魂——常用来存灰度标签(version=gray)、版本号(app-version=1.3.5)、权重(weight=100)、机房(zone=cn-hangzhou-a),是做灰度路由、权重负载、同机房优先的基础。

③ ReactorLoadBalancer(客户端负载均衡器,Spring Cloud LoadBalancer):核心方法 Mono<Response<ServiceInstance>> choose(Request request)。默认实现与扩展:
· RoundRobinLoadBalancer(默认,轮询);
· RandomLoadBalancer(随机);
· RetrySupport(失败重试装饰器);
· WeightedLoadBalancer(Nacos 扩展,基于 metadata weight 权重);
· HintBasedLoadBalancer(基于请求头 X-Service-Version 匹配实例 metadata 的 version)。

④ Spring Factories vs AutoConfiguration.imports 机制:
· Boot 2.x 时代:META-INF/spring.factories 里写 org.springframework.boot.autoconfigure.EnableAutoConfiguration=xxx.NacosDiscoveryAutoConfiguration。
· Boot 3.x 时代:改为 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,每一行一个配置类全限定名。
Nacos Discovery 的入口:NacosDiscoveryAutoConfiguration → 条件 @ConditionalOnNacosDiscoveryEnabled(即 spring.cloud.nacos.discovery.enabled=true,默认 true)→ 注册 NacosDiscoveryClient Bean。
· 关键机制:当 pom 里同时有 Eureka + Nacos 两个 discovery starter,两者的 AutoConfiguration 都会尝试注册 DiscoveryClient Bean,此时 DiscoveryClient 注入点会发现多个候选,Spring 按 @Primary + @Order 取第一个(如果没 @Primary,启动报错 expected single matching bean but found 2)。

工程实践

① 无缝切换示例:Eureka → Nacos 仅改 pom + yml,代码零改动:

维度 Eureka 时代 Nacos 时代
pom 依赖 spring-cloud-starter-netflix-eureka-client spring-cloud-starter-alibaba-nacos-discovery
application.yml eureka.client.serviceUrl.defaultZone=http://eureka:8761/eureka/ spring.cloud.nacos.discovery.server-addr=nacos:8848
业务代码 @LoadBalanced RestTemplate / DiscoveryClient / Feign 完全不变,一行不用改

② 自定义 StaticDiscoveryClient(本地测试环境,不连注册中心):

@Configuration
@ConditionalOnProperty(name = "spring.cloud.discovery.client.static.enabled", havingValue = "true")
public class StaticDiscoveryConfig {

    @Bean
    @Primary
    public DiscoveryClient staticDiscoveryClient() {
        return new DiscoveryClient() {
            @Override
            public List<ServiceInstance> getInstances(String serviceId) {
                if ("user-service".equals(serviceId)) {
                    return List.of(
                        new DefaultServiceInstance("user-service", "127.0.0.1", 8081, false, Map.of("version", "gray")),
                        new DefaultServiceInstance("user-service", "127.0.0.1", 8082, false, Map.of("version", "prod"))
                    );
                }
                return Collections.emptyList();
            }
            @Override public List<String> getServices() { return List.of("user-service"); }
            @Override public String description() { return "Static Discovery Client (dev-only)"; }
        };
    }
}

③ 利用 ServiceInstance metadata + HintBasedLoadBalancer 做版本路由:

// 自定义 HintBasedLoadBalancer 配置
@Configuration
public class VersionLbConfig {

    @Bean
    public ReactorLoadBalancer<ServiceInstance> versionBasedLb(
            Environment env,
            LoadBalancerClientFactory factory) {
        String serviceId = env.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
        return new ReactorServiceInstanceLoadBalancer() {
            @Override
            public Mono<Response<ServiceInstance>> choose(Request request) {
                // 从请求头取 X-Version=gray,匹配实例 metadata version=gray
                String hint = (String) request.getContext()
                    .getOrDefault("X-Version", "prod");
                return getInstanceListSupplier(serviceId, factory)
                    .get().next()
                    .map(instances -> instances.stream()
                        .filter(ins -> hint.equals(ins.getMetadata().get("version")))
                    .map(filtered -> filtered.findFirst()
                        .map(DefaultResponse::new)
                        .orElseGet(() -> new EmptyResponse()));
            }
        };
    }
}

// 指定该 LB 配置仅在 user-service 生效,不污染全局
@LoadBalancerClient(name = "user-service", configuration = VersionLbConfig.class)
@SpringBootApplication
public class OrderApplication { }

④ ServiceInstanceListSupplier 三大增强装饰器:CachingServiceInstanceListSupplier(本地缓存实例列表,默认 TTL 35s,避免每次请求都打注册中心)→ HealthCheckServiceInstanceListSupplier(主动探活,过滤不健康实例)→ ZonePreferenceServiceInstanceListSupplier(同机房/同 Zone 优先,降低跨机房调用延迟)。装饰器链顺序推荐:先 Zone 过滤 → 再 HealthCheck 过滤 → 最后 Caching 缓存。

深度追问

追问 1:CompositeDiscoveryClient 如何聚合 Nacos + K8s Discovery,让一部分服务从 K8s Endpoints 拉,另一部分从 Nacos 拉?

Spring Cloud Kubernetes 提供了 KubernetesDiscoveryClient,它实现了 DiscoveryClient 接口,从 K8s API Server 读 Endpoints / EndpointSlice 资源拿 Pod IP。把 Nacos starter 和 Spring Cloud Kubernetes starter 同时加到 pom,两个 DiscoveryClient Bean 都会注册,Spring Cloud Commons 的 CompositeDiscoveryClient 会自动把两者按 @Order 包装起来——查询某个 serviceId 时,按 order 从小到大依次问每个子 DiscoveryClient,第一个返回非空列表的直接作为结果。具体做法:① 给 NacosDiscoveryClient 配 @Order(0) 优先查 Nacos(已注册的业务服务走 Nacos);② KubernetesDiscoveryClient 配 @Order(100) 次级查 K8s(中间件 MySQL/Redis 的 Service 通过 K8s Endpoints 拿 ClusterIP 或 Pod IP);③ 用 spring.cloud.kubernetes.discovery.all-namespaces=true 跨命名空间。不想让 Nacos 先查到也可以:自定义一个 DiscoveryPredicate,按 serviceId 前缀分发(infra-* 走 K8s,其他走 Nacos)。

追问 2:Nacos 的 ServiceInstance metadata 权重值如何在自定义 LoadBalancer 中生效?

Nacos 控制台注册实例时可以设置 weight=0~10000,这个值自动出现在 ServiceInstance.getMetadata().get("weight") 里。自定义权重 LB 实现 WeightedResponseTimeRule 风格:① 从所有健康实例中拿各自的 weight(转 int,默认值 100);② 计算总权重 sumWeight;③ 取 random = [0, sumWeight) 的随机整数;④ 遍历实例累加 weight,直到"累计权重 > random",命中该实例(实例 A weight=20,B weight=80,命中 B 的概率是 80%)。生产建议再加"兜底保护":如果某实例连续 3 次 5xx,临时把它的权重降到 1(保留少量流量验证是否恢复),不要直接摘除,避免误判全挂。

追问 3:ServiceInstanceListSupplier 的缓存刷新与注册中心推送变更如何做防抖?

Nacos 注册中心在发布变更、实例上下线时可能会连续推送 3~5 次(Nacos 1.x Distro 协议节点间同步延迟),如果每次推送都刷新缓存 + 重建 LB,容易抖动。防抖做法三层:① CachingServiceInstanceListSupplier 默认 TTL 35s 已经覆盖大部分场景,TTL 没到之前即使 Nacos 推送也不真正刷新缓存;② 自定义一个 DebouncedServiceInstanceListSupplier 装饰器,持有一个 AtomicLong lastRefreshNanos,两次刷新之间强制至少间隔 5s(用 System.nanoTime() 比较),小于则直接返回上次缓存结果;③ 如果是 Nacos 推送的订阅回调,回调里不直接刷新缓存,而是把刷新任务丢到一个 singleThreadScheduledExecutor.schedule(task, 3s, SECONDS) 延迟队列里,3s 内多次推送只有最后一次会执行(典型 debounce 模式)。

常见陷阱
易错点
  • pom 同时加了 Eureka 和 Nacos,都注册了 DiscoveryClient Bean(按 order 排序取第一个,可能不是你想要的):启动时如果 DiscoveryClient 注入点发现多个候选但都没 @Primary,会直接报错 NoUniqueBeanDefinitionException。排查:pom 排掉不用的 starter;或给要优先用的 DiscoveryClient 的 AutoConfiguration 类用 @AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) 指定顺序,或者给 DiscoveryClient 注入点加 @Qualifier("nacosDiscoveryClient")。
  • 自定义 LoadBalancer 忘了 @LoadBalancerClient(name="user-service", configuration=MyLBConfig.class) 指定生效服务,导致全局生效污染:@LoadBalancerClient 的 configuration 类不能被主 @ComponentScan 扫描到(否则就变成全局共享配置),必须放到 @SpringBootApplication scanBasePackages 之外的独立包路径,或加 @ComponentScan(excludeFilters = @ComponentScan.Filter(type=ASSIGNABLE_TYPE, classes=MyLBConfig.class))。踩坑症状:原本只想给 user-service 做灰度路由,结果 order-service / pay-service 的调用全都被自定义 LB 接管,瞬间全报 No servers available。
  • ServiceInstance metadata 用中文或特殊字符引发 HTTP URL 编码异常:Nacos 的 metadata 其实是 Map<String,String>,会被拼接进注册请求的 Body / 查询参数,若包含中文或 &、?、= 等字符,不同注册中心实现的 URL 编码不一致,有的会报 500,有的会被截断导致 version/weight 读出来是乱码。metadata 的 value 应限定:ASCII 英文字母数字 + 点 + 下划线 + 中划线,禁止中文和特殊字符。
  • CachingServiceInstanceListSupplier 缓存 TTL 过长(默认 35s)+ Nacos 推送延迟叠加,实例下线后 40s 还被调用:生产如果 Nacos 推送偶发延迟(注册中心集群跨机房同步慢),LB 里的缓存要等 35s TTL 过期才刷新,这时已下线实例还会被流量打到。解决方案:① spring.cloud.loadbalancer.cache.ttl=5s 把 TTL 调低(或按服务维度通过 LoadBalancerClients 为关键服务单独配 TTL);② 打开 HealthCheckServiceInstanceListSupplier 主动探活,即使缓存里有,探活失败的实例也会被实时过滤。
C10.24
Spring Cloud 常见生产 7 大坑:Feign 传文件、LoadBalanced WebClient 冲突、RefreshScope Bean 失效、Gateway Predicate 优先级、上下文丢失、Date 序列化、Sentinel Fallback 吞异常高级P2
本质问题

这 7 个坑是 Spring Cloud 社区 GitHub issue 星标最高的 7 个真实生产事故集合,覆盖 Feign / Gateway / RefreshScope / Sentinel 四大组件。能否讲清"踩坑经验",是区分"用过 Spring Cloud"和"用 Spring Cloud 做过生产"的分水岭。核心不是背"怎么改",而是每坑能说出 根因 → 触发条件 → 最小复现代码 → 修复代码 → 如何预防下一次 五步完整链路。如果能在每一个坑上补充自己团队的真实事故编号、发生时间、影响面、事后复盘改进项,就足以证明真正的生产级掌控力。

演进脉络

坑的版本依赖性很强——Spring Boot 2.x → 3.x,Spring Cloud 2021.x → 2022.x → 2023.x,很多坑在新版本修复但老项目仍然大量存在。本题重点讲"当前主流版本(Boot 3.2+ / Spring Cloud 2023.x)仍会踩到的坑":

  • 坑 1 Feign 传文件 → Feign 12.x 仍默认不支持,需显式加 feign-form 依赖。
  • 坑 2 @LoadBalanced WebClient 冲突 → Boot 3.2 仍存在。
  • 坑 3 @RefreshScope 定时任务失效 → 设计层面就是这样,官方不会改。
  • 坑 4 Gateway Predicate 优先级 → 配置顺序匹配,官方文档强调过但生产仍天天踩。
  • 坑 5 Gateway 上下文丢失 → 新人写 Gateway Filter 必踩。
  • 坑 6 Feign Date 序列化 → Jackson 默认把 Date 转 timestamp,跨时区必错。
  • 坑 7 Sentinel FallbackFactory 吞异常 → Sentinel 1.8.x 仍是默认行为。
第一性原理 + 7 坑详解(根因 + 修复代码)

坑 1:Feign 传 File / InputStream 报 is not a type supported by this encoder
根因:Feign 默认的 SpringEncoder(基于 Jackson)只处理 JSON / POJO,不处理 MultipartFile / File / InputStream。要传文件必须用 Feign 官方的 feign-form 扩展,它实现了 multipart/form-data 编码。
修复代码:下面实践部分给出完整版。

坑 2:@LoadBalanced WebClient 与 Spring Cloud Gateway 路由冲突(500 No ServiceInstance)
根因:在 Gateway 应用里把一个全局的 WebClient.Builder 加了 @LoadBalanced,导致 Gateway 内部用 lb:// scheme 路由时,和这个全局 Builder 加的 LoadBalancerFilterFunction 冲突——重复做了两次 loadBalance 或者 WebClient 试图解析 lb:// 的 serviceId 失败,报 500 "No servers available for service: xxx" 或 "No ServiceInstance"。
修复思路:全局 WebClient.Builder 不要加 @LoadBalanced;专门给"内部服务间调用"用的 Builder 单独用 @Qualifier 隔离。

坑 3:@RefreshScope 触发后,@Scheduled 定时任务 / 线程池单例 Bean 失效
根因:@RefreshScope 会销毁并重建被代理的 target Bean。但是 @Scheduled 注解的 Bean 是被 ScheduledAnnotationBeanPostProcessor 在初始化时扫描,把具体 target 实例的方法句柄(Method + 实例引用)注册给 ThreadPoolTaskScheduler。RefreshScope 销毁旧 target、重建新 target 后,调度器持有的仍然是旧 target 引用,永远不会调度到新实例。
典型症状:刷新配置后定时任务还在执行,但打印出来的配置值还是旧的。

坑 4:Gateway Predicate 多路由匹配优先级陷阱:多个路由同时匹配但 Path 前缀重叠,选了错的
根因:Gateway RouteDefinitionRouteLocator 加载 route 时,默认按 routes 列表在配置文件中的从上到下顺序取第一个匹配的。如果 order-service 的 Path=/api/order/**,pay-service 的 Path=/api/order/pay/**,且 pay-service 写在后面,那么 /api/order/pay/create 会优先命中前面的 /api/order/** → 流量被打到错误的 order-service,404 或 500。
修复:给每个 route 显式加 order: 数字(越小越优先)。

坑 5:Gateway GlobalFilter 中从 exchange.getAttribute("userId") 总是 null(Reactor 上下文透传失败)
根因:很多人把 Servlet 的经验带到了 Reactor Gateway,在某处 Filter 用 Mono.subscriberContext(Context.of("userId", uid)) 写入上下文,然后在另一个 Filter 里想当然用 Mono.subscriberContext().get("userId") 读。但 Gateway Filter 链的每一步都是一个独立的 Mono<Void> 订阅,Reactor Context 只在当前 Flux/Mono 链上传递,跨独立 Mono 就丢。
正确做法:跨 Filter 用 exchange.getAttributes()(它跟 ServerWebExchange 请求对象绑定,整个请求-响应生命周期内不丢)。

坑 6:Feign Date / LocalDateTime 序列化错误(时区错一天 / JsonMappingException)
根因:Feign 底层默认用 Jackson 的 ObjectMapper。Jackson 对 java.util.Date 默认序列化为 timestamp(毫秒数),对 LocalDateTime 默认需要 jackson-datatype-jsr310 模块。当服务端和客户端时区不一致时,Date 的 timestamp 反序列化会"错一天"(比如客户端 GMT+8,服务端 UTC,23:30 发送的日期在服务端变成 UTC 当天 15:30,日期被认为是昨天)。

坑 7:Sentinel Feign FallbackFactory 吞业务异常导致无法触发告警
根因:FallbackFactory 的接口是 T create(Throwable cause),很多人写 fallback 时对所有异常(限流、熔断、业务 500、超时)都返回 空列表 / null / 默认值,然后上游 @RestControllerAdvice 全局异常处理拿不到真正的异常——该告警的没告警,该追查的被静默,线上出了问题排查时根本不知道 500 什么时候发生的。
修复:Fallback 里把真正的异常"传出来",让全局异常处理能抓到。

工程实践(每坑一段修复代码)

① 坑 1 修复:Feign 传文件 SpringFormEncoder 配置:

// pom.xml 加依赖
<dependency>
    <groupId>io.github.openfeign.form</groupId>
    <artifactId>feign-form-spring</artifactId>
    <version>3.8.0</version>
</dependency>

// 配置类:显式注册 SpringFormEncoder
@Configuration
public class FeignMultipartConfig {
    @Autowired
    private ObjectFactory<HttpMessageConverters> messageConverters;

    @Bean
    @Scope("prototype")
    public Encoder feignEncoder() {
        return new SpringFormEncoder(new SpringEncoder(messageConverters));
    }
}

// Feign 接口:@RequestPart + consumes = MULTIPART_FORM_DATA
@FeignClient(name = "file-service", configuration = FeignMultipartConfig.class)
public interface FileServiceClient {
    @PostMapping(value = "/api/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    FileUploadResult upload(@RequestPart("file") MultipartFile file,
                            @RequestParam("bizType") String bizType);
}

② 坑 2 修复:多 WebClient.Builder 分离(LoadBalanced 专用 + Gateway 路由专用):

@Configuration
public class WebClientConfig {

    // 专门给"内部服务间 lb:// 调用"用的 Builder:加 @LoadBalanced + @Qualifier
    @Bean
    @LoadBalanced
    @Qualifier("internalLbWebClientBuilder")
    public WebClient.Builder internalLbWebClientBuilder() {
        return WebClient.builder()
            .codecs(config -> config.defaultCodecs().maxInMemorySize(2 * 1024 * 1024))
            .defaultHeader("X-Caller", "gateway");
    }

    // 普通调用外部 HTTPS 的 Builder:不要加 @LoadBalanced,用默认
    @Bean
    @Primary
    public WebClient.Builder primaryWebClientBuilder() {
        return WebClient.builder();
    }
}

// 业务使用:注入时用 @Qualifier("internalLbWebClientBuilder") 指定
@Service
public class UserServiceRemote {
    private final WebClient lbWebClient;

    public UserServiceRemote(@Qualifier("internalLbWebClientBuilder") WebClient.Builder builder) {
        this.lbWebClient = builder.baseUrl("lb://user-service").build();
    }
}

③ 坑 3 修复:监听 RefreshScopeRefreshedEvent 重新注册 @Scheduled 任务:

// 推荐方案:把 @Scheduled 移到非 RefreshScope Bean,配置类改用 @ConfigurationProperties(自动刷新)
@ConfigurationProperties(prefix = "schedule")
public class ScheduleProperties {
    private Long orderCheckIntervalMs = 60000L;
    // setter / getter
}

@Component
public class OrderCheckTask implements DisposableBean {
    private final ScheduleProperties props;
    private final ThreadPoolTaskScheduler scheduler;
    private ScheduledFuture<?> currentFuture;

    public OrderCheckTask(ScheduleProperties props, ThreadPoolTaskScheduler scheduler) {
        this.props = props; this.scheduler = scheduler;
    }

    // 启动时注册 + RefreshScope 刷新后重新注册
    @EventListener({ ApplicationReadyEvent.class, RefreshScopeRefreshedEvent.class })
    public synchronized void reschedule() {
        if (currentFuture != null) currentFuture.cancel(false);
        currentFuture = scheduler.scheduleAtFixedRate(
            this::checkOrders, new Date(), props.getOrderCheckIntervalMs());
    }

    public void checkOrders() { /* 定时任务逻辑 */ }

    @Override public void destroy() {
        if (currentFuture != null) currentFuture.cancel(true);
    }
}

④ 坑 4 修复:Gateway routes 显式加 order(越小越优先):

spring:
  cloud:
    gateway:
      routes:
        # /api/order/pay/** 前缀更精确,order 越小越优先
        - id: pay-service-route
          uri: lb://pay-service
          order: 0
          predicates:
            - Path=/api/order/pay/**
          filters:
            - StripPrefix=2

        # /api/order/** 前缀更宽泛,order 放后面
        - id: order-service-route
          uri: lb://order-service
          order: 100
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=2

⑤ 坑 5 修复:Gateway 跨 Filter 用 exchange.getAttributes(),跨 Reactor 算子用 Context.of:

@Component
@Order(-99)
public class UserExtractFilter implements GlobalFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        String userId = JwtUtils.parseUserId(token);

        // 正确做法 1:跨 GlobalFilter 用 exchange.getAttributes() 存
        exchange.getAttributes().put("userId", userId);

        // 正确做法 2:跨当前链上的 Reactor 算子用 contextWrite(需下游用 deferContextual 读)
        return chain.filter(exchange)
            .contextWrite(Context.of("bizTag", "vip"));
    }
}

@Component
@Order(-80)
public class AuditLogFilter implements GlobalFilter {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 从 exchange.getAttributes() 读 → 一定能读到,跟请求绑定
        String userId = (String) exchange.getAttributes().get("userId");

        // 从 Reactor Context 读 → 必须在 chain.filter 返回的 Mono 上 deferContextual
        return chain.filter(exchange)
            .then(Mono.deferContextual(ctx -> {
                String bizTag = ctx.getOrDefault("bizTag", "normal");
                log.info("审计日志 userId={}, bizTag={}", userId, bizTag);
                return Mono.empty();
            }));
    }
}

⑥ 坑 6 修复:Feign ObjectMapper 定制 + JavaTimeModule + GMT+8 时区:

// application.yml 全局 Jackson 配置(Spring MVC + Feign 共用)
spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8
    serialization:
      write-dates-as-timestamps: false   # 禁止 Date 转成 timestamp 毫秒数
    default-property-inclusion: non_null

// 自定义 Feign Encoder/Decoder 用同一个定制 ObjectMapper
@Configuration
public class FeignJacksonConfig {
    @Bean
    public Encoder feignEncoder(ObjectMapper objectMapper) {
        return new SpringEncoder(
            () -> new HttpMessageConverters(new MappingJackson2HttpMessageConverter(customize(objectMapper))));
    }
    @Bean
    public Decoder feignDecoder(ObjectMapper objectMapper) {
        return new SpringDecoder(
            () -> new HttpMessageConverters(new MappingJackson2HttpMessageConverter(customize(objectMapper))));
    }
    private ObjectMapper customize(ObjectMapper om) {
        return om.copy()
            .registerModule(new JavaTimeModule())  // LocalDate/LocalDateTime 序列化
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"))
            .setTimeZone(TimeZone.getTimeZone("GMT+8"));
    }
}

⑦ 坑 7 修复:FallbackFactory 抛 BusinessFallbackException,交由 @RestControllerAdvice 统一告警:

// 自定义业务 Fallback 异常,让全局异常处理能识别
public class BusinessFallbackException extends RuntimeException {
    private final String serviceName;
    public BusinessFallbackException(String serviceName, String msg, Throwable cause) {
        super(msg, cause); this.serviceName = serviceName;
    }
    public String getServiceName() { return serviceName; }
}

// FallbackFactory 实现:按异常类型分支处理,不要吞异常
@Component
public class UserServiceFallbackFactory implements FallbackFactory<UserServiceClient> {

    @Override
    public UserServiceClient create(Throwable cause) {
        return new UserServiceClient() {
            @Override
            public UserVO getUserById(Long id) {
                // 1. 先打 error 日志,带完整异常栈,ELK 能搜到
                log.error("[FALLBACK] user-service getUserById 失败, id={}, causeType={}",
                    id, cause.getClass().getSimpleName(), cause);

                // 2. CircuitBreakerOpenException(熔断)→ 抛 BusinessFallbackException,触发告警
                if (cause instanceof CircuitBreakerOpenException) {
                    throw new BusinessFallbackException("user-service",
                        "user-service 熔断,暂时无法访问", cause);
                }

                // 3. Sentinel 限流异常 BlockException → 返回降级默认值(属于预期内,不必告警)
                if (cause instanceof BlockException) {
                    return new UserVO(id, "系统繁忙,请稍后重试");
                }

                // 4. 其他异常(超时 / 500 / connect 失败)→ 抛,不要吞
                throw new BusinessFallbackException("user-service",
                    "调用 user-service 失败: " + cause.getMessage(), cause);
            }
        };
    }
}

// 全局异常处理:统一捕获 BusinessFallbackException,做告警 + 响应
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(BusinessFallbackException.class)
    public Result<Void> handleFallback(BusinessFallbackException e) {
        // 这里可以发 Prometheus Counter、钉钉/飞书告警
        alertService.sendAlert("SERVICE_FALLBACK", e.getServiceName(), e.getMessage());
        return Result.error(5003, "服务暂不可用: " + e.getServiceName());
    }
}
深度追问

追问 1:坑 5 中用了 exchange.getAttributes(),但在 Gateway 的 NettyRoutingFilter 中异步转发后,响应链路上一个 Post GlobalFilter 还能读到吗?

能。ServerWebExchange 的生命周期是从"请求进入 Gateway"一直到"响应完全写回客户端"的整个请求-响应过程。NettyRoutingFilter 异步转发后,响应沿着 Filter 链按 order 从大到小反向执行(post 阶段),每一步 GlobalFilter 拿到的 exchange 对象是同一个实例(不是拷贝)。所以只要你在前置 Filter(order=-99)往 exchange.getAttributes().put("userId", uid) 里写进去,后面所有 Filter(无论前置还是后置),包括 NettyRoutingFilter 转发完毕后的 Post GlobalFilter,甚至 exchange.getResponse().beforeCommit() 回调里都能读到同一个值。这就是为什么 跨 Filter 用 exchange.getAttributes() 是最佳实践——语义清晰、生命周期明确、不依赖 Reactor Context 订阅链。

追问 2:坑 3 中 @RefreshScope + @Async 线程池引用失效的根因(与 @Scheduled 类似)?

根因和 @Scheduled 类似。@Async 是由 AsyncAnnotationBeanPostProcessor 在初始化期扫描 @Async 方法,把 target Bean 的方法句柄 + 实例引用包装成 AsyncExecutionInterceptor 的 MethodInterceptor,交给 TaskExecutor 异步调度。RefreshScope 销毁旧 target、建新 target 时,AsyncExecutionInterceptor 里持有的实例引用仍然是旧的——后续每次 @Async 调用都是在旧 target 上执行,自然拿到旧配置值。修复思路一致:① 把 @Async 方法放到非 RefreshScope Bean,配置改用 @ConfigurationProperties;② 或者在 RefreshScopeRefreshedEvent 监听器里手动销毁并重新注册 AsyncAdvisor(更重,不推荐)。推荐方案永远是:业务单例 Bean 保持 singleton,配置抽 @ConfigurationProperties(它是 rebind 机制,不需要销毁重建 Bean),@RefreshScope 只用于 Controller / Feign 客户端这种轻量对象。

追问 3:坑 7 如果是 CircuitBreakerOpenException 与业务 FallbackException 同时触发如何区分?

用异常类型的 instanceof 精确判断,判断顺序必须从具体子类到宽泛父类。FallbackFactory.create(Throwable cause) 的 cause 真实类型在 Sentinel + Resilience4j 组合场景下有 6 种常见:① BlockException(Sentinel 限流)→ 子类 FlowException / DegradeException / ParamFlowException;② CircuitBreakerOpenException(Resilience4j 熔断打开);③ TimeoutException(Feign readTimeout);④ ConnectException(连接失败);⑤ RetryableException(重试耗尽);⑥ 真正的业务 FeignException$InternalServerError(HTTP 500,服务端抛业务异常)。判断顺序建议:先判断 BlockException 及其子类 → 再 CircuitBreakerOpenException → TimeoutException/ConnectException → FeignException(看 status())→ 最后兜底 RuntimeException。每一种分别走不同的告警策略:限流降级、熔断、超时、服务端 5xx,分别对应 Prometheus 的 counter name 不同。

常见陷阱
易错点
  • 以为用了 Spring Cloud 就高枕无忧,不做压测:上面 7 个坑几乎所有都是"线上有流量、走特定路径"才会触发,本地单测和功能测试根本复现不了(比如坑 1 传文件、坑 4 Path 重叠必须请求 /api/order/pay/create、坑 3 必须 refresh+等下一次定时任务才会暴露)。上线前必须做:接口级压测 + 配置中心 refresh 演练 + Gateway 路由全矩阵用例 + 故障注入(Sentinel 限流熔断触发),这四项缺一项都叫没准备好上生产。
  • 每个坑都用"临时 try-catch"修复而不在根本处调整架构:比如坑 4 Gateway Predicate 优先级,很多人明知 Path 重叠但为了"先上线"就在 order-service 里再加一个 /pay/create 的 Controller 做转发——这是把架构问题变成业务代码的债,过三个月连本人都忘了 order-service 里为什么有个 pay 的 Controller。正确修复永远是"在问题的根源层改":Path 重叠就拆前缀,order 显式加,不要绕道。
  • 不记录告警埋点,出了生产事故才发现:坑 7 FallbackFactory 吞异常的可怕之处不是"降级返回空值"本身,而是"线上连续 3 小时调用 user-service 全走 fallback,业务报表对不上、客诉激增、工程师在 ELK 里搜不到任何 error 日志"。正确修复必须配套三层埋点:① FallbackFactory.error 级日志带 cause;② Prometheus Counter(service_fallback_total{service="user-service", cause="circuit_breaker"});③ 钉钉/飞书告警(Counter > 10/min 时)。三层都有才能"5 分钟内发现、15 分钟定位、30 分钟恢复"。
C10.25
Dubbo vs Spring Cloud:阿里系 RPC 核心原理与选型架构P1
本质问题

这道题考察对两种主流微服务技术路线的原理级区分与选型判断:Dubbo 是基于 TCP 长连接的高性能 RPC 框架(强调接口粒度治理与强契约),Spring Cloud 是基于 HTTP/REST 的微服务全家桶(强调生态整合与跨语言)。本质是"自研高性能 RPC vs 标准 HTTP 生态"的取舍。

演进脉络

Dubbo:阿里 2011 开源(2.x,Netty + Hessian),停更数年后由 Apache 接管;Dubbo 2.7(2019)强化元数据/配置中心,Dubbo 3.x(2021)引入 Triple 协议(gRPC over HTTP/2)与应用级服务发现,解决跨语言与 URL 爆炸。

Spring Cloud:Netflix OSS(Eureka/Ribbon/Hystrix)起步,后因 Netflix 停更转向官方/Spring Cloud Alibaba(Nacos/Sentinel/Seata),以 OpenFeign(HTTP)做声明式调用,配套 Gateway/Config/Sleuth 全家桶。

新变量:gRPC、Service Mesh(Sidecar 接管通信,框架变薄)正在重塑选型。

第一性原理
维度 Dubbo Spring Cloud
通信 TCP 长连接(Dubbo 协议/Netty),二进制序列化 HTTP/REST(OpenFeign),文本 JSON
性能 高(二进制、长连接复用、低延迟) 中(HTTP 握手、JSON 解析开销)
服务治理 接口级精细(方法级路由/限流) 靠生态组件(Sentinel/Gateway)组合
跨语言 旧版弱;3.x Triple(gRPC) 改善 天然好(HTTP 标准)
契约 强类型接口(Interface 即契约) 弱(URL + JSON,易不一致)
生态 RPC 为核心,周边自补 全家桶完整(网关/配置/链路/熔断)

Dubbo 调用链:Consumer 从注册中心(Nacos/ZK)拉 Provider 列表 → 负载均衡选节点 → Netty 长连接发二进制请求 → 序列化(Hessian2/Kryo)→ Provider 执行返回。3.x 的 Triple 用 HTTP/2 + Protobuf,兼顾性能与跨语言。

关键结论

性能与强契约敏感的内部服务调用 → Dubbo;快速搭建、多语言、偏 RESTful 治理 → Spring Cloud。两者可混用:Dubbo 做内核 RPC,Spring Cloud Gateway 做边界 REST 网关。

工程实践

注册中心:Nacos 同时服务 Dubbo 与 Spring Cloud,可统一。Dubbo 3 推荐应用级服务发现(dubbo.application.register-mode=instance)降低注册中心压力。

混布架构:对外暴露 REST(Gateway/OpenFeign),内部服务间用 Dubbo 高性能 RPC;Provider 同时注册到 Nacos 供两类消费者发现。

序列化选型:Dubbo 避免用 Hessian 跨语言坑,内部用 Kryo/Fastjson2,跨语言用 Triple(Protobuf)。

治理兜底:注册中心不可用时本地缓存兜底 + 客户端重试,避免全链路雪崩。

深度追问

追问 1:Dubbo 3 应用级服务发现解决了什么? 旧 2.x 接口级发现为每个接口生成一条 URL,大规模下注册中心 URL 数量爆炸("URL 爆炸")。应用级发现按"应用"粒度注册,接口映射下沉到 metadata,URL 数量降一到两个数量级。

追问 2:为什么大厂(阿里/字节/美团)偏好 Dubbo 系? 内部服务调用量大、对延迟与吞吐敏感、强类型契约利于大规模协作与治理,RPC 的性能和精细治理优势显著。

追问 3:Service Mesh 趋势下框架会变薄吗? 是的。Sidecar(Envoy)接管服务发现、负载均衡、熔断、mTLS,应用框架只关心业务逻辑,Dubbo/SC 的治理职责逐步上移,但 RPC 高性能内核仍有价值。

常见陷阱
易错点
  • 以为 Spring Cloud 性能差不可用:合理连接池+序列化下吞吐足够,HTTP 并非瓶颈,瓶颈多在业务与 DB。
  • Dubbo 接口级发现 URL 爆炸:旧 2.x 大规模踩坑,应升 3.x 应用级发现。
  • 混用不分边界:应"内 Dubbo、外 REST",否则治理混乱、跨语言困难。
  • 序列化选型随意:Hessian 跨语言有坑,应按场景选 Kryo/Triple(Protobuf)。
  • 注册中心强依赖无降级:注册中心抖动导致全量不可用,需本地缓存兜底。
C10.26
@RefreshScope 生产踩坑与 @ConfigurationProperties 优雅使用规范架构P1
本质问题

@RefreshScope 是 Spring Cloud 配置热刷新的"双刃剑":它本质是一个自定义 Scope,把 Bean 包成代理、刷新时 cache.clear() 重建 target。很多团队把它当"加上就能热更新"的银弹,结果线上出现双实例、内存泄漏、定时任务重复执行、事务/AOP 失效等隐性生产事故。重点是:你能讲清 @RefreshScope 的 9 大坑与避坑准则,并能给出 @ConfigurationProperties 业务侧"优雅使用 + 安全热更新"的工程规范——尤其是 Nacos 下 @ConfigurationProperties 自动 rebind 无需 @RefreshScope,而 @Value 才需要;Spring Cloud Config 下 @ConfigurationProperties 仍需 @RefreshScope 这一反直觉差异。

演进脉络

配置热更新从「重启生效」→ @RefreshScope 代理重建 → 配置中心 rebind(Nacos 对 @ConfigurationProperties)→ 事件监听手动托管资源生命周期。演进方向是少代理、可控销毁,避免把业务 Bean 塞进 RefreshScope。

第一性原理:9 大生产坑与根因
# 坑 现象 根因
1 新旧 Bean 共存 / 双实例 旧 Bean 的线程池、定时任务、MQ 消费者未回收,新 Bean 又建一份,任务重复执行 旧 Bean 被普通单例 Bean 强引用,GC 无法回收;@RefreshScope 仅标记过期不立即销毁
2 刷新延迟(懒加载坑) 调完 /actuator/refresh 立刻读还是旧值,下次请求才变新值 懒加载:刷新仅标记过期,第一次访问才重建 target
3 AOP / 事务 / @Async 失效 打 @RefreshScope 的 Service 事务偶尔失效 RefreshScope 生成 CGLIB 代理,多层代理顺序错乱覆盖切面
4 @Bean + @RefreshScope 管理重资源 RestTemplate/连接池每次刷新重建,连接泄漏 @Bean 加 @RefreshScope 刷新即新建,重资源未复用
5 循环依赖刷新异常 refresh 时抛 BeanCurrentlyInCreationException RefreshScope Bean 与单例互相依赖,循环代理
6 Nacos 自动刷新无限循环 CPU 飙升 refresh-enabled=true + 初始化 BUG 抛异常,缓存未清反复触发刷新
7 静态变量不刷新 @Value 注入 static 字段永远旧值 @RefreshScope 对静态字段无效,静态变量属于类而非实例
8 Feign + @RefreshScope 灾难 Feign 接口偶发调用失败 Feign 代理外再包 RefreshScope 代理,方法找不到
9 旧资源未关闭 OOM 线程数只涨不跌最终 OOM 刷新建新 Bean 但旧线程池/HttpClient 未 shutdown
避坑提要

@RefreshScope 只给纯配置属性类;业务勿缓存字段快照;重资源与事务/AOP Bean 禁止加 @RefreshScope。完整准则见下方「常见陷阱」层。

工程实践:@ConfigurationProperties 业务侧优雅使用

① 标准模板(Nacos 无需 @RefreshScope):

@Data
@Component
@ConfigurationProperties(prefix = "app.rule")
public class AppRuleProperties {
    private Integer limit;
    private Boolean switchFlag = false; // 给默认值防 NPE
    private NotifyProperties notify; // 嵌套子配置
    @Data
    public static class NotifyProperties {
        private Boolean enable;
        private String url;
    }
}

要点:Lombok @Data 生成 setter(Nacos rebind 通过 setter 改字段);字段给默认值;不 new 线程池/HttpClient/MQ;不要加 @RefreshScope。

② 三种注入姿势(实时读取优先):

  • 方式 1(最推荐):@Autowired 注入,方法内实时 get,不缓存字段快照。Nacos 改值立刻读到新值,无快照问题。
  • 方式 2:构造器注入 final AppRuleProperties——引用不可变但内部字段可被 Nacos 修改(经典误区:final 锁的是引用不是字段)。
  • 方式 3(高 QPS 折中):本地短过期缓存(5~10s),平衡性能与时效性;严禁永久快照。
最高频事故

@PostConstruct 里把 prop.getLimit() 赋值给成员变量 = 永久快照,热更新永远失效。只要把配置字段赋给另一个成员变量,就等于放弃热更新。

③ 重型资源热更新(手动监听,资源生命周期托管):

@Service
public class BizThreadPoolManager {
    private ExecutorService executor;
    private final AppRuleProperties properties;
    public BizThreadPoolManager(AppRuleProperties properties) {
        this.properties = properties;
        refreshThreadPool();
    }
    @EventListener(EnvironmentChangeEvent.class)
    public void onConfigChange() { refreshThreadPool(); }
    private synchronized void refreshThreadPool() {
        if (executor != null && !executor.isShutdown()) executor.shutdown();
        executor = Executors.newFixedThreadPool(properties.getLimit());
    }
}

④ 启动校验 + 包分层:@Validated + JSR-380(@Min/@Max)启动即拦截错误配置;所有配置 Bean 统一放 xxx.config.properties 包,与 @Configuration 配置类区分。

深度追问:Nacos vs Spring Cloud Config 刷新差异
配置中心 注解组合 是否需要 @RefreshScope 原理
Nacos Config @ConfigurationProperties 不需要 Nacos 发 EnvironmentChangeEvent → ConfigurationPropertiesRebinder.rebind() 改单例字段,始终同一对象
Nacos Config @Value("${x}") 需要 @Value 字段注入后固化,rebind 不刷 @Value,需 @RefreshScope 重建
Spring Cloud Config @ConfigurationProperties 需要 原生无 Nacos 增强,不自动 rebind,调 /actuator/refresh 才生效
Spring Cloud Config @Value("${x}") 需要 同上

划重点:Nacos 下绝大多数 @ConfigurationProperties 配置类可以删掉 @RefreshScope;加上反而引入新旧双实例风险,收益为 0。只有 @Value 注入才需要 @RefreshScope。

常见陷阱
避坑黄金准则
  • @RefreshScope 只给纯配置属性类,禁止加在业务 Service / Mapper / Feign / 定时任务类上。
  • 业务代码不要缓存 RefreshScope Bean 的字段快照;每次用时实时 get 或走监听回调。
  • 重型资源(线程池、连接池、客户端)禁止交给 @RefreshScope,需手动销毁旧资源再建新资源。
  • 事务 / @Async / AOP 切面 Bean 禁止加 @RefreshScope。
  • 最优替代:放弃 @RefreshScope,用 @NacosConfigListener / EnvironmentChangeEvent 手动监听,刷新单例 Bean 字段,无新旧双实例问题。
排障 SOP 与工程速查

① 线上已出事怎么定位(5 步 SOP):@RefreshScope 的坑多为隐性、偶现、难复现,按以下顺序排查:

  1. 查业务 Service / Mapper / Feign / 定时任务类是否误打 @RefreshScope(最高发事故源)。
  2. 查 RefreshScope Bean 是否被普通单例 Bean 的成员变量注入持有——强引用导致旧实例永不回收,双实例共存。
  3. 线程堆栈看是否存在两份同名业务线程(如两个同名定时任务线程池),据此判断新旧 Bean 共存。
  4. 关闭 Nacos 自动刷新(refresh-enabled=false),手动调 /actuator/refresh 复现,排除"无限刷新"干扰。
  5. 查是否用静态变量 / @PostConstruct 快照接收配置——这两类永远读不到刷新后的值。

② 两种热刷新方案对比选型:

方案 优点 致命缺点 适用场景
@RefreshScope + actuator/refresh 代码改动少、开箱即用 懒加载、新旧 Bean 共存、内存泄漏风险 纯配置 POJO、轻量无资源对象
配置监听器手动回调(Nacos Listener / EnvironmentChangeEvent) 全程可控、单例 Bean 无新旧实例问题、生产首选 需自己写回调更新字段、资源需手动关闭 业务开关、线程池、连接池热更新
IDE 配置元数据提示

引入 spring-boot-configuration-processor 依赖,@ConfigurationProperties 类编译后生成 spring-configuration-metadata.json,在 application.yml 写配置时自动补全 + 类型校验,减少手误。

C10.27
配置中心 / 注册中心的推送范式全景:通知+二次拉取 vs Watch 直推 vs 纯 Pull?架构P1
本质问题

客户端如何"及时拿到"配置 / 服务实例变更,是配置中心(Nacos/Apollo)与注册中心(Nacos/Eureka/ZK/Etcd/Consul)的核心差异点。常要求横向对比"推送机制",本质是对三种数据同步范式的取舍。

演进脉络

配置/注册变更感知:定时全量 Pull → 通知+二次拉取(Nacos/Apollo 常见)→ Watch/长轮询 → 纯事件流。选型看一致性窗口、连接数与防火墙,而非只比“推/拉”口号。

三种推送范式
范式 机制 代表组件 优点 缺点
① 通知 + 二次拉取 服务端变更后通知客户端(长轮询 / 长连接),客户端再主动拉取完整最新数据 Nacos 1.x 配置(HTTP 长轮询)、Nacos 2.x 配置(gRPC 通知)、Apollo、Eureka 客户端控拉取节奏、服务端无状态、天然支持灰度 / 分批 多一次拉取 RTT;通知通道需保活
② Watch 直推完整数据 CP 型系统 Watch 事件直接携带变更后完整节点数据,客户端无需再拉 ZK(Watcher 带新 Data)、Etcd(Watch 带 MVCC 全量)、Consul(Blocking Query 返回新值) 一次到位、强一致、实时性最高 服务端需维护 Watch 会话状态、大数据节点事件体积大
③ 纯 Pull 客户端定时 / 按需轮询服务端 早期 DNS、简单 HTTP 健康检查 实现最简单、无长连接 实时性差、轮询空转浪费
为什么 AP 型用"通知+二次拉取"而 CP 型可"直推完整数据"

AP 型(Nacos/Apollo/Eureka):服务端为保证可用性、不维护每个客户端会话状态,变更时不持有"完整最新快照给谁"的上下文,故只发轻量通知,由客户端自行拉取它需要的那份数据——解耦、可水平扩展、支持批量 / 灰度。

CP 型(ZK/Etcd/Consul):基于强一致存储(ZAB/Raft),Watch 事件本身就能附带 MVCC 版本下的完整新值,且客户端会话由服务端维护,直推完整数据不破坏一致性,反而减少一次 RTT。

关键澄清:Nacos 2.x 用 gRPC 长连接,但配置推送仍是"通知+二次拉取"二阶段范式——gRPC 只升级了"通知"通道(从 HTTP 长轮询变为长连接主动 Push 通知),客户端收到通知后仍拉取最新配置快照,并非 Watch 直推。切勿与 ZK 的 Watch 直推混淆。

选型与生产要点
  • 选型:追求强一致 + 实时直推选 ZK/Etcd/Consul(Watch 直推);追求高可用 + 大规模动态配置选 Nacos/Apollo(通知+二次拉取,长连接保活)。
  • 客户端本地快照兜底:配置 / 注册中心全宕机时,客户端用上次快照启动运行(Nacos/Apollo 均支持),避免中心故障阻断业务。
  • 推送通道保活:长轮询 / 长连接需心跳,断连降级为定时拉取(如 Nacos 1.x 服务发现 UDP 丢包靠 10s 定时拉取补偿)。
常见陷阱
易错点
  • 混淆 Nacos 2.x 与 ZK 推送模型:以为 gRPC 长连接就是"直推完整数据",实际仍是通知+二次拉取。
  • 无本地快照:配置中心宕机业务起不来。
  • 依赖 UDP 单通道:不配定时拉取兜底,UDP 丢包导致实例列表陈旧。
C10.28
平台工程与内部开发者平台(IDP):Golden Path、自助交付与平台团队边界如何设计?架构P1
本质问题

平台工程不是"再做一个运维门户",而是把认知负荷从业务团队转移到平台:用 Golden Path(默认正确路径)让开发者自助完成创建服务、CI/CD、可观测、权限与环境,同时保留逃生舱。核心回答:IDP 装什么?平台与业务的所有权边界?如何用 SLO/错误预算约束平台自身?

演进脉络

运维工单时代:申请机器/域名/证书靠人肉,交付周期周级。

DevOps 工具堆砌:Jenkins/K8s/Prometheus 齐了,但每个团队自建流水线,标准分裂、重复造轮。

平台工程 / IDP(2020s):以开发者体验为产品,Backstage 等门户聚合软件目录、模板、文档与评分;GitOps(Argo CD)+ Progressive Delivery 把发布能力产品化。

与云原生合流:Service Mesh、可观测、密钥管理下沉为平台能力;业务只写业务(呼应 C10.05 Mesh、C10.17 SLO)。

第一性原理
  • Golden Path:官方推荐默认路径(脚手架、基础镜像、流水线、探针、日志规范),可选但默认最优;逃生舱允许偏离并登记例外。
  • 软件目录:服务所有权、依赖、文档、SLO、Oncall 一处可查(Backstage Catalog 思路)。
  • 自助 > 工单:环境、密钥、Topic、数据库 schema 变更尽量 API/门户自助,工单只处理例外。
  • 平台也是产品:有用户(研发)、有路线图、有采纳率/TTFV(Time To First Value)指标。
职责 平台团队 业务团队
流水线/基础镜像/合规门禁 拥有 消费,提需求
服务 SLO / 告警路由 提供模板与管道 定义业务 SLI/告警
业务功能发布 提供发布能力 对变更结果负责
工程实践

最小可行 IDP:① 服务脚手架(Boot 4 + 探针 + OTel Agent + 结构化日志);② 一键流水线(构建/单测/镜像/安全扫描/部署);③ 软件目录(Owner、依赖、Runbook);④ 环境自助(dev/staging 命名空间配额);⑤ 可观测默认接入(互链 C10.15/C11.09)。

治理:Scorecard(测试覆盖、有无 Runbook、有无 SLO)驱动改进,而非单纯审计惩罚。

深度追问

追问 1:平台工程和 SRE 什么关系? SRE 偏可靠性与错误预算;平台工程偏开发者生产力与路径标准化。二者重叠在可观测/发布,但 KPI 不同(MTTR vs TTFV)。

追问 2:强制 Golden Path 会不会扼杀创新? 默认强制、例外可审批;创新在沙箱集群验证后回流成新路径版本。

常见陷阱
  • 门户堆链接:没有模板与自助 API,只是书签墙。
  • 平台背业务锅:未划清 SLO 所有权,业务告警全打给平台。
  • 一次做完大而全:先交付 1 条 Golden Path 端到端,再扩展。
C10.29
网关超时链路怎么拆:从客户端到下游每一跳的超时与排查顺序?高级P1
本质问题

超时不是单点配置:客户端、网关、服务、下游各有一层;任一层大于上游会导致「上游先断、下游仍占资源」。

原理映射

超时预算应自上而下递减;重试与超时乘积决定放大倍数。Trace 各 span 耗时是定位「谁先超时」的证据,存活探针绿不代表链路健康。

落地排查

排查顺序:

  1. 确认客户端超时与重试次数。
  2. 网关全局/路由超时与连接池。
  3. 服务端容器/WebClient/Feign 超时。
  4. 下游依赖超时。
  5. 对照 Trace 各 span:谁先超时、谁还在重试。
参数与开关

常用旋钮:Gateway response-timeout、Netty 连接超时;Feign/HttpClient connect/read;线程池 queue。契约示例:客户端 3s = 网关 2.5s = 服务内下游合计 ≤2s。把总预算写进接口契约。

失败模式与回滚

失败模式:只调网关不调客户端;超时后疯狂重试放大;异步回调无超时。回滚点:恢复上一版超时配置(配置当发布,对照 G01.02)。

超时链路要可加总:客户端 > 网关 > 服务 > 下游,且每一跳留余量。排查从最外层超时症状往内对预算表。

常见坑:网关 1s、服务 3s;或重试把预算打穿。临时止血:降重试、扩超时(有上限)、切降级。

量化契约:客户端 3s > 网关 2.5s > 服务 2.0s > 单跳下游 ≤ 500ms,各跳留 ≥ 20% 余量;重试次数与超时须满足「(n+1) × timeout ≤ 上游预算」,否则重试即放大。演进:把超时预算写进接口契约(随 OpenAPI / gRPC 描述交付),CI 阶段校验「下游声明超时之和 ≤ 本服务超时」,从源头阻断「网关 1s、服务 3s」类倒挂。

C10.30
配置刷新安全回滚值班:@RefreshScope 之外还要核对什么?高级P1
本质问题

动态刷新能秒级改行为,也是秒级制造事故的入口。值班要识别「配置已推但行为未变 / 行为已变但不可退」。

原理映射

配置中心是关键依赖:客户端要有本地快照、推送失败降级、订阅隔离。RefreshScope 只覆盖被代理的 Bean;重资源 Bean 更适合 @ConfigurationProperties + rebind。回滚必须含配置维(对照 G01.02 / C10.26)。

落地排查

排查顺序:

  1. 变更单与配置版本/MD5。
  2. 实例订阅滞后与快照版本分布。
  3. 刷新后 Bean 是否真重建(管理端/日志)。
  4. 关停开关是否可用。
  5. 优先回滚配置快照,而不是再推「修复值」。
参数与开关

核对表:生效范围(命名空间/DataId)、灰度实例列表、本地快照兜底、关停开关、双人复核(限流/鉴权/路由)。15 分钟观察:推送成功率、订阅滞后、客户端版本分布。

失败模式与回滚

失败模式:控制台无单改生产;只退镜像不退配置;RefreshScope 未覆盖的 Bean 静默不生效。回滚点:配置快照 + 必要时缩容到未刷新实例。

@RefreshScope 只刷新代理 Bean;静态字段、连接池、本地缓存不会自动跟。回滚要核对配置中心版本与实例实际生效值。

清单:刷新范围、失败实例、是否需滚动重启、回滚后的只读校验。

量化:推送后 15 分钟内订阅滞后实例比例 > 5% 或快照版本不一致,即暂停放量;@RefreshScope 只覆盖被代理 Bean,静态字段 / 连接池 / 本地缓存需滚动重启才生效。演进:把「配置版本 + 生效实例清单 + 需重启实例数」写入变更单,需重启实例数为 0 才认刷新成功;回滚默认退配置快照而非重推修复值,限流 / 鉴权 / 路由类开关设双人复核。