DevOps平台 — 第五篇:操作日志与调用链追踪

审计日志不是「记录一下谁做了什么」这么简单。当管理员修改了一个角色的权限,你需要知道:改了哪些字段、从什么值改成了什么值、这次请求执行了哪些 SQL、每一步耗时多少、有没有异常。本篇记录 DevOps 平台的操作日志模块设计------基于 mzt-biz-log 框架扩展,实现异步 Diff 计算、树形调用链追踪、双阶段耗时分离和字典标签翻译。

一、整体架构

操作日志模块(dev-ops-modules-log)不是一个被动记录器,而是一个贯穿请求全生命周期的主动采集系统。它由四个维度构成:

scss 复制代码
操作日志模块架构

  采集层(请求线程)
    ├─ ApiLogInterceptor              HTTP 拦截器:初始化 TraceId / 请求参数 / 用户信息
    ├─ ControllerCallChainAspect      Controller 切面:挂载 Controller 节点到调用链
    ├─ OperLogSqlInterceptor          MyBatis 拦截器:挂载 Mapper + SQL 节点
    └─ AuditLogContextAspect          审计切面:捕获返回值 / 交接 Diff 对象 / 开启审计计时

  组装层(请求线程,mzt 框架回调)
    └─ BizLogRecordServiceImpl.record()
         ├─ buildSnapshot()           从 LogRecord + OperLogContext 组装快照
         ├─ withCallChainAndAuditDuration()  定稿调用链 JSON + 审计耗时
         └─ operLogAsyncWriter.submit(snapshot)  投递到异步线程池

  异步层(独立线程池)
    └─ OperLogAsyncWriter.write()
         ├─ OperLogDiffCalculator.diffJson()  计算 Diff + 字典标签翻译
         └─ SysOperLogMapper.insert()         落库

  支撑层
    ├─ OperLogContext        ThreadLocal 上下文(请求参数 / 返回值 / 异常 / 耗时 / Diff 对象)
    ├─ CallChainContext      ThreadLocal 调用栈(HTTP → Controller → Service → Mapper → SQL)
    └─ OperLogSnapshot       不可变快照对象(跨线程传递,禁止携带 ThreadLocal)

核心设计原则: 请求线程只做「采集 + 组装 + 投递」,不做任何耗时操作(Diff 计算、JSON 序列化、数据库 INSERT 都在异步线程池中完成)。这保证了审计日志不会拖慢业务响应。

二、日志类型体系

2.1 Type 与 SubType 双维度分类

每条操作日志由 type(模块)+ subType(操作类型)两个维度定位:

Type --- 日志类型(对应业务模块)

常量 说明
TYPE_AUTH auth 登录认证
TYPE_USER user 用户管理
TYPE_ROLE role 角色管理
TYPE_DEPT dept 部门管理
TYPE_DICT_TYPE dict_type 字典类型
TYPE_DICT_ITEM dict_item 字典项
TYPE_PROJECT project 项目管理
TYPE_PASSWORD_POLICY password_policy 密码策略
TYPE_POSITION position 职位管理

SubType --- 操作类型(全局固定 8 类)

常量 说明
SUB_CREATE create 新增
SUB_UPDATE update 修改
SUB_DELETE delete 删除
SUB_LOGIN login 登录
SUB_LOGOUT logout 退出
SUB_STATUS status 状态变更(启用/禁用等)
SUB_GRANT grant 授权(权限分配等)
SUB_SECURITY security 安全(改密、切换企业等)

2.2 注解驱动声明

