☰
TraceId全链路日志追踪实战:从生成到Elasticsearch检索
2026/10/2 9:33:18 网站建设 项目流程

有一次排查线上接口超时问题,我对着日志平台来回筛了快40分钟。用户反馈13点42分点提交按钮没反应,我手头只有用户ID、客户端IP和接口名。先从Nginx access log里抠出那个时间点的请求,再拿客户端IP去四个微服务里逐条grep,每到一个服务都要对比前后时间戳去猜调用顺序,最后终于在B服务的一段异常堆栈里看到了root cause。整个过程最耗时的不是看代码,而是把散落在十几个文件、几百条日志里的片段拼成一条完整的请求故事。

后来我们在全链路日志里统一加了TraceId,再遇到同类问题,从拿到ID到定位完成基本不超过三分钟。这篇文章就把我从零搭建TraceId机制的过程、踩过的坑、以及最终怎么和Elasticsearch打通讲清楚。内容包括Java项目里如何生成和注入TraceId、怎么在线程池和跨服务调用中透传、日志侧如何输出、以及如何通过TraceId在ES里查回一次请求对应的SQL执行链。适合正在做日志治理、接口排查效率优化,或者准备落地全链路日志的同学参考。

1. 先想清楚TraceId到底在解决什么:日志的“检索主键”思维

1.1 一次故障排查现场:没有TraceId时我们是这样“考古”的

很多团队其实不是没有日志,而是日志太多、太散。微服务架构下,一次用户请求往往会经过网关、鉴权服务、业务服务、基础服务,甚至还会触发MQ异步任务和定时任务。每个服务各自写各自的日志文件,服务之间没有任何关联ID,排查问题就变成了“考古”。

我记得最典型的一次,线上有个订单状态没更新,用户那边已经付款了,但订单服务显示未支付。我手里的线索只有用户ID和大概时间。排查路径是这样的:

  1. 先去Nginx access log里找到那个时间点的请求,拿到完整URL和入参。
  2. 凭经验判断这个请求先进了哪个服务,到对应服务的日志文件里grep用户ID。
  3. 在订单服务里发现它调了支付回调接口,于是再去支付服务的日志里grep同一个时间段的请求。
  4. 几个服务之间反复横跳,用时间戳去对齐调用顺序,猜哪一步出了问题。

这种方式的痛点非常明显:只要有一个服务的日志没看全,或者客户端IP被Nginx转发后变了,链条就断了。更难受的是,如果请求量很大,同一个用户ID在同一秒可能有好几条请求,靠时间戳过滤出来的日志根本分不清哪条是哪条。

所以在做日志治理的时候,第一步不是搞什么复杂链路追踪系统,而是先给每个请求发一个唯一ID,让这个ID贯穿整条调用链。这就是TraceId存在的意义——它是日志的“检索主键”,像数据库主键一样,让你能从海量日志中精确定位到某一条请求的全部痕迹。

1.2 TraceId的完整链路逻辑:生成、透传、收敛、检索

TraceId本身不复杂,就是一个字符串,但它要起作用的链路比想象中长。一条带TraceId的请求生命周期大概是这样的:

  1. 入口生成:外部请求到达网关或第一个服务时,如果没有携带TraceId,就生成一个新的;如果带了,就沿用。
  2. 透传:服务A调用服务B时,把TraceId放到HTTP Header或RPC隐式参数里带过去;服务B收到后取出并写入自己的日志上下文。
  3. 收敛:所有日志(业务日志、SQL日志、异常堆栈)在输出时都带上当前上下文的TraceId。
  4. 检索:日志采集到Elasticsearch后,用TraceId做关键字,一次查回这个请求从入口到出口、从业务逻辑到SQL执行的全部日志。

这个机制很像快递单号。你寄快递时拿到一个单号,这个单号跟着包裹走遍全网,每个中转站扫码都会记录一笔。出了问题,快递公司拿着单号一查,所有流转记录全出来,不用靠打电话问“你那个包裹长什么样”。

