Skip to content

Day19 Spring Cloud Alibaba 微服务全家桶:Nacos、Sentinel、Feign、Gateway、Seata 分布式事务 ​

📖 基础知识讲解【基础原理 + 高级深挖知识点】 ​

基础原理 ​

1. 全景图:一次请求在微服务体系里走过的八道关 ​

Day14~Day15 讲的是单体内部(容器、事务、MVC),Day19 讲的是服务之间:地址怎么找到、流量怎么挡住、调用怎么发出、事务怎么跨服务保证一致。

微服务的所有组件都在解决同一个根本问题:单体里的方法调用变成了跨进程的网络调用,而网络是不可靠的。把这句话记住,下面每个组件都是它的一个推论。

text
  客户端 / App
     │ HTTPS
     ▼
┌──────────────────────────────────────────────────┐
│ Gateway:路由匹配 → 鉴权 → 限流 → 跨域 → 转发    │
└──────────────────────────────────────────────────┘
     │ 从 Nacos 拿到 order-svc 实例列表 → 负载均衡选一台
     ▼
┌─────────────────┐   OpenFeign 调用    ┌───────────────┐
│    order-svc    │ ──────────────────▶ │   stock-svc   │
│ @GlobalTransac- │  经 Sentinel 限流/熔断 └───────────────┘
│ tional(TM 发起)│                     ┌───────────────┐
│                 │ ──────────────────▶ │  account-svc  │
└─────────────────┘                     └───────────────┘
     │ 三个服务的 RM 各自向 TC 注册分支事务、上报状态
     ▼
┌──────────────────────────────────────────────────┐
│ Seata TC:全局事务状态机 + 全局锁 + 提交/回滚指令 │
└──────────────────────────────────────────────────┘

旁路(贯穿所有服务):
  启动时 → 向 Nacos 注册实例 + 拉取配置
  运行中 → 每 5s 心跳续约;配置变更由 Nacos 长轮询推回各服务

八道关对应八个问题:

关卡解决的问题组件不做会怎样
1. 服务注册我在哪,别人怎么找到我Nacos Discovery地址硬编码,扩容要改代码重启
2. 配置管理配置怎么统一改、怎么分环境Nacos Config改一个开关要重新打包发布
3. 网关路由外部流量从哪进、怎么分发Gateway前端要记住 N 个服务地址
4. 统一鉴权登录态在哪校验Gateway Filter每个服务重复写一遍鉴权
5. 服务调用跨进程调用怎么写得像本地方法OpenFeign手写 RestTemplate 拼 URL
6. 负载均衡下游有 10 个实例,调哪个Spring Cloud LoadBalancer流量全压一台
7. 流量防护流量超了、下游挂了怎么办Sentinel雪崩,整个集群瘫痪
8. 分布式事务三个服务的库怎么一起成功Seata扣了库存没扣钱,数据对不上

⚠️ 面试最容易踩的第一个坑:把微服务组件当成「必须全上」的套餐。真实项目里 Nacos + Feign + Sentinel + Gateway 是标配,Seata 是按需引入——它的性能和运维成本都不低,能用「最终一致 + 对账」解决的场景不要上强一致方案(Day18 的本地消息表就是替代方案)。


2. 版本对应关系:这是最容易翻车却最少人提的一环 ​

Spring Cloud Alibaba 的版本和 Spring Boot、Spring Cloud 强绑定,版本错配的报错往往完全不指向真实原因(比如 NoSuchMethodError、ClassNotFoundException、启动时 Bean 循环依赖)。

Spring Cloud AlibabaSpring CloudSpring Boot说明
2022.0.0.x2022.0.x(Kilburn)3.0.xJDK 17 起步,javax 全改 jakarta
2021.0.5.02021.0.52.6.13目前生产最常见的稳定组合
2.2.9.RELEASEHoxton.SR122.3.12老项目存量

正确做法是只用官方 BOM 管理版本,自己绝不写组件版本号:

xml
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-alibaba-dependencies</artifactId>
      <version>2021.0.5.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

面试加分表述:「我们踩过一次版本坑:升 Spring Boot 小版本后 Nacos 配置中心突然不生效,排查了半天发现是 2021.0.x 把配置导入方式从 spring.cloud.nacos.config.* 自动加载改成了必须显式写 spring.config.import=nacos:xxx(或者加 spring.cloud.nacos.config.import-check.enabled=false)。这类问题不看 release note 是查不出来的,所以我们现在升级任何中间件版本都要求先读变更日志。」


3. Nacos 注册中心:注册、发现、心跳、下线这四件事 ​

注册中心的职责只有一句话:维护一张「服务名 → 健康实例列表」的表,并让这张表的变化尽快传播出去。

text
① 启动注册:order-svc 启动 → POST /nacos/v1/ns/instance
   携带 serviceName、ip、port、weight、metadata、ephemeral

② 心跳续约:客户端每 5s 发一次心跳
   Nacos 15s 没收到心跳 → 标记实例 unhealthy(不摘除,但不再返回给消费者)
   Nacos 30s 没收到心跳 → 直接摘除实例(仅临时实例)

③ 服务发现:stock-svc 的消费者首次调用时拉全量实例列表并缓存在本地
   之后依赖 Nacos 主动推送(2.x 走 gRPC 长连接)+ 兜底定时拉取

④ 优雅下线:收到 SIGTERM → 先调 deregisterInstance 注销
   再等一段时间让上游刷新缓存 → 最后停止 Tomcat

关键配置:

yaml
spring:
  application:
    name: order-service          # 注册到 Nacos 的服务名,必须配
  cloud:
    nacos:
      discovery:
        server-addr: 10.0.0.1:8848,10.0.0.2:8848,10.0.0.3:8848
        namespace: 7a2f-dev-uuid # 环境隔离,写 namespace 的 ID 不是名字
        group: ORDER_GROUP       # 业务分组
        ephemeral: true          # 临时实例(AP),默认 true
        weight: 100              # 权重,灰度/小规格机器可以调低
        metadata:
          version: v2            # 自定义元数据,可用于灰度路由

⚠️ namespace 必须填 ID(那串 UUID),不是控制台上显示的名字。填名字不会报错,但会被当成一个不存在的命名空间,表现为「服务注册上去了却找不到」——这是新手最高频的一个坑。


4. 临时实例与持久化实例:Nacos 的 AP/CP 到底切的是什么 ​

这是面试高频但答错率极高的一题。Nacos 的 AP/CP 不是「集群模式」的开关,而是按实例类型自动区分的:

维度临时实例(ephemeral=true)持久化实例(ephemeral=false)
一致性协议Distro(AP,最终一致)Raft / JRaft(CP,强一致)
健康检查方式客户端上报心跳服务端主动探测(TCP/HTTP/MySQL)
心跳丢失后15s 不健康、30s 自动摘除只标记不健康,永不自动摘除
数据存储内存,重启丢失后由客户端重新注册持久化到磁盘/数据库
适用场景微服务实例(默认,弹性伸缩)MySQL、Redis 这类需要手工运维的基础设施

所以标准答案是:微服务一律用临时实例走 AP,因为服务发现场景下「拿到一个稍微过期的列表」远比「注册中心不可用导致全站不能调用」要好——这正是 AP 优先的本质原因。只有当你要把一些非 Spring Cloud 的、不会自己发心跳的资源(比如一台固定 IP 的 MySQL)登记到 Nacos 做统一管理时,才用持久化实例。

面试加分表述:「很多人会说『Nacos 节点数大于 3 就是 CP』,这是错的。节点数只影响 Raft 选举能不能成功,协议的选择取决于实例类型。而且这两种实例在 Nacos 里是两套独立的数据链路:Distro 的数据在内存里按 responsible 节点分片同步,Raft 的数据走日志复制。这也解释了为什么同一个服务名下不允许混用两种类型的实例,会直接报错。」


5. 服务发现的「延迟」从哪来:为什么下线后还会被调用 ​

这是生产环境发布期间报错的头号原因,必须算清楚这条时间线:

text
T0    实例 A 被 kill(或网络断开)
T0    上游消费者的本地缓存里 A 还在 ───┐
T0+5s Nacos 没收到心跳(第 1 次缺失)  │  这段时间内所有打到 A 的
T0+15s Nacos 标记 A 为 unhealthy      │  请求全部 Connection refused
T0+15s Nacos 推送变更给订阅者          │
T0+16s 上游刷新本地缓存,A 被剔除  ────┘  窗口期 ≈ 15~30 秒

根因是「本地缓存」:消费者为了性能不会每次调用都查 Nacos,而是把实例列表缓存在内存里。所以无论 Nacos 多快发现故障,上游总有一个刷新延迟。

三条解决路径,生产上要同时用:

  1. 优雅下线(最有效):发布脚本先调 /actuator/service-registry?status=DOWN 或 Nacos 的注销接口,主动注销再停进程,把「被动发现」变成「主动通知」,窗口从 15 秒压到 1 秒内。
  2. Spring Boot 优雅停机:server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase=30s,保证已经进来的请求能处理完。
  3. 调用侧重试:对幂等的 GET 接口开启重试并换一个实例(retryableStatusCodes + retryOnAllOperations=false),把漏网的请求兜住。

⚠️ 第 3 条有严格前提:只有幂等接口能重试。给 POST /createOrder 开重试,就会出现「超时了其实已经成功」导致的重复下单——这是 Feign 重试最经典的生产事故。


6. Nacos 配置中心:Namespace / Group / DataId 三级模型 ​

配置中心解决的是「配置和代码解耦」,它的定位模型是三级的:

text
Namespace(命名空间)   ← 环境隔离:dev / test / pre / prod,彼此完全不可见
   └── Group(分组)    ← 业务/集群隔离:ORDER_GROUP / PAY_GROUP
         └── DataId     ← 具体配置文件:order-service-prod.yaml

DataId 的默认拼接规则是这三段:

text
${spring.cloud.nacos.config.prefix}-${spring.profiles.active}.${file-extension}
  ↓ prefix 默认取 spring.application.name
order-service-prod.yaml

实际项目里一个服务通常要加载三类配置,加载顺序决定了谁覆盖谁:

类型配置项典型用途优先级
主配置自动按 DataId 规则加载本服务独有配置最高
扩展配置extension-configs[n]本服务引用的专用配置中(n 越大越高)
共享配置shared-configs[n]所有服务共用(如日志、链路追踪)最低(n 越大越高)
yaml
spring:
  config:
    import:                       # 2021.0.x 之后必须显式声明
      - optional:nacos:order-service-prod.yaml
  cloud:
    nacos:
      config:
        server-addr: 10.0.0.1:8848
        namespace: prod-namespace-id
        group: ORDER_GROUP
        file-extension: yaml      # 默认是 properties,用 yaml 必须改
        shared-configs:
          - data-id: common-log.yaml
            group: COMMON_GROUP
            refresh: true         # 不写默认 false,改了不会热更新
        extension-configs:
          - data-id: datasource.yaml
            group: ORDER_GROUP
            refresh: true

⚠️ 两个默认值的坑:file-extension 默认是 properties(写了 yaml 内容却不生效);shared-configs / extension-configs 的 refresh 默认是 false(在控制台改了配置,服务完全没反应)。这两个都要显式写。


7. 配置热更新的传播链路:长轮询(1.x)与 gRPC 长连接(2.x) ​

面试常问「Nacos 配置是推还是拉」,标准答案是「1.x 是客户端长轮询伪装成的推,2.x 是真正的长连接推送」。

Nacos 1.x 的长轮询机制:

text
客户端 ──► 发起一个 /v1/cs/configs/listener 请求,带上本地配置的 MD5
             │
服务端 ──► 不立刻返回,挂起这个请求最长 29.5 秒
             │
       ┌─────┴─────┐
   配置变了?      没变?
       │             │
  立刻返回变更的   29.5s 后返回空
  DataId 列表      客户端立刻再发起下一次
       │             │
客户端 ──► 拿到变更列表后,再发一次请求获取完整配置内容

为什么是 29.5 秒而不是 30 秒?因为客户端的 HTTP 超时设置是 30 秒,服务端提前 0.5 秒返回,避免客户端先超时导致每次都走异常分支。这个细节经常被拿来考察是否真的读过源码。

Nacos 2.x 的改进:

维度1.x(HTTP 长轮询)2.x(gRPC 长连接)
连接模型每 30s 一次请求,连接反复建立一条长连接复用,双向流
变更感知延迟最长 29.5s + 一次拉取 RTT毫秒级推送
服务端压力大量挂起请求占线程/内存连接数固定,压力降一个量级
端口88488848 + 9848/9849(必须额外放开)

⚠️ 升级 Nacos 2.x 最高频的生产事故:只放开了 8848 端口,没放开 9848(客户端 gRPC)和 9849(服务端间 gRPC),表现为「注册成功但配置拉不到」或者客户端日志里刷 Client not connected。防火墙/安全组必须一起开。


8. @RefreshScope:配置变更后 Bean 是怎么拿到新值的 ​

配置拉到客户端只是第一步,让已经注入到 Bean 里的值变成新值是另一回事。

text
Nacos 推送变更
   │
   ▼
NacosContextRefresher 发布 RefreshEvent
   │
   ▼
ContextRefresher.refresh()
   ├─ 重新构建 Environment(用新配置替换 PropertySource)
   └─ 发布 EnvironmentChangeEvent
        │
        ▼
RefreshScope.refreshAll():清空 refresh 作用域的 Bean 缓存
        │
        ▼
下次通过代理访问该 Bean 的方法时 → 发现缓存为空 → 重新创建 Bean
                                → @Value 重新从新 Environment 取值

核心机制是作用域代理:@RefreshScope 本质是 @Scope("refresh", proxyMode = TARGET_CLASS),Spring 注入给你的其实是一个 CGLIB 代理。配置变更时并不是「原地改字段」,而是销毁缓存的真实对象,等下次调用时懒加载重建一个新的。

读配置的方式能否热更新说明
@Value + @RefreshScope✅ 能最常见写法,Bean 重建时重新解析
@Value 无 @RefreshScope❌ 不能Bean 不会重建,字段永远是启动时的值
@ConfigurationProperties✅ 能不需要 @RefreshScope,它监听 EnvironmentChangeEvent 后直接 rebind
static 字段❌ 不能Spring 根本不会注入 static 字段
Environment.getProperty()✅ 能每次都从最新 Environment 现取,最可靠

✅ 推荐写法(配置多时优先用 @ConfigurationProperties,类型安全且天然支持刷新):

java
@Component
@ConfigurationProperties(prefix = "order.risk")
@Data
public class RiskConfig {
    private boolean enabled = true;
    private int maxAmount = 100000;
    private List<String> blackList = new ArrayList<>();
}

❌ 错误写法(看起来能用,实际永远拿不到新值):

java
@Component
public class RiskChecker {
    // 错误一:没有 @RefreshScope,改配置后这个值不会变
    @Value("${order.risk.maxAmount}")
    private int maxAmount;

    // 错误二:static 字段 Spring 不注入,启动就是 0
    @Value("${order.risk.enabled}")
    private static boolean enabled;
}

⚠️ @RefreshScope 的隐藏代价:Bean 会被销毁重建,所以有状态的 Bean 绝对不能加(内存计数器、连接池、本地缓存都会被清空)。另外重建发生在下一次方法调用时,如果构造逻辑重(比如要连数据库),那次调用的 RT 会突然飙高。


9. OpenFeign 的本质:一个「把接口变成 HTTP 请求」的动态代理 ​

Feign 让远程调用写起来像本地方法,它的实现链路是:

text
启动阶段
  @EnableFeignClients 扫描包
    → 找到所有 @FeignClient 接口
    → 为每个接口注册一个 FeignClientFactoryBean(BeanDefinition)
    → getObject() 时用 JDK 动态代理生成实现类
    → Contract 解析接口上的 @RequestMapping/@PathVariable 等注解
       生成 MethodMetadata(URL 模板、方法、参数位置、编解码器)

调用阶段
  调用 stockApi.deduct(skuId, num)
    → 进入 InvocationHandler
    → 用 MethodMetadata + 实参拼出 RequestTemplate
    → 交给 LoadBalancer:根据服务名从 Nacos 拿实例列表 → 选一台 → 替换 host
    → 经过 RequestInterceptor(可在这里塞 token、traceId)
    → Client 真正发出 HTTP 请求(默认 HttpURLConnection)
    → Decoder 把响应体反序列化成返回值对象

标准用法(强烈建议把 Feign 接口抽成独立的 xxx-api 模块,由服务提供方维护):

java
@FeignClient(
        name = "stock-service",              // 对应 Nacos 里的服务名
        path = "/stock",                     // 统一前缀,避免每个方法重复写
        contextId = "stockApi",              // 同一服务多个接口时必须加,否则 Bean 名冲突
        fallbackFactory = StockApiFallback.class)  // 降级实现,能拿到异常原因
public interface StockApi {

    @PostMapping("/deduct")
    Result<Void> deduct(@RequestBody DeductDTO dto);

    // ⚠️ GET 带对象参数必须显式加 @SpringQueryMap,否则会被当成 body 发出去
    @GetMapping("/page")
    Result<PageVO<StockVO>> page(@SpringQueryMap StockQuery query);
}
坑点现象正确做法
同一服务写了两个 Feign 接口启动报 Bean 名冲突每个接口加不同的 contextId
GET 传 POJO 没加注解请求变成 GET 带 body,服务端收不到加 @SpringQueryMap
用了 @RequestMapping 在接口类上Spring MVC 把 Feign 接口当 Controller 注册用 @FeignClient(path=...) 代替
参数没加 @RequestParam单参数被当作 body,多参数直接报错Feign 的参数注解一个都不能省
返回 List<T> 泛型丢失反序列化成 LinkedHashMap用具体 DTO,或保证 Jackson 泛型信息完整

10. 负载均衡:从 Ribbon 到 Spring Cloud LoadBalancer ​

Ribbon 在 Spring Cloud 2020 之后已经被移除,现在默认是 spring-cloud-loadbalancer。面试说「Feign 底层是 Ribbon」在新版本里是错的,正确表述是「老版本 Ribbon,新版本 Spring Cloud LoadBalancer」。

维度Ribbon(已停更)Spring Cloud LoadBalancer
默认策略ZoneAvoidanceRule(可用区感知轮询)RoundRobinLoadBalancer(轮询)
可选策略随机、响应时间加权、重试等 7 种轮询、随机,其余自己实现
编程模型阻塞式支持响应式(Gateway 必须用它)
缓存ServerListUpdater 定时拉Caffeine 缓存实例列表(默认 35s TTL)

自定义策略(按 Nacos 元数据做版本灰度):

java
public class VersionLoadBalancer implements ReactorServiceInstanceLoadBalancer {

    private final String serviceId;
    private final ObjectProvider<ServiceInstanceListSupplier> supplierProvider;
    private final AtomicInteger position = new AtomicInteger(0);

    @Override
    public Mono<Response<ServiceInstance>> choose(Request request) {
        return supplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new)
                .get(request).next()
                .map(instances -> filterAndPick(instances));
    }

    private Response<ServiceInstance> filterAndPick(List<ServiceInstance> instances) {
        // 从请求上下文里取灰度标记(由 Gateway 透传下来)
        String grayTag = GrayContext.get();
        List<ServiceInstance> candidates = instances.stream()
                .filter(i -> Objects.equals(grayTag, i.getMetadata().get("version")))
                .collect(Collectors.toList());
        // 没有匹配的灰度实例时必须回落到全量,否则灰度机器一挂整条链路就断了
        if (candidates.isEmpty()) {
            candidates = instances;
        }
        if (candidates.isEmpty()) {
            return new EmptyResponse();
        }
        int idx = Math.abs(position.incrementAndGet()) % candidates.size();
        return new DefaultResponse(candidates.get(idx));
    }
}

⚠️ 灰度路由一定要写「回落逻辑」。我们线上出过一次:灰度标记打到了一个已经下线的 version,过滤后候选列表为空直接抛 NoInstanceAvailable,而这个标记是从前端 Cookie 带过来的,导致部分用户持续 100% 失败,监控上整体成功率只掉了 0.3%,排查了很久。


11. Feign 的超时与重试:微服务稳定性的第一个开关 ​

超时配置错了,比没有熔断更危险,因为它会直接造成线程堆积。

Feign 的超时有两层,容易配混:

text
┌─ Feign 自己的超时(feign.client.config)
│    connectTimeout 默认 10s   建立 TCP 连接的时间
│    readTimeout    默认 60s   等响应体的时间 ← 默认值极其危险
│
└─ LoadBalancer / 老版 Ribbon 的超时
     新版本中 Feign 的配置优先级更高,老版本 Ribbon 配置会覆盖 Feign
yaml
feign:
  client:
    config:
      default:                      # 全局兜底
        connectTimeout: 2000
        readTimeout: 3000
        loggerLevel: basic
      stock-service:                # 按服务名精确配置,优先级更高
        readTimeout: 1000           # 核心链路必须短
  httpclient:
    enabled: true                   # 换成 Apache HttpClient,支持连接池
    max-connections: 500
    max-connections-per-route: 100
  compression:
    request: { enabled: true, min-request-size: 2048 }
    response: { enabled: true }

⚠️ readTimeout 默认 60 秒是生产头号隐患。假设 Tomcat 线程池 200,下游 hang 住 60 秒,那么 200 个线程会在 60 秒内全部被占满,本服务也彻底不可用了——这就是雪崩的起点。超时时间的设定原则:下游接口 P99 的 2~3 倍,且必须小于本服务对上游承诺的超时。

Feign 重试默认是关闭的(Retryer.NEVER_RETRY),要开必须自己声明 Bean:

java
@Bean
public Retryer feignRetryer() {
    // 首次间隔 100ms,最大间隔 1s,最多尝试 3 次(含首次)
    return new Retryer.Default(100, SECONDS.toMillis(1), 3);
}
维度判断理由
查询接口(GET)✅ 可以重试天然幂等
创建订单(POST)❌ 不要重试超时可能已成功,重试会重复下单
幂等的写接口(带幂等号)✅ 可以重试服务端能识别重复请求
连接超时(connect timeout)✅ 相对安全连都没连上,请求没到服务端
读超时(read timeout)⚠️ 危险请求已到服务端,可能已执行

面试加分表述:「我们的规范是默认全局关闭 Feign 重试,只在明确标注了幂等的接口上用注解开启。原因是重试会放大流量:下游已经因为过载变慢了,上游还重试 3 次,相当于瞬间把压力变成 3 倍,这叫重试风暴,是把『慢』直接推成『挂』的典型路径。而且重试必须配合重试预算(比如整体重试量不超过总请求的 10%)才安全。」


12. Feign 拦截器:把 traceId、token、灰度标记传下去 ​

微服务链路上最常见的需求是透传上下文。Feign 提供 RequestInterceptor:

java
@Component
public class FeignContextInterceptor implements RequestInterceptor {

    private static final List<String> PASS_THROUGH_HEADERS =
            Arrays.asList("traceId", "Authorization", "gray-tag", "tenant-id");

    @Override
    public void apply(RequestTemplate template) {
        // 来源一:当前 HTTP 请求的 header(同步调用场景)
        ServletRequestAttributes attrs =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attrs != null) {
            HttpServletRequest req = attrs.getRequest();
            for (String name : PASS_THROUGH_HEADERS) {
                String value = req.getHeader(name);
                if (value != null) {
                    template.header(name, value);
                }
            }
        }
        // 来源二:MDC(异步线程、MQ 消费线程里没有 HTTP 请求)
        String traceId = MDC.get("traceId");
        if (traceId != null && !template.headers().containsKey("traceId")) {
            template.header("traceId", traceId);
        }
    }
}

⚠️ RequestContextHolder 基于 ThreadLocal,所以在三种场景下会拿不到值:① Feign 调用发生在 @Async 线程里;② Sentinel/Hystrix 使用线程池隔离时(切换了线程);③ MQ 消费线程。解决办法是统一走 MDC + TransmittableThreadLocal(阿里的 TTL,专门解决线程池场景下的上下文传递),而不是只依赖 RequestContextHolder。


13. Sentinel 的四个核心概念:资源、规则、Slot 链、Entry ​

Sentinel 的所有能力都建立在四个概念上,答题时按这个顺序说最清楚:

概念含义对应代码
资源(Resource)要保护的东西,可以是接口、方法、甚至一段代码@SentinelResource("createOrder"),Web 接口默认用 URL 作资源名
规则(Rule)对资源的保护策略流控、熔断、热点、系统、授权五类规则
Slot 链规则的执行流水线,责任链模式NodeSelectorSlot → ... → FlowSlot → DegradeSlot
Entry一次资源访问的凭证,必须 exitSphU.entry() / try-with-resources

Slot 链的执行顺序(这是源码级高频题,顺序不能错):

text
SphU.entry("createOrder")
  │
  ├─1 NodeSelectorSlot    构建调用链路树(谁调用了谁),支撑「链路限流」
  ├─2 ClusterBuilderSlot  为资源创建 ClusterNode,汇总全局统计数据
  ├─3 LogSlot             记录被阻塞的请求日志
  ├─4 StatisticSlot       ★ 核心:用滑动窗口统计 QPS、线程数、RT、异常数
  ├─5 AuthoritySlot       黑白名单(授权规则)
  ├─6 SystemSlot          系统自适应保护(Load、CPU、总 QPS、总线程数)
  ├─7 FlowSlot            ★ 流控规则判断 → 不通过抛 FlowException
  └─8 DegradeSlot         ★ 熔断降级判断 → 不通过抛 DegradeException
         │
         ▼
   全部通过 → 执行业务逻辑
   任一拒绝 → 抛 BlockException(所有阻塞异常的父类)

两个必须记住的要点:

  1. StatisticSlot 在 FlowSlot 之前。因为要先有统计数据,后面的规则才有判断依据。而且 StatisticSlot 是在业务执行完 exit 时才记录 RT 和异常,所以它的 entry() 调用是包裹后续 slot 的。
  2. BlockException 是所有限流熔断异常的父类,FlowException(限流)、DegradeException(熔断)、ParamFlowException(热点)、AuthorityException(授权)、SystemBlockException(系统保护)都继承它。这就是为什么全局异常处理只需要捕获 BlockException 一个。
java
// 手动埋点的标准写法(注意 exit 必须在 finally,且异常要 traceEntry)
Entry entry = null;
try {
    entry = SphU.entry("queryStock");
    return stockService.query(skuId);
} catch (BlockException e) {
    return Result.fail("系统繁忙,请稍后重试");   // 被 Sentinel 拦截
} catch (Exception bizEx) {
    Tracer.traceEntry(bizEx, entry);             // ★ 业务异常必须上报,否则异常比例熔断统计不到
    throw bizEx;
} finally {
    if (entry != null) {
        entry.exit();
    }
}

⚠️ Tracer.trace() 漏写是「异常比例熔断永远不触发」的根本原因。Sentinel 不会自动感知你的业务异常,用注解方式(@SentinelResource)时框架帮你调了,但手动埋点时必须自己调。


14. 流控规则:阈值类型、流控模式、流控效果三个维度 ​

一条流控规则是这三个维度的组合,面试按维度拆开讲最清晰。

维度一:阈值类型(grade)

类型含义适用场景
QPS每秒请求数超过阈值就拦绝大多数场景,直观好算
并发线程数正在处理的线程数超过阈值就拦下游是慢接口时更有效,本质是隔离,防止慢调用占满线程池

维度二:流控模式(strategy)

模式含义典型用法
直接资源自己的 QPS 超了就限自己默认,保护单个接口
关联资源 B 的 QPS 超了,限制资源 A写接口压力大时限制读接口,给写让路
链路只统计从指定入口进来的流量同一个 Service 被 Web 和 MQ 都调用,只想限 Web 的那部分

维度三:流控效果(controlBehavior)

效果行为本质算法适用场景
快速失败超了立即抛 FlowException滑动窗口计数默认,对外接口
Warm Up(预热)阈值从 threshold/coldFactor(默认 3)逐步爬升到设定值,默认 10 秒令牌桶(Guava SmoothWarmingUp)冷启动场景:刚发布完连接池/JIT/缓存都没热,突然满量会被压垮
排队等待请求匀速通过,超时的才拒绝漏桶削峰填谷:消息处理、后台任务,允许排队不允许丢

⚠️ 「排队等待」只支持 QPS 模式,而且阈值不能超过 1000(因为它按 1000/QPS 毫秒的间隔匀速放行,QPS 超过 1000 时间隔不足 1 毫秒就失去意义)。另外排队会让请求真的 sleep 在线程里,设置的超时时间过长(比如 20 秒)会直接把 Tomcat 线程池占满——这是「加了限流反而把服务搞挂」的经典翻车姿势。


15. 熔断降级:三种策略 + 熔断器状态机 ​

Sentinel 1.8 之后熔断规则重构过,老版本的「RT / 异常比例 / 异常数」变成了下面三种策略,面试问到一定要说新版的。

策略触发条件关键参数典型场景
慢调用比例(SLOW_REQUEST_RATIO)慢调用(RT > 阈值)占比超过比例最大 RT、比例阈值、最小请求数下游变慢但没报错,最常用
异常比例(ERROR_RATIO)异常数占比超过比例比例阈值 [0,1]、最小请求数下游偶发报错,按比例容忍
异常数(ERROR_COUNT)统计时长内异常总数超过阈值异常数、统计时长低 QPS 接口,比例算不准时用

三个容易漏掉的关键参数:

text
minRequestAmount   最小请求数,默认 5
                   统计窗口内请求数不到这个值,无论比例多离谱都不熔断
                   ★ 防止「半夜一个请求失败 = 100% 异常率 = 全局熔断」
statIntervalMs     统计时长,默认 1000ms
                   异常数策略必须结合它看:10 个异常 / 1 秒 和 10 个异常 / 1 分钟 天差地别
timeWindow         熔断时长(秒),熔断后多久进入半开

熔断器状态机(三态):

text
                     统计窗口内满足熔断条件
      ┌────────────────────────────────────────────┐
      │                                            ▼
 ┌─────────┐                                 ┌──────────┐
 │ CLOSED  │                                 │   OPEN   │
 │  关闭   │                                 │   打开   │
 │ 正常放行│                                 │ 全部拒绝 │
 └─────────┘                                 └──────────┘
      ▲                                            │
      │ 探测请求成功                                │ 经过 timeWindow 秒
      │                                            ▼
      │                                      ┌──────────┐
      └──────────────────────────────────────│HALF_OPEN │
                                             │  半开    │
         探测请求失败 → 直接回到 OPEN        │放行 1 个 │
         (并重新计时 timeWindow)           │探测请求  │
                                             └──────────┘

⚠️ 半开状态只放行「一个」探测请求,不是「一批」。这意味着:如果这唯一的探测请求恰好打到了那台还没恢复的实例上,熔断器会立刻重新打开,整个服务多等一个 timeWindow。所以熔断恢复的实际时间往往比配置的时间长,不要在故障复盘里拿配置值当实际恢复时间。

