一个请求跨越多个服务 — 分布式请求全链路图解

从网关入口到服务间调用,从消息队列到链路追踪,看懂数千节点如何协同工作

Spring Cloud Nacos Feign/RPC RabbitMQ Sentinel Sleuth+Zipkin

一、一个跨服务请求的完整旅程

一次"下单"请求,从客户端发出,到网关路由、服务间RPC调用、MQ异步通知,经历5个服务节点、4种中间件——这就是分布式系统的日常。

Client HTTP请求 HTTP Spring Cloud Gateway RoutePredicateFactory GatewayFilterChain LoadBalancerClient FilterChain .filter() Order Service 订单服务 @RestController OrderService Feign Client RabbitTemplate RPC Feign.invoke() User Service 用户服务 @RestController UserService MySQL MQ convertAndSend() Notification 通知服务 @RabbitListener EmailService 可能出错的位置 1. Gateway → Service A: 超时/服务不可用 (503) 2. Service A → Service B: RPC调用失败/重试耗尽 3. Service A → MQ: 消息堆积/投递失败 Nacos 服务注册中心 instance list health check getInstances() 代码行映射 GatewayFilterChain LoadBalancer.choose() Feign.invoke() convertAndSend() @RabbitListener getInstances()
图1-1:分布式请求全链路流程 — 一次下单请求从客户端到5个服务节点的完整路径
💻 生活类比:微服务 = 公司各部门

微服务 = 公司各部门(订单部 / 用户部 / 通知部),各自独立办公

Gateway 网关 = 公司前台,来访客户先到前台登记,前台根据需求指路到对应部门

RPC 调用 = 内线电话,部门之间直接通话解决需要协作的事情

MQ 消息队列 = 内部快递,不用等人签收,把文件放前台就行,对方有空再来取

Nacos 服务注册 = 公司通讯录,新员工入职登记、离职删记录、定期更新

请求流转关键代码行

1. 网关过滤GatewayFilterChain.filter() 请求经过前置过滤器链,做路由匹配、鉴权、限流

2. 负载均衡选择LoadBalancerClient.choose() 从 Nacos 拿到实例列表后选择一台机器

3. RPC 远程调用Feign.invoke() Order Service 通过 Feign 同步调用 User Service

4. 发送消息RabbitTemplate.convertAndSend() 订单创建后发消息到 MQ,异步通知

5. 消费消息@RabbitListener.onMessage() Notification Service 监听到消息后发送邮件/短信

EXPLORER
▼ spring-cloud-gateway
▼ core
FilteringWebHandler.java
GatewayFilterChain.java
RoutePredicateHandler.java
▼ filter
LoadBalancerFilter.java
RateLimiterFilter.java
// FilteringWebHandler.java — Gateway 过滤器链核心 public class FilteringWebHandler implements WebHandler { private final List<GatewayFilter> filters; public FilteringWebHandler(List<GatewayFilter> filters) { this.filters = new ArrayList<>(filters); this.filters.sort(AnnotationAwareOrderComparator.INSTANCE); } @Override public Mono<Void> handle(ServerWebExchange exchange) { // 1. 构建过滤器链,依次执行每个 GatewayFilter return new DefaultGatewayFilterChain(filters) .filter(exchange) // ← GatewayFilterChain.filter() .then(() -> Mono.empty()); } private static class DefaultGatewayFilterChain { public Mono<Void> filter(ServerWebExchange exchange) { // 2. 递归调用:当前 filter 执行后调用下一个 return filters.get(index).filter(exchange, next); } } } // 流程:RoutePredicate匹配 → FilterChain.filter() → 负载均衡 → 转发请求

二、服务注册与发现——服务之间怎么找到对方

在分布式系统中,服务实例随时可能上下线。Nacos 作为服务注册中心,让服务之间不需要硬编码 IP 地址,而是动态发现彼此。

