审计日志不是「记录一下谁做了什么」这么简单。当管理员修改了一个角色的权限,你需要知道:改了哪些字段、从什么值改成了什么值、这次请求执行了哪些 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? 用户可能只修改了角色名称,请求体中只有 id 和 roleName,其他字段为 null。如果用请求体做 Diff,会把所有未传字段都标记为 null → 原值,产生大量假变更。更新后重新查询完整实体,确保 Diff 只记录真正的变更。
八、总结与思考
做对了什么
-
异步 Diff + INSERT,请求线程零阻塞。Diff 计算、字典翻译、JSON 序列化、数据库 INSERT 全部在独立线程池中完成。请求线程只做快照组装和投递,业务响应不受审计日志影响。
-
深拷贝脱离请求线程引用 。
copyOf()使用 Jackson 序列化做深拷贝,避免了异步线程访问实体时触发 MyBatis Session 已关闭异常或读到脏数据。 -
双阶段耗时分离 。
duration(业务耗时)和auditDuration(审计耗时)分开记录,定位性能问题时能区分是业务慢还是审计慢。 -
调用链树形结构。HTTP → Controller → Mapper → SQL 的完整调用路径,每个节点带耗时。AUDIT 节点与 HTTP 同级,不污染业务耗时。
-
SQL 拦截器排除自身表 。
sys_oper_log的 INSERT 不挂入调用链,避免无限递归。 -
字典标签翻译。Diff 中的码值自动翻译为中文标签,管理员不需要查字典就能看懂变更内容。
-
降级策略。异步提交失败时降级为同步写入,保证日志不丢失。
-
ThreadLocal 幂等清理 。
record()的finally和afterCompletion双重清理,保证线程池复用时数据不串读。
可以改进的地方
-
Service 层调用链未挂载。当前调用链只覆盖 HTTP → Controller → Mapper → SQL,Service 层的方法调用未挂入。如果一个 Controller 方法调用了多个 Service 方法,无法区分各 Service 的耗时。后续可以通过 AOP 切面挂载 Service 节点。
-
调用链 JSON 体积可能偏大 。复杂请求的调用链可能包含几十个 SQL 节点,JSON 序列化后可能达到数 KB。存储在
call_chain字段中会占用数据库空间。可以考虑对超长调用链做截断或压缩。 -
Diff 不支持嵌套对象 。当前
OperLogDiffCalculator只比对实体直接字段,不支持嵌套对象或集合的 Diff。如果实体包含List或嵌套对象,Diff 结果不够精细。 -
线程池监控缺失 。
bizLogExecutor线程池没有暴露监控指标(队列长度、活跃线程数、拒绝次数)。如果日志量突增,线程池可能积压,但没有告警机制。 -
查询接口未实现 。
BizLogRecordServiceImpl.queryLog()和queryLogByBizNo()返回空列表。mzt-biz-log 框架的日志查询功能未接入,当前只能通过数据库直接查询。
2026 年 8 月 · 第五篇