熔断的三个高频坑:

坑说明
熔断粒度搞错Sentinel 熔断是按资源(接口)粒度,不是按实例。一台实例挂了会拉高整个资源的异常比例,导致打到健康实例的请求也被熔断
业务异常计入熔断「余额不足」「参数非法」这类业务异常不应该触发熔断,必须用 exceptionsToIgnore 排除,否则用户填错参数填多了就能把接口熔断掉
没有 fallback熔断后抛 DegradeException,如果没写降级逻辑,错误会直接透给前端,熔断的意义就只剩「换个报错信息」
java
@SentinelResource(
    value = "queryStock",
    blockHandler = "stockBlockHandler",          // 处理限流/熔断(BlockException)
    fallback = "stockFallback",                  // 处理业务异常
    exceptionsToIgnore = {BizException.class}    // ★ 业务异常不计入熔断统计
)
public Stock queryStock(Long skuId) {
    return stockFeignClient.query(skuId);
}

// blockHandler 签名:参数与原方法一致 + 末尾加 BlockException,且必须 static(或同类非 static)
public Stock stockBlockHandler(Long skuId, BlockException e) {
    // 降级返回缓存中的兜底库存,标记为「不可信」让上游决定是否展示
    return Stock.degraded(skuId);
}

// fallback 签名:参数与原方法一致 + 末尾可选加 Throwable
public Stock stockFallback(Long skuId, Throwable t) {
    log.warn("查询库存降级, skuId={}", skuId, t);
    return Stock.degraded(skuId);
}

面试加分表述:「限流是保护自己不被上游压垮,熔断是保护自己不被下游拖垮——一个防入口,一个防出口。所以我们的规则配置也是分开的:对外 HTTP 接口配流控,对内 Feign 调用配熔断。」


16. 热点参数限流、系统自适应保护、授权规则 ​

除了流控和熔断,Sentinel 还有三类规则,面试问「Sentinel 有哪几种规则」时答不全很吃亏。

(1)热点参数限流(ParamFlowRule)

普通限流是「整个接口 1000 QPS」,热点限流是「每个用户 ID 单独 10 QPS」。它用一个 LRU + 滑动窗口 的结构按参数值分桶统计。

java
// 必须用 @SentinelResource 并指定 value,热点规则只对「埋点资源」生效
@SentinelResource("createOrder")
public Result createOrder(Long userId, Long skuId) { ... }
java
ParamFlowRule rule = new ParamFlowRule("createOrder")
        .setParamIdx(0)                 // ★ 第 0 个参数(userId)作为热点 key
        .setCount(10);                  // 每个 userId 每秒 10 次
// 参数例外项:VIP 大客户单独放宽到 100
ParamFlowItem vip = new ParamFlowItem()
        .setObject(String.valueOf(10086L))
        .setClassType(long.class.getName())
        .setCount(100);
rule.setParamFlowItemList(Collections.singletonList(vip));
ParamFlowRuleManager.loadRules(Collections.singletonList(rule));
关键限制说明
只支持基本类型 + String传对象(如 OrderDTO)不会生效也不会报错,因为它拿参数的 toString() 当 key,对象默认 toString 带 hashCode,每次都不一样
paramIdx 是下标不是名字方法参数顺序一调整,规则就静默打到了错误的参数上,这是重构时最隐蔽的坑
必须手动埋点热点规则不支持 Web 接口自动埋点,必须加 @SentinelResource

(2)系统自适应保护(SystemRule)

前面的规则都是「我猜接口能撑多少」,系统规则是「让机器自己说还能不能撑」,是整个服务的最后一道兜底。

阈值类型含义建议
Load(仅 Linux)系统 load1 超过阈值触发建议 CPU 核数 * 2.5
CPU 使用率[0,1],比 Load 更通用跨平台优先用这个,建议 0.8
平均 RT入口流量平均响应时间—
并发线程数入口流量总并发—
入口 QPS所有入口资源 QPS 之和—

⚠️ 系统规则只对「入口流量」(EntryType.IN)生效。Feign 调用默认是 EntryType.OUT,所以系统负载再高也不会拦你的出口调用——这是设计使然(防止雪崩时连自救的调用都发不出去),但很多人以为配了系统规则就全局兜底了。

(3)授权规则(AuthorityRule)

基于「来源」(origin)做黑白名单。核心是必须自己实现 RequestOriginParser 告诉 Sentinel「来源怎么取」:

java
@Component
public class HeaderOriginParser implements RequestOriginParser {
    @Override
    public String parseOrigin(HttpServletRequest request) {
        String origin = request.getHeader("X-Gateway-Origin");
        // ★ 必须返回非 null、非空串,否则 Sentinel 用 "default",白名单会全部放行
        return StringUtils.hasText(origin) ? origin : "blank";
    }
}

⚠️ 授权规则的 origin 由调用方自己在 Header 里填,等于「自己声明自己是谁」,天然可伪造。它只适合「内网服务间的粗粒度隔离」(比如禁止服务 A 直连服务 C,必须走网关),绝不能当认证用。真正的鉴权必须放在网关层做签名校验。

五类规则速查:

规则Rule 类抛出的 BlockException 子类
流控FlowRuleFlowException
熔断降级DegradeRuleDegradeException
热点参数ParamFlowRuleParamFlowException
系统保护SystemRuleSystemBlockException
授权AuthorityRuleAuthorityException

面试加分表述:「这五种 Block 异常都继承自 BlockException,所以我们在全局异常处理里先按子类分别返回不同文案和不同 HTTP 状态码(限流 429、熔断 503、授权 403),再兜底 BlockException。这样前端能区分『稍后重试』和『没权限』,监控也能按类型分开告警。」


17. Sentinel 整合 Feign:fallback 与 fallbackFactory ​

Hystrix 退役后,Feign 的降级能力由 Sentinel 接管,但默认是关闭的:

yaml
feign:
  sentinel:
    enabled: true          # ★ 不写这行,fallback 配了也不生效,而且不报错

两种写法的区别(面试常问):

写法能力缺点
fallback = XxxFallback.class返回兜底数据拿不到异常对象,不知道是限流、熔断还是超时,排查困难
fallbackFactory = XxxFallbackFactory.class返回兜底数据 + 拿得到 Throwable多写一层,但生产环境必须用这个
java
@FeignClient(
    name = "stock-service",
    path = "/stock",
    fallbackFactory = StockFallbackFactory.class   // ★ 生产用 Factory
)
public interface StockFeignClient {
    @GetMapping("/{skuId}")
    Result<Stock> query(@PathVariable("skuId") Long skuId);

    @PostMapping("/deduct")
    Result<Void> deduct(@RequestBody DeductDTO dto);
}
java
@Slf4j
@Component                                          // ★ 必须交给 Spring 管理,否则注入不到
public class StockFallbackFactory implements FallbackFactory<StockFeignClient> {

    @Override
    public StockFeignClient create(Throwable cause) {
        return new StockFeignClient() {

            @Override
            public Result<Stock> query(Long skuId) {
                // 查询类:可以降级返回缓存/默认值
                log.warn("[降级] 查询库存失败, skuId={}, reason={}", skuId, reason(cause));
                return Result.degraded(Stock.unknown(skuId));
            }

            @Override
            public Result<Void> deduct(DeductDTO dto) {
                // ★ 写操作绝不能「假装成功」降级,必须把失败如实抛出去
                //   否则订单创建成功但库存没扣,直接超卖
                log.error("[降级] 扣减库存失败, dto={}", dto, cause);
                return Result.fail("库存服务不可用,请稍后重试");
            }
        };
    }

    private String reason(Throwable cause) {
        if (cause instanceof FlowException)    return "被限流";
        if (cause instanceof DegradeException) return "已熔断";
        if (cause instanceof FeignException)   return "下游返回错误码";
        return cause == null ? "unknown" : cause.getClass().getSimpleName();
    }
}

Feign + Sentinel 的四个认知要点:

要点说明
资源名格式HTTP_METHOD:协议://服务名/路径,例如 GET:http://stock-service/stock/{skuId}。带 {} 占位符,控制台里看到的就是这个,不要手动展开成具体 ID
降级覆盖所有失败不只是限流熔断,连接超时、读超时、下游 5xx 走的都是同一个 fallback
读写要区别对待读可以兜底,写必须快速失败(见上面代码注释)
不要吞异常fallback 里 return null 会让上游 NPE,比直接报错更难排查

⚠️ 最危险的降级反模式:给扣库存、扣余额这类写接口返回「成功」。降级的前提是「这个结果不对也无所谓」,写操作永远不满足这个前提。如果确实要做最终一致,正确做法是降级时落一条本地消息表记录 + 后台补偿,而不是直接返回成功。


18. Gateway 三大核心概念:Route、Predicate、Filter ​

一句话串起来:Predicate 决定「这个请求归谁管」,Route 决定「转发到哪」,Filter 决定「转发前后干什么」。

text
         ┌─────────────────────── Route(路由) ───────────────────────┐
         │  id: order-route                                           │
         │  uri: lb://order-service      ← lb 表示走负载均衡          │
         │  order: 0                     ← 多条路由匹配时的优先级      │
         │  ┌────────────── Predicate(断言,全部满足才匹配)──────┐  │
         │  │  Path=/api/order/**                                 │  │
         │  │  Method=GET,POST                                    │  │
         │  │  Header=X-Version, v2                            │  │
         │  └─────────────────────────────────────────────────────┘  │
         │  ┌────────────── Filter(过滤器,按 order 排序)────────┐  │
         │  │  StripPrefix=2        去掉 /api/order                │  │
         │  │  RequestRateLimiter   令牌桶限流                     │  │
         │  │  自定义 AuthFilter    鉴权                           │  │
         │  └─────────────────────────────────────────────────────┘  │
         └────────────────────────────────────────────────────────────┘

常用内置断言:

断言示例说明
PathPath=/api/order/**最常用,** 匹配多级,* 只匹配一级
MethodMethod=GET,POST按 HTTP 方法
HeaderHeader=X-Gray, true第二个参数是正则,不是字面量
QueryQuery=debug, true同上
RemoteAddrRemoteAddr=192.168.1.1/24⚠️ 取的是直连 IP,前面挂了 Nginx 就全是 Nginx 的 IP
After/Before/BetweenAfter=2026-11-11T00:00:00+08:00[Asia/Shanghai]定时上线活动页,必须带时区
WeightWeight=group1, 8按权重分流,做灰度发布

典型配置:

yaml
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: false            # ★ 建议关掉!开了会用服务名自动建路由,
                                    #   等于把所有内部服务都暴露出去,是安全事故重灾区
      routes:
        - id: order-gray            # 灰度路由必须放在正式路由前面(order 更小)
          uri: lb://order-service-gray
          order: 0
          predicates:
            - Path=/api/order/**
            - Header=X-Gray, true
          filters:
            - StripPrefix=2
        - id: order-route
          uri: lb://order-service
          order: 1
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=2         # /api/order/create → /create
            - name: RequestSize
              args:
                maxSize: 5MB        # 限制请求体大小,防止大包打挂下游

四个必须记住的坑:

坑说明
路由顺序多条路由都匹配时,按 order 升序取第一条。灰度路由 order 必须小于正式路由,否则永远匹配不上
StripPrefix 数错StripPrefix=1 只去掉一级。/api/order/create 配 StripPrefix=1 会转发成 /order/create,下游 404
lb:// 写成 http://写 http://order-service 不走负载均衡,会把服务名当域名解析,直接报 UnknownHostException
断言是 AND 关系同一条路由下的多个断言全部满足才匹配,没有 OR。需要 OR 就拆成两条路由

面试加分表述:「Gateway 的路由匹配本质是 RoutePredicateHandlerMapping 遍历所有 Route,对每条路由的断言做 and 组合后求值,取第一条匹配成功的。所以我们线上把『灰度 / 维护页 / 限流兜底』这类特殊路由统一放在 order 负数区间,正式业务路由从 0 开始,避免新同学加路由时踩顺序的坑。」


19. Gateway 执行流程与过滤器链的「洋葱模型」 ​

完整请求链路:

text
客户端
  │
  ▼
Netty Server(Reactor 线程,默认 CPU 核数个)
  │
  ▼
DispatcherHandler
  │
  ▼
RoutePredicateHandlerMapping   ── 遍历路由,断言求值,命中后把 Route 存进 exchange 属性
  │
  ▼
FilteringWebHandler            ── 把「全局过滤器 + 该路由的局部过滤器」合并,按 order 排序
  │
  ▼
GatewayFilterChain  ┌──── pre 阶段:鉴权、改写、限流、埋点 ────┐
                    │                                          │
                    │  NettyRoutingFilter(order=MAX_VALUE-1) │
                    │  ★ 真正发出 HTTP 请求的就是它,永远在最后 │
                    │                                          │
                    └──── post 阶段:改响应头、统计耗时 ───────┘
  │
  ▼
客户端

「洋葱模型」怎么写 post 逻辑:WebFlux 没有「后置方法」,必须靠 chain.filter(exchange).then(...)。

java
@Component
public class AccessLogFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        long start = System.currentTimeMillis();
        String path = exchange.getRequest().getPath().value();

        // ===== pre 阶段:在 chain.filter 之前 =====
        // ★ request 是只读的,要改必须 mutate() 出一个新的
        ServerHttpRequest newReq = exchange.getRequest().mutate()
                .header("X-Trace-Id", TraceIdGenerator.next())
                .build();

        return chain.filter(exchange.mutate().request(newReq).build())
                // ===== post 阶段:then / doFinally 里 =====
                .then(Mono.fromRunnable(() -> {
                    HttpStatusCode status = exchange.getResponse().getStatusCode();
                    log.info("path={}, status={}, cost={}ms",
                            path, status, System.currentTimeMillis() - start);
                }));
    }

    @Override
    public int getOrder() {
        // ★ 越小越先执行;要统计全链路耗时就必须是最小的那个
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

鉴权过滤器:拦截并直接返回

java
@Component
public class AuthFilter implements GlobalFilter, Ordered {

    private static final List<String> WHITE_LIST = List.of("/api/auth/login", "/api/public/**");

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getPath().value();
        if (WHITE_LIST.stream().anyMatch(p -> MATCHER.match(p, path))) {
            return chain.filter(exchange);
        }

        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (!StringUtils.hasText(token) || !jwtUtil.verify(token)) {
            // ★ 直接终止:不调 chain.filter,自己写响应体
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            byte[] body = "{\"code\":401,\"msg\":\"未登录\"}".getBytes(StandardCharsets.UTF_8);
            return response.writeWith(Mono.just(response.bufferFactory().wrap(body)));
            // ⚠️ 不能用 exchange.getResponse().setComplete() 后再 writeWith,
            //    响应一旦 commit 就不能再写,会抛 IllegalStateException
        }

        // 解析出的用户信息透传给下游,下游不用再解 token
        ServerHttpRequest newReq = exchange.getRequest().mutate()
                .header("X-User-Id", String.valueOf(jwtUtil.getUserId(token)))
                .build();
        return chain.filter(exchange.mutate().request(newReq).build());
    }

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

三类过滤器的区别:

类型接口作用范围备注
全局过滤器GlobalFilter所有路由鉴权、日志、链路追踪
局部过滤器GatewayFilter / AbstractGatewayFilterFactory配了的路由类名必须以 GatewayFilterFactory 结尾,配置里写前缀
默认过滤器default-filters 配置项所有路由不写代码,纯配置实现「全局」

⚠️ Gateway 跑在 Netty 的 Reactor 线程上,整个进程的 IO 线程数就是 CPU 核数。在过滤器里做任何阻塞操作(RestTemplate 同步调用、JDBC 查库、Thread.sleep、同步读 Redis)都会直接把网关打死。必须用 WebClient、Reactive Redis,或者迫不得已时用 publishOn(Schedulers.boundedElastic()) 切到弹性线程池。


20. Seata 三大角色(TC / TM / RM)与四种模式 ​

三大角色:

角色全称是谁职责
TCTransaction Coordinator独立部署的 Seata Server维护全局事务状态,决定全局提交还是回滚
TMTransaction Manager标了 @GlobalTransactional 的那个服务开启全局事务、发起提交/回滚决议
RMResource Manager每个连了数据库的参与者服务管理分支事务,向 TC 注册分支、上报状态、执行提交/回滚

⚠️ TM 和 RM 是「角色」不是「服务」。发起方服务自己也要操作数据库,所以它同时是 TM 和 RM。面试时说成「订单服务是 TM,库存服务是 RM」是不严谨的。

一次全局事务的消息流:

text
 订单服务(TM+RM)          Seata Server(TC)          库存服务(RM)
      │                        │                         │
      │ 1. begin 全局事务       │                         │
      ├───────────────────────►│                         │
      │ ◄───── 返回 XID ───────┤                         │
      │                        │                         │
      │ 2. 本地执行 + 注册分支1  │                         │
      ├───────────────────────►│                         │
      │                        │                         │
      │ 3. Feign 调用(XID 随 Header 透传)               │
      ├─────────────────────────────────────────────────►│
      │                        │ 4. 注册分支2             │
      │                        │◄────────────────────────┤
      │                        │                         │
      │ 5. TM 决议:commit / rollback                     │
      ├───────────────────────►│                         │
      │                        │ 6. 通知各 RM 二阶段       │
      │◄───────────────────────┼────────────────────────►│

四种事务模式对比(面试必问的选型题):

模式侵入性一阶段做什么二阶段做什么隔离性适用场景
AT无侵入执行业务 SQL + 生成 undo_log,直接提交本地事务提交:删 undo_log;回滚:按 undo_log 反向补偿全局锁保证写隔离,默认读未提交绝大多数场景,有数据库、能加 undo_log 表
TCC强侵入Try:预留资源(冻结库存/冻结余额)Confirm:真扣;Cancel:解冻业务自己保证,隔离性最好高并发核心链路(资金、库存),性能要求高
SAGA中等直接提交每个正向服务失败时逆序调用补偿服务无隔离,可能脏读长流程(跨多个系统、人工审批)、对接不支持事务的老系统
XA无侵入执行 SQL 但不提交,保持在 prepare统一 commit / rollback强一致,隔离性最好对一致性要求极高且 QPS 低;数据库必须支持 XA

选型决策(一句话版):

text
能加 undo_log 表、QPS 不极端  ──────────────►  AT  ← 默认选它
资金/库存类核心链路、要求高并发 ──────────────►  TCC
跨多个系统、流程长、有人工节点 ──────────────►  SAGA
强一致优先、能接受锁住连接     ──────────────►  XA
只有两个服务、能容忍秒级不一致 ──────────────►  本地消息表(别上 Seata)

面试加分表述:「我们的选型原则是**『能不上分布式事务就不上』**。单服务能解决的用本地事务;两个服务之间能接受最终一致的用本地消息表 + MQ;只有『用户在界面上等着看结果、必须立刻知道成败』的强一致场景才用 Seata AT。因为 Seata AT 会引入全局锁,QPS 大概会掉 30%~50%,这个成本得算清楚。」


21. Seata AT 模式:两阶段 + undo_log 全流程拆解 ​

AT 是 Seata 的招牌,面试基本必问「AT 怎么做到无侵入回滚的」。

一阶段(业务执行时),Seata 通过代理 DataSource 拦截 SQL:

text
1. 解析 SQL                   拿到表名、WHERE 条件、要改的列
2. 查询 before image           SELECT 改之前的行数据 → 存快照
3. 执行业务 SQL                UPDATE stock SET count = count - 1 WHERE id = 1
4. 查询 after image            SELECT 改之后的行数据 → 存快照
5. 插入 undo_log               把 before/after image 序列化写入同库的 undo_log 表
6. 向 TC 申请全局锁            拿到才继续,拿不到重试(默认 30次 × 10ms)
7. 本地事务 commit             ★ 业务 SQL 和 undo_log 在同一个本地事务里一起提交

关键点:第 7 步「一起提交」是 AT 无侵入的核心——undo_log 和业务数据的写入是原子的,不会出现「改了数据但没有回滚日志」的断层。这也是为什么 undo_log 表必须建在业务库里,建在独立库就失去原子性了。

二阶段:

决议RM 做什么是否同步
全局提交把 undo_log 记录删掉异步,所以提交极快
全局回滚校验 after image 与当前数据是否一致 → 一致则用 before image 生成反向 SQL 执行 → 删 undo_log同步

回滚时的「脏写校验」(最容易被追问的点):

text
回滚前比对:当前数据库里的行  vs  undo_log 里的 after image

  一致   →  说明这期间没人动过,安全回滚
  不一致 →  说明有「不受 Seata 管控的操作」改了数据(脏写)
            此时 Seata 默认抛 BranchRollbackFailed,
            ★ 不会强行覆盖,而是把异常和两份快照打到日志里,等人工介入

⚠️ 这个行为由 client.undo.onlyCareUpdateColumns 和 rollback 策略影响,但默认绝不会自动覆盖。很多人以为 Seata 能兜住一切,实际上只要有一条绕过 Seata 的 SQL(比如 DBA 手改、定时任务直连库),AT 回滚就可能失败并留下脏数据。

必备的三张表:

表建在哪作用
undo_log每个业务库(AT 模式必建)存 before/after image
global_table / branch_table / lock_tableTC 的库(store.mode=db 时)全局事务、分支事务、全局锁

最小可用配置:

yaml
seata:
  enabled: true
  application-id: order-service
  tx-service-group: default_tx_group        # ★ 事务分组名,要和下面的 vgroup 映射对上
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      namespace: 7f2a1b3c-xxxx              # 同样必须填 namespace 的 ID
      group: SEATA_GROUP                    # ★ Seata Server 注册的默认 group 是这个,
                                            #   不是 DEFAULT_GROUP,写错就找不到 TC
      application: seata-server
  config:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      data-id: seataServer.properties
  service:
    vgroup-mapping:
      default_tx_group: default             # 事务分组 → TC 集群名
  data-source-proxy-mode: AT                # AT / XA

代码只需要一个注解:

java
@Service
public class OrderServiceImpl implements OrderService {

    @GlobalTransactional(name = "createOrder", rollbackFor = Exception.class)
    public Long create(OrderCreateDTO dto) {
        Long orderId = orderMapper.insert(buildOrder(dto));   // 分支1:本地库
        stockFeignClient.deduct(dto.toDeductDTO());           // 分支2:远程服务
        accountFeignClient.debit(dto.toDebitDTO());           // 分支3:远程服务
        return orderId;
    }
}

⚠️ 三个必踩的坑:

  1. @GlobalTransactional 只加在发起方,参与方什么都不用加(加了 @Transactional 是对的,加 @GlobalTransactional 会变成嵌套事务)。
  2. 参与方必须抛异常。如果库存服务被 Feign 降级了、fallback 里 return Result.fail(...),对发起方来说是「正常返回」,全局事务不会回滚。必须在发起方检查返回码并主动 throw。
  3. XID 必须能传过去。Feign/RestTemplate 有自动拦截器,但自己 new 的线程池、@Async、MQ 异步消费都会丢 XID,需要手动 RootContext.bind(xid)。

22. Seata AT 的全局锁与隔离级别 ​

AT 一阶段就把本地事务提交了,这带来一个必然问题:本地事务已提交但全局事务还没结束的那段时间里,数据是「中间态」。Seata 用「全局锁」来兜这个窟窿。

写隔离:靠全局锁

text
时间轴    事务 A(全局事务)                事务 B(全局事务)
  │
  t1     本地事务开始,UPDATE stock
  t2     ★ 向 TC 申请 (stock, pk=1) 的全局锁 → 拿到
  t3     本地事务 commit(本地行锁已释放!)
  │                                        本地事务开始,UPDATE stock
  t4                                       ★ 申请同一把全局锁 → 被占,重试等待
  t5     业务报错,开始全局回滚
  t6     拿到本地行锁 → 按 undo_log 回滚 → 释放全局锁
  t7                                       拿到全局锁 → 继续执行

关键:全局锁是在 TC 侧维护的(lock_table),粒度是「表名 + 主键」。它保证了「两个全局事务不会同时改同一行」,从而避免脏写(A 回滚时发现数据被 B 改过,回滚不了)。

读隔离:默认是「读未提交」

因为 t3 时刻 A 的本地事务已提交,此时任何普通 SELECT 都能读到 A 的中间结果,而 A 之后可能回滚——这就是脏读。

需求做法代价
默认(读未提交)什么都不做可能读到会被回滚的数据
读已提交SELECT ... FOR UPDATESeata 会代理它去申请全局锁,拿不到就重试,等于把读也串行化了
彻底规避业务层设计上避免「读中间态」需要业务配合
java
// ★ 只有加了 FOR UPDATE,Seata 才会介入做全局锁校验
@Select("SELECT * FROM stock WHERE sku_id = #{skuId} FOR UPDATE")
Stock selectForUpdate(Long skuId);

全局锁等待配置:

yaml
seata:
  client:
    rm:
      lock:
        retry-interval: 10          # 重试间隔 10ms
        retry-times: 30             # 重试 30 次 → 总共等 300ms
        retry-policy-branch-rollback-on-conflict: true
        # ★ true(默认):分支回滚时如果和本地锁冲突,会一直重试直到拿到锁
        #   这是为了保证回滚一定成功,但可能长时间阻塞

⚠️ LockWaitTimeoutException(GlobalLockAcquireFailed)是 Seata 线上最高频的报错。原因通常是:同一行数据被高并发的全局事务反复争抢(比如秒杀都扣同一个 SKU),300ms 内抢不到锁就失败。这不是 bug 而是设计上的保护——它告诉你这个场景不该用 AT,应该换成 TCC(冻结库存,不锁行)或者 Redis 预扣减 + 异步落库。

全局锁 vs 数据库行锁:

维度数据库行锁Seata全局锁
持有者本地事务全局事务(跨多个本地事务)
存放位置MySQL InnoDBTC 的 lock_table
持有时长本地事务结束即释放(毫秒级)整个全局事务生命周期(可能几百毫秒到几秒)
冲突表现等锁超时LockWaitTimeoutException

面试加分表述:「AT 的本质是用『全局锁 + 反向补偿』换取『无侵入 + 一阶段即提交』。它牺牲了读隔离性(默认读未提交)和一部分并发度(全局锁持有时间远长于行锁),换来业务代码零改造。所以我判断能不能用 AT 的标准是两个:一是有没有热点行,热点行用 AT 必然锁冲突;二是能不能容忍中间态被读到,比如账户余额展示这种,读到中间态会被用户投诉就不能用。」


✨ 高级深挖知识点 ​

1. Nacos 的 AP/CP 之争:Distro 协议与 Raft 协议 ​

Nacos 最特别的地方是同一个集群里同时跑着两套一致性协议,按数据类型分流。

数据协议模型为什么这么选
临时实例(注册信息)DistroAP实例列表变化极频繁(心跳、扩缩容),要的是可用——哪怕多返回一个已下线实例,客户端重试一下就好
持久化实例 + 配置数据Raft(1.x)/ JRaft(2.x)CP配置写错会全站故障,宁可写不进去也不能半边新半边旧

Distro 协议的三个要点:

text
① 每个节点「负责」一部分数据(按 instance 的 hash 分片)
   注册请求打到任意节点 → 转发给「负责该实例的节点」处理

② 负责节点写入后,异步广播给其他所有节点(DistroSyncChangeTask)
   ★ 异步 = 短暂不一致,这就是 AP

③ 每个节点每 5 秒发一次「全量数据摘要(checksum)」给其他节点
   对方发现 checksum 对不上 → 主动拉取全量数据修复
   ★ 这是最终一致性的兜底

面试加分表述:「很多人说『Nacos 是 AP 的』或者『Nacos 默认 CP』都不准确。准确说法是:Nacos 按数据类型选协议,注册中心的临时实例走 Distro 是 AP,配置中心和持久化实例走 Raft 是 CP。而且这个和『部署几个节点』无关——单机部署一样是两套协议,只是 Raft 集群只有一个成员而已。」

对比 Eureka / ZooKeeper / Consul:

组件一致性健康检查特点
NacosAP + CP 可选心跳 / TCP / HTTP / MySQL注册 + 配置二合一,国内生态好
EurekaAP心跳已停止维护(2.x 闭源)
ZooKeeperCP临时节点 + session选主期间整个注册中心不可用,这是它做注册中心的硬伤
ConsulCP多种自带 KV 和多数据中心

2. Nacos 2.x 的 gRPC 改造:从「每秒都在握手」到长连接 ​

Nacos 1.x 的性能瓶颈不在业务逻辑,而在 HTTP 短连接。

维度1.x(HTTP 短连接)2.x(gRPC 长连接)
注册/心跳HTTP 轮询,每 5 秒一次心跳长连接本身就是心跳(双向流)
配置变更感知长轮询,29.5 秒一轮服务端主动推送,毫秒级
连接数每次请求建连(配了 keep-alive 也要频繁重建)一个客户端一条连接复用
端口88488848(HTTP)+ 9848(客户端 gRPC)+ 9849(服务端间 gRPC)
官方性能提升—约 10 倍

端口偏移规则(最高频的部署坑):

text
主端口 8848
  ├── 8848 + 1000 = 9848   客户端 ← → 服务端   gRPC 请求端口
  └── 8848 + 1001 = 9849   服务端 ← → 服务端   gRPC 集群同步端口

★ 改了主端口,这两个偏移端口会跟着变
★ Docker / K8s / 安全组 只放开 8848 的话:
   表现是「服务能启动、能看到注册列表,但配置死活不刷新、日志里一堆 connection timeout」

⚠️ 2.x 客户端连 1.x 服务端会失败,反过来 1.x 客户端连 2.x 服务端是兼容的(2.x 保留了 HTTP 接口)。所以升级顺序必须是先升服务端,再升客户端。


3. 无损上下线:把「15~30 秒窗口」压到接近零 ​

前面讲过服务发现有延迟窗口,这里给出完整的工程解法——这是 P7 面试的高频追问点。

下线(缩容 / 发版)的四层保障:

text
① 主动注销      应用关闭前先调 Nacos deregister,不等 30 秒心跳超时
   ↓
② 等待传播      sleep 一个「客户端缓存刷新周期」(默认订阅是推送,约 1~3 秒)
   ↓
③ 优雅停机      Spring Boot: server.shutdown=graceful
                等待在途请求处理完(spring.lifecycle.timeout-per-shutdown-phase=30s)
   ↓
④ 调用方重试    Feign/LoadBalancer 对 ConnectException 自动换一台重试
                ★ 这是最后一道兜底,前三层都漏了还能救
yaml
server:
  shutdown: graceful                        # ① 优雅停机
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
  cloud:
    nacos:
      discovery:
        # ② Nacos 客户端会注册 ShutdownHook 自动注销,但 kill -9 不会触发
        ephemeral: true
java
/**
 * 配合 K8s preStop hook 使用:
 *   lifecycle.preStop.exec.command: ["sh","-c","curl -X POST localhost:8080/actuator/service-registry?status=DOWN; sleep 5"]
 * 先摘流量,再等 5 秒让调用方缓存刷新,最后才发 SIGTERM
 */

