做微信生态开发这两年,最让我头疼的不是签名算法,也不是XML报文解析,而是“回调明明进来了,业务层面却像没发生一样”。微信服务器从外部直接请求我们的URL,这个入口天生没有统一请求ID,链路里的网关、Spring Boot服务、消息队列、异步消费者,各记各的日志。消息某天没有落地,排查就得在几台机器之间反复翻日志、对时间,运气好十分钟,运气差一下午。后来我把OpenTelemetry接到Spring Boot的自动配置体系里,专门针对微信回调这类外部入口链路补齐分布式追踪能力,才算把这个问题根治。这篇文章要分享的是完整的实现思路、关键代码和踩坑记录,给维护公众号回调、微信支付通知、企业微信事件的读者做一个可以直接抄作业的参考。
1. 微信回调为什么总是“链路黑洞”:从一次真实故障说起
1.1 一次说不清的回调丢失事故
某个周一上午,运营反馈用户在小程序里提交资料后没收到确认通知。我第一反应是去微信公众平台查服务器配置:回调URL正确、token测试正常、签名验证通过,看起来一切正常。再进我们自己的业务日志,却在“回调记录”表里找不到任何一条对应记录。
奇怪的是,翻网关的access log,那条POST请求明明到了,状态码200,耗时120ms。那为什么“回调记录”表是空的?是Controller没命中路由?是MQ消费失败?还是数据库写入异常?跨了网关、Spring MVC、MQ三个子系统,没有任何一个共同的ID能把这三段日志串起来,答案只能靠猜。
那天下午我做了个实验:打开微信公众平台的“消息日志”,找到那条回调,观察时间戳,再去网关日志里找同一秒的请求,然后凭IP和URL匹配到Spring Boot的access log,最后靠消息内容特征匹配MQ消费日志。三步跨了四个系统,每步都靠人肉关联,前后花了近一个小时,最终才发现问题出在消费者线程池拒绝策略上——一个新同事把线程池核心线程数配成了0,任务队列满后直接抛异常,消息被吞了。
这个排查体验让我下定决心,想清楚一件事:微信回调这类外部入口链路,如果不从入口就给业务请求发“身份证”,以后每次出问题都只能靠人肉拼图。
1.2 微信回调链路特有的三个断点
微信回调链路比普通API链路更容易断,主要有三个断点:
| 断点类型 | 具体表现 | 追踪缺失时的后果 |
|---|---|---|
| 入口断点 | 微信服务器直接POST到公网URL,请求只带微信自己的参数,没有任何链路ID | 无法确认回调到底有没有到、到了哪台机器、签名有没有通过 |
| 边界断点 | Controller返回success后,业务逻辑经常投递到MQ或线程池异步处理 | 回调“成功”和业务“完成”被割裂,失败方完全盲区 |
| 重试断点 | 微信支付、公众号消息在超时或失败后会反复重试推送 | 同一笔业务的多次回调无法关联,幂等去重和重试次数统计完全靠业务猜 |
入口断点是最要命的。我们自己系统的服务间调用,可以在HTTP Header里约定traceId;但微信服务器不会听你的,它只关心你的回调URL是否返回了指定字符串。所以这个入口处,如果没有程序主动去“创建”一个链路上下文,后面所有服务拿到的都是没有父节点的流浪请求。
重试断点也值得多说一句。微信支付结果通知在没有收到正确应答时会多次重试,最长可以持续几十个小时。如果没有追踪,同一笔订单的多次回调无法关联,你很难分辨“这是用户真的重复支付了”还是“微信在不停重试同一笔”。一旦把traceId串起来,重试几次、间隔多久、哪次成功哪次失败,一目了然,业务上的幂等逻辑也能顺带评估。
1.3 为什么传统日志排查救不了场
很多团队的现状是:网关有网关的requestId,Spring Boot有自己生成的requestId,MQ消费者又有一套messageId,三者互不相通。MDC里的traceId没有统一来源,各服务自己生成,跨服务直接断掉。
时间线对不上也是常见问题。A服务凌晨重启过,B服务时钟漂移了半分钟,你在日志系统里按时间顺序看,同一笔业务在不同服务的日志根本排不到一起。微信回调的故障还有个特性是高并发瞬时涌入,某一条特定消息被淹没在大量相似日志里,靠“搜索URL+关键词”的方式去捞,运气成分很大。
这些问题的本质是:链路缺少一个从入口到出口贯穿始终、在每一条日志里都会出现的标识。分布式追踪解决的就是这件事,而OpenTelemetry正是目前做这件事最主流的基础设施。
2. 选型分析:为什么是OpenTelemetry加Spring Boot自动配置
2.1 OpenTelemetry的组成与传播原理
OpenTelemetry不是一个单一组件,而是一整套可观测性框架,包含API、SDK、Collector、Agent、Exporter等部分。简单理解:
- API:定义Trace、Span、Metric的接口,业务代码只依赖API即可。
- SDK:真正实现这些接口,管理span生命周期、采样、导出。
- Exporter:把数据发给后端,常见的有Jaeger、Grafana Tempo、SkyWalking、云厂商的监控平台。
- Propagator:负责链路上下文的传递,核心格式是W3C标准定义的traceparent。
traceparent这个Header长这样:
00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01从左到右依次是版本号、32位的traceId、16位的parentSpanId、8位的flags。任何服务只要解析这个Header,就能知道自己是整条链路的哪一环、上游是谁、Trace归属是谁。OpenTelemetry之所以能跨语言、跨厂商,靠的就是这套标准化传播协议。
我习惯把traceId比作快递单号:链路从入口产生一个单号,经过网关、业务服务、MQ、数据库时都贴上这个单号,任何一个环节出问题,把单号输入查询系统就知道卡在哪个转运中心。
2.2 三种接入方式对比
这里直接给出结论:Spring Boot应用接入OpenTelemetry追踪,主流有三条路。
| 接入方式 | 侵入性 | 业务语义 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| 纯Java Agent | 零侵入,启动参数加-javaagent即可 | 弱,无法自动识别“微信回调”“openid”这类业务概念 | 需每台机器统一JVM参数,容器环境有麻烦 | 内部服务快速打通全链路 |
| 纯SDK手动埋点 | 高,每个入口、每个关键步骤都要手动加span | 最强,完全自定义 | 开发量集中在埋点代码上,容易漏 | 小型服务、入口极少 |
| Agent + 代码自动配置 | 中,入口等关键位置用代码埋点,其余交给Agent | 强,可精确描述微信回调语义 | 需要维护一个starter,但成本可控 | 微信生态入口类服务、对语义要求高的场景 |
我最终选的是第三种组合。单靠Agent,JVM参数在Docker/K8s环境里不好统一,而且Agent给Spring MVC生成的span只叫“HTTP GET /wechat/callback/xxx”,它不知道这个回调是哪个公众号的、是哪个用户的消息、是文本还是事件。单靠SDK,每个入口都要手写,很容易漏,维护成本也高。
用自动配置模块的好处是:用户只要把jar包和少量配置项加进Spring Boot工程,入口span、traceId日志关联、异步上下文传播全部自动生效,不用每个项目重复写同样的Filter。这才是“自动配置”四个字的真正含义。
2.3 自动配置的Spring Boot机制
Spring Boot的自动配置核心是@AutoConfiguration注解加上META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。Spring Boot启动时会扫描这个文件,加载其中列出的配置类;配置类里再通过@ConditionalOnClass、@ConditionalOnProperty、@ConditionalOnMissingBean等条件注解,决定在什么情况下创建哪些Bean。
这意味着我们可以做得很克制:只有引入OpenTelemetry相关依赖、而且配置了wechat.otel.enabled=true时,追踪Filter才会生效;如果用户自己已经定义过Filter,自动配置不会重复注册。这套机制保证了模块的“显式开箱即用”,不会污染用户已有的配置。
2.4 为什么不再依赖Spring Cloud Sleuth/Zipkin
如果你维护的是老项目,可能已经在用Spring Cloud Sleuth + Zipkin。我要提醒一个问题:Spring Cloud Sleuth在2022年的Spring Cloud版本之后已经进入维护模式,官方给出的迁移方向是Micrometer Tracing,而Micrometer Tracing的后端之一就是OpenTelemetry。
Sleuth的另一个问题是,它从HTTP入口取traceId的方式依赖服务框架自带的拦截器,微信回调这种“由外部直接打到业务URL”的入口它也要依赖Spring MVC才有机会处理,且业务语义很难自定义。与其在Sleuth的基础上写一堆定制逻辑,不如直接用OpenTelemetry作为底层设施,数据格式更标准、生态更开放,后续接任何后端都方便。
3. 动手实现:一个wechat-otel-spring-boot-starter的完整落地方案
3.1 工程结构与依赖
我的做法是把代码做成一个独立的starter模块,放在wechat-otel-spring-boot-starter工程里。这样所有微信生态相关的服务,只要引入这个jar,就能获得统一的追踪能力。
wechat-otel-spring-boot-starter/ ├── pom.xml └── src/main/java/com/example/wechatotel/ ├── WechatCallbackTracingAutoConfiguration.java ├── WechatTracingProperties.java └── otel/ └── WechatCallbackTraceFilter.java └── src/main/resources/META-INF/ ├── spring.factories └── org.springframework.boot.autoconfigure.AutoConfiguration.imports依赖方面,核心只需要三样:
<properties> <otel.version>1.32.0</otel.version> </properties> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-api</artifactId> <version>${otel.version}</version> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-sdk</artifactId> <version>${otel.version}</version> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId> <version>${otel.version}</version> </dependency>如果应用中已经有OpenTelemetry Java Agent在跑,这些SDK依赖也可以不引入,直接使用Agent提供的GlobalOpenTelemetry实例。这个细节我在后面的配置里会用开关控制。
3.2 可配置项设计:WechatTracingProperties
一个自动配置模块,最重要的就是把“哪些内容允许用户调节”设计清楚。我定义了下面这些配置项:
@ConfigurationProperties(prefix = "wechat.otel") public class WechatTracingProperties { /** 总开关 */ private boolean enabled = true; /** 追踪服务名,会作为attribute写入span */ private String serviceName = "wechat-callback-service"; /** OTLP导出地址,默认本机Jaeger */ private String exporterEndpoint = "http://localhost:4317"; /** 需要追踪的微信回调路径,Ant风格匹配 */ private List<String> callbackPaths = List.of( "/wechat/callback/**", "/pay/notify/**", "/wxwork/callback/**" ); /** 采样比例:1.0表示全量,生产环境可按需降低 */ private double samplerRatio = 1.0; /** 是否优先使用已注册的GlobalOpenTelemetry实例 */ private boolean useGlobalOpenTelemetry = true; }callbackPaths这个配置项是核心。不同业务系统挂载的微信回调URL差异很大:公众号回调可能是/wx/portal/{appId},微信支付通知是/pay/notify,企业微信事件又是另一套路径。你不可能要求所有项目统一URL,所以把可匹配路径暴露给用户是最合理的设计。
3.3 核心过滤器:入口span创建与上下文传播
追踪入口这步我用的是OncePerRequestFilter。相比HandlerInterceptor,过滤器的优势是能用一个try-finally清楚地包裹住后续调用链,scope的关闭和span的结束时机完全可控。
package com.example.wechatotel.otel; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.Context; import io.opentelemetry.context.Scope; import io.opentelemetry.context.propagation.TextMapGetter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.MDC; import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; public class WechatCallbackTraceFilter extends OncePerRequestFilter { private final Tracer tracer; private final WechatTracingProperties properties; private final AntPathMatcher pathMatcher = new AntPathMatcher(); private static final TextMapGetter<HttpServletRequest> GETTER = new TextMapGetter<>() { @Override public Iterable<String> keys(HttpServletRequest carrier) { return Collections.list(carrier.getHeaderNames()); } @Override public String get(HttpServletRequest carrier, String key) { return carrier.getHeader(key); } }; public WechatCallbackTraceFilter(OpenTelemetry openTelemetry, WechatTracingProperties properties) { this.tracer = openTelemetry.getTracer("wechat-callback-tracer", "1.0.0"); this.properties = properties; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String path = request.getRequestURI(); boolean shouldTrace = properties.getCallbackPaths().stream() .anyMatch(pattern -> pathMatcher.match(pattern, path)); if (!shouldTrace) { filterChain.doFilter(request, response); return; } // 1. 先从请求头中提取上游上下文,有则继承,无则创建新root Context extracted = tracer.getOpenTelemetry() .getPropagators() .getTextMapPropagator() .extract(Context.current(), request, GETTER); SpanBuilder spanBuilder = tracer.spanBuilder("WechatCallback") .setSpanKind(SpanKind.SERVER) .setAttribute("url.path", path) .setAttribute("http.request.method", request.getMethod()); if (Span.fromContext(extracted).getSpanContext().isValid()) { spanBuilder.setParent(extracted); } Span span = spanBuilder.startSpan(); request.setAttribute("wechat.otel.span", span); try (Scope scope = span.makeCurrent()) { MDC.put("traceId", span.getSpanContext().getTraceId()); filterChain.doFilter(request, response); if (response.getStatus() >= 400) { span.setStatus(StatusCode.ERROR, "HTTP " + response.getStatus()); } } catch (Exception e) { span.recordException(e); span.setStatus(StatusCode.ERROR); throw e; } finally { MDC.remove("traceId"); span.end(); } } }这个Filter的关键点有三个。
第一,提取上游上下文时不能盲目新建span。如果微信回调入口前面还有一层你自己的网关或内部代理,它可能已经往请求头里塞了traceparent,这时候我们应该作为子span挂在已有链路上,而不是另起炉灶。判断方式是看extract出来的上下文是否含有有效SpanContext。
第二,span.makeCurrent()必须和try-with-resources配对。Scope如果不在finally里关闭,会导致Span永远停留在当前线程上下文中,后续业务代码拿到的始终是同一个span,再新建的span全部变成它的子孙,链路就乱了。
第三,MDC操作也要在finally里清理。traceId放进MDC是为了让日志打印时带上追踪ID,但不清除的话,线程池复用线程时,下一条无关业务会继承上一个请求的traceId,日志关联反而出错。
3.4 注册自动装配:AutoConfiguration.imports与条件装配
自动配置类的逻辑很简单:创建OpenTelemetry实例和Filter,但所有Bean创建都套上条件判断,避免和用户自定义配置冲突。
package com.example.wechatotel; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.context.propagation.ContextPropagators; import io.opentelemetry.context.propagation.W3CTraceContextPropagator; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; import io.opentelemetry.sdk.trace.samplers.Sampler; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.core.Ordered; import static io.opentelemetry.api.common.AttributeKey.stringKey; @AutoConfiguration @ConditionalOnClass(OpenTelemetry.class) @ConditionalOnProperty(prefix = "wechat.otel", name = "enabled", havingValue = "true", matchIfMissing = true) @EnableConfigurationProperties(WechatTracingProperties.class) public class WechatCallbackTracingAutoConfiguration { @Bean @ConditionalOnMissingBean(OpenTelemetry.class) public OpenTelemetry openTelemetry(WechatTracingProperties properties) { if (properties.isUseGlobalOpenTelemetry()) { try { return GlobalOpenTelemetry.get(); } catch (IllegalStateException ignored) { // 全局实例未注册,走SDK自动创建分支 } } Resource resource = Resource.getDefault().merge( Resource.create(Attributes.of(stringKey("service.name"), properties.getServiceName())) ); SdkTracerProvider tracerProvider = SdkTracerProvider.builder() .setResource(resource) .setSampler(Sampler.parentBased(Sampler.traceIdRatioBased(properties.getSamplerRatio()))) .addSpanProcessor(BatchSpanProcessor.builder( OtlpGrpcSpanExporter.builder() .setEndpoint(properties.getExporterEndpoint()) .build() ).build()) .build(); return OpenTelemetrySdk.builder() .setTracerProvider(tracerProvider) .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) .build(); } @Bean @ConditionalOnMissingBean(name = "wechatCallbackTraceFilter") public FilterRegistrationBean<WechatCallbackTraceFilter> wechatCallbackTraceFilter( OpenTelemetry openTelemetry, WechatTracingProperties properties) { FilterRegistrationBean<WechatCallbackTraceFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new WechatCallbackTraceFilter(openTelemetry, properties)); registration.addUrlPatterns("/*"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 100); return registration; } }注册文件不能忘。Spring Boot 3.x用AutoConfiguration.imports,Spring Boot 2.7之前的项目用spring.factories。两套都放上,兼容性最好。
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
com.example.wechatotel.WechatCallbackTracingAutoConfigurationMETA-INF/spring.factories:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.example.wechatotel.WechatCallbackTracingAutoConfiguration有人会问:为什么Bean方法里要处理GlobalOpenTelemetry.get()?因为当OpenTelemetry Java Agent在运行时,Agent会自动注册一个全局OpenTelemetry实例,如果你再用SDK创建第二个,会产生两份TracerProvider、两条导出通道,数据重复或冲突。优先使用全局实例是最安全的策略。
3.5 业务层埋点:把openid、消息类型打进span
入口span建好之后,业务代码里就可以通过Span.current()拿到当前span,往上面补充业务属性。这些属性最终会出现在追踪后端的详情页里,配合搜索过滤非常有用。
@PostMapping("/wechat/callback/{appId}") public String handleWechatCallback(@PathVariable String appId, @RequestBody String xmlBody) { WxMpXmlMessage message = WxMpXmlMessage.fromXml(xmlBody); Span.current().setAttribute("wechat.appid", appId); Span.current().setAttribute("wechat.from_user", message.getFromUser()); Span.current().setAttribute("wechat.msg_type", message.getMsgType()); Span.current().setAttribute("wechat.event", message.getEvent()); Span.current().setAttribute("wechat.msg_id", message.getMsgId()); // 业务处理... return "success"; }这种“入口自动建span + 业务代码补属性”的组合,比纯手工埋点高效得多。你不需要在Controller里手动startSpan/endSpan,只要在需要关键业务信息的地方setAttribute。注意一点:如果某条回调消息里的事件是空的、消息类型也没有,这本身就是问题信号,与其在日志里打一行event=null,不如让追踪后端直接展示出这个空缺,排查时一眼就能看到“这条回调缺少了必要字段”。
4. 打通异步边界与日志关联
4.1 线程池导致的链路断裂与TaskDecorator修复
微信回调最典型的处理模式是:Controller收到消息,解析后扔进线程池或MQ,立刻返回success。问题在于,OpenTelemetry的Context默认只跟随当前线程,线程池里的新线程拿不到父线程的Context,异步任务里创建的span会变成孤儿节点。
随手写个@Async方法,看起来是异步了,但链路在异步边界直接断掉。解决方案是在线程池上配置TaskDecorator,把提交任务那一刻的Context快照,绑定到任务执行线程上:
@Bean("wechatAsyncExecutor") public ThreadPoolTaskExecutor wechatAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix("wechat-async-"); executor.setTaskDecorator(runnable -> { Context context = Context.current(); Map<String, String> mdcContext = MDC.getCopyOfContextMap(); return () -> { try (Scope ignored = context.makeCurrent()) { if (mdcContext != null) { MDC.setContextMap(mdcContext); } runnable.run(); } finally { MDC.clear(); } }; }); return executor; }这个装饰器的核心逻辑是:在submit方法向队列提交任务的那一刻,把当前线程的Context和MDC内容都复制一份,随任务对象一起进入线程池;任务真正执行时,再把快照恢复。
如果你用的是Spring的@Async注解,需要确保它使用的是这个自定义Executor:
@Configuration public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { return wechatAsyncExecutor(); } }这里有个特别容易踩的坑:如果你用CompletableFuture.supplyAsync(() -> xxx),它会走ForkJoinPool.commonPool(),你的TaskDecorator配置根本不会生效。必须显式传入带Decorator的Executor,或者在异步任务内部再调用Context.current().makeCurrent()。用不对就是白改。
4.2 把traceId注入日志:MDC联动方案
链路通了之后,日志还是要对得上。我在Filter的finally里已经调了MDC.put("traceId", ...),日志配置文件里只要增加一个占位符就能打印出traceId:
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{traceId:-}] %msg%n</pattern> </encoder> </appender>%X{traceId:-}里的-表示当MDC中没有traceId时打印一个短横线,避免日志格式错位。如果你用JSON日志格式(生产环境强烈推荐),同理在JSON字段里增加一个traceId字段即可。
这套方案能做到什么效果?在ELK或Loki里,你搜索一个openid,会在同一条traceId下看到:网关access log、Spring Boot业务日志、异步任务日志、数据库慢查询日志,全部串在一起。哪怕跨了三个服务,只要traceId一致,就是同一笔业务。
4.3 微信消息异步处理场景的完整示例
把前面的东西拼起来,一个典型的微信消息处理流程长这样:
@PostMapping("/wechat/callback/{appId}") public String callback(@PathVariable String appId, @RequestBody String xmlBody) { WxMpXmlMessage message = WxMpXmlMessage.fromXml(xmlBody); Span.current().setAttribute("wechat.msg_id", message.getMsgId()); // 业务消息投递到线程池异步处理 MessagePipeline messagePipeline = new MessagePipeline(message); wechatAsyncExecutor().execute(() -> messagePipeline.process()); // 立即返回微信要求的success return "success"; }MessagePipeline.process()内部再做一些耗时操作,比如查会员、调用下游接口、写库。由于线程池配了TaskDecorator,异步任务里的Span.current()拿到的还是入口那个span的Context,整个流程在追踪后端看起来就是一条完整的Trace,而不是两段孤立的记录。
如果你的异步边界是RabbitMQ或Kafka,原理也类似。OpenTelemetry的JMS/Kafka instrumentation会在消息Header里自动注入traceparent,消费端提取后自动续接链路。关键是入口span必须先建立好,否则所有下游传播都无从谈起。
5. 实测与踩坑:Header缺失、重复Span、版本兼容问题
5.1 本地验证环境与观测后端
代码写完了,怎么验证?最省事的方案是用Jaeger all-in-one,一条命令起一个包含UI、查询服务的完整后端:
docker run -d --name jaeger \ -e COLLECTOR_OTLP_ENABLED=true \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ jaegertracing/all-in-one:latest然后启动你的Spring Boot应用,用curl模拟微信回调请求。微信本身不送traceparent,我们就手造一个,验证“无Header时创建root span”这个分支:
curl -X POST http://localhost:8080/wechat/callback/test \ -H "Content-Type: text/xml" \ --data '<xml><ToUserName><![CDATA[gh_test]]></ToUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hello]]></Content></xml>'打开http://localhost:16686,选择服务名wechat-callback-service,点击Find Traces,能看到刚才的请求生成了一条Trace,span name为WechatCallback,属性里带上了url.path和wechat.msg_type,链路ID和日志里的traceId完全一致,就说明闭环通了。
5.2 踩坑一:回调已经带了traceparent怎么办
我一开始写的Filter逻辑很简单:不管三七二十一,直接tracer.spanBuilder("WechatCallback").startSpan()。上线没多久,发现某些请求在追踪后端出现了“两个root span”,一条是Agent自动埋的Spring MVC入口,一条是我Filter建的。两条span的traceId都不一样,链路被劈成了两半。
排查过程花了不少时间,最终定位到原因:那类请求经过了内部网关,网关已经生成了新的traceparent并在路由转发时塞进了请求头;我的Filter在创建span前没有检查已有上下文,直接无条件建root span,把上一条链路强行切断了。
修复方法就是前面代码里写的:先extract请求头,如果存在有效的SpanContext,就setParent(extracted)继承上游;只有完全无有效上下文时,才作为新的root span。这里再补充一句,如果当前线程上下文中已经有Agent创建的span,而请求头里又没有traceparent,spanBuilder.startSpan()默认也会把当前span当作父节点,这同样能避免重复root,所以关键就一条:不要无脑新建根span。
5.3 踩坑二:异步场景Span无法结束的根因
另一个让我印象深刻的坑,是异步任务里的span一直不结束,在Jaeger里显示为大量“open spans”。当时我没有给线程池配TaskDecorator,而是在异步方法内部手动调Context.current(),看起来也拿到了span,但问题在于:span.makeCurrent()返回的Scope在线程池线程上始终没有调用close(),导致该span一直被认定为活跃状态,永远不会上报。
翻译成人话就是:你不光要让异步线程能“看到”上下文,还要负责在任务结束时把Scope关掉。所以正确做法是用try-with-resources包裹,或者统一用TaskDecorator把Scope的close一并处理。我后来把TaskDecorator方案普及到所有线程池,这个问题才彻底消失。
5.4 踩坑三:Spring Boot 2.x与3.x自动配置差异
微信生态的老项目很多还在Spring Boot 2.3、2.4甚至更早版本,自动配置的兼容性必须提前想好。有三个主要差异:
第一,自动配置注册文件不一样。Spring Boot 2.7才引入AutoConfiguration.imports,2.6及以前只认spring.factories里的EnableAutoConfiguration配置。为了兼容,两个文件都放了最稳妥。
第二,Servlet API的包名变了。Boot 3.x基于Jakarta EE,Filter类要引jakarta.servlet.Filter;Boot 2.x用javax.servlet.Filter。同一个代码要跨两个大版本,要么做两个分支,要么用Maven profile。我个人的做法是:新项目统一上3.x,老项目单独维护一个2.x分支,改动量很小,也就Filter里的import行和自动配置注解。
第三,@AutoConfiguration注解在Boot 2.x并不存在。2.x直接写@Configuration即可,加载方式和module强度略有差异,但对这个业务场景无关紧要。
我的建议是,如果你们维护着大量Boot 2.x系统,又不想维护两个分支,至少把spring.factories保留住,并在代码里统一用javax.servlet那套API,这样在Boot 3.x也能运行(它会自动适配),只是长时间不升级还是不推荐。
5.5 采样与资源开销控制
全链路追踪是有开销的,虽然单个span的创建成本很低,但高并发下瞬时产生几千个span,批量导出时的CPU、内存、网络占用都不能忽视。
BatchSpanProcessor默认的队列大小是2048,批量大小512,导出间隔5秒。如果业务峰值很高而导出端点偶尔不可用,队列满了之后,后续span会被直接丢弃,你看到的trace就会缺尾巴。生产环境我建议配置合理的采样策略,而不是盲目扩队列。
我的默认配置是sampler-ratio: 1.0全量采集,但这只适用于低流量服务。如果微信消息量每天超过几十万条,把采样比例降到0.1或0.5,配合Sampler.parentBased(...),既能保证每条外部链路至少留痕,又避免把资源全部耗在采集上。
wechat: otel: enabled: true service-name: wechat-callback-service exporter-endpoint: http://otel-collector:4317 callback-paths: - /wechat/callback/** - /pay/notify/** - /wxwork/callback/** sampler-ratio: 0.5 use-global-open-telemetry: true还有一个容易被忽略的点:OtlpGrpcSpanExporter默认走的gRPC,如果你的网络环境不允许访问导出端点的4317端口,可以改用OtlpHttpSpanExporter走4318,配置差别很小。我在有些客户现场就是用HTTP方式绕开了防火墙限制。
最后聊两句落地经验。这套自动配置模块,做第一版其实只花了两天,但把异步、重试、版本兼容这些坑趟平,前后用了一个多月。如果你只是想让微信回调链路不再“查无此单”,别一上来就追求全链路端到端,先把入口span和日志traceId串起来,这个闭环带来的收益已经非常大。之后再加服务间传播、加采样、加业务标签,都是一步一步长出来的。踩过这些坑的体会,今天一次性倒给你了,希望你的下一条回调,从进门那一刻起就带着自己的身份证。