Nacos Server 服务注册中心 instance registry health check table Service A 启动 Order Service 1. 注册 NacosServiceRegistry .register() 心跳上报 NacosHeartbeatTask 2. 心跳 每5s/次 Service B 查询 User Service 3. 查询 getInstances() 本地缓存 serviceMap cache 4. 缓存 Service A 宕机 心跳超时! 5. 心跳超时 deregister() 订阅者收到通知 更新本地缓存 6. 推送变更 notify subscribers 健康检查机制 客户端心跳: 每5s上报一次 服务端探针: 15s未收到→不健康, 30s→剔除
图2-1:Nacos 服务注册与发现流程 — 注册、心跳、查询、下线通知完整生命周期
💻 生活类比:Nacos = 公司通讯录

新员工入职 = 服务启动,调用 register() 登记到通讯录

每天打卡 = 心跳上报 NacosHeartbeatTask.run(),不打就当离职了

查通讯录找人 = 服务查询 getInstances(),找到对方的位置

离职删记录 = 心跳超时后 deregister(),通讯录中删除,群发通知

有更新群发通知 = Nacos 推送变更,订阅者更新本地缓存

EXPLORER
▼ nacos-discovery
▼ registry
NacosServiceRegistry.java
NacosRegistration.java
▼ heartbeat
NacosHeartbeatTask.java
// NacosServiceRegistry.java — 服务注册核心代码 public class NacosServiceRegistry implements ServiceRegistry<Registration> { private final NamingService namingService; @Override public void register(Registration registration) { // 服务启动时注册实例到 Nacos Instance instance = new Instance(); instance.setIp(registration.getHost()); instance.setPort(registration.getPort()); instance.setWeight(1.0); instance.setHealthy(true); namingService.registerInstance(serviceName, group, instance); } @Override public void deregister(Registration registration) { // 服务下线时注销实例 namingService.deregisterInstance(serviceName, group, instance); } } // NacosHeartbeatTask.java — 心跳上报 public class NacosHeartbeatTask implements Runnable { @Override public void run() { // 每5秒向 Nacos 发送心跳 if (!instance.isHealthy()) { namingService.beat(serviceName, beatInfo); } } }
💡 要点

服务实例有三种状态:健康(心跳正常)→ 不健康(15s未收到心跳,不路由但保留)→ 剔除(30s未收到心跳,从列表删除)

客户端会本地缓存实例列表,即使 Nacos 短暂不可用,服务间仍可通信(最终一致性)

三、负载均衡——请求到底打给哪台机器

一个服务部署了3台机器,请求来了给谁?负载均衡就是"前台分配客户"的策略——轮着来、抽签、能者多劳、还是看谁闲着就给谁。

请求来源 Service A LoadBalancer Ribbon / Spring Cloud LB 实例列表 [A, B, C] IRule 策略选择 chooseServer() Instance A 192.168.1.10:8080 Instance B 192.168.1.11:8080 Instance C 192.168.1.12:8080 策略对比 轮询 RoundRobin: ①→A, ②→B, ③→C, ④→A 按顺序分配 随机 Random: ①→B, ②→A, ③→C 抽签决定 加权 Weighted: A(w=5)多, B(w=3)中, C(w=2)少 最少连接 BestAvailable: 谁手上活少分给谁 BaseLoadBalancer.chooseServer() → IRule.choose() → Server instance
图3-1:负载均衡决策流程 — 从实例列表中选择目标服务器的过程
💻 生活类比:负载均衡 = 前台分配客户

轮询 = 按顺序分,A、B、C、A、B、C... 公平但不管能力

随机 = 抽签决定,简单但不均匀

加权 = 能者多劳,配置高的机器分到更多请求

最少连接 = 看谁手上活少分给谁,最合理但开销大

