Micrometer 系列【68】统一观测:基于 Spring Boot 的生产级演示案例 | 跨进程场景

文章目录

  • [1. 案例概述](#1. 案例概述)
    • [1.1 跨进程问题](#1.1 跨进程问题)
    • [1.2 整体架构](#1.2 整体架构)
    • [1.3 技术栈与版本对齐](#1.3 技术栈与版本对齐)
  • [2. 环境搭建](#2. 环境搭建)
    • [2.1 前置:Nacos 服务端 3.x](#2.1 前置:Nacos 服务端 3.x)
    • [2.2 父 POM:多模块 + 版本对齐](#2.2 父 POM:多模块 + 版本对齐)
    • [2.3 子模块依赖](#2.3 子模块依赖)
    • [2.4 两个服务的 application.yaml](#2.4 两个服务的 application.yaml)
  • [3. 代码实现](#3. 代码实现)
    • [3.1 被调方 payment-service](#3.1 被调方 payment-service)
    • [3.2 调用方 order-service:@HttpExchange 声明式接口](#3.2 调用方 order-service:@HttpExchange 声明式接口)
    • [3.3 调用方 order-service:装配客户端](#3.3 调用方 order-service:装配客户端)
    • [3.4 调用方 order-service:业务 + 观测](#3.4 调用方 order-service:业务 + 观测)
    • [3.5 跨进程 trace 自动传播的原理](#3.5 跨进程 trace 自动传播的原理)
  • [4. 运行与验证](#4. 运行与验证)
    • [4.1 构建](#4.1 构建)
    • [4.2 启动两个服务](#4.2 启动两个服务)
    • [4.3 远程调用验证](#4.3 远程调用验证)
    • [4.4 错误路径](#4.4 错误路径)
    • [4.5 跨服务链路验证](#4.5 跨服务链路验证)
  • [5. 总结和扩展](#5. 总结和扩展)
    • [5.1 与单进程案例的对比](#5.1 与单进程案例的对比)
    • [5.2 其他远程调用框架集成](#5.2 其他远程调用框架集成)

1. 案例概述

1.1 跨进程问题

单进程里,MicrometerObservationopenScope() 维护一张 ThreadLocal 栈,子观测自动成为父观测的 childSpan 自动串成树。

但分布式系统里,「下单」和「支付」通常不在同一个进程

  • 它们各自是一个独立的 Spring Boot 应用,有各自的端口、各自的 JVM、各自的 ThreadLocal
  • order-service 通过 HttpGrpc 远程调 payment-service ,垮进程场景线程栈已经断了payment 怎么知道自己是 order 这条链路上的子节点呢

1.2 整体架构

一个简单的微服务架构:

复制代码
        curl / 浏览器
             │  POST /api/order/create?orderNo=NO-001&userId=1001
             ▼
   ┌──────────────────────┐     注册/发现      ┌──────────────┐
   │   order-service :8081 │ ─────────────────▶ │  Nacos :8848  │
   └──────────────────────┘                     └──────────────┘
             │  远程调用支付(服务名 payment-service)
             │  
             │ 
             ▼
   ┌──────────────────────┐
   │  payment-service :8082 │
   └──────────────────────┘

模块拆分为:

1.3 技术栈与版本对齐

组件 版本 说明
Spring Boot 4.1.0 micrometer-boot-demo 一致,JDK 17
Spring Cloud BOM 2025.1.2 (Oakwood) 官方矩阵:Boot 4.1 必须 ≥ 2025.1.2(2025.1.1 只支持 4.0.x)
Spring Cloud Alibaba BOM 2025.1.0.0 追踪 Spring Cloud 2025.1.x;要求 Nacos 服务端 3.x
Nacos 服务端 3.x 本机 localhost:8848,开启鉴权(nacos / nacos
链路导出 OTLP → 192.168.1.1:4318 复用现有 Collector(Jaeger 后端)

Boot 4starter 做了模块化拆分,本案例用到三个关键新命名:

  • spring-boot-starter-webmvc(原 -web
  • spring-boot-restclientRestClient 自动配置 + 观测,独立模块)
  • spring-boot-micrometer-tracing-opentelemetry(链路自动配置,独立模块)

2. 环境搭建

2.1 前置:Nacos 服务端 3.x

SCA 2025.1.0.0 要求 Nacos 服务端 3.x 。本机已启动在 localhost:8848,且开启了鉴权,默认账号 nacos / nacos

bash 复制代码
# 控制台
http://localhost:8080/nacos  

服务端开启鉴权后,客户端必须显式传 username / password,否则注册时报 401 User not found!

2.2 父 POM:多模块 + 版本对齐

micrometer-cloud-demo/pom.xml

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.0</version>
    <relativePath/>
</parent>

<groupId>com.yovis.example</groupId>
<artifactId>micrometer-cloud-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>

<modules>
    <module>order-service</module>
    <module>payment-service</module>
</modules>

<properties>
    <java.version>17</java.version>
    <!-- Boot 4.1 必须配 Spring Cloud 2025.1.2(2025.1.1 只支持 4.0.x) -->
    <spring-cloud.version>2025.1.2</spring-cloud.version>
    <!-- 2026-02 发布,追踪 Spring Cloud 2025.1.x;要求 Nacos 服务端 3.x -->
    <spring-cloud-alibaba.version>2025.1.0.0</spring-cloud-alibaba.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>${spring-cloud-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

2.3 子模块依赖

order-service (调用方)比 payment3 个依赖:

xml 复制代码
<!-- MVC 入口(Boot 4 模块化) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- RestClient 自动配置与观测,供 @HttpExchange 使用 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-restclient</artifactId>
</dependency>
<!-- 服务名解析:@HttpExchange 里的 payment-service 经 LoadBalancer 解析到 Nacos 实例 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- Nacos 服务注册与发现 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- 链路:OpenTelemetry bridge + OTLP 导出(Boot 4 独立模块) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-micrometer-tracing-opentelemetry</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

payment-service (被调方):只有 webmvcactuatornacos-discoverytracing 三件套,不需要 RestClientLoadBalancer

2.4 两个服务的 application.yaml

order-servicesrc/main/resources/application.yaml):

yaml 复制代码
server:
  port: 8081

spring:
  application:
    name: order-service
  cloud:
    nacos:
      username: nacos
      password: 123456
      discovery:
        server-addr: localhost:8848

management:
  tracing:
    sampling:
      probability: 1.0
  opentelemetry:
    tracing:
      export:
        otlp:
          endpoint: http://192.168.1.1:4318/v1/traces

payment-service 结构完全一致,只有 port: 8082name: payment-service 不同。


3. 代码实现

3.1 被调方 payment-service

业务服务 PaymentService.java:故意 sleep 200ms 模拟网关耗时,FAIL 渠道抛异常演示错误路径:

java 复制代码
@Service
public class PaymentService {

    public PaymentResult pay(String orderId, String payChannel) {
        if ("FAIL".equalsIgnoreCase(payChannel)) {
            throw new IllegalStateException("模拟支付失败: channel=" + payChannel);
        }
        try {
            TimeUnit.MILLISECONDS.sleep(200);
        }
        catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
        String payNo = "PAY-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
        return new PaymentResult(payNo, orderId, new BigDecimal("99.90"), "PAID");
    }

    public record PaymentResult(String payNo, String orderId, BigDecimal amount, String status) {
    }
}

控制器 PaymentController.java

java 复制代码
@RestController
@RequestMapping("/api")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/pay")
    public PaymentService.PaymentResult pay(@RequestParam String orderId,
            @RequestParam(defaultValue = "WECHAT") String payChannel) {
        return this.paymentService.pay(orderId, payChannel);
    }
}

3.2 调用方 order-service:@HttpExchange 声明式接口

@HttpExchangespring-web 自带的 HTTP InterfaceFeign 的替代品),无需额外依赖 。接口里的 url服务名 payment-service 而非 IP,解析交给 LoadBalancer

api/PaymentApi.java

java 复制代码
/**
 * 支付服务 HTTP Interface(@HttpExchange,Feign 的替代)。
 * url 里的主机名 {@code payment-service} 是 Nacos 注册的服务名,
 * 由 {@code @LoadBalanced RestClient} 经 LoadBalancer 解析到实际实例。
 */
@HttpExchange(url = "http://payment-service/api/pay")
public interface PaymentApi {

    @PostExchange
    PaymentResult pay(@RequestParam("orderId") String orderId,
            @RequestParam("payChannel") String payChannel);
}

api/PaymentResult.java

java 复制代码
public record PaymentResult(String payNo, String orderId, BigDecimal amount, String status) {
}

3.3 调用方 order-service:装配客户端

config/PaymentClientConfig.java:两个 Bean 完成「服务名 → 可调用客户端」的组装:

java 复制代码
@Configuration
public class PaymentClientConfig {

    // ① @LoadBalanced:让 RestClient 在发送时把 payment-service 交给 LoadBalancer 解析
    @Bean
    @LoadBalanced
    RestClient.Builder restClientBuilder() {
        return RestClient.builder();
    }

    // ② HttpServiceProxyFactory:把声明式接口 PaymentApi 生成成可调用的代理
    @Bean
    PaymentApi paymentApi(@LoadBalanced RestClient.Builder builder, ObservationRegistry observationRegistry) {
        // ③ 关键:@LoadBalanced 的原型 builder 只被 Spring Cloud 挂上 LoadBalancer 拦截器,
        //    不经过 Boot 的 RestClientBuilderCustomizer(观测),必须手动补挂观测,
        //    否则出站请求不生成 http.client span、不发 traceparent,跨服务链路会断成两条。
        RestClient restClient = builder
            .observationRegistry(observationRegistry)
            .observationConvention(new DefaultClientRequestObservationConvention())
            .build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(restClient))
            .build();
        return factory.createClient(PaymentApi.class);
    }
}

关键点@LoadBalanced 注解是这一切的枢纽。它给 RestClient.Builder 装上一个 LoadBalancer 拦截器,http://payment-service/api/pay 里以服务名 形式出现的主机名,会在真正发请求前被解析成 Nacos 注册表里的实例地址(192.168.142.1:8082)。

3.4 调用方 order-service:业务 + 观测

service/OrderService.java:用 Observation 包住下单逻辑,paymentApi.pay(...) 的远程调用发生在观测作用域内:

java 复制代码
@Service
public class OrderService {

    private final PaymentApi paymentApi;
    private final ObservationRegistry observationRegistry;

    public OrderService(PaymentApi paymentApi, ObservationRegistry observationRegistry) {
        this.paymentApi = paymentApi;
        this.observationRegistry = observationRegistry;
    }

    public OrderResult createOrder(String orderNo, Long userId, String orderType) {
        return Observation.createNotStarted("order.create", this.observationRegistry)
            .lowCardinalityKeyValue("order.type", orderType)
            .observe(() -> {
                String orderId = "ORD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
                BigDecimal amount = new BigDecimal("99.90");
                // 远程调用支付服务:内部经 @HttpExchange + LoadBalancer 打到 payment-service
                PaymentResult payment = this.paymentApi.pay(orderId, "WECHAT");
                return new OrderResult(orderId, orderNo, amount, "CREATED", payment);
            });
    }

    public record OrderResult(String orderId, String orderNo, BigDecimal amount, String status,
            PaymentResult payment) {
    }
}

controller/OrderController.java

java 复制代码
@RestController
@RequestMapping("/api/order")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping("/create")
    public OrderService.OrderResult createOrder(@RequestParam(defaultValue = "NO-001") String orderNo,
            @RequestParam(defaultValue = "1001") Long userId,
            @RequestParam(defaultValue = "NORMAL") String orderType) {
        return this.orderService.createOrder(orderNo, userId, orderType);
    }
}

3.5 跨进程 trace 自动传播的原理

本案例没有写任何 trace 相关代码 ,链路却能跨进程拼起来,靠的是 micrometer-otel 的自动埋点 + OTelW3C TraceContext Propagator

复制代码
order-service 进程内                              │ 网络              │  payment-service 进程内
                                                  │                   │
Observation("order.create") 打开 scope           │                   │
  └─ http.client span(发请求前)                  │  inject            │
       Span.current() 已存在                       │  traceparent:     │
       → 生成子 span C                            │  00-T-A-01        │
       → 把 C 的信息注入 header                   │ ─────────────────▶│
       └─ GET /api/pay HTTP/1.1                   │                   │   http.server span(收到请求)
           traceparent: 00-T-A-01                 │                   │     extract 出 (T, A, sampled)
                                                  │                   │     → 新 span B 的 parentId = A
                                                  │                   │     → 自动成为 C 的兄弟、A 的子

关键点:

  1. inject(注入) :客户端在发请求前,把当前 SpantraceId/spanId/采样标志 写进 traceparent: 00-<traceId>-<spanId>-01
  2. extract(提取) :服务端收到请求时,从 header 还原出父上下文,新建的 http.server span 自动把 parentId 设为传来的 spanId

这就是之前「Propagator 跨进程上下文透传」的源码原理在本工程的开箱即用 落地,手动版要自己 inject/extractBoot 自动配置版零成本。


4. 运行与验证

4.1 构建

bash 复制代码
cd micrometer-cloud-demo
mvn clean package

4.2 启动两个服务

bash 复制代码
java -jar payment-service/target/payment-service-0.0.1-SNAPSHOT.jar
java -jar order-service/target/order-service-0.0.1-SNAPSHOT.jar

启动日志里出现注册成功的标志:

text 复制代码
payment-service: nacos registry, DEFAULT_GROUP payment-service 192.168.142.1:8082 register finished
order-service:   nacos registry, DEFAULT_GROUP order-service   192.168.142.1:8081 register finished

Nacos 控制台(http://localhost:8848/nacos,服务管理 → 服务列表)确认两个实例都在。

4.3 远程调用验证

bash 复制代码
curl -X POST "http://localhost:8081/api/order/create?orderNo=NO-001&userId=1001"

返回:

json 复制代码
{
  "orderId": "ORD-F80D970C",
  "orderNo": "NO-001",
  "amount": 99.90,
  "status": "CREATED",
  "payment": {
    "payNo": "PAY-695ECB31",
    "orderId": "ORD-F80D970C",
    "amount": 99.90,
    "status": "PAID"
  }
}

4.4 错误路径

bash 复制代码
curl -X POST "http://localhost:8082/api/pay?orderId=ORD-F80D970C&payChannel=FAIL"

返回 500 Internal Server Error,对应 PaymentService 抛出的 IllegalStateException("模拟支付失败: channel=FAIL")

4.5 跨服务链路验证

Jaeger 控制台按 order-servicetraceId 查询,应看到同一条 trace 下的父子结构。

四个 span 共享同一个 traceId

text 复制代码
http post /api/order/create   order-service    根(无父)
└─ order.create               order-service    父 = 根
   └─ http post(http.client) order-service    父 = order.create
      └─ http post /api/pay    payment-service   父 = order 的 http.client,同 traceId

5. 总结和扩展

5.1 与单进程案例的对比

维度 单进程(micrometer-boot-demo) 跨进程(micrometer-cloud-demo)
部署 一个应用、一个 JVM order-service + payment-service 两个应用
调用方式 方法调用 orderService.createOrder() HTTP 远程调用 paymentApi.pay()
服务定位 不需要 Nacos 注册发现 + LoadBalancer 服务名解析
上下文传播 ThreadLocal + openScope() W3C traceparent header(inject/extract)
父子 Span 形成 同线程自动嵌套 跨进程靠 header 提取 parentId
观测代码 五件套(Context/Documentation/Convention/Handler/Config) Observation.createNotStarted 极简 API
追踪配置 /actuator/prometheus 看指标 OTLP 导出到 Collector,Tempo/Jaeger 看链路

两篇合起来正好是「统一观测」的完整拼图:单进程Observation 栈把指标、日志、嵌套 Span 对齐到同一份上下文;跨进程Propagator 把这份上下文沿 HTTP 传到下游,同一个 traceId、同一棵调用树,贯穿整条业务链路。


5.2 其他远程调用框架集成

官方文档已经提供了详细的集成说明,这里就不赘述了

Java 生态的组件或者框架,一般都提供了可观测性内置集成,比如常用的 Dubbo ,在官方文档中可以看到全套支持:

Dubbo 目前借助 Micrometer Observation 完成 Tracing 的所有埋点工作,依赖 Micrometer 提供的各种 Bridge 适配,我们可以实现将 Tracing 导入各种后端系统,具体工作原理如下:

对于 SpringBoot 用户,Dubbo 提供了 Tracing 相关的 starters,自动装配 Micrometer 相关的配置代码,且用户可自由选择 TracerExporter

OpenTelemetry 作为 Tracer,将 Trace 信息 exportOTlp Collector

xml 复制代码
  <dependency>
    <groupId>org.apache.dubbo</groupId>
    <artifactId>dubbo-spring-boot-tracing-otel-otlp-starter</artifactId>
    <version>${version}</version>
  </dependency>
相关推荐
counting money2 小时前
SpringBoot 项目创建(IDEA不全,还需要修改)
java·spring boot·spring·maven
MacroZheng2 小时前
几行代码给项目集成AI功能,Spring AI 2.0太香了!
java·人工智能·spring boot
元界metalite2 小时前
SpringBoot修改接口全字段更新为什么危险-MetaLite如何只更新变化字段
spring boot
Devin~Y2 小时前
从内容社区到AI智能客服:Spring Boot + Spring Cloud + Spring AI 全栈实战面试拆解
java·spring boot·redis·elasticsearch·spring cloud·kafka·mybatis
2602_959960922 小时前
电商大厂Java面试:从Spring Boot、JPA、微服务到Redis、Kafka、Spring Security与监控,谢飞机的爆笑三轮问答
java·jvm·spring boot·redis·面试题
码农进化录2 小时前
Java 程序员的 AI 进化论 | Spring Boot 搭企业智能客服,从设计到上线
java·spring boot·openai
旺仔学长 哈哈2 小时前
别只做车辆 CRUD:Spring Boot 共享汽车管理系统从预约到还车的完整实现---源码53766
java·spring boot·在线预约·共享汽车
weixin_BYSJ19872 小时前
springboot技能与工具共享小程序---附源码29657
java·javascript·spring boot·python·小程序·django·php
weixin_BYSJ19873 小时前
flask民族服饰饰品商城小程序---附源码37399
java·javascript·spring boot·python·小程序·django·php
YDS8293 小时前
大营销平台 —— 第二阶段应用接口实现
java·spring boot·ddd