但和快递单号不同,TraceId不是天然存在的,需要你自己在每个环节做埋点。实际的难点不在生成ID本身,而在“透传”和“日志打点”这两个环节。线程池会弄丢它,HTTP调用默认不会带它,日志框架如果没有额外配置也不会输出它。后面的章节就按这条链路逐步拆开讲。

2. 入口侧实现:从请求到达的第一毫秒就把TraceId种下去

2.1 生成规则的选择:UUID、雪花还是自定义随机串

先把最简单的部分说清楚——TraceId怎么生成。

我见过很多团队直接用UUID.randomUUID().toString(),生成出来是36个字符(含横杠),依然能用,但有点浪费存储。日志里每行都带这个字段,在ES里索引和存储成本都会翻倍。而且纯UUID是随机串,不带时间信息,你一眼看不出这条请求是什么时候进来的。

也有的团队用雪花算法(Snowflake)生成。雪花ID是64位整数,转成字符串之后大概19位,比UUID短很多,而且自带时间戳和机器信息,适合已经有分布式ID生成器的团队。缺点是需要引入额外的组件或依赖,比如美团Leaf、百度UidGenerator这些。

我的建议是:如果没有现成的分布式ID基础设施,优先用自定义随机串,长度控制在30位左右,包含时间信息+随机字符。这样可以兼顾可读性、存储成本和唯一性。TraceId不要求全局绝对唯一,只要在日志保留周期内不冲突就行。

一个比较实用的生成方式:

public static String generateTraceId() { // 时间戳取到毫秒,转成36进制,长度约8位 String timePart = Long.toString(System.currentTimeMillis(), 36); // 后面拼24位随机字符,字符集去掉容易混淆的0O1IlL String randomPart = RandomStringUtils.randomAlphanumeric(24); return timePart + randomPart; }

这里把时间戳换成36进制是为了压缩长度,同时让TraceId从字符串上就能看出生成时间,排查时直接心里有数。随机部分用SecureRandom更好,但一般场景RandomStringUtils够用了。如果团队有雪花ID生成器,直接用雪花ID也可以,长度更短,就是可读性差一些。

2.2 入口Filter的标准写法:解析Header、自动生成、写入MDC

生成规则定了之后,下一个问题就是在哪一步把TraceId种到日志上下文里。我推荐在Servlet Filter里做,而不是Interceptor或者AOP。原因很简单:Filter是Servlet规范里最靠前的入口,连Spring MVC还没介入时它就能拿到请求,而且Filter天然覆盖静态资源、拦截器没覆盖到的路径。

一个标准的TraceIdFilter长这样:

@Component public class TraceIdFilter implements Filter { private static final String TRACE_ID_HEADER = "traceId"; private static final String TRACE_ID_MDC_KEY = "traceId"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String traceId = httpRequest.getHeader(TRACE_ID_HEADER); // 上游没传就自己生成一个 if (StringUtils.isBlank(traceId)) { traceId = generateTraceId(); } MDC.put(TRACE_ID_MDC_KEY, traceId); try { chain.doFilter(request, response); } finally { // 一定记得清理,否则线程池复用时会出现串号 MDC.remove(TRACE_ID_MDC_KEY); } } }

这个Filter做了三件事:从请求头里取traceId,取不到就生成;把traceId放进MDC;请求结束从MDC移除。注意finally里的MDC.remove(),这一步比MDC.put()还要重要,后面讲线程池的时候会细说为什么。

在Spring Boot里注册这个Filter有两种方式:一种是加@Component注解,靠Spring Boot自动扫描生效;另一种是通过FilterRegistrationBean显式注册,可以精确控制过滤顺序和URL匹配。建议用FilterRegistrationBean,把顺序调到最前面,确保真正“第一毫秒”就生效。毕竟如果TraceIdFilter没排到最前,前面的Filter里打日志还是没有TraceId。

2.3 MDC的本质:为什么它能自动跟着日志走

很多同学第一次接触MDC会有点懵:我只是MDC.put("traceId", xxx),为什么logback打印日志时就能自动带上这个值?

MDC的全称是Mapped Diagnostic Context,直译是“映射诊断上下文”。它本质上就是一个ThreadLocal<Map<String, String>>,每个线程自己维护一份键值对。logback在打印日志时,会去PatternLayout里找%X{traceId}这样的占位符,然后从当前线程的MDC Map里取值拼到日志文本里。这就解释了为什么同一条线程里所有日志都能自动带上TraceId——因为同一个线程的ThreadLocal始终能取到同一个值。

但这也就引出了一个关键问题:ThreadLocal是线程私有的。一旦发生线程切换,比如用了线程池、@Async、CompletableFuture,子线程的MDC里是拿不到父线程那个值的。这是整个TraceId机制里最大的坑,下一章单独展开。

3. 跨线程传递:异步场景里TraceId丢失和串号的双重陷阱

3.1 线程池复用导致的“看不到”和“看错人”

异步场景下TraceId有两个问题:丢失和串号。

丢失好理解:你在Controller里MDC.put("traceId", xxx),然后往线程池里submit一个任务,子线程执行时MDC.get("traceId")是null。因为ThreadLocal不跨线程继承,子线程有自己独立的ThreadLocal Map,父线程put的值对它不可见,于是子线程里打的日志全部没有TraceId。

串号比丢失更隐蔽,也更危险。线程池里的线程是复用的——执行完任务A之后,这个线程会被归还给线程池,下一次执行任务B时还是同一个线程。如果任务A执行时往MDC里put了traceId,但结束前没清理,线程B执行时从MDC里取到的是任务A的traceId,日志全部记到别人名下了。

我之前就踩过这个坑。有个模块用了@Async去发通知邮件,发邮件的日志偶尔会混在完全不相干的请求TraceId下面。排查了好久才发现是线程池复用的锅,异步任务执行完没有清MDC,下一个任务上来直接拿脏数据。

3.2 TaskDecorator包装Runnable的标准解法

Spring的ThreadPoolTaskExecutor提供了setTaskDecorator方法,可以在每次执行任务前对Runnable做一层包装。这是解决线程池MDC透传最优雅的方式,代码侵入小,只要在创建线程池时统一配置一次。

@Bean("commonTaskExecutor") public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix("common-task-"); executor.setTaskDecorator(runnable -> { // 父线程(提交任务的那个线程)的MDC上下文 Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { Map<String, String> previous = MDC.getCopyOfContextMap(); try { // 把父线程的MDC上下文塞到子线程 if (contextMap != null) { MDC.setContextMap(contextMap); } runnable.run(); } finally { // 执行完恢复子线程之前的MDC,没有就清空 if (previous != null) { MDC.setContextMap(previous); } else { MDC.clear(); } } }; }); return executor; }

核心逻辑就三步:先getCopyOfContextMap()把父线程当前的MDC内容拷贝一份;在子线程执行前setContextMap(contextMap)把拷贝内容填进去;执行完后要么恢复之前的MDC,要么直接MDC.clear(),防止污染这个线程的下一个任务。

这个方案同样适用于@Async场景。你需要实现AsyncConfigurer接口,返回自定义的ThreadPoolTaskExecutor,@Async注解就会自动使用这个线程池。当然,@Async本身就是基于代理的,它拿到Runnable时会走TaskDecorator这一层包装,所以能生效。

3.3 需要无侵入透传时:TransmittableThreadLocal

TaskDecorator方案虽然好,但有一个前提:代码里的线程池都是你自己创建的。如果项目里存在原生Executors.newFixedThreadPool()、第三方SDK的内部线程池,或者用了ForkJoinPool、并行流这种不好干预的地方,TaskDecorator就覆盖不到了。

这时候可以考虑阿里的TransmittableThreadLocal(TTL)。它的原理是继承InheritableThreadLocal并扩展,在线程池复用场景下,能自动捕获提交任务时父线程的值并传给子线程。用法非常简:

// 把MDC换成TTL包装一下 TransmittableThreadLocal<Map<String, String>> holder = new TransmittableThreadLocal<>(); // 提交任务时用TtlRunnable包装,或在创建线程池时统一包装 executor.execute(TtlRunnable.get(() -> { // 这里能拿到父线程的MDC }));

更省事的是用TtlExecutors.getTtlExecutorService(executor)直接包装整个线程池,这样提交任务时连TtlRunnable.get()都不用显式调。

不过我也要说句实话:TTL确实方便,但引入一个新依赖、尤其是可能需要Java Agent配合才能完全发挥效果时,团队接入成本其实不低。如果你的异步场景主要集中在Spring线程池、@Async、MQ消费端这几个可控位置,TaskDecorator方案已经能解决90%的问题。TTL更适合那些线程模型很复杂、动不了源码、只能靠外部包装解决的场景。

4. 跨服务传递:让TraceId顺着HTTP和RPC调用链游走

4.1 Header命名规范和网关统一入口

线程内部搞定了,接下来是跨服务调用。分布式系统里,一次请求往往要经过多个服务,如果每个服务各用各的TraceId,前面做的工作就白费了。所以必须定一个规矩:TraceId在服务间传递时放在哪里,叫什么名字。

我们的做法是统一用HTTP Header里的traceId字段。所有服务约定俗成:收到外部请求时看Header里有没有traceId,有就沿用,没有就生成。内部服务之间调用时,一律从MDC里取出当前traceId放到Header里带过去。名字也可以叫X-Trace-Id或者X-Request-Id,这个不关键,关键是全团队统一。

微服务架构下,外部请求往往先进网关。网关这里做一次统一入口处理:检查请求Header里的traceId,为空就生成一个放进去,然后转发给下游服务。Spring Cloud Gateway里可以写一个GlobalFilter:

@Component public class TraceIdGatewayFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String traceId = exchange.getRequest().getHeaders().getFirst("traceId"); if (StringUtils.isBlank(traceId)) { traceId = generateTraceId(); } ServerWebExchange mutatedExchange = exchange.mutate() .request(r -> r.header("traceId", traceId)) .build(); return chain.filter(mutatedExchange); } @Override public int getOrder() { return -1000; } }

Gateway这里有个坑要提醒一下:WebFlux是响应式编程模型,基于Netty,不走Servlet规范,所以MDC在这套模型里默认不可用,直接MDC.put是无效的。所以我上面这段代码只是往Header里塞了traceId,没有碰MDC。下游的WebFlux服务如果要打日志,建议用Reactor的contextWrite或者把traceId塞进请求上下文里,这个主题比较深,先不展开。你只需要记住一个关键点:网关层最核心的任务是保证Header里有traceId,保证下游能拿到。

4.2 Feign、RestTemplate、OkHttp三大客户端的拦截器配置

网关配好了,内部服务之间的调用也要带上Header。最省心的做法是配置一个全局拦截器,而不是在每次调用时手动加Header。

Feign场景(Spring Cloud OpenFeign)最常用,通过RequestInterceptor实现:

@Bean public RequestInterceptor traceIdRequestInterceptor() { return template -> { String traceId = MDC.get("traceId"); if (StringUtils.isNotBlank(traceId)) { template.header("traceId", traceId); } }; }

这个Bean配上之后,所有Feign请求都会自动带上MDC里的traceId。唯一要注意的是,如果Feign配置了RequestInterceptor多个,注意它们的顺序;不过我们的场景里顺序无所谓,只要能把Header塞上就行。

RestTemplate场景,用ClientHttpRequestInterceptor:

@Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); restTemplate.getInterceptors().add((request, body, execution) -> { String traceId = MDC.get("traceId"); if (StringUtils.isNotBlank(traceId)) { request.getHeaders().add("traceId", traceId); } return execution.execute(request, body); }); return restTemplate; }

