Vue + SpringBoot系统中集成 Transaction ID(事务 ID)看这一篇就够了!!!

124 阅读14分钟

1. 背景介绍

1.1 什么是 Transaction ID(事务ID)

Transaction ID(事务ID,简称 trancid 或 traceId)是一种用于唯一标识一次完整业务请求的追踪标识符。在分布式系统或复杂调用链中,一个用户操作可能会触发多个服务之间的调用,通过在整个调用链路中传递同一个事务ID,可以将分散在不同服务、不同日志文件中的日志串联起来。

1.2 为什么需要事务ID

在现代微服务架构和前后端分离的系统中,事务ID解决了以下核心问题:

  1. 分布式链路追踪

    • 一个前端请求可能经过 API网关 → 业务服务A → 业务服务B → 数据库
    • 通过统一的事务ID,可以快速定位整个调用链路中的问题节点
  2. 快速问题定位

    • 用户报告问题时,通过事务ID可以立即检索出该次请求的完整日志
    • 大幅减少问题排查时间,从数小时降低到分钟级
  3. 性能分析与优化

    • 通过事务ID聚合统计,分析接口响应时间、失败率等指标
    • 识别系统瓶颈,针对性优化慢接口
  4. 日志聚合与监控

    • 配合ELK、Splunk等日志分析平台,实现日志智能聚合
    • 支持按事务维度进行告警和监控
  5. 审计与合规

    • 满足金融、医疗等行业的审计要求
    • 提供完整的操作轨迹记录

1.3 事务ID的特点

  • 全局唯一性:在整个系统生命周期内不重复
  • 时序性(可选):部分实现方案支持从ID中提取时间信息
  • 透传性:在整个调用链路中保持不变
  • 低开销:生成和传递的性能开销可忽略不计

1.4 典型应用场景

用户操作:点击"查询患者信息"按钮

前端 → 生成 trancid: a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d
  ↓
API网关(记录trancid)
  ↓
患者服务(使用同一trancid记录日志)
  ↓
EMPI服务(透传trancid)
  ↓
数据库(在SQL注释中包含trancid)

当查询失败时,通过 trancid 可以快速在所有服务的日志中检索到完整调用链:
- 前端发起时间:14:32:15.123
- 网关接收时间:14:32:15.145(耗时22ms)
- 患者服务处理时间:14:32:15.150(耗时5ms)
- EMPI服务查询时间:14:32:15.155(耗时3200ms)❌ 问题定位
- 数据库查询时间:14:32:15.160(耗时3150ms)❌ 慢查询

2. 前端集成(Vue + Axios)

2.1 生成规则方案对比与选型

方案一:crypto.randomUUID()

示例代码:

const trancid = crypto.randomUUID();
// 输出: "a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d"

优点:

  • ✅ 浏览器原生API,无需额外依赖
  • ✅ 性能最优(原生实现)
  • ✅ 符合RFC 4122标准
  • ✅ 生成速度极快(纳秒级)

缺点:

  • ❌ 兼容性有限(Chrome 92+, Safari 15.4+, Firefox 95+)
  • ❌ 不支持IE和旧版浏览器
  • ❌ Node.js需要v14.17.0+(且需要特殊处理)

适用场景:

  • 现代浏览器项目(不需要支持旧版浏览器)
  • 内部管理系统
  • 对性能要求极高的场景

👉 查看最新兼容性:caniuse.com - crypto.randomUUID


方案二:uuid 库(v4 方法)

示例代码:

import { v4 as uuidv4 } from 'uuid';

const trancid = uuidv4();
// 输出: "f47ac10b-58cc-4372-a567-0e02b2c3d479"

优点:

  • ✅ 兼容性极佳(支持所有浏览器和Node.js)
  • ✅ 生态成熟,npm周下载量1亿+
  • ✅ 提供多种UUID版本(v1/v3/v4/v5)
  • ✅ 符合RFC 4122标准
  • ✅ 支持自定义随机数生成器

缺点:

  • ❌ 增加打包体积(约4-5KB gzipped)
  • ❌ 性能略低于原生API(但差异可忽略)

适用场景:

  • 需要广泛浏览器兼容的项目✅ 推荐
  • 已经使用uuid库的项目(本项目已引入)✅
  • 需要不同UUID版本的场景

方案三:自定义简易UUID函数

示例代码:

function generateSimpleUUID() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

const trancid = generateSimpleUUID();
// 输出: "3d8f4a2b-1e5c-4f7a-b8c3-9d2e1f4a5b6c"

优点:

  • ✅ 无需额外依赖,代码简洁
  • ✅ 打包体积最小
  • ✅ 全浏览器兼容

缺点:

  • ❌ 随机性依赖Math.random()(非加密级安全)
  • ❌ 理论上存在碰撞风险(虽然极低)
  • ❌ 不符合RFC 4122严格标准
  • ❌ 不支持高并发场景(Math.random种子问题)

适用场景:

  • 简单项目,对唯一性要求不高
  • 追求极致打包体积优化
  • 非金融/医疗等严格合规场景

📋 方案对比总结表

对比维度crypto.randomUUID()uuid库(v4)自定义简易函数
兼容性⚠️ 有限(现代浏览器)✅ 优秀(全兼容)✅ 优秀(全兼容)
性能⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
打包体积✅ 0KB⚠️ 4-5KB✅ <1KB
标准符合性✅ RFC 4122✅ RFC 4122⚠️ 部分符合
安全性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
生态成熟度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
维护成本✅ 低✅ 低⚠️ 中(需自维护)
推荐场景现代浏览器内部系统企业级项目(推荐)简单项目

2.2 ⭐ 选型建议

针对本项目的推荐方案:uuid 库(v4)

理由:

  1. ✅ 项目已经引入uuid库(package.json第63行),无额外成本
  2. ✅ 医疗行业项目,需要严格的唯一性和合规性
  3. ✅ 需要支持多种浏览器环境(包括医院内网旧版浏览器)
  4. ✅ 当前代码已经在使用(axios.js第9行和第422行)

通用选型决策树:

需要支持旧版浏览器?
├─ 是 → 使用 uuid 库 ✅
└─ 否 → 是否已有uuid依赖?
      ├─ 是 → 使用 uuid 库 ✅
      └─ 否 → 是否为严格合规项目?
            ├─ 是 → 使用 uuid 库 ✅
            └─ 否 → 使用 crypto.randomUUID()

2.3 trancid 命名规范建议

2.3.1 HTTP Header 命名

推荐命名:

X-Transaction-Id  ✅ 推荐(清晰易懂)
X-Trace-Id        ✅ 常用(业界标准)
X-Request-Id      ⚠️ 当前使用(可能与Request ID混淆)

