在构建我们的内部 Agent 平台时,有一条从项目第一天就立下的规矩:
上线前自问:能否仅凭日志还原一次请求的完整调用链?
这条规矩落地为两套互补的机制:一套是结构化 JSON 日志 + trace_id(面向机器,排查问题用);另一套是本文的主角——工作日志(work log)系统,一张业务事件表,驱动首页的"系统日志流"实时滚动,同时承担事后审计的职责。它不是技术日志的重复,而是用业务语言记录系统里发生了什么:谁的文档索引完成了、谁创建了 Agent、哪次推理跑完了几步。
这篇文章讲清楚三件事:为什么选 Spring ApplicationEvent 而不是在业务代码里直接写库、异步落库怎么保证主链路零阻塞、以及这套机制的边界和扩展点。
一、为什么不直接在业务代码里 insert 一条日志
最朴素的实现是:文档索引完成后,在同一个方法里调 workLogMapper.insert(...)。我们没有这么做,原因有三个:
- 耦合方向错了。业务模块(知识库、推理、反馈)不应该感知日志表的存在。直接写库意味着每个业务 Service 都要注入日志 Mapper、拼日志字段,日志格式演进时要改 N 处。
- 主链路被拖慢。一次同步 INSERT 看起来只要几毫秒,但它挂在请求线程上:文档索引本来要上传对象存储、写向量库,再加一次 MySQL 往返,长尾延迟就是这样攒出来的。更糟的是,日志表写入失败会反过来让业务操作报错——日志系统绝不能成为业务故障源。
- 没有扩展点。如果后面想把事件同时推到 ClickHouse 做分析、推到 Prometheus Pushgateway 做计数,直接写库的代码只能再堆 if。
所以我们选了 Spring 自带的 ApplicationEvent / EventListener 模式——不需要引入任何消息中间件,单体应用内的事件总线足够用了。
二、整体架构
业务方法 (KnowledgeService, ReasoningService, ...)
│
├── 执行核心逻辑
│
└── 发布 Spring ApplicationEvent ← 业务代码唯一要做的事
│
▼
WorkLogListener (@Async) ← 独立线程消费
│
└── 写入 work_log 表 (MySQL)
业务侧对日志的全部感知就是一行 publish(...)。事件发布是同步的、进程内的、纳秒级的开销——它只负责把事件对象丢进 Spring 的事件多播器;真正的数据库写入发生在 @Async 监听器的独立线程里,主请求线程在 publish 返回的那一刻就已经脱身了。
这个架构换来的性质:
- 业务模块与日志模块完全解耦,只发事件,不感知日志表;
@Async异步写入,日志库抖动、慢 SQL 都不会阻塞主业务流程;- 前台查询统一
SELECT即可,没有异构数据源; - 后续可扩展:再加一个
@EventListener就能把同一事件流接到 ClickHouse、Pushgateway,业务代码零改动。
三、数据模型:一张刻意简单的事件表
CREATE TABLE work_log (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
space_id VARCHAR(64) NOT NULL COMMENT '工作空间',
module VARCHAR(32) NOT NULL COMMENT '模块: Knowledge/Agent/Reasoning/Feedback/Tool',
level VARCHAR(8) NOT NULL DEFAULT 'info' COMMENT 'info/success/warn/error',
message TEXT NOT NULL COMMENT '事件描述',
metadata JSON COMMENT '扩展信息 (docId, sessionId, agentId ...)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_worklog_space (space_id),
INDEX idx_worklog_time (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
设计上几个有意的取舍:
| 决策 | 理由 |
|---|---|
message 存人读的自然语言("文档「xxx.md」索引完成,3 chunks") |
这张表的第一消费者是首页日志流和运营排查,不是程序。结构化检索需求交给 metadata |
metadata 用 JSON 列存关联 ID |
事件 schema 一定会演进,为每种事件建宽表是过度设计;MySQL JSON 列够查、够改 |
level 与 module 分开 |
日志流前端按 level 着色(error 红、success 绿),按 module 过滤,两个维度是正交的 |
space_id 必填且有索引 |
平台是多工作空间隔离的,任何数据访问都强制 WHERE space_id = ?,日志也不例外 |
只建 (space_id) 和 (created_at) 两个索引 |
查询模式就两种:"某空间最近 N 条"和全局时间线,不为假想查询建索引 |
注意这张表故意没有 trace_id 字段。work log 是业务事件流,粒度是"一件事发生了";调用链还原是结构化 JSON 日志的职责,两者通过时间戳和 metadata 里的 sessionId / docId 即可关联,不需要在两张体系里重复存链路标识。
四、事件接入:发布侧一行,消费侧一个类
平台约定:新功能上线时,在其关键生命周期点发布对应事件。当前的接入点:
| 触发位置 | 时机 | 事件 | level | message 示例 |
|---|---|---|---|---|
| 文档处理流水线 | 索引完成 / 失败 | DocumentIndexed / DocumentError | success / error | 文档「xxx.md」索引完成,3 chunks |
| Agent 创建接口 | 实例创建成功 | AgentCreated | info | 创建 Agent「xxx」 |
| 推理服务 | 流式输出结束 | ReasoningCompleted | success | 推理完成 (session: xxx, steps: 2) |
| 反馈服务 | 反馈提交 | FeedbackSubmitted | info | 提交反馈 5 分 |
| 知识库服务 | 知识库创建 | KnowledgeBaseCreated | info | 创建知识库「xxx」 |
发布侧长这样——提供一个静态便捷方法,业务代码只传业务参数,不碰事件构造细节:
// 业务代码里唯一的日志痕迹:
WorkLogEvent.publish(eventPublisher, this, spaceId, "Knowledge", "success",
"文档「" + docName + "」索引完成," + chunks + " chunks",
"{\"docId\":\"" + docId + "\"}"); // metadata 可选
消费侧是一个带 @Async 的监听器,收到事件后写入 work_log。整个链路里唯一需要小心的就是 @Async 的线程池配置:队列要有界、拒绝策略要明确(我们选丢弃 + warn 日志——日志系统可以丢日志,绝不能拖垮业务),而不是用 Spring 默认的 SimpleAsyncTaskExecutor(它不复用线程,每次执行新建线程,事件洪峰时会失控)。
五、消费端:首页日志流
前端首页的系统日志流调用 GET /api/home/logs,后端就是一句查询:
SELECT * FROM work_log ORDER BY created_at DESC LIMIT 30;
前端 5 秒轮询一次。这里没有上 WebSocket / SSE 推送,是刻意的:日志流是"氛围感 + 粗粒度监控",秒级延迟完全可接受,轮询实现零状态、零连接管理,配合 (created_at) 索引,这条查询的成本可以忽略。可观测性基建本身也应该遵守简单原则——如果一个 5s 轮询能解决问题,就不要为它维护长连接。
六、与结构化日志的分工
平台里其实有两条日志线,经常被混为一谈,值得说清:
| 结构化 JSON 日志(logback) | work_log 事件表 | |
|---|---|---|
| 受众 | 工程师排查问题 | 运营/用户看系统活动、审计 |
| 粒度 | 每个请求、每条 SQL 级 | 业务事件级(一件事一次) |
| 关联手段 | trace_id 串起完整调用链 |
space_id + metadata 里的业务 ID |
| 存储 | 日志文件 / 采集系统 | MySQL 表 |
| 语言 | 技术语言(stack trace、耗时) | 业务语言("索引完成,3 chunks") |
开头那条规矩——"能否仅凭日志还原完整调用链"——靠的是第一条线:所有日志带 trace_id,请求入口的 Web Filter 生成并注入 MDC,后续所有日志输出自动携带。而 work_log 回答的是另一个问题:"系统今天在忙什么?"两条线各司其职,互不替代。
七、边界与已知短板
诚实地说,这套方案有明确的适用边界:
@Async是进程内异步,不是持久队列。应用重启或崩溃时,线程池队列里未落库的事件会丢。对"日志流 + 粗审计"的场景可接受;如果某天审计合规要求"一条不能丢",就要把发布侧换成写本地表 + 后台搬运,或引入真正的 MQ——好在业务代码只调publish(...),替换实现不动业务。- MySQL 承载的是低基数事件流。当前事件都是"创建/完成/提交"这类低频动作,QPS 个位数。如果未来要把高频事件(如每次 LLM token 计费)也塞进来,这张表和 MySQL 都不是正确答案,那时应该走 ClickHouse——同样靠新增一个监听器分流。
- 事件类型是约定而不是注册表。事件种类靠团队约定维护,没有强类型的注册中心。对单体 + 小团队,这是优点(加一个事件零流程成本);团队大了以后可能需要给
module字段加枚举约束。
结语
这套工作日志系统的全部代码量不超过两百行,但它兑现了"Day 1 可观测"的承诺:业务代码里只有一行 publish,日志落库不阻塞主链路,前端一张表轮询出实时日志流,事后审计有据可查。它的价值不在技术新颖——Spring 事件机制二十年前就有了——而在于从第一天就把"记录系统行为"当成功能来设计,而不是等出事后才想起补日志。