日志通过 @LogRecord 注解声明在 Controller 方法上,使用 SpEL 表达式动态生成操作描述:

  • success --- 操作成功时的描述文案,支持 SpEL(如 新增角色「{{#role.roleName}}」
  • fail --- 操作失败时的描述文案
  • type --- 日志类型(引用 OperLogType 常量)
  • subType --- 操作类型(引用 OperLogType 常量)
  • bizNo --- 业务编号(通常为实体 ID,支持 SpEL)
  • operator --- 操作人(不指定时从 Sa-Token 获取)

功能截图:

三、操作日志实体

3.1 SysOperLog 字段设计

操作日志实体 SysOperLog 包含以下字段:

字段 类型 说明
type String 日志类型(模块)
subType String 操作类型
bizNo String 业务编号
operator String 操作人
action String 操作描述(SpEL 解析后)
fail Boolean 是否失败
extra String 额外信息
traceId String 请求链路 ID
callChain String 调用链 JSON
requestParams String 请求参数
returnValue String 返回值
errorStack String 异常堆栈
duration Long 业务耗时(毫秒)
auditDuration Long 审计耗时(毫秒)
diffJson String 字段变更 Diff JSON
operTime LocalDateTime 操作时间

3.2 双阶段耗时:duration vs auditDuration

操作日志记录两个独立的耗时指标:

  • duration(业务耗时):从 HTTP 请求进入到 Controller 方法返回的时间。这是用户感知到的响应时间。
  • auditDuration(审计耗时):从业务返回后开始,到快照组装 + 调用链定稿 + 异步投递完成的时间。不含异步 Diff 和 INSERT。
scss 复制代码
时间线
  ├─ HTTP 请求到达
  │    ├─ ApiLogInterceptor.preHandle() → startTime 开始
  │    ├─ Controller 方法执行(业务逻辑 + SQL)
  │    └─ Controller 方法返回 → duration 结束
  │
  ├─ AuditLogContextAspect 捕获返回值 → 审计阶段开始
  │    ├─ BizLogRecordServiceImpl.record()
  │    │    ├─ buildSnapshot() → 组装快照
  │    │    ├─ withCallChainAndAuditDuration() → 定稿调用链
  │    │    └─ operLogAsyncWriter.submit() → 投递
  │    └─ auditDuration 结束
  │
  └─ 异步线程池
       ├─ OperLogDiffCalculator.diffJson() → 计算 Diff
       └─ SysOperLogMapper.insert() → 落库
            (不计入 duration 和 auditDuration)

为什么要分离? 如果审计日志导致接口变慢,需要区分是业务本身慢(duration 大)还是审计组装慢(auditDuration 大)。异步 Diff 和 INSERT 不计入任何指标------它们在独立线程池中执行,对请求线程零影响。

功能截图:

四、异步 Diff 落库

4.1 为什么不能在请求线程中做 Diff

Diff 计算需要遍历实体所有标注 @DiffLogField 的字段,逐一比对旧值和新值,还要查询字典翻译码值为中文标签。如果实体有 20 个字段,涉及 3 个字典翻译,这个操作可能耗时数毫秒甚至十几毫秒。再加上 JSON 序列化和数据库 INSERT,总耗时可能占整个请求的 10%~30%。

因此平台将 Diff + INSERT 完全异步化------请求线程只组装一个不可变快照(OperLogSnapshot),投递到独立线程池后立即返回。

4.2 快照设计

OperLogSnapshot 是跨线程传递的不可变对象,使用 @Builder + final 字段保证线程安全:

  • 包含日志全部字段(type、subType、action、traceId 等)
  • 包含 Diff 对象(oldObj、newObj),但在组装时已通过 OperLogDiffCalculator.copyOf() 深拷贝,脱离请求线程的引用
  • 不携带任何 ThreadLocal------异步线程无法访问请求线程的 ThreadLocal

4.3 异步落库流程

scss 复制代码
异步落库流程
  ├─ 请求线程
  │    ├─ BizLogRecordServiceImpl.record(logRecord)
  │    │    ├─ buildSnapshot(logRecord)
  │    │    │    ├─ 从 OperLogContext 读取请求参数 / 返回值 / 异常 / 耗时
  │    │    │    ├─ 从 LogRecordContext 读取 oldObj / newObj
  │    │    │    ├─ OperLogDiffCalculator.copyOf() → 深拷贝 Diff 对象
  │    │    │    └─ 组装 OperLogSnapshot
  │    │    ├─ withCallChainAndAuditDuration()
  │    │    │    ├─ CallChainContext.toJson() → 定稿调用链 JSON
  │    │    │    └─ 补充 auditDuration
  │    │    └─ operLogAsyncWriter.submit(snapshot) → 投递到线程池
  │    └─ 请求线程返回(业务响应不受影响)
  │
  └─ 异步线程(bizLogExecutor)
       ├─ OperLogAsyncWriter.write(snapshot)
       │    ├─ toEntity(snapshot) → 转换为 SysOperLog
       │    ├─ OperLogDiffCalculator.diffJson(oldObj, newObj, dictLabelProvider)
       │    │    ├─ 遍历 @DiffLogField 字段
       │    │    ├─ 逐一比对旧值与新值
       │    │    ├值不等 → 记录 {field, before, after}
       │    │    ├─ 字段标注 @Dict(diff=true) → 查询字典翻译码值为标签
       │    │    └─ 序列化为 JSON 数组
       │    └─ SysOperLogMapper.insert(operLog) → 落库
       │
       └─ 异常处理
            ├─ 线程池提交失败 → 降级同步写入(不丢日志)
            └─ Diff/INSERT 异常 → log.error 记录(不影响业务)

4.4 核心实现伪代码

以下是异步落库的关键代码路径,用伪代码展示核心逻辑:

java 复制代码
// ━━━━ 请求线程:BizLogRecordServiceImpl ━━━━

void record(LogRecord logRecord) {
    try {
        // 1. 组装快照(从 ThreadLocal + LogRecordContext 采集所有数据)
        OperLogSnapshot snapshot = buildSnapshot(logRecord);

        // 2. 定稿调用链 JSON + 计算审计耗时
        CallChainContext.exitAudit();
        OperLogContext.markAuditEnd();
        snapshot = withCallChainAndAuditDuration(snapshot);

        // 3. 投递到异步线程池(不阻塞请求线程)
        operLogAsyncWriter.submit(snapshot);

    } finally {
        OperLogContext.clear();  // 清理 ThreadLocal,防止线程复用串数据
    }
}

OperLogSnapshot buildSnapshot(LogRecord logRecord) {
    // 从 LogRecordContext 读取 Controller 传入的 Diff 对象
    Object oldObj = LogRecordContext.getVariable("oldObj");
    Object newObj = LogRecordContext.getVariable("newObj");

    return OperLogSnapshot.builder()
        .type(logRecord.getType())
        .subType(logRecord.getSubType())
        .action(logRecord.getAction())
        .traceId(OperLogContext.getTraceId())
        .requestParams(OperLogContext.getRequestParams())
        .returnValue(OperLogContext.getReturnValue())
        .errorStack(logRecord.isFail() ? OperLogContext.getErrorStack() : null)
        .duration(OperLogContext.getDuration())
        // ★ 深拷贝:脱离请求线程引用,避免异步线程触发 Session 已关闭异常
        .oldObj(OperLogDiffCalculator.copyOf(oldObj))
        .newObj(OperLogDiffCalculator.copyOf(newObj))
        .operTime(LocalDateTime.now())
        .build();
}

// ━━━━ 异步线程:OperLogAsyncWriter ━━━━

void submit(OperLogSnapshot snapshot) {
    try {
        bizLogExecutor.execute(() -> write(snapshot));
    } catch (Exception e) {
        // ★ 降级:线程池满时改为同步写入,不丢日志
        log.warn("异步提交失败,降级同步写入: {}", e.getMessage());
        write(snapshot);
    }
}

void write(OperLogSnapshot snapshot) {
    try {
        SysOperLog operLog = toEntity(snapshot);
        // ★ Diff 计算 + 字典翻译(耗时操作,在异步线程中执行)
        operLog.setDiffJson(
            OperLogDiffCalculator.diffJson(
                snapshot.getOldObj(),
                snapshot.getNewObj(),
                dictLabelProvider  // 可选,未注入时存码值
            )
        );
        sysOperLogMapper.insert(operLog);
    } catch (Exception e) {
        log.error("日志落库失败: type={}, bizNo={}", snapshot.getType(), snapshot.getBizNo(), e);
        // 不抛异常,不影响业务
    }
}

异步提交可能失败(线程池满、拒绝策略触发)。OperLogAsyncWriter.submit() 的降级策略是:提交失败时改为同步写入。虽然会拖慢当前请求,但保证了日志不丢失。这是一种权衡------日志丢失是不可逆的,请求慢一点是可接受的。

4.6 Diff 计算与字典翻译

OperLogDiffCalculator 通过反射遍历实体字段,只处理标注了 @DiffLogField 的字段:

  • name 属性指定字段的中文名(如 @DiffLogField(name = "角色名称")
  • 比对旧值和新值,值不相等时记录变更
  • 如果字段同时标注了 @Dict(diff = true),在落库前将字典码值翻译为中文标签

Diff 结果是 JSON 数组格式:

json 复制代码
[
  {"field": "角色名称", "before": "管理员", "after": "超级管理员"},
  {"field": "状态", "before": "禁用", "after": "启用"},
  {"field": "备注", "before": null, "after": "系统最高权限角色"}
]

字典翻译示例: 假设角色实体的 status 字段值为 0(禁用)和 1(启用),如果不做翻译,Diff 中显示的是 before: 0, after: 1------管理员看不懂。通过 @Dict(diff = true) 标注后,Diff 中显示 before: 禁用, after: 启用,一目了然。

4.5 Diff 计算核心实现伪代码

以下是 Diff 计算 + 字典翻译的核心逻辑:

java 复制代码
// ━━━━ OperLogDiffCalculator(异步线程中调用) ━━━━

static String diffJson(Object oldObj, Object newObj, DictLabelProvider labelProvider) {
    List<Map<String, String>> diffs = new ArrayList<>();
    if (oldObj == null || newObj == null) return null;

    // 遍历实体所有字段(含父类 BaseEntity)
    for (Field field : collectFields(oldObj.getClass())) {
        DiffLogField anno = field.getAnnotation(DiffLogField.class);
        if (anno == null) continue;  // 只处理标注了 @DiffLogField 的字段

        field.setAccessible(true);
        Object beforeVal = field.get(oldObj);
        Object afterVal  = readField(newObj, field.getName());

        if (Objects.equals(beforeVal, afterVal)) continue;  // 值未变,跳过

        // 检查是否需要字典翻译
        String dictCode = resolveDiffDictCode(field);  // @Dict(diff=true) 时返回字典编码
        String before = toDisplay(beforeVal, dictCode, labelProvider);
        String after  = toDisplay(afterVal,  dictCode, labelProvider);

        Map<String, String> item = new LinkedHashMap<>(3);
        item.put("field",  anno.name());     // 中文名,如 "角色名称"
        item.put("before", before);
        item.put("after",  after);
        diffs.add(item);
    }

    return diffs.isEmpty() ? null : toJson(diffs);
}

// 字典翻译:码值 → 中文标签
static String toDisplay(Object value, String dictCode, DictLabelProvider labelProvider) {
    if (value == null) return null;
    if (dictCode != null && labelProvider != null) {
        // 例如 status=0 → "禁用",status=1 → "启用"
        String label = labelProvider.getLabel(dictCode, value.toString());
        if (label != null) return label;
    }
    // 无字典翻译时直接返回字面值
    return formatValue(value);  // 日期格式化、布尔转中文等
}

// 深拷贝:Jackson 序列化 → 反序列化,脱离请求线程引用
static <T> T copyOf(T source) {
    if (source == null) return null;
    return (T) objectMapper.convertValue(source, source.getClass());
}

4.7 深拷贝:脱离请求线程引用

OperLogDiffCalculator.copyOf() 使用 Jackson 的 convertValue 做深拷贝:

scss 复制代码
深拷贝的必要性
  ├─ 请求线程
  │    ├─ Controller 查询 oldObj(MyBatis 实体,可能关联 Session)
  │    ├─ 执行更新操作
  │    ├─ 查询 newObj
  │    └─ 传入 LogRecordContext
  │
  ├─ 组装快照时
  │    └─ copyOf(oldObj) / copyOf(newObj)
  │         → Jackson 序列化 → 反序列化为新对象
  │         → 脱离 MyBatis Session / 请求线程引用
  │
  └─ 异步线程
       └─ 安全地读取 oldObj / newObj 字段值
            → 不会触发懒加载异常 / Session 已关闭问题

如果不做深拷贝,异步线程访问实体字段时可能触发 LazyInitializationException(MyBatis Session 已关闭),或者读到被请求线程修改后的脏数据。

五、调用链追踪

5.1 为什么需要调用链

操作日志只记录「谁做了什么」是不够的。当接口慢的时候,需要知道时间花在哪里------是 Controller 逻辑慢、某个 SQL 慢、还是调用了外部服务。调用链把请求的完整执行路径以树形结构记录下来,每个节点带耗时。

5.2 树形调用链结构

调用链基于 CallChainContext 的 ThreadLocal 维护一个 Deque<CallNode> 调用栈,节点类型有七种:

sql 复制代码
调用链节点类型
  ├─ HTTP       根节点(PUT /system/role)
  ├─ CONTROLLER  Controller 方法(SysRoleController.edit)
  ├─ SERVICE     Service 方法(可选,当前未挂载)
  ├─ MAPPER      Mapper 方法(SysRoleMapper.updateById)
  ├─ SQL         完整 SQL 语句
  ├─ CLIENT      出站 HTTP 调用(RestTemplate / WebClient / OkHttp)
  └─ AUDIT       审计阶段(与 HTTP 同级,不计入业务耗时)

一个典型请求的调用链结构:

scss 复制代码
调用链示例
  [HTTP] PUT /system/role (125ms)
    └─ [CONTROLLER] SysRoleController.edit (120ms)
         ├─ [MAPPER] SysRoleMapper.selectById (3ms)
         │    └─ [SQL] SELECT * FROM sys_role WHERE id = 1 (3ms)
         ├─ [MAPPER] SysRoleMapper.updateById (5ms)
         │    └─ [SQL] UPDATE sys_role SET ... WHERE id = 1 (5ms)
         └─ [MAPPER] SysRoleMapper.selectById (2ms)
              └─ [SQL] SELECT * FROM sys_role WHERE id = 1 (2ms)
  [AUDIT] LogRecord/ASYNC (8ms)

AUDIT 节点与 HTTP 同级: 审计阶段(快照组装 + 调用链定稿 + 异步投递)的耗时节点挂在 HTTP 根节点旁边,而不是作为 HTTP 的子节点。这保证了 duration(HTTP 耗时)不被审计耗时污染。

功能截图:

5.3 节点挂载机制

调用链的节点挂载由三个组件协同完成:

scss 复制代码
节点挂载流程
  ├─ ApiLogInterceptor.preHandle()
  │    ├─ CallChainContext.init() → 初始化 ThreadLocal
  │    ├─ 生成 TraceId(UUID,存入 MDC)
  │    └─ CallChainContext.enterHttp(method, uri) → 创建 HTTP 根节点
  │
  ├─ ControllerCallChainAspect(AOP 切面)
  │    ├─ 拦截 com.dev.ops..controller.. 的 public 方法
  │    ├─ CallChainContext.enterController(className, methodName)
  │    │    → 挂为当前栈顶的子节点
  │    └─ finally → CallChainContext.exitMethod() → 弹出并记录 endTime
  │
  ├─ OperLogSqlInterceptor(MyBatis Interceptor)
  │    ├─ 拦截 Executor.update / query
  │    ├─ 解析 MappedStatement id → 提取 Mapper 类名和方法名
  │    ├─ CallChainContext.enterMapper(className, methodName) → 挂 MAPPER 节点
  │    ├─ CallChainContext.enterSql(sql) → 挂 SQL 子节点
  │    ├─ 执行 SQL
  │    └─ finally → 依次弹出 SQL / MAPPER 节点
  │
  └─ AuditLogContextAspect(AOP 切面,最低优先级)
       ├─ Controller 方法返回后触发
       ├─ CallChainContext.finishHttp() → 收口 HTTP 根节点耗时
       └─ CallChainContext.enterAudit("LogRecord/ASYNC") → 创建审计节点

SQL 拦截器的排除机制: OperLogSqlInterceptor 会检测 SQL 是否包含 sys_oper_log(操作日志表自身),如果是则跳过------否则日志落库的 INSERT 也会被挂入调用链,形成无限递归。

5.4 核心实现伪代码

以下是调用链节点挂载的核心逻辑:

java 复制代码
// ━━━━ CallChainContext(ThreadLocal 调用栈) ━━━━

// 根节点:HTTP 请求进入时创建
void enterHttp(String method, String uri) {
    CallNode root = new CallNode("HTTP", method + " " + uri, now());
    ROOT.set(root);
    STACK.get().clear();
    STACK.get().push(root);
}

// 子节点:挂为当前栈顶的子节点
void enterController(String className, String methodName) {
    CallNode node = new CallNode("CONTROLLER", className + "." + methodName, now());
    pushChild(node);  // stack.peek().children.add(node); stack.push(node);
}

void enterMapper(String className, String methodName) {
    CallNode node = new CallNode("MAPPER", className + "." + methodName, now());
    pushChild(node);
}

void enterSql(String sql) {
    CallNode node = new CallNode("SQL", sql, now());
    pushChild(node);
}

// 弹出当前节点并记录 endTime
void exitMethod() {
    Deque<CallNode> stack = STACK.get();
    if (stack.isEmpty()) return;
    // HTTP 根节点不弹出(贯穿整个业务阶段)
    if (stack.peek().type == "HTTP" && stack.size() == 1) return;
    stack.poll().setEndTime(now());
}

// 审计节点:与 HTTP 同级,不作为子节点
void enterAudit(String name) {
    finishHttp();  // 先收口 HTTP 根节点耗时
    CallNode audit = new CallNode("AUDIT", name, now());
    AUDIT.set(audit);  // 独立存放,不挂入调用栈
}

// 序列化为 JSON(落库时调用)
String toJson() {
    List<Map> list = new ArrayList<>();
    if (ROOT.get() != null)  list.add(ROOT.get().toMap());
    if (AUDIT.get() != null) list.add(AUDIT.get().toMap());
    return objectMapper.writeValueAsString(list);
}

// ━━━━ OperLogSqlInterceptor(MyBatis 拦截器) ━━━━

Object intercept(Invocation invocation) {
    if (!CallChainContext.hasChain()) return invocation.proceed();

    BoundSql boundSql = ms.getBoundSql(parameter);
    String rawSql = boundSql.getSql();

    // ★ 排除自身表,防止无限递归
    if (rawSql.toLowerCase().contains("sys_oper_log")) {
        return invocation.proceed();
    }

    String sql = formatSql(configuration, boundSql);  // 参数代入 SQL
    String[] mapperRef = parseMapperRef(ms.getId());   // com.x.SysRoleMapper.updateById

    if (mapperRef != null) {
        CallChainContext.enterMapper(mapperRef[0], mapperRef[1]);
    }
    CallChainContext.enterSql(sql);

    try {
        return invocation.proceed();
    } finally {
        CallChainContext.exitMethod();  // 弹出 SQL
        if (mapperRef != null) {
            CallChainContext.exitMethod();  // 弹出 MAPPER
        }
    }
}

// ━━━━ AuditLogContextAspect(AOP 切面,最低优先级) ━━━━

@Around("@annotation(logRecord)")
Object captureReturnValue(ProceedingJoinPoint joinPoint, LogRecord logRecord) {
    Object result = joinPoint.proceed();  // 先执行业务方法

    // 1. 捕获返回值
    OperLogContext.setReturnValue(toJson(result));

    // 2. 收口业务 HTTP 耗时
    CallChainContext.finishHttp();
    OperLogContext.markBusinessEnd();

    // 3. 交接 Diff 对象(从 mzt 的 LogRecordContext 转入自己的 OperLogContext)
    Object oldObj = LogRecordContext.getVariable("oldObj");
    Object newObj = LogRecordContext.getVariable("newObj");
    OperLogContext.setDiffObjects(oldObj, newObj);

    // 4. 开启审计阶段计时
    CallChainContext.enterAudit("LogRecord/ASYNC");
    OperLogContext.markAuditStart();

    return result;  // 返回业务结果
}

5.5 调用链序列化

调用链最终序列化为 JSON 数组存储在 call_chain 字段中。JSON 数组包含两个顶级元素:[HTTP 根节点, AUDIT 节点],每个节点递归包含 children

json 复制代码
   [
     {
       "type": "HTTP",
       "name": "PUT /system/role",
       "startTime": 1724476800000,
       "endTime": 1724476800125,
       "children": [
         {
           "type": "CONTROLLER",
           "name": "SysRoleController.edit",
           "startTime": ...,
           "endTime": ...,
           "children": [
             { "type": "MAPPER", "name": "SysRoleMapper.selectById", ... ,
               "children": [ { "type": "SQL", "name": "SELECT ...", ... } ]
             },
             ...
           ]
         }
       ]
     },
     { "type": "AUDIT", "name": "LogRecord/ASYNC", ... }
   ]

前端可以基于这个 JSON 渲染为可折叠的树形视图,展开/收起每个节点查看详情。

六、ThreadLocal 上下文管理

6.1 OperLogContext

OperLogContext 基于 ThreadLocal 在请求生命周期内暂存审计日志所需的上下文信息:

ThreadLocal 说明 写入时机 读取时机
TRACE_ID 请求链路 ID ApiLogInterceptor.preHandle buildSnapshot
REQUEST_PARAMS 请求参数 ApiLogInterceptor.preHandle buildSnapshot
RETURN_VALUE 返回值 AuditLogContextAspect buildSnapshot
ERROR_STACK 异常堆栈 ApiLogInterceptor.afterCompletion buildSnapshot
START_TIME 业务开始时间 ApiLogInterceptor.preHandle calcAndSetDuration
DURATION 业务耗时 markBusinessEnd buildSnapshot
AUDIT_START_TIME 审计开始时间 markAuditStart markAuditEnd
AUDIT_DURATION 审计耗时 markAuditEnd withCallChain
DIFF_OLD 更新前实体 setDiffObjects buildSnapshot
DIFF_NEW 更新后实体 setDiffObjects buildSnapshot

6.2 生命周期与清理

ThreadLocal 如果不清理会导致线程池中的线程复用时数据串读。清理在两个地方保证:

scss 复制代码
ThreadLocal 清理流程
  ├─ BizLogRecordServiceImpl.record()
  │    └─ finally → OperLogContext.clear()
  │         (正常流程:record 执行后清理)
  │
  └─ ApiLogInterceptor.afterCompletion()
       ├─ CallChainContext.clear()
       ├─ LogUserContext.clear()
       └─ OperLogContext.clear()
            (兜底:即使 record() 未触发或异常,afterCompletion 也会清理)

幂等清理: clear() 方法内部是 ThreadLocal.remove(),多次调用安全。这保证了即使 record()finally 已清理,afterCompletion 再次清理也不会出问题。

6.3 TraceId 与 MDC 联动

OperLogContext.setTraceId() 同时写入 ThreadLocal 和 SLF4J MDC:

  • ThreadLocal 中的 TraceId 供 buildSnapshot() 读取,写入日志实体的 trace_id 字段
  • MDC 中的 TraceId 供日志框架使用,所有 log.info() / log.error() 自动带上 TraceId

这使得操作日志表中的 trace_id 与应用日志文件中的 TraceId 一致------通过一个 TraceId 可以同时在操作日志表和应用日志中定位同一次请求的完整链路。

七、Controller 使用约定

7.1 Diff 对象交接

修改类操作需要在 Controller 中提供更新前后的实体快照,通过 LogRecordContext.putVariable() 传入:

less 复制代码
Controller Diff 交接流程
  ├─ Controller.edit(entity)
  │    ├─ LogRecordContext.putVariable("oldObj", service.getById(entity.getId()))
  │    │    → 更新前查询完整实体(含所有字段,供 Diff 比对)
  │    ├─ service.update(entity)
  │    │    → 执行更新
  │    └─ LogRecordContext.putVariable("newObj", service.getById(entity.getId()))
  │         → 更新后查询完整实体(而非用请求传入的 entity,避免未传字段假变更)
  │
  └─ AuditLogContextAspect 捕获
       └─ 从 LogRecordContext 读取 oldObj / newObj → 转入 OperLogContext
            → 供 buildSnapshot() 组装到快照

为什么不直接用请求传入的 entity 作为 newObj? 用户可能只修改了角色名称,请求体中只有 idroleName,其他字段为 null。如果用请求体做 Diff,会把所有未传字段都标记为 null → 原值,产生大量假变更。更新后重新查询完整实体,确保 Diff 只记录真正的变更。

八、总结与思考

做对了什么

  1. 异步 Diff + INSERT,请求线程零阻塞。Diff 计算、字典翻译、JSON 序列化、数据库 INSERT 全部在独立线程池中完成。请求线程只做快照组装和投递,业务响应不受审计日志影响。

  2. 深拷贝脱离请求线程引用copyOf() 使用 Jackson 序列化做深拷贝,避免了异步线程访问实体时触发 MyBatis Session 已关闭异常或读到脏数据。

  3. 双阶段耗时分离duration(业务耗时)和 auditDuration(审计耗时)分开记录,定位性能问题时能区分是业务慢还是审计慢。

  4. 调用链树形结构。HTTP → Controller → Mapper → SQL 的完整调用路径,每个节点带耗时。AUDIT 节点与 HTTP 同级,不污染业务耗时。

  5. SQL 拦截器排除自身表sys_oper_log 的 INSERT 不挂入调用链,避免无限递归。

  6. 字典标签翻译。Diff 中的码值自动翻译为中文标签,管理员不需要查字典就能看懂变更内容。

  7. 降级策略。异步提交失败时降级为同步写入,保证日志不丢失。

  8. ThreadLocal 幂等清理record()finallyafterCompletion 双重清理,保证线程池复用时数据不串读。

可以改进的地方

  1. Service 层调用链未挂载。当前调用链只覆盖 HTTP → Controller → Mapper → SQL,Service 层的方法调用未挂入。如果一个 Controller 方法调用了多个 Service 方法,无法区分各 Service 的耗时。后续可以通过 AOP 切面挂载 Service 节点。

  2. 调用链 JSON 体积可能偏大 。复杂请求的调用链可能包含几十个 SQL 节点,JSON 序列化后可能达到数 KB。存储在 call_chain 字段中会占用数据库空间。可以考虑对超长调用链做截断或压缩。

  3. Diff 不支持嵌套对象 。当前 OperLogDiffCalculator 只比对实体直接字段,不支持嵌套对象或集合的 Diff。如果实体包含 List 或嵌套对象,Diff 结果不够精细。

  4. 线程池监控缺失bizLogExecutor 线程池没有暴露监控指标(队列长度、活跃线程数、拒绝次数)。如果日志量突增,线程池可能积压,但没有告警机制。

  5. 查询接口未实现BizLogRecordServiceImpl.queryLog()queryLogByBizNo() 返回空列表。mzt-biz-log 框架的日志查询功能未接入,当前只能通过数据库直接查询。


2026 年 8 月 · 第五篇

相关推荐
Lyy2 小时前
DevOps平台 — 第四篇:密码策略与安全防护
devops
Lyy5 小时前
DevOps平台 — 第二篇:登录认证与用户角色管理
devops
终末圆6 小时前
GitHub Actions 实现 CI/CD
运维·ci/cd·github·运维开发·devops
运维开发那些事3 天前
GitOps最佳实践 (gitlab ci + ArgoCD)
ci/cd·devops
可乐ea3 天前
Anthropic 的 CI/CD 值班智能体:Claude Tag 当一线响应者的架构拆解与踩坑复盘
ci/cd·架构·claude·devops·ai智能体·mcp
Zadig4 天前
Zadig 全面支持 CRD,至此所有 K8s 资源类型均可一键发布!
后端·devops
NineData9 天前
DTCC 2026 预告|NineData CEO& 创始人叶正盛:面向 AI Agent 的数据库 DevOps 与数据复制实践
数据库·人工智能·数据库开发·devops·ninedata·数据库技术·dtcc
7177779 天前
如何搭建信创研发交付体系?Gitee DevOps 国产化适配、安全与流水线能力解析
安全·gitee·devops