命名原则:

  1. 使用 X- 前缀:表示自定义非标准Header(RFC 6648已不强制,但仍是惯例)
  2. 语义清晰:避免使用缩写(如 trancid)
  3. 与业界标准对齐
    • OpenTelemetry 使用 traceparent
    • AWS X-Ray 使用 X-Amzn-Trace-Id
    • Zipkin 使用 X-B3-TraceId

本项目建议:

  • 保持 X-Request-Id 作为单个请求的唯一标识
  • 新增 X-Transaction-IdX-Trace-Id 作为业务事务标识
  • 在前后端统一使用相同命名

2.3.2 代码中的变量命名

// ✅ 推荐
const transactionId = uuidv4();
const traceId = uuidv4();

// ⚠️ 可接受
const requestId = uuidv4();  // 如果确实指代单个请求
const txId = uuidv4();       // 在内部工具函数中可用

// ❌ 不推荐
const trancid = uuidv4();    // 拼写非标准
const id = uuidv4();         // 语义不明确
const uuid = uuidv4();       // 混淆了ID类型和值

2.4 请求携带方式建议

方式一:HTTP Header(推荐)✅

优点:

  • ✅ 语义清晰,不污染业务参数
  • ✅ 适用于所有HTTP方法(GET/POST/PUT/DELETE)
  • ✅ 后端拦截器统一处理
  • ✅ 不影响接口缓存策略
  • ✅ 符合RESTful设计原则

缺点:

  • ❌ 某些网络设备可能会过滤自定义Header(极少见)

实现代码:

// src/plugins/axios.js

import { v4 as uuidv4 } from 'uuid';

// 方式1:每个请求生成新ID
instance.interceptors.request.use(config => {
  config.headers['X-Transaction-Id'] = uuidv4();
  return config;
});

// 方式2:同一业务流程使用同一ID(高级用法)
// 适用于:一个页面操作触发多个接口调用的场景
let currentTransactionId = null;

export function startTransaction() {
  currentTransactionId = uuidv4();
  return currentTransactionId;
}

export function endTransaction() {
  currentTransactionId = null;
}

instance.interceptors.request.use(config => {
  // 如果有活跃的事务ID,使用它;否则生成新ID
  config.headers['X-Transaction-Id'] = currentTransactionId || uuidv4();
  return config;
});

方式二:Query参数(不推荐)⚠️

适用场景:

  • WebSocket连接建立
  • SSE(Server-Sent Events)
  • 第三方服务不支持自定义Header

缺点:

  • ❌ GET请求URL过长
  • ❌ 日志中暴露,可能泄露追踪信息
  • ❌ 影响接口缓存键
  • ❌ 污染业务参数空间

实现代码:

// 仅用于特殊场景
config.params = {
  ...config.params,
  _tid: uuidv4()  // 使用下划线前缀避免与业务参数冲突
};

方式三:Body参数(不推荐)❌

缺点:

  • ❌ 仅适用于POST/PUT请求
  • ❌ 污染业务数据模型
  • ❌ 需要前后端数据结构特殊处理

2.5 完整实现代码(基于现有项目)

2.5.1 优化 axios.js 配置

// src/plugins/axios.js(优化现有代码)

import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { Message } from '@imfe/ui';

// ... 现有代码 ...

// 当前事务ID(用于关联同一业务流程的多个请求)
let currentTransactionId = null;

/**
 * 开始一个新的业务事务
 * @param {string} businessType - 业务类型(用于日志分类)
 * @returns {string} 事务ID
 */
export function startTransaction(businessType = '') {
  currentTransactionId = uuidv4();
  console.log(`[Transaction Started] ${currentTransactionId} - ${businessType}`);
  return currentTransactionId;
}

/**
 * 结束当前业务事务
 */
export function endTransaction() {
  if (currentTransactionId) {
    console.log(`[Transaction Ended] ${currentTransactionId}`);
    currentTransactionId = null;
  }
}

/**
 * 获取当前事务ID
 */
export function getCurrentTransactionId() {
  return currentTransactionId;
}

// 请求拦截器(在现有拦截器中添加)
instance.interceptors.request.use(config => {
  // ... 现有逻辑 ...
  
  // 添加事务ID(修改现有的handleRequestId函数)
  handleTransactionId(config);
  
  return config;
}, error => {
  // ... 现有错误处理 ...
});

/**
 * 添加事务ID和请求ID
 * @param {Object} config - axios配置对象
 */
function handleTransactionId(config) {
  // X-Request-Id: 单个HTTP请求的唯一标识(每次都生成新的)
  config.headers['X-Request-Id'] = uuidv4();
  
  // X-Transaction-Id: 业务事务标识(同一业务流程共享)
  // 如果有活跃的事务ID,使用它;否则与Request-Id相同
  config.headers['X-Transaction-Id'] = currentTransactionId || config.headers['X-Request-Id'];
  
  // 可选:添加到响应中,方便前端获取
  config._transactionId = config.headers['X-Transaction-Id'];
  config._requestId = config.headers['X-Request-Id'];
}

/**
 * 原有的handleRequestId函数可以改为调用新函数
 * 保持向后兼容
 */
function handleRequestId(config) {
  handleTransactionId(config);
}

// 响应拦截器(添加日志输出)
instance.interceptors.response.use(
  response => {
    // 记录成功响应
    const { config } = response;
    console.log(`[API Success] ${config.method?.toUpperCase()} ${config.url}`, {
      transactionId: config._transactionId,
      requestId: config._requestId,
      duration: Date.now() - config._requestStartTime
    });
    
    // ... 现有逻辑 ...
    return response;
  },
  error => {
    // 记录失败响应
    const { config } = error;
    console.error(`[API Error] ${config?.method?.toUpperCase()} ${config?.url}`, {
      transactionId: config?._transactionId,
      requestId: config?._requestId,
      error: error.message,
      duration: Date.now() - (config?._requestStartTime || Date.now())
    });
    
    // ... 现有错误处理 ...
  }
);

// 添加请求开始时间(用于计算耗时)
instance.interceptors.request.use(config => {
  config._requestStartTime = Date.now();
  return config;
});

export default instance;

2.5.2 业务代码使用示例

// src/project-patient/pages/patient-view/Index.vue

<template>
  <div class="patient-view">
    <el-button @click="handleLoadPatientData">加载患者信息</el-button>
  </div>
</template>

<script>
import axios, { startTransaction, endTransaction } from '@/plugins/axios';