上线的两层保障:

问题解法
刚注册就被打流量,但 Bean 还没初始化完用 /actuator/health/readiness 做就绪探针;Nacos 配 spring.cloud.nacos.discovery.register-enabled=false + 启动完成后手动注册
新实例冷启动,JIT 没热、连接池空,被满量打挂Sentinel WarmUp 流控 + 负载均衡的预热权重(新实例初始权重低,随运行时间线性爬升)

面试加分表述:「无损发布不是单点解决的,它是『注册中心摘流 + 应用优雅停机 + 调用方重试 + 新实例预热』四件事一起做。我们线上做完这套之后,发版期间的 5xx 从每次几百条降到个位数,剩下的个位数是长连接类请求,只能靠业务侧重试兜。」


4. Nacos 的保护阈值:注册中心的「雪崩保护」 ​

这是一个很少有人答得出来但非常加分的配置。

场景:某服务有 10 个实例,突然机房网络抖动,8 个实例心跳超时被摘除。剩下 2 个实例要承接 100% 流量 → 立刻被压垮 → 心跳也发不出来被摘除 → 服务列表变成空 → 全线 502。

保护阈值(protectThreshold,0~1)就是为此而生:

text
健康实例数 / 总实例数 < protectThreshold 时
  ↓
Nacos 返回「全部实例」(包含不健康的)给调用方
  ↓
调用方会打到一些不健康实例上,部分请求失败(可用性降级)
  ↓
★ 但避免了「剩余健康实例被打死 → 服务完全不可用」的雪崩

设计哲学:宁可放一部分错误流量,也不让服务整体崩掉
配置值行为建议
0(默认)关闭保护,健康实例为 0 时返回空列表不推荐生产使用
0.5健康实例少于一半时启用保护推荐值
1永远返回全部实例等于关掉健康检查,不要用

⚠️ 这个值在 Nacos 控制台的服务编辑页设置,不在客户端 yaml 里,所以代码仓库里搜不到——新同学接手项目时经常完全不知道它的存在。配置变更要走配置中心备份或 IaC,别手改忘记。

与 Eureka 自我保护的区别:

维度Nacos 保护阈值Eureka 自我保护模式
粒度按服务 单独配全局,一刀切
触发条件健康实例占比低于阈值15 分钟内心跳续约成功率 < 85%
效果返回全部实例(含不健康)停止摘除任何实例
灵活性高低,经常出现「实例早就死了还在列表里」

5. Sentinel 的 Slot 责任链:一次 entry 到底做了什么 ​

SphU.entry() 背后是一条责任链(Slot Chain),理解它的顺序就理解了 Sentinel 的全部。

text
SphU.entry("resource")
  │
  ▼
NodeSelectorSlot      ── 构建调用链路树(同一资源在不同入口下是不同节点)
  │
  ▼
ClusterBuilderSlot    ── 构建 ClusterNode(资源的全局统计)+ OriginNode(按来源统计)
  │
  ▼
LogSlot               ── 记录 block 日志(~/logs/csp/sentinel-block.log)
  │
  ▼
StatisticSlot         ── ★ 核心!统计 QPS / 线程数 / RT / 异常数
  │                      注意它的位置:先放行到后面的规则 Slot,
  │                      规则都通过了才回来「加数」——所以被限流的请求不计入通过数
  ▼
AuthoritySlot         ── 授权规则(黑白名单)     ← 规则检查从这里开始
  │
  ▼
SystemSlot            ── 系统自适应保护(Load/CPU/RT/线程数/入口QPS)
  │
  ▼
FlowSlot              ── 流控规则
  │
  ▼
DegradeSlot           ── 熔断降级规则
  │
  ▼
业务代码执行

为什么 StatisticSlot 在规则 Slot 的前面,却能统计到「规则是否拦截」?

因为它的实现是「先调 fireEntry() 往下走,等后面的 Slot 全部返回(不抛异常)才统计通过数;如果捕获到 BlockException 就统计拒绝数」——典型的环绕式写法:

java
// StatisticSlot.entry() 的逻辑骨架
public void entry(Context context, ResourceWrapper rw, DefaultNode node, int count, ...) {
    try {
        fireEntry(context, rw, node, count, prioritized, args);  // ① 先往下走规则校验
        node.increaseThreadNum();                                // ② 没抛异常 → 通过
        node.addPassRequest(count);
    } catch (BlockException e) {
        context.getCurEntry().setBlockError(e);
        node.increaseBlockQps(count);                            // ③ 被拦 → 记拒绝数
        throw e;
    } catch (Throwable e) {
        context.getCurEntry().setError(e);
        throw e;
    }
}

面试加分表述:「Sentinel 的 Slot 链是用 SPI 机制(META-INF/services/com.alibaba.csp.sentinel.slotchain.SlotChainBuilder)加载的,每个 Slot 有 @Spi(order = ...) 注解控制顺序。所以扩展 Sentinel 不需要改源码——我们就自己加过一个 Slot,在限流前先读一个『压测标记』,压测流量走独立的统计节点,不污染线上的 QPS 曲线。」


6. LeapArray 滑动窗口:怎么在 O(1) 内算出 QPS ​

Sentinel 统计 QPS 的数据结构是 LeapArray(时间轮 + 数组复用),这是最能体现功底的一道深挖题。

朴素实现的问题:

text
❌ 固定窗口(每秒清零):
   临界点问题 —— 0.9 秒来 100 个,1.1 秒又来 100 个
   两个窗口各自都没超 100 QPS,但实际 200ms 内进了 200 个请求

❌ 存所有请求时间戳的链表:
   内存爆炸,统计要 O(n) 遍历

LeapArray 的做法:

text
intervalInMs = 1000ms(统计周期),sampleCount = 2(样本数)
→ 每个小窗口 windowLengthInMs = 500ms

数组(长度=2,循环复用):
     idx 0              idx 1
 ┌───────────────┬───────────────┐
 │ start=1000    │ start=1500    │
 │ pass=37       │ pass=52       │
 │ block=2       │ block=0       │
 └───────────────┴───────────────┘

定位公式:
  timeId  = currentTimeMillis / windowLengthInMs
  idx     = timeId % sampleCount             ← O(1) 定位
  windowStart = currentTimeMillis - (currentTimeMillis % windowLengthInMs)

取数时判断:
  bucket.windowStart == windowStart   → 命中,直接用
  bucket.windowStart <  windowStart   → ★ 过期了,CAS 重置这个桶后复用
  bucket.windowStart >  windowStart   → 时钟回拨,异常情况

求 QPS:遍历数组里「未过期」的桶,把 pass 累加 ÷ 实际时间跨度
设计点价值
数组循环复用内存固定,不随 QPS 增长
取模定位O(1) 读写,无锁热点
桶过期即重置不需要后台线程清理,惰性失效
sampleCount 可调样本数越多窗口滑动越平滑,但统计开销越大;默认 QPS 统计用 2,秒级监控用 60

⚠️ 滑动窗口依然有精度问题,只是比固定窗口小。sampleCount=2 时窗口粒度是 500ms,所以「1 秒 100 QPS」的规则在极端情况下可能放过接近 150 个请求。要求绝对精确就得用漏桶(排队等待)或令牌桶,但那两个会引入排队延迟。


7. Sentinel 规则持久化:pull 模式 vs push 模式 ​

默认规则存在内存里,服务一重启全丢。这是 Sentinel 原生版本最大的工程缺陷,必须自己解决。

模式原理优点缺点
原始模式规则在内存零配置重启即丢,只能用于本地调试
pull 模式客户端定时(默认 3 秒)从本地文件 / DB 拉规则实现简单有拉取延迟;文件方式无法多机统一;本质是轮询,有一致性窗口
push 模式规则存 Nacos,控制台改 → 写 Nacos → Nacos 推给所有客户端实时、集群一致、规则可版本管理需要改造 Sentinel 控制台源码(官方 Dashboard 默认只写内存)

push 模式的数据流:

text
 Sentinel Dashboard(需改造)
        │  ① 修改规则时,除了推给客户端,还写一份到 Nacos
        ▼
    Nacos 配置中心
    dataId: order-service-flow-rules
    group:  SENTINEL_GROUP
        │  ② Nacos 长连接推送
        ▼
 各个应用实例(ReadableDataSource)
        │  ③ 反序列化成 List<FlowRule>
        ▼
   FlowRuleManager.loadRules()

★ 关键:Dashboard 和应用都以 Nacos 为准,应用重启后从 Nacos 拉一次,规则不丢

客户端接入(这部分不用改源码):

yaml
spring:
  cloud:
    sentinel:
      transport:
        dashboard: 127.0.0.1:8080
        port: 8719                    # 客户端和 Dashboard 通信的端口,被占用会自动 +1
      eager: true                     # ★ 立即初始化,不然要等第一次调用才在控制台看到
      datasource:
        flow:                         # 名字随便起
          nacos:
            server-addr: 127.0.0.1:8848
            dataId: order-service-flow-rules
            groupId: SENTINEL_GROUP
            rule-type: flow           # ★ flow/degrade/param-flow/system/authority
            data-type: json
        degrade:
          nacos:
            server-addr: 127.0.0.1:8848
            dataId: order-service-degrade-rules
            groupId: SENTINEL_GROUP
            rule-type: degrade

⚠️ 只配 datasource 不改 Dashboard 源码 = 单向同步。此时在控制台改规则,应用会立即生效但 Nacos 里的值没变,下次重启又被 Nacos 的旧值覆盖回去——表现为「改完当时好了,重启又回去了」,这是最让人抓狂的坑。正确做法要么改 Dashboard,要么约定只在 Nacos 里改规则,控制台只读。

面试加分表述:「我们生产用的是 push 模式 + Nacos,但没改 Dashboard 源码,而是把 Sentinel 控制台定位成『只读观测台』,规则变更统一走 Nacos 配置发布流程。好处是规则变更有审批、有版本、有回滚,符合变更管理要求——这比改源码更适合团队协作。」


8. Sentinel 集群流控:单机阈值为什么不够用 ​

单机限流的致命缺陷:

text
场景:集群 10 台机器,想限制「整个服务 1000 QPS」
做法:每台配 100 QPS

问题 ①  负载不均:网关 hash 不均匀,有台机器实际分到 200 QPS
        → 它被限流了,但其他 9 台加起来才 600,总量远没到 1000
问题 ②  弹性伸缩:扩容到 20 台,总阈值自动变成 2000,下游直接被打挂
问题 ③  缩容到 5 台,总阈值变 500,明明能扛 1000 却开始拒绝

集群流控的架构:

text
             ┌─────────────────────┐
             │   Token Server      │  持有全局计数器
             │  (全局唯一)        │  用 LeapArray 统计总 QPS
             └─────────────────────┘
                 ▲   ▲   ▲   ▲
       申请 token │   │   │   │ 返回 OK / BLOCKED
                 │   │   │   │
            ┌────┴┐ ┌┴──┐ ┌┴──┐ ┌┴───┐
            │ C1 │ │C2 │ │C3 │ │ C4 │  Token Client(各业务实例)
            └────┘ └───┘ └───┘ └────┘
部署模式说明适用
embedded(嵌入式)集群中选一台业务实例兼任 Token Server规模小、运维简单;但那台机器负载更高,且它挂了要重新选
alone(独立)独立部署 Token Server 进程生产推荐,稳定、可做主备

必须配置的降级策略:

java
// ClusterFlowConfig
config.setFallbackToLocalWhenFail(true);   // ★ Token Server 不可用时,退化为单机限流
config.setThresholdType(ClusterRuleConstant.FLOW_THRESHOLD_GLOBAL);
//   FLOW_THRESHOLD_GLOBAL      总体阈值(整个集群 1000 QPS)
//   FLOW_THRESHOLD_AVG_LOCAL   单机均摊(每台 100 QPS × 机器数)

⚠️ fallbackToLocalWhenFail 不设 true 的后果:Token Server 挂掉后,所有 Client 申请 token 失败 → 按配置要么全部放行(限流完全失效,下游被打挂)要么全部拒绝(服务直接不可用)。这两个结果都是灾难,必须退化到单机限流。

什么时候才真的需要集群流控:「保护的是一个总量有限的共享资源」时——比如下游第三方接口买的是 1000 QPS 的配额、数据库连接池总共 200 个。如果只是保护自己的服务,单机限流 + 合理的负载均衡通常就够了,不要为了技术而上集群流控,它多了一次网络 RTT 和一个单点。


9. 线程隔离:信号量 vs 线程池(Sentinel 与 Hystrix 的分水岭) ​

维度信号量隔离(Sentinel 默认)线程池隔离(Hystrix 默认)
实现一个计数器,超了就拒每个依赖一个独立线程池
线程开销零,在调用方线程里执行每个依赖一个池,几十个依赖 = 几百个线程
上下文传递天然可用(同一线程,ThreadLocal 正常)会丢(跨线程),需要装饰器传递
支持超时中断❌ 做不到,只能等调用方自己超时✅ 可以 Future.cancel()
适用依赖多、QPS 高、调用本身有超时控制依赖少、调用不可控(无超时的第三方 SDK)

为什么 Sentinel 选信号量:

text
线程池隔离的隐性成本:
  30 个下游依赖 × 每个池 10 线程 = 300 个线程
  → 线程上下文切换开销
  → 每个线程 1MB 栈 ≈ 300MB 内存
  → ThreadLocal 全丢:traceId、用户上下文、Seata XID 都得手动传

Sentinel 的观点:
  ★ 真正需要「超时中断」的场景很少 —— 只要你的 HTTP 客户端 / RPC 配了 readTimeout,
    调用本身就会超时返回,不需要额外的线程来「掐断」它
  ★ 所以用信号量(并发线程数限流)就够了,成本接近零

面试加分表述:「我的判断标准是**『这个调用能不能自己超时』。能(Feign 配了 readTimeout、Redis 配了 timeout)就用信号量——Sentinel 的『并发线程数』流控本质就是信号量隔离。不能自己超时的(比如某些老的第三方 SDK、同步 JDBC 的慢查询)才单独开线程池包一层,并且只给这类依赖开**,不要全局都用线程池。」


10. Gateway 的请求体只能读一次:缓存 body 的正确姿势 ​

问题根因:WebFlux 的 request.getBody() 返回 Flux<DataBuffer>,这是一个只能订阅一次的响应式流(底层是 Netty 的 ByteBuf,读完就被释放)。

java
// ❌ 典型错误:在自定义过滤器里读了 body,下游收到空请求体
return exchange.getRequest().getBody()
        .map(buf -> buf.toString(StandardCharsets.UTF_8))
        .doOnNext(body -> log.info("请求体: {}", body))
        .then(chain.filter(exchange));
// 结果:NettyRoutingFilter 再去读 body 时已经被消费完 → 下游拿到空 body → 400/参数为 null

正确做法一:复用官方 ReadBodyRoutePredicateFactory 的缓存机制

Spring Cloud Gateway 内置了 AdaptCachedBodyGlobalFilter,只要在 exchange 属性里塞上 cachedRequestBody,它就会自动替换 request。标准写法是借 ServerWebExchangeUtils.cacheRequestBody:

java
@Component
public class BodyLogFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        HttpMethod method = exchange.getRequest().getMethod();
        MediaType contentType = exchange.getRequest().getHeaders().getContentType();

        // ★ 只对有 body 且是 JSON 的请求做缓存,GET / 文件上传一律跳过
        if (!HttpMethod.POST.equals(method) && !HttpMethod.PUT.equals(method)) {
            return chain.filter(exchange);
        }
        if (contentType == null || !contentType.includes(MediaType.APPLICATION_JSON)) {
            return chain.filter(exchange);
        }

        // cacheRequestBody 会把 body 读出来缓存,并给你一个「可重复读」的新 exchange
        return ServerWebExchangeUtils.cacheRequestBody(exchange, serverHttpRequest -> {
            DataBuffer buffer = exchange.getAttribute(ServerWebExchangeUtils.CACHED_REQUEST_BODY_ATTR);
            String body = buffer == null ? "" : buffer.toString(StandardCharsets.UTF_8);
            log.info("path={}, body={}", exchange.getRequest().getPath(), abbreviate(body, 2000));

            // ★ 必须用回调给的 serverHttpRequest 构造新 exchange,它才是可重复读的那个
            return chain.filter(exchange.mutate().request(serverHttpRequest).build());
        });
    }

    private String abbreviate(String s, int max) {
        return s.length() <= max ? s : s.substring(0, max) + "...(truncated)";
    }

    @Override
    public int getOrder(){
        // 要早于鉴权(如果鉴权要读 body 做签名校验)
        return Ordered.HIGHEST_PRECEDENCE + 1;
    }
}

三条必须守的纪律:

纪律原因
不缓存文件上传multipart/form-data 可能几百 MB,缓存到内存直接 OOM。按 Content-Type 白名单放行
限制缓存大小配 spring.codec.max-in-memory-size: 1MB,超过直接拒绝
日志要截断打全量 body 会把磁盘写满,而且可能打出身份证、手机号等敏感信息(合规风险)

⚠️ 响应体的改写更危险。重写 response body 需要装饰 ServerHttpResponseDecorator 并把 Flux<DataBuffer> 全部 join 成一个 buffer,这意味着整个响应要在网关内存里完整驻留一次。导出大文件、流式响应的接口必须走白名单排除,否则网关会被一个下载请求打 OOM。


11. Gateway 的 Redis 令牌桶限流与 Lua 原子性 ​

Gateway 自带的 RequestRateLimiter 过滤器,底层是 Redis + Lua 实现的令牌桶。

yaml
spring:
  data:
    redis:
      host: 127.0.0.1
  cloud:
    gateway:
      routes:
        - id: order-route
          uri: lb://order-service
          predicates:
            - Path=/api/order/**
          filters:
            - name: RequestRateLimiter
              args:
                key-resolver: "#{@userKeyResolver}"      # ★ SpEL 引用 Bean
                redis-rate-limiter.replenishRate: 100    # 每秒补充 100 个令牌(稳态 QPS)
                redis-rate-limiter.burstCapacity: 200    # 桶容量 200(允许瞬时双倍突发)
                redis-rate-limiter.requestedTokens: 1    # 每个请求消耗几个令牌
java
@Configuration
public class KeyResolverConfig {

    /** 按用户限流:每个用户独立桶 */
    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> {
            String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
            // ★ 返回 Mono.empty() 会「不限流直接放行」,所以未登录必须给个兜底 key
            return Mono.just(StringUtils.hasText(userId) ? userId : "anonymous");
        };
    }

    /** 按 IP 限流:注意要取 X-Forwarded-For 的第一段 */
    @Bean
    public KeyResolver ipKeyResolver() {
        return exchange -> {
            String xff = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
            if (StringUtils.hasText(xff)) {
                return Mono.just(xff.split(",")[0].trim());
            }
            return Mono.just(Optional.ofNullable(exchange.getRequest().getRemoteAddress())
                    .map(a -> a.getAddress().getHostAddress()).orElse("unknown"));
        };
    }
}

为什么必须用 Lua:

text
令牌桶的一次判断需要三步:
  ① 读取「上次补充时间」和「当前令牌数」
  ② 按时间差计算应补充的令牌,算出新余量
  ③ 够则扣减并写回,不够则拒绝

如果用三次独立的 Redis 命令:
  ❌ 两个并发请求都读到「剩 1 个令牌」→ 都扣减 → 实际放过 2 个(超卖令牌)

Lua 脚本在 Redis 中单线程原子执行:
  ✅ 读-算-写 一气呵成,不可能被打断
  ★ 这和「Redis 分布式锁用 Lua 做原子释放」是同一个道理
坑说明
burstCapacity 必须 ≥ replenishRate配小了会让稳态流量都过不去
burstCapacity = replenishRate等于完全不允许突发,真实流量总有毛刺,容易误杀
Redis 挂了怎么办默认所有请求被拒(拿不到令牌)。生产要考虑加一层本地兜底限流,或者把 Redis 做成哨兵/集群
返回的响应头带 X-RateLimit-Remaining / X-RateLimit-Burst-Capacity,前端可以用来做退避重试

12. Gateway 的 CORS 重复响应头问题 ​

现象:浏览器报 The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed。

根因:

text
网关配了 CORS                  →  响应头加一份 Access-Control-Allow-Origin
下游服务也配了 @CrossOrigin     →  响应头又加一份
                                   ↓
                   浏览器收到两个同名头 → 直接拒绝

解法一(推荐):CORS 只在网关做一次,下游全部移除

yaml
spring:
  cloud:
    gateway:
      globalcors:
        add-to-simple-url-handler-mapping: true   # ★ 让 OPTIONS 预检请求也能被处理,
                                                  #   否则预检会被当成找不到路由返回 404
        cors-configurations:
          '[/**]':
            allowedOriginPatterns: "*"            # ★ Spring 5.3+ 用这个,
                                                  #   allowedOrigins:"*" 与 allowCredentials:true 冲突会启动报错
            allowedMethods: "*"
            allowedHeaders: "*"
            allowCredentials: true
            maxAge: 3600                          # 预检结果缓存 1 小时,减少 OPTIONS 请求

解法二:网关侧强制去重(下游代码改不动时的兜底)

java
@Component
public class CorsDeduplicateFilter implements GlobalFilter, Ordered {

    private static final List<String> CORS_HEADERS = List.of(
            HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN,
            HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS,
            HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS,
            HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        exchange.getResponse().beforeCommit(() -> {
            HttpHeaders headers = exchange.getResponse().getHeaders();
            CORS_HEADERS.forEach(name -> {
                List<String> values = headers.get(name);
                if (values != null && values.size() > 1) {
                    // ★ 只保留第一个(网关自己加的那个)
                    headers.set(name, values.get(0));
                }
            });
            return Mono.empty();
        });
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return Ordered.LOWEST_PRECEDENCE;    // 最后执行,确保能看到全部响应头
    }
}

⚠️ 也可以用 DedupeResponseHeader 过滤器,它是官方提供的:

yaml
filters:
  - name: DedupeResponseHeader
    args:
      name: "Access-Control-Allow-Origin Access-Control-Allow-Credentials"
      strategy: RETAIN_FIRST     # RETAIN_FIRST / RETAIN_LAST / RETAIN_UNIQUE

但它要配在每条路由上(或放进 default-filters),而且只处理配了名字的那几个头。

面试加分表述:「CORS 的正确架构是**『只在最外层处理一次』**。我们的规范是下游服务一律禁止出现 @CrossOrigin 和 CorsFilter,CORS 配置统一收口到网关——因为跨域本质是浏览器和『对外入口』之间的协议,内部服务不该关心这件事。」


13. Gateway 整合 Sentinel:route 维度与 API 分组维度 ​

Gateway 自带的限流只有令牌桶,功能单一。生产通常换成 Sentinel,获得熔断、热点、系统保护全套能力。

xml
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-starter-sentinel-gateway</artifactId>
</dependency>

两种限流粒度:

维度资源名适用
route 维度就是路由的 id(如 order-route)粒度粗,一条路由下所有接口共享一个阈值
API 分组维度自定义 ApiDefinition 的名字粒度细:可以把「下单」单独划一组限 100 QPS,「查询」划一组限 5000 QPS
java
@Configuration
public class GatewaySentinelConfig {

    /** ① 定义 API 分组:把若干 URL 归到一个逻辑资源下 */
    @PostConstruct
    public void initApiDefinitions() {
        Set<ApiDefinition> definitions = new HashSet<>();

        ApiDefinition writeApi = new ApiDefinition("order-write-api")
                .setPredicateItems(new HashSet<>(Arrays.asList(
                        new ApiPathPredicateItem().setPattern("/api/order/create"),
                        new ApiPathPredicateItem().setPattern("/api/order/cancel"))));

        ApiDefinition readApi = new ApiDefinition("order-read-api")
                .setPredicateItems(Collections.singleton(
                        new ApiPathPredicateItem()
                                .setPattern("/api/order/query/**")
                                .setMatchStrategy(SentinelGatewayConstants.URL_MATCH_STRATEGY_PREFIX)));

        definitions.add(writeApi);
        definitions.add(readApi);
        GatewayApiDefinitionManager.loadApiDefinitions(definitions);
    }

    /** ② 定义限流规则 */
    @PostConstruct
    public void initGatewayRules() {
        Set<GatewayFlowRule> rules = new HashSet<>();
        rules.add(new GatewayFlowRule("order-write-api")
                .setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_CUSTOM_API_NAME)
                .setCount(100).setIntervalSec(1));
        rules.add(new GatewayFlowRule("order-read-api")
                .setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_CUSTOM_API_NAME)
                .setCount(5000).setIntervalSec(1));
        // ★ 按参数限流:同一个 userId 每秒只能下 5 单
        rules.add(new GatewayFlowRule("order-write-api")
                .setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_CUSTOM_API_NAME)
                .setParamItem(new GatewayParamFlowItem()
                        .setParseStrategy(SentinelGatewayConstants.PARAM_PARSE_STRATEGY_HEADER)
                        .setFieldName("X-User-Id"))
                .setCount(5).setIntervalSec(1));
        GatewayRuleManager.loadRules(rules);
    }

    /** ③ 自定义被限流后的响应,默认返回 429 和一段英文文案,必须改 */
    @PostConstruct
    public void initBlockHandler() {
        GatewayCallbackManager.setBlockHandler((exchange, t) -> {
            Map<String, Object> body = Map.of(
                    "code", 429,
                    "msg", "当前访问人数过多,请稍后重试",
                    "path", exchange.getRequest().getPath().value());
            return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
                    .contentType(MediaType.APPLICATION_JSON)
                    .bodyValue(body);
        });
    }
}

⚠️ RESOURCE_MODE_ROUTE_ID(默认)和 RESOURCE_MODE_CUSTOM_API_NAME 搞混是最常见的失效原因。配了 API 分组但忘了 setResourceMode,Sentinel 会拿分组名去匹配 route id,匹配不上就等于规则不存在,而且不报错。

面试加分表述:「网关层限流和服务层限流解决的是不同问题:网关限的是『入口总量』,防止流量洪峰打穿整个系统;服务层限的是『单接口容量』,防止某个慢接口拖垮本服务。两层都要有,而且网关层阈值要比各服务阈值之和略小,让拒绝尽早发生在最便宜的地方——在网关拒掉一个请求的成本远低于它穿过三层服务后被拒。」


14. Feign 的底层 HTTP 客户端:默认实现为什么必须换掉 ​

Feign 默认用 java.net.HttpURLConnection,这是一个没有连接池的实现。

text
每次调用:
  三次握手 → 发请求 → 收响应 → 四次挥手
     ↑
  高并发下:① 大量 TIME_WAIT 端口堆积(默认 60 秒才释放,端口耗尽)
            ② 每次握手多 1 个 RTT,内网也有 0.5~1ms,调用链长了就是几十毫秒
            ③ HTTPS 还要多一次 TLS 握手(更贵)
实现连接池依赖备注
HttpURLConnection❌无(默认)生产禁用
Apache HttpClient 5✅feign-hc5成熟稳定,配置项丰富
OkHttp✅feign-okhttp更轻量,支持 HTTP/2
xml
<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-hc5</artifactId>
</dependency>
yaml
spring:
  cloud:
    openfeign:
      httpclient:
        hc5:
          enabled: true
        max-connections: 500              # 全局最大连接数
        max-connections-per-route: 100    # ★ 每个目标(服务+IP+端口)最大连接数
                                          #   默认只有 50,调用一个热点服务时很容易打满
        connection-timeout: 2000
        time-to-live: 900                 # 连接存活时间
        time-to-live-unit: seconds

⚠️ max-connections-per-route 是最容易被忽略的瓶颈。全局配了 500 连接,但单个路由只有 50——如果你的流量 90% 打在同一个下游服务上,实际可用连接只有 50 个。表现是「QPS 上不去但 CPU 很闲,线程都阻塞在 getConnection」。排查时看 PoolingHttpClientConnectionManager 的 leased/pending 指标。

连接池大小怎么估:

text
按 利特尔法则(Little's Law):
  所需连接数 = QPS × 平均响应时间(秒)

例:下游接口 500 QPS,平均 RT 40ms
  → 500 × 0.04 = 20 个连接
  → 留 2~3 倍余量应对毛刺 → 配 50~60

★ 不要无脑配几千 —— 连接池不是越大越好,
  连接过多会把下游的线程/连接数打满,把问题推给下游

15. Feign 的编解码与日志级别 ​

日志级别(面试常问「Feign 日志怎么开」):

java
@Configuration
public class FeignConfig {
    @Bean
    public Logger.Level feignLoggerLevel() {
        return Logger.Level.BASIC;   // 生产推荐 BASIC
    }
}
级别输出内容建议
NONE不打(默认)—
BASIC方法、URL、状态码、耗时生产用这个,信息够且开销小
HEADERSBASIC + 请求/响应头排查认证问题时临时开
FULLHEADERS + 请求体 + 响应体只在本地调试用;生产开了会打爆磁盘,还可能泄露敏感数据
yaml
logging:
  level:
    com.example.feign: debug      # ★ 必须把 Feign 接口所在包设为 debug,
                                  #   否则配了 Logger.Level 也看不到日志

自定义 Decoder 处理统一响应体:

下游都返回 Result<T> 包装,调用方每次都要 .getData() 并判断 code,很啰嗦。可以在 Decoder 里统一拆包:

java
public class ResultDecoder implements Decoder {

    private final Decoder delegate;

    public ResultDecoder(ObjectFactory<HttpMessageConverters> converters) {
        this.delegate = new SpringDecoder(converters);
    }

    @Override
    public Object decode(Response response, Type type) throws IOException, FeignException {
        // ★ 先用委托 Decoder 解成 Result<T>,再把 data 取出来
        Type wrapped = TypeUtils.parameterize(Result.class, type);
        Result<?> result = (Result<?>) delegate.decode(response, wrapped);

        if (result == null) {
            throw new BizException("下游返回空响应");
        }
        if (!result.isSuccess()) {
            // ★ 关键:把下游的业务失败「抛」出来,而不是返回 null
            //   这样 @GlobalTransactional 才能感知到并回滚
            throw new RemoteCallException(result.getCode(), result.getMsg());
        }
        return result.getData();
    }
}
java
/**
 * 注意:这个类「不要」加 @Configuration,
 * 否则会被主容器扫到变成全局生效,污染所有 FeignClient
 */
public class StockFeignConfig {
    @Bean
    public Decoder resultDecoder(ObjectFactory<HttpMessageConverters> converters) {
        return new ResultDecoder(converters);
    }
}
java
// Feign 接口就可以直接声明业务类型,不用套 Result
@FeignClient(name = "stock-service", path = "/stock", configuration = StockFeignConfig.class)
public interface StockFeignClient {
    @GetMapping("/{skuId}")
    Stock query(@PathVariable("skuId") Long skuId);   // 不是 Result<Stock>
}

⚠️ 这个优化有代价:拆包后调用方拿不到下游的原始错误码(除非像上面一样封进异常)。而且一旦下游某个接口不按 Result 规范返回,解码会直接报错。要么全公司强制统一响应体规范,要么别做这个优化。

ErrorDecoder 统一处理 HTTP 错误码:

java
public class FeignErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        if (response.status() == 404) {
            return new ResourceNotFoundException(methodKey);
        }
        if (response.status() >= 500) {
            // ★ 返回 RetryableException,Feign 的 Retryer 才会重试
            return new RetryableException(response.status(), "下游服务异常",
                    response.request().httpMethod(), (Long) null, response.request());
        }
        return new ErrorDecoder.Default().decode(methodKey, response);
    }
}