OkHttp场景,用okhttp3.Interceptor:

@Bean public OkHttpClient okHttpClient() { return new OkHttpClient.Builder() .addInterceptor(chain -> { Request original = chain.request(); String traceId = MDC.get("traceId"); if (StringUtils.isNotBlank(traceId)) { Request requestWithTrace = original.newBuilder() .header("traceId", traceId) .build(); return chain.proceed(requestWithTrace); } return chain.proceed(original); }) .build(); }

这三个拦截器的套路完全一样:从MDC里取traceId,取到就往Header里塞。之所以要用拦截器而不是每次手动加Header,是因为拦截器能保证团队所有成员写的调用代码默认带上TraceId,不需要每个人都记得手动处理。这属于“约定优于配置”的思路,能让机制持续运转,而不是靠某个人写代码时想起来才加。

下游服务收到带traceId的Header后,会走我前面写的TraceIdFilter:从Header里读到traceId,直接put进MDC,于是整条调用链的日志就串起来了。

4.3 Dubbo这类RPC框架的隐式参数传递

服务之间不全是HTTP调用,很多团队内部用Dubbo这类RPC框架。Dubbo天然支持隐式参数传递(attachments),不会污染业务入参,很适合用来传TraceId。

消费者侧,在调用前把traceId塞进RpcContext:

RpcContext.getContext().setAttachment("traceId", MDC.get("traceId"));

提供者侧,在收到请求时从RpcContext取出traceId,并放入MDC:

String traceId = RpcContext.getContext().getAttachment("traceId"); if (StringUtils.isNotBlank(traceId)) { MDC.put("traceId", traceId); }

当然,你可以在Dubbo的Filter扩展点里做统一处理,这样不需要每个接口都手动setAttachment。实现一个org.apache.dubbo.rpc.Filter,在invoke方法里处理presetAttachment和MDC的写入与清理,然后通过@Activate(group = {CommonConstants.PROVIDER, CommonConstants.CONSUMER})激活即可。这个方案和HTTP拦截器的思路一样,核心都是“通过统一入口透传,而不是靠业务代码手动配合”。

5. 日志侧收口:业务日志和SQL日志如何都带上TraceId

5.1 logback下MDC输出配置与JSON结构化日志

到了这一步,TraceId已经能在一次请求的所有线程和服务里传递了,接下来要做的就是在日志输出时把它“印”出来。

如果你用的是logback,最简单的方式是在pattern里加%X{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> <charset>UTF-8</charset> </encoder> </appender>

这里的%X{traceId}就是从当前线程MDC里取“traceId”这个key对应的值。如果MDC里没有,就输出空字符串。所以即使某个地方漏配了TraceIdFilter,日志也能照常输出,不会因为缺这个字段就报错崩溃。

但如果你的日志要采集进Elasticsearch,我更推荐直接用LogstashEncoder输出JSON格式的日志。好处是MDC里的所有字段会被自动解析成JSON的独立字段,到了ES里就是独立的traceId字段,检索效率远超在整段message里做模糊匹配。

<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.json.log</file> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <includeMdc>true</includeMdc> <customFields>{"appname":"order-service"}</customFields> </encoder> </appender>

这样打出来的日志是类似这样的JSON:

{ "@timestamp": "2025-01-15T13:42:37.123Z", "level": "INFO", "logger": "com.example.OrderServiceImpl", "message": "订单创建成功 orderId=12345", "traceId": "6aa2526590ad07346b76e2b8d8d80384", "appname": "order-service" }

traceId作为独立字段出现,之后在ES里用term查询就能精确匹配,不用再担心正则匹配把性能拖垮。

5.2 SQL日志打点:p6spy和MyBatis的两种配置方式

业务日志带上TraceId之后,还差一类关键日志:SQL执行日志。这也是标题里“能在Elasticsearch查询到对应SQL日志”的核心诉求。如果SQL日志没有带TraceId,你就没法知道某条慢SQL到底是哪次请求触发的,排查性能问题还得靠猜。

方案一:p6spy 统一拦截JDBC

p6spy是一个JDBC驱动代理,会在你的真实数据库驱动外层包装一层,把执行的SQL、参数、耗时都打印出来。配置方式如下:

  1. 引入依赖:
<dependency> <groupId>p6spy</groupId> <artifactId>p6spy</artifactId> <version>3.9.1</version> </dependency>
  1. 修改JDBC连接串,把真实驱动包在p6spy外面:
jdbc-url=jdbc:p6spy:mysql://localhost:3306/order_db driver-class-name=com.p6spy.engine.spy.P6SpyDriver
  1. 在classpath下放一个spy.properties:
# 让SQL日志走logback,而不是输出到控制台 appender=com.p6spy.engine.spy.appender.Slf4JLogger logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat customLogMessageFormat=%(currentTime) | took %(executionTime) ms | %(sql)

p6spy有一个很明显的优势:它对业务代码完全透明,只要改了连接串和驱动,所有JDBC操作都会被统一记录下来,包括MyBatis、JPA、原生JDBC。而且p6spy打印SQL日志时,是直接在应用进程里打logback的那套logger,所以MDC里的traceId会自然带上。

方案二:MyBatis本身的日志输出

MyBatis本身也支持打印SQL,通过configuration.setLogImpl(StdOutImpl.class)或配置log-impl就能实现。但MyBatis打印SQL的时候默认不是DEBUG级别,你需要在配置文件里把mapper所在的包级别调成DEBUG:

logging: level: com.example.mapper: debug

这样MyBatis会打印Preparing、Parameters、Total这几个阶段。缺点是SQL日志和业务日志可能不在同一行,如果MDC没设置好,检索时不太方便关联。

我的建议是,如果你依赖的ORM不只是MyBatis,或者你有多个数据源,直接用p6spy;如果项目很轻、只是MyBatis,用MyBatis本身的日志也够用。两种方案的关键点是:不管用哪种,一定要让SQL日志的logger是走logback/Log4j2体系的,这样MDC里的traceId才会同步出现在SQL日志上。否则SQL日志从JDBC层面直接打到stdout,那采集到ES里还是没有traceId关联。

5.3 日志采集到Elasticsearch时避免TraceId变成“消息里的字符串”

日志文件生成后,采集环节也很关键。很多团队用Filebeat或Logstash采集日志,如果日志是文本格式,TraceId会嵌在整行message里,比如:

2025-01-15 13:42:37.123 [http-nio-8080-exec-8] INFO c.e.OrderServiceImpl [6aa2526590ad07346b76e2b8d8d80384] - 订单创建成功

这种情况下,ES里traceId不是一个独立字段,你只能用message: "*6aa2526590ad07346b76e2b8d8d80384*"去做wildcard查询。Wildcard查询性能差,尤其在高基数日志量下,慢得让人崩溃。

所以在Filebeat采集时,最好把traceId单独拆成一个字段。以Filebeat为例,用processors里的grok解析:

filebeat.inputs: - type: filestream paths: - /data/logs/order-service/*.log processors: - dissect: tokenizer: "%{@timestamp} [%{thread}] %{level} %{logger} [%{traceId}] - %{message}" target_prefix: ""

如果你的日志直接用LogstashEncoder输出JSON格式,就不用这么麻烦。Filebeat的json keys配置或Logstash的json filter能自动把JSON展开成独立字段,traceId天然成为独立字段。

这里要特别提一句:在ES的索引mapping里,最好给traceId建keyword类型的字段。因为traceId的用法是精确匹配(term查询),不需要分词。如果默认被映射成text,查询时还要处理分词问题,甚至可能被拆得面目全非。

{ "mappings": { "properties": { "traceId": { "type": "keyword" } } } }

这一步做到了,才真正具备“在ES里用TraceId查回对应SQL日志”的能力。

6. Elasticsearch里的垂直切片:用TraceId捞回一次请求的完整执行链

6.1 先看Kibana查询:一条TraceId对齐所有服务与SQL

一切配置就绪后,排查问题的体验会发生质的改变。比如你在日志平台收到一条报错,或者某个请求比较慢,从日志里看到traceId是6aa2526590ad07346b76e2b8d8d80384,在Kibana的Discover页面直接搜:

traceId: "6aa2526590ad07346b76e2b8d8d80384"

或者用Elasticsearch的DSL:

{ "query": { "bool": { "must": [ { "term": { "traceId": "6aa2526590ad07346b76e2b8d8d80384" } } ] } }, "sort": [ { "@timestamp": "asc" } ] }

搜索结果会把这个ID对应的所有日志按时间排好序呈现出来。你看到的可能包括:

  • 网关的转发日志
  • 订单服务的Controller入参日志
  • 订单服务的Service业务日志
  • 订单服务调用支付服务的Feign日志
  • 支付服务的业务日志
  • p6spy打印的SQL执行日志(包含真实参数和耗时)
  • 异常堆栈日志(如果请求失败)

这就是“垂直切片”。同一条请求的生命周期全部日志,再也不用跨服务去grep,再也不用靠时间戳和IP猜顺序。一个ID,整条链。

这就是标题里“提高日志排查效率”的真实落地场景。以前40分钟才能拼出来的故事,现在一条查询语句搞定。尤其是SQL日志,当你能看到“这条请求在这个时间点执行了什么SQL、耗时多少毫秒”时,排查慢请求基本就是看证据而不是猜方向了。

6.2 更高效的三步排查法:错误定位、时间轴还原、SQL分析

拿到traceId之后,怎么利用它快速定位问题,我自己的习惯是三步走。

第一步,先看错误。在搜索框里输入:

traceId: "6aa2526590ad07346b76e2b8d8d80384" AND level: "ERROR"

如果这条请求里有异常,直接先看错误堆栈,弄清楚是什么类型的错——是空指针、是超时、还是SQL异常。错误定位是性价比最高的一步,很多时候看到错误信息,问题原因就已经清楚了。

第二步,看时间轴。如果没有任何ERROR日志,那大概率不是“报错型”问题,而是“性能型”问题。这时候去掉level过滤,按@timestamp升序排列,看整条链路的日志节奏。重点关注:

  • 请求到达网关的时间
  • 进入订单服务的时间
  • 调用支付服务的耗时
  • 返回响应的时间

如果发现某个服务之间的时间差特别大,那瓶颈基本就锁定了。

第三步,重点看SQL。如果请求慢是因为数据库操作慢,SQL日志会是关键证据。p6spy打印的每条SQL都带执行耗时,直接看这条请求里多条SQL的执行时间分布。比如有SQL花了2秒,拿那条SQL去数据库EXPLAIN一遍,看是否有索引失效、全表扫描之类的问题。

这三步法不需要什么高端工具,就在Kibana的搜索框里反复组合条件而已。但配合TraceId这个“主键”,所有操作都是在一条请求的有限日志里进行,而不是面对几十个服务日志文件做全量grep。

6.3 落地为团队可复用的搜索模板

学会自己查没用,还得让团队都用起来。如果每个人都靠手工输入搜索条件,效率还是会打折扣。

Kibana支持保存搜索和创建可视化的功能,建议把上面三步法的Query DSL直接保存成模板:

  1. 按TraceId查全链路:traceId: "$id$"按时间排序
  2. 按TraceId查错误:traceId: "$id$" AND level: "ERROR"
  3. 按TraceId查SQL:traceId: "$id$" AND logger: "p6spy"(或SQL logger的名字)

把这三个保存成Discover里的Saved Search,团队成员排查时只需要输入一个变量——traceId,再点开对应的保存搜索,就能跳过繁琐的组合条件输入,直接看到结果。

更进一步,如果你的日志平台支持自定义看板,还可以把“每日慢SQL对应的traceId Top20”这种聚合指标做成看板,从更大维度反推系统瓶颈。不过这些都是锦上添花,先把“按TraceId查全链路”这个基本动作在团队里普及开,排查效率就已经提升一大截了。

结尾:把TraceId用成本能后的几个小习惯

我现在排查线上问题,第一步一定是先把请求的TraceId拿在手上。不管是用户报障时日志里贴的、还是Kibana里看到的,先用它把该请求的日志垂直切出来,再看错误、拖时间轴、分析SQL。这个操作路径已经变成肌肉记忆了。

最后再分享两个小细节,可能帮你少走弯路。

第一个,在业务日志里把TraceId和关键入参放在一起。比如下单时打一条订单创建请求 userId=xxx orderNo=xxx,这样在ES里搜索时不仅能按TraceId切片,还能拿业务字段反向查TraceId。很多时候用户只记得“我当时的订单号是多少”,不记得具体时间,有这个映射日志就能反查。

第二个,TraceId只是起点,不是终点。如果团队后续要做真正的调用链监控(每个Span的耗时、依赖关系、拓扑图),可以在TraceId基础上再引入SpanId和父SpanId,形成完整的调用树。但说句实在话,大部分中小团队先别急着上一套重量级链路追踪系统,把TraceId在日志侧老老实实打透,已经能解决80%的日志排查痛点了。先把地基打好,再谈盖高楼。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询