export default {
  name: 'PatientView',
  methods: {
    async handleLoadPatientData() {
      // 开始一个新的业务事务
      const transactionId = startTransaction('LOAD_PATIENT_DATA');
      
      try {
        // 这些请求会共享同一个 transactionId
        const patientInfo = await axios.post('/api/patient/info', { 
          patientId: this.patientId 
        });
        
        const visitRecords = await axios.post('/api/patient/visits', { 
          patientId: this.patientId 
        });
        
        const labResults = await axios.post('/api/patient/lab-results', { 
          patientId: this.patientId 
        });
        
        // 处理数据...
        this.patientData = {
          info: patientInfo,
          visits: visitRecords,
          labs: labResults
        };
        
        console.log(`[业务成功] 事务ID: ${transactionId}`);
      } catch (error) {
        console.error(`[业务失败] 事务ID: ${transactionId}`, error);
        this.$message.error(`加载失败,事务ID: ${transactionId}`);
      } finally {
        // 结束事务
        endTransaction();
      }
    }
  }
};
</script>

2.5.3 全局错误处理增强

// src/utils/error-handler.js

import { getCurrentTransactionId } from '@/plugins/axios';
import { Message } from '@imfe/ui';

/**
 * 全局错误处理器
 */
export function handleGlobalError(error, vm, info) {
  const transactionId = getCurrentTransactionId();
  
  console.error('[Global Error]', {
    error,
    component: vm?.$options?.name,
    info,
    transactionId,
    timestamp: new Date().toISOString()
  });
  
  // 显示用户友好的错误信息
  Message.error({
    message: `操作失败,请联系管理员\n事务ID: ${transactionId}`,
    duration: 5000,
    showClose: true
  });
  
  // 可选:上报到监控平台
  reportErrorToMonitoring({
    error,
    transactionId,
    component: vm?.$options?.name,
    info
  });
}

/**
 * 上报错误到监控平台(示例)
 */
function reportErrorToMonitoring(errorInfo) {
  // 集成Sentry、阿里云ARMS等监控平台
  if (window.Sentry) {
    window.Sentry.captureException(errorInfo.error, {
      tags: {
        transactionId: errorInfo.transactionId,
        component: errorInfo.component
      }
    });
  }
}

2.5.4 在 main.js 中注册

// src/project-patient/main.js

import Vue from 'vue';
import App from './App.vue';
import { handleGlobalError } from '@/utils/error-handler';

// 注册全局错误处理
Vue.config.errorHandler = handleGlobalError;

new Vue({
  render: h => h(App)
}).$mount('#app');

3. 后端集成(Spring Boot)

3.1 添加依赖

通常Spring Boot项目已经包含所需依赖,无需额外添加。如果使用基础Spring项目,需确认以下依赖:

<!-- pom.xml -->
<dependencies>
    <!-- Spring Web(通常已有) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Servlet API(通常已有) -->
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <scope>provided</scope>
    </dependency>
    
    <!-- SLF4J + Logback(Spring Boot自带) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-logging</artifactId>
    </dependency>
</dependencies>

3.2 创建 Filter 实现(推荐方案)✅

3.2.1 完整Filter代码

package com.zhyl.dsj.common.filter;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

/**
 * 事务ID过滤器
 * 功能:
 * 1. 从请求头提取或生成 Transaction ID
 * 2. 将 Transaction ID 存入 MDC(Mapped Diagnostic Context)
 * 3. 将 Transaction ID 添加到响应头
 * 4. 请求完成后清理 MDC
 * 
 * @author Your Name
 * @date 2025-11-19
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)  // 最高优先级,确保最先执行
public class TransactionFilter implements Filter {

    private static final Logger logger = LoggerFactory.getLogger(TransactionFilter.class);

    // MDC 键名
    private static final String MDC_TRANSACTION_ID_KEY = "transactionId";
    private static final String MDC_REQUEST_ID_KEY = "requestId";
    
    // HTTP Header 名称
    private static final String HEADER_TRANSACTION_ID = "X-Transaction-Id";
    private static final String HEADER_REQUEST_ID = "X-Request-Id";

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        logger.info("TransactionFilter initialized");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        try {
            // 1. 获取或生成 Transaction ID
            String transactionId = getOrGenerateTransactionId(httpRequest);
            String requestId = getOrGenerateRequestId(httpRequest);

            // 2. 存入 MDC(线程上下文)
            MDC.put(MDC_TRANSACTION_ID_KEY, transactionId);
            MDC.put(MDC_REQUEST_ID_KEY, requestId);

            // 3. 添加到响应头(方便前端获取)
            httpResponse.setHeader(HEADER_TRANSACTION_ID, transactionId);
            httpResponse.setHeader(HEADER_REQUEST_ID, requestId);

            // 4. 记录请求开始日志
            long startTime = System.currentTimeMillis();
            logger.info("Request started: {} {} from {}",
                    httpRequest.getMethod(),
                    httpRequest.getRequestURI(),
                    httpRequest.getRemoteAddr());

            // 5. 执行请求
            chain.doFilter(request, response);

            // 6. 记录请求完成日志
            long duration = System.currentTimeMillis() - startTime;
            logger.info("Request completed: {} {} - Status: {} - Duration: {}ms",
                    httpRequest.getMethod(),
                    httpRequest.getRequestURI(),
                    httpResponse.getStatus(),
                    duration);

        } catch (Exception e) {
            // 记录异常
            logger.error("Request failed: {} {} - Error: {}",
                    httpRequest.getMethod(),
                    httpRequest.getRequestURI(),
                    e.getMessage(),
                    e);
            throw e;
        } finally {
            // 7. 清理 MDC(非常重要!避免内存泄漏)
            MDC.remove(MDC_TRANSACTION_ID_KEY);
            MDC.remove(MDC_REQUEST_ID_KEY);
            MDC.clear();
        }
    }

    @Override
    public void destroy() {
        logger.info("TransactionFilter destroyed");
    }

    /**
     * 获取或生成 Transaction ID
     * 优先从请求头获取,如果没有则生成新的
     */
    private String getOrGenerateTransactionId(HttpServletRequest request) {
        String transactionId = request.getHeader(HEADER_TRANSACTION_ID);
        
        if (!StringUtils.hasText(transactionId)) {
            // 如果前端没有传递,则生成新的
            transactionId = generateUUID();
            logger.debug("Generated new Transaction ID: {}", transactionId);
        } else {
            logger.debug("Using Transaction ID from request header: {}", transactionId);
        }
        
        return transactionId;
    }

    /**
     * 获取或生成 Request ID
     */
    private String getOrGenerateRequestId(HttpServletRequest request) {
        String requestId = request.getHeader(HEADER_REQUEST_ID);
        
        if (!StringUtils.hasText(requestId)) {
            requestId = generateUUID();
        }
        
        return requestId;
    }

    /**
     * 生成UUID
     */
    private String generateUUID() {
        return UUID.randomUUID().toString();
    }
}

