许多后端项目一开始只有应用日志:请求来了、接口报错了、SQL 超时了。这些信息适合排障,却很难回答另一个更重要的问题:谁在什么时间对哪个对象做了什么操作,结果如何,能否与一次请求或链路追踪关联?

这才是审计日志要解决的问题。它不是把 logger.Info 的文本写进一张表,而是为有业务意义的操作保存一条可查询、可关联、可长期解释的事实记录。

本文讨论一套可适用于 Go、Java、Node.js 等后端服务的通用设计:如何将审计日志从散落在各模块中的埋点,提炼成所有业务用例都能复用的平台能力。

先划清边界:审计日志不是应用日志,也不是领域事件

应用日志面向运行诊断,通常包含调用栈、耗时、错误细节和临时调试信息;它可以采样、滚动和删除。审计日志面向责任追踪,核心是稳定的业务语义,例如 article_publishedrole_permission_changedlogin_failed。它需要保留操作者、目标、结果和时间,并避免记录密码、JWT、完整请求体等敏感信息。

审计日志也不等同于领域事件。领域事件用于驱动后续副作用,例如发送邮件、刷新缓存、更新搜索索引,因此可以通过 Outbox 异步投递并允许至少一次消费。审计记录的目标是让“业务修改”和“这次修改的责任事实”一起成立。对于同一数据库中的审计表,最合适的方式是随业务事务同步写入,而不是发一条 Kafka 消息后由 Worker 回写。

usecase transaction
  -> 更新业务表
  -> audit.Recorder 写入 audit_logs
  -> Outbox 写入需要异步交付的领域事件
commit / rollback

这样,业务变更回滚时审计记录也回滚;业务变更提交时,审计记录不会因为异步消费者失败而丢失。应为此建立集成测试:在事务中同时写业务数据和审计记录,分别验证提交后两者都存在、回滚后两者都不存在。

一个可复用审计能力的最小模型

一条通用审计记录可以抽象为九类信息:

actor_user_id  谁发起操作;未知或机器操作可为空
target_type    操作对象类型,例如 article、user、role
target_id      对象标识,统一使用字符串以兼容数字和业务键
action         稳定的领域动作码,例如 article_published
result         success 或 failure
ip             请求来源
user_agent     客户端标识
trace_id       OpenTelemetry 链路追踪 ID
metadata       有限、结构化的补充上下文

这些字段满足了绝大多数审计查询:查看某篇文章的完整变更历史,排查某个账号的权限操作,或从一条审计记录跳转到一次分布式追踪。target_type + target_id 是关键设计,它让平台不用依赖任何具体领域表,也不需要为每个资源建立专用审计表。

action 不应是自由文本。动作码由各业务领域定义和维护,平台只负责存储它。例如内容领域可定义 article.publishedarticle.archived,授权领域可定义 role.permission_changed,认证领域可定义 auth.login_failed。建议以 <bounded_context>.<past_tense_action> 命名,维护枚举或常量,并禁止由请求参数直接决定动作码。这样的职责划分既保持平台通用,也避免审计模块反向依赖业务领域。

用端口把业务与存储解耦

可复用的核心不是 ORM Model,而是一个很小的端口:

type Recorder interface {
    Record(ctx context.Context, input RecordInput) error
}

业务用例依赖 Recorder,并在完成状态转换的位置构造 RecordInput;平台的数据库实现负责将输入验证为 AuditLog,从 context.Context 提取 trace ID,再持久化到 audit_logs。依赖装配在应用启动处完成:创建 Repository,构造 Recorder,再注入各个业务用例。

这一层次有三个实际收益。第一,单元测试可以注入内存 Recorder,直接断言“用户删除文章时产生了何种审计事实”,无需启动数据库。第二,存储实现可以从 PostgreSQL 迁移到独立审计存储,业务用例不需要改动。第三,领域用例仍掌握动作码、目标 ID 和前后状态等语义,不会把关键审计决策下沉到 HTTP Handler。

事务上下文是这项能力的关键约束

若 Recorder 自己新开数据库连接,业务更新成功而审计写入失败,或业务回滚而审计写入成功,都会产生不可信的记录。应通过事务上下文传递连接:Usecase 在 TxManager.Do 中执行,Repository 再从 context 或 Unit of Work 取得当前 transaction。审计 Repository 使用同一机制,因此天然加入调用方的原子边界。

这也解释了为什么审计应由 Usecase 调用,而不是由 Handler 或数据库 Trigger 单独完成。Handler 知道 HTTP 请求,但不知道业务状态转换是否真正成功;Trigger 看得到数据行,却往往缺少清晰的动作码、操作者、请求 IP 和 correlation ID。Usecase 同时掌握这些事实,才是最合适的记录点。