面试加分表述:「Feign 的扩展点都是接口 + 默认实现,可以按 @FeignClient(configuration = ...) 做单客户端粒度的定制,也可以声明成全局 @Bean。我们的做法是全局配 Logger.Level 和连接池,单客户端配 ErrorDecoder 和超时——因为超时时间跟接口特性强相关,统一配一定有接口被冤枉。」


16. Seata TCC 的三大陷阱:空回滚、幂等、悬挂 ​

TCC 把一致性交给业务自己实现,换来了性能,但也必须自己处理这三个问题。面试问 TCC 必问这三个。

陷阱一:空回滚(Cancel 比 Try 先到)

text
正常:  Try 成功 → Cancel(有东西可回滚)
异常:  Try 还没执行(网络超时 / 服务宕机 / 一直在排队)
        → TC 超时判定全局回滚 → 直接调 Cancel
        → ★ Cancel 要回滚一个「从没发生过的 Try」= 空回滚

如果 Cancel 里写 UPDATE account SET frozen = frozen - 100
  → frozen 变成 -100,数据直接错乱

陷阱二:幂等(Confirm/Cancel 被重复调用)

text
TC 调 Confirm → 网络超时没收到响应 → TC 重试 → Confirm 执行第二次
  → 如果 Confirm 是 UPDATE balance = balance - 100,就扣了两次

陷阱三:悬挂(Try 比 Cancel 后到)

text
① TM 发起 Try(RPC 超时,但请求其实在网络里慢慢走着)
② TC 超时 → 判定回滚 → 调 Cancel(空回滚,正确返回)
③ 全局事务已结束
④ ★ 此时那个「慢的 Try」终于到了,冻结了 100 块钱
   → 这笔冻结永远不会有 Confirm/Cancel 来释放 = 资源悬挂

统一解法:一张事务控制表

sql
CREATE TABLE tcc_tx_record (
  xid          VARCHAR(128) NOT NULL COMMENT '全局事务ID',
  branch_id    BIGINT       NOT NULL COMMENT '分支事务ID',
  action_name  VARCHAR(64)  NOT NULL COMMENT '业务动作名',
  state        TINYINT      NOT NULL COMMENT '1-TRY 2-CONFIRM 3-CANCEL',
  gmt_create   DATETIME     NOT NULL,
  PRIMARY KEY (xid, branch_id)
) COMMENT 'TCC 事务控制表';
java
@LocalTCC
public interface AccountTccAction {

    @TwoPhaseBusinessAction(name = "freezeBalance", commitMethod = "confirm", rollbackMethod = "cancel")
    boolean tryFreeze(@BusinessActionContextParameter(paramName = "userId") Long userId,
                      @BusinessActionContextParameter(paramName = "amount") BigDecimal amount);

    boolean confirm(BusinessActionContext ctx);

    boolean cancel(BusinessActionContext ctx);
}
java
@Service
public class AccountTccActionImpl implements AccountTccAction {

    @Resource private TccRecordMapper recordMapper;
    @Resource private AccountMapper accountMapper;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean tryFreeze(Long userId, BigDecimal amount) {
        String xid = RootContext.getXID();
        Long branchId = BusinessActionContextUtil.getContext().getBranchId();

        // ★★ 防悬挂:如果已经有 CANCEL 记录,说明回滚已经发生过,Try 必须拒绝执行
        if (recordMapper.existsByState(xid, branchId, TccState.CANCEL)) {
            log.warn("[防悬挂] 已回滚,拒绝 try, xid={}", xid);
            return false;
        }
        // ★ 插入 TRY 记录,主键冲突天然做了幂等
        int inserted = recordMapper.insertIgnore(xid, branchId, "freezeBalance", TccState.TRY);
        if (inserted == 0) {
            return true;    // 重复 try,直接返回成功
        }
        // 真正的业务:冻结余额(带乐观条件,避免余额被冻成负数)
        int rows = accountMapper.freeze(userId, amount);
        if (rows == 0) {
            throw new BizException("余额不足");
        }
        return true;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean confirm(BusinessActionContext ctx) {
        // ★ 幂等:CAS 把状态从 TRY 改成 CONFIRM,改不到说明已经确认过了
        int rows = recordMapper.casState(ctx.getXid(), ctx.getBranchId(),
                                         TccState.TRY, TccState.CONFIRM);
        if (rows == 0) {
            log.info("[幂等] confirm 已执行过, xid={}", ctx.getXid());
            return true;
        }
        Long userId = Long.valueOf(ctx.getActionContext("userId").toString());
        BigDecimal amount = new BigDecimal(ctx.getActionContext("amount").toString());
        accountMapper.confirmFreeze(userId, amount);   // frozen -= amount, balance 不变(已在 try 扣)
        return true;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean cancel(BusinessActionContext ctx) {
        String xid = ctx.getXid();
        Long branchId = ctx.getBranchId();

        // ★★ 防空回滚:没有 TRY 记录 → 插一条 CANCEL 占位后直接返回成功
        int inserted = recordMapper.insertIgnore(xid, branchId, "freezeBalance", TccState.CANCEL);
        if (inserted == 1) {
            log.warn("[空回滚] try 未执行, xid={}", xid);
            return true;     // ★ 必须返回 true,否则 TC 会无限重试
        }
        // ★ 幂等:CAS TRY → CANCEL
        int rows = recordMapper.casState(xid, branchId, TccState.TRY, TccState.CANCEL);
        if (rows == 0) {
            return true;     // 已回滚过
        }
        Long userId = Long.valueOf(ctx.getActionContext("userId").toString());
        BigDecimal amount = new BigDecimal(ctx.getActionContext("amount").toString());
        accountMapper.unfreeze(userId, amount);        // 解冻
        return true;
    }
}
陷阱解法关键点
空回滚Cancel 时先查有没有 TRY 记录,没有就插 CANCEL 占位并返回成功必须返回 true,否则 TC 无限重试
幂等用事务控制表的主键唯一约束 + 状态 CAS不要靠「查一下再写」,有并发窗口
悬挂Try 时先查有没有 CANCEL 记录,有就拒绝执行这正是空回滚插占位记录的用途

⚠️ 三个问题是连锁的:解决空回滚要插 CANCEL 记录,而这条记录正好被防悬挂用来判断。所以它们必须一起解决,只做幂等不做防悬挂,照样会漏资源。

面试加分表述:「TCC 的工作量不在 Seata 而在业务设计——Try 阶段必须能『预留资源』。像『冻结余额』『冻结库存』都可以,但『发短信』『调第三方支付』就没法预留,这类操作只能放在 Confirm 阶段,并且要自己保证幂等和可重试。所以 TCC 的适用边界是:业务上存在『中间态』的资源型操作。」


17. Seata XID 的传播:跨线程、跨 MQ、跨网关 ​

Seata 全靠 XID 能从发起方一路传到所有参与方。XID 存在 RootContext 里,底层是 ThreadLocal——所以只要换线程就会丢。

Seata 原生支持自动透传的场景:

场景实现
Feign / OpenFeignSeataFeignClient 自动在 Header 加 TX_XID
RestTemplateSeataRestTemplateAutoConfiguration 注册拦截器
DubboTransactionPropagationFilter
Spring MVC 接收侧SeataHandlerInterceptor 从 Header 取出并 RootContext.bind(xid)

会丢 XID 的四个场景 + 解法:

text
① @Async / 自建线程池          → ThreadLocal 不跨线程
② CompletableFuture.supplyAsync → 同上
③ MQ 异步消费                   → 完全是另一个进程 + 另一个时间点
④ Gateway 转发                   → ⚠️ 这个其实不丢(Header 原样透传),
                                    但如果网关做了 Header 白名单过滤就会丢

解法一:线程池装饰器(推荐,一次性解决 ①②)

java
/**
 * 把 XID(以及 traceId、用户上下文)在提交任务时「抓取」,在执行时「恢复」
 */
public class SeataTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // ★ 在「提交任务的线程」里读取,这时还在主线程,XID 是有的
        String xid = RootContext.getXID();
        return () -> {
            // ★ 在「执行任务的线程」里恢复
            boolean bound = false;
            try {
                if (StringUtils.hasText(xid) && RootContext.getXID() == null) {
                    RootContext.bind(xid);
                    bound = true;
                }
                runnable.run();
            } finally {
                // ★★ 必须解绑!线程池会复用线程,不解绑会把 XID 泄露给下一个任务
                if (bound) {
                    RootContext.unbind();
                }
            }
        };
    }
}
java
@Bean("bizExecutor")
public ThreadPoolTaskExecutor bizExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(8);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("biz-");
    executor.setTaskDecorator(new SeataTaskDecorator());   // ★ 关键一行
    executor.initialize();
    return executor;
}

⚠️ 把 XID 传到异步线程里是一件要非常慎重的事。全局事务的生命周期由 TM 控制,主线程一返回事务就结束了;而异步线程可能还在跑,此时它注册的分支事务会挂在一个「已经结束的全局事务」上,TC 要么找不到全局事务报错,要么已经提交完了它才注册 → 数据永久不一致。

正确的架构姿势:

text
❌ 在全局事务里开异步线程做「事务的一部分」
   → 根本矛盾:事务要求同步等待结果,异步要求不等

✅ 两种正确做法:
   方案 A  把异步任务挪到事务之外
           @GlobalTransactional 方法只做必须强一致的部分,
           发短信/发积分/写日志这些用 TransactionSynchronizationManager
           的 afterCommit 回调,在事务提交后再异步执行

   方案 B  改成最终一致
           事务内只写一条本地消息表记录(和业务同事务),
           提交后由定时任务/MQ 异步推进 —— 这就是 Day18 讲的本地消息表

MQ 场景(跨进程)的处理:

java
// 发送侧:把 XID 作为消息属性带上(但通常「不应该」这么做,见下面说明)
message.putUserProperty("SEATA_XID", RootContext.getXID());

⚠️ 强烈不建议把 XID 透传到 MQ 消费侧。消息可能延迟几秒甚至几分钟才被消费,那时全局事务早就结束了。MQ 天然是最终一致的场景,应该用「事务消息 / 本地消息表 + 消费幂等」,而不是硬把它塞进 Seata 的全局事务里。


18. Seata TC 的高可用部署与存储模式 ​

三种 store.mode:

模式存储位置高可用性能适用
file本地文件(默认)❌ 单机,挂了事务状态全丢最快仅本地开发
dbMySQL(三张表)✅ 多 TC 共享一个库中等生产主流
redisRedis✅快性能敏感;但 Redis 持久化不如 MySQL 可靠

生产部署形态:

text
                  ┌──────────────────────┐
                  │   Nacos(注册中心)   │
                  │ seata-server 集群列表 │
                  └──────────────────────┘
                       ▲            ▲
              注册     │            │     注册
                 ┌─────┴─────┐  ┌───┴───────┐
                 │  TC 节点1  │  │  TC 节点2  │   ← 无状态,可水平扩展
                 └─────┬─────┘  └───┬───────┘
                       │            │
                       ▼            ▼
                ┌───────────────────────────┐
                │   MySQL(主从 / RDS)      │
                │ global_table              │
                │ branch_table              │
                │ lock_table                │
                │ distributed_lock  ★1.5+   │
                └───────────────────────────┘

★ TC 节点本身无状态 → 任意一台挂掉,客户端重连到另一台,
  从数据库里读到事务状态后继续推进(这就是为什么必须用 db/redis 模式)
★ distributed_lock 表:多个 TC 节点的「定时任务」(事务超时检测、重试提交/回滚)
  需要抢分布式锁,保证同一时刻只有一个节点在跑,否则会重复推进

客户端的事务分组映射(最绕的一个概念):

text
三层映射关系:

  ① 应用配置            tx-service-group: default_tx_group
                                  ↓
  ② vgroup 映射         service.vgroup-mapping.default_tx_group = default
                                  ↓
  ③ Nacos 里的 TC 集群   seata-server 注册时 cluster = default
                                  ↓
                        拿到这个 cluster 下的所有 TC 地址

★ 任何一层对不上,启动日志就是 "no available service 'null' found"
  或者 "can not get cluster name in registry config"

★ 这套绕设计的价值:切换 TC 集群只改一行映射配置,不用改应用的 tx-service-group,
  可以做 TC 集群的灰度迁移

三个运维要点:

要点说明
TC 必须和业务同机房一次全局事务要和 TC 交互 4~6 次 RTT,跨机房直接让 RT 翻倍
undo_log 要定期清理正常情况下二阶段会删,但回滚失败的记录会一直堆着,要有监控和清理任务
监控全局事务状态查 global_table 里 status 长时间停在 Begin/Rollbacking 的记录,这些是卡住的事务,必须告警

19. 分布式事务四种方案的选型决策树与性能成本 ​

面试问「你们为什么用 AT 不用 TCC」,答「AT 简单」是不及格的,要能把成本、侵入性、一致性窗口三个维度量化出来。

text
                        要不要用分布式事务?
                                │
          ┌─────────────────────┴─────────────────────┐
          │                                           │
   能不能拆成"本服务事务 + 异步补偿"?            必须强一致同步返回
          │ 能                                        │
          ▼                                           ▼
  ┌───────────────────┐                   ┌─────────────────────┐
  │ 本地消息表 / 事务  │                   │ 参与方都是自己的     │
  │ 消息(RocketMQ)   │                   │ 数据库 + 单表操作?  │
  └───────────────────┘                   └──────────┬──────────┘
   最终一致,延迟秒级                      是 │          │ 否(有外部 HTTP / 无 undo 能力)
   零全局锁,吞吐最高                         ▼          ▼
                                        ┌────────┐  ┌────────┐
                                        │   AT   │  │  TCC   │
                                        └────────┘  └────────┘
                                         零侵入      手写三接口
                                         有全局锁    无全局锁、性能最好

  ★ 长流程(> 5 个参与方、有人工审核节点)→ SAGA 状态机

四种方案横向对比:

维度ATTCCSAGA本地消息表
代码侵入零(加注解)高(每个参与方写 3 个方法)中(写状态机 JSON + 补偿方法)中(加消息表 + 定时任务)
隔离性读未提交(默认),可升级无,业务自己兜无无
一致性窗口毫秒级(同步)毫秒级(同步)秒~分钟秒级
全局锁有,热点行会排队无无无
性能开销比本地事务慢 3~5 倍(多 4~6 次 TC 交互 + 2 次镜像查询)慢 2~3 倍最低几乎无额外开销
适用场景内部服务、DB 都可控高并发扣减、有外部接口长流程、跨组织可异步的最终一致场景

⚠️ AT 的性能成本要说具体:一次 AT 分支要做「查前镜像 → 执行业务 SQL → 查后镜像 → 写 undo_log → 向 TC 注册分支并申请全局锁」,相当于把一条 UPDATE 放大成 5 次 DB 交互 + 1 次 RPC。所以万级 TPS 的核心链路(如秒杀扣库存)绝不能用 AT,要么 TCC,要么 Redis 预扣 + 异步落库。

面试加分表述:我们的原则是「能不用分布式事务就不用」。先看能不能用业务手段消除跨库写,比如把订单和订单明细放一个库、用幂等 + 对账替代回滚。真需要的时候按这个顺序选:可异步 → 本地消息表;强一致且 DB 可控 → AT;强一致且高并发 → TCC;长流程 → SAGA。实际我们线上 80% 用的是本地消息表,只有「创建订单 + 扣优惠券 + 扣积分」这条必须同步返回结果的链路用了 AT,QPS 不到 200,AT 的性能损耗完全可以接受。


20. 生产落地配置清单与监控告警指标 ​

这节是面试里「你们线上是怎么配的」的标准答案,也是真正区分「跑过 Demo」和「上过生产」的分水岭。

关键配置清单(踩过坑才会配的那些项):

组件配置项生产推荐值为什么
Nacosspring.cloud.nacos.discovery.namespace填命名空间 ID(UUID),不是名字填名字会注册到 public,和其他环境串
NacosprotectThreshold核心服务 0.5,非核心 0防雪崩时把流量摊给剩余健康实例
Nacosspring.cloud.nacos.discovery.ephemeraltrue(默认)持久化实例下线后还在列表里
Feignconnect-timeout / read-timeout1000 / 3000(按 P99 × 1.5 定)不配默认 10s,下游抖一下线程池就打满
Feignmax-connections-per-route200(默认只有 50)默认值在高并发下是最隐蔽的瓶颈
Feignhttpclient.enabled / okhttp.enabled开其中一个默认 HttpURLConnection 无连接池
Sentineldatasource.*.nacos必配规则持久化不配重启规则全丢
Sentinelfeign.sentinel.enabledtrue不写这行 Feign 上的降级完全不生效
Sentineleagertrue默认懒加载,第一次调用才连 Dashboard
Gatewayhttpclient.connect-timeout / response-timeout2000 / 5s网关超时要比下游 Feign 超时长一点,否则下游还没返回网关先断
GatewayDedupeResponseHeaderAccess-Control-Allow-Origin RETAIN_FIRST网关 + 下游都配 CORS 会出重复头
Seataservice.vgroupMapping.my_tx_group必须和 TC 的 cluster 一致对不上就是 no available service
Seataregistry.nacos.groupSEATA_GROUPTC 默认注册在这个 group,写成 DEFAULT_GROUP 找不到
Seataclient.rm.reportRetryCount / lock.retryTimes5 / 30全局锁竞争激烈时给足重试,否则大量 LockConflict

监控告警指标清单:

text
【Nacos】
  · 注册实例数突降         → 批量下线/网络分区,立即告警
  · 配置发布次数 + 变更人   → 审计,防误改生产配置
  · Nacos 集群节点 leader 切换频率 → Raft 不稳定

【Feign / 调用链】
  · 各接口 P99 / 错误率     → 超时阈值是否配得合理的唯一依据
  · 连接池 Pending 数        → > 0 说明 max-connections 不够
  · 重试次数                 → 突增说明下游在抖

【Sentinel】
  · BlockException 分类计数(Flow / Degrade / ParamFlow / System)
       ↳ Degrade 突增 = 下游真的挂了
       ↳ Flow 突增   = 要么流量涨了,要么阈值配低了
  · 熔断器状态变更事件       → OPEN 必须告警到群
  · Dashboard 心跳丢失       → 规则推送链路断了

【Gateway】
  · 路由级 QPS / RT / 5xx
  · Reactor 线程池活跃线程数 → 接近核数说明有阻塞调用
  · 限流 Lua 执行耗时        → Redis 慢会直接拖垮网关

【Seata】
  · global_table 中 Begin 状态且 begin_time > 60s 的记录数  ★最重要
  · Rollbacking / RollbackRetrying 状态记录数               ★回滚卡死
  · undo_log 表行数趋势                                     ★只增不减 = 有脏数据
  · 全局锁冲突(LockConflict)次数                           → 热点行瓶颈
  · TC 节点存活数

⚠️ 最容易被忽略的一条:undo_log 表行数必须做趋势监控。正常情况下它应该在 0 附近波动,一旦出现「只增不减」,说明有事务二阶段失败、回滚卡住,这些脏数据会持续占着全局锁,最终让整条链路的全局事务全部超时。我们线上设的是「行数 > 1000 持续 5 分钟」告警。

面试加分表述:这套指标我们是分三层看的。第一层是业务层(订单创建成功率),出问题先看这个;第二层是治理层(Sentinel 的 Block 分类、熔断器状态),用来定位是被限流还是下游挂了;第三层是基础设施层(Nacos 实例数、TC 存活数、连接池 Pending),用来定位根因。排查顺序永远是从上往下,这样能在 2 分钟内把问题收敛到具体组件。


21. Nacos 的权重、同集群优先与多机房就近访问 ​

前面讲的灰度负载均衡解决的是「按版本分流」,但生产上还有两类更常见的流量调度需求:按机器规格分配不同流量(权重)和不跨机房调用(就近访问)。这两件事 Nacos 都是用实例元数据支撑的,面试里经常接着「负载均衡怎么做」继续问。

① 权重(weight):解决机器规格不一致

text
场景:集群里 6 台机器,4 台是 8C16G 的新机器,2 台是 4C8G 的老机器
      轮询会给它们打一样的流量 → 老机器先被打挂

Nacos 的 weight 是实例级属性,默认 1.0,范围 0 ~ 10000
  · weight = 0   → 不再分配流量(★ 这是「优雅摘流」的关键用法)
  · weight = 1   → 默认
  · weight = 2   → 拿到两倍于 weight=1 实例的流量

配置方式三种:
  · 配置文件:spring.cloud.nacos.discovery.weight=2
  · 控制台手动改:服务列表 → 编辑实例 → 权重(★ 发布时常用)
  · OpenAPI:PUT /nacos/v1/ns/instance 带 weight 参数(★ 发布脚本里用)

⚠️ 只配 weight 不换负载均衡器是没用的。这是极高频的坑:Spring Cloud LoadBalancer 的默认 RoundRobinLoadBalancer 完全不看 weight,你在 Nacos 控制台把权重调成 0,流量照旧打进来。必须显式启用 Nacos 自己的负载均衡实现(SCA 2021.x 起提供 NacosLoadBalancer):

yaml
spring:
  cloud:
    loadbalancer:
      nacos:
        enabled: true      # 启用 NacosLoadBalancer(权重 + 同集群优先都靠它)
    nacos:
      discovery:
        weight: 2
        cluster-name: SH   # 当前实例所在集群(通常填机房/可用区)

② 同集群优先:解决跨机房调用的延迟与成本

text
部署现状(两地机房):
  上海机房(cluster-name=SH):order-service × 3、user-service × 3
  北京机房(cluster-name=BJ):order-service × 3、user-service × 3

默认轮询的结果:上海的 order 有 50% 的概率调到北京的 user
  → 单次 RTT 从 2ms 变成 30ms,链路有 5 跳就是 150ms 的白送延迟
  → 还要付公网/专线流量费

NacosLoadBalancer 的选择顺序:
  ① 先筛出 cluster-name 与「调用方自己」相同的健康实例
  ② 同集群内按 weight 做加权随机
  ③ 同集群内一个都没有(整个机房挂了)→ 回落到全部集群 + 打 WARN 日志
text
        ┌──────────── 上海机房 (SH) ────────────┐
        │  order-service ──① 优先──▶ user(SH)  │
        └──────────────────│───────────────────┘
                           │ ② SH 全挂才跨机房(容灾回落)
        ┌──────────────────▼───────────────────┐
        │  北京机房 (BJ)        user(BJ)        │
        └──────────────────────────────────────┘

③ 三层流量调度能力的对照

维度载体典型用途回落行为
cluster-name实例固定属性机房 / 可用区就近访问同集群全挂 → 跨集群(内置)
weight实例可动态改大小机型配比、灰度放量、优雅摘流weight=0 的实例不参与
metadata(如 version)实例自定义 KV版本灰度、金丝雀要自己写,不写就会全断

面试加分表述:「我们发布时不是直接 kill,而是先把 Nacos 里该实例的 weight 改成 0,等 30 秒让调用方的本地缓存刷新、在途请求跑完,再执行停机。这比单纯依赖 deregister 更稳——weight=0 时实例还在列表里、还能处理在途请求,只是不再分配新流量,相当于一个比下线更温和的『只出不进』状态。缩容和故障隔离也用同一套手段。」

⚠️ cluster-name 必须两边都配。同集群优先比较的是「调用方的 cluster-name」和「被调方的 cluster-name」,只给被调方配、调用方留默认 DEFAULT,会一个都匹配不上,然后静默回落到全量轮询——表现就是「配了同集群优先但完全没生效」。


22. 配置中心的生产化:灰度发布、加密、权限与集群部署 ​

前面讲的是配置怎么推下去,这一节讲的是配置怎么敢往生产推。这部分最容易被跳过,但只要面试官问到「你们线上改配置的流程是什么」,答不出来就等于没真正用过配置中心。

① 配置灰度(Beta 发布):别让一次手抖打挂全站

配置热更新最大的风险是没有发布批次——点一下「发布」,几百个实例在几秒内全部换成新值,配错了就是全站瞬时故障,而且比代码发布更危险(代码发布至少是滚动的)。

text
Nacos 的 Beta 发布:按客户端 IP 灰度

发布时勾选「Beta 发布」并填入 IP 列表(10.1.2.11, 10.1.2.12)
        │
        ├─ 这两个 IP 的客户端 → 拿到 Beta 版本配置
        └─ 其余所有客户端     → 继续拿旧的正式版配置

观察 10 分钟 → 指标正常 → 点「发布」转为全量
            → 指标异常 → 点「停止 Beta」,两台瞬间回到旧配置
能力怎么用面试要点
Beta 发布控制台勾选 + 填 IP 名单粒度是客户端 IP,不是百分比;容器环境 IP 会变,要配合固定 IP 或 StatefulSet
历史版本与回滚配置详情 → 历史版本 → 回滚回滚本质是「把旧内容再发布一次」,同样会触发一次全量推送
变更对比(diff)发布前弹窗展示新旧 diff发布前必须逐行看 diff,这是成本最低的一道防线
监听查询服务端查「谁在监听这个 DataId」改配置前先确认影响面;下线配置前确认已无人监听

⚠️ 最该设的一道闸:生产命名空间开只读 + 走审批。我们的规则是「生产配置任何人不能在控制台直接改」,所有变更走 Git 仓库 PR 评审后由流水线调 OpenAPI 推送,Nacos 控制台的生产账号只有读权限。原因很直接:配置变更是即时生效、无灰度、无回滚窗口的操作,风险等级等同于直接改生产数据库。

② 敏感配置加密:数据库密码不能明文放在配置中心

text
三种做法,推荐程度从低到高:

① 明文 + 命名空间权限隔离  → 能跑,但 DBA 审计一定不过
② 客户端解密(推荐入门)
   Nacos 对 dataId 以 cipher- 开头的配置提供加解密扩展点
   cipher-aes-datasource.yaml → 客户端拿到密文后用本地密钥解密
   ★ 密钥通过环境变量/启动参数注入,绝不进配置中心
③ 对接外部 KMS / Vault(推荐生产)
   配置中心只存「密钥的引用(keyId)」,真正的密文由 KMS 托管
   ★ 好处:密钥轮转、访问审计、权限收口都由 KMS 统一做

⚠️ 自己做加密时最常犯的错:把密钥和密文放在同一个配置文件里。那不叫加密,叫编码——任何能读配置的人都能解开。判断标准只有一条:密钥和密文必须来自两套不同的权限体系。

③ 权限模型:Namespace 是隔离边界,不是分类标签

层级作用生产建议
Namespace环境隔离(dev / test / prod)一环境一 namespace,且生产独立账号;绝不用 Group 代替环境隔离
Group同环境内的业务线隔离按业务域划分(ORDER_GROUP / PAY_GROUP)
DataId具体配置文件公共配置抽成 shared-configs,避免多处复制
用户 / 角色 / 权限开启 nacos.core.auth.enabled=true 后生效必开;默认 nacos/nacos 账号必须改密码,开源版早期有未授权访问漏洞

④ 集群部署架构:生产最少 3 节点

text
                        ┌─────────────┐
       客户端 ──────────▶│ VIP / Nginx │(8848 http + 9848 gRPC 都要代理)
                        └──────┬──────┘
               ┌───────────────┼───────────────┐
         ┌─────▼─────┐   ┌─────▼─────┐   ┌─────▼─────┐
         │ nacos-1   │   │ nacos-2   │   │ nacos-3   │
         └─────┬─────┘   └─────┬─────┘   └─────┬─────┘
               └───────────────┼───────────────┘
                        ┌──────▼──────┐
                        │ MySQL 主从  │ ← 配置数据的唯一真相
                        └─────────────┘

为什么必须 3 节点(不是 2):
  配置数据走 Raft,需要「过半数」才能写入
  2 节点挂 1 台 → 剩 1 票不过半 → 配置变成只读,无法发布
  3 节点挂 1 台 → 剩 2 票过半   → 仍可正常读写
项单机模式生产集群
存储嵌入式 Derby(重启丢数据)MySQL 主从 + 定时备份
节点数1≥ 3(奇数)
端口88488848 / 9848 / 9849 / 7848 都要放开
客户端配置server-addr: ip:8848配 VIP,或直接配多地址逗号分隔(客户端自带故障转移)

面试加分表述:「我会强调一点——Nacos 挂了,线上业务不会立刻挂。客户端对服务列表和配置都有本地缓存(配置落在 ~/nacos/config 的快照目录),注册中心不可用期间,已有调用关系照常走,只是拿不到新的实例变更和配置变更。所以 Nacos 的故障等级是『不能发布、不能扩缩容』,而不是『全站不可用』。这个认知决定了应急时该先抢修 Nacos 还是先稳业务——答案是先冻结发布、稳住业务,再修 Nacos。」

⚠️ 客户端本地快照是最后一道保险,但也可能是陷阱:如果 Nacos 长时间不可用、同时某个实例重启,它会读本地快照启动,拿到的可能是几天前的配置。所以快照目录不能当缓存随便清,也不能无限信任——我们在启动日志里会打印「配置来源:server / local-snapshot」,一旦出现 local-snapshot 立刻告警。


❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】 ​

题 1:Nacos 为什么既能做 AP 又能做 CP?它和 Eureka、ZooKeeper 的区别是什么? ​

精简口述答案(90 秒版)

因为 Nacos 把服务发现和配置管理用了两套不同的一致性协议,而且服务发现内部还按实例类型分成两条路。

临时实例(ephemeral=true,默认)走 AP,底层是阿里自研的 Distro 协议:每个 Nacos 节点负责一部分实例的「权威写入」,收到注册请求后先写本地内存再异步广播给其他节点,不等多数派确认就返回成功。所以写入极快、节点挂了不影响其他节点读,代价是短时间内各节点数据可能不一致。