3.2.2 为什么推荐 Filter 而非 Interceptor?

对比维度Filter(推荐)✅Interceptor(备选)⚠️
执行时机Servlet容器级别,最早执行Spring MVC级别,晚于Filter
覆盖范围所有请求(包括静态资源、错误页面)仅Controller请求
异常捕获可捕获所有异常(包括404)仅捕获Controller异常
技术标准Java Servlet标准Spring特有
适用场景通用基础设施(日志、鉴权、CORS)业务拦截(权限校验、参数校验)

结论: Transaction ID属于基础设施功能,应该覆盖所有请求,因此使用 Filter 是最佳实践。


3.3 注册 Filter

方式一:自动注册(推荐)✅

使用 @Component 注解,Spring Boot 会自动注册所有实现 Filter 接口的Bean。

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)  // 控制Filter执行顺序
public class TransactionFilter implements Filter {
    // 代码同上...
}

优点:

  • ✅ 配置简单,零XML
  • ✅ 自动扫描,无需手动注册
  • ✅ 使用Spring的依赖注入

缺点:

  • ❌ URL匹配模式固定为 /*(匹配所有请求)
  • ❌ 初始化参数不灵活

方式二:显式注册(灵活配置)⚠️

当需要自定义URL匹配模式或初始化参数时使用。

package com.zhyl.dsj.common.config;

import com.zhyl.dsj.common.filter.TransactionFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;

/**
 * Filter 配置类
 */
@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<TransactionFilter> transactionFilterRegistration() {
        FilterRegistrationBean<TransactionFilter> registration = new FilterRegistrationBean<>();
        
        // 1. 设置Filter实例(不使用@Component,手动创建)
        registration.setFilter(new TransactionFilter());
        
        // 2. 设置URL匹配模式
        registration.addUrlPatterns("/api/*");  // 仅匹配 /api/ 开头的请求
        registration.addUrlPatterns("/gateway/*");
        
        // 3. 设置执行顺序
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
        
        // 4. 设置Filter名称
        registration.setName("transactionFilter");
        
        // 5. 设置初始化参数(可选)
        registration.addInitParameter("excludeUrls", "/health,/actuator");
        
        return registration;
    }
}

使用场景:

  • 需要精确控制URL匹配规则
  • 需要传递初始化参数
  • 需要动态启用/禁用Filter
  • 需要根据环境配置不同的Filter行为

方式三:使用 @WebFilter(不推荐)❌

@WebFilter(urlPatterns = "/*", filterName = "transactionFilter")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TransactionFilter implements Filter {
    // 代码同上...
}

缺点:

  • ❌ 需要在启动类添加 @ServletComponentScan
  • ❌ 无法使用Spring的依赖注入
  • ❌ 不推荐在Spring Boot项目中使用

3.4 配置 Logback 日志格式

方式 A:通过 application.yml(简单场景)✅

适用于简单的日志格式配置,无需复杂的Appender和Logger策略。

# application.yml

# 日志配置
logging:
  # 日志级别
  level:
    root: INFO
    com.zhyl.dsj: DEBUG
    org.springframework.web: INFO
  
  # 日志格式(包含 Transaction ID)
  pattern:
    # 控制台输出格式
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [TransactionId:%X{transactionId}] [RequestId:%X{requestId}] %logger{36} - %msg%n"
    
    # 文件输出格式
    file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [TransactionId:%X{transactionId}] [RequestId:%X{requestId}] %logger{50} - %msg%n"
  
  # 日志文件配置
  file:
    name: logs/dsj-clinical.log
    max-size: 100MB
    max-history: 30
    total-size-cap: 10GB

# Spring Boot Actuator(可选,用于健康检查)
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics

日志输出示例:

2025-11-19 14:32:15.123 [http-nio-8080-exec-1] INFO  [TransactionId:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RequestId:f47ac10b-58cc-4372-a567-0e02b2c3d479] c.z.d.patient.controller.PatientController - 查询患者信息: patientId=12345
2025-11-19 14:32:15.456 [http-nio-8080-exec-1] INFO  [TransactionId:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RequestId:f47ac10b-58cc-4372-a567-0e02b2c3d479] c.z.d.patient.service.PatientService - 从数据库加载患者数据: patientId=12345
2025-11-19 14:32:15.789 [http-nio-8080-exec-1] INFO  [TransactionId:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RequestId:f47ac10b-58cc-4372-a567-0e02b2c3d479] c.z.d.common.filter.TransactionFilter - Request completed: GET /api/patient/info - Status: 200 - Duration: 666ms

格式说明:

  • %d{yyyy-MM-dd HH:mm:ss.SSS} - 时间戳(精确到毫秒)
  • [%thread] - 线程名
  • %-5level - 日志级别(INFO/DEBUG/ERROR等)
  • [TransactionId:%X{transactionId}] - 事务ID(从MDC获取)
  • [RequestId:%X{requestId}] - 请求ID(从MDC获取)
  • %logger{36} - Logger名称(最长36个字符)
  • %msg%n - 日志消息 + 换行

方式 B:通过 logback-spring.xml(企业级推荐)✅

适用于复杂的日志策略,如按日期归档、异步日志、不同级别输出到不同文件等。