EXPLORER
▼ ribbon
▼ loadbalancer
BaseLoadBalancer.java
▼ rule
RoundRobinRule.java
RandomRule.java
WeightedResponseTimeRule.java
BestAvailableRule.java
// BaseLoadBalancer.java — 负载均衡核心 public class BaseLoadBalancer extends AbstractLoadBalancer { private final IRule rule = new RoundRobinRule(); public Server chooseServer(Object key) { // 委托给 IRule 策略来选择服务器 return rule.choose(key); } } // RoundRobinRule.java — 轮询策略 public class RoundRobinRule extends AbstractLoadBalancerRule { private AtomicInteger nextIndex = new AtomicInteger(0); @Override public Server choose(Object key) { List<Server> servers = getLoadBalancer().getAllServers(); // 轮询:取余选择下一个服务器 int next = nextIndex.getAndIncrement() % servers.size(); return servers.get(next); } } // WeightedResponseTimeRule.java — 加权策略 // 根据响应时间动态计算权重: 响应越快权重越高
策略原理适用场景代码入口
RoundRobinRule按顺序轮流分配机器配置相同nextIndex % size
RandomRule随机选择一台简单场景Random.nextInt()
WeightedResponseTimeRule响应越快权重越高机器配置不同weightCalcTimer
BestAvailableRule选并发请求最少的长连接场景server.getActiveRequests()

四、熔断与限流——系统怎么自我保护

当下游服务崩溃或流量激增,系统需要"保险丝"自动断开、"闸门"控制流量,防止雪崩效应拖垮整个链路。

CLOSED 正常营业 请求正常放行 tryAcquire() → PASS 计数失败率: 3/10 = 30% OPEN 暂停营业 所有请求快速拒绝 tryAcquire() → REJECT 失败率超阈值: 6/10 = 60% > 50% HALF-OPEN 试营业 放1个请求探测 tryAcquire() → PROBE 成功→CLOSED / 失败→OPEN 失败率超阈值 超时后 探测成功 探测失败
图4-1:熔断器状态机 — Closed → Open → Half-Open 三态循环
💻 生活类比:熔断 = 保险丝

CLOSED 正常营业 = 电路正常,电流畅通,计数器默默统计失败率

OPEN 暂停营业 = 电流太大,保险丝自动断开,所有请求直接拒绝(快速失败)

HALF-OPEN 试营业 = 过一会儿合上保险丝试试,放一个请求过去看看恢复没有

限流 = 闸门 = 每秒只放 N 个人进门,多了就在外面排队

Request NodeSelectorSlot 构建调用树 buildFlowTree() FlowSlot 检查 QPS checkFlow() DegradeSlot 检查熔断 checkDegrade() Entry 通过! SphU.entry() 业务 逻辑 BlockException! SphU.entry("resourceName") → slot chain → pass/block → Entry.exit()
图4-2:Sentinel 流控插槽链 — 请求经过 NodeSelector → Flow → Degrade 插槽
固定窗口计数器 0s-1s: ▓▓▓▓▓░░░░░ 5/10 1s-2s: ▓▓▓▓▓▓▓▓▓░ 9/10 2s-3s: ███████████ 11/10 超了! 简单但有临界突刺问题 counter++ < threshold? 滑动窗口 窗口: [0.5s-1.5s] 窗口: [1.0s-2.0s] 窗口: [1.5s-2.5s] 窗口平滑滑动,更精确 windowCount < limit? 令牌桶 请求取令牌 有令牌→通过 无令牌→拒绝 令牌匀速补充 允许突发流量(桶里有存货) tokens-- > 0? 漏桶 ▓▓▓▓ ▓▓▓░ ▓▓░░ ▓░░░ 请求入桶排队 匀速流出处理 桶满→拒绝 严格匀速,不允许突发 queue.offer()? SphU.entry() → FlowSlot.checkFlow() → 规则匹配 → 通过/BlockException
图4-3:四种限流算法对比 — 计数器、滑动窗口、令牌桶、漏桶
💻 生活类比:限流算法

令牌桶 = 游乐场排队取号,每秒放 N 个号,有号就能进,没号就等。前面没人时可以攒号突发进入

漏桶 = 漏水龙头,不管水多大,出口匀速流出,严格平滑

固定窗口 = 每小时限100人,简单但59分59秒和0分01秒可能瞬间200人

滑动窗口 = 任意1秒内限100人,更精确,解决临界突刺