持久化实例(ephemeral=false)走 CP,底层是 Raft 协议(1.x 是自研 JRaft,2.x 用 JRaft 统一),必须多数派写入成功才返回,数据会落盘。配置中心也是 CP + 持久化到 MySQL。

和另两个的区别:Eureka 只有 AP,而且是纯客户端缓存 + 定时拉取,剔除不健康实例最长要 90 秒;ZooKeeper 只有 CP,leader 选举期间(通常 30 秒以上)整个注册中心不可写,这在服务发现场景下是致命的——注册中心短暂不一致可以接受,但完全不可用不能接受。Nacos 的价值就是让使用者按场景选。

连环追问链

追问答题要点
服务发现为什么默认选 AP 而不是 CP?服务列表短暂不一致只会导致「调到一个刚下线的实例」,有重试和熔断兜底;但注册中心不可写会导致新实例无法上线、扩容失效,后者危害大得多
Distro 协议具体怎么保证最终一致?每个节点按 hash(实例key) % 节点数 确定责任节点,只有责任节点能处理该实例的写;节点间每 5 秒同步一次校验值(checksum),发现不一致就从责任节点全量拉取
临时实例的健康检查和持久化实例有什么不同?临时实例是客户端主动上报心跳(1.x 每 5 秒,15 秒标记不健康,30 秒剔除;2.x 用 gRPC 长连接,连接断开立即剔除);持久化实例是服务端主动探测(TCP/HTTP/MySQL),不健康也不剔除,只标记
什么场景才真的需要持久化实例?非 Spring Cloud 的外部依赖,比如手动录入的一个第三方 HTTP 服务、一台 Nginx、一个 Redis 代理,它们不会主动上报心跳
配置中心是 CP,那 Nacos 挂了应用还能启动吗?能。客户端会把配置快照写到本地 ~/nacos/config/ 目录,Nacos 不可用时降级读本地快照,但拿不到最新变更
临时实例和持久化实例能混在同一个服务名下吗?不能。1.x 混用会报错或行为诡异,2.x 明确禁止;同一个 service 的 ephemeral 必须统一,这是面试和线上都踩过的坑

✅ 项目关联话术

我们线上就踩过这个坑。有同事为了「让实例下线后还能在控制台看到」,把一个服务配成了 ephemeral=false,结果这个服务滚动发布时老实例进程已经没了,但 Nacos 里状态还是「不健康但存在」,上游 Ribbon 的默认过滤策略又没过滤掉,持续调了快两分钟的死实例。后来我们在网关的健康巡检脚本里加了一条规则:所有 Spring Cloud 应用必须是临时实例,发现 ephemeral=false 直接告警,持久化实例只允许用于手工录入的外部依赖。


题 2:Nacos 配置热更新的底层原理是什么?是推还是拉? ​

精简口述答案(90 秒版)

1.x 是「客户端长轮询」,本质是拉,但做出了推的效果。客户端发一个 HTTP 请求带上所有监听配置的 MD5,服务端拿到后比对:如果一致就把请求挂起 29.5 秒不返回,挂起期间如果有配置变更就立即唤醒返回变更的 key;29.5 秒到了还没变更就返回空,客户端马上再发一次。所以变更能在毫秒级感知,又避免了服务端维护推送连接。

2.x 改成了 gRPC 双向长连接,是真正的推。服务端维护客户端订阅关系,变更时直接 ConfigChangeNotifyRequest 推过去,不需要轮询,连接数和 CPU 开销都大幅下降。

客户端拿到新配置后的链路是:更新本地缓存和快照文件 → 发布 RefreshEvent → ContextRefresher 重新加载 Environment → 销毁 @RefreshScope 作用域内的 Bean。下次访问这些 Bean 时重新创建,构造时就读到了新值。所以热更新只对 @RefreshScope 标注的 Bean 和 @ConfigurationProperties 生效。

连环追问链

追问答题要点
为什么是 29.5 秒而不是 30 秒?客户端请求超时设的是 30 秒,服务端 29.5 秒主动返回,留 0.5 秒余量,避免客户端先超时导致误判为网络异常
@RefreshScope 的实现原理是什么?它是一个自定义 Scope,Bean 实际注入的是 CGLIB 代理,真实对象存在 RefreshScope 的缓存里;刷新时 destroy() 清空缓存,下次调用代理时触发重新创建
哪些东西加了 @RefreshScope 也刷不动?① 构造器注入的值(对象已创建);② 静态变量;③ @Value 注入到单例且被其他单例持有的字段;④ 线程池、数据源等需要重建连接的组件(必须自己监听事件手动重建)
热更新会不会导致读到「一半新一半旧」?会。多个配置项如果在不同 key 里,刷新不是原子的。解决办法是把需要原子生效的配置放在同一个 dataId 里,或者用一个 version 字段做版本校验
Nacos 挂了,正在运行的应用受影响吗?不受影响。配置已在内存里,长连接断开只是收不到新变更;重启时会降级读本地快照文件
配置的优先级顺序是什么?扩展配置 < 共享配置 < 应用名配置(${app}.${ext}) < 带 profile 的配置(${app}-${profile}.${ext}),后者覆盖前者;bootstrap.yml 优先级高于 application.yml

✅ 项目关联话术

我们有个限流阈值是放在 Nacos 里动态调的,一开始用 @Value 注入到一个单例的 RateLimitService 里,加了 @RefreshScope 发现确实能刷。但后来又加了个「动态调整线程池核心线程数」的需求,同样的写法完全不生效——因为 ThreadPoolExecutor 是在 Bean 初始化时就创建好的,刷新只是重新 new 了一个包装类,底层线程池根本没变。最后改成监听 RefreshEvent,在事件回调里调 executor.setCorePoolSize() 才真正生效。这件事让我明白 @RefreshScope 只解决「重建 Bean」,不解决「重建 Bean 内部持有的有状态资源」。


题 3:一次 Feign 调用的完整流程是怎样的? ​

精简口述答案(90 秒版)

分启动期和调用期两段。

启动期:@EnableFeignClients 触发 FeignClientsRegistrar 扫描所有 @FeignClient 接口,为每个接口注册一个 FeignClientFactoryBean(BeanDefinition),它最终生产出的是 JDK 动态代理对象,注入到你的 Service 里。同时解析接口上的注解(@GetMapping、@RequestParam 等)生成 MethodMetadata,这一步是启动时完成的,不是每次调用都解析。

调用期有六步:

  1. 代理对象的 invoke() 拿到对应方法的 MethodHandler;
  2. 构造请求模板:按 MethodMetadata 把参数填进 URL / Header / Body,Body 用 Encoder 序列化(默认 Jackson);
  3. 执行 RequestInterceptor 链:这是透传 token、traceId 的入口;
  4. 负载均衡:FeignBlockingLoadBalancerClient 拦住 http://service-name,向 ServiceInstanceListSupplier(数据来自 Nacos 本地缓存)要实例列表,用 ReactorLoadBalancer 选一个,把 URL 替换成真实 IP:Port;
  5. 发起 HTTP 请求:默认 HttpURLConnection(无连接池),生产必须换 Apache HttpClient 或 OkHttp;
  6. 解码与异常处理:2xx 走 Decoder 反序列化,非 2xx 走 ErrorDecoder;失败时 Retryer 决定是否重试(默认 NEVER_RETRY)。

如果开了 Sentinel,第 4 步前面还会包一层 SentinelInvocationHandler,由它做流控和 fallback。

连环追问链

追问答题要点
Feign 为什么用 JDK 代理不用 CGLIB?@FeignClient 标注的是接口,JDK 代理天生就是面向接口的,没必要用 CGLIB 生成子类
负载均衡发生在哪一层?是在发 HTTP 之前还是之后?之前。LoadBalancer 只是把 http://order-service/xxx 这个 URI 里的服务名替换成真实地址,再交给 HTTP 客户端,它本身不发请求
Feign 的重试和 HTTP 客户端的重试是一回事吗?不是。Feign 的 Retryer 在解码失败/抛异常后触发,会重新走一遍负载均衡(可能换实例);HttpClient 自带的重试在连接层,只重试同一个地址。两者叠加会放大请求,必须显式关掉一个
为什么说 Feign 的重试很危险?① read-timeout 触发的重试,下游可能已经执行成功,非幂等接口会重复下单;② 重试次数会乘上调用链层数,3 层各重试 2 次就是 8 倍流量
Feign 接口能不能用 @RequestBody 传多个参数?不能,@RequestBody 只能有一个。多个参数要么封装成 DTO,要么全部用 @RequestParam。GET 请求带复杂对象还必须加 @SpringQueryMap
怎么让 Feign 打印完整的请求响应日志?配 logging.level.包名=DEBUG + 注入 Logger.Level.FULL。注意 FULL 会打印 Body,生产环境有性能和脱敏风险,一般只在 BASIC 级别

✅ 项目关联话术

我们排查过一个「下单偶发重复」的问题,查了半天业务代码没问题,最后发现是 Feign 配了 Retryer(当时为了提高成功率加的),而下游创单接口的 P99 刚好贴着 read-timeout 配的 1 秒。超时触发重试时,下游第一次其实已经写库成功了,第二次又创了一单。修复做了两件事:一是把 Feign 重试彻底关掉,只对明确幂等的查询接口单独开;二是在创单接口加了基于「用户 ID + 商品 ID + 分钟级时间窗」的幂等 key。这之后我的原则就是:重试必须和幂等成对出现,没有幂等保障的接口绝不开重试。


题 4:Sentinel 和 Hystrix 的区别是什么?为什么现在都用 Sentinel? ​

精简口述答案(90 秒版)

最核心的区别是隔离方式和判断维度。

隔离方式:Hystrix 默认用线程池隔离,每个依赖一个独立线程池,隔离彻底但有线程切换开销(官方数据 3%~10% RT 损耗),而且线程数要一个个调,几十个依赖就是几十个线程池。Sentinel 默认用信号量隔离(计数器),只是一个 AtomicInteger,几乎零开销,但不能主动中断慢调用。

判断维度:Hystrix 只能按错误率熔断。Sentinel 有三个维度——慢调用比例、异常比例、异常数,前者尤其重要,因为线上更常见的故障是「下游没报错但变得很慢」,Hystrix 对这种情况毫无反应(除非超时触发异常),Sentinel 可以直接按 RT 阈值熔断。

另外 Sentinel 多了三块能力:实时控制台 + 规则动态下发(Hystrix Dashboard 只能看不能改)、系统自适应保护(按 Load、CPU、入口 QPS 整体兜底)、热点参数限流(针对某个参数值限流,比如单个用户、单个商品)。

最后是现实原因:Hystrix 2018 年就停止维护了,官方推荐迁到 Resilience4j;国内生态里 Sentinel 和 Nacos、Dubbo、Spring Cloud Alibaba 整合度最高,所以成了事实标准。

连环追问链

追问答题要点
信号量隔离为什么不能中断慢调用?信号量只是在业务线程上做计数,业务代码还跑在调用方自己的线程里;要中断必须换个线程执行(线程池隔离)才能对它做 Future.cancel
那 Sentinel 怎么防慢调用拖垮线程?两条路:① 熔断规则按慢调用比例直接断掉,不让新请求进来;② 依赖 Feign/HTTP 客户端自己的超时设置,Sentinel 不负责超时,超时必须靠客户端配
Sentinel 的统计精度怎么保证?用 LeapArray 滑动窗口,默认把 1 秒切成 2 个 500ms 的样本窗口,过期窗口复用数组槽位(环形数组),只在槽位过期时加锁重置,所以无锁读、开销极低
Sentinel 的规则存在哪里?重启丢不丢?默认存内存,重启全丢。生产必须接 ReadableDataSource(Nacos / Apollo / ZK)做持久化,而且要用 push 模式(控制台改 → 写 Nacos → Nacos 推客户端),不能只用 pull
Resilience4j 和 Sentinel 怎么选?Resilience4j 更轻量、函数式 API、无控制台,适合「只要熔断能力」的场景;Sentinel 适合需要统一治理平台 + 动态规则 + 多维度的中大型系统
Sentinel 的 Slot 链有哪些?顺序重要吗?顺序极其重要:NodeSelector(构建调用链)→ ClusterBuilder(簇点统计节点)→ LogSlot → StatisticSlot(环绕式统计)→ Authority → System → Flow → Degrade。StatisticSlot 必须在各规则 Slot 之前,因为它要包住后面所有 Slot 的 fireEntry() 来捕获 BlockException 并计数

✅ 项目关联话术

我们是从 Hystrix 迁到 Sentinel 的,直接动因是一次事故:下游一个推荐服务的 Redis 变慢,接口 RT 从 20ms 涨到 4 秒,但没有任何异常抛出,Hystrix 的错误率一直是 0%,熔断器压根没开,结果上游的 Hystrix 线程池被慢请求占满,整个商品详情页挂了 7 分钟。迁到 Sentinel 后我给所有外部依赖都配了「慢调用比例规则:RT 超过 500ms 的比例大于 50% 就熔断 10 秒」,同一类故障再出现时,熔断器在 6 秒内就打开了,降级返回了兜底推荐位,页面没挂。


题 5:常见的限流算法有哪些?Sentinel 默认用的是哪种? ​

精简口述答案(90 秒版)

四种:

计数器(固定窗口):每个时间窗口内计数,超了就拒。实现最简单,但有临界问题——前一秒后 500ms 来 100 个、后一秒前 500ms 来 100 个,这 1 秒实际放了 200 个。

滑动窗口:把窗口切成 N 个小格子,按时间滑动,解决了临界问题。Sentinel 的统计和默认直接拒绝模式用的就是这个(LeapArray)。

漏桶:请求先进桶,按固定速率流出,桶满则拒。特点是绝对匀速,不允许突发。Sentinel 的「排队等待」流控效果就是漏桶,它会算出每个请求的期望通过时间,让请求休眠到那个时刻。

令牌桶:固定速率往桶里放令牌,请求取到令牌才能过,桶里能攒令牌所以允许一定突发。Guava RateLimiter 和 Spring Cloud Gateway 的 RequestRateLimiter 都是令牌桶。

Sentinel 的三种流控效果正好对应:快速失败 = 滑动窗口、Warm Up(预热)= 令牌桶的变体(冷启动时阈值从 1/3 慢慢爬升到设定值,防止冷系统被瞬时流量打死)、排队等待 = 漏桶。

连环追问链

追问答题要点
Warm Up 为什么需要?系统刚启动时连接池空、JIT 未编译、缓存未预热,承载能力只有稳态的 1/3。直接放满额流量会把实例打死。Warm Up 默认在 warmUpPeriodSec 内把阈值从 threshold/coldFactor(默认 3)线性抬到 threshold
排队等待会不会把线程全占住?会。排队靠 Thread.sleep 实现,队列超时时间(maxQueueingTimeMs)配太大就是在堆积线程。所以它只适合消息消费、定时任务这类后台链路,HTTP 入口不要用
单机限流和集群限流的区别?单机是「每台机器各限 N」,扩缩容时总阈值会跟着变,而且流量不均时会误限;集群限流有一个 Token Server 统一发令牌,总阈值精确,但 Token Server 要考虑单点和网络开销
集群限流的 Token Server 挂了怎么办?Sentinel 支持两种降级策略:fallbackToLocalWhenFail=true 时退化为单机限流(推荐),否则直接放行或全拒。必须显式配置,不然故障时行为不可控
限流该放在网关还是服务内部?两层都要。网关做粗粒度入口限流(按 IP、用户、路由,挡住恶意流量和总量);服务内做细粒度资源限流(按接口、按参数,保护具体的 DB / 下游)。网关挂不住的会漏到服务,服务是最后一道防线
分布式环境用 Redis 做限流要注意什么?限流逻辑必须用 Lua 脚本保证「读计数 + 判断 + 写计数」原子;key 要带时间窗口后缀并设过期时间防内存泄漏;Redis 抖动时要有本地兜底,不能因为限流组件挂了就全部拒绝

✅ 项目关联话术

我们的优惠券领取接口被刷过。一开始只在网关配了「按 IP 100 QPS」,结果对方用了代理池,每个 IP 都不超阈值,总量照样把 DB 打满。后来改成三层:网关按 IP 限 50 QPS(挡最粗暴的)、服务内用 Sentinel 热点参数限流按 userId 限 5 QPS(挡单个账号刷)、券维度用 Redis + Lua 做总量令牌桶(挡总超发)。第二层是最有效的,因为账号的获取成本远高于 IP。而且热点参数限流还支持给 VIP 客户配例外阈值,不用改代码。


题 6:熔断降级的状态机是怎么流转的?半开状态怎么判断能不能恢复? ​

精简口述答案(90 秒版)

三个状态循环:CLOSED(关闭)→ OPEN(打开)→ HALF_OPEN(半开)→ CLOSED 或 OPEN。

CLOSED 是正常状态,请求全放行,同时在统计窗口内累计指标。当「统计时长内请求数 ≥ 最小请求数(minRequestAmount,默认 5)」且「指标超过阈值」时,转为 OPEN。这两个条件是与的关系,少了后者会出现「只来了 1 个请求失败了就熔断」的误判。

OPEN 状态下所有请求不执行业务逻辑,直接抛 DegradeException 走 fallback。持续 timeWindow(熔断时长)后自动转 HALF_OPEN。

HALF_OPEN 是探测状态,只放过一个请求去试探下游:

  • 这个请求成功,且不违反熔断条件(RT 没超慢调用阈值)→ 回到 CLOSED,统计数据清零;
  • 这个请求失败或者还是慢 → 回到 OPEN,重新计时 timeWindow。

三种熔断策略的阈值判断不同:慢调用比例(RT > 阈值的请求占比)、异常比例(异常数/总数)、异常数(绝对值,统计时长可设为分钟级)。

连环追问链

追问答题要点
为什么半开只放一个请求,不放一批?下游可能还没恢复,放一批等于再打它一次,容易造成「反复熔断-恢复」的震荡。放一个的代价最小,用一次探测换一个明确结论
minRequestAmount 不配会怎样?低峰期一个偶发失败就能让「异常比例 = 100%」,熔断器直接打开,整个接口被误杀。所以熔断规则必须同时配比例阈值和最小请求数
熔断时长配多久合适?看下游故障的典型恢复时间。配太短会频繁探测打扰正在恢复的下游,配太长会让已恢复的下游迟迟拿不到流量。我们一般配 10~30 秒,并配合告警人工干预
熔断的统计维度是什么?按什么隔离?按资源名(resource)。Feign 场景下资源名默认是 HTTP 方法:URL模板,所以不同接口的熔断器是独立的,一个接口熔断不影响同一个服务的其他接口
fallback 里应该写什么?不该写什么?该写:返回缓存数据、默认值、空列表、友好提示、降级标记。不该写:再调另一个远程服务(可能也挂了)、复杂计算、写库操作。fallback 必须快且无依赖
@SentinelResource 的 blockHandler 和 fallback 有什么区别?blockHandler 只处理 BlockException(被限流/熔断/系统保护拦了);fallback 处理业务异常。两个都配时 BlockException 优先走 blockHandler。另外 exceptionsToIgnore 里的异常不计入熔断统计,参数校验类异常应该放进去

✅ 项目关联话术

我们在用户中心的「查询用户标签」接口上配了熔断,一开始只配了「异常比例 50%」没配最小请求数,结果凌晨三点流量很低的时候,一个超时就触发了熔断,导致后面十秒所有请求都走了降级,监控上看是「用户标签服务挂了」,排查了半天发现是误报。加上 minRequestAmount=20 之后就没再出现过。另外我把 fallback 设计成分级降级:先读本地 Caffeine 缓存(10 秒过期),缓存没有再返回「默认标签组」,这样熔断期间大部分用户还能看到基本正确的数据,而不是一刀切返回空。


题 7:Spring Cloud Gateway 为什么基于 WebFlux?和 Zuul 1.x 的区别是什么? ​

精简口述答案(90 秒版)

因为网关的工作特征是「大量并发 + 极少 CPU 计算 + 几乎全是等待 IO」,这正是 Reactor 这种事件驱动模型最擅长的场景。

Zuul 1.x 是阻塞式的:基于 Servlet + 同步阻塞 IO,一个请求独占一个线程直到下游返回。假设 Tomcat 配 200 个线程,下游 RT 1 秒,那网关的理论上限就是 200 QPS,第 201 个请求只能排队。下游一变慢,线程池立刻打满,网关自己先挂。

Gateway 基于 Netty + WebFlux,是非阻塞的:少量 EventLoop 线程(默认 CPU 核数)轮转处理所有连接,请求发给下游后线程立刻去处理别的请求,下游响应回来再用回调继续。所以线程数和并发数解耦了,几千并发也只用几个线程,内存和上下文切换开销都极低。

其他差异:Gateway 的核心抽象是 Route = Predicate + Filter,断言组合灵活(Path / Header / Cookie / Weight / 时间 / 自定义);原生支持 WebSocket、SSE、限流(令牌桶)、重试、熔断;而且和 Spring Boot 配置体系、Nacos 动态路由无缝整合。

Zuul 2.x 其实也改成了 Netty 异步,但 Netflix 开源节奏很慢、Spring 官方没有再做整合,所以社区事实上统一到了 Gateway。

连环追问链

追问答题要点
Gateway 里绝对不能做什么?不能有任何阻塞调用。同步 JDBC 查询、RestTemplate 调用、Thread.sleep、同步 Redis 客户端都会占住 EventLoop 线程,几个慢请求就能把整个网关打死。必须用 WebClient、Reactive Redis;非要用阻塞 API 就 publishOn(Schedulers.boundedElastic()) 切到弹性线程池
GlobalFilter 和 GatewayFilter 的区别?GlobalFilter 对所有路由生效,GatewayFilter 只对配置了它的路由生效。两者最终都被包成 GatewayFilterChain 里的元素,按 getOrder() 统一排序
过滤器的执行顺序是怎样的?洋葱模型:chain.filter(exchange) 之前的代码是「请求阶段」,按 order 升序执行;.then(Mono.fromRunnable(...)) 里的代码是「响应阶段」,按 order 降序执行。所以记录耗时、改响应头必须写在 then 里
为什么请求体只能读一次?怎么解决?Body 是 Flux<DataBuffer> 数据流,被订阅消费后就没了。解决办法是用内置的 ReadBodyPredicateFactory / cacheRequestBody 过滤器把 body 缓存到 exchange.getAttributes(),或自己用 DataBufferUtils.join 读完后重新包一个 ServerHttpRequestDecorator
鉴权过滤器应该配什么 order?要配负数(比如 -100),确保在 NettyRoutingFilter(order = Integer.MAX_VALUE - 1)和限流过滤器之前执行。鉴权不通过要直接 setComplete() 短路,不要再调 chain.filter
动态路由怎么做?监听 Nacos 配置变更,拿到路由 JSON 后调 RouteDefinitionWriter.save()/delete(),最后必须发布 RefreshRoutesEvent,否则 CachingRouteLocator 的缓存不会失效,新路由不生效

✅ 项目关联话术

我们网关出过一次「整站 502」的事故,原因特别典型:有同事在一个 GlobalFilter 里用 RestTemplate 同步调了一个权限服务来查用户角色。平时权限服务 10ms 返回,看不出问题;某天权限服务的 DB 慢查询,RT 涨到 3 秒,网关的 EventLoop 线程(只有 8 个)全被阻塞住,所有路由的所有请求全部超时,包括完全不需要鉴权的静态资源。修复后我们定了两条铁律:网关里只允许用 WebClient,并且在 CI 里加了静态扫描,一旦在 gateway 模块里发现 RestTemplate、JdbcTemplate、Thread.sleep 直接卡构建。同时把角色信息改成由鉴权服务写进 JWT,网关只验签不查库。


题 8:网关层的鉴权和限流怎么设计?和服务内部的治理怎么分工? ​

精简口述答案(90 秒版)

分工原则一句话:网关管「身份与总量」,服务管「资源与细粒度」。

鉴权放在网关是因为它是统一入口,能避免每个服务重复实现。标准做法是网关只做三件轻量事:① 白名单路径直接放行;② 从 Header 取 JWT 并本地验签(不查库、不调远程);③ 验签通过后把 userId、tenantId、roles 解析出来塞进请求头透传给下游,同时强制清除客户端自己伪造的这些头。下游服务信任这些头,但必须做**「只信任来自网关的请求」**的网络隔离(下游不对外暴露端口),否则伪造头就能越权。

限流在网关做粗粒度:按 IP / 用户 / 租户 / 路由限总量,挡住爬虫和异常流量。Gateway 内置 RequestRateLimiter 是 Redis + Lua 的令牌桶,KeyResolver 决定按什么维度限。更复杂的(比如阶梯阈值、热点参数)用 Sentinel 整合网关,它提供 route 和 API 分组 两种维度。

服务内部则用 Sentinel 做接口级 / 方法级 / 参数级的限流 + 熔断 + 降级,保护的是具体的 DB、缓存、下游依赖。

连环追问链

追问答题要点
为什么网关不要调远程鉴权服务?① 每个请求多一跳 RPC,网关 RT 直接翻倍;② 鉴权服务挂了整站不可用;③ 异步调用写不好就阻塞 EventLoop。正确做法是JWT 本地验签,公钥放本地或启动时拉一次
JWT 怎么做主动失效(登出/踢人)?JWT 本身无状态不能撤销。两种补充:① 短 token(15 分钟)+ refresh token;② 在 Redis 维护一个黑名单/版本号,网关用 Reactive Redis 查一次(key 极少、命中快)。要权衡「无状态」和「可撤销」
网关限流的 key 怎么选?匿名接口按 IP(注意取 X-Forwarded-For 第一个真实 IP,不是 getRemoteAddress);登录态接口按 userId;多租户 SaaS 按 tenantId。还要有一条全局兜底限流,防止单维度被绕过
Redis 限流脚本挂了网关要不要放行?要放行并告警(fail-open)。限流是保护手段不是业务功能,因为限流组件故障导致全站拒绝服务是本末倒置。但如果是支付这类强风控场景,可以单独配 fail-close
Sentinel 整合网关时 route 和 API 分组怎么选?route 维度是按路由 ID 限流,一个路由一条规则,配置简单;API 分组是自己定义一组 URL 匹配规则(精确/前缀/正则)聚合成一个资源,适合「多个路由共享一个阈值」或「一个路由里只限某几个 URL」
网关怎么做灰度发布?两种:① 用 Weight 断言按权重分流到不同路由;② 自定义 GlobalFilter 从 Header / 用户标签里取灰度标记,写进 ThreadLocal/Reactor Context,再由自定义的 ServiceInstanceListSupplier 按实例 metadata 过滤,实现全链路灰度

✅ 项目关联话术

我们网关的鉴权过滤器一开始是调用用户中心验 token 的,压测时发现网关 QPS 上不去,瓶颈全在那一跳。改成 JWT 本地验签后网关单机 QPS 从 1200 涨到 8000 多。但带来一个新问题:管理员把用户封禁后,用户手上的 token 还能用 30 分钟。我们的折中方案是在 Redis 维护一个 user:token:version:{userId},封禁/改密时把版本号 +1,JWT 里带签发时的版本号,网关用 Reactive Redis 做一次比对——这个 key 数量少、全在内存、RT 不到 1ms,相当于用 1ms 的成本换回了可撤销能力。另外我们还在网关强制 remove("X-User-Id") 再重新 set,防止外部伪造这个头直接越权,这个点评审时被专门提出来过。


题 9:Seata AT 模式的两阶段原理是什么?undo_log 是怎么工作的? ​

精简口述答案(90 秒版)

AT 是自动补偿型的两阶段提交,核心思路是「一阶段直接提交本地事务,二阶段靠反向 SQL 回滚」。

一阶段(业务执行时),Seata 的数据源代理拦住你的 SQL,做五件事:

  1. 解析 SQL 拿到表名和条件,执行 SELECT ... FOR UPDATE 查出修改前的数据 → 前镜像(before image);
  2. 执行业务 SQL;
  3. 再查一次 → 后镜像(after image);
  4. 把「前镜像 + 后镜像 + XID + branchId」拼成一条记录写进同库的 undo_log 表;
  5. 向 TC 注册分支并申请全局锁(锁的是主键),拿到锁后提交本地事务(业务数据和 undo_log 在同一个本地事务里,保证原子)。

二阶段由 TC 根据全局事务结果通知各分支:

  • 提交:什么都不用做(数据早就提交了),只需异步删除 undo_log,所以 AT 的提交极快;
  • 回滚:根据 undo_log 里的前镜像生成反向 SQL(INSERT 反向成 DELETE、UPDATE 反向成 UPDATE 回去、DELETE 反向成 INSERT),执行前先用后镜像和当前数据比对,一致才回滚,不一致说明被别人改过(脏写),抛异常转人工。

连环追问链

追问答题要点
一阶段就提交了,那不就破坏隔离性了吗?是的,这是 AT 的核心取舍。AT 用全局锁换隔离性:写-写通过全局锁串行化(防脏写);读-写默认是读未提交,需要防脏读得用 SELECT ... FOR UPDATE(会被 Seata 代理去拿全局锁)升级为读已提交
undo_log 为什么必须和业务表在同一个库?因为要靠本地事务的原子性保证「业务数据改了」和「undo_log 写了」要么都成功要么都失败。放不同库就又变成一个分布式事务问题了
二阶段回滚时发现数据被改了怎么办?抛 BranchTransactionException,TC 会按 client.rm.reportRetryCount 重试;一直失败就挂在 Rollbacking 状态,需要告警 + 人工介入。这就是为什么必须监控 undo_log 行数和 global_table 状态
AT 对 SQL 有什么限制?① 表必须有主键,没主键无法生成镜像;② 不支持一条 SQL 更新多表的复杂写法;③ 批量更新大量行时镜像会很大,性能和 undo_log 体积都是问题;④ 不走 JDBC 的操作(存储过程里的 DML、LOAD DATA)Seata 拦不住
@GlobalTransactional 加在哪一层?加在事务发起方(TM)的最外层入口方法上,通常是 Service 的编排方法。参与方(RM)什么注解都不用加,只要数据源被代理 + XID 传过来了就会自动注册分支
为什么说 AT 的性能损耗是 3~5 倍?一条 UPDATE 被放大成「查前镜像 + 业务 SQL + 查后镜像 + 写 undo_log」4 次 DB 交互,再加上「注册分支/申请全局锁 + 二阶段通知」至少 2 次 TC 的网络往返

✅ 项目关联话术

我们在「创建订单 + 扣优惠券 + 扣积分」这条链路上用了 AT。接入时踩的第一个坑是积分表没有主键(历史表只有联合唯一索引),Seata 直接报 Not supported for update,最后给它加了自增主键才跑通。第二个坑是参与方被 Feign 熔断降级了,fallback 返回了「成功」的默认值,TM 以为全部成功就提交了全局事务,结果积分根本没扣。修复是在所有参与方的 fallback 里显式抛异常而不是返回兜底值——分布式事务链路上的降级逻辑必须让事务失败,不能静默吞掉。另外我们做了 undo_log 行数监控,阈值设在 1000,上线半年抓到过两次回滚卡死。