请求上下文与业务上下文如何汇合

HTTP Handler 的责任不是写审计表,而是提取并传递必要的请求元数据:客户端 IP、User-Agent 和可信的 correlation ID。用例命令携带这些值,在记录时再填入 RecordInput.Metadata;Recorder 则从 OpenTelemetry span 自动补充 trace_id

因此一条审计记录有两条关联线:correlation_id 便于业务请求之间的人工追踪,trace_id 便于进入可观测性系统查看调用链。二者不应互相替代,也不应让客户端提供的 correlation ID 覆盖服务端追踪身份。

机器身份是另一个不能遗漏的场景。审计主体不应只等于用户 ID:服务账号、异步任务、定时任务和匿名访问同样会产生需要追溯的操作。建议从一开始就采用 actor_typeactor_id、可选的 actor_display_name 三元组,例如 user/42service/order-workeranonymous/<request-id>。若旧表只有 actor_user_id,可以在兼容期同时写入,再迁移为通用主体模型。

Metadata 的价值与纪律

metadata JSONB 是平台的扩展点,而不是“把整个请求序列化进去”的逃生舱。适合记录的内容包括旧值与新值、关联对象 ID、原因码、审批单号和 correlation ID。例如权限调整可以保存 old_permissionsnew_permissions,状态变更可以保存 from_statusto_status

不适合写入的内容包括密码、token、Authorization Header、正文、身份证件、完整邮件地址,以及未经分类的大型输入。应在平台层提供字段白名单、大小上限和敏感键拒绝策略;否则 JSONB 很快会成为绕过数据治理的通道。metadata 编码或校验失败不能被静默吞掉:强一致审计应让事务失败,best-effort 审计至少应产生指标、告警和可检索的错误日志。

查询、写入策略与运维如何落地

审计表的默认查询模式不是“全文搜索全部 JSON”,而是基于一组稳定索引字段过滤。至少建立 (created_at DESC)(actor_type, actor_id, created_at DESC)(target_type, target_id, created_at DESC)(action, created_at DESC)(result, created_at DESC)trace_id 索引。查询 API 应只读,支持以下条件:时间范围、主体、目标、动作、结果、trace ID 与 metadata 中已批准索引的键。使用 keyset pagination,例如 (created_at, id),避免审计数据增长后 offset 分页越来越慢且出现翻页漂移。

一个典型接口可以是:

GET /admin/audit-logs?from=...&to=...&actor_type=service&actor_id=mailer
    &target_type=invoice&target_id=INV-2026-001&action=invoice.refunded
    &result=success&cursor=...

审计查询本身也应受严格权限控制,并记录“谁导出了哪些时间范围的审计数据”。普通管理员通常只能查看本租户或本业务域的数据;安全管理员可以跨域查询;原始 metadata 的敏感字段应按查看者权限脱敏。不要为管理界面暴露无限制的 JSON 条件查询。

写入策略需要显式分类。强一致审计用于资金、权限、配置、数据删除、审批和合规动作:审计写入失败即返回错误并回滚业务事务。尽力而为审计用于登录失败、限流拒绝、外部认证失败等即使数据库不可用也不应阻塞主流程的安全事件:写入失败应记录结构化错误、增加指标,并可投递到独立的安全事件管道。不要用同一种错误处理覆盖两类事件。

在容量与留存上,建议按月或按周对大表分区,将在线热数据保留在主库,将超过保留期但仍需取证的数据归档到受访问控制的对象存储或独立仓库。业务应用账号只拥有 INSERT 和受限 SELECT 权限,不应拥有 UPDATE 或 DELETE 权限;清理任务使用单独的受控角色。高合规系统可以为每批记录生成哈希链、签名或写入 WORM 存储,但这些机制应建立在正确的主体、时钟、访问控制和备份策略之上,而不是替代它们。

最后,为审计链路建立自己的服务等级指标:写入成功率、写入延迟、强一致回滚次数、best-effort 丢失数、metadata 拒绝数、查询延迟、分区大小和归档滞后。审计系统最大的风险常常不是一次显式失败,而是在无人察觉时停止记录。

结语

把审计日志提炼为平台能力,关键不在于创建一张 audit_logs 表,而在于建立一条清晰的责任链:业务 Usecase 定义事实,Recorder 提供稳定端口,Repository 复用当前事务,HTTP 与 tracing 提供关联上下文,数据库保存结构化且受治理的记录。

当这些边界建立起来后,新增一个领域不必重新设计审计基础设施。它只需要定义自己的动作码,并在真正完成业务状态转换的地方记录事实。审计日志也就从散落的埋点,成为后端项目可验证、可演进、可复用的平台能力。