说实话,我第一次在团队里引入“操作日志”这个需求时,是有点懵的。业务方要的是“谁、在什么时候、对哪个订单做了什么事”,听起来很简单,但如果每个业务方法里都手动去写一遍日志收集逻辑,那代码就废了。后来用Spring Boot配合AOP(切面编程)把这块彻底梳理清楚,才真正体会到什么叫“优雅落地”。
这篇文章我会直接拿实际项目里的方案来讲,把自定义注解、切面处理、日志表设计、异步持久化这些环节逐一拆开,包括我在生产环境里踩过的坑、优化过的点,以及最后的扩展思路。内容不是教科书式的概念堆砌,而是可以直接抄进项目里用的完整实践。适合有一定Spring Boot基础、正在做系统日志模块或者准备重构日志方案的开发者,看完你就能照着搭一套自己的操作日志持久化能力。
1. 整体设计思路与方案选型
1.1 为什么不是拦截器,也不是手动埋点
接到操作日志需求,第一反应通常是三种做法:手动在每个业务方法里写日志、用Spring的拦截器(HandlerInterceptor)统一处理、或者用过滤器(Filter)层面去搞。这三种我都试过,各有各的痛点。
手动埋点最直接,但坏处太明显:日志代码和业务代码混在一起,每个方法都要重复写一遍,时间长了根本没法治。而且一旦有人图省事少写了某个重要操作,日志审计就有缺口,这在我们后来说是“事故级别”的问题。
拦截器能切到Controller层,但只能拿到HTTP层面的信息,拿不到Service层里具体的业务参数,更拿不到“操作前和操作后的数据变化”。比如“用户修改了订单金额从100到120”,拦截器层面很难优雅地拿到修改前后的值,只能靠业务代码自己在方法里传出来,又回到了手动埋点的老路。
过滤器就更不用说了,层次太高,跟业务完全脱节,除非是做全站访问日志,否则根本不适合做精细化操作审计。
AOP的好处在于,它能精确切到你想记录的那个方法上,方法入参、返回值、异常信息都能拿到,还能在方法执行前、执行后、抛出异常时分别处理。配合自定义注解,可以把“要不要记日志”这个决策权交给业务方法自己,真正做到声明式控制,代码完全解耦。这也是我最终选择AOP最核心的原因。
1.2 方案选型时要考虑的几个关键点
选型不是写个切面就完事,真正动手前有几个点必须先想清楚,否则后面返工成本极高。
第一个是注解粒度。日志是记在Controller层还是Service层?我建议核心业务操作放在Service层,因为Service层才真正包含了业务状态的变更,Controller层更多是参数组装和响应封装,信息容易失真。当然,有些场景也需要记录Controller层的访问轨迹,那就属于另一套“访问日志”体系,跟“操作日志”分开处理。
第二个是同步还是异步。这个问题我放在后面专门说,但选型阶段就必须确定,因为这决定了日志表设计、线程池配置、事务处理方式都不一样。一句话先说结论:日志写入一定不能阻塞主业务流程,必须异步化。
第三个是日志内容怎么组织。我见过不少团队把日志内容拼成一大段JSON塞进一个字段里,虽然实现了,但查询和统计分析极其痛苦。正确的做法是把操作人、操作类型、业务对象类型、业务对象ID、操作内容、操作时间、IP地址、请求ID这些字段拆开存,内容部分再考虑结构化。
第四个是扩展性。今天记的是订单操作,明天可能要记用户操作、商品操作,你的设计必须保证新增一个业务模块的日志时,不需要改切面代码,只加一个注解和对应的业务类型枚举就够了。
这四个点定下来,整体框架就不会跑偏。我当时的最终方案是:自定义注解 + Spring AOP环绕通知 + 异步线程池落库 + 独立日志表 + 查询接口。接下来我逐步拆解。
2. 核心实现:自定义注解与切面解析
2.1 自定义注解:把选择权交给业务
注解是整个方案的入口,设计得好不好直接影响使用体验。我定义了一个叫@OperationLog的注解,用起来是这样的效果:
@OperationLog( module = "order", type = OperationType.UPDATE, description = "修改订单金额", bizId = "#order.id", detail = "'将订单金额从' + #oldAmount + '修改为' + #newAmount" ) public void updateOrderAmount(Order order, BigDecimal oldAmount, BigDecimal newAmount) { // 业务逻辑 }是不是看着很清爽?业务方法完全不需要关注日志怎么记,只需要声明“我要记日志”,然后通过注解把关键信息告诉切面就行了。
注解本身的定义长这样:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { // 业务模块,比如订单、用户、商品 String module() default ""; // 操作类型:新增、修改、删除、查询、导出等 OperationType type() default OperationType.OTHER; // 操作内容描述,支持SpEL表达式 String description() default ""; // 业务对象ID,支持SpEL表达式,方便后续按业务ID检索 String bizId() default ""; // 是否记录方法入参 boolean recordParams() default true; // 是否记录返回值 boolean recordResult() default false; // 是否异步保存,默认异步 boolean async() default true; }有几个细节必须说明白。首先是OperationType,我建议用枚举而非字符串,枚举能保证类型统一,后续前端展示、后台统计都不需要再去解析字符串。其次是bizId和description支持SpEL表达式,这是为了从方法入参中动态提取数据,比如#order.id就表示取入参中名为order的对象的id属性。这个能力很重要,不然日志和具体业务对象就关联不上。
2.2 切面核心代码:环绕通知的正确姿势
有了注解,接下来就是切面。我的切面类核心逻辑是围绕HashMap来组织的,但直接看代码更直观,先上个骨架:
@Aspect @Component public class OperationLogAspect { @Autowired private OperationLogService operationLogService; @Around("@annotation(operationLog)") public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { // 方法执行前的上下文构建 // 方法执行 // 方法执行后的日志组装 // 异常情况下的日志记录 } }这里面有几个关键点我逐个展开。
第一,方法执行前要准备好操作上下文。操作人信息、IP地址、请求ID这些当前线程环境里的数据,一旦方法执行完切换到异步线程,就可能取不到了。所以必须在进入方法前就把这些上下文快照下来。我封装了一个OperationContext对象,里面放操作人ID、操作人姓名、IP、请求ID、方法全限定名、模块、操作类型等。
第二,环绕通知里对返回值和异常都要处理。正常返回时记录成功日志,抛出异常时记录失败日志,同时把异常信息放进日志内容里。注意,不能让日志逻辑吞掉业务异常,joinPoint.proceed()抛出的异常必须继续向上抛,否则业务事务就乱了。
第三,切点表达式要收敛。一定要用@annotation(operationLog)这种绑定注解的写法,不要直接写execution(* com.xxx.service..*.*(..)),否则所有Service方法都会被切入,日志量爆炸不说,无谓的性能损耗也很大。只有加了注解的方法才会被记录,这是“宣言式”设计的精髓。
2.3 SpEL表达式动态解析参数
前面提到注解里的bizId和description支持SpEL,这一块是切面里比较“高级”的部分,很多人的实现就在这里翻车。翻车原因通常是直接拿#order.id当字符串去SpEL解析,结果Spring上下文里根本没有order这个变量。
正确的做法是先拿到方法入参名和参数值的映射,再传给SpEL表达式解析器。这里有几种实现路径,我推荐用Spring的DefaultParameterNameDiscoverer,它可以自动获取方法参数名(前提是编译时加了-parameters参数,或者用了Spring Boot的默认配置),然后组装成StandardEvaluationContext:
private String parseExpression(String expression, Method method, Object[] args) { if (StringUtils.hasText(expression)) { String[] paramNames = discoverer.getParameterNames(method); StandardEvaluationContext context = new StandardEvaluationContext(); if (paramNames != null) { for (int i = 0; i < paramNames.length; i++) { context.setVariable(paramNames[i], args[i]); } } return parser.parseExpression(expression).getValue(context, String.class); } return ""; }这里有个必须强调的坑:如果编译时没有保留参数名,getParameterNames()会返回null,此时SpEL解析会直接报错。我的经验是,在项目的pom.xml里显式加上-parameters编译参数,一劳永逸。另外,解析出的SpEL结果要包一层try-catch,因为表达式是业务人员(或者你自己)写的,写错的情况非常常见,日志切面不能因为解析失败反过来把业务搞挂。
3. 日志数据的持久化落地
3.1 日志表设计:面向查询而不是面向存储
日志表的设计经常被低估,总觉得“反正就是insert一条记录嘛”。但等你真要去查“这个订单都被谁改过”的时候,糟糕的表设计会让你崩溃。
我提供的日志表结构是经过几轮迭代后的版本,核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| trace_id | varchar(64) | 全局链路追踪ID,用于串联一次请求内的所有日志 |
| user_id | varchar(64) | 操作人ID |
| user_name | varchar(64) | 操作人姓名 |
| module | varchar(32) | 业务模块(order、user、product等) |
| type | varchar(16) | 操作类型(CREATE、UPDATE、DELETE等) |
| biz_id | varchar(64) | 业务对象ID,方便按对象维度查询 |
| description | varchar(512) | 操作内容描述 |
| request_params | text | 方法入参(JSON格式) |
| response_result | text | 返回值(可选) |
| error_msg | varchar(1024) | 异常信息(可选) |
| ip_address | varchar(64) | 操作IP |
| request_uri | varchar(128) | 请求路径(可选) |
| cost_time | bigint | 方法执行耗时(毫秒) |
| create_time | datetime | 操作时间 |
这里有几个设计心得很重要。
一是trace_id不能省。很多团队一开始不设计这个字段,结果一旦操作日志和业务数据要关联排查问题,没有全链路ID根本没法串起来。我在切面里直接调用MDC.get("traceId")获取日志链路ID,前提是你已经接入了类似SLF4J MDC的链路追踪机制。如果没有,也可以用UUID在请求入口生成一个,关键是保证一次请求内的日志共享同一个ID。
二是request_params存JSON,但不建议无脑存所有入参。序列化大对象非常耗时,而且有些参数根本没必要记录,比如HttpServletResponse、MultipartFile这类对象。我在注解里设计了recordParams开关,同时切面里对参数做类型过滤,遇到不能序列化的对象就转成简单类型描述,避免序列化报错。
三是关于大字段。description我定了512字节,实际够用。request_params用text类型,但查询时不要SELECT *,确认清楚字段需求再查询,避免大字段拖慢数据库。
3.2 异步持久化:三种方案我最后选了哪种
日志写入如果同步执行,一来增加接口响应时间,二来日志库不稳定时还会拖垮主业务库,这绝对是生产环境的大忌。我在这块尝试过三种方案。
第一种是直接@Async异步方法。实现最简单,在切面里调用一个标注了@Async的Service方法保存日志。问题在于,Spring的@Async默认使用SimpleAsyncTaskExecutor,来一个线程就新建一个,根本不会复用,高并发下线程数会炸。而且这个方案在自调用时会失效,虽然我这里是从切面调Service,不属于自调用,但线程池不可控是硬伤。
第二种是自建线程池 +@Async("operationLogExecutor")指定线程池。这个方案解决了线程复用的问题,是大多数团队的常规做法。我自己用下来效果也不错,具体配置是这样的:
@Bean("operationLogExecutor") public ThreadPoolTaskExecutor operationLogExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(4); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("op-log-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }参数为什么这么定?日志写入是IO密集型操作,核心线程数不用太多,2个就够打底;队列容量1000是为了在突发流量下先排队,日志稍微延迟入账可以接受;拒绝策略用CallerRunsPolicy是为了极端情况下不丢日志,宁可牺牲一点接口性能,也不能让审计记录白白消失。
第三种是先写入内存队列,再由消费者批量落库。这个方案性能最好,比如用ArrayBlockingQueue或者引入消息中间件,但复杂度也明显上升,需要处理队列满了怎么办、消费者挂了怎么办、批量提交失败怎么补偿。对于绝大多数中小规模系统,第二种方案已经足够,我最终也选了第二种。如果你未来日志量真的大到单机处理不动,再升级到MQ异步落库也不迟,前面的表结构设计完全可以复用。
3.3 日志查询与管理:不能只写不读
日志落库之后,如果没有配套的查询能力,那这套系统只完成了一半。现实中业务方隔几天就会过来问:“上星期那个订单为什么状态变了?谁改的?”这时候你总不能直接甩给他一条SQL让他去数据库里查吧。
我的做法是提供一套基于日志表的查询接口,核心条件包括:
- 按时间范围查询
- 按操作人查询
- 按业务模块和操作类型查询
- 按
biz_id查询某个业务对象的全部操作历史
查询接口本身不难,但分页和索引设计要考虑。我建了联合索引(module, type, biz_id, create_time),最常用的场景是“查某模块下某个业务对象的变更历史”,这个索引能直接命中。另外,日志表的写入频繁,如果数据库压力大,可以考虑定期归档历史数据到冷存储,只保留最近3个月的热数据在业务库。
我在实际项目里还加了一个“日志详情”的展示能力,点击一条日志能看到该操作的完整入参、返回值和异常信息,这在排查线上问题的时候格外有用,省去了翻业务服务日志的麻烦。建议你至少在管理后台给操作日志模块留一个入口页,哪怕列表页都行,这能让这套方案的价值立刻被业务方感知。
4. 常见问题与排坑实录
说真的,网上讲AOP日志的文章一抓一大把,但大多数只讲正向流程,真正能告诉你“哪些地方会炸”的文章很少。下面这些坑全是我自己踩过的,每一个都对应的线上问题的血泪史。
4.1 事务与日志异步导致的数据不一致
这个问题最容易踩,而且踩了还不容易发现。场景是这样的:业务方法加了@Transactional,方法内更新订单状态后,切面异步去记录日志。如果业务事务最终回滚了,但日志已经通过异步线程插入到了日志表,就会留下一堆“虚假操作日志”。
日志表和业务表不在同一个数据库中,这是硬约束,所以无法用本地的单事务去保证一致性。我最后采用的策略是:切面在业务方法正常返回后才提交日志,但这仍然无法解决“业务事务提交之后、异步日志写入之前又失败了”的问题。在这种极端情况下,日志确实会丢,但业务层面已经无法感知。
想要更稳,我的做法是先将日志记录写入一个operation_log_draft表,和业务处于同一个事务内,业务事务提交后,再由异步任务把草稿表的数据搬移到正式日志表,搬运成功后就删除草稿记录。如果业务事务回滚,草稿记录也跟着回滚,天然保证一致性。当然这需要多一张表,多一步处理,但如果你对日志准确率有强要求,这一步不能省。
4.2 切面内部方法调用导致AOP失效
@OperationLog注解标注在方法A上,方法A在同一个类里被方法B调用,这个时候切面是不生效的。因为Spring AOP基于代理机制,只有通过代理对象调用才会触发切面逻辑,而this.methodA()直接调用的是原始对象的方法,代理逻辑完全被绕过了。
这个问题的解法有三个。最推荐的是避免同类自调用,把需要被代理的方法单独放到一个Bean里,或者在业务类外部通过另一个Service去调用。其次是用@Lazy注入自身代理,或者通过ApplicationContext.getBean()拿到代理对象再调用。最后一种方案是直接开启@EnableAspectJAutoProxy(exposeProxy = true),然后用AopContext.currentProxy()取代理对象,但这个方案有一定的风险,性能也很一般,我能不用就不用。
在实际代码审查中,这是我最常发现的一个“暗坑”。你以为加了个注解就能记录,结果日志全都悄无声息地消失了,排查半天发现是同类内调用。建议你可以写一个测试用例,专门验证注解方法在代理和非代理调用下的行为差异,这样以后就再也不会被坑。
4.3 JSON序列化性能与循环引用
记录入参的核心动作就是序列化。在这个环节我踩过两个坑:第一个是循环引用,比如Order对象里引用了User,User里又引用了Order的List,直接用Jackson序列化会抛出JsonMappingException。第二个是序列化大对象太耗时,比如入参里带了一个很大的配置文件对象,一次序列化花几十毫秒,这在高频操作下完全不可接受。
解决办法是:切面里自己维护一个序列化工具类,用ObjectMapper配置好,包含以下关键设置:
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 循环引用处理 mapper.configure(SerializationFeature.FAIL_ON_SELF_REFERENCES, false);同时,在切面里对入参对象做类型判断,遇到以下几种类型直接简化处理:
MultipartFile、HttpServletRequest、HttpServletResponseBindingResult等Spring框架内部对象- 类名带
$$的CGLIB代理对象
更进一步,如果某个方法的入参对象非常复杂,我会在注解上手动关闭recordParams,只记录description。毕竟日志的最终目的是知道“发生了什么事”,而不是把所有细节都复制一份,这个权衡你要有。
4.4 日志丢失与沉默失败
异步日志最怕的就是“丢了还没人知道”。默认情况下,异步线程里如果日志插入失败,异常会被线程池吞掉,主流程无感知,但日志文件里也看不到任何记录。等你发现某段时间的操作记录缺失时,早就过了补救窗口期。
我给异步线程的日志写入逻辑做了完整兜底:
try { operationLogMapper.insert(log); } catch (Exception e) { // 降级处理:输出到应用日志文件 log.error("操作日志写入失败,日志内容:{}", JSON.toJSONString(log), e); // 或者写入本地消息表,等待定时任务补偿 }如果你的团队对日志完整性有监管层面的要求(比如金融项目),我强烈建议你加上本地补偿机制:日志插入失败时,先把记录写入一个error_log_retry表,由定时任务每隔1分钟扫描重试。这样可以保证“不丢一条日志”的承诺,代价就是多一张表和多一个定时任务,值得。
5. 优雅进阶:从操作日志到事件中台
5.1 用Spring Event解耦更彻底
如果你觉得自定义注解和切面已经很优雅了,那我要告诉你还有更优雅的路线,那就是把日志处理从切面里进一步解耦成事件驱动模式。
具体思路是:切面只负责把操作信息封装成一个OperationLogEvent,然后发布到Spring容器里。容器中的监听器(Listener)负责接收这个事件,再异步去落库。这样切面类变得更加纯粹,职责单一:只收集信息,不做具体的持久化。日志的消费方则变成了一个独立的事件监听器,你可以随时替换、追加监听器,而不用改切面本身。
public class OperationLogEvent extends ApplicationEvent { private final OperationLogInfo logInfo; public OperationLogEvent(Object source, OperationLogInfo logInfo) { super(source); this.logInfo = logInfo; } }发布端只需要一行:
applicationEventPublisher.publishEvent(new OperationLogEvent(this, logInfo));监听端:
@EventListener @Async("operationLogExecutor") public void handleOperationLog(OperationLogEvent event) { operationLogService.save(event.getLogInfo()); }这个方案的扩展空间在于,同一个事件可以对应多个订阅者。未来你可能想对操作日志做实时监控、敏感操作告警、或者把日志同步到大数据平台,都只需要在监听器里增加对应逻辑,完全不影响主流程。可以说,从“面向切面”到“面向事件”是这套方案从单兵作战到体系化作战的关键转变。
5.2 还可以扩展成哪些能力
日志持久化只是起点,我落地之后半年内又在这个底座上扩展了三个能力,你可以参考。
第一个是操作审计报表。基于日志表的数据,可以按操作人、操作类型、模块维度生成统计报表。比如“某个用户今天做了多少个删除操作”“某个订单在生命周期内被修改了多少次”,这些数据对账务审计、风控分析、运维排查都有直接价值。
第二个是敏感操作告警。在事件监听器里对特定模块、特定类型、特定业务ID做规则判断,一旦命中就把告警推到钉钉或者企业微信群里。比如“删库类操作”“大批量导出数据”“权限变更操作”,这些高风险行为必须在实时性上做响应,而不能等到事后翻日志。
第三个是日志数据生命周期管理。日志只增不减,半年后表空间会非常庞大。我写了一个定时归档任务,把三个月前的日志按月分区归档到冷存储(例如小型数据仓库或者归档表),同时在查询接口里增加一个“历史日志”入口。归档完成后对原表做分区裁剪,保证热库的性能不衰退。
这些扩展做下来,你会发现最初那个小小的AOP切面,已经变成了一个稳定运转的系统审计基座。这个投资收益比,是我当年手动埋点的时候完全不敢想的。
最后说点实在的
这套方案最核心的价值,是让你从“到处埋点”的泥潭里彻底抽身出来。每次新接一个业务模块,开发只需要做三件事:加注解、写清楚SpEL表达式、完成自己的业务逻辑。日志落库、异步处理、查询入口,全部由底层能力自动接管。
我个人在实际操作中最大的体会是,日志系统最重要的不是功能强大,而是不能出乱子。宁愿少记一些不必要的细节,也不能因为日志反而把主流程搞出故障。异步、降级、失败补偿这三板斧才是日志持久化方案的灵魂。另外,如果你刚准备动手做这块,我建议不要一上来就追新技术,就用Spring Boot原生的AOP和异步线程池,先把完整链路跑通,再考虑升级到事件驱动甚至接消息队列。方向对了,慢慢走比跑偏了更快。