运维出身的同学做后端的,应该都体会过这种痛苦:业务系统上线跑了一段时间,某天运营跑过来说“这个订单金额是谁改的?什么时候改的?改之前是多少?”结果全组人翻日志翻到天黑,愣是找不到一条完整的链路记录。我上个月就刚经历了一回,改价入口没有留任何审计痕迹,最后只能靠数据库binlog反推。那之后我下决心把“操作日志”这件事系统化,方案就是自定义注解 + Spring AOP:在需要留痕的方法上打一个 @OperateLog 注解,切面自动把模块、操作人、操作内容、参数、返回值、IP、耗时全部串成一条语义完整的审计记录。
这篇文章我会把整套实现思路、完整代码、SpEL动态模板的设计,以及我在生产环境踩过的几个坑都摊开讲清楚。适合对 Spring AOP 有基础了解、但还没自己动手写过“自定义注解 + 切面”这套组合拳的后端同学。文章不追求华丽架构,追求的是拿来能用、用完能扛住线上流量。
1. 操作日志不是打印几行 log:先搞清要解决什么问题
很多同学一听“操作日志”,第一反应是“我接口里早就写了 log.info 啊”。这话没错,但 log.info 和审计意义上的操作日志,根本是两回事。先把这个差异掰扯清楚,后面的设计才有依据。
1.1 技术日志与操作日志的定位差异
技术日志记录的是程序运行状态,面向的是开发人员排查问题;操作日志记录的是“某个用户在什么时间做了哪个业务动作”,面向的是运营、客服、审计甚至法务。一个是机器视角,一个是业务视角,字段设计、存储策略、保留周期完全不一样。
| 维度 | 技术日志(log.info/error) | 操作日志(审计日志) |
|---|---|---|
| 记录主体 | 程序运行状态 | 用户的业务动作 |
| 面向读者 | 开发人员 | 运营、客服、审计、产品 |
| 典型字段 | 时间、线程、类名、堆栈、message | 模块、操作类型、操作人、详情、IP、结果 |
| 保留周期 | 几天到几周,滚动清理 | 按合规要求,动辄半年以上 |
| 核心要求 | 可追踪、可检索 | 可还原业务现场、可追责 |
典型的例子:技术日志里你会看到 [http-nio-8080-exec-3] ERROR OrderService - null pointer,但你根本不知道是哪个用户干了什么操作触发的。操作日志里你应该能看到 [订单管理] 用户admin(1001) 将订单20240315001金额由100.00修改为80.00。后者才是业务方真正需要的信息。
1.2 手写日志代码的三个痛点
最原始的方案就是每个方法里手写 log。我见过不少项目就是这么干的,看起来简单,实际维护起来全是坑。
第一个痛点是业务侵入。业务方法里塞了一堆日志拼接代码,真正的业务逻辑和审计逻辑缠在一起。后来需求变了,订单号从 orderNo 改为 orderId,你得同时改业务代码和日志代码两处,漏改一处就会出脏数据。
第二个痛点是标准缺失。张三写的日志是“改价:订单号xxx,改成xxx”,李四写的是“用户修改订单xxx金额”,同一个动作,格式五花八门。到了真要排查的时候,要么搜不到,要么搜到一堆对不上的记录。没有统一的模块、操作类型、详情口径,日志形同虚设。
第三个痛点是漏记。日志是“顺手”写的,功能一多就容易忘。而操作日志最怕的就是漏记——漏一条,等于这段操作在审计上是裸奔的。一旦出事,你连“这事发生过”都证明不了。
1.3 用自定义注解统一收口的设计思路
自定义注解方案解决的就是上面三个痛点。核心思路很简单:把“记不记”和“怎么记”分离开来。
业务方法上只要出现 @OperateLog 注解,就约定为“这个方法需要记录操作日志”。至于日志怎么格式化、怎么存储、怎么异步化,全部交给切面统一处理。业务代码里不需要出现任何一行日志拼装代码,真正的业务逻辑干干净净。
想记录哪个方法,加注解就行:
java复制@OperateLog(module = "订单管理", operation = "修改订单金额")
public void modifyAmount(OrderModifyDTO dto) {
// 业务代码
}
这个方法长什么样、能改成什么参数,就是下面几章要讲的重点了。先记住一个结论:自定义注解只是“声明”,真正干活的是切面;切面读注解、取参数、执行 SpEL 模板、落库,一气呵成。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 注解定义:五个属性怎么设计才够用
注解本身很简单,难的是属性设计。属性设计决定了这个注解是“只能记个流水账”还是“能还原业务现场”。我最终落地的版本长这样:
java复制package com.example.operatelog.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperateLog {
/** 业务模块,比如:订单管理、用户中心、财务 */
String module() default "";
/** 操作类型,建议动词开头,比如:修改订单金额、审核通过、发起退款 */
String operation() default "";
/** 日志详情模板,支持 SpEL 表达式,比如:订单#{#dto.orderNo}金额由#{#oldAmount}改为#{#dto.newAmount} */
String detail() default "";
/** 是否记录本次操作的 SpEL 条件表达式,返回 true 才记录 */
String condition() default "true";
/** 是否记录方法返回值(可能包含敏感数据时设为 false) */
boolean saveResult() default true;
/** 是否记录异常堆栈 */
boolean saveError() default true;
}
2.1 元注解的选型
有几个元注解必须理解清楚,否则很容易踩坑。
@Target({ElementType.METHOD}):表示这个注解只能标在方法上。为什么不放宽到类级别?因为操作日志天然是方法级的动作,类级别的注解会让粒度失控。你可以在类上标注“这个类的所有方法都要记”,但每个方法的 detail 模板大概率不一样,落到类上反而增加复杂度。就锁死在方法上。@Retention(RetentionPolicy.RUNTIME):这个是最关键的。Java 注解的保留策略有三个级别:SOURCE、CLASS、RUNTIME。SOURCE 只在源码里存在,编译后就没;CLASS 会写进字节码但在运行时反射读不到;只有 RUNTIME 才能被反射读取。切面靠反射读注解,所以必须 RUNTIME。@Documented:让 javadoc 生成时把这个注解带出来,纯文档层面的好事,加上不亏。@interface:定义注解类型的关键字。理解成一种特殊的接口就行,它定义的是“注解的成员属性”而不是普通方法。
2.2 五个属性背后的取舍
module和operation:这两个字段是日志的“索引”。后续审计查询时,按模块聚类、按操作类型过滤是最常见的检索方式。module 建议和你后台菜单的模块名保持一致,operation 建议用动词开头,读起来像人话。detail:这是整个注解最有含金量的属性。它不是普通字符串,而是支持 SpEL 的模板。为什么必须动态?因为审计日志要还原现场,一条“修改订单金额”没有业务意义,但一条“订单20240315001金额由100.00改为80.00”就有。detail 的设计我在第 4 章单独展开。condition:布尔类型的 SpEL 表达式,控制“这次操作要不要记”。比如有些接口是轮询调用,只有状态变更时才需要留痕,条件表达式就是这个闸门。saveResult/saveError:控制返回值和异常是否存储。这俩开关是给敏感场景留的后门,比如查询接口会返回用户手机号,你显然不希望把手机号整段落库。
2.3 最小编写成本的用法示例
所有属性都有默认值,目的很简单:降低使用成本。最小用法只需要两个属性:
java复制@OperateLog(module = "订单管理", operation = "修改金额")
public void modify() {
// ...
}
这样一条没有 detail 的日志能记下“谁在什么时候改了订单管理里的金额”,但看不到具体改了哪个单子。所以我建议模块和操作类型必填,detail 尽量写,实在没有动态信息时也可以不写。条件表达式、保存返回值这些默认值足够覆盖 90% 的场景,不需要每次调用都显式声明。
3. 切面是真正的执行者:核心代码拆解
注解只负责“声明我要记录”,真正干活的是切面。这一章把最核心的切面实现拆开讲清楚。
3.1 为什么选 @Around 而不是 @Before/@After
Spring AOP 的五个通知类型里,@Before 在方法执行前拦截、@AfterReturning 在成功返回后执行、@AfterThrowing 在抛异常后执行。要完整记录一条操作日志,需要同时拿到前置信息、返回结果、异常信息、耗时,用 @Around 是最顺手的——它把整个方法执行包在一个环绕块里,前后都能干预。
java复制@Aspect
@Component
public class OperateLogAspect {
private static final String POINT_CUT = "@annotation(com.example.operatelog.annotation.OperateLog)";
@Around(POINT_CUT)
public Object recordOperateLog(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Method method = resolveMethod(joinPoint);
OperateLog operateLog = method.getAnnotation(OperateLog.class);
if (operateLog == null) {
return joinPoint.proceed();
}
Object result = null;
Throwable error = null;
try {
result = joinPoint.proceed();
return result;
} catch (Throwable t) {
error = t;
throw t;
} finally {
long cost = System.currentTimeMillis() - start;
saveLog(joinPoint, method, operateLog, result, error, cost);
}
}
private Method resolveMethod(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = joinPoint.getTarget().getClass();
return ClassUtils.getMostSpecificMethod(method, targetClass);
}
}
很多人会忽略 resolveMethod 这一步。直接用 signature.getMethod() 有风险:如果被拦截的目标方法是接口实现或经过了 CGLIB 代理,返回的可能是接口方法而非实现类方法。注解如果写在实现类上,接口方法上是没有的,此时拿不到注解,日志就漏了。ClassUtils.getMostSpecificMethod 会帮你找到“最具体”的那个方法,也就是实际执行的方法。
3.2 从连接点组装日志内容
拿到注解和方法之后,就是把散落在各处的信息组装起来。主要包含几块:注解属性值、SpEL 解析后的业务详情、方法参数序列化、请求上下文、操作人信息。
java复制private void saveLog(ProceedingJoinPoint joinPoint, Method method,
OperateLog operateLog, Object result, Throwable error, long cost) {
OperateLogContent content = new OperateLogContent();
// 1. 注解基础信息
content.setModuleName(operateLog.module());
content.setOperation(operateLog.operation());
// 2. SpEL 解析:详情 + 条件
boolean condition = SpelParseUtils.parseBoolean(operateLog.condition(), method, joinPoint.getArgs(), result, error);
if (!condition) {
return;
}
content.setDetail(SpelParseUtils.parse(operateLog.detail(), method, joinPoint.getArgs(), result, error));
// 3. 类名 + 方法名
content.setClassName(method.getDeclaringClass().getName());
content.setMethodName(method.getName());
// 4. 参数 JSON(注意脱敏,后面第8章会讲)
content.setParamsJson(JsonUtils.safeSerialize(joinPoint.getArgs()));
// 5. 返回值 / 异常开关
content.setSuccess(error == null);
content.setCostMs(cost);
if (operateLog.saveResult()) {
content.setResultJson(JsonUtils.safeSerialize(result));
}
if (operateLog.saveError() && error != null) {
content.setErrorMsg(ExceptionUtils.getStackTrace(error));
}
// 6. 请求上下文与操作人
fillRequestInfo(content);
OperateLogAppender.append(content);
}
这里有一个我特别想强调的细节:finally 块里做日志收尾。不管业务方法成功还是抛异常,都会走到 finally,这样“成功的操作”和“失败的操作”都能留下审计记录。异常被 throw t; 原样抛出去,业务调用方感知不到切面存在过,这是切面设计的基本修养。
而 OperateLogAppender.append 内部必须是异步且兜底的,这个逻辑放在第 5 章讲。
3.3 请求上下文与操作人怎么拿
审计日志里最重要的字段之一是“谁操作的”。如果项目里用 Spring Security / Shiro,直接 SecurityContextHolder.getContext().getAuthentication() 拿当前登录用户就行。如果是自己维护的会话,通常从 ThreadLocal 或者请求头里取。这里给一个兼容写法:
java复制private void fillRequestInfo(OperateLogContent content) {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
if (attributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) attributes).getRequest();
content.setRequestIp(getClientIp(request));
content.setRequestUri(request.getRequestURI());
content.setRequestMethod(request.getMethod());
}
// 从当前登录上下文取操作人
Operator operator = OperatorContext.get();
if (operator != null) {
content.setOperatorId(operator.getId());
content.setOperatorName(operator.getName());
}
}
取客户端 IP 时要特别小心反向代理。如果服务前面挂了 Nginx 或网关,request.getRemoteAddr() 拿到的可能是代理服务器的 IP 而不是真实客户端 IP。标准做法是依次读 X-Forwarded-For、X-Real-IP、getRemoteAddr(),同时要防伪造:
java复制private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) {
// X-Forwarded-For 可能由多个 IP 逗号拼接,取第一个
return ip.split(",")[0].trim();
}
ip = request.getHeader("X-Real-IP");
if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) {
return ip;
}
return request.getRemoteAddr();
}
需要提一句:如果请求是从 MQ 消费者线程或者定时任务里发起的,RequestContextHolder 可能拿不到 RequestAttributes,所以这段代码必须做 null 判断,否则会 NPE 把业务线程搞挂。这个坑我在第 7 章还会再提。
4. SpEL 表达式:让日志内容从“写死”变成“动态填充”
如果说注解是操作日志的骨架,SpEL 就是它的灵魂。没有 SpEL 的日志注解,只能记“谁在什么时候做了什么模块的操作”,没法记“具体操作了什么内容”。这一章把 SpEL 的设计讲透。
4.1 日志模板为什么需要 SpEL
先看一个反例。如果 detail 不支持动态内容,你写死“修改订单金额”,所有修改金额的操作日志都长一个样。审计人员查日志时看到一百条“修改订单金额”,还得去翻参数表才知道改的是哪一单,那这日志的价值就大打折扣。必须有一种机制,能在方法运行期间把参数、返回值填充到日志模板里。
SPEL(Spring Expression Language)就是 Spring 生态里的表达式语言。你可以在日志模板里写占位符,运行时由 SPEL 引擎根据当前方法上下文求值。你可以理解为:模板是你的便签纸,SPEL 是那个往便签纸上填具体内容的笔。
看这个模板:
text复制订单#{#dto.orderNo}金额由#{#oldAmount}修改为#{#dto.newAmount}
运行时解析出来的效果是:
text复制订单20240315001金额由100.00修改为80.00
这就是审计需要的“业务现场”。
4.2 基于 MethodBasedEvaluationContext 的参数引用
Spring 提供了一个非常贴心的类:MethodBasedEvaluationContext。它会把当前方法的参数按参数名暴露成 SpEL 变量,所以你可以在模板里直接用 #dto.orderNo、#oldAmount,而不是丑陋的 #args[0].orderNo。同时我还会主动往上下文里放两个保留变量:result(返回值)和 error(异常对象)。
java复制package com.example.operatelog.utils;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
public class SpelParseUtils {
private static final ExpressionParser PARSER = new SpelExpressionParser();
private static final ParameterNameDiscoverer NAME_DISCOVERER = new LocalVariableTableParameterNameDiscoverer();
public static String parse(String template, Method method, Object[] args,
Object result, Throwable error) {
if (!StringUtils.hasText(template)) {
return "";
}
try {
StandardEvaluationContext context = new MethodBasedEvaluationContext(
method.getDeclaringClass(), method, args, NAME_DISCOVERER);
context.setVariable("result", result);
context.setVariable("error", error);
Expression expression = PARSER.parseExpression(template, new TemplateParserContext());
Object value = expression.getValue(context);
return value == null ? "" : value.toString();
} catch (Exception e) {
// 解析失败时返回原模板,绝不影响主流程,稍后在日志里告警
return template;
}
}
public static boolean parseBoolean(String condition, Method method, Object[] args,
Object result, Throwable error) {
if (!StringUtils.hasText(condition)) {
return true;
}
try {
StandardEvaluationContext context = new MethodBasedEvaluationContext(
method.getDeclaringClass(), method, args, NAME_DISCOVERER);
context.setVariable("result", result);
context.setVariable("error", error);
return Boolean.TRUE.equals(PARSER.parseExpression(condition).getValue(context, Boolean.class));
} catch (Exception e) {
return true;
}
}
}
实现的几个关键点:
TemplateParserContext表示模板解析模式,只有#{...}包裹的部分会被当作表达式求值,其他部分按纯文本原样输出。没有它,整个字符串都会被当成表达式解析。LocalVariableTableParameterNameDiscoverer通过读取 class 文件里的调试信息(LocalVariableTable)来获取参数名。如果编译时没带-parameters或-g参数,这里拿不到参数名,那#orderNo这种写法就会失效。这个问题我在第 7 章会给出解决办法。- 表达式求值的结果统一
toString()拼进最终详情。如果结果为 null,返回空字符串而不是字符串 "null",避免日志里出现一堆 "null" 刺眼。 - 任何异常都不能往外抛。日志解析失败可以容忍,但业务方法不能被日志拖挂。
4.3 注册自定义函数,模板里直接调方法
SpEL 还有一个高级能力:在上下文中注册自定义函数,然后在模板里直接调用。最典型的场景是把操作人 ID 渲染成操作人姓名。日志模板里写“#{operatorName(#dto.operatorId)}”远比“#{#dto.operatorId}”好看。
用法如下:定义一个静态方法,通过反射注册进上下文。
java复制public class OperateLogFunctions {
public static String operatorName(Long userId) {
if (userId == null) {
return "未知用户";
}
// 这里从缓存或用户服务里查名称,注意不要引入耗时操作
String name = UserCache.get(userId);
return StringUtils.hasText(name) ? name : String.valueOf(userId);
}
}
在 SpelParseUtils 里加一段静态注册:
java复制static {
// 反射获取静态方法,注册为 SpEL 自定义函数
Method operatorNameMethod = ReflectionUtils.findMethod(
OperateLogFunctions.class, "operatorName", Long.class);
// 实际使用时按需注册,这里给出其中一种方式
}
或者在每次 parse 时单独注册:
java复制context.registerFunction("operatorName",
ReflectionUtils.findMethod(OperateLogFunctions.class, "operatorName", Long.class));
注册完,模板里就能写成:
text复制订单#{#dto.orderNo}由#{operatorName(#dto.operatorId)}操作,金额从#{#oldAmount}改为#{#dto.newAmount}
这里提个醒:自定义函数里不要做重活。比如查数据库这种操作,如果日志模板表达式执行耗时太长,一样会让接口变慢。用户名的查询应该走本地缓存,没有缓存就返回 ID 兜底。
4.4 解析失败时的兜底策略
SpEL 表达式是字符串,写到注解里之后,编译器不会帮你检查语法,只能运行时报错。所以解析工具类里的 catch 很重要,我特意加了一个兜底:解析失败返回原始模板,并且在日志里打 WARN 告警。这样业务不受影响,开发也能在日志系统里看到“哪条模板写错了”。
另外一个更稳妥的做法:在项目里给 SpEL 模板写单元测试。把所有使用 @OperateLog 注解的方法扫描出来,用真实类型 mock 参数,把 detail 表达式逐个跑一遍。这个测试能拦截掉绝大多数语法错误、参数名写错、类型不匹配的问题。我后面第 8 章还会提到这个测试的价值。
5. 日志落库与异步化:别让旁路逻辑拖慢主流程
操作日志是旁路逻辑,它的天职是“不能影响业务主流程”。但很多团队第一次落地时就是在这里翻车:日志表写入变慢,直接把业务接口拖垮。这章讲清楚存储设计、异步化、事务边界三个核心问题。
5.1 一张够用的 operate_log 表
日志表设计不需要花里胡哨,但字段要够用。我长期在用的表结构长这样:
sql复制CREATE TABLE `operate_log` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`trace_id` VARCHAR(32) DEFAULT NULL COMMENT '链路追踪ID',
`module_name` VARCHAR(64) NOT NULL COMMENT '业务模块',
`operation` VARCHAR(64) NOT NULL COMMENT '操作类型',
`detail` TEXT COMMENT '操作详情,SpEL解析后的中文描述',
`operator_id` BIGINT DEFAULT NULL COMMENT '操作人ID',
`operator_name` VARCHAR(64) DEFAULT NULL COMMENT '操作人姓名',
`request_ip` VARCHAR(64) DEFAULT NULL COMMENT '客户端IP',
`request_uri` VARCHAR(256) DEFAULT NULL COMMENT '请求URI',
`request_method` VARCHAR(16) DEFAULT NULL COMMENT 'HTTP方法',
`class_name` VARCHAR(192) NOT NULL COMMENT '类名',
`method_name` VARCHAR(128) NOT NULL COMMENT '方法名',
`params_json` TEXT COMMENT '入参JSON快照',
`result_json` TEXT COMMENT '返回结果JSON快照',
`error_msg` TEXT COMMENT '异常堆栈',
`cost_ms` BIGINT DEFAULT NULL COMMENT '方法耗时毫秒数',
`success` TINYINT NOT NULL COMMENT '是否成功 1成功 0失败',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
KEY `idx_operator_id_create_time` (`operator_id`, `create_time`),
KEY `idx_module_operation` (`module_name`, `operation`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作审计日志表';
几个字段的设计理由:
trace_id:把操作日志和全链路 trace 串起来,排障时可以跟技术日志联动。这是我强烈建议加的字段,成本极低价值极高。detail:解析后的中文描述,这是业务方最关注的字段,一定要保证可读性。params_json/result_json:完整的入参和返回快照,给“无法预料的排查”留底。- 索引:查询主模型是“操作人 + 时间段 + 模块/操作”,所以复合索引按这个方向建。
5.2 独立线程池与异步写法
日志写入绝对不能同步阻塞业务接口。最直接的做法是丢进线程池异步执行。但不要随便用 @Async 的默认线程池——Spring 默认的 SimpleAsyncTaskExecutor 每次执行都会 new 一个线程,日志量一大线程数直接失控。
单独配一个线程池:
java复制@Configuration
public class OperateLogThreadPoolConfig {
@Bean("operateLogExecutor")
public ThreadPoolTaskExecutor operateLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(2000);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("operate-log-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(10);
// 日志写入失败不能影响业务,使用 DiscardPolicy 丢弃多余任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
executor.initialize();
return executor;
}
}
然后把日志投递逻辑封装一下:
java复制@Component
public class OperateLogAppender {
private static final Logger log = LoggerFactory.getLogger(OperateLogAppender.class);
@Resource(name = "operateLogExecutor")
private ThreadPoolTaskExecutor executor;
@Autowired
private OperateLogMapper operateLogMapper;
public void append(OperateLogContent content) {
try {
executor.execute(() -> {
try {
OperateLogRecord record = OperateLogConverter.toRecord(content);
operateLogMapper.insert(record);
} catch (Exception e) {
log.error("insert operate log error, operation={}", content.getOperation(), e);
}
});
} catch (Exception e) {
log.error("submit operate log task rejected, operation={}", content.getOperation(), e);
}
}
}
这里用了显式线程池而不是 @Async,好处是线程池参数、拒绝策略都握在自己手里。DiscardPolicy 是刻意的选择:当线程池和队列都满时,宁可直接丢弃日志任务,也不能让业务接口因为日志堆积而变慢。如果你有强审计需求,可以考虑 CallerRunsPolicy,但代价就是极端情况下会把写日志的压力回传到业务线程,需要评估量级。
5.3 事务边界问题:审计日志不该跟着业务一起回滚
异步化还有一个隐含的好处:规避事务陷阱。如果日志是同步写入,而且用的是和业务相同的数据源,那么日志插入操作会参与到当前事务里。当业务方法抛异常回滚时,日志插入也会一起回滚。结果是什么?操作失败了,日志也消失了——而审计最需要记录的恰恰是失败操作。
这里要理解 Spring AOP 的切面顺序:多个切面作用于同一个方法时,执行顺序由 @Order 决定。如果操作日志切面和 @Transactional 切面都在同一线程,操作日志的存储时机和事务提交时机很容易搅在一起。你无法保证“业务成功提交了,日志才落库”,更无法保证“业务回滚了,日志还在”。
解决办法有三个层次:
- 异步写入(上面方案),线程池的线程不在原事务上下文内,天然隔离。
- 同步写入但强制新事务,
@Transactional(propagation = Propagation.REQUIRES_NEW),但要注意不能和业务共用事务。 - 发 Spring Event,监听器里再异步落库。
我个人在生产环境一直用方案一。异步 + 独立线程池,既保证性能又规避事务陷阱,一举两得。代价是极端情况下日志可能有延迟,但操作审计场景下这个延迟完全可接受。
还有一点必须做好:OperateLogAppender.append 内部最外层也要 try/catch。如果线程池拒绝提交、序列化失败、数据库连接池耗尽,都不能让异常冒泡到调用方。日志系统是旁路,永远不能成为主流程的故障源。
6. 完整示例:改价、审核、退款三个真实场景
理论讲完,上实战。我选三个最典型的业务场景,对应三种 SpEL 模板的用法,你可以直接抄走改一改。
6.1 场景一:修改订单金额
这个方法有三个动态信息:订单号、旧金额、新金额。旧金额往往不是入参,而是从库里查出来的原值,所以我把 oldAmount 也作为方法参数传进来,这样模板就能引用。
java复制/**
* 修改订单金额
*/
@OperateLog(
module = "订单管理",
operation = "修改订单金额",
detail = "订单#{#dto.orderNo}金额由#{#oldAmount}修改为#{#dto.newAmount}",
saveResult = false
)
public void modifyOrderAmount(OrderModifyDTO dto, BigDecimal oldAmount) {
// 1. 校验权限
// 2. 更新订单金额
// 3. 发送金额变更消息
}
运行时解析出的 detail 大概是:
text复制订单20240315001金额由100.00修改为80.00
saveResult = false 是因为改价方法返回值通常是 void,没必要记录。log 落到库里,审计人员一眼就能看明白。
6.2 场景二:审批通过
审核类方法的特点是:操作动作固定,但审核对象和审核备注需要动态展示。这个例子里我还用到了“字符串拼接”型的 SpEL 模板。
java复制@OperateLog(
module = "审批中心",
operation = "审核通过",
detail = "通过审批单#{#approvalNo},审核人备注:#コメント",
saveResult = false
)
public void approve(String approvalNo, String comment) {
// 审批流程流转
}
运行时解析出的 detail:
text复制通过审批单AP20240516001,审核人备注:信息核实无误,同意放行
注意模板里 #{...} 之间可以混普通文本,这就是 TemplateParserContext 的能力。如果 comment 为空,SpelParseUtils 会把 null 转成空字符串,日志里不会出现刺眼的 "null"。
6.3 场景三:退款发起外部调用
第三类场景是调用外部接口,需要记录调用结果。这时候就要同时用到 #result、SpEL 三元表达式、saveError 这几个能力。
java复制@OperateLog(
module = "支付中心",
operation = "发起退款",
detail = "退款单#{#request.refundNo}发起#{#request.amount}元退款,结果:#{#result.code == 200 ? '成功' : '失败 ' + #result.msg}",
saveResult = true,
saveError = true
)
public RefundResult refund(RefundRequest request) {
RefundResult result = refundClient.refund(request);
if (result.getCode() != 200) {
throw new BusinessException(result.getMsg());
}
return result;
}
运行时 detail 可能长这样:
text复制退款单RF20240516001发起88.00元退款,结果:失败 银行接口超时
这个场景说明一件事:SpEL 不只是“取参数”,它还支持简单的三元判断和字符串拼接。这让日志模板的表达能力变得非常强,几乎能覆盖所有“人话化”需求。
但也要注意,模板里别写太复杂的逻辑。SpEL 表达式本质是字符串,太复杂的判断既难读又难维护,拆成一个返回布尔值的自定义函数更清晰。
7. 生产环境踩过的坑与优化解法
这部分是我最想写的。网上讲 @OperateLog 怎么写的教程很多,但真正上了生产才会遇到下面这些坑。我把每个坑的根因和解决方式都记录下来。
7.1 SpEL 取不到参数名:编译参数与 args 数组
有段时间,我们代码里明明写了 #{#dto.orderNo},但线上日志打出来的 detail 还是原始模板。排查半天发现是编译环境没开 -parameters 参数,LocalVariableTableParameterNameDiscoverer 拿不到参数名,SpEL 里 #dto 根本不知道是谁。
解决方式有两种。
第一种,在 Maven 编译插件里开启参数名保留:
xml复制<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
开启后编译出的 class 会带 MethodParameters 属性,Spring 就能反射拿到参数名了。注意这个配置是对所有方法生效的,不只是被 @OperateLog 标记的方法。某些低版本的 Spring Boot 父 POM 已经帮你配好了,但如果你用的是自定义构建,一定要检查。
第二种,如果不方便改编译参数,就退一步用 args 下标。比如 #{#args[0].orderNo} 永远不会依赖参数名,缺点是参数一变下标就乱,可读性差。我建议尽量开 -parameters,一劳永逸。
7.2 参数为 null 时模板解析直接报错
另一个高频坑:方法参数本身是 null。比如退款接口允许不传备注,模板里写 #{#request.remark},解析时 #request.remark 会直接抛 SpelEvaluationException,因为 request 为 null,访问属性失败。
我们的 SpelParseUtils catch 住异常后返回原始模板,所以业务方法不会挂,但日志内容变成了模板原文,可读性很差不便于审计。解决方式是两个习惯:
- 模板里写防御式判断:
#{#request == null ? '无请求数据' : #request.remark}。 - 在工具类解析失败时打一条 WARN 日志,把方法名和模板打出来,方便开发在日志系统里定位问题。
这也是为什么我一直强调“模板要写单元测试”。测试环境把这些边界情况 mock 出来跑一遍,能省掉很多线上改版式的调试。
7.3 切面存储日志抛异常,业务接口被拖垮
这是最严重的一个坑,我见过不止一个团队踩过:在 finally 里同步调用 Mapper 插入日志,没有包 try/catch,结果日志表的一个字段超长直接抛 DataIntegrityViolationException,异常顺着 finally 飞到了业务调用方,好好的接口瞬间 500。
切面里的 saveLog 和 OperateLogAppender.append 必须做到“任何情况下都不抛异常”。我在第 5 章给的代码里做了双层 try/catch,不是过度设计,是为了让日志系统彻底成为旁路。日志写失败了,业务接口照常返回成功,只不过丢失一条审计信息——这个损失是可以接受的。
另外要注意 JSON 序列化的坑。入参里如果带了 HttpServletRequest、MultipartFile 这类对象,序列化会失败或者写出巨大字符串。所以 JsonUtils.safeSerialize 里要过滤掉常见不可序列化类型,或者统一走 toString() 兜底,同时限制最大长度,防止大字段把日志表撑爆。
7.4 异步写入导致的日志顺序颠倒
用户连续点击两次“改价”,第一次操作和第二次操作的日志都是异步落库。并发高的时候,第二次操作的日志可能比第一次先插入,于是日志表里时间顺序是乱的。大多数审计场景下问题不大,因为有 create_time 字段,查询时按时间排序列出来,毫秒级延迟可以忽略。
但如果审计要求特别严格,比如“必须严格按照操作先后顺序呈现”,异步方案就不够用了。可选方案:
- 用单线程消费的队列,串行写入日志,保序但不保吞吐。
- 同步写库但走
REQUIRES_NEW独立事务,性能和吞吐受限。 - 在日志里额外记录一个
biz_time业务时间,排序用业务时间而不是数据库自增 ID。
我个人的取舍是:操作日志场景下,异步 + create_time 排序足够。审计人员在意的是“谁在什么时候干了什么”,毫秒级的乱序不影响结论。
8. 这套方案还能往哪些方向扩展
基础版落地之后,还可以根据业务需要做几个方向的增强。这些都是我在实践过程中踩到真实需求后加的,写出来供参考。
8.1 消息队列削峰与本地消息表
日志量一旦到了每天几十万上百万条,直接写数据库会带来压力,尤其是大促、活动期间会突然飙高。这时候可以引入 MQ 做削峰:切面把日志内容发到消息队列,消费端再批量落库。
但审计场景对“不能丢消息”是有要求的。如果 MQ 发送失败,日志丢了,审计就有了缺口。我建议的稳妥方案是本地消息表:先在业务库里写一条 operate_log_send 待发消息,再由一个定时任务或消息生产者把它发到 MQ,消费端确认后更新发送状态。这是经典的“本地消息表 + 最终一致性”思路,用少量代码成本换来了不错的可靠性。
8.2 敏感字段脱敏
打印参数和返回值快照时,手机号、身份证、银行卡号这类敏感信息必须脱敏。两种做法:
第一种是序列化层面脱敏。用 Jackson 自定义序列化器,对标注了 @Sensitive 注解的字段做掩码处理。
java复制public class SensitiveSerializer extends JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (StringUtils.hasText(value) && value.length() >= 11) {
gen.writeString(value.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2"));
} else {
gen.writeString(value);
}
}
}
第二种是在 JsonUtils.safeSerialize 里统一做一次脱敏过滤,对所有字符串类型的字段扫描,匹配手机号、身份证正则就掩码。这种方式侵入性更小,但对正则的准确性要求高,误伤业务字段的风险存在。
我的建议是:如果敏感字段分布明确,用第一种注解方式;如果不确定有哪些敏感字段,用第二种兜底。二者也可以叠加。
8.3 多租户与审计维度扩展
SaaS 系统里日志表千万别忘了租户维度。加一列 tenant_id,所有查询强制带上,否则租户 A 的运营人员可能查到租户 B 的操作记录,这属于严重越权。索引也要把 tenant_id 放在最前面,按租户隔离查询。
除了租户,还可以根据业务需要扩展 source(来源端:APP/PC/H5)、channel(渠道)、business_id(业务对象 ID)等维度字段。business_id 这个字段的价值在于:可以支持“查某个订单的所有操作轨迹”这类审计需求。如果你们经常有这种需求,建议把 business_id 单独列出来而不是只存在 detail 里。
8.4 操作日志的测试基建
最后分享一个容易被人忽视的扩展:给操作日志做一套测试基建。写一个切面测试工具,扫描所有注解方法,用 mock 参数把 SpEL 模板跑一遍。这样每次新增 @OperateLog 方法,模板写没写错,测试一跑就知道。我在项目里就是这么干的,上线以来模板报错的数量接近于零。
最后说一点个人体会
这套 @OperateLog 方案我从最初“在方法里手写 log”进化到现在,中间迭代了好几版。最大的体会有两条:一是注解属性一定要把默认值给足,让大家使用成本降到最低,团队才愿意用;二是日志系统永远是旁路,安全和性能都要为这句话让路,任何时候不能让日志拖垮业务。如果你们项目也想要一套可落地的操作日志能力,直接从这篇文章里的代码抄起,先跑通,再按自己的业务去扩展属性,会比从零设计省很多事。
