Appearance
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 Alibaba | Spring Cloud | Spring Boot | 说明 |
|---|---|---|---|
| 2022.0.0.x | 2022.0.x(Kilburn) | 3.0.x | JDK 17 起步,javax 全改 jakarta |
| 2021.0.5.0 | 2021.0.5 | 2.6.13 | 目前生产最常见的稳定组合 |
| 2.2.9.RELEASE | Hoxton.SR12 | 2.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 多快发现故障,上游总有一个刷新延迟。
三条解决路径,生产上要同时用:
- 优雅下线(最有效):发布脚本先调
/actuator/service-registry?status=DOWN或 Nacos 的注销接口,主动注销再停进程,把「被动发现」变成「主动通知」,窗口从 15 秒压到 1 秒内。 - Spring Boot 优雅停机:
server.shutdown=graceful+spring.lifecycle.timeout-per-shutdown-phase=30s,保证已经进来的请求能处理完。 - 调用侧重试:对幂等的 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.yamlDataId 的默认拼接规则是这三段:
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 | 毫秒级推送 |
| 服务端压力 | 大量挂起请求占线程/内存 | 连接数固定,压力降一个量级 |
| 端口 | 8848 | 8848 + 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 配置会覆盖 Feignyaml
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 | 一次资源访问的凭证,必须 exit | SphU.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(所有阻塞异常的父类)两个必须记住的要点:
- StatisticSlot 在 FlowSlot 之前。因为要先有统计数据,后面的规则才有判断依据。而且
StatisticSlot是在业务执行完 exit 时才记录 RT 和异常,所以它的entry()调用是包裹后续 slot 的。 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 子类 |
|---|---|---|
| 流控 | FlowRule | FlowException |
| 熔断降级 | DegradeRule | DegradeException |
| 热点参数 | ParamFlowRule | ParamFlowException |
| 系统保护 | SystemRule | SystemBlockException |
| 授权 | AuthorityRule | AuthorityException |
面试加分表述:「这五种 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 鉴权 │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘常用内置断言:
| 断言 | 示例 | 说明 |
|---|---|---|
Path | Path=/api/order/** | 最常用,** 匹配多级,* 只匹配一级 |
Method | Method=GET,POST | 按 HTTP 方法 |
Header | Header=X-Gray, true | 第二个参数是正则,不是字面量 |
Query | Query=debug, true | 同上 |
RemoteAddr | RemoteAddr=192.168.1.1/24 | ⚠️ 取的是直连 IP,前面挂了 Nginx 就全是 Nginx 的 IP |
After/Before/Between | After=2026-11-11T00:00:00+08:00[Asia/Shanghai] | 定时上线活动页,必须带时区 |
Weight | Weight=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)与四种模式
三大角色:
| 角色 | 全称 | 是谁 | 职责 |
|---|---|---|---|
| TC | Transaction Coordinator | 独立部署的 Seata Server | 维护全局事务状态,决定全局提交还是回滚 |
| TM | Transaction Manager | 标了 @GlobalTransactional 的那个服务 | 开启全局事务、发起提交/回滚决议 |
| RM | Resource 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_table | TC 的库(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;
}
}⚠️ 三个必踩的坑:
@GlobalTransactional只加在发起方,参与方什么都不用加(加了@Transactional是对的,加@GlobalTransactional会变成嵌套事务)。- 参与方必须抛异常。如果库存服务被 Feign 降级了、fallback 里
return Result.fail(...),对发起方来说是「正常返回」,全局事务不会回滚。必须在发起方检查返回码并主动throw。- 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 UPDATE | Seata 会代理它去申请全局锁,拿不到就重试,等于把读也串行化了 |
| 彻底规避 | 业务层设计上避免「读中间态」 | 需要业务配合 |
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 InnoDB | TC 的 lock_table |
| 持有时长 | 本地事务结束即释放(毫秒级) | 整个全局事务生命周期(可能几百毫秒到几秒) |
| 冲突表现 | 等锁超时 | LockWaitTimeoutException |
面试加分表述:「AT 的本质是用『全局锁 + 反向补偿』换取『无侵入 + 一阶段即提交』。它牺牲了读隔离性(默认读未提交)和一部分并发度(全局锁持有时间远长于行锁),换来业务代码零改造。所以我判断能不能用 AT 的标准是两个:一是有没有热点行,热点行用 AT 必然锁冲突;二是能不能容忍中间态被读到,比如账户余额展示这种,读到中间态会被用户投诉就不能用。」
✨ 高级深挖知识点
1. Nacos 的 AP/CP 之争:Distro 协议与 Raft 协议
Nacos 最特别的地方是同一个集群里同时跑着两套一致性协议,按数据类型分流。
| 数据 | 协议 | 模型 | 为什么这么选 |
|---|---|---|---|
| 临时实例(注册信息) | Distro | AP | 实例列表变化极频繁(心跳、扩缩容),要的是可用——哪怕多返回一个已下线实例,客户端重试一下就好 |
| 持久化实例 + 配置数据 | 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:
| 组件 | 一致性 | 健康检查 | 特点 |
|---|---|---|---|
| Nacos | AP + CP 可选 | 心跳 / TCP / HTTP / MySQL | 注册 + 配置二合一,国内生态好 |
| Eureka | AP | 心跳 | 已停止维护(2.x 闭源) |
| ZooKeeper | CP | 临时节点 + session | 选主期间整个注册中心不可用,这是它做注册中心的硬伤 |
| Consul | CP | 多种 | 自带 KV 和多数据中心 |
2. Nacos 2.x 的 gRPC 改造:从「每秒都在握手」到长连接
Nacos 1.x 的性能瓶颈不在业务逻辑,而在 HTTP 短连接。
| 维度 | 1.x(HTTP 短连接) | 2.x(gRPC 长连接) |
|---|---|---|
| 注册/心跳 | HTTP 轮询,每 5 秒一次心跳 | 长连接本身就是心跳(双向流) |
| 配置变更感知 | 长轮询,29.5 秒一轮 | 服务端主动推送,毫秒级 |
| 连接数 | 每次请求建连(配了 keep-alive 也要频繁重建) | 一个客户端一条连接复用 |
| 端口 | 8848 | 8848(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: truejava
/**
* 配合 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过滤器,它是官方提供的:yamlfilters: - 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、状态码、耗时 | 生产用这个,信息够且开销小 |
HEADERS | BASIC + 请求/响应头 | 排查认证问题时临时开 |
FULL | HEADERS + 请求体 + 响应体 | 只在本地调试用;生产开了会打爆磁盘,还可能泄露敏感数据 |
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 / OpenFeign | SeataFeignClient 自动在 Header 加 TX_XID |
| RestTemplate | SeataRestTemplateAutoConfiguration 注册拦截器 |
| Dubbo | TransactionPropagationFilter |
| 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 | 本地文件(默认) | ❌ 单机,挂了事务状态全丢 | 最快 | 仅本地开发 |
db | MySQL(三张表) | ✅ 多 TC 共享一个库 | 中等 | 生产主流 |
redis | Redis | ✅ | 快 | 性能敏感;但 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 状态机四种方案横向对比:
| 维度 | AT | TCC | SAGA | 本地消息表 |
|---|---|---|---|---|
| 代码侵入 | 零(加注解) | 高(每个参与方写 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」和「上过生产」的分水岭。
关键配置清单(踩过坑才会配的那些项):
| 组件 | 配置项 | 生产推荐值 | 为什么 |
|---|---|---|---|
| Nacos | spring.cloud.nacos.discovery.namespace | 填命名空间 ID(UUID),不是名字 | 填名字会注册到 public,和其他环境串 |
| Nacos | protectThreshold | 核心服务 0.5,非核心 0 | 防雪崩时把流量摊给剩余健康实例 |
| Nacos | spring.cloud.nacos.discovery.ephemeral | true(默认) | 持久化实例下线后还在列表里 |
| Feign | connect-timeout / read-timeout | 1000 / 3000(按 P99 × 1.5 定) | 不配默认 10s,下游抖一下线程池就打满 |
| Feign | max-connections-per-route | 200(默认只有 50) | 默认值在高并发下是最隐蔽的瓶颈 |
| Feign | httpclient.enabled / okhttp.enabled | 开其中一个 | 默认 HttpURLConnection 无连接池 |
| Sentinel | datasource.*.nacos | 必配规则持久化 | 不配重启规则全丢 |
| Sentinel | feign.sentinel.enabled | true | 不写这行 Feign 上的降级完全不生效 |
| Sentinel | eager | true | 默认懒加载,第一次调用才连 Dashboard |
| Gateway | httpclient.connect-timeout / response-timeout | 2000 / 5s | 网关超时要比下游 Feign 超时长一点,否则下游还没返回网关先断 |
| Gateway | DedupeResponseHeader | Access-Control-Allow-Origin RETAIN_FIRST | 网关 + 下游都配 CORS 会出重复头 |
| Seata | service.vgroupMapping.my_tx_group | 必须和 TC 的 cluster 一致 | 对不上就是 no available service |
| Seata | registry.nacos.group | SEATA_GROUP | TC 默认注册在这个 group,写成 DEFAULT_GROUP 找不到 |
| Seata | client.rm.reportRetryCount / lock.retryTimes | 5 / 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(奇数) |
| 端口 | 8848 | 8848 / 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,这一步是启动时完成的,不是每次调用都解析。
调用期有六步:
- 代理对象的
invoke()拿到对应方法的MethodHandler; - 构造请求模板:按
MethodMetadata把参数填进 URL / Header / Body,Body 用Encoder序列化(默认 Jackson); - 执行
RequestInterceptor链:这是透传 token、traceId 的入口; - 负载均衡:
FeignBlockingLoadBalancerClient拦住http://service-name,向ServiceInstanceListSupplier(数据来自 Nacos 本地缓存)要实例列表,用ReactorLoadBalancer选一个,把 URL 替换成真实 IP:Port; - 发起 HTTP 请求:默认
HttpURLConnection(无连接池),生产必须换 Apache HttpClient 或 OkHttp; - 解码与异常处理: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,做五件事:
- 解析 SQL 拿到表名和条件,执行
SELECT ... FOR UPDATE查出修改前的数据 → 前镜像(before image); - 执行业务 SQL;
- 再查一次 → 后镜像(after image);
- 把「前镜像 + 后镜像 + XID + branchId」拼成一条记录写进同库的
undo_log表; - 向 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.enabled | Feign 的 fallback 完全不触发,异常直接抛给上层(必须回归验证) |
| 8 | 事务参与方 deductPoint 被降级 | fallback 抛异常,全局事务回滚,绝不能返回 void 假成功 |
| 9 | MQ 消费抛业务异常 | Tracer.traceEntry 上报,Dashboard 的异常数正确递增 |
| 10 | SphU.entry 后忘记 exit() | 调用链 Context 泄漏,ContextUtil 线程数暴涨,必须在 finally 中 exit |
🚀 变种拓展题
- 把
BlockExceptionHandler改造成支持按资源配置不同降级文案(文案配在 Nacos,支持热更新)。 - 实现一个熔断器状态变更监听器(
EventObserverRegistry.getInstance().addStateChangeObserver),熔断器 OPEN 时自动发企业微信告警并记录到事件表。 - 把本地 Caffeine 兜底升级为二级缓存(Caffeine + Redis),要求 Redis 不可用时自动退化为纯本地。
- 实现集群流控:搭建 Token Server,并配置
fallbackToLocalWhenFail=true,验证 Token Server 挂掉后退化为单机限流。 - 给
@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;验签成功时该头被覆盖为真实用户 |
| 3 | token 已过期 | 返回 401 + code=40102,不穿透到下游 |
| 4 | 管理员封禁用户后,用户用旧 token 请求 | Redis 版本号不匹配,返回 401 + code=40104 |
| 5 | Redis 不可用 | 鉴权降级放行并告警,限流fail-open 放行,不让组件故障拖垮全站 |
| 6 | 1 秒内同一用户发 150 个请求(阈值 100) | 前 100 个通过,其余返回 429 + Retry-After: 1 |
| 7 | Nacos 推下来一份非法 JSON 路由 | 解析异常被捕获,保留原路由表,不出现整站 404 |
| 8 | 新增路由后不发 RefreshRoutesEvent | 路由不生效(必须验证这一步,是最常见的漏写点) |
| 9 | StripPrefix 配成 2 | 下游收到的路径少一层,返回 404(提醒按真实前缀层数配) |
| 10 | 过滤器里误用 RestTemplate 同步调用 | EventLoop 被阻塞,压测时 QPS 断崖下跌,CI 静态扫描应直接拦截 |
🚀 变种拓展题
- 实现全链路灰度:从 Header 取灰度标记写入 Reactor Context,并自定义
ServiceInstanceListSupplier按实例 metadata 过滤。 - 实现请求/响应日志过滤器:用
cacheRequestBody读取 body,响应阶段用ServerHttpResponseDecorator包装,记录完整报文并脱敏。 - 把限流改造成阶梯限流:普通用户 10 QPS、VIP 50 QPS、内部调用不限,阈值配在 Nacos 支持热更新。
- 整合 Sentinel 网关流控,用 API 分组维度给「多个路由共享一个阈值」,并自定义
BlockRequestHandler。 - 实现签名验签过滤器(开放平台场景):校验
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) | 网关必须剥离该头 + 下游只监听内网端口,不能仅依赖头做鉴权豁免 |
🚀 变种拓展题
- 把
RpcContextHolder改造成支持嵌套作用域:提供runAs(ctx, supplier)方法,内部用栈保存旧值,执行完自动还原(定时任务里切换租户的典型需求)。 - 实现上下文透传字段白名单化:从 Nacos 读
rpc.context.pass-through-headers列表,新增字段只改配置不改代码。 - 把透传改造成Micrometer Tracing / SkyWalking 接入版:traceId 不自己生成,而是复用 APM 的 traceId,保证日志能和链路图对上。
- 实现 WebFlux 版本(网关侧):
ThreadLocal在 Reactor 里不可用,要求用Context.put+deferContextual完成同样的透传。 - 实现跨语言透传: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 >= #{count}
</update>
<!-- Confirm:只减 frozen(available 一阶段已扣) -->
<update id="confirmFreeze">
UPDATE t_storage
SET frozen = frozen - #{count}, version = version + 1
WHERE sku_id = #{skuId} AND frozen >= #{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 >= #{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 | 期望行为 |
|---|---|---|
| 1 | Try 因网络超时未执行,TC 下发 Cancel | 识别为空回滚,插入 EMPTY_CANCEL 并返回 true,不动库存 |
| 2 | 迟到的 Try 在 Cancel 之后到达 | 查到 EMPTY_CANCEL 直接返回 false,库存不被冻结,不产生悬挂 |
| 3 | TC 重复下发 Confirm 3 次 | 第 1 次扣减,第 2、3 次因 state != TRY_OK 幂等跳过,frozen 不会被多减 |
| 4 | 库存不足 | freeze 影响行数为 0,抛异常触发全局回滚,其他分支一并 Cancel |
| 5 | 两个线程并发进入同一分支的 Try | 唯一键 uk_xid_branch 冲突,后者走 DuplicateKeyException 幂等返回 |
| 6 | Cancel 与 Try 几乎同时发生 | Cancel 插入占位冲突时抛异常让 TC 重试,下一轮能读到 TRY_OK 走正常回滚 |
| 7 | Confirm 阶段数据库宕机 | 方法抛异常,TC 持续重试直到成功(Confirm 必须最终成功,不可回滚) |
| 8 | 全局事务 timeoutMills 小于 Try 总耗时 | TC 提前回滚,表现为「明明成功了却被撤销」,必须按 P99 耗时放大配置 |
| 9 | 库存服务被 Sentinel 降级返回了兜底成功 | 全局事务感知不到失败,必须让降级方法抛异常或返回失败标识 |
| 10 | tcc_tx_record 数据持续增长 | 配定时任务归档 7 天前的 CONFIRM_OK/CANCEL_OK 记录,避免表膨胀影响写入 |
🚀 变种拓展题
- 把手写防护换成官方
useTCCFence = true,对比两者在「并发 Try/Cancel」场景下的表现差异,并说明tcc_fence_log的清理策略。 - 实现账户服务的 TCC(冻结金额),并和库存服务组成双 TCC 分支,验证任一分支失败时另一分支能正确 Cancel。
- 给 TCC 加补偿巡检任务:扫描
state = TRY_OK且create_time超过 30 分钟的记录,查询 TC 的global_table判断全局事务是否已结束,未结束则告警、已结束则按结果补偿。 - 把库存冻结改造成 Redis + DB 双层:Redis 做热点扣减,DB 做最终一致,说明 TCC 三阶段在双层结构下分别操作哪一层。
- 实现 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 恢复后自动重试清理 |
| 4 | undo_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 + 指标告警,提示缩小事务范围、把非核心操作移出事务 |
🚀 变种拓展题
- 把巡检结果接入 Grafana 看板:画出「全局事务 QPS / 失败率 / P99 耗时 / undo_log 残留数」四个核心面板,并配置分级告警阈值。
- 实现全局事务链路可视化:用 XID 关联各分支日志,提供一个接口返回「分支列表 + 各分支状态 + 耗时瀑布图」数据。
- 把 Seata 接入 SkyWalking:让全局事务在链路图上显示为一个 Span,
XID作为 Tag,排查时可直接从慢链路跳到事务详情。 - 实现对账补偿任务:按日对比订单表与库存流水,发现「订单成功但库存未扣」的差异数据,输出差异报表并支持一键修复。
- 设计降级开关:当 TC 不可用时,让
@GlobalTransactional自动降级为「本地事务 + 消息表最终一致」,并说明这种降级的风险边界。
⚠️ 当日踩坑实录
| ❌ 坑 | ✅ 解决方案 |
|---|---|
Spring Cloud Alibaba 版本和 Spring Boot 版本不匹配,启动报 NoSuchMethodError / ClassNotFoundException | SCA 的版本号前缀就是对应的 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 直接杀进程,调用方报一片 500 | kill -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,1001 | header(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→ 重建@RefreshScopeBean - [ ] 说清
@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 比例 - [ ] 把今天的全家桶配置整理成一份生产落地清单,作为面试时的项目素材