链路追踪实现原理简述
链路追踪(Distributed Tracing)是一种用于监控和分析分布式系统中请求调用链路的技术。通过为每个请求分配唯一的追踪标识(TraceId)和跨度标识(SpanId),可以追踪请求在微服务架构中的完整调用路径。
核心概念
-
TraceId(追踪ID):唯一标识一次完整的请求调用链路,在整个调用过程中保持不变
-
SpanId(跨度ID):标识调用链路中的一个节点或操作,每次调用第三方服务时会生成子SpanId
-
ThreadLocal:Java中的线程局部变量,确保在多线程环境下每个线程都有独立的TraceId和SpanId副本,避免数据混乱
-
HandlerInterceptor: Spring MVC请求拦截器接口
方法名 执行时机 核心作用 preHandle控制器(Controller)方法执行之前(最先执行) 前置拦截:校验权限、登录状态、IP 白名单等,返回 false直接终止请求postHandle控制器(Controller)方法执行之后、视图(View)渲染之前 后置增强:修改响应数据、添加通用视图参数(如登录用户信息)、处理模型数据 afterCompletion视图渲染完成(整个请求处理完毕)之后(最后执行,无论是否抛异常) 最终清理:释放资源(如关闭流、删除临时文件)、记录请求耗时、处理异常日志
实现流程
1. 请求入口 - 拦截器初始化
-
当请求进入系统时,
TraceIdInterceptor的preHandle方法首先被触发 -
检查请求头(Header)中是否已存在TraceId和SpanId:
- 如果存在(从Nginx或上游服务传递过来),则复用这些ID
- 如果不存在,则生成新的TraceId和SpanId
-
将TraceId和SpanId存储到ThreadLocal中,确保当前线程在后续处理过程中可以随时访问
-
将TraceId和SpanId添加到响应头(Response Header)中,便于前端或下游服务获取
2. 业务处理阶段
- 在Controller层处理业务逻辑时,可以通过
Traces.getTraceId()和Traces.getSpanId()获取当前请求的追踪信息 - 日志记录时会自动携带TraceId和SpanId,便于日志聚合和问题排查
3. 调用第三方服务 - 传递追踪信息
- 在调用第三方服务(如HTTP请求)时,通过
setHeader方法将当前的TraceId传递到请求头中 - 生成新的子SpanId(
Traces.generateSubSpanId()),标识这是一次新的服务调用 - 将TraceId和子SpanId放入HTTP请求头,确保下游服务能够继续追踪
4. 请求结束 - 清理资源
afterCompletion方法在请求处理完成后执行(无论成功或异常)- 调用
LogMessage.remove()和Traces.reset()清理ThreadLocal中的数据,避免内存泄漏 - 清理请求路径等临时信息
@Component
public class TraceIdInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(TraceIdInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse, Object object) throws Exception {
iniTrace(httpServletRequest);
httpServletResponse.addHeader(LogMessage.HEADER_TRACEID, LogMessage.getTraceId());
httpServletResponse.addHeader(LogMessage.HEADER_SPANID, LogMessage.getSpanId());
return true;
}
private void iniTrace(HttpServletRequest httpServletRequest) throws Exception{
String path = httpServletRequest.getRequestURI();
RequestExtHolder.setPath(path);
putTraceToThreadLocal(httpServletRequest);
}
private void putTraceToThreadLocal(HttpServletRequest httpServletRequest) {
String traceId = httpServletRequest.getHeader(LogMessage.HEADER_TRACEID);
if (StringUtils.isBlank(traceId)) {
traceId = LogMessage.genertorNewTraceid();
} else {
LoggerFactory.getLogger(TraceIdInterceptor.class)
.info("from nginx, trace_id:{}", traceId);
}
LogMessage.setTraceId(traceId);
String spanId = httpServletRequest.getHeader(LogMessage.HEADER_SPANID);
if (StringUtils.isBlank(spanId)) {
spanId = LogMessage.generatorNewSpanid();
} else {
LoggerFactory.getLogger(TraceIdInterceptor.class).info("from nginx, span_id:{}", spanId);
}
LogMessage.setSpanId(spanId);
Traces.setTraceId(LogMessage.getTraceId());
Traces.setSpanId(LogMessage.getSpanId());
}
@Override
public void postHandle(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse, Object object, ModelAndView modelAndView)
throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse, Object object, Exception exception)
throws Exception {
LogMessage.remove();
Traces.reset();
RequestExtHolder.setPath(null);
}
}
- 三方服务调用,在请求header中添加traceId和spanId
private static void setHeader(HttpRequest request, HttpMessage httpMessage, HttpContext httpContext) {
if (request != null) {
Optional.ofNullable(request.getHeaders()).filter((map) -> !map.isEmpty()).ifPresent((map) -> map.forEach(httpMessage::setHeader));
httpMessage.setHeader(LogMessage.HEADER_TRACEID, Traces.getTraceId());
httpMessage.setHeader(LogMessage.HEADER_SPANID, Traces.generateSubSpanId());
httpMessage.setHeader(HeaderEnum.ContentType.getHeaderName(), StringUtils.isNotBlank(request.getContentType()) ? request.getContentType() : "application/json");
BasicCookieStore cookieStore = new BasicCookieStore();
Optional.ofNullable(request.getCookies()).filter((map) -> !map.isEmpty()).ifPresent((map) -> map.forEach((key, value) -> {
BasicClientCookie cookie = new BasicClientCookie(key, value);
cookieStore.addCookie(cookie);
}));
if (CollectionUtils.isNotEmpty(cookieStore.getCookies())) {
httpMessage.setHeader(HeaderEnum.Cookie.getHeaderName(), (String)cookieStore.getCookies().stream().map((cookie) -> cookie.getName() + "=" + cookie.getValue()).collect(Collectors.joining(";")));
}
}
}
关键技术点
ThreadLocal的使用
使用ThreadLocal存储TraceId和SpanId是为了解决多线程环境下的数据隔离问题。每个线程都有自己的副本,互不干扰。
public class LogMessage {
private static ThreadLocal<ExtElement> extElements = new InheritableThreadLocal<ExtElement>() {
public static String getTraceId() {
return ((ExtElement)extElements.get()).getTraceId();
}
public static String getSpanId() {
return ((ExtElement)extElements.get()).getSpanId();
}
public static void setTraceId(String traceId) {
((ExtElement)extElements.get()).setTraceId(traceId);
}
public static void setSpanId(String spanId) {
((ExtElement)extElements.get()).setSpanId(spanId);
}
public static String generatorNewSpanid() {
String newSpanid = SpanIdGenerator.getSpanId();
return newSpanid;
}
public static String genertorNewTraceid() {
String traceid = SpanIdGenerator.getTraceId();
return traceid;
}
}
public class ExtElement {
private String traceId = "";
private String spanId = "";
public String getTraceId() {
return this.traceId;
}
public void setTraceId(String traceId) {
this.traceId = traceId;
}
public String getSpanId() {
if (this.spanId == null || this.spanId.equals("")) {
this.spanId = SpanIdGenerator.getSpanId();
}
return this.spanId;
}
public void setSpanId(String spanId) {
this.spanId = spanId;
}
}
为什么使用 InheritableThreadLocal?
InheritableThreadLocal 是 ThreadLocal 的子类,它的核心特性是能够将父线程的 ThreadLocal 变量值自动传递给子线程。在链路追踪场景中使用它有以下重要原因:
1. 解决异步线程场景下的追踪信息丢失问题
在实际业务中,经常会遇到异步处理的场景,比如:
- 使用
@Async注解创建异步任务 - 使用线程池
ExecutorService提交任务 - 使用
CompletableFuture进行异步编排
如果使用普通的 ThreadLocal,当主线程创建子线程时,子线程无法访问到父线程的 TraceId 和 SpanId,导致链路追踪断裂。
2. 代码示例对比
使用普通 ThreadLocal 的问题:
// 普通 ThreadLocal
private static ThreadLocal<String> normalThreadLocal = new ThreadLocal<>();
public void testNormalThreadLocal() {
normalThreadLocal.set("trace-123");
System.out.println("主线程: " + normalThreadLocal.get()); // 输出: trace-123
new Thread(() -> {
System.out.println("子线程: " + normalThreadLocal.get()); // 输出: null (获取不到)
}).start();
}
使用 InheritableThreadLocal 的优势:
// InheritableThreadLocal
private static InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
public void testInheritableThreadLocal() {
inheritableThreadLocal.set("trace-123");
System.out.println("主线程: " + inheritableThreadLocal.get()); // 输出: trace-123
new Thread(() -> {
System.out.println("子线程: " + inheritableThreadLocal.get()); // 输出: trace-123 (自动继承)
}).start();
}
3. 链路追踪中的实际应用场景
假设服务 A 在处理请求时需要异步调用多个服务:
@RestController
public class OrderController {
@Autowired
private ExecutorService executorService;
@GetMapping("/createOrder")
public String createOrder() {
// 主线程已经设置了 TraceId
String traceId = Traces.getTraceId(); // trace-123
// 异步扣减库存
executorService.submit(() -> {
// 如果使用 InheritableThreadLocal,这里能获取到 trace-123
// 如果使用普通 ThreadLocal,这里获取到 null
String subTraceId = Traces.getTraceId();
inventoryService.reduce(subTraceId);
});
// 异步发送消息
executorService.submit(() -> {
String subTraceId = Traces.getTraceId();
notificationService.send(subTraceId);
});
return "success";
}
}
4. InheritableThreadLocal 的工作原理
当创建新线程时,Java 会检查父线程是否有 InheritableThreadLocal 变量:
- 如果有,则自动调用
Thread.init()方法 - 将父线程的
inheritableThreadLocals表复制到子线程 - 子线程就能访问到父线程设置的值
5. 注意事项与局限性
- 线程池复用问题:
InheritableThreadLocal只在线程创建时复制父线程的值。如果使用线程池,线程被复用时不会重新复制,可能导致数据错乱 - 解决方案: 使用阿里巴巴的
TransmittableThreadLocal(TTL) 库,或者在任务提交时手动传递 TraceId - 性能开销: 相比普通 ThreadLocal 有额外的复制开销,但在链路追踪场景下是可接受的
- 内存泄漏风险: 同样需要在合适的时机调用
remove()清理资源
6. 更好的替代方案:TransmittableThreadLocal
对于生产环境,建议使用阿里开源的 TransmittableThreadLocal(TTL):
// 使用 TTL
private static TransmittableThreadLocal<String> traceIdHolder = new TransmittableThreadLocal<>();
// 装饰线程池
ExecutorService executorService = TtlExecutors.getTtlExecutorService(
Executors.newFixedThreadPool(10)
);
TransmittableThreadLocal 解决了线程池复用场景下的值传递问题,是更加健壮的解决方案。
SpanId的层级关系
SpanId通常采用层级结构,例如:
- 服务A:SpanId = 1
- 服务A调用服务B:SpanId = 1.1
- 服务A调用服务C:SpanId = 1.2
- 服务B调用服务D:SpanId = 1.1.1
这种层级结构可以清晰地展示调用关系和调用深度。
完整调用链示例
- 前端发起请求:请求到达Nginx
- Nginx转发:Nginx可能会在请求头中添加初始的TraceId(如果配置了的话)
- 服务A接收请求:
TraceIdInterceptor拦截,初始化或复用TraceId,生成SpanId - 服务A处理业务:记录日志,日志中包含TraceId和SpanId
- 服务A调用服务B:在HTTP请求头中传递TraceId和新的子SpanId
- 服务B接收请求:复用TraceId,使用传递过来的SpanId
- 服务B处理并返回:日志记录,响应返回给服务A
- 服务A返回响应:将TraceId和SpanId放入响应头,返回给前端
- 清理资源:
afterCompletion执行,清理ThreadLocal
注意事项与最佳实践
- 必须清理ThreadLocal:在请求结束时务必调用
remove(),否则在线程池复用场景下会导致内存泄漏或数据污染 - 异步调用处理:如果使用异步线程(如
@Async),需要手动传递TraceId和SpanId到新线程,可以使用InheritableThreadLocal或手动传递 - 日志框架集成:通常会配合MDC(Mapped Diagnostic Context)使用,自动在日志中添加TraceId和SpanId
- 统一格式:TraceId和SpanId的Header名称应在整个系统中保持一致(如
X-Trace-Id、X-Span-Id) - 性能考虑:链路追踪会带来一定的性能开销,需要在可观测性和性能之间找到平衡
扩展:与开源链路追踪框架的对比
常见的开源链路追踪框架包括:
- Zipkin:Twitter开源,轻量级,支持多种语言
- Jaeger:Uber开源,云原生架构,支持OpenTracing标准
- SkyWalking:Apache项目,专为微服务设计,支持自动埋点
- Sleuth + Zipkin:Spring Cloud生态,与Spring Boot无缝集成
这些框架提供了更完善的功能,如可视化界面、性能分析、服务依赖图等,但实现原理与自定义实现类似。