假如你有一个 CRM 系统的需求,给客户资料编辑页面加上操作日志。一开始觉得小菜一碟,改个字段加一行 log.info 的事。结果一周后你就会对着代码陷入了沉思——
一个「编辑客户信息」的方法里,塞了七八行日志拼接代码,还因为同事交接时没写注释,你根本分不清某段文案是系统日志还是操作日志。更崩溃的是,产品说「改字段之前的值也要记上」,于是你不得不在方法签名上加了个参数,就为了传个旧值进来。
这种体验,我相信每一个写过业务系统的后端都不会陌生。
一、为什么操作日志总是写得很痛苦
先定义清楚:我们说的操作日志到底是什么?

操作日志,记录的是「某个人在某个时间对某个对象做了什么」。用户看到的物流信息(「2026-07-14 10:00 您的包裹已到达配送站」)、客服看到的工单处理记录(「张三 2026-07-14 09:30 将工单状态从「待处理」修改为「处理中」」),都是操作日志。
它和 系统日志 有本质区别。系统日志写给开发者看的,排查 NPE、SQL 慢查询用的,里面充斥着类名、行号、堆栈信息。操作日志是写给用户或运营看的,要求可读性高,一眼就能明白发生了什么。
一张对比表可以更清晰地看出区别:
操作日志(Operation Log):记录用户或系统对业务对象执行的操作,具备高可读性和可追溯性。你可以理解为系统的「黑匣子录音笔」——不光录下结果,还录下了谁、什么时候、怎么操作的。
但在实际代码里,它们往往混在一起,甚至写在同一行日志里。
我总结了三个最普遍的痛点:
痛点一:LogUtil 散落一地
// 在业务方法里随手记一笔
log.info("订单已创建,订单号:{}", orderNo);
// 改地址的时候再拼一段
String template = "用户%s修改了配送地址:从"%s"修改到"%s"";
LogUtil.log(orderNo, String.format(template, userName, oldAddr, newAddr), userName);
这还算体面的。我见过更夸张的——一个方法里横跨了四五种日志记录方式,有的是 LoggerFactory.getLogger,有的是 LogUtil.log,还有的直接 System.out.println。新人进组看到这段代码直接自闭。
痛点二:文案永远说不清楚
可能有人会问:文案不就是个字符串吗,写清楚很难吗?
难在动态部分。比如「修改了配送员:从 10090 修改到 10099」——用户 ID 谁看得懂?用户想看的是「从张三修改到李四」。为了把 ID 翻译成姓名,你不得不在业务方法里再加一段查询和拼接逻辑。
痛点三:历史值从哪里拿
要记录「从 A 改到 B」,你得先拿到 A。但 A 是修改前的值,方法入参里只有新值。常见的解法是在方法开头多查一次数据库,但这样代码就变成了:
public void updateAddress(UpdateRequest request) {
String oldAddress = addressService.queryOld(request.getOrderNo()); // 为了日志多查一次
doUpdate(request); // 真正的业务
LogUtil.log(orderNo, "从" + oldAddress + "改到" + request.getNewAddress());
}
这段代码里,有两行跟业务逻辑毫无关系,纯粹是为了记录日志。而且这样的代码在每个需要记录日志的方法里都要重复一遍。
操作日志的核心困境,说白了就一句话:日志代码和业务代码严重耦合,而且动态文案的组装没有统一规范。
二、优雅操作日志的核心原理
既然问题找到了,那解决方案也就清晰了:把日志代码从业务方法里抽出去。
如果你写过 Spring,第一反应应该是 AOP(面向切面编程)——在方法执行前后统一处理日志,业务代码不需要知道日志的存在。
但光有 AOP 还不够,核心要解决两个问题:
1) 模板动态化:日志文案不是写死的,里面要嵌入方法参数、数据库查出来的旧值、甚至方法返回值。这就需要一个表达式引擎来解析模板。
2)上下文传递:有些变量不在方法参数里(比如旧值、当前用户),需要一种方式让业务代码「告诉」日志组件这些值。
于是业界达成了共识方案:注解 + SpEL + AOP。
@LogRecord(
success = "用户{
{#userName}}修改了订单的配送地址:从"{
{
#oldAddress}}"修改到"{
{
#request.address}}"",
bizNo = "#request.deliveryOrderNo"
)
public void modifyAddress(UpdateRequest request) {
LogRecordContext.putVariable("oldAddress", deliveryService.queryOld(request.getDeliveryOrderNo()));
doUpdate(request);
}
这套方案的运行机制是:
- AOP 拦截器捕获方法上的
@LogRecord注解 - 解析注解中的 SpEL 表达式,从方法参数和 LogRecordContext 中取值填充模板
- 将组装好的操作日志持久化(数据库 / ES / 文件)
- 整个过程出错不影响主业务(日志记录异常不会抛给调用方)
SpEL(Spring Expression Language):Spring 框架自带的表达式语言,可以在运行时通过字符串表达式求值。你可以理解成一种「安全的字符串模板引擎」——不用拼代码,用
#paramName就能引用方法参数。
这套模式的核心价值在于:让你的业务方法从「做业务 + 记日志」变成了只做业务。日志代码被注解和配置替代,可读性大幅提升,维护成本直线下降。
三、log-record:阿里程序员的注解日志实践
先说这个由阿里巴巴程序员开源的项目 log-record。目前 1.1k Star,支持 SpringBoot 1/2/3(JDK 8~21),稳定更新到 v1.7.x。

快速接入
三步走,极致简单:
第一步:引入 Maven 依赖
SpringBoot 2 引入:
<dependency>
<groupId>cn.monitor4all</groupId>
<artifactId>log-record-starter</artifactId>
<version>1.7.1</version>
</dependency>
SpringBoot 3(JDK 17+)引入:
<dependency>
<groupId>cn.monitor4all</groupId>
<artifactId>log-record-springboot3-starter</artifactId>
<version>1.7.1</version>
</dependency>
第二步:实现日志处理接口
@Component
public class MyLogService implements IOperationLogGetService {
@Override
public boolean createLog(LogDTO logDTO) {
// 这里写你的持久化逻辑:存数据库、写ES、发MQ...
System.out.println(JSON.toJSONString(logDTO));
return true;
}
}
第三步:在方法上加注解
@OperationLog(
bizType = "'orderCreate'",
bizId = "#request.orderNo",
msg = "'用户' + #queryUserName(#request.userId) + '创建了订单'"
)
public Response<String> createOrder(CreateRequest request) {
// 纯业务逻辑,不需要写任何日志代码
return orderService.create(request);
}
基础功能拆解
SpEL 表达式:注解中 bizType、bizId、msg 等字段全部使用 SpEL 解析。注意字符串常量要用单引号包裹——'orderCreate',否则 SpEL 会当成方法名解析报错。
自定义上下文:当需要的变量不在方法参数中时,用 LogRecordContext.putVariable() 手动传递:
@OperationLog(
bizType = "'followerChange'",
bizId = "#request.orderId",
msg = "'用户' + #userName + '修改了跟进人:从' + #oldFollower + '改到' + #request.newFollower"
)
public Response<String> changeFollower(ChangeRequest request) {
LogRecordContext.putVariable("userName", queryUserName(request.getUserId()));
LogRecordContext.putVariable("oldFollower", queryOldFollower(request.getOrderId()));
return bizService.changeFollower(request);
}
自定义函数:通过 @LogRecordFunc 注册静态方法,在 SpEL 中直接调用:
@LogRecordFunc("UserHelper")
public class UserFunctions {
@LogRecordFunc
public static String queryName(Long userId) {
return userService.getNameById(userId);
}
}
然后在注解中使用:msg = "#UserHelper_queryName(#request.userId)"
实体类 Diff:这是 log-record 的高频功能之一。比较新旧两个对象,自动生成变更字段的对比文案:
@OperationLog(
bizType = "'userUpdate'",
bizId = "'1'",
msg = "#_DIFF(#oldUser, #newUser)"
)
public void updateUser(User newUser) {
LogRecordContext.putVariable("oldUser", userService.getById(newUser.getId()));
userService.update(newUser);
}
输出的日志内容示例:「用户工号」从【1】变成了【2】 「name」从【张三】变成了【李四】
可能有人会问:日志处理失败了会不会影响业务?
不会。log-record 的日志切面异常不会抛给调用方——日志记录出错,业务逻辑照常执行。它还提供了重试机制(
log-record.retry.retry-times=5)和兜底 SPI 接口,确保日志尽可能不丢。
亮点特性
log-record 还有一些很实用的差异化功能:
- 数据管道:支持 RabbitMQ / RocketMQ / SpringCloud Stream 直接推送日志,适合微服务架构的统一日志采集场景
- 条件记录:
condition = "#result.code == 200"满足条件才记录 - 重复注解:一个方法可以加多个
@OperationLog记录多维度日志 - 方法执行优化制:
executeBeforeFunc = true可在方法执行前解析 SpEL
四、mzt-biz-log:美团程序员的亲儿子
mzt-biz-log 由美团程序员开发,正是美团技术博客那篇文章背后代码的直接开源版本。目前 2.8k Star,是操作日志领域 Star 数最高的项目之一。

快速接入
同样是三步走:
第一步:引入 Maven 依赖
<dependency>
<groupId>io.github.mouzt</groupId>
<artifactId>bizlog-sdk</artifactId>
<version>3.0.7-SNAPSHOT</version>
</dependency>
第二步:在 SpringBoot 启动类上开启注解
@SpringBootApplication
@EnableLogRecord(tenant = "com.myapp")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
tenant 代表租户,同一个集团下的多服务可以共用同一个 tenant 标识来划分日志归属。
第三步:在方法上加注解
@LogRecord(
success = "{
{#order.purchaseName}}下了一个订单,购买商品「{
{#order.productName}}」",
type = LogRecordType.ORDER,
bizNo = "{
{#order.orderNo}}"
)
public boolean createOrder(Order order) {
return orderService.create(order);
}
mzt-biz-log 使用 {
{}} 双大括号来标识 SpEL 表达式,和 log-record 略有不同。
基础功能拆解
成功/失败双文案:方法执行成功和抛出异常时记录不同的文案:
@LogRecord(
success = "{
{#order.purchaseName}}下了一个订单",
fail = "创建订单失败,原因:「{
{#_errorMsg}}」",
type = LogRecordType.ORDER,
bizNo = "{
{#order.orderNo}}"
)
public boolean createOrder(Order order) {
// 如果抛出异常则记录 fail,否则记录 success
}
操作人获取:mzt-biz-log 提供了两种方案。注解上指定:
@LogRecord(
operator = "{
{#currentUser}}",
success = "...",
type = LogRecordType.ORDER,
bizNo = "{
{#order.orderNo}}"
)
public boolean createOrder(Order order, String currentUser) {
}
或通过全局接口注入,这样每个注解都不用重复指定操作人:
@Component
public class MyOperatorGetService implements IOperatorGetService {
@Override
public Operator getUser() {
return new Operator(UserContext.getCurrentUser().getName(),
UserContext.getCurrentUser().getId());
}
}
自定义函数:通过实现 IParseFunction 接口,把 ID 翻译成可读文案:
@Component
public class OrderParseFunction implements IParseFunction {
@Override
public boolean executeBefore() {
return true; }
@Override
public String functionName() {
return "ORDER"; }
@Override
public String apply(Object value) {
// value 是订单 ID,返回「商品名称(订单ID)」的格式
Order order = orderService.getById(Long.valueOf(value.toString()));
return order.getProductName() + "(" + value + ")";
}
}
然后在注解中使用:success = "更新了订单{ORDER{#orderId}},更新内容为..."
对象 Diff:mzt-biz-log 的 Diff 功能非常强大,支持嵌套对象、集合、自定义字段注解:
@LogRecord(
success = "更新了订单{_DIFF{#oldOrder, #newOrder}}",
type = LogRecordType.ORDER,
bizNo = "{
{#newOrder.orderNo}}"
)
public boolean diff(Order oldOrder, Order newOrder) {
return true;
}
配合 @DiffLogField 和 @DiffLogAllFields 注解,可以精确控制 Diff 的字段名和翻译函数,输出结果类似:
更新了订单【创建人的用户ID】从【9001】修改为【9002】;【订单号】从【MT0000011】修改为【MT0000099】
亮点特性
- 事务集成:
@EnableLogRecord(joinTransaction = true)让日志记录与业务事务绑定,日志落库失败则业务回滚 - 性能监控:提供了
ILogRecordPerformanceMonitor接口,可监控日志记录的性能开销 - 多注解支持:同一个方法可以叠加多个
@LogRecord,记录不同维度的日志 - 成功率表达式:
successCondition = "{ {#result.code == 200}}"精细化控制成功/失败的判断逻辑
五、log-record vs mzt-biz-log,怎么选
两个项目解决问题的方式高度一致,都是「注解 + SpEL + AOP」模式,但在细节取舍上各有侧重。
| 对比维度 | log-record(qqxx6661) | mzt-biz-log(mouzt) |
|---|---|---|
| Star 数 | 1.1k | 2.8k |
| 目前版本 | v1.7.x | v3.0.x |
| SpringBoot 版本 | 1.x / 2.x / 3.x 均支持 | 2.x / 3.x |
| JDK 兼容 | JDK 8 ~ 21 | JDK 8+ |
| SpEL 表达式风格 | 标准 SpEL(字符串需加单引号) | {
{}} 双大括号包裹 |
| 数据管道 | 内置 RabbitMQ / RocketMQ / Stream 支持 | 需自行实现持久化 |
| 对象 Diff | 通过 _DIFF 内置函数 |
通过 _DIFF + @DiffLogField 注解 |
| 事务回滚 | 不支持 | 支持(joinTransaction) |
| 性能监控 | 无 | 有(ILogRecordPerformanceMonitor) |
| 失败重试 + 兜底 | 支持(配置重试次数 + SPI) | 不支持 |
| 在方法执行前解析 | 支持(executeBeforeFunc) |
支持(自定义函数加 executeBefore) |
| 非注解方式记录 | 支持 OperationLogUtil.log() |
不支持 |
选型建议其实很简单:
选 log-record 的场景:
- 你的团队用 SpringBoot 3 / JDK 17+,需要开箱即用的 MQ 管道
- 对日志可靠性要求高,需要重试和兜底机制
- 偏好标准的 SpEL 语法,不想学新的
{ {}}风格
选 mzt-biz-log 的场景:
- 你的场景对日志的事务一致性要求高(日志写入失败业务也回滚)
- 需要完善的 Diff 功能(嵌套对象、集合对比、字段级自定义)
- 社区更活跃(2.8k Star),遇到问题更容易找到解决方案
一句话选型:想要消息管道和重试兜底 → log-record;想要对象 Diff 和事务集成 → mzt-biz-log。
六、总结
操作日志优雅化的本质,不是找到了什么黑科技,而是 用 AOP 解耦、用 SpEL 动态化、用注解替代重复代码。这三个技术单独拿出来都不稀奇,但组合在一起就解决了后端开发的一个高频痛点。
如果你现在正在被操作日志折磨,别硬扛了。找一个合适的开源项目,加一个注解,把时间省下来解决真正有价值的业务问题。
参考与说明
- 本文在操作日志主题上结合个人实践独立撰写
- 部分背景信息参考:美团技术团队博客《如何优雅地记录操作日志?》,观点与表述均为作者本人
- 涉及工具版本请以官方文档为准
本文为作者原创,转载请注明出处。如果对你有帮助,欢迎点赞、收藏、转发。