题 10:AT 模式的全局锁是什么?会不会出现脏读脏写? ​

精简口述答案(90 秒版)

全局锁锁的是「表名 + 主键值」,存在 TC 的 lock_table 里,生命周期是整个全局事务(从一阶段申请到二阶段结束),比本地行锁长得多。

脏写(写-写冲突)AT 是能防住的。假设全局事务 A 改了 id=1 的余额并持有全局锁,此时全局事务 B 也想改 id=1:B 执行业务 SQL 后向 TC 申请全局锁会拿不到,它会按 lock.retryInterval(默认 10ms)重试 lock.retryTimes(默认 30 次);超时还拿不到就回滚自己的本地事务并抛 LockConflictException。所以同一行永远只有一个全局事务能改成功。

脏读(读-写)默认是防不住的,AT 的默认全局隔离级别就是「读未提交」。因为 A 在一阶段已经提交了本地事务,别的事务用普通 SELECT 立刻就能读到 A 的中间结果,而 A 后来可能回滚。

要升级到「读已提交」得主动用 SELECT ... FOR UPDATE。Seata 代理会拦住它,先拿本地行锁,再去 TC 查这一行有没有被别的全局事务锁住:有就释放本地锁、重试;没有才返回数据。所以 FOR UPDATE 在 AT 下的语义变成了「全局的读已提交」。

⚠️ 一个极易被忽略的点:只有被 Seata 代理的数据源才受全局锁约束。如果有个没接入 Seata 的服务(或者运维直接连 DB)去改同一行,全局锁对它完全无效,回滚时就会因为「后镜像和当前数据不一致」而失败。

连环追问链

追问答题要点
全局锁和本地行锁的区别?本地锁由 DB 持有、随本地事务提交就释放(AT 一阶段后就没了);全局锁由 TC 持有、跨越整个全局事务。两者是两层独立的锁
为什么 B 拿不到全局锁要回滚自己的本地事务?因为 B 此时持有本地行锁,如果它不回滚而是一直等全局锁,就会和持有全局锁的 A 的二阶段操作形成死锁(A 回滚需要本地锁,B 等全局锁不放本地锁)。所以 Seata 的策略是「让后来者退让」
LockConflictException 频繁出现说明什么?说明有热点行(比如所有订单都扣同一个商品的库存)。AT 在这种场景下会退化成串行,TPS 上不去。解决办法:拆分热点(库存分片)、换 TCC、或者用 Redis 预扣
全局锁会不会死锁?会(A 锁了 1 要 2,B 锁了 2 要 1)。Seata 没有死锁检测,靠重试超时 + 全局事务超时自动破解。所以业务代码里多个参与方的加锁顺序要尽量一致
TC 挂了,全局锁怎么办?锁记录在 lock_table(DB 里),TC 是无状态的,新 TC 节点接管后锁依然有效。但 TC 完全不可用期间,新事务申请不到锁 → 全部失败,这就是为什么 TC 要集群部署
怎么彻底避免 AT 的脏读问题?三个层次:① 关键查询加 FOR UPDATE;② 业务上接受读未提交(大多数场景可以,比如订单列表读到一个即将回滚的订单,刷新后就没了);③ 查询走「只读副本 + 状态字段过滤」,只展示已确认状态的数据

✅ 项目关联话术

我们一开始在库存服务上用 AT,压测时 LockConflictException 刷满了日志——因为压测脚本一直在抢同一个 SKU,全局锁把所有请求串行化了,TPS 只有 60。分析清楚之后做了两件事:一是把库存扣减从 AT 换成了 TCC(try 阶段扣「可用库存」并加「冻结库存」,confirm 扣真实库存,cancel 解冻),TCC 没有全局锁,TPS 直接上到 1800;二是热点 SKU 做了库存分片,一个 SKU 拆成 10 条记录随机扣。另外我们还把 lock.retryTimes 从默认 30 调到了 10,让冲突快速失败而不是长时间占着本地行锁——失败让上层重试比在底层死等更可控。


题 11:分布式事务怎么做技术选型? ​

精简口述答案(90 秒版)

我的决策顺序是先问「能不能不用」,再问「用哪种」。

第一步:消除。很多跨库写是设计问题,比如订单和订单明细本来可以放一个库、积分可以异步补、对账任务能兜住的就不需要强一致。能通过聚合边界、幂等 + 对账解决的,优先不引入分布式事务。

第二步:判断是否必须同步返回结果。

  • 不必须 → 本地消息表 / RocketMQ 事务消息。业务数据和消息在一个本地事务里落库,再由定时任务或 MQ 投递给下游。最终一致、延迟秒级、零全局锁、吞吐最高,这是我们线上用得最多的方案。
  • 必须同步 → 进第三步。

第三步:看参与方特征。

  • 参与方都是自己可控的关系型数据库、SQL 不复杂、QPS 不高(千级以下)→ AT。零侵入,加个注解就行。
  • 有外部 HTTP 接口、Redis/ES 等非关系型存储,或者是高并发热点(秒杀扣库存)→ TCC。手写 try/confirm/cancel,没有全局锁,性能最好,但要自己处理空回滚、幂等、悬挂。
  • 流程很长(> 5 个参与方)、有人工审核或等待外部回调的节点 → SAGA。用状态机编排,天然支持长事务。

连环追问链

追问答题要点
本地消息表和 RocketMQ 事务消息的区别?本地消息表要自己建表 + 写定时扫描任务,但不依赖 MQ 特性、任何 MQ 都能用;RocketMQ 事务消息用「half message + 事务回查」由 MQ 托管,代码更少,但绑定 RocketMQ。原理是同一套:先保证消息和业务数据原子落地,再保证投递最终成功
最大努力通知属于哪一类?最弱的一类最终一致,典型是支付回调:发起方按递增间隔重试 N 次,接收方必须幂等,最后还要提供对账查询接口兜底。适合跨组织(你无法要求对方实现 TCC)
为什么 2PC/XA 现在很少用?① 同步阻塞,一阶段 prepare 后资源锁住直到二阶段,持锁时间极长;② 协调者单点;③ 对数据库 XA 支持有依赖,MySQL 的 XA 在 5.7 之前有 bug;④ 性能比 AT 更差
TCC 一定比 AT 快吗?是的,TCC 没有镜像查询、没有 undo_log、没有全局锁,一阶段就是一条普通 UPDATE。但它把复杂度转移给了业务代码,开发成本高 3~5 倍,而且容易写错
SAGA 的补偿和 AT 的回滚有什么本质区别?AT 是框架自动生成反向 SQL,数据回到原样(向后恢复);SAGA 是业务自己写补偿方法,而且补偿是「语义补偿」而非「物理回滚」——比如发出去的短信撤不回,只能再发一条「订单已取消」。SAGA 还支持向前恢复(重试直到成功)
怎么跟面试官说你们的真实选型比例?诚实分层说:80% 用本地消息表/事务消息(异步能接受),15% 用 AT(内部库、QPS 不高),5% 用 TCC(库存扣减这类热点)。这个比例本身就说明你是在做权衡而不是什么都上重武器

✅ 项目关联话术

我们最初有个误区,是把「分布式事务」当成跨服务写操作的标准解法,结果给「下单 → 发优惠券 → 加积分 → 同步数仓」整条链路都套上了 @GlobalTransactional,下单接口 P99 从 200ms 涨到 900ms,而且 undo_log 表天天告警。后来我重新梳理了一遍:积分、优惠券、数仓都不需要同步成功,用户看不到也不影响下单,只要最终到账就行。于是只保留「创建订单 + 扣库存」用 AT(后来库存换成了 TCC),其余三个改成发 ORDER_CREATED 事务消息异步消费,带重试和对账任务。改完 P99 回到 220ms,AT 的参与方从 4 个降到 2 个,全局锁冲突也基本消失。这件事让我形成了一个判断习惯:先问这个操作的失败后果能不能异步修复,能的话就不要用分布式事务。


题 12:TCC 的空回滚、幂等、悬挂分别是什么?怎么解决? ​

精简口述答案(90 秒版)

这三个是 TCC 落地必答的三大坑,根源都是「网络不可靠 + 调用顺序不确定」。

空回滚:Try 请求因为网络超时或服务宕机根本没执行,但 TM 已经判定全局事务失败,于是调 Cancel。此时 Cancel 要回滚一个从没执行过的 Try,如果直接执行解冻逻辑就会把不存在的冻结额度退回去,造成数据多出来。 解决:Cancel 执行前先查「这个 XID 的 Try 有没有执行过」,没有就直接返回成功(记一条「空回滚」标记),什么都不做。

幂等:Confirm 和 Cancel 都可能被 TC 重复调用(TC 没收到响应会重试)。重复执行会重复扣减或重复退还。 解决:用事务记录表的状态字段 + 唯一索引,执行前判断状态已是终态就直接返回成功。

悬挂:Try 请求被网络延迟堵在路上,Cancel 先到并完成了空回滚,之后那个迟到的 Try 才执行,于是冻结的资源再也没有人来 Confirm 或 Cancel,永久悬挂。 解决:Try 执行前先查「这个 XID 有没有已经被 Cancel 过」,有就直接拒绝执行。

三个问题可以用一张事务控制表统一解决:tcc_tx_record(xid, branch_id, action_name, status, create_time),(xid, branch_id) 建唯一索引,status 取值 TRIED / CONFIRMED / CANCELLED / EMPTY_CANCELLED。Try / Confirm / Cancel 三个方法进来第一件事都是查这张表判断状态,而且插入记录的操作要和业务操作放在同一个本地事务里。

连环追问链

追问答题要点
为什么不用 Redis 存事务记录?必须和业务操作在同一个本地事务里才能保证原子。Redis 和 DB 没法做本地事务,会出现「业务改了但记录没写」或反之,三个问题就都防不住了
事务记录表要不要定期清理?要。终态(CONFIRMED / CANCELLED / EMPTY_CANCELLED)且创建时间超过「全局事务超时 + 安全余量」(比如 7 天)的记录可以归档删除,否则表会无限膨胀
悬挂判断为什么不能只靠「没查到 Try 记录」?因为空回滚时必须插入一条 EMPTY_CANCELLED 记录,Try 后到时才能查到这个标记并拒绝。如果空回滚什么都不写,Try 查不到任何记录就会以为自己是正常第一次执行
Confirm 失败了一直重试不成功怎么办?TCC 的 Confirm 必须设计成最终能成功(只做简单状态流转,不做复杂校验)。真失败了 TC 会按 maxCommitRetryTimeout 重试,超时后进入「需人工干预」状态并告警,绝不能自动转回滚
TCC 的 Try 里能不能查库校验库存够不够?能且必须。Try 就是「预留资源 + 业务校验」阶段,校验失败就抛异常让全局事务回滚。Confirm 阶段不要再做校验,因为 Confirm 必须成功
Seata 的 @TwoPhaseBusinessAction 怎么拿到 XID?两种:① RootContext.getXID();② Confirm/Cancel 方法参数里注入 BusinessActionContext,调 context.getXid(),还能用 @BusinessActionContextParameter 把 Try 阶段的参数自动传递过来,这是官方推荐的做法

✅ 项目关联话术

我们把库存扣减从 AT 换成 TCC 的时候,这三个问题全撞上了。最先暴露的是幂等:压测后发现有少量 SKU 的冻结库存变成了负数,查日志是 Confirm 被 TC 重试了两次。补上状态判断之后又出现了悬挂:极少量 SKU 的冻结库存永远不归零,对账时才发现。最后统一用一张 tcc_tx_record 表解决,(xid, branch_id) 做唯一索引,三个方法开头都先查状态——Try 查有没有被 Cancel 过、Confirm/Cancel 查是不是终态、Cancel 查 Try 有没有执行过。另外我们还加了一个每日对账任务,比对 冻结库存总和 和 tcc_tx_record 里 TRIED 状态记录的总和,不一致就告警,相当于给 TCC 加了一层数据正确性的兜底验证。


✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】 ​

题 1:实现 Sentinel 统一降级体系(全局 BlockException 处理 + 规则持久化 + 分级降级) ​

核心实现

java
// ========== 1. 全局 BlockException 处理器(Web 入口统一兜底) ==========
// 作用:所有被 Sentinel 拦截的 HTTP 请求(限流/熔断/热点/系统/授权)
//      都走到这里,返回统一的 JSON 结构,而不是默认的 "Blocked by Sentinel"
@Slf4j
@Component
public class GlobalBlockExceptionHandler implements BlockExceptionHandler {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       BlockException e) throws Exception {

        // 按异常子类区分原因,便于前端做不同提示、便于监控分类统计
        BlockReason reason = BlockReason.of(e);

        log.warn("[Sentinel] 请求被拦截 uri={} rule={} reason={} ruleLimitApp={}",
                request.getRequestURI(),
                e.getRule() == null ? "-" : e.getRule().toString(),
                reason.name(),
                e.getRuleLimitApp());

        // 打点:按「资源 + 拦截类型」上报,用于区分"被限流"还是"下游挂了"
        Metrics.counter("sentinel.block",
                "uri", request.getRequestURI(),
                "reason", reason.name()).increment();

        response.setStatus(reason.getHttpStatus());
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        // Retry-After 告诉客户端多久后可以重试,配合前端做退避
        response.setHeader("Retry-After", String.valueOf(reason.getRetryAfterSeconds()));

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("code", reason.getBizCode());
        body.put("message", reason.getUserMessage());
        body.put("reason", reason.name());
        body.put("timestamp", System.currentTimeMillis());
        MAPPER.writeValue(response.getWriter(), body);
    }

    /** 把 Sentinel 的异常子类映射成业务可理解的拦截原因 */
    @Getter
    @AllArgsConstructor
    enum BlockReason {
        FLOW(429, 42901, "当前访问人数过多,请稍后再试", 1),
        DEGRADE(503, 50301, "服务暂时不可用,请稍后再试", 10),
        PARAM_FLOW(429, 42902, "操作过于频繁,请稍后再试", 5),
        SYSTEM(503, 50302, "系统繁忙,已启动保护", 5),
        AUTHORITY(403, 40301, "无权访问该资源", 0),
        UNKNOWN(503, 50300, "服务繁忙,请稍后再试", 5);

        private final int httpStatus;
        private final int bizCode;
        private final String userMessage;
        private final int retryAfterSeconds;

        static BlockReason of(BlockException e) {
            if (e instanceof FlowException) {
                return FLOW;
            }
            if (e instanceof DegradeException) {
                return DEGRADE;
            }
            if (e instanceof ParamFlowException) {
                return PARAM_FLOW;
            }
            if (e instanceof SystemBlockException) {
                return SYSTEM;
            }
            if (e instanceof AuthorityException) {
                return AUTHORITY;
            }
            return UNKNOWN;
        }
    }
}
java
// ========== 2. 分级降级:@SentinelResource + 多级 fallback ==========
// 设计原则:fallback 必须"快且无依赖",绝不能在 fallback 里再调远程服务
@Slf4j
@Service
@RequiredArgsConstructor
public class UserTagService {

    private final UserTagFeignClient feignClient;
    /** 本地缓存作为一级兜底,10 秒过期,容量有限防 OOM */
    private final Cache<Long, List<String>> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofSeconds(10))
            .build();

    /**
     * blockHandler:处理 BlockException(被限流/熔断/系统保护拦住)
     * fallback    :处理业务异常(下游 500、超时抛出的 RuntimeException)
     * exceptionsToIgnore:参数校验类异常不应计入熔断统计,否则会被自己的脏数据熔断
     */
    @SentinelResource(
            value = "user:tag:query",
            blockHandler = "queryTagsBlockHandler",
            blockHandlerClass = UserTagService.class,
            fallback = "queryTagsFallback",
            exceptionsToIgnore = {IllegalArgumentException.class}
    )
    public List<String> queryTags(Long userId) {
        Assert.notNull(userId, "userId 不能为空");   // 走 exceptionsToIgnore,不计入熔断
        List<String> tags = feignClient.queryTags(userId);
        localCache.put(userId, tags);                // 成功时回填缓存,供降级时使用
        return tags;
    }

    /** blockHandler 必须是 static(因为指定了 blockHandlerClass),签名 = 原方法参数 + BlockException */
    public static List<String> queryTagsBlockHandler(Long userId, BlockException e) {
        log.warn("[降级] 用户标签查询被 Sentinel 拦截 userId={} type={}",
                userId, e.getClass().getSimpleName());
        return Collections.emptyList();              // 被限流时返回空,前端隐藏标签区
    }

    /** fallback 在同类中可以是实例方法,签名 = 原方法参数 + Throwable(可选) */
    public List<String> queryTagsFallback(Long userId, Throwable t) {
        // 一级降级:读本地缓存(可能是 10 秒内的旧数据,业务可接受)
        List<String> cached = localCache.getIfPresent(userId);
        if (cached != null) {
            log.warn("[降级] 命中本地缓存 userId={} cause={}", userId, t.toString());
            return cached;
        }
        // 二级降级:返回默认标签组,保证页面结构不塌
        log.error("[降级] 本地缓存未命中,返回默认标签 userId={}", userId, t);
        return List.of("NORMAL");
    }
}
java
// ========== 3. Feign 场景的 fallbackFactory(能拿到异常原因) ==========
// 关键:分布式事务链路上的 fallback 必须抛异常,不能返回"假成功"
@Slf4j
@Component
public class PointFeignFallbackFactory implements FallbackFactory<PointFeignClient> {

    @Override
    public PointFeignClient create(Throwable cause) {
        return new PointFeignClient() {

            /** 查询类:可以安全降级,返回兜底值 */
            @Override
            public Integer queryPoint(Long userId) {
                log.warn("[Feign 降级] 查询积分失败 userId={} cause={}", userId, cause.toString());
                return 0;
            }

            /**
             * 写入类(事务参与方):绝不能返回成功!
             * 否则 TM 会认为全部成功而提交全局事务,导致数据不一致
             */
            @Override
            public void deductPoint(DeductPointReq req) {
                log.error("[Feign 降级] 扣减积分失败,主动抛异常让全局事务回滚 req={}", req, cause);
                throw new BizException("POINT_UNAVAILABLE", "积分服务不可用", cause);
            }
        };
    }
}
yaml
# ========== 4. 规则持久化(push 模式):控制台改 → 写 Nacos → Nacos 推客户端 ==========
spring:
  cloud:
    sentinel:
      eager: true                        # 启动即连 Dashboard,不等第一次调用
      transport:
        dashboard: sentinel.inner:8080
        port: 8719                       # 客户端监听端口,被占用会自动 +1
      datasource:
        flow:
          nacos:
            server-addr: nacos.inner:8848
            namespace: ${NACOS_NS_ID}    # 填命名空间 ID(UUID),不是名字
            group-id: SENTINEL_GROUP
            data-id: ${spring.application.name}-flow-rules
            rule-type: flow
            data-type: json
        degrade:
          nacos:
            server-addr: nacos.inner:8848
            namespace: ${NACOS_NS_ID}
            group-id: SENTINEL_GROUP
            data-id: ${spring.application.name}-degrade-rules
            rule-type: degrade
        param-flow:
          nacos:
            server-addr: nacos.inner:8848
            namespace: ${NACOS_NS_ID}
            group-id: SENTINEL_GROUP
            data-id: ${spring.application.name}-param-rules
            rule-type: param-flow
feign:
  sentinel:
    enabled: true                        # ★ 不写这一行,Feign 的降级就是死的
java
// ========== 5. 非 Web 入口(MQ 消费、定时任务)的手动埋点 ==========
// SphU.entry 的资源不会自动统计业务异常,必须手动 Tracer.trace
public void consume(OrderMessage msg) {
    Entry entry = null;
    try {
        entry = SphU.entry("mq:order:consume");
        doConsume(msg);
    } catch (BlockException e) {
        // 被限流:MQ 场景下不要丢消息,抛出重试异常让 Broker 稍后重投
        log.warn("[Sentinel] MQ 消费被限流,稍后重试 msgId={}", msg.getMsgId());
        throw new RetryLaterException();
    } catch (Exception bizEx) {
        // ★ 关键:业务异常必须手动上报,否则异常比例永远是 0,熔断规则形同虚设
        Tracer.traceEntry(bizEx, entry);
        throw bizEx;
    } finally {
        if (entry != null) {
            entry.exit();                // 必须在 finally 里 exit,否则调用链泄漏
        }
    }
}

边界 Case 自测清单

#Case期望行为
1接口被流控规则拦截返回 429 + code=42901,响应头带 Retry-After: 1
2下游熔断器处于 OPEN返回 503 + code=50301,Retry-After: 10,不穿透到下游
3传入 userId=null抛 IllegalArgumentException,不计入熔断统计,不触发误熔断
4下游超时抛异常且本地缓存有值走 fallback,返回 10 秒内的旧标签,日志打 WARN
5下游超时且本地缓存已过期返回默认标签 ["NORMAL"],日志打 ERROR 并告警
6应用重启规则从 Nacos 自动拉回,不会丢失(验证 push 模式配置生效)
7不写 feign.sentinel.enabledFeign 的 fallback 完全不触发,异常直接抛给上层(必须回归验证)
8事务参与方 deductPoint 被降级fallback 抛异常,全局事务回滚,绝不能返回 void 假成功
9MQ 消费抛业务异常Tracer.traceEntry 上报,Dashboard 的异常数正确递增
10SphU.entry 后忘记 exit()调用链 Context 泄漏,ContextUtil 线程数暴涨,必须在 finally 中 exit

🚀 变种拓展题

  1. 把 BlockExceptionHandler 改造成支持按资源配置不同降级文案(文案配在 Nacos,支持热更新)。
  2. 实现一个熔断器状态变更监听器(EventObserverRegistry.getInstance().addStateChangeObserver),熔断器 OPEN 时自动发企业微信告警并记录到事件表。
  3. 把本地 Caffeine 兜底升级为二级缓存(Caffeine + Redis),要求 Redis 不可用时自动退化为纯本地。
  4. 实现集群流控:搭建 Token Server,并配置 fallbackToLocalWhenFail=true,验证 Token Server 挂掉后退化为单机限流。
  5. 给 @SentinelResource 写一个注解增强切面,自动按「类名:方法名」生成资源名并注册默认规则,避免每个方法手写 value。

题 2:实现 Gateway 全局鉴权 + Redis 令牌桶限流 + 动态路由 ​

核心实现

java
// ========== 1. 全局鉴权过滤器(JWT 本地验签 + 身份透传 + 防伪造头) ==========
// 铁律:网关里绝对不能有阻塞调用,只允许 WebClient / Reactive Redis
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    private final JwtVerifier jwtVerifier;                 // 本地公钥验签,不查库
    private final ReactiveStringRedisTemplate redis;        // 响应式 Redis,非阻塞
    private final AuthProperties props;                     // 白名单路径,Nacos 热更新

    /** 透传给下游的身份头,必须先清除客户端伪造的同名头 */
    private static final List<String> IDENTITY_HEADERS =
            List.of("X-User-Id", "X-Tenant-Id", "X-User-Roles");

    @Override
    public int getOrder() {
        // 负数,确保在限流过滤器和 NettyRoutingFilter 之前执行
        return -100;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();

        // ① 白名单直接放行(但依然要清掉伪造的身份头)
        if (props.isWhiteList(path)) {
            return chain.filter(exchange.mutate().request(stripIdentity(request)).build());
        }

        // ② 取 token
        String token = resolveToken(request);
        if (!StringUtils.hasText(token)) {
            return reject(exchange, 401, 40101, "未登录");
        }

        // ③ 本地验签(CPU 操作,微秒级,不阻塞 EventLoop)
        JwtPayload payload;
        try {
            payload = jwtVerifier.verify(token);
        } catch (JwtExpiredException e) {
            return reject(exchange, 401, 40102, "登录已过期");
        } catch (Exception e) {
            log.warn("[网关] token 验签失败 path={} msg={}", path, e.getMessage());
            return reject(exchange, 401, 40103, "身份凭证无效");
        }

        // ④ 版本号校验:解决 JWT 无法主动失效的问题(key 极少、命中快、RT < 1ms)
        String versionKey = "auth:ver:" + payload.getUserId();
        return redis.opsForValue().get(versionKey)
                // Redis 查不到视为未被踢下线(fail-open:鉴权组件故障不该让全站不可用)
                .defaultIfEmpty(String.valueOf(payload.getVersion()))
                .onErrorResume(e -> {
                    log.error("[网关] 查询 token 版本失败,降级放行 userId={}", payload.getUserId(), e);
                    return Mono.just(String.valueOf(payload.getVersion()));
                })
                .flatMap(currentVer -> {
                    if (!currentVer.equals(String.valueOf(payload.getVersion()))) {
                        return reject(exchange, 401, 40104, "账号状态已变更,请重新登录");
                    }
                    // ⑤ 身份透传:先 remove 再 set,防止外部伪造 X-User-Id 越权
                    ServerHttpRequest mutated = request.mutate()
                            .headers(h -> {
                                IDENTITY_HEADERS.forEach(h::remove);
                                h.set("X-User-Id", String.valueOf(payload.getUserId()));
                                h.set("X-Tenant-Id", payload.getTenantId());
                                h.set("X-User-Roles", String.join(",", payload.getRoles()));
                            })
                            .build();
                    return chain.filter(exchange.mutate().request(mutated).build());
                });
    }

    private ServerHttpRequest stripIdentity(ServerHttpRequest request) {
        return request.mutate().headers(h -> IDENTITY_HEADERS.forEach(h::remove)).build();
    }

    private String resolveToken(ServerHttpRequest request) {
        String auth = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
        return (auth != null && auth.startsWith("Bearer ")) ? auth.substring(7) : null;
    }

    /** 短路返回:setComplete 后不再调 chain.filter */
    private Mono<Void> reject(ServerWebExchange exchange, int status, int code, String msg) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.valueOf(status));
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = "{\"code\":" + code + ",\"message\":\"" + msg + "\"}";
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer));
    }
}
java
// ========== 2. 限流 KeyResolver:登录态按 userId,匿名按真实 IP ==========
@Configuration
public class RateLimitConfig {

    /** 优先按用户维度限流(账号获取成本远高于 IP,防刷更有效) */
    @Bean
    @Primary
    public KeyResolver userKeyResolver() {
        return exchange -> {
            String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
            if (StringUtils.hasText(userId)) {
                return Mono.just("u:" + userId);
            }
            return Mono.just("ip:" + realIp(exchange));
        };
    }

    @Bean
    public KeyResolver apiKeyResolver() {
        // 路由级总量兜底:防止单维度被绕过
        return exchange -> Mono.just("api:" + exchange.getRequest().getPath().value());
    }

    /**
     * ★ 必须取 X-Forwarded-For 的第一个地址,
     *   直接用 getRemoteAddress 拿到的是 Nginx/SLB 的 IP,所有人会共用一个限流 key
     */
    private String realIp(ServerWebExchange exchange) {
        String xff = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
        if (StringUtils.hasText(xff)) {
            return xff.split(",")[0].trim();
        }
        InetSocketAddress addr = exchange.getRequest().getRemoteAddress();
        return addr == null ? "unknown" : addr.getAddress().getHostAddress();
    }

    /** 内置 RequestRateLimiter 底层就是 Redis + Lua 的令牌桶,Lua 保证原子 */
    @Bean
    public RedisRateLimiter redisRateLimiter() {
        // replenishRate=20(每秒补 20 个令牌), burstCapacity=40(允许 2 倍突发)
        // requestedTokens=1(每个请求消耗 1 个令牌)
        return new RedisRateLimiter(20, 40, 1);
    }
}
java
// ========== 3. 自定义滑动窗口限流(需要精确计数时用,Lua 保证原子) ==========
@Component
@RequiredArgsConstructor
public class SlidingWindowRateLimitFilter implements GlobalFilter, Ordered {

    private final ReactiveStringRedisTemplate redis;

    /**
     * ZSET 实现滑动窗口:
     *   1. 移除窗口外的旧记录
     *   2. 统计窗口内数量
     *   3. 未超限则写入当前请求
     * 三步必须在一个 Lua 里,否则高并发下会超发
     */
    private static final RedisScript<Long> SCRIPT = RedisScript.of("""
            local key    = KEYS[1]
            local now    = tonumber(ARGV[1])
            local window = tonumber(ARGV[2])
            local limit  = tonumber(ARGV[3])
            redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
            local count = redis.call('ZCARD', key)
            if count >= limit then
              return 0
            end
            redis.call('ZADD', key, now, now .. '-' .. math.random(100000))
            redis.call('PEXPIRE', key, window)
            return 1
            """, Long.class);

    @Override
    public int getOrder() {
        return -50;   // 鉴权之后、路由之前
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String key = "rl:" + exchange.getRequest().getPath().value();
        return redis.execute(SCRIPT, List.of(key),
                        List.of(String.valueOf(System.currentTimeMillis()), "1000", "100"))
                .next()
                // ★ fail-open:限流组件故障时放行并告警,而不是让全站拒绝服务
                .onErrorResume(e -> Mono.just(1L))
                .flatMap(pass -> pass == 1L
                        ? chain.filter(exchange)
                        : tooManyRequests(exchange));
    }

    private Mono<Void> tooManyRequests(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
        exchange.getResponse().getHeaders().set("Retry-After", "1");
        return exchange.getResponse().setComplete();
    }
}
java
// ========== 4. 动态路由:监听 Nacos 配置变更,热更新路由表 ==========
@Slf4j
@Component
@RequiredArgsConstructor
public class NacosDynamicRouteService implements ApplicationEventPublisherAware {

    private final RouteDefinitionWriter routeWriter;
    private final NacosConfigManager nacosConfigManager;
    private final ObjectMapper mapper = new ObjectMapper();
    private ApplicationEventPublisher publisher;
    /** 记录当前由动态配置管理的路由 ID,用于全量替换时精准删除 */
    private final Set<String> managedRouteIds = ConcurrentHashMap.newKeySet();

    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    @PostConstruct
    public void init() throws NacosException {
        ConfigService configService = nacosConfigManager.getConfigService();
        String dataId = "gateway-routes.json";
        String group = "GATEWAY_GROUP";

        // 启动时先全量加载一次
        refresh(configService.getConfig(dataId, group, 5000));

        // 再注册监听器,后续变更实时生效
        configService.addListener(dataId, group, new Listener() {
            @Override
            public Executor getExecutor() {
                return null;   // 用 Nacos 内部线程池即可
            }

            @Override
            public void receiveConfigInfo(String configInfo) {
                refresh(configInfo);
            }
        });
    }

