Spring Cloud 微服务生态
🚪 1. Spring Cloud Gateway 的工作原理是什么?¶
1.1 从请求到转发的四个核心组件¶
Spring Cloud Gateway 是基于 WebFlux + Netty 的反应式网关,它的整个处理过程可以抽象成一个管道,有三个关键角色:
| 角色 | 作用 |
|---|---|
| Route(路由) | 定义请求与目标服务之间的映射关系,包含 id、uri、predicates 和 filters。 |
| Predicate(断言) | 用 Java 8 的函数式接口来匹配请求,比如路径匹配、Header 匹配、请求参数匹配等。 |
| Filter(过滤器) | 对请求或响应做修改,分为两种: GatewayFilter(作用于单个路由)和 GlobalFilter(作用于所有路由)。 |
一个请求经过网关时,会先遍历所有 Route,找到第一个匹配的 Predicate 集合,然后执行该路由上配置的 Filter 链,最终通过 Netty 客户端转发到下游服务。
1.2 底层执行流程(WebFlux 非阻塞模型)¶
Client Request → HttpWebHandlerAdapter
→ RoutePredicateHandlerMapping(匹配路由)
→ FilteringWebHandler(组装过滤器链)
→ NettyRoutingFilter(发起代理请求)
→ 响应反向经过 Filter 链 → 返回客户端
整个过程中 没有阻塞线程,完全基于 Reactor 的事件循环。每个请求在 Netty 连接池中获取一个客户端连接,通过 WebClient 发出异步 HTTP 调用。
1.3 扩展点和常见用法¶
-
自定义全局过滤器:实现
GlobalFilter接口,可以全局控制鉴权、日志、流量染色。 -
路由动态刷新:通过
GatewayControllerEndpoint或集成配置中心(Nacos/Apollo)动态修改路由,不用重启。
它和 Zuul 1.x 最大的区别是异步非阻塞和函数式编程,从而在面对高并发时消耗更少的线程资源。
🧬 2. Spring Cloud Feign 的底层原理是什么?如何实现负载均衡和重试?¶
2.1 Feign 的本质:动态代理 + 契约解析¶
Feign 本身是一个 声明式 HTTP 客户端,它通过 JDK 动态代理,把接口方法调用转换成 HTTP 请求。
@FeignClient(name = "order-service", configuration = FeignConfig.class)
public interface OrderClient {
@GetMapping("/orders/{id}")
Order getOrder(@PathVariable Long id);
}
当调用 getOrder(123) 时,背后发生了什么?
-
Feign 在运行时为
OrderClient生成代理对象。 -
通过
Contract(默认是 Spring MVC 的SpringMvcContract)解析方法上的注解,生成RequestTemplate。 -
代理方法拦截后,将参数值填充到
RequestTemplate,交给Client组件执行 HTTP 请求。 -
Spring Cloud Feign 使用
FeignBlockingLoadBalancerClient或早期的LoadBalancerFeignClient替换默认的 HTTP 客户端,集成了负载均衡。
2.2 负载均衡集成¶
Spring Cloud Feign 依赖 Spring Cloud LoadBalancer(或 Ribbon)实现客户端负载均衡。过程:
-
解析
@FeignClient(name = "order-service")中的服务名。 -
通过服务发现组件(Nacos/Consul)获取该服务名对应的所有实例。
-
内置的
ReactorServiceInstanceLoadBalancer根据策略(轮询/随机/权重)选出一个实例。 -
将
RequestTemplate中的目标 URL 替换为选中的真实地址,发起请求。
如果你想替换负载均衡算法,只需要自定义一个 ServiceInstanceListSupplier 或 LoadBalancer Bean。
2.3 重试机制如何实现¶
Feign 自带了重试功能,但需要显式开启:
-
Feign 内置重试器:在
Feign.Builder中配置Retryer.Default,可以设置最大重试次数、初始间隔等。不过这只适用于幂等方法,GET 请求默认是安全的。 -
配合 Spring Retry:在配置中增加
spring.cloud.loadbalancer.retry.enabled=true,并提供一个RetryTemplateBean。这样底层LoadBalancer在发现连接失败时会自动重试下一个实例,起到“故障转移”的效果。
spring:
cloud:
loadbalancer:
retry:
enabled: true
feign:
client:
config:
default:
retryer: feign.Retryer.Default
注意:只应该重试那些幂等的请求,非幂等请求重试可能导致数据重复或状态异常,需要配合接口幂等设计一起使用。
🛡️ 3. Agent 调用多个微服务,如何用 Gateway 统一做鉴权和限流?¶
这个问题很贴合业务实际。Agent 服务背后通常会调用模型推理、知识库、工具链等多个微服务,如果每个服务都单独鉴权限流,不仅重复开发,还会导致安全管控零散。Gateway 作为唯一入口,正是统一横切关注点的最佳位置。
3.1 统一鉴权:GlobalFilter 实现 JWT 校验¶
我们可以写一个全局过滤器,对所有 /api/** 的请求进行 JWT 校验:
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 从请求头中提取 Authorization
String token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
// 2. 校验 token,并解析出用户信息
if (token == null || !TokenUtils.verify(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 3. 将用户信息写入请求头,传递给下游服务
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header("X-User-Id", TokenUtils.getUserId(token))
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
}
}
-
这样下游微服务直接从
X-User-Id头中获取身份,无需重复解析 JWT。 -
也可以在这里集成 OAuth2、API Key 等其他认证方式。
3.2 统一限流:Redis + Lua 实现分布式限流¶
Gateway 内置了 RequestRateLimiter GatewayFilter,一般我们选用 Redis 实现生产级的分布式限流:
spring:
cloud:
gateway:
routes:
- id: agent-route
uri: lb://agent-core-service
predicates:
- Path=/api/v1/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 每秒允许的请求数
redis-rate-limiter.burstCapacity: 20 # 突发最大容量
key-resolver: "#{@userKeyResolver}" # 基于用户维度的限流
需要注册一个 KeyResolver Bean,例如基于用户 ID 或 IP:
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst("X-User-Id")
);
}
这样每个用户每秒最多 10 个请求,超过时返回 429 Too Many Requests。
3.3 与 Agent 场景深度结合¶
-
API Key 鉴权:每个 Agent 客户端分配一个 API Key,网关过滤器验证 Key 的有效性和权限范围,无需在每个微服务重复实现。
-
动态路由:根据请求路径
/agent/tool-*动态路由到不同的工具微服务,由 Gateway 统一控制。 -
限流维度:除了用户维度,还可以针对 Agent 调用的 LLM 推理接口设置更严苛的限流(比如每分钟只允许 100 次),防止成本失控。
-
监控和日志:在过滤器中记录每次请求的
userId、serviceName、cost到 Micrometer,方便在大盘看到哪个 Agent 在狂刷接口。
把鉴权限流集中到网关,就像给整个 Agent 系统装上了智能门禁和流量阀门——既保证了安全性,又避免了下游服务的防护短板,是所有高流量 AI 应用必须要走的一步。
🔍 4. Nacos 作为注册中心和配置中心的核心原理?与 Eureka 的区别?¶
Nacos 是阿里巴巴开源的服务发现和配置管理平台,它把 注册中心 和 配置中心 合二为一,核心优势在于对 CAP 理论的灵活支持,以及比 Eureka 更丰富的数据模型。
4.1 注册中心核心原理¶
-
数据模型:Nacos 将服务信息抽象为
Namespace→Group→Service→Cluster→Instance五层结构,天然支持多环境隔离、同城多活。 -
注册与发现机制:
- 服务提供者启动时,通过 OpenAPI 向 Nacos Server 发送
POST /nacos/v1/ns/instance注册自己,包含 IP、端口、元数据等信息。 -
服务消费者订阅服务名,Nacos Server 将当前健康的实例列表推送给消费者。后续若实例变化,使用 UDP 推送 + 定时轮询 混合机制保证最终一致。
-
健康检查:
- 临时实例 采用客户端主动上报心跳(5s 间隔),Nacos 15s 内未收到心跳则标记不健康,30s 剔除。这符合 AP 模型,适合弹性扩缩的微服务。
-
持久实例 由 Nacos Server 主动探测(TCP/HTTP),即使客户端断连也不会立即删除,适合数据库等有状态服务,体现 CP 模型。
-
集群模式与 CAP 切换:
- Nacos Server 集群使用简化的 Raft 协议(CP 模式)或 Distro 协议(AP 模式)进行数据同步。默认 AP,但可以在注册持久实例或进行配置管理时切换到 CP,这是它最大的灵活性所在。
4.2 配置中心核心原理¶
-
配置存储:配置数据保存在内置 Derby(单机)或 MySQL(集群)中,Nacos Server 启动时加载到内存。
-
配置推送:客户端通过长轮询(Long Polling)监听配置变更。客户端发起监听请求,服务端阻塞 30s 等待配置变化,如果有变化则立即返回新配置;若超时则返回空,客户端马上发起下一次长轮询。这种方式既保证了实时性,又避免了轮询风暴。
-
动态刷新:配合 Spring Cloud Alibaba Nacos Config,使用
@RefreshScope或@Value+RefreshAutoConfiguration,可在运行时刷新 Bean。
4.3 与 Eureka 的核心区别¶
| 维度 | Nacos | Eureka 1.x |
|---|---|---|
| 功能范围 | 注册中心 + 配置中心 | 纯注册中心,配置需配合 Spring Cloud Config |
| CAP 模型 | 可切换 AP/CP | 严格 AP(优先可用性,牺牲一致性) |
| 健康检查 | 支持临时实例心跳(AP)和持久实例主动探测(CP) | 仅客户端心跳 + 自我保护模式(即便所有实例都不健康也不剔除) |
| 服务变更通知 | UDP 推送 + 定时拉取,秒级 | 客户端增量拉取(30s),变更传播较慢 |
| 多数据中心 | 原生支持命名空间、集群级别就近路由 | 需要手动配置 Region/Zone |
| 控制台 | 功能丰富的 Web 管理界面,可实时上下线实例、灰度发布 | 界面相对简单,操作能力有限 |
| 生态 | 完美融入 Spring Cloud Alibaba,支持阿里系中间件 | Netflix 全系,但已进入维护模式 |
Eureka 的自我保护模式虽然避免了因网络分区导致的大量实例误剔,但在实际生产中也容易掩盖真正下线的服务,导致调用失败。而 Nacos 通过 AP/CP 模式切换 解决了这个难题。
🧪 5. Agent 平台有多个版本的模型服务(GPT-4、Claude、本地模型),如何用微服务架构实现灰度路由和流量切换?¶
这是一个典型的“多版本服务治理”场景。灰度路由的终极目标是:在不中断线上服务的前提下,将特定流量导向新版本模型,实现可控的体验验证和迁移。
我习惯把整个灰度拆成三个层级:入口标识 → 标签传递 → 路由选择。
5.1 架构设计:基于元数据的服务分组¶
假设我们有三个模型服务:
-
model-service-gpt4(v1 稳定版) -
model-service-gpt4(v2 新版本,需要灰度) -
model-service-claude -
model-service-local-llama
在 Nacos 注册中心,我们可以给每个实例打上丰富的 元数据标签:
spring:
cloud:
nacos:
discovery:
metadata:
model-type: gpt4
version: v1.0
ability: text,code
# 灰度标签
gray-release: stable
灰度节点的标签可以设为 gray-release: canary。
5.2 入口标识:如何区分灰度流量¶
在 API Gateway 层,我们可以根据请求头(如 X-Gray-Tag: true、X-User-ID 白名单)或请求参数来判断该请求是否应该走灰度。
比如,内部测试人员带特殊 Header 的请求被识别为灰度流量:
// Gateway 全局过滤器
if (exchange.getRequest().getHeaders().containsKey("X-Gray-Enable")) {
// 设置灰度标记,向下游传递
exchange.getRequest().mutate().header("X-Gray-Release", "canary");
}
5.3 标签传递:保证整条链路灰度一致¶
如果 Agent 调用链路是 Gateway → 编排服务 → 模型服务,必须确保灰度标记沿着调用链传递。我们可以用 Spring Cloud Sleuth 或者自定义 RequestInterceptor(Feign) / ClientHttpRequestInterceptor(RestTemplate)来透传灰度头。
@Bean
public FeignInterceptor feignInterceptor() {
return (requestTemplate) -> {
String grayTag = MDC.get("gray-release"); // 从上下文获取
if (grayTag != null) {
requestTemplate.header("X-Gray-Release", grayTag);
}
};
}
5.4 路由选择:在负载均衡中实现按标签匹配¶
最关键的一步在于,服务消费者从 Nacos 拿到所有实例后,需要根据灰度标记选择对应的节点。我们可以自定义 ServiceInstanceListSupplier 来过滤实例。
Spring Cloud LoadBalancer 默认会返回所有健康实例,我们扩展它:
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
// 从上下文中获取灰度标记
String grayTag = GrayContextHolder.get();
List<ServiceInstance> instances = discoveryClient.getInstances("model-service");
// 如果灰度流量,优先筛选 canary 节点,若无则回退 stable
if ("canary".equals(grayTag)) {
List<ServiceInstance> canaryList = instances.stream()
.filter(i -> "canary".equals(i.getMetadata().get("gray-release")))
.collect(Collectors.toList());
if (!canaryList.isEmpty()) {
return Mono.just(new DefaultResponse(canaryList.get(random.nextInt(canaryList.size()))));
}
}
// 普通流量仅选择 stable 节点
List<ServiceInstance> stableList = instances.stream()
.filter(i -> "stable".equals(i.getMetadata().get("gray-release")))
.collect(Collectors.toList());
return Mono.just(new DefaultResponse(stableList.get(random.nextInt(stableList.size()))));
}
}
然后将这个 GrayLoadBalancer 注册为默认的负载均衡器。
5.5 更灵活的手段:利用 Nacos 配置中心动态控制灰度规则¶
把灰度的 白名单用户、流量比例 等规则放在 Nacos 配置中心,运行时动态刷新,无需重启。例如:
# 配置 dataId: model-gray-rule
gray:
percentage: 10
white-list: user123,user456
model-mapping:
canary: gpt4-v2
在 Gateway 或负载均衡器中实时读取这些配置,决定是否将请求标记为灰度。甚至可以通过修改 Nacos 配置,在线将灰度比例从 10% 调到 50%,瞬间完成流量切换。
5.6 多模型流量切换的高级玩法¶
如果需要根据请求内容(比如用户问的是代码问题,还是翻译问题)路由到不同的模型(GPT-4 负责代码,Claude 负责翻译),可以利用 Gateway 的 Path 或 Header 断言 + Nacos 元数据过滤 来做 内容路由。服务名统一叫 model-service,不同模型用不同的集群或元数据区分,实现按能力分发。
整个方案最核心的设计理念是:用元数据给实例打标,用染色标记透传流量,用负载均衡策略实现路由隔离。这套思路不仅适用于模型服务,也同样适用于多版本 API、多地域部署等灰度场景。当你的 Agent 平台需要从单一模型平滑过渡到多模型混合推理时,这种架构能让你在不修改任何业务代码的前提下,动态调整流量,真正做到了“发布即上线,上线可验证,验证再全量”。
2.6 Spring Bean 作用域与依赖注入¶
1、基础题:Spring Bean 的作用域有哪些?默认是什么?¶
难度级别:⭐(singleton、prototype、request、session、application)
Answer
-
singleton(默认):整个容器一个实例
-
prototype:每次请求新实例
-
request:每个 HTTP 请求一个实例(Web 环境)
-
session:每个 Session 一个实例(Web 环境)
-
application:每个 ServletContext 一个实例
[补充下]
2、进阶题:Spring 中@Lazy 注解的作用是什么?如何解决循环依赖?¶
难度级别:⭐⭐⭐⭐(三级缓存、提前暴露、构造器注入失效、@Lazy 原理)
1️⃣ Common Answer
@Lazy 是懒加载,用的时候才创建 Bean。循环依赖的话,Spring 会自动解决,加@Lazy 也可以。但是构造器注入的循环依赖解决不了。
2️⃣ Impressive Answer
-
@Lazy 的本质:注入一个代理对象,真实 Bean 第一次使用时才创建。可打破循环依赖,也可优化启动速度(大 Bean 懒加载)。
-
Spring 循环依赖解决方案:setter/字段注入的循环依赖靠三级缓存解决:
- 一级缓存:成品 Bean
- 二级缓存:早期暴露的 Bean(未填充属性)
-
三级缓存:ObjectFactory,用于生成 AOP 代理的早期引用
-
构造器注入失效原因:构造器执行时 Bean 还没暴露,无法注入循环依赖。解决:字段/ setter 注入,或其中一个加
@Lazy。 -
最佳实践:推荐构造器注入(不可变、易测试),循环依赖时局部用@Lazy 打破环,而不是全局改用字段注入。
3️⃣ Key Differences
| 维度 | Common Answer | Impressive Answer |
|---|---|---|
| 结构性 | 说了@Lazy 和三级缓存但较浅 | @Lazy 原理→三级缓存→构造器失效→最佳实践 |
| 技术深度 | 不知道三级缓存的具体作用 | 清楚三级缓存各自存放什么 |
| 实践经验 | 没说如何优雅解决 | 推荐构造器注入 + 局部@Lazy 的组合 |
| 面试官印象 | 知道现象 | 理解 Spring 设计哲学,有架构权衡能力 |
3、进阶题:@Autowired、@Resource、@Inject 三种注入方式的区别?Spring 推荐哪种?¶
难度级别:⭐⭐⭐(byType vs byName、JSR-250 vs JSR-330 vs Spring 原生、构造器注入推荐理由)
1️⃣ Common Answer
@Autowired 是 Spring 的注解,按类型注入。@Resource 是 Java 标准注解,按名称注入。@Inject 也是 Java 标准的,和 @Autowired 差不多。Spring 推荐用构造器注入。
2️⃣ Impressive Answer
- 三者对比:
| 维度 | @Autowired | @Resource | @Inject |
|---|---|---|---|
| 来源 | Spring 原生 | JSR-250(Java 标准) | JSR-330(Java 标准) |
| 匹配策略 | 先 byType,再 byName | 先 byName,再 byType | 先 byType,再 byName |
| required 属性 | 支持(默认 true) | 不支持 | 不支持 |
| 配合限定符 | @Qualifier | name 属性 | @Named |
- Spring 官方推荐构造器注入,理由:
- 不可变性:字段可以声明为 final,保证依赖不会被意外修改
- 完整性:对象创建时就注入所有依赖,不会出现"半初始化"状态
- 可测试性:单元测试时直接 new 传参,不依赖 Spring 容器
-
循环依赖暴露:构造器注入会在启动时暴露循环依赖,而不是运行时才发现
-
实践建议:必选依赖用构造器注入,可选依赖用 setter 注入 +
@Autowired(required=false);避免字段注入(虽然代码最少,但可测试性最差)。
3️⃣ Key Differences
| 维度 | Common Answer | Impressive Answer |
|---|---|---|
| 结构性 | 只说了基本区别 | 对比表格→推荐理由→实践建议 |
| 技术深度 | 不知道匹配策略的差异 | 清楚 byType/byName 的优先级区别 |
| 实践经验 | 只说了"推荐构造器注入" | 给出 4 个具体理由和必选/可选的区分策略 |
| 面试官印象 | 知道有区别 | 理解设计哲学,有最佳实践意识 |
4、场景题:Agent 系统中有多个 LLM 客户端实现(OpenAI、Claude、本地模型),如何用 Spring 优雅管理多实现的动态切换?¶
难度级别:⭐⭐⭐(@Qualifier、策略模式 + Map 注入、@ConditionalOnProperty、SPI 机制)
1️⃣ Common Answer
可以定义一个 LlmClient 接口,然后写多个实现类。用 @Qualifier 注解指定注入哪个。或者用 @ConditionalOnProperty 根据配置决定加载哪个实现。运行时切换的话可以用策略模式。
2️⃣ Impressive Answer
- 策略模式 + Map 自动注入(最推荐):
public interface LlmClient {
String getModelName();
ChatResponse chat(ChatRequest request);
}
@Service
public class LlmRouter {
private final Map<String, LlmClient> clientMap;
// Spring 自动将所有 LlmClient 实现注入到 Map 中
// key = Bean 名称,value = Bean 实例
public LlmRouter(Map<String, LlmClient> clientMap) {
this.clientMap = clientMap;
}
public ChatResponse route(String modelName, ChatRequest request) {
LlmClient client = clientMap.get(modelName);
if (client == null) {
throw new UnsupportedModelException("不支持的模型: " + modelName);
}
return client.chat(request);
}
}
-
动态切换:路由策略存 Nacos 配置中心,通过
@RefreshScope实现运行时切换默认模型,无需重启。 -
扩展性设计:新增模型只需实现
LlmClient接口并注册为 Spring Bean,路由器自动感知,零修改扩展(开闭原则)。 -
配合 @ConditionalOnProperty:按环境启停,如测试环境不加载 GPT-4 客户端(节省成本),生产环境全量加载。
3️⃣ Key Differences
| 维度 | Common Answer | Impressive Answer |
|---|---|---|
| 结构性 | 列举了几种方式但没有组合 | Map 注入→动态切换→扩展性→环境隔离,完整方案 |
| 技术深度 | 不知道 Map 自动注入的特性 | 利用 Spring 的 Map |
| 实践经验 | 没考虑运行时切换 | 配合 Nacos + @RefreshScope 实现热切换 |
| 面试官印象 | 知道策略模式 | 能用 Spring 特性优雅实现,有架构设计能力 |
2.7 响应式编程 Spring WebFlux¶
1、基础题:Spring WebFlux 和 Spring MVC 有什么区别?¶
难度级别:⭐⭐(阻塞 vs 非阻塞、Reactor、背压、适用场景)
Answer
| 维度 | Spring MVC | Spring WebFlux |
|---|---|---|
| 编程模型 | 阻塞式(Servlet) | 非阻塞响应式(Reactor) |
| 容器 | Tomcat/Jetty | Netty/Undertow |
| 数据流 | Request → Response | Flux/Mono 响应流 |
| 适用场景 | 传统 CRUD、IO 密集型 | 高并发、流式处理、SSE |
2、进阶题:WebFlux 中的 Mono 和 Flux 是什么?如何处理异常?¶
难度级别:⭐⭐⭐(Reactor、onErrorResume、onErrorReturn、全局异常处理)
1️⃣ Common Answer
Mono 是返回 0 或 1 个元素,Flux 是返回多个。异常处理可以用 onErrorReturn 返回默认值,或者用 onErrorResume 继续执行。也可以在全局用@ControllerAdvice 处理。
2️⃣ Impressive Answer
-
Mono/Flux 本质:Reactor 的响应式类型,Mono
表示 0/1 个元素(如 Optional),Flux 表示 0~N 个元素(如 List)。惰性执行,订阅后才触发。 -
异常处理策略:
- 局部处理:
onErrorReturn(默认值)、onErrorResume(恢复流)、onErrorMap(转换异常) - 全局处理:实现
WebExceptionHandler或用@ControllerAdvice + @ExceptionHandler(WebFlux 版本) -
finally 语义:
doFinally(SignalType→回调),无论成功/失败都执行 -
背压(Backpressure):下游控制上游生产速率,避免内存溢出。Reactor 默认策略是
onNext拉动式,Flux 会自动处理。 -
Agent 场景:流式输出(SSE)用
Flux<ServerSentEvent<String>>;多工具并行调用用Flux.mergeSequential()保持顺序,Flux.combineLatest()合并结果。
3️⃣ Key Differences
| 维度 | Common Answer | Impressive Answer |
|---|---|---|
| 结构性 | 说了 Mono/Flux 和几种处理方式 | 类型定义→异常处理→背压→Agent 场景 |
| 技术深度 | 不知道背压的概念 | 能解释背压的作用和 Reactor 的实现 |
| 实践经验 | 没有场景化应用 | 给出 Agent 流式输出和多工具并发的方案 |
| 面试官印象 | 会用 WebFlux | 理解响应式编程范式,能解决复杂场景 |
4、进阶题:WebFlux 的线程模型是什么?为什么不能在响应式链中调用阻塞 API?¶
难度级别:⭐⭐⭐(EventLoop 模型、Scheduler 调度、publishOn/subscribeOn、阻塞检测)
1️⃣ Common Answer
WebFlux 用的是 Netty 的 EventLoop 线程模型,线程数很少,默认是 CPU 核心数。如果在响应式链里调用阻塞 API,会把 EventLoop 线程阻塞住,导致其他请求也处理不了。所以阻塞操作要放到单独的线程池里。
2️⃣ Impressive Answer
-
EventLoop 线程模型:Netty 默认创建
CPU 核心数个 EventLoop 线程,每个线程负责多个 Channel 的 I/O 事件。所有请求共享这几个线程,靠非阻塞 I/O + 事件驱动实现高并发。 -
阻塞的致命影响:假设 4 核 CPU = 4 个 EventLoop 线程,一个阻塞调用占住 1 个线程 500ms,就意味着 25% 的处理能力被浪费。如果 4 个线程都被阻塞,整个服务完全停止响应。
-
正确处理阻塞调用:
// 错误:直接在响应式链中调用阻塞 API
Mono.just(request)
.map(req -> jdbcTemplate.query(...)); // 阻塞 EventLoop!
// 正确:用 subscribeOn 切换到阻塞线程池
Mono.fromCallable(() -> jdbcTemplate.query(...))
.subscribeOn(Schedulers.boundedElastic()); // 切到弹性线程池
- publishOn vs subscribeOn:
subscribeOn影响整个链的订阅线程(从源头切换);publishOn影响下游操作的执行线程(从当前位置切换)。阻塞调用用subscribeOn,后续处理切回用publishOn。
3️⃣ Key Differences
| 维度 | Common Answer | Impressive Answer |
|---|---|---|
| 结构性 | 知道不能阻塞但说不清后果 | 线程模型→阻塞影响量化→正确写法→调度器区别 |
| 技术深度 | 不知道 publishOn 和 subscribeOn 的区别 | 清楚两者的作用范围差异 |
| 实践经验 | 只说了"放到线程池" | 有具体的代码对比(错误 vs 正确) |
| 面试官印象 | 知道原则 | 能量化阻塞影响,知道如何正确处理 |
5、场景题:Agent 需要同时调用 3 个外部 API(搜索、天气、数据库),如何用 WebFlux 实现并发调用并合并结果?¶
难度级别:⭐⭐⭐(Mono.zip、Flux.merge、超时控制、fallback 降级)
1️⃣ Common Answer
可以用 Mono.zip 把三个 Mono 合并,它们会并发执行。或者用 Flux.merge 合并多个流。加个 timeout 设置超时时间,超时了就返回默认值。
2️⃣ Impressive Answer
- Mono.zip 并发合并:
public Mono<AgentContext> gatherContext(String query) {
Mono<SearchResult> searchMono = searchClient.search(query)
.timeout(Duration.ofSeconds(3))
.onErrorResume(e -> Mono.just(SearchResult.empty()));
Mono<WeatherInfo> weatherMono = weatherClient.getWeather(query)
.timeout(Duration.ofSeconds(2))
.onErrorResume(e -> Mono.just(WeatherInfo.unavailable()));
Mono<DbResult> dbMono = Mono.fromCallable(() -> dbService.query(query))
.subscribeOn(Schedulers.boundedElastic())
.timeout(Duration.ofSeconds(5))
.onErrorResume(e -> Mono.just(DbResult.empty()));
return Mono.zip(searchMono, weatherMono, dbMono)
.map(tuple -> AgentContext.builder()
.search(tuple.getT1())
.weather(tuple.getT2())
.dbResult(tuple.getT3())
.build());
}
- 关键设计点:
- 独立超时:每个调用设置不同的超时时间(搜索 3s、天气 2s、DB 5s),而非统一超时
- 独立降级:
onErrorResume返回空结果而非抛异常,一个失败不影响其他 -
阻塞隔离:DB 查询是阻塞的,用
subscribeOn(Schedulers.boundedElastic())隔离到弹性线程池 -
进阶优化:如果某个 API 非必需(如天气),可以用
Mono.zipDelayError()延迟错误处理,优先返回已完成的结果;配合Mono.firstWithSignal()实现"谁先返回用谁"的竞速模式。
3️⃣ Key Differences
| 维度 | Common Answer | Impressive Answer |
|---|---|---|
| 结构性 | 只说了 Mono.zip | 完整代码→独立超时/降级→阻塞隔离→进阶优化 |
| 技术深度 | 不知道阻塞调用需要隔离 | DB 查询用 subscribeOn 切线程池 |
| 实践经验 | 统一超时 | 每个调用独立超时和降级策略 |
| 面试官印象 | 知道 API | 有生产级的并发编排经验 |