<?xml version="1.0" encoding="UTF-8"?>
<!-- logback-spring.xml(放在 src/main/resources 目录) -->
<configuration>
    <!-- 引入 Spring Boot 默认配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- ============================================ -->
    <!-- 变量定义 -->
    <!-- ============================================ -->
    <property name="LOG_HOME" value="logs"/>
    <property name="APP_NAME" value="dsj-clinical"/>
    
    <!-- 日志格式(包含 Transaction ID) -->
    <property name="LOG_PATTERN" 
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [TID:%X{transactionId:-N/A}] [RID:%X{requestId:-N/A}] %logger{50} - %msg%n"/>
    
    <!-- JSON格式日志(用于日志采集系统) -->
    <property name="LOG_PATTERN_JSON" 
              value='{"timestamp":"%d{yyyy-MM-dd HH:mm:ss.SSS}","thread":"%thread","level":"%-5level","transactionId":"%X{transactionId:-}","requestId":"%X{requestId:-}","logger":"%logger{50}","message":"%msg"}%n'/>

    <!-- ============================================ -->
    <!-- Appender 定义 -->
    <!-- ============================================ -->
    
    <!-- 1. 控制台输出 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <!-- 仅输出 INFO 及以上级别 -->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>INFO</level>
        </filter>
    </appender>

    <!-- 2. 所有日志文件(按日期归档) -->
    <appender name="FILE_ALL" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_HOME}/${APP_NAME}-all.log</file>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        
        <!-- 滚动策略:按日期和大小 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 日志文件命名规则 -->
            <fileNamePattern>${LOG_HOME}/${APP_NAME}-all-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <!-- 单个文件最大100MB -->
            <maxFileSize>100MB</maxFileSize>
            <!-- 保留30天 -->
            <maxHistory>30</maxHistory>
            <!-- 总大小限制10GB -->
            <totalSizeCap>10GB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <!-- 3. 错误日志文件(单独记录ERROR级别) -->
    <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_HOME}/${APP_NAME}-error.log</file>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        
        <!-- 仅输出 ERROR 级别 -->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
        
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_HOME}/${APP_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>90</maxHistory>  <!-- 错误日志保留更久 -->
            <totalSizeCap>5GB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <!-- 4. 业务日志文件(用于业务审计) -->
    <appender name="FILE_BUSINESS" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_HOME}/${APP_NAME}-business.log</file>
        <encoder>
            <pattern>${LOG_PATTERN}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_HOME}/${APP_NAME}-business-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>180</maxHistory>  <!-- 业务日志保留更久 -->
            <totalSizeCap>20GB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <!-- 5. JSON格式日志(用于ELK等日志采集系统) -->
    <appender name="FILE_JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_HOME}/${APP_NAME}-json.log</file>
        <encoder>
            <pattern>${LOG_PATTERN_JSON}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_HOME}/${APP_NAME}-json-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>7</maxHistory>  <!-- JSON日志通常被实时采集,保留时间短 -->
            <totalSizeCap>5GB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <!-- 6. 异步日志(提升性能) -->
    <appender name="ASYNC_FILE_ALL" class="ch.qos.logback.classic.AsyncAppender">
        <!-- 队列大小 -->
        <queueSize>512</queueSize>
        <!-- 队列满时丢弃 TRACE/DEBUG/INFO 日志 -->
        <discardingThreshold>0</discardingThreshold>
        <!-- 不阻塞业务线程 -->
        <neverBlock>true</neverBlock>
        <appender-ref ref="FILE_ALL"/>
    </appender>

    <!-- ============================================ -->
    <!-- Logger 配置 -->
    <!-- ============================================ -->
    
    <!-- 业务日志Logger(单独输出到业务日志文件) -->
    <logger name="com.zhyl.dsj.business" level="INFO" additivity="false">
        <appender-ref ref="FILE_BUSINESS"/>
        <appender-ref ref="CONSOLE"/>
    </logger>

    <!-- 数据库SQL日志(开发环境开启) -->
    <logger name="com.zhyl.dsj.mapper" level="DEBUG" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>

    <!-- Spring框架日志(减少冗余) -->
    <logger name="org.springframework" level="INFO"/>
    <logger name="org.springframework.web" level="INFO"/>
    <logger name="org.mybatis" level="INFO"/>

    <!-- 根Logger -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ASYNC_FILE_ALL"/>
        <appender-ref ref="FILE_ERROR"/>
        <appender-ref ref="FILE_JSON"/>
    </root>

    <!-- ============================================ -->
    <!-- 环境配置(Spring Profile) -->
    <!-- ============================================ -->
    
    <!-- 开发环境:仅输出到控制台 -->
    <springProfile name="dev">
        <root level="DEBUG">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <!-- 测试环境:输出到控制台和文件 -->
    <springProfile name="test">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="ASYNC_FILE_ALL"/>
        </root>
    </springProfile>

    <!-- 生产环境:仅输出到文件 -->
    <springProfile name="prod">
        <root level="INFO">
            <appender-ref ref="ASYNC_FILE_ALL"/>
            <appender-ref ref="FILE_ERROR"/>
            <appender-ref ref="FILE_JSON"/>
        </root>
    </springProfile>

</configuration>

配置说明:

  1. 多文件策略:

    • dsj-clinical-all.log - 所有日志
    • dsj-clinical-error.log - 错误日志(单独文件便于告警)
    • dsj-clinical-business.log - 业务审计日志
    • dsj-clinical-json.log - JSON格式日志(供ELK采集)
  2. 日志归档策略:

    • 按日期滚动(每天一个文件)
    • 单文件大小限制(100MB自动切割)
    • 历史文件压缩(.gz格式节省空间)
    • 总大小限制(防止磁盘爆满)
  3. 异步日志:

    • 使用 AsyncAppender 提升性能
    • 避免日志IO阻塞业务线程
    • 生产环境必备
  4. 环境隔离:

    • 开发环境:控制台 + DEBUG级别
    • 测试环境:控制台 + 文件 + INFO级别
    • 生产环境:仅文件 + INFO级别 + JSON格式

方式 C:使用 Logstash JSON Encoder(高级)⚡

适用于微服务架构,需要将日志发送到ELK、Splunk等日志分析平台。

添加依赖:

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.3</version>
</dependency>

logback-spring.xml配置:

<!-- Logstash JSON Appender -->
<appender name="LOGSTASH" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${LOG_HOME}/${APP_NAME}-logstash.json</file>
    
    <!-- 使用 LogstashEncoder -->
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <!-- 自定义字段 -->
        <customFields>{"app_name":"dsj-clinical","environment":"${spring.profiles.active}"}</customFields>
        
        <!-- 包含 MDC 数据 -->
        <includeMdcKeyName>transactionId</includeMdcKeyName>
        <includeMdcKeyName>requestId</includeMdcKeyName>
        
        <!-- 包含调用堆栈 -->
        <includeCallerData>true</includeCallerData>
    </encoder>
    
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>${LOG_HOME}/${APP_NAME}-logstash-%d{yyyy-MM-dd}.%i.json</fileNamePattern>
        <maxFileSize>100MB</maxFileSize>
        <maxHistory>7</maxHistory>
    </rollingPolicy>
</appender>

输出的JSON格式:

{
  "@timestamp": "2025-11-19T14:32:15.123+08:00",
  "@version": "1",
  "message": "查询患者信息: patientId=12345",
  "logger_name": "com.zhyl.dsj.patient.controller.PatientController",
  "thread_name": "http-nio-8080-exec-1",
  "level": "INFO",
  "level_value": 20000,
  "transactionId": "a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d",
  "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "app_name": "dsj-clinical",
  "environment": "prod"
}

3.5 业务代码中使用 Transaction ID

3.5.1 在 Controller 中使用

package com.zhyl.dsj.patient.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/patient")
public class PatientController {

    private static final Logger logger = LoggerFactory.getLogger(PatientController.class);