    private synchronized void refresh(String json) {
        if (!StringUtils.hasText(json)) {
            return;
        }
        try {
            List<RouteDefinition> routes = mapper.readValue(
                    json, new TypeReference<List<RouteDefinition>>() {});

            // ① 删除上一轮管理的路由(只删自己管的,不碰 yml 里静态配置的)
            managedRouteIds.forEach(id ->
                    routeWriter.delete(Mono.just(id))
                            .onErrorResume(e -> Mono.empty()).subscribe());
            managedRouteIds.clear();

            // ② 写入新路由
            routes.forEach(r -> {
                routeWriter.save(Mono.just(r)).subscribe();
                managedRouteIds.add(r.getId());
            });

            // ③ ★ 必须发布事件,否则 CachingRouteLocator 缓存不失效,新路由不生效
            publisher.publishEvent(new RefreshRoutesEvent(this));
            log.info("[网关] 动态路由刷新完成,共 {} 条", routes.size());
        } catch (Exception e) {
            // 解析失败绝不能清空路由表,否则整站 404
            log.error("[网关] 动态路由解析失败,保留原路由表", e);
        }
    }
}
yaml
# ========== 5. 关键配置(含 CORS 去重和超时) ==========
spring:
  cloud:
    gateway:
      httpclient:
        connect-timeout: 2000
        response-timeout: 5s          # 要比下游 Feign 超时略长,否则网关先断
        pool:
          type: elastic
          max-connections: 500
      default-filters:
        # 网关和下游都配了 CORS 时会出重复头,浏览器直接报错,必须去重
        - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST
      routes:
        - id: order-service
          uri: lb://order-service       # ★ 必须是 lb:// 才会走负载均衡
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=1             # /api/order/create → /order/create(数准确层数)
            - name: RequestRateLimiter
              args:
                key-resolver: "#{@userKeyResolver}"
                redis-rate-limiter.replenishRate: 20
                redis-rate-limiter.burstCapacity: 40
            - name: CircuitBreaker
              args:
                name: orderCb
                fallbackUri: forward:/fallback/order

边界 Case 自测清单

#Case期望行为
1请求白名单路径 /api/auth/login放行,但客户端传的 X-User-Id 被清除
2客户端伪造 X-User-Id: 1 访问需鉴权接口验签失败返回 401;验签成功时该头被覆盖为真实用户
3token 已过期返回 401 + code=40102,不穿透到下游
4管理员封禁用户后,用户用旧 token 请求Redis 版本号不匹配,返回 401 + code=40104
5Redis 不可用鉴权降级放行并告警,限流fail-open 放行,不让组件故障拖垮全站
61 秒内同一用户发 150 个请求(阈值 100)前 100 个通过,其余返回 429 + Retry-After: 1
7Nacos 推下来一份非法 JSON 路由解析异常被捕获,保留原路由表,不出现整站 404
8新增路由后不发 RefreshRoutesEvent路由不生效(必须验证这一步,是最常见的漏写点)
9StripPrefix 配成 2下游收到的路径少一层,返回 404(提醒按真实前缀层数配)
10过滤器里误用 RestTemplate 同步调用EventLoop 被阻塞,压测时 QPS 断崖下跌,CI 静态扫描应直接拦截

🚀 变种拓展题

  1. 实现全链路灰度:从 Header 取灰度标记写入 Reactor Context,并自定义 ServiceInstanceListSupplier 按实例 metadata 过滤。
  2. 实现请求/响应日志过滤器:用 cacheRequestBody 读取 body,响应阶段用 ServerHttpResponseDecorator 包装,记录完整报文并脱敏。
  3. 把限流改造成阶梯限流:普通用户 10 QPS、VIP 50 QPS、内部调用不限,阈值配在 Nacos 支持热更新。
  4. 整合 Sentinel 网关流控,用 API 分组维度给「多个路由共享一个阈值」,并自定义 BlockRequestHandler。
  5. 实现签名验签过滤器(开放平台场景):校验 appId + timestamp + nonce + sign,要求 nonce 用 Redis 防重放。

题 3:实现 Feign 全链路上下文透传(用户身份 + traceId + 线程池 + XID) ​

题目描述:网关校验完 JWT 后把 userId 放进 Header,要求:① 下游服务自动解析成当前登录人;② A 服务调 B 服务时 userId / traceId / 灰度标记 自动带上;③ A 服务内部用线程池异步时上下文不丢;④ Seata 的 XID 在异步线程里也能传下去,保证全局事务不断链。

核心实现

第一步:定义统一上下文容器。不要给每个字段都写一个 ThreadLocal,而是封装成一个对象,并且用 TransmittableThreadLocal(TTL)打底,为后面的线程池透传留好接口。

java
/**
 * 全链路上下文:一次请求内任意位置可读
 * 用 TransmittableThreadLocal 而不是 ThreadLocal/InheritableThreadLocal:
 *  - ThreadLocal:子线程完全读不到
 *  - InheritableThreadLocal:只在"创建线程"时拷贝一次,线程池复用时拿到的是旧值(脏数据)
 *  - TTL:在"提交任务"时拷贝,线程池场景也正确
 */
public class RpcContextHolder {

    private static final TransmittableThreadLocal<RpcContext> CTX =
            new TransmittableThreadLocal<>();

    public static void set(RpcContext ctx) {
        CTX.set(ctx);
    }

    public static RpcContext get() {
        RpcContext ctx = CTX.get();
        return ctx == null ? RpcContext.anonymous() : ctx;
    }

    /** 必须在 finally 里调用,否则 Tomcat 线程复用会把上一个请求的身份带给下一个人 */
    public static void clear() {
        CTX.remove();
    }

    @Data
    @Builder
    public static class RpcContext {
        private Long userId;
        private String tenantId;
        /** 全链路追踪 ID,贯穿网关 → A → B → MQ */
        private String traceId;
        /** 灰度标记:gray / base */
        private String grayTag;
        /** 调用来源服务名,用于排查「谁调了我」 */
        private String fromApp;

        public static RpcContext anonymous() {
            return RpcContext.builder().userId(0L).traceId("-").grayTag("base").build();
        }
    }
}

第二步:入口拦截器,把 Header 解析成上下文。注意这里用 HandlerInterceptor 而不是 Filter,是为了拿到 handler 做注解级开关;但 clear() 必须放在 afterCompletion,异步 Servlet 场景则要放到 AsyncHandlerInterceptor#afterConcurrentHandlingStarted 之外处理。

java
@Component
public class RpcContextInterceptor implements HandlerInterceptor {

    public static final String H_USER_ID   = "X-User-Id";
    public static final String H_TENANT_ID = "X-Tenant-Id";
    public static final String H_TRACE_ID  = "X-Trace-Id";
    public static final String H_GRAY_TAG  = "X-Gray-Tag";
    public static final String H_FROM_APP  = "X-From-App";

    @Value("${spring.application.name}")
    private String appName;

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
        String traceId = req.getHeader(H_TRACE_ID);
        if (!StringUtils.hasText(traceId)) {
            // 链路入口(没人传 traceId)时自己生成,保证任何请求都可追
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        RpcContextHolder.set(RpcContextHolder.RpcContext.builder()
                .userId(parseLong(req.getHeader(H_USER_ID)))
                .tenantId(req.getHeader(H_TENANT_ID))
                .traceId(traceId)
                .grayTag(Optional.ofNullable(req.getHeader(H_GRAY_TAG)).orElse("base"))
                .fromApp(Optional.ofNullable(req.getHeader(H_FROM_APP)).orElse("unknown"))
                .build());
        // 写进 MDC,日志模板里配 %X{traceId} 就能自动打出来
        MDC.put("traceId", traceId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
                                Object handler, Exception ex) {
        // 两个 clear 都不能漏:ThreadLocal 泄漏 = 身份串号 + 内存泄漏
        RpcContextHolder.clear();
        MDC.clear();
    }

    private Long parseLong(String v) {
        try {
            return StringUtils.hasText(v) ? Long.parseLong(v) : 0L;
        } catch (NumberFormatException e) {
            return 0L;
        }
    }
}

第三步:Feign RequestInterceptor,把上下文重新写回下游请求头。这是「透传」真正发生的地方。

java
@Configuration
public class FeignContextConfig {

    @Bean
    public RequestInterceptor rpcContextInterceptor(
            @Value("${spring.application.name}") String appName) {
        return template -> {
            RpcContextHolder.RpcContext ctx = RpcContextHolder.get();

            // 1. 身份与链路:下游拿到后由 RpcContextInterceptor 继续解析,形成接力
            put(template, RpcContextInterceptor.H_USER_ID, String.valueOf(ctx.getUserId()));
            put(template, RpcContextInterceptor.H_TENANT_ID, ctx.getTenantId());
            put(template, RpcContextInterceptor.H_TRACE_ID, ctx.getTraceId());
            put(template, RpcContextInterceptor.H_GRAY_TAG, ctx.getGrayTag());

            // 2. 标记调用方是自己(而不是把上游的 fromApp 原样传下去)
            template.header(RpcContextInterceptor.H_FROM_APP, appName);

            // 3. 内部调用标记:下游网关/鉴权可据此跳过 JWT 校验
            template.header("X-Internal-Call", "1");
        };
    }

    /** 先 remove 再 header:Feign 的 header 是 append 语义,重复调用会变成 a,b 两个值 */
    private void put(RequestTemplate template, String name, String value) {
        if (!StringUtils.hasText(value)) {
            return;
        }
        template.header(name);           // 传空数组 = 清空该 header
        template.header(name, value);
    }
}

⚠️ RequestTemplate#header(name, value) 是追加而不是覆盖。如果同时存在「全局 RequestInterceptor」和「接口上 @RequestHeader 参数」,下游会收到 X-User-Id: 1001,1001,Long.parseLong 直接炸。所以统一走 put() 先清空。

第四步:线程池透传。Spring 的 TaskDecorator 是最干净的切点,它在「提交任务」时抓快照、在「执行任务」时恢复。XID 也在这里一起带上。

java
public class ContextTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // ===== 提交线程(父线程):抓快照 =====
        RpcContextHolder.RpcContext ctx = RpcContextHolder.get();
        Map<String, String> mdc = MDC.getCopyOfContextMap();
        // Seata 的全局事务 ID,不传则子线程里的 Feign 调用不在同一个全局事务内
        String xid = RootContext.getXID();

        return () -> {
            // ===== 执行线程(子线程):恢复 =====
            try {
                RpcContextHolder.set(ctx);
                if (mdc != null) {
                    MDC.setContextMap(mdc);
                }
                if (StringUtils.hasText(xid)) {
                    RootContext.bind(xid);
                }
                runnable.run();
            } finally {
                // 顺序无所谓,但一个都不能少,否则线程池复用后污染下一个任务
                if (StringUtils.hasText(xid)) {
                    RootContext.unbind();
                }
                MDC.clear();
                RpcContextHolder.clear();
            }
        };
    }
}
java
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("bizExecutor")
    public ThreadPoolTaskExecutor bizExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(16);
        executor.setMaxPoolSize(32);
        executor.setQueueCapacity(500);
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("biz-");
        // 关键一行:所有提交到该池的任务都会被包装
        executor.setTaskDecorator(new ContextTaskDecorator());
        // 拒绝策略用 CallerRuns:由调用线程执行,天然就有上下文,且能反压
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

面试加分表述:TaskDecorator 只能管住走 Spring 线程池的任务。如果代码里有 CompletableFuture.supplyAsync(...) 不指定 Executor,它会跑在 ForkJoinPool.commonPool() 上,完全绕过装饰器。所以生产上要么禁止裸用 commonPool,要么直接上 TTL 的 TtlExecutors.getTtlExecutorService() / -javaagent 方式做无侵入增强。

第五步:MQ 场景的透传。HTTP 有 Header,MQ 也有「消息属性」,思路完全一致:生产端塞进去、消费端取出来。

java
/** 生产端:发消息时把上下文写进消息属性 */
@Component
@RequiredArgsConstructor
public class ContextAwareMqProducer {

    private final RocketMQTemplate rocketMQTemplate;

    public void send(String topic, Object payload) {
        RpcContextHolder.RpcContext ctx = RpcContextHolder.get();
        Message<Object> message = MessageBuilder.withPayload(payload)
                .setHeader("X-User-Id", String.valueOf(ctx.getUserId()))
                .setHeader("X-Trace-Id", ctx.getTraceId())
                .setHeader("X-Gray-Tag", ctx.getGrayTag())
                // 注意:XID 绝不能传给 MQ 消费端
                // MQ 是异步的,消费时全局事务早已提交/回滚,绑定旧 XID 会导致分支注册失败
                .build();
        rocketMQTemplate.syncSend(topic, message);
    }
}
java
/** 消费端:从消息属性恢复上下文,消费完清理 */
@Component
@RocketMQMessageListener(topic = "order_paid", consumerGroup = "point-consumer")
public class OrderPaidListener implements RocketMQListener<MessageExt> {

    @Override
    public void onMessage(MessageExt msg) {
        String traceId = Optional.ofNullable(msg.getUserProperty("X-Trace-Id"))
                .orElse(UUID.randomUUID().toString().replace("-", ""));
        try {
            RpcContextHolder.set(RpcContextHolder.RpcContext.builder()
                    .userId(Long.parseLong(
                            Optional.ofNullable(msg.getUserProperty("X-User-Id")).orElse("0")))
                    .traceId(traceId)
                    .grayTag(Optional.ofNullable(msg.getUserProperty("X-Gray-Tag")).orElse("base"))
                    .fromApp("mq")
                    .build());
            MDC.put("traceId", traceId);

            // 消费侧要开一个「新的」全局事务,而不是续上生产侧的
            handle(msg);
        } finally {
            MDC.clear();
            RpcContextHolder.clear();
        }
    }

    @GlobalTransactional(name = "mq-order-paid", rollbackFor = Exception.class)
    public void handle(MessageExt msg) {
        // 业务处理……
    }
}

最后给出整条链路的透传视图,面试时可以直接按这张图讲:

text
                 Header: X-User-Id / X-Trace-Id / X-Gray-Tag
Gateway ──────────────────────────────────────────────► Service A
 (验 JWT                                                 │
  写 Header)                     RpcContextInterceptor.preHandle
                                  └─► TTL 里存 RpcContext + MDC
                                          │
                        ┌─────────────────┼─────────────────┐
                        │                 │                 │
               同步 Feign 调用      异步线程池任务        发 MQ 消息
                        │                 │                 │
          RequestInterceptor    ContextTaskDecorator   消息属性 Header
           写回 Header + XID      快照/恢复 ctx+MDC+XID    (不含 XID)
                        │                 │                 │
                        ▼                 ▼                 ▼
                   Service B        biz-* 线程内        Consumer 侧
                 (继续接力)      Feign 仍带 XID      新开全局事务
                        │
              afterCompletion → clear():防止 Tomcat 线程复用串号

边界 Case 自测清单

#Case期望行为
1请求没带 X-Trace-Id入口自动生成一个 UUID,不能让 traceId 为 null 导致日志无法串联
2请求带的 X-User-Id 是非数字(被人乱改)parseLong 捕获异常降级为 0,按未登录处理,不能抛 500
3全局 RequestInterceptor 与接口上的 @RequestHeader("X-User-Id") 同时存在下游只收到一个值,不能出现 1001,1001 的逗号拼接
4业务用 @Async("bizExecutor") 异步发 Feign子线程里 RpcContextHolder.get() 能拿到 userId,Feign 请求头正常带出
5业务用 CompletableFuture.supplyAsync() 不传 Executor上下文丢失(已知限制),必须在规范里禁掉或改用 TtlExecutors 包装
6线程池任务执行完RootContext.unbind() + MDC.clear() + RpcContextHolder.clear() 都执行,下一个任务拿不到残留值
7请求处理抛异常afterCompletion 仍被调用完成清理,不会因为异常跳过 clear()
8子线程里发起 Feign 且外层有 @GlobalTransactional子线程 Feign 请求带上相同 XID,TC 上能看到该分支事务
9发 MQ 消息时外层在全局事务内消息属性里不含 XID,消费端另开全局事务,避免注册到已结束的事务
10下游服务被外网直接访问(伪造 X-Internal-Call: 1)网关必须剥离该头 + 下游只监听内网端口,不能仅依赖头做鉴权豁免

🚀 变种拓展题

  1. 把 RpcContextHolder 改造成支持嵌套作用域:提供 runAs(ctx, supplier) 方法,内部用栈保存旧值,执行完自动还原(定时任务里切换租户的典型需求)。
  2. 实现上下文透传字段白名单化:从 Nacos 读 rpc.context.pass-through-headers 列表,新增字段只改配置不改代码。
  3. 把透传改造成Micrometer Tracing / SkyWalking 接入版:traceId 不自己生成,而是复用 APM 的 traceId,保证日志能和链路图对上。
  4. 实现 WebFlux 版本(网关侧):ThreadLocal 在 Reactor 里不可用,要求用 Context.put + deferContextual 完成同样的透传。
  5. 实现跨语言透传:Java 侧与 Go 侧共用一套 Header 协议(兼容 W3C traceparent),并写一份协议文档约束双方。

题 4:实现 Seata TCC 模式库存冻结(解决空回滚 / 幂等 / 悬挂三大问题) ​

题目描述:下单扣库存不能用 AT(库存是热点行,AT 的全局锁会把 TPS 打死;而且库存服务还要对接外部 WMS,没有 undo_log 可回滚)。要求用 TCC 实现:Try 阶段「冻结」库存,Confirm 阶段把冻结转为真实扣减,Cancel 阶段释放冻结。并且必须处理 TCC 的三大经典问题。

核心实现

第一步:表设计。库存表拆出「冻结列」,再加一张事务控制表专门解决空回滚与悬挂。

sql
-- 库存表:available 可用,frozen 冻结中
CREATE TABLE `t_storage` (
  `id`        BIGINT NOT NULL AUTO_INCREMENT,
  `sku_id`    BIGINT NOT NULL,
  `available` INT    NOT NULL DEFAULT 0 COMMENT '可用库存',
  `frozen`    INT    NOT NULL DEFAULT 0 COMMENT '冻结库存(Try 已占, 未最终扣)',
  `version`   INT    NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_sku` (`sku_id`)
) ENGINE=InnoDB;

-- TCC 事务控制表:一个分支事务一行,是幂等/空回滚/悬挂的唯一依据
CREATE TABLE `tcc_tx_record` (
  `id`          BIGINT NOT NULL AUTO_INCREMENT,
  `xid`         VARCHAR(128) NOT NULL COMMENT '全局事务 ID',
  `branch_id`   BIGINT       NOT NULL COMMENT '分支事务 ID',
  `action_name` VARCHAR(64)  NOT NULL COMMENT '业务动作名, 如 storageTccAction',
  -- 1=TRY_OK  2=CONFIRM_OK  3=CANCEL_OK  4=EMPTY_CANCEL(空回滚占位)
  `state`       TINYINT      NOT NULL,
  `biz_param`   VARCHAR(512) DEFAULT NULL COMMENT '冗余业务参数, 便于补偿与排查',
  `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  -- 唯一键是整套方案的地基:靠它把"并发重复进入"变成"插入冲突"
  UNIQUE KEY `uk_xid_branch` (`xid`, `branch_id`)
) ENGINE=InnoDB;

⚠️ 三大问题的本质都是「Cancel/Confirm 与 Try 的时序被打乱」:

  • 空回滚:Try 因为网络超时根本没执行,TC 却发来了 Cancel → Cancel 必须能识别「没 Try 过」并直接返回成功;
  • 幂等:TC 的 Confirm/Cancel 是不断重试直到成功的,同一个分支会被调多次 → 第二次必须是空操作;
  • 悬挂:Cancel 先到、Try 的请求因为网络阻塞后到 → 后到的 Try 会把库存冻结住再也没人释放,必须让它直接拒绝执行。

第二步:定义 TCC 接口。@LocalTCC + @TwoPhaseBusinessAction 声明三个阶段,@BusinessActionContextParameter 标记需要在二阶段回传的参数。

java
@LocalTCC
public interface StorageTccAction {

    /**
     * 一阶段 Try:冻结库存
     * name 必须全局唯一,它就是 TC 识别分支的 action 名
     * useTCCFence = true 可让 Seata 1.5+ 用内置 tcc_fence_log 自动处理防悬挂
     * 这里演示"手写版",便于说清原理;生产可二选一(不要重复做)
     */
    @TwoPhaseBusinessAction(name = "storageTccAction",
                            commitMethod = "confirm",
                            rollbackMethod = "cancel")
    boolean tryFreeze(@BusinessActionContextParameter(paramName = "skuId") Long skuId,
                      @BusinessActionContextParameter(paramName = "count") Integer count);

    /** 二阶段 Confirm:冻结 → 真实扣减 */
    boolean confirm(BusinessActionContext context);

    /** 二阶段 Cancel:释放冻结 */
    boolean cancel(BusinessActionContext context);
}

第三步:实现类,三个方法分别加上防护。核心口诀是「Try 查悬挂、Confirm 查幂等、Cancel 查空回滚」。

java
@Service
@Slf4j
@RequiredArgsConstructor
public class StorageTccActionImpl implements StorageTccAction {

    private final StorageMapper storageMapper;
    private final TccTxRecordMapper recordMapper;

    private static final int TRY_OK = 1, CONFIRM_OK = 2, CANCEL_OK = 3, EMPTY_CANCEL = 4;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean tryFreeze(Long skuId, Integer count) {
        String xid = RootContext.getXID();
        long branchId = currentBranchId();

        // ===== 防悬挂:Cancel 可能已经先到并插入了 EMPTY_CANCEL 占位 =====
        TccTxRecord exist = recordMapper.selectForUpdate(xid, branchId);
        if (exist != null) {
            // 已有记录:要么是重复 Try(幂等返回成功),要么是空回滚占位(必须拒绝)
            if (exist.getState() == EMPTY_CANCEL) {
                log.warn("[TCC] 悬挂拦截: Cancel 已执行, 拒绝迟到的 Try. xid={}", xid);
                return false;   // 返回 false, 不冻结任何库存
            }
            log.info("[TCC] Try 重复提交, 幂等返回. xid={}", xid);
            return true;
        }

        // ===== 插入 TRY_OK: 唯一索引保证并发下只有一个线程成功 =====
        try {
            recordMapper.insert(TccTxRecord.builder()
                    .xid(xid).branchId(branchId)
                    .actionName("storageTccAction")
                    .state(TRY_OK)
                    .bizParam("skuId=" + skuId + ",count=" + count)
                    .build());
        } catch (DuplicateKeyException e) {
            log.info("[TCC] Try 并发重复, 幂等返回. xid={}", xid);
            return true;
        }

        // ===== 真正的业务: available -= count, frozen += count =====
        // SQL 里带 available >= #{count} 条件, 用更新行数判断是否够库存, 避免"先查后改"的并发超卖
        int rows = storageMapper.freeze(skuId, count);
        if (rows == 0) {
            // 库存不足:抛异常让本地事务回滚(record 行也一起回滚),同时触发全局回滚
            throw new BizException("库存不足, skuId=" + skuId);
        }
        return true;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean confirm(BusinessActionContext context) {
        String xid = context.getXid();
        long branchId = context.getBranchId();

        TccTxRecord record = recordMapper.selectForUpdate(xid, branchId);
        // ===== 幂等:已 Confirm 过直接返回 true,否则 TC 会无限重试 =====
        if (record == null || record.getState() != TRY_OK) {
            log.info("[TCC] Confirm 幂等跳过. xid={}, state={}", xid,
                    record == null ? "null" : record.getState());
            return true;
        }

        Long skuId = Long.valueOf(String.valueOf(context.getActionContext("skuId")));
        Integer count = Integer.valueOf(String.valueOf(context.getActionContext("count")));

        // 冻结转实扣:只需把 frozen 减掉(available 在 Try 阶段已经减过)
        storageMapper.confirmFreeze(skuId, count);
        recordMapper.updateState(xid, branchId, CONFIRM_OK);
        return true;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean cancel(BusinessActionContext context) {
        String xid = context.getXid();
        long branchId = context.getBranchId();

        TccTxRecord record = recordMapper.selectForUpdate(xid, branchId);

        // ===== 空回滚:Try 没执行过(没有记录),插一条占位后直接返回成功 =====
        if (record == null) {
            try {
                recordMapper.insert(TccTxRecord.builder()
                        .xid(xid).branchId(branchId)
                        .actionName("storageTccAction")
                        .state(EMPTY_CANCEL)          // 给迟到的 Try 留下"禁止通行"的标记
                        .build());
            } catch (DuplicateKeyException e) {
                // 并发下 Try 刚好插入成功:说明不是空回滚,重走一遍即可
                log.warn("[TCC] 空回滚与 Try 并发, 重试. xid={}", xid);
                throw new BizException("cancel 与 try 并发, 交给 TC 重试");
            }
            log.warn("[TCC] 空回滚: Try 未执行, 直接成功. xid={}", xid);
            return true;
        }

        // ===== 幂等:已 Cancel / 已 Confirm 过,不再重复操作 =====
        if (record.getState() != TRY_OK) {
            log.info("[TCC] Cancel 幂等跳过. xid={}, state={}", xid, record.getState());
            return true;
        }

        Long skuId = Long.valueOf(String.valueOf(context.getActionContext("skuId")));
        Integer count = Integer.valueOf(String.valueOf(context.getActionContext("count")));

        // 释放冻结:frozen -= count, available += count
        storageMapper.unfreeze(skuId, count);
        recordMapper.updateState(xid, branchId, CANCEL_OK);
        return true;
    }

    /** BusinessActionContext 只在二阶段有,一阶段只能从 RootContext 侧拿分支 ID */
    private long currentBranchId() {
        BusinessActionContext ctx = BusinessActionContextUtil.getContext();
        return ctx == null ? 0L : ctx.getBranchId();
    }
}

第四步:三条关键 SQL。不要「先 select 再 update」,把条件写进 WHERE,靠影响行数判断成败。

xml
<!-- Try:带 available >= count 条件,并发下不会超卖 -->
<update id="freeze">
    UPDATE t_storage
       SET available = available - #{count},
           frozen    = frozen + #{count},
           version   = version + 1
     WHERE sku_id = #{skuId}
       AND available &gt;= #{count}
</update>

<!-- Confirm:只减 frozen(available 一阶段已扣) -->
<update id="confirmFreeze">
    UPDATE t_storage
       SET frozen = frozen - #{count}, version = version + 1
     WHERE sku_id = #{skuId} AND frozen &gt;= #{count}
</update>

<!-- Cancel:frozen 退回 available -->
<update id="unfreeze">
    UPDATE t_storage
       SET available = available + #{count},
           frozen    = frozen - #{count},
           version   = version + 1
     WHERE sku_id = #{skuId} AND frozen &gt;= #{count}
</update>

第五步:发起方。@GlobalTransactional 只加在最外层,内部的 TCC 分支通过 Feign 调用,XID 由前面题 3 的拦截器自动透传。

java
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderMapper orderMapper;          // 本地 AT 分支
    private final StorageFeignClient storageClient; // 远程 TCC 分支
    private final AccountFeignClient accountClient; // 远程 TCC 分支

    /**
     * AT 与 TCC 可以混用:同一个全局事务里,订单库用 AT,库存/账户用 TCC
     * timeoutMills 要大于「所有分支 Try 阶段耗时之和」,否则 TC 会提前触发回滚
     */
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class,
                         timeoutMills = 60_000)
    public Long createOrder(OrderCreateDTO dto) {
        Order order = orderMapper.insertSelective(dto.toOrder());   // AT 分支
        storageClient.freeze(dto.getSkuId(), dto.getCount());       // TCC 分支
        accountClient.freezeAmount(dto.getUserId(), dto.getAmount());// TCC 分支
        return order.getId();
    }
}

三大问题与防护手段的对应关系,一张图记牢:

text
正常时序:  TM 开启 ──► Try ──► Try ──► TC 决议 ──► Confirm ──► Confirm
异常时序一:TM 开启 ──► Try 超时(实际未执行) ──► TC 回滚 ──► Cancel  ← 空回滚
异常时序二:TM 开启 ──► Try ──► Cancel ──► (网络重试) Cancel        ← 幂等
异常时序三:TM 开启 ──► Try 请求卡网络 ──► TC 超时回滚 ──► Cancel
                                      └──► Try 迟到抵达           ← 悬挂

           ┌──────────────┬──────────────────────────────────────┐
           │  问题         │  防护点(都落在 tcc_tx_record 上)    │
           ├──────────────┼──────────────────────────────────────┤
           │  空回滚       │  Cancel 查不到记录 → 插 EMPTY_CANCEL  │
           │  幂等         │  state != TRY_OK → 直接 return true  │
           │  悬挂         │  Try 发现 EMPTY_CANCEL → return false│
           └──────────────┴──────────────────────────────────────┘

面试加分表述:Seata 1.5.1 之后官方提供了 useTCCFence = true,底层用 tcc_fence_log 表自动做防悬挂与幂等,原理和上面手写的完全一致(也是靠唯一键 + 状态机)。实际项目里我优先用官方 Fence减少心智负担,但如果业务需要把「冻结参数」持久化下来做人工补偿或对账,就仍然保留自己的业务流水表——两者职责不同,一个管事务正确性,一个管业务可追溯。

边界 Case 自测清单

#Case期望行为
1Try 因网络超时未执行,TC 下发 Cancel识别为空回滚,插入 EMPTY_CANCEL 并返回 true,不动库存
2迟到的 Try 在 Cancel 之后到达查到 EMPTY_CANCEL 直接返回 false,库存不被冻结,不产生悬挂
3TC 重复下发 Confirm 3 次第 1 次扣减,第 2、3 次因 state != TRY_OK 幂等跳过,frozen 不会被多减
4库存不足freeze 影响行数为 0,抛异常触发全局回滚,其他分支一并 Cancel
5两个线程并发进入同一分支的 Try唯一键 uk_xid_branch 冲突,后者走 DuplicateKeyException 幂等返回
6Cancel 与 Try 几乎同时发生Cancel 插入占位冲突时抛异常让 TC 重试,下一轮能读到 TRY_OK 走正常回滚
7Confirm 阶段数据库宕机方法抛异常,TC 持续重试直到成功(Confirm 必须最终成功,不可回滚)
8全局事务 timeoutMills 小于 Try 总耗时TC 提前回滚,表现为「明明成功了却被撤销」,必须按 P99 耗时放大配置
9库存服务被 Sentinel 降级返回了兜底成功全局事务感知不到失败,必须让降级方法抛异常或返回失败标识
10tcc_tx_record 数据持续增长配定时任务归档 7 天前的 CONFIRM_OK/CANCEL_OK 记录,避免表膨胀影响写入