EXPLORER
▼ sentinel-core
▼ slotchain
SlotChainProvider.java
▼ slots
NodeSelectorSlot.java
FlowSlot.java
DegradeSlot.java
▼ circuitbreaker
CircuitBreaker.java
// Sentinel SphU.entry() — 流控入口 public class SphU { public static Entry entry(String name) throws BlockException { // 1. 创建或获取 ProcessorSlotChain ProcessorSlotChain chain = SlotChainProvider .newSlotChain(); // 2. 依次执行: NodeSelector → Flow → Degrade chain.entry(context, resourceWrapper, 1, false); return entry; } } // FlowSlot.java — 流量控制 public class FlowSlot extends AbstractLinkedProcessorSlot { @Override public void entry(...) throws BlockException { // 检查流控规则: QPS / 线程数 FlowRuleChecker.checkFlow(rules, context, node); // 通过 → fireEntry() 到下一个 slot // 不通过 → 抛出 FlowException fireEntry(context, resourceWrapper, obj, count, prioritized, args); } } // CircuitBreaker.java — 熔断器状态机 public enum State { CLOSED, // 正常放行 OPEN, // 快速拒绝 HALF_OPEN // 探测放行 }

五、链路追踪——一个请求经过了哪些服务

一个请求跨越5个服务,出问题了怎么定位?链路追踪给每个请求发一个"快递单号"(TraceId),每经过一个服务就记录一站(Span)。

Client Gateway Tracer.nextId() 生成 TraceId: abc123 X-B3-TraceId: abc123 inject() Service A Tracer.extract() 读取 TraceId: abc123 startSpan("order") inject(RPC) Service B extract() → abc123 span: "getUser" inject(MQ header) Service C 从MQ header读: abc123 span: "notify" TraceId 传播 HTTP: X-B3-TraceId header RPC: 附加到 Feign Request MQ: 放入 Message Header 线程池: 包装到 Runnable Tracer.inject() / extract()
图5-1:TraceId 传播流程 — 跨服务、跨协议的链路标识透传
0ms 50ms 150ms 200ms 250ms Trace abc123 Span 1: Gateway 0-50ms Span 2: OrderService 50-200ms Span 3: getUser 80-120ms ← Feign Span 4: DB query 130-180ms Span 5: notify 200-250ms ← MQ 代码行映射 Tracer.nextId() → 生成TraceId | Tracer.extract() → 读取TraceId | Tracer.inject() → 传播TraceId | Tracer.startSpan() → 创建Span HTTP: X-B3-TraceId/SpanId | MQ: message.header | RPC: request.attachment
图5-2:Span 树可视化 — Trace abc123 的时间线拆解
App 服务 Sleuth Agent traces+spans Sleuth 自动埋点 Trace/Span propagation HTTP/Kafka Zipkin Collector 收集+聚合 span store Storage ES/MySQL UI 查询展示 App → Sleuth自动埋点 → Zipkin Collector收集 → Storage存储 → UI可视化查询
图5-3:Sleuth + Zipkin 架构 — 自动埋点、收集、存储、展示
💻 生活类比:链路追踪 = 快递物流追踪

TraceId = 快递单号,从寄件到收件,同一个单号贯穿始终

SpanId = 每个中转站编号,北京分拣中心、上海转运站、杭州派送点

Sleuth = 快递员自动记录,包裹每到一个站点自动扫描录入

Zipkin = 物流查询网站,输入快递单号,查看完整流转路径和耗时

EXPLORER
▼ spring-cloud-sleuth
▼ instrument
▼ web
TracingFilter.java
▼ propagation
Propagation.java
▼ tracer
Tracer.java
// TracingFilter.java — Sleuth 自动埋点过滤器 public class TracingFilter implements Filter { private final Tracer tracer; @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { // 1. 从请求头中提取 TraceId(如果有的话) Span span = tracer.nextSpan() .name("http:" + request.getRequestURI()) .start(); // 2. 将 TraceId 注入到响应头中(传播给下游) try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) { // 3. 注入 TraceId 到请求头,传递给下游服务 tracer.inject(span.context(), Format.Builtin.HTTP_HEADERS, new HttpHeadersCarrier(request)); chain.doFilter(req, res); } finally { span.finish(); // 记录 Span 结束时间和耗时 } } } // 关键传播路径: // Gateway: Tracer.nextId() → 生成 TraceId // Service A: Tracer.extract() → 读取 TraceId // Feign/RPC: Tracer.inject() → 传播到下游 // MQ: TraceId 放入 Message Header → 消费者读取