    @PostMapping("/info")
    public Result<PatientInfo> getPatientInfo(@RequestBody PatientQuery query) {
        // 直接使用 logger,Transaction ID 会自动包含在日志中
        logger.info("查询患者信息: patientId={}", query.getPatientId());
        
        // 如果需要在业务逻辑中使用 Transaction ID(例如返回给前端)
        String transactionId = MDC.get("transactionId");
        logger.debug("当前事务ID: {}", transactionId);
        
        // 业务逻辑...
        PatientInfo patientInfo = patientService.getPatientInfo(query.getPatientId());
        
        return Result.success(patientInfo)
                     .withTransactionId(transactionId);  // 可选:返回给前端
    }
}

3.5.2 在 Service 中使用

package com.zhyl.dsj.patient.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class PatientService {

    private static final Logger logger = LoggerFactory.getLogger(PatientService.class);

    public PatientInfo getPatientInfo(String patientId) {
        // 自动包含 Transaction ID
        logger.info("从数据库加载患者数据: patientId={}", patientId);
        
        try {
            // 数据库查询...
            PatientInfo info = patientMapper.selectById(patientId);
            
            if (info == null) {
                logger.warn("患者信息不存在: patientId={}", patientId);
                throw new BusinessException("患者信息不存在");
            }
            
            logger.info("患者信息加载成功: patientId={}, patientName={}", 
                       patientId, info.getName());
            return info;
            
        } catch (Exception e) {
            // 异常日志会自动包含 Transaction ID
            logger.error("加载患者信息失败: patientId={}", patientId, e);
            throw new SystemException("系统错误,请联系管理员", e);
        }
    }
}

3.5.3 在异常处理中使用

package com.zhyl.dsj.common.handler;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        String transactionId = MDC.get("transactionId");
        
        logger.error("系统异常,事务ID: {}", transactionId, e);
        
        // 返回用户友好的错误信息,包含 Transaction ID
        return Result.error("系统繁忙,请稍后重试")
                     .withTransactionId(transactionId)
                     .withMessage("如需帮助,请提供事务ID: " + transactionId);
    }
    
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        String transactionId = MDC.get("transactionId");
        
        logger.warn("业务异常,事务ID: {}, 错误信息: {}", transactionId, e.getMessage());
        
        return Result.error(e.getMessage())
                     .withTransactionId(transactionId);
    }
}

3.5.4 统一响应结构(包含 Transaction ID)

package com.zhyl.dsj.common.model;

/**
 * 统一响应结构
 */
public class Result<T> {
    private Integer code;
    private String message;
    private T data;
    private String transactionId;  // 添加 Transaction ID
    private Long timestamp;
    
    // 构造方法和Builder...
    
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMessage("success");
        result.setData(data);
        result.setTimestamp(System.currentTimeMillis());
        return result;
    }
    
    public static <T> Result<T> error(String message) {
        Result<T> result = new Result<>();
        result.setCode(500);
        result.setMessage(message);
        result.setTimestamp(System.currentTimeMillis());
        return result;
    }
    
    public Result<T> withTransactionId(String transactionId) {
        this.transactionId = transactionId;
        return this;
    }
    
    // Getters and Setters...
}

4. 高级建议

4.1 透传给下游服务

在微服务架构中,Transaction ID 需要在服务间透传,以保持完整的调用链追踪。

4.1.1 使用 RestTemplate 透传

package com.zhyl.dsj.common.config;

import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.web.client.RestTemplate;

import java.util.ArrayList;
import java.util.List;

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        
        // 添加拦截器,自动透传 Transaction ID
        List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>();
        interceptors.add((request, body, execution) -> {
            // 从 MDC 获取 Transaction ID
            String transactionId = MDC.get("transactionId");
            String requestId = MDC.get("requestId");
            
            if (transactionId != null) {
                request.getHeaders().add("X-Transaction-Id", transactionId);
            }
            if (requestId != null) {
                request.getHeaders().add("X-Request-Id", requestId);
            }
            
            return execution.execute(request, body);
        });
        
        restTemplate.setInterceptors(interceptors);
        return restTemplate;
    }
}

使用示例:

@Service
public class PatientService {
    
    @Autowired
    private RestTemplate restTemplate;
    
    public EmpiData getEmpiData(String patientId) {
        // Transaction ID 会自动添加到请求头
        String url = "http://empi-service/api/empi/info?patientId=" + patientId;
        return restTemplate.getForObject(url, EmpiData.class);
    }
}

4.1.2 使用 Feign 客户端透传

package com.zhyl.dsj.common.feign;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Feign 请求拦截器配置
 */
@Configuration
public class FeignConfig {

    @Bean
    public RequestInterceptor transactionIdInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                // 从 MDC 获取并透传 Transaction ID
                String transactionId = MDC.get("transactionId");
                String requestId = MDC.get("requestId");
                
                if (transactionId != null) {
                    template.header("X-Transaction-Id", transactionId);
                }
                if (requestId != null) {
                    template.header("X-Request-Id", requestId);
                }
            }
        };
    }
}

Feign 接口定义:

@FeignClient(name = "empi-service", configuration = FeignConfig.class)
public interface EmpiServiceClient {
    
    @GetMapping("/api/empi/info")
    EmpiData getEmpiData(@RequestParam("patientId") String patientId);
}

4.2 与 OpenTelemetry / Spring Cloud Sleuth 集成

4.2.1 Spring Cloud Sleuth 集成(推荐)✅

Spring Cloud Sleuth 是 Spring 生态的分布式追踪解决方案,可以与自定义 Transaction ID 共存。

添加依赖:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>

<!-- 可选:集成 Zipkin -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-sleuth-zipkin</artifactId>
</dependency>

配置:

# application.yml
spring:
  sleuth:
    sampler:
      probability: 1.0  # 采样率(1.0 = 100%)
    baggage:
      correlation-fields:
        - transactionId  # 将自定义 Transaction ID 添加到 Sleuth Baggage
      remote-fields:
        - transactionId  # 在服务间透传
  zipkin:
    base-url: http://localhost:9411  # Zipkin 服务地址
    sender:
      type: web

集成代码:

package com.zhyl.dsj.common.filter;

import brave.Tracer;
import brave.baggage.BaggageField;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;

