在构建我们的内部 Agent 平台时,有一条从项目第一天就立下的规矩:

上线前自问:能否仅凭日志还原一次请求的完整调用链?

这条规矩落地为两套互补的机制:一套是结构化 JSON 日志 + trace_id(面向机器,排查问题用);另一套是本文的主角——工作日志(work log)系统,一张业务事件表,驱动首页的"系统日志流"实时滚动,同时承担事后审计的职责。它不是技术日志的重复,而是用业务语言记录系统里发生了什么:谁的文档索引完成了、谁创建了 Agent、哪次推理跑完了几步。

这篇文章讲清楚三件事:为什么选 Spring ApplicationEvent 而不是在业务代码里直接写库、异步落库怎么保证主链路零阻塞、以及这套机制的边界和扩展点。

一、为什么不直接在业务代码里 insert 一条日志

最朴素的实现是:文档索引完成后,在同一个方法里调 workLogMapper.insert(...)。我们没有这么做,原因有三个:

  1. 耦合方向错了。业务模块(知识库、推理、反馈)不应该感知日志表的存在。直接写库意味着每个业务 Service 都要注入日志 Mapper、拼日志字段,日志格式演进时要改 N 处。
  2. 主链路被拖慢。一次同步 INSERT 看起来只要几毫秒,但它挂在请求线程上:文档索引本来要上传对象存储、写向量库,再加一次 MySQL 往返,长尾延迟就是这样攒出来的。更糟的是,日志表写入失败会反过来让业务操作报错——日志系统绝不能成为业务故障源
  3. 没有扩展点。如果后面想把事件同时推到 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 列够查、够改
levelmodule 分开 日志流前端按 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 回答的是另一个问题:"系统今天在忙什么?"两条线各司其职,互不替代。

七、边界与已知短板

诚实地说,这套方案有明确的适用边界:

  1. @Async 是进程内异步,不是持久队列。应用重启或崩溃时,线程池队列里未落库的事件会丢。对"日志流 + 粗审计"的场景可接受;如果某天审计合规要求"一条不能丢",就要把发布侧换成写本地表 + 后台搬运,或引入真正的 MQ——好在业务代码只调 publish(...),替换实现不动业务。
  2. MySQL 承载的是低基数事件流。当前事件都是"创建/完成/提交"这类低频动作,QPS 个位数。如果未来要把高频事件(如每次 LLM token 计费)也塞进来,这张表和 MySQL 都不是正确答案,那时应该走 ClickHouse——同样靠新增一个监听器分流。
  3. 事件类型是约定而不是注册表。事件种类靠团队约定维护,没有强类型的注册中心。对单体 + 小团队,这是优点(加一个事件零流程成本);团队大了以后可能需要给 module 字段加枚举约束。

结语

这套工作日志系统的全部代码量不超过两百行,但它兑现了"Day 1 可观测"的承诺:业务代码里只有一行 publish,日志落库不阻塞主链路,前端一张表轮询出实时日志流,事后审计有据可查。它的价值不在技术新颖——Spring 事件机制二十年前就有了——而在于从第一天就把"记录系统行为"当成功能来设计,而不是等出事后才想起补日志