六、面试速答

分布式系统面试高频问题,30秒给出有深度的回答。

Q1: 微服务之间怎么通信?各有什么优缺点?

同步调用:Feign/RestTemplate(HTTP)或 Dubbo(RPC)。优点:实时性强、调用简单;缺点:耦合度高、级联失败风险。

异步消息:RabbitMQ/Kafka。优点:解耦、削峰填谷、不依赖对方在线;缺点:一致性弱、调试难。

核心代码:Feign 同步用 Feign.invoke(),MQ 异步用 convertAndSend() + @RabbitListener

Q2: 服务注册发现原理是什么?Nacos 和 Eureka 的区别?

原理:服务启动时注册到注册中心,定期发心跳维持存活;消费方从注册中心拉取实例列表并缓存本地。

Nacos vs Eureka:Nacos 支持 AP/CP 切换、健康检查更精细(客户端+服务端)、支持配置中心;Eureka 仅 AP 模式、仅客户端心跳、有自我保护机制。

核心代码:NacosServiceRegistry.register() 注册,NacosHeartbeatTask.run() 心跳

Q3: 熔断器的三种状态是怎么转换的?

CLOSED → 正常放行,统计失败率。失败率超过阈值(如 50%)→ 转为 OPEN

OPEN → 快速拒绝所有请求。经过超时时间后 → 转为 HALF-OPEN

HALF-OPEN → 放一个请求探测。成功 → 转 CLOSED;失败 → 转 OPEN。

核心代码:CircuitBreaker.tryAcquire() 三种状态三种返回

Q4: 令牌桶和漏桶的区别?各适合什么场景?

令牌桶:以固定速率往桶里放令牌,请求取令牌通过。允许突发流量(桶里有存货时),适合有流量波动的场景。

漏桶:请求先入桶排队,以固定速率流出处理。严格匀速,不允许突发,适合需要平滑流量的场景(如数据库写入)。

核心代码:Sentinel FlowSlot 中 checkFlow() 可配置不同流量整形策略

Q5: 链路追踪的 TraceId 是怎么跨服务传播的?

HTTP 调用:通过请求头 X-B3-TraceId 传播,Sleuth 的 TracingFilter 自动注入/提取。

RPC/Feign:通过 Request Interceptor 将 TraceId 注入到 Feign 请求头。

MQ:将 TraceId 放入 Message 的 Header,消费者从 Header 读取。

线程池:通过包装 Runnable/Callable,将 TraceId 从父线程传递到子线程。

核心代码:Tracer.inject() 传播,Tracer.extract() 读取,Tracer.nextId() 生成

Q6: 分布式系统如何防止雪崩?

熔断:下游故障时快速失败,不阻塞线程。Sentinel DegradeSlot / Hystrix CircuitBreaker。

限流:限制入口 QPS,防止过载。Sentinel FlowSlot,令牌桶/滑动窗口算法。

超时设置:合理设置 RPC/HTTP 超时时间,避免无限等待。

舱壁隔离:不同服务用独立线程池,一个服务故障不影响其他。

降级:返回兜底数据而非报错。如推荐服务挂了返回热门商品。

一句话总结

分布式请求全链路 = 网关(前台指路) + 注册中心(通讯录找人) + 负载均衡(前台分配) + RPC/MQ(内线电话/内部快递) + 熔断限流(保险丝+闸门) + 链路追踪(快递物流追踪)

每个环节都有对应的代码入口,理解这些代码行就能看懂整个链路的运行原理。