/**
 * 集成 Sleuth 的 Transaction Filter
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TransactionFilter implements Filter {

    @Autowired(required = false)
    private Tracer tracer;  // Sleuth 的 Tracer

    private static final String HEADER_TRANSACTION_ID = "X-Transaction-Id";
    private static final BaggageField TRANSACTION_ID_FIELD = BaggageField.create("transactionId");

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        
        try {
            // 1. 获取或生成 Transaction ID
            String transactionId = httpRequest.getHeader(HEADER_TRANSACTION_ID);
            if (transactionId == null) {
                transactionId = UUID.randomUUID().toString();
            }
            
            // 2. 存入 MDC
            MDC.put("transactionId", transactionId);
            
            // 3. 存入 Sleuth Baggage(会自动透传到下游服务)
            if (tracer != null) {
                TRANSACTION_ID_FIELD.updateValue(transactionId);
                
                // 可选:获取 Sleuth 的 Trace ID 和 Span ID
                String traceId = tracer.currentSpan().context().traceIdString();
                String spanId = tracer.currentSpan().context().spanIdString();
                
                MDC.put("traceId", traceId);
                MDC.put("spanId", spanId);
            }
            
            chain.doFilter(request, response);
            
        } finally {
            MDC.clear();
        }
    }
}

日志输出(包含 Sleuth 信息):

2025-11-19 14:32:15.123 [http-nio-8080-exec-1] INFO  [dsj-clinical,a7b3d9e2,f47ac10b] [TID:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] c.z.d.patient.controller.PatientController - 查询患者信息

格式说明:[应用名,TraceId,SpanId]


4.2.2 OpenTelemetry 集成(云原生推荐)⚡

OpenTelemetry 是 CNCF 的标准可观测性框架,支持多语言、多后端。

添加依赖:

<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-api</artifactId>
    <version>1.32.0</version>
</dependency>
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-sdk</artifactId>
    <version>1.32.0</version>
</dependency>
<dependency>
    <groupId>io.opentelemetry.instrumentation</groupId>
    <artifactId>opentelemetry-spring-boot-starter</artifactId>
    <version>1.32.0-alpha</version>
</dependency>

配置:

# application.yml
otel:
  traces:
    sampler:
      probability: 1.0
  exporter:
    otlp:
      endpoint: http://localhost:4317  # OTLP Collector 地址
  service:
    name: dsj-clinical
  resource:
    attributes:
      environment: production

集成代码:

import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Context;

@Service
public class PatientService {
    
    @Autowired
    private Tracer tracer;
    
    public PatientInfo getPatientInfo(String patientId) {
        // 创建自定义 Span
        Span span = tracer.spanBuilder("getPatientInfo")
                          .setParent(Context.current())
                          .startSpan();
        
        try {
            // 添加自定义属性
            span.setAttribute("patient.id", patientId);
            span.setAttribute("transaction.id", MDC.get("transactionId"));
            
            // 业务逻辑...
            PatientInfo info = patientMapper.selectById(patientId);
            
            span.addEvent("患者信息加载完成");
            return info;
            
        } catch (Exception e) {
            span.recordException(e);
            throw e;
        } finally {
            span.end();
        }
    }
}

4.3 异步线程中的 MDC 传递

问题: 在使用 @AsyncCompletableFuture、线程池等异步编程时,MDC 不会自动传递到子线程。

4.3.1 使用 TaskDecorator(推荐)✅

package com.zhyl.dsj.common.config;

import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.Map;
import java.util.concurrent.Executor;

/**
 * 异步任务配置
 */
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    @Bean(name = "taskExecutor")
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("async-task-");
        
        // 设置 TaskDecorator,自动传递 MDC
        executor.setTaskDecorator(new MdcTaskDecorator());
        
        executor.initialize();
        return executor;
    }

    /**
     * MDC 传递装饰器
     */
    private static class MdcTaskDecorator implements TaskDecorator {
        @Override
        public Runnable decorate(Runnable runnable) {
            // 保存当前线程的 MDC
            Map<String, String> contextMap = MDC.getCopyOfContextMap();
            
            return () -> {
                try {
                    // 在子线程中恢复 MDC
                    if (contextMap != null) {
                        MDC.setContextMap(contextMap);
                    }
                    runnable.run();
                } finally {
                    // 清理 MDC
                    MDC.clear();
                }
            };
        }
    }
}

使用示例:

@Service
public class PatientService {
    
    private static final Logger logger = LoggerFactory.getLogger(PatientService.class);
    
    @Async
    public void sendNotificationAsync(String patientId) {
        // Transaction ID 会自动传递到异步线程
        logger.info("发送异步通知: patientId={}", patientId);
        
        // 异步业务逻辑...
    }
}

4.3.2 使用 CompletableFuture 传递 MDC

package com.zhyl.dsj.common.util;

import org.slf4j.MDC;

import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.Supplier;

/**
 * 支持 MDC 传递的 CompletableFuture 工具类
 */
public class MdcCompletableFuture {

    /**
     * 创建带 MDC 的异步任务
     */
    public static <T> CompletableFuture<T> supplyAsync(Supplier<T> supplier, Executor executor) {
        // 保存当前线程的 MDC
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        
        return CompletableFuture.supplyAsync(() -> {
            try {
                // 在子线程中恢复 MDC
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                return supplier.get();
            } finally {
                // 清理 MDC
                MDC.clear();
            }
        }, executor);
    }
}

使用示例:

@Service
public class PatientService {
    
    @Autowired
    @Qualifier("taskExecutor")
    private Executor executor;
    
    public void processPatientDataAsync(String patientId) {
        // 使用自定义的 MdcCompletableFuture
        MdcCompletableFuture.supplyAsync(() -> {
            logger.info("异步处理患者数据: patientId={}", patientId);
            // Transaction ID 会自动传递
            return patientMapper.selectById(patientId);
        }, executor)
        .thenAccept(data -> {
            logger.info("数据处理完成: {}", data);
        })
        .exceptionally(ex -> {
            logger.error("数据处理失败", ex);
            return null;
        });
    }
}

4.3.3 使用 TransmittableThreadLocal(终极方案)⚡

阿里巴巴开源的 TTL(TransmittableThreadLocal)可以自动在线程池中传递 ThreadLocal。

添加依赖:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.3</version>
</dependency>

配置:

import com.alibaba.ttl.threadpool.TtlExecutors;
import java.util.concurrent.Executor;

@Configuration
public class ThreadPoolConfig {
    
    @Bean
    public Executor ttlTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setThreadNamePrefix("ttl-task-");
        executor.initialize();
        
        // 使用 TTL 包装,自动传递 ThreadLocal
        return TtlExecutors.getTtlExecutor(executor.getThreadPoolExecutor());
    }
}

优点:

  • ✅ 无需手动传递 MDC
  • ✅ 支持所有 ThreadLocal(不仅限于MDC)
  • ✅ 支持线程池、ForkJoinPool 等多种场景

4.4 性能优化建议

4.4.1 使用异步日志