🚀 变种拓展题

  1. 把手写防护换成官方 useTCCFence = true,对比两者在「并发 Try/Cancel」场景下的表现差异,并说明 tcc_fence_log 的清理策略。
  2. 实现账户服务的 TCC(冻结金额),并和库存服务组成双 TCC 分支,验证任一分支失败时另一分支能正确 Cancel。
  3. 给 TCC 加补偿巡检任务:扫描 state = TRY_OK 且 create_time 超过 30 分钟的记录,查询 TC 的 global_table 判断全局事务是否已结束,未结束则告警、已结束则按结果补偿。
  4. 把库存冻结改造成 Redis + DB 双层:Redis 做热点扣减,DB 做最终一致,说明 TCC 三阶段在双层结构下分别操作哪一层。
  5. 实现 SAGA 版本的同一业务(用状态机 JSON 编排),对比 TCC 与 SAGA 在「长流程、第三方接口无冻结能力」场景下的取舍。

题 5:实现全局事务可观测性与兜底补偿(AOP 埋点 + undo_log 巡检 + 告警) ​

题目描述:分布式事务上线后最怕「悄悄失败」——TC 回滚失败、undo_log 残留、分支长时间挂在 Rollbacking 状态,而业务侧毫无感知。要求实现:① 全局事务埋点(耗时、成功率、XID 打进日志);② undo_log 与 global_table 巡检;③ 异常时告警并留下人工补偿入口。

核心实现

第一步:AOP 埋点。切 @GlobalTransactional,把 XID、耗时、结果都记录下来,并接入 Micrometer 便于 Prometheus 采集。

java
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class GlobalTxMetricsAspect {

    private final MeterRegistry registry;

    /**
     * 优先级必须「低于」Seata 自己的 GlobalTransactionalInterceptor(order = Ordered.HIGHEST_PRECEDENCE)
     * 否则切面执行时 RootContext 里还没有 XID,取到的永远是 null
     */
    @Around("@annotation(globalTx)")
    @Order(Ordered.HIGHEST_PRECEDENCE + 100)
    public Object around(ProceedingJoinPoint pjp, GlobalTransactional globalTx) throws Throwable {
        String txName = StringUtils.hasText(globalTx.name())
                ? globalTx.name()
                : pjp.getSignature().toShortString();
        long start = System.currentTimeMillis();
        String result = "success";
        try {
            Object ret = pjp.proceed();
            // 这里才能拿到 XID:Seata 的拦截器已经在外层开好了全局事务
            MDC.put("xid", RootContext.getXID());
            return ret;
        } catch (Throwable e) {
            result = e instanceof TransactionException ? "tc_error" : "biz_error";
            log.error("[GlobalTx] 全局事务失败 name={}, xid={}, err={}",
                    txName, RootContext.getXID(), e.getMessage(), e);
            throw e;
        } finally {
            long cost = System.currentTimeMillis() - start;
            Timer.builder("seata.global.tx")
                    .tag("name", txName)
                    .tag("result", result)
                    .register(registry)
                    .record(cost, TimeUnit.MILLISECONDS);
            if (cost > 3_000) {
                // 全局事务越长,全局锁持有时间越长,是雪崩的前兆
                log.warn("[GlobalTx] 慢全局事务 name={}, cost={}ms, xid={}",
                        txName, cost, RootContext.getXID());
            }
            MDC.remove("xid");
        }
    }
}

⚠️ 切面顺序是这里最容易踩的坑:如果自定义切面的 @Order 比 Seata 的拦截器更靠外,RootContext.getXID() 一定是 null,日志里就会出现「全局事务失败但 xid=null」的灵异现象,排查时完全串不起来。

第二步:日志模板带上 XID。只要日志里有 XID,一次失败的分布式事务就能在 ELK 里一键捞出所有参与方。

xml
<!-- logback-spring.xml -->
<property name="PATTERN"
    value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] [%X{xid:-}] %logger{36} - %msg%n"/>

第三步:undo_log 巡检任务。AT 模式下 undo_log 正常会在二阶段被删除,残留就意味着「回滚失败」或「二阶段请求丢了」。

java
@Component
@Slf4j
@RequiredArgsConstructor
public class UndoLogInspectJob {

    private final JdbcTemplate jdbcTemplate;
    private final AlertService alertService;

    /**
     * 每 5 分钟巡检一次,避开整点(整点任务多,容易把慢查询堆一起)
     * 多实例部署时必须加分布式锁,否则会重复告警
     */
    @Scheduled(cron = "0 2/5 * * * ?")
    @SchedulerLock(name = "undoLogInspect", lockAtMostFor = "4m")
    public void inspect() {
        // 1. 残留超过 10 分钟的 undo_log:正常情况二阶段秒级就删了
        List<Map<String, Object>> stale = jdbcTemplate.queryForList(
                "SELECT xid, branch_id, log_status, log_created " +
                "  FROM undo_log " +
                " WHERE log_created < DATE_SUB(NOW(), INTERVAL 10 MINUTE) " +
                " ORDER BY log_created ASC LIMIT 100");
        if (!stale.isEmpty()) {
            // log_status = 1 表示 GlobalFinished,是防悬挂插入的占位记录,可安全清理
            long suspend = stale.stream()
                    .filter(r -> Objects.equals(r.get("log_status"), 1))
                    .count();
            alertService.warn(String.format(
                    "undo_log 残留 %d 条(其中防悬挂占位 %d 条),最早 %s,疑似二阶段回滚失败",
                    stale.size(), suspend, stale.get(0).get("log_created")));
            stale.forEach(r -> log.error("[UndoLog] 残留 xid={}, branchId={}, status={}",
                    r.get("xid"), r.get("branch_id"), r.get("log_status")));
        }

        // 2. 表体积巡检:undo_log 暴增说明全局事务量级或失败率异常
        Long rows = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM undo_log", Long.class);
        if (rows != null && rows > 10_000) {
            alertService.warn("undo_log 行数达到 " + rows + ",请检查二阶段是否大面积超时");
        }
    }
}

第四步:TC 侧 global_table 巡检。这是「全局事务卡死」的权威数据源,比业务日志更准。

java
@Component
@Slf4j
@RequiredArgsConstructor
public class GlobalTxInspectJob {

    /** 单独的数据源,指向 TC 的 seata 库(db 存储模式下) */
    @Qualifier("seataJdbcTemplate")
    private final JdbcTemplate seataJdbc;
    private final AlertService alertService;

    /** Seata GlobalStatus:2=Committing 4=Rollbacking 7=CommitRetrying 10=RollbackRetrying */
    private static final String SQL =
            "SELECT xid, transaction_name, status, application_id, gmt_create " +
            "  FROM global_table " +
            " WHERE status IN (2, 4, 7, 10) " +
            "   AND gmt_create < DATE_SUB(NOW(), INTERVAL 5 MINUTE) " +
            " ORDER BY gmt_create ASC LIMIT 200";

    @Scheduled(cron = "0 3/5 * * * ?")
    @SchedulerLock(name = "globalTxInspect", lockAtMostFor = "4m")
    public void inspect() {
        List<Map<String, Object>> stuck = seataJdbc.queryForList(SQL);
        if (stuck.isEmpty()) {
            return;
        }
        // 按事务名聚合,一眼看出是哪条业务链路出问题,而不是刷一屏 XID
        Map<Object, Long> byName = stuck.stream()
                .collect(Collectors.groupingBy(r -> r.get("transaction_name"),
                        Collectors.counting()));
        alertService.alert("【分布式事务卡死】超过 5 分钟未终结的全局事务 "
                + stuck.size() + " 个,分布:" + byName);
        stuck.forEach(r -> log.error(
                "[GlobalTx] 卡死 xid={}, name={}, status={}, app={}, create={}",
                r.get("xid"), r.get("transaction_name"), r.get("status"),
                r.get("application_id"), r.get("gmt_create")));
    }
}

第五步:人工补偿入口。自动补偿解决不了的,必须有带审计的人工通道,而不是让人直接连生产库改数据。

java
@RestController
@RequestMapping("/ops/tx")
@RequiredArgsConstructor
public class TxCompensateController {

    private final TxCompensateService compensateService;

    /**
     * 人工重试某个全局事务的二阶段
     * 三道防线:① 内网路由 ② 操作人鉴权 ③ 全量审计日志
     */
    @PostMapping("/retry")
    @OpsAudit(action = "GLOBAL_TX_RETRY")
    @PreAuthorize("hasRole('OPS_ADMIN')")
    public Result<String> retry(@RequestParam String xid,
                               @RequestParam RetryMode mode) {
        // mode = COMMIT / ROLLBACK,必须由人判断,系统不能猜
        return Result.ok(compensateService.retryPhase2(xid, mode));
    }

    /** 查询一个 XID 的全链路分支状态,供人工决策 */
    @GetMapping("/detail")
    public Result<GlobalTxDetailVO> detail(@RequestParam String xid) {
        return Result.ok(compensateService.detail(xid));
    }
}

最后是整套可观测体系的分层视图:

text
┌──────────────── 应用层埋点 ────────────────┐
│ GlobalTxMetricsAspect                     │
│  · seata.global.tx 耗时/成功率 → Prometheus│
│  · XID 写入 MDC → 日志可串联               │
└───────────────────┬───────────────────────┘
                    │
┌───────────────── 数据层巡检 ──────────────┐
│ 业务库 undo_log      TC 库 global_table   │
│  · 残留 > 10min       · status∈{2,4,7,10} │
│    → 回滚失败           且 > 5min → 卡死   │
│  · 行数 > 1w          · 按事务名聚合告警    │
└───────────────────┬───────────────────────┘
                    │
┌───────────────── 处置层 ──────────────────┐
│ 自动:TC 自带定时重试(二阶段幂等保证安全) │
│ 人工:/ops/tx/retry(鉴权 + 审计 + 只内网)│
└───────────────────────────────────────────┘

边界 Case 自测清单

#Case期望行为
1自定义切面 @Order 比 Seata 拦截器更靠外RootContext.getXID() 为 null,必须调整 order 到 Seata 之后才能取到
2全局事务正常提交undo_log 被二阶段删除,巡检任务无告警
3参与方在二阶段前宕机undo_log 残留,巡检 10 分钟后告警,TC 恢复后自动重试清理
4undo_log 中出现 log_status = 1 的记录识别为防悬挂占位,告警文案区分说明,不要当成回滚失败处理
5巡检任务多实例同时触发@SchedulerLock 保证只有一个实例执行,不重复告警
6巡检 SQL 扫全表导致慢查询必须加 LIMIT 并给 log_created / gmt_create 建索引
7全局事务卡在 Rollbacking 超 5 分钟按 transaction_name 聚合告警,便于定位是哪条业务链路
8人工补偿接口被外网访问路由只开内网 + 角色鉴权双重拦截,返回 403
9人工重试选错了 COMMIT/ROLLBACK操作留审计日志可追溯;二阶段幂等保证重复执行不产生二次扣减
10慢全局事务(> 3s)频繁出现日志 warn + 指标告警,提示缩小事务范围、把非核心操作移出事务

🚀 变种拓展题

  1. 把巡检结果接入 Grafana 看板:画出「全局事务 QPS / 失败率 / P99 耗时 / undo_log 残留数」四个核心面板,并配置分级告警阈值。
  2. 实现全局事务链路可视化:用 XID 关联各分支日志,提供一个接口返回「分支列表 + 各分支状态 + 耗时瀑布图」数据。
  3. 把 Seata 接入 SkyWalking:让全局事务在链路图上显示为一个 Span,XID 作为 Tag,排查时可直接从慢链路跳到事务详情。
  4. 实现对账补偿任务:按日对比订单表与库存流水,发现「订单成功但库存未扣」的差异数据,输出差异报表并支持一键修复。
  5. 设计降级开关:当 TC 不可用时,让 @GlobalTransactional 自动降级为「本地事务 + 消息表最终一致」,并说明这种降级的风险边界。

⚠️ 当日踩坑实录 ​

❌ 坑✅ 解决方案
Spring Cloud Alibaba 版本和 Spring Boot 版本不匹配,启动报 NoSuchMethodError / ClassNotFoundExceptionSCA 的版本号前缀就是对应的 Boot 版本(如 2021.0.5.0 对应 Boot 2.6.x),必须查官方版本对应表,不要手动升降单个组件
配置里 namespace 填了命名空间名称,结果读不到任何配置namespace 必须填命名空间的 ID(UUID),public 命名空间要留空而不是写 public
bootstrap.yml 里的 Nacos 配置不生效,服务起来读的还是本地配置必须引入 spring-cloud-starter-bootstrap 依赖;Boot 2.4+ 默认关闭了 bootstrap 上下文
Nacos 2.x 升级后客户端连不上,只开了 8848 端口2.x 新增 gRPC 通道,需要额外放开 9848(客户端 gRPC)和 9849(集群间 gRPC),规则是 主端口 + 1000 / + 1001
@Value 注入的配置在 Nacos 改了之后不变需要在 Bean 上加 @RefreshScope;@ConfigurationProperties 不需要但必须是 Spring 管理的 Bean
加了 @RefreshScope 后,配置刷新时该 Bean 被销毁重建,导致内部缓存/连接池全丢@RefreshScope 不要加在持有重量级资源的 Bean 上,改成把配置抽成独立的 @ConfigurationProperties 对象注入
@RefreshScope 的 Bean 被单例 Bean 构造器注入,刷新后拿到的还是旧代理改用 setter / 方法参数注入,或注入 ObjectProvider 每次取最新实例
服务下线后调用方还在往旧实例打流量,报连接拒绝这是三层缓存造成的秒级延迟窗口:Nacos 推送 + Ribbon/LoadBalancer 本地缓存 + 连接池;正解是「先下注册(deregister)→ 等 30 秒 → 再停进程」的无损下线
发布时用 kill -9 直接杀进程,调用方报一片 500kill -9 跳过 Shutdown Hook,Nacos 不会收到注销请求,只能等心跳超时(15~30s)。必须用 kill -15 + spring.lifecycle.timeout-per-shutdown-phase
机房网络抖动,Nacos 把大批实例摘除导致雪崩配置 protectThreshold(保护阈值,如 0.5):健康实例比例低于阈值时把所有实例(含不健康)都返回,宁可调用失败也不压垮剩余实例
Nacos 单机模式用了默认的嵌入式 Derby,重启数据全丢生产必须用 MySQL 存储(spring.datasource.platform=mysql)并至少 3 节点集群
Feign 接口上的 @RequestMapping 和服务端实际路径不一致,报 404 却查不出原因Feign 的路径是客户端自己声明的,和服务端没有强约束;正解是把接口定义抽成独立的 api 模块让双方共同依赖
Feign 传 GET + 对象参数,报 Method Not Allowed 或参数全空Feign 对 GET + POJO 默认会转成 body,必须加 @SpringQueryMap 或拆成 @RequestParam
Feign 调用一直超时,调大 ribbon.ReadTimeout 没用新版用的是 spring.cloud.openfeign.client.config.<name>.readTimeout;注意 Feign 超时、Ribbon 超时、Hystrix/Sentinel 超时三者取最小值,只改一个没用
Feign 配了 maxAttempts=3 重试,结果同一个订单被创建了 3 笔只有幂等接口才能重试,写接口要么关重试(Retryer.NEVER_RETRY),要么服务端做幂等(唯一键 / 幂等 token)
Feign 并发上来后大量 ConnectionPoolTimeoutException默认 HttpURLConnection 无连接池;换成 OkHttp/ApacheHttpClient 并调大 max-connections-per-route(默认只有 50)
Feign 的 RequestInterceptor 里用 template.header() 追加,下游收到 1001,1001header(name, value) 是追加语义,要先 template.header(name) 传空清除再写入
Sentinel 的 fallback 方法写了但不生效fallback 不处理 BlockException(限流/熔断),那是 blockHandler 的职责;fallbackClass 里的方法必须是 static
Feign 整合 Sentinel 后降级完全没触发必须显式配置 feign.sentinel.enabled=true(新版是 spring.cloud.openfeign.sentinel.enabled),默认是关的
Sentinel 控制台配的规则一重启就全丢控制台默认推到内存,必须做规则持久化:datasource 指向 Nacos(pull 模式)或改造控制台直接写 Nacos(push 模式)
Sentinel 控制台看不到任何实时数据控制台是反向拉取客户端 8719 端口的数据,要保证控制台能访问客户端;容器部署时要配 spring.cloud.sentinel.transport.client-ip
熔断规则配了 50% 慢调用比例,结果凌晨 2 点来了 1 个慢请求就直接熔断必须配 minRequestAmount(最小请求数,如 20)和 statIntervalMs,低流量时不参与统计
手动 SphU.entry() 后忘了 entry.exit(),一段时间后所有请求都被限流entry() / exit() 必须严格配对且 exit 放在 finally;exit 没调用会导致线程数统计只增不减
把 BlockException 当业务异常 catch 掉,导致熔断器统计不到BlockException 必须原样抛出或交给统一处理器;catch 掉等于告诉 Sentinel「调用成功了」
限流阈值按单机 100 QPS 配,10 台机器实际放进来 1000 QPS单机阈值 × 实例数才是总量;要精确控总量必须用集群流控(Token Server)
Gateway 里写了 restTemplate.getForObject() 做鉴权,压测时 QPS 掉到两位数Gateway 基于 WebFlux/Netty,少量 EventLoop 线程不能有任何阻塞调用;必须用 WebClient 或 ReactiveRedisTemplate
Gateway 过滤器里读了一次 body,后面的过滤器和下游都拿不到DataBuffer 是只读一次的流;必须用官方的 cacheRequestBody 过滤器或 ServerWebExchangeDecorator 缓存
路由配 uri: http://user-service,报 UnknownHostException要走注册中心负载均衡必须用 lb://user-service,http:// 是直连固定地址
StripPrefix 数错,下游收到的路径多/少一段StripPrefix=1 去掉 /api,=2 去掉 /api/user;改路由前先用日志打印 exchange.getRequest().getPath() 确认
Gateway 和下游都配了 CORS,浏览器报 multiple values '*'加 DedupeResponseHeader 过滤器(RETAIN_FIRST),或者只在网关一处配 CORS
在 Nacos 改了路由配置,但网关不生效动态路由除了写进 RouteDefinitionRepository,还必须 publishEvent(new RefreshRoutesEvent(this)) 触发刷新
限流按 X-Forwarded-For 取第一个 IP,被客户端伪造绕过X-Forwarded-For 是可追加的,要从右往左数、信任自己网关层追加的那一跳,或直接用 RemoteAddr + 可信代理白名单
Gateway 全局过滤器里鉴权失败直接 return 而没 setComplete()必须 response.setStatusCode(...) 并 return response.setComplete(),否则请求会继续往下走
Seata 启动报 can not get cluster name in registry config 'service.vgroupMapping.xxx'事务分组是三层映射:客户端 tx-service-group → vgroupMapping.<group> → 集群名 → 实例地址,中间任一层对不上就报这个错
Seata 的 Nacos 配置能读到,但服务列表是空的Seata Server 注册到 Nacos 时默认用的 group 是 SEATA_GROUP,客户端的 registry.nacos.group 必须对齐
AT 模式加了 @GlobalTransactional 但完全不回滚三件事缺一不可:业务库建 undo_log 表、数据源被 DataSourceProxy 代理(starter 自动做)、XID 能透传到参与方
自己配了 DataSourceProxy 之后反而不回滚了新版 starter 已自动代理,手动再包一层会造成双层代理,去掉手动配置或设 seata.enable-auto-data-source-proxy=false 二选一
AT 回滚报 dirty data found二阶段回滚时发现数据被别人改过(脏写)。根因是存在绕过 Seata 的直连修改,必须让所有写路径都走全局事务,或对该表加 @GlobalLock + SELECT FOR UPDATE
AT 模式下表没有主键,回滚时报错或回滚错数据AT 靠主键定位行来生成 undo 镜像,业务表必须有主键;联合主键也可以但要确保唯一
热点行(如秒杀库存)用 AT,大量 LockConflict,TPS 只有几十全局锁在二阶段才释放,热点行注定排队。热点场景改用 TCC(冻结列无全局锁)或 Redis 预扣减
读到了别人未提交的全局事务中间态数据AT 默认全局隔离级别是 读未提交;要读已提交必须用 SELECT ... FOR UPDATE(被 @GlobalLock 代理会去 TC 校验全局锁)
参与方抛异常了,但全局事务没回滚常见两个原因:① 参与方被 Sentinel/Feign 降级吞掉异常返回了兜底值;② 异常被 try-catch 吃了。降级方法必须抛异常或返回失败标识
发起方的 @GlobalTransactional 和 @Transactional 加在同一个方法且互相调用,事务不生效Spring AOP 自调用不走代理;@GlobalTransactional 必须加在被外部调用的入口方法上
全局事务 timeoutMills 用默认 60s,长流程被提前回滚超时时间必须大于所有分支 Try/业务阶段耗时之和(按 P99 放大),但也不能无限大,否则全局锁持有过久
TCC 的 Cancel 比 Try 先到,Try 后到把库存冻死了(悬挂)必须用 tcc_tx_record / 官方 useTCCFence=true 做防悬挂:Cancel 插占位记录,迟到的 Try 查到占位直接拒绝
TCC 的 Confirm 被重复调用,库存被多扣二阶段是 TC 不断重试直到成功的,Confirm/Cancel 必须幂等(靠事务记录表的状态机)
undo_log 表持续增长到几百万行残留说明二阶段回滚失败或请求丢失,需要巡检告警;同时确认 Seata 的 undo 清理任务正常工作
自定义切面取 RootContext.getXID() 永远是 null切面 @Order 比 Seata 的 GlobalTransactionalInterceptor 更靠外,要排在它内层才能看到 XID
@Async 异步线程里的 Feign 调用没进全局事务XID 存在 ThreadLocal 里,必须用 TaskDecorator + RootContext.bind/unbind 手动传播
把 XID 传给了 MQ 消费端,消费时报分支注册失败MQ 是异步的,消费时原全局事务早已结束;消费端必须另开全局事务,绝不能复用旧 XID
在 Nacos 控制台把实例权重调成 0,流量照旧打进来默认的 RoundRobinLoadBalancer 不读 weight,必须开 spring.cloud.loadbalancer.nacos.enabled=true 启用 NacosLoadBalancer
配了同集群优先,但调用还是跨机房cluster-name 要调用方和被调方都配;只配一边会匹配不上,然后静默回落到全量轮询
生产配置改错一个值,几秒内全站实例同时生效导致故障配置变更是即时、无滚动、无灰度的,风险等同改生产库;必须用 Beta 发布按 IP 灰度 + 发布前看 diff + 生产命名空间只读走流水线
数据库密码在配置中心明文存放,审计不通过用 cipher- 前缀配置 + 本地密钥解密,或对接 KMS/Vault;密钥绝不能和密文放在同一份配置里
Nacos 生产只部了 2 节点,挂 1 台后配置无法发布配置走 Raft 需要过半数才能写,2 节点挂 1 台就不过半变只读;生产必须 ≥ 3 个奇数节点
Nacos 全挂后以为业务会立刻雪崩,慌乱重启服务客户端有本地缓存和配置快照,故障期间已有调用照常走;正确动作是先冻结发布稳业务,重启反而会让实例读到过期快照

📌 当日复盘任务 ​

一、微服务全景与版本管理(能脱口而出)

  • [ ] 画出一次请求的完整八道关:网关路由 → 鉴权 → 限流 → 服务发现 → 负载均衡 → 熔断降级 → 远程调用 → 分布式事务
  • [ ] 说清 Spring Cloud Alibaba 各组件分别解决什么问题,以及和 Netflix 套件的对应关系
  • [ ] 知道 SCA 版本号前缀 = 对应的 Spring Boot 版本,并能说出当前项目用的版本组合
  • [ ] 能说清自己项目为什么选 SCA 而不是 Spring Cloud Netflix / Dubbo

二、Nacos 注册中心与配置中心

  • [ ] 说清 Nacos 为什么能同时支持 AP(Distro)和 CP(Raft),以及各自对应什么场景
  • [ ] 默写临时实例 vs 持久化实例的四点差异(心跳/存储/一致性协议/下线行为)
  • [ ] 说清服务发现的三层缓存,以及为什么下线后仍有秒级流量打到旧实例
  • [ ] 默写无损上下线的完整步骤(先注销 → 等待 → 优雅停机)
  • [ ] 说清 protectThreshold 保护阈值的作用,以及不配会在什么场景出事
  • [ ] 默写配置中心的三级模型:Namespace(环境)→ Group(业务线)→ DataId(文件)
  • [ ] 说清配置热更新的完整链路:长轮询/gRPC → 客户端收到变更 → 发 RefreshEvent → 重建 @RefreshScope Bean
  • [ ] 说清 @RefreshScope 的实现原理(作用域代理 + Bean 销毁重建)和它的三个副作用
  • [ ] 说清 Nacos 1.x → 2.x 的 gRPC 长连接改造带来的收益,以及必须放开 9848/9849 端口
  • [ ] 能说清 namespace 必须填 ID 而不是名称这个坑
  • [ ] 说清 weight / cluster-name / metadata 三者分别解决什么流量调度问题,以及各自的回落行为
  • [ ] 知道只配 weight 不换 NacosLoadBalancer 是不生效的,并能说出「weight=0 优雅摘流」的发布用法
  • [ ] 说清配置 Beta 发布的灰度粒度(客户端 IP)与生产配置变更应有的流程管控
  • [ ] 说清敏感配置的三种加密方案,以及「密钥与密文必须分属两套权限体系」的判断标准
  • [ ] 说清 Nacos 生产集群为什么是 ≥3 个奇数节点 + MySQL,以及 Nacos 挂掉时业务的真实影响面

三、OpenFeign 与负载均衡

  • [ ] 说清 Feign 的动态代理链路:@EnableFeignClients 扫描 → FeignClientFactoryBean → JDK 动态代理 → SynchronousMethodHandler → 编码 → 负载均衡 → 发送 → 解码
  • [ ] 说清 Spring Cloud LoadBalancer 替代 Ribbon 后的差异,以及如何自定义灰度 ServiceInstanceListSupplier
  • [ ] 默写 Feign 的超时三层取最小值规则(Feign / LoadBalancer / Sentinel)
  • [ ] 能说清为什么只有幂等接口才能开重试,以及写接口的正确做法
  • [ ] 说清默认 HttpURLConnection 无连接池的问题,以及 OkHttp/HttpClient 的关键参数(max-connections-per-route 默认 50)
  • [ ] 手写 RequestInterceptor 实现上下文透传,并说清 header() 是追加语义这个坑
  • [ ] 说清 TransmittableThreadLocal 相比 InheritableThreadLocal 在线程池复用场景下的优势

四、Sentinel 流控与熔断

  • [ ] 默写 Sentinel 的核心概念:Resource / Rule / Slot Chain / Entry / Context
  • [ ] 说清 Slot 责任链的执行顺序,以及 StatisticSlot 为什么是统计的核心
  • [ ] 说清 LeapArray 滑动时间窗的数据结构与过期判定方式
  • [ ] 默写限流三种维度(QPS / 线程数)和三种流控效果(快速失败 / Warm Up / 排队等待)
  • [ ] 对比固定窗口 / 滑动窗口 / 漏桶 / 令牌桶四种限流算法的优劣与适用场景
  • [ ] 默写熔断三种策略(慢调用比例 / 异常比例 / 异常数)和状态机(Closed → Open → Half-Open)
  • [ ] 说清 minRequestAmount 不配会导致的误熔断问题
  • [ ] 说清 blockHandler 和 fallback 的职责边界(前者处理 BlockException,后者处理业务异常)
  • [ ] 说清 Sentinel vs Hystrix 的核心差异,以及信号量隔离 vs 线程池隔离的取舍
  • [ ] 默写 Sentinel 规则持久化的 pull vs push 两种模式与各自缺陷
  • [ ] 说清集群流控(Token Server)的原理和降级策略
  • [ ] 能解释 SphU.entry() 不 exit() 为什么会导致线程数统计只增不减

五、Gateway 网关

  • [ ] 默写 Gateway 三大核心概念:Route / Predicate / Filter
  • [ ] 画出 Gateway 的洋葱模型执行顺序(pre 阶段正序、post 阶段逆序)
  • [ ] 说清 Gateway 为什么基于 WebFlux/Netty,以及阻塞调用的致命后果
  • [ ] 手写全局鉴权过滤器(本地 JWT 验签 + Redis 版本号校验 + 剥离伪造头 + fail-open 策略)
  • [ ] 手写 Redis + Lua 的令牌桶/滑动窗口限流,并说清为什么必须用 Lua 保证原子性
  • [ ] 说清 DataBuffer 只读一次的限制,以及 cacheRequestBody 的解法
  • [ ] 默写常见内置过滤器:StripPrefix / DedupeResponseHeader / CircuitBreaker / Retry
  • [ ] 说清动态路由的实现要点,特别是必须发 RefreshRoutesEvent
  • [ ] 说清 lb:// 和 http:// 的区别
  • [ ] 说清网关限流和服务内限流的分工(网关挡入口洪峰、服务挡内部调用与细粒度资源)

六、Seata 分布式事务

  • [ ] 画出 Seata 的 TC / TM / RM 三角色协作流程
  • [ ] 默写 AT / TCC / SAGA / XA 四种模式的适用场景与侵入性对比
  • [ ] 说清 AT 模式两阶段的完整流程,以及 undo_log 的前后镜像作用
  • [ ] 说清 AT 的全局锁机制、释放时机,以及默认读未提交的隔离级别
  • [ ] 能解释 dirty data found 报错的根因和解法
  • [ ] 说清为什么热点行不能用 AT,以及该换成什么方案
  • [ ] 手写 TCC 三阶段,并默写空回滚 / 幂等 / 悬挂三大问题的防护手段
  • [ ] 说清官方 useTCCFence=true(tcc_fence_log)与手写事务记录表的异同
  • [ ] 默写分布式事务选型决策树:强一致 vs 最终一致、有无冻结能力、流程长短、改造成本
  • [ ] 说清 Seata 事务分组的三层映射关系,以及 SEATA_GROUP 这个坑
  • [ ] 说清 XID 的传播机制,以及异步线程 / MQ 场景分别该怎么处理
  • [ ] 说清为什么参与方被降级会导致全局事务不回滚

七、动手与产出

  • [ ] 本地跑通 Nacos + Sentinel + Gateway + Seata 的最小可用环境(Nacos 用 MySQL 存储)
  • [ ] 实现一条完整链路:Gateway 鉴权限流 → 订单服务 → Feign 调库存/账户 → AT + TCC 混合全局事务
  • [ ] 人为制造四种故障并验证表现:杀掉参与方、参与方抛异常、Try 超时、TC 宕机
  • [ ] 跑一轮压测,记录开启/关闭 Seata 前后的 TPS 差异,并能解释差异来源
  • [ ] 配好指标与告警:全局事务成功率、undo_log 残留数、Sentinel 限流/熔断次数、网关 5xx 比例
  • [ ] 把今天的全家桶配置整理成一份生产落地清单,作为面试时的项目素材