Java案例如何实现操作日志?5种实战方案深度解析
📖 目录导读
- 操作日志的核心价值 - 为什么业务系统必须记录操作日志?
- 五大实现方案对比 - AOP切面、数据库触发器、事件驱动、中间件、自定义注解
- 基于Spring AOP的通用日志切面(最推荐)
- MyBatis-Plus自动填充与日志入库
- RabbitMQ异步日志收集(高并发场景)
- 自定义注解+反射实现灵活标记
- 数据库触发器与存储过程(遗留系统适配)
- 常见问题与避坑指南(面试高频)
- 总结与最佳实践
❓ 问答预热
Q:操作日志和系统日志有什么区别?
A:操作日志记录“谁在什么时候对什么资源做了什么操作”,如“用户A于10:00删除了订单B”,关注业务语义,系统日志(Log4j等)记录技术异常、调试信息,两者不可混用。

Q:为什么不用简单的System.out.println?
A:不满足生产环境要求:①无法统一管理②性能低③无持久化④不利于问题追溯,企业级日志必须满足结构化、可查询、异步化、低侵入四大特性。
基于Spring AOP的通用日志切面(最推荐)
适用场景
多数CRUD系统,要求日志与业务代码解耦,且对性能要求中等的场景。
核心思路
通过AOP环绕通知拦截Controller或Service方法,提取方法入参、注解、返回值,自动记录日志。
代码案例(关键步骤)
// 1. 定义自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogRecord {
String value() default ""; // 日志描述
boolean saveParam() default true; // 是否保存参数
}
// 2. AOP切面实现
@Aspect
@Component
public class LogAspect {
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
@Around("@annotation(logRecord)")
public Object around(ProceedingJoinPoint joinPoint, LogRecord logRecord) throws Throwable {
// 前置处理:获取请求信息
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
String desc = logRecord.value();
// 执行目标方法
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long cost = System.currentTimeMillis() - start;
// 后置处理:组装日志对象
OperationLog operationLog = new OperationLog();
operationLog.setDescription(desc);
operationLog.setMethod(methodName);
operationLog.setParams(logRecord.saveParam() ? JSON.toJSONString(args) : null);
operationLog.setResult(result != null ? JSON.toJSONString(result) : null);
operationLog.setCostTime(cost);
operationLog.setCreateTime(new Date());
// 异步保存日志(避免主业务阻塞)
CompletableFuture.runAsync(() -> logService.save(operationLog));
return result;
}
}
健壮性优化
- 参数脱敏:对密码、身份证等字段使用
@Sensitive注解过滤 - 失败补偿:若日志保存失败,打印告警而非影响主流程
- 防重复记录:使用
ThreadLocal标记是否为本切面触发的递归调用
MyBatis-Plus自动填充与日志入库
适用场景
使用MyBatis-Plus的项目,希望自动捕获所有数据库变更记录。
核心实现
通过MetaObjectHandler实现插入/更新时的自动填充,配合拦截器记录变更前后对比。
@Component
public class LogMetaHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 自动填充创建人、创建时间
this.strictInsertFill(metaObject, "createBy", String.class, "当前用户");
}
@Override
public void updateFill(MetaObject metaObject) {
// 可在此记录修改前快照(需配合拦截器)
}
}
// 配合Interceptor记录变更差异
@Intercepts({@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})})
public class DiffLogInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 读取修改前的数据
// 2. 执行修改
// 3. 对比修改前后字段差异
// 4. 写入日志表
}
}
注意:此方案不适合复杂业务逻辑,且需要自行实现字段对比算法。
RabbitMQ异步日志收集(高并发场景)
场景定位
日活百万以上、日志写入量大的系统,直接入库会压垮数据库。
架构设计
调用方 → 日志切面 → MQ生产者 (Direct Exchange) → 消费者 → 批量写入ES/MySQL
生产者端(关键配置)
// 异步发送,无需等待ACK
rabbitTemplate.convertAndSend("log.exchange", "log.routing", operationLog,
message -> {
message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
return message;
});
消费者端优化
@RabbitListener(queues = "log.queue")
public void consume(List<OperationLog> logs) {
// 批量操作:每100条或每5秒刷一次
logService.saveBatch(logs);
}
可靠性保障
- 配置重试机制(失败后重新入队)
- 设置死信队列处理持久异常
- 定期对账:对比MQ发送量与落库量
自定义注解+反射实现灵活标记
差异化需求
某些操作需要额外记录关联ID(如订单号),或某些方法不需要记录。
注解设计
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface BusinessLog {
String module(); // 模块名,如“订单”
String operation(); // 操作类型,如“创建”
String bindKey() default ""; // 关联业务ID的SpEL表达式,如 "#order.id"
}
解析SpEL
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("args", joinPoint.getArgs());
String orderId = parser.parseExpression(annotation.bindKey()).getValue(context, String.class);
数据库触发器与存储过程(遗留系统适配)
技术特点
零代码改动,但耦合度高,难以维护。
-- 简单示例
CREATE TRIGGER trg_after_update_order
AFTER UPDATE ON `order`
FOR EACH ROW
BEGIN
INSERT INTO op_log (table_name, old_data, new_data, operator, op_time)
VALUES ('order', OLD.content, NEW.content, @current_user, NOW());
END;
使用限制
- 跨数据库迁移困难
- 无法记录HTTP上下文信息(IP、请求路径等)
- 多次触发可能导致性能问题
🛠️ 常见问题与避坑指南
问题1:AOP切面导致循环依赖
解决:使用@Lazy注解或Setter注入,避免构造器循环依赖。
问题2:异步日志导致事务不一致
场景:主业务回滚,但日志已写入。
方案:采用事务同步策略,在TransactionSynchronizationManager的afterCommit中发送MQ。
问题3:日志数据量过大
优化:
- 区分核心日志与辅助日志,核心日志存MySQL,辅助日志入Elasticsearch
- TTL索引:设置日志表保留90天数据,定期归档到冷存储
问题4:敏感信息泄露
必须处理:
- 参数序列化前替换敏感字段(如
password→ ) - 使用
Jackson的@JsonFilter进行属性过滤
📌 总结与最佳实践
方案选择决策树
日志写入量 < 10万/天 → 方案一(AOP + 同步入库)
10万~100万/天 → 方案三(MQ异步) + 方案二(MyBatis拦截器)
> 100万/天 → 方案三 + Elasticsearch集群
遗留系统 → 方案五(触发器过渡)
需要精细控制 → 方案四(自定义注解)
最终推荐组合
- 核心日志:Spring AOP + 自定义注解 + 异步写入MySQL
- 变更历史:MyBatis-Plus拦截器 + 快照对比
- 审计报表:日志数据同步到Elasticsearch,使用Kibana可视化
验收标准
- 日志与业务代码完全解耦(修改日志逻辑无需改业务代码)
- 单次日志写入对主业务响应时间影响 < 2ms
- 日志完整率 > 99.99%(需做幂等和补偿)
- 支持按模块、用户、时间段、操作类型组合检索
延伸思考:若使用Spring Cloud微服务架构,可集成
Spring Cloud Sleuth,在日志中注入traceId,实现全链路追踪,此时日志表建议增加trace_id字段,配合Zipkin进行调用链分析。