<!-- logback-spring.xml -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>512</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <neverBlock>true</neverBlock>
    <appender-ref ref="FILE_ALL"/>
</appender>

4.4.2 日志采样(高流量场景)

@Component
public class TransactionFilter implements Filter {
    
    private static final double SAMPLING_RATE = 0.1;  // 10% 采样率
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        
        // 根据采样率决定是否记录详细日志
        boolean shouldSample = Math.random() < SAMPLING_RATE;
        MDC.put("sampled", String.valueOf(shouldSample));
        
        // ...
    }
}

4.4.3 避免过度日志

// ❌ 不推荐:在循环中记录日志
for (Patient patient : patients) {
    logger.debug("处理患者: {}", patient.getId());  // 高频日志
}

// ✅ 推荐:聚合后记录
logger.info("批量处理患者: 共{}个", patients.size());

5. 完整示例:查询患者信息完整链路

5.1 前端代码

// 用户点击"查询患者信息"按钮
import axios, { startTransaction, endTransaction } from '@/plugins/axios';

async function loadPatientData(patientId) {
  const tid = startTransaction('LOAD_PATIENT_DATA');
  console.log(`[前端] 事务开始: ${tid}`);
  
  try {
    const response = await axios.post('/api/patient/info', { patientId });
    console.log(`[前端] 事务成功: ${tid}`);
    return response;
  } catch (error) {
    console.error(`[前端] 事务失败: ${tid}`, error);
    throw error;
  } finally {
    endTransaction();
  }
}

5.2 后端代码

// Controller
@RestController
@RequestMapping("/api/patient")
public class PatientController {
    
    @PostMapping("/info")
    public Result<PatientInfo> getPatientInfo(@RequestBody PatientQuery query) {
        logger.info("查询患者信息: patientId={}", query.getPatientId());
        // Transaction ID 自动包含在日志中
        
        PatientInfo info = patientService.getPatientInfo(query.getPatientId());
        return Result.success(info).withTransactionId(MDC.get("transactionId"));
    }
}

// Service
@Service
public class PatientService {
    
    public PatientInfo getPatientInfo(String patientId) {
        logger.info("从数据库加载患者数据: patientId={}", patientId);
        // Transaction ID 自动包含在日志中
        
        // 调用下游服务(Transaction ID 自动透传)
        EmpiData empiData = empiServiceClient.getEmpiData(patientId);
        
        return buildPatientInfo(patientId, empiData);
    }
}

5.3 日志输出示例

# 前端日志(浏览器控制台)
[Transaction Started] a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d - LOAD_PATIENT_DATA
[API Success] POST /api/patient/info transactionId=a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d requestId=f47ac10b-58cc-4372-a567-0e02b2c3d479 duration=666ms
[Transaction Ended] a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d

# 后端日志(dsj-clinical-all.log)
2025-11-19 14:32:15.123 [http-nio-8080-exec-1] INFO  [TID:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RID:f47ac10b-58cc-4372-a567-0e02b2c3d479] c.z.d.common.filter.TransactionFilter - Request started: POST /api/patient/info from 192.168.1.100
2025-11-19 14:32:15.145 [http-nio-8080-exec-1] INFO  [TID:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RID:f47ac10b-58cc-4372-a567-0e02b2c3d479] c.z.d.patient.controller.PatientController - 查询患者信息: patientId=12345
2025-11-19 14:32:15.167 [http-nio-8080-exec-1] INFO  [TID:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RID:f47ac10b-58cc-4372-a567-0e02b2c3d479] c.z.d.patient.service.PatientService - 从数据库加载患者数据: patientId=12345
2025-11-19 14:32:15.789 [http-nio-8080-exec-1] INFO  [TID:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RID:f47ac10b-58cc-4372-a567-0e02b2c3d479] c.z.d.common.filter.TransactionFilter - Request completed: POST /api/patient/info - Status: 200 - Duration: 666ms

# EMPI 服务日志(下游服务)
2025-11-19 14:32:15.200 [http-nio-8081-exec-5] INFO  [TID:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d] [RID:b8c4e21d-69dd-5483-c678-1f3g4b5c6d7e] c.z.empi.service.EmpiService - 查询EMPI数据: patientId=12345

关键点: 通过 TID:a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d 可以快速检索到整个调用链的所有日志。


6. 故障排查实战案例

场景:用户反馈"查询患者信息失败"

步骤 1:获取 Transaction ID

  • 用户提供截图,显示错误提示:"操作失败,事务ID: a7b3d9e2..."

步骤 2:检索日志

# 在日志文件中搜索
grep "a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d" dsj-clinical-all.log

# 或使用 ELK
GET /logs/_search
{
  "query": {
    "match": { "transactionId": "a7b3d9e2-4f1c-4a5b-8c9d-1e2f3a4b5c6d" }
  }
}

步骤 3:分析日志

14:32:15.123 - Request started
14:32:15.145 - 查询患者信息
14:32:15.167 - 从数据库加载患者数据
14:32:15.200 - [EMPI服务] 查询EMPI数据
14:32:18.400 - [EMPI服务] ERROR: 数据库连接超时  ❌ 问题定位!
14:32:18.450 - Request failed: 系统错误

结论: EMPI 服务的数据库连接超时,导致整个请求失败。定位时间:< 5分钟 ✅


7. 总结与最佳实践

7.1 核心要点

  1. 前端:使用 uuid 库生成 Transaction ID,通过 HTTP Header 传递
  2. 后端:使用 Filter + MDC 实现 Transaction ID 的接收和传递
  3. 日志:使用 Logback 配置,在日志格式中包含 Transaction ID
  4. 透传:使用 RestTemplate/Feign 拦截器自动透传到下游服务
  5. 异步:使用 TaskDecorator 或 TTL 在异步线程中传递 MDC

7.2 命名规范

  • HTTP Header: X-Transaction-IdX-Trace-Id
  • MDC Key: transactionId
  • 变量名: transactionIdtraceId

7.3 注意事项

  1. ⚠️ 必须在 Filter 的 finally 块中清理 MDC,避免内存泄漏
  2. ⚠️ 异步场景必须显式传递 MDC,否则子线程获取不到
  3. ⚠️ 高流量场景考虑采样,避免日志爆炸
  4. ⚠️ 敏感信息不要记录到日志,即使有 Transaction ID

7.4 进阶扩展

  • 🚀 集成 Zipkin/Jaeger 实现分布式追踪可视化
  • 🚀 集成 ELK/Splunk 实现日志智能分析和告警
  • 🚀 集成 Prometheus + Grafana 实现性能监控
  • 🚀 使用 OpenTelemetry 实现云原生可观测性

附录

相关资源