审计日志写失败,业务要不要跟着失败?
后台管理、资金、权限这类系统,迟早要回答一个问题:“这条数据谁改的、改之前是什么、改了哪几个字段?” 业务表只保存当前值,答不了。通常做法是在业务表旁边落一张 operation log / audit log 表。
这类日志表最容易踩的不是“怎么查”,而是两个决定:
- 只存整份 before/after 快照,排查时人工比 JSON。
- 审计写入失败当普通写失败处理,没有明确策略。
下面是把这套逻辑落到 MySQL 表上时,我会先定下来的设计。
日志表只追加写入,不给 update/delete 权限
operation_log 的业务语义是“一条记录代表一次事实变更”。事实只能追加,不能被原地改掉。
- 表结构不设计
updated_at。 - 业务代码禁止
UPDATE operation_log/DELETE FROM operation_log。 - 生产环境单独建审计账号,只授
INSERT、SELECT,不授UPDATE、DELETE。
CREATE TABLE operation_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
trace_id CHAR(32) NOT NULL COMMENT '一次请求的 ID,可关联同一请求内多条日志',
entity_type VARCHAR(64) NOT NULL COMMENT '实体类型,如 order、inventory',
entity_id VARCHAR(64) NOT NULL COMMENT '实体 ID',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人或应用账号',
op_type VARCHAR(32) NOT NULL COMMENT 'create/update/delete 等',
diff_json JSON NOT NULL COMMENT '字段级 diff:[{"field":"status","before":"pending","after":"paid"}]',
op_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
prev_hash CHAR(64) NULL COMMENT '哈希链中上一条的 hash;首条为 NULL',
hash CHAR(64) NOT NULL COMMENT 'sha256(prev_hash + canonical(record))',
PRIMARY KEY (id),
KEY idx_entity_time (entity_type, entity_id, op_time),
KEY idx_operator_time (operator_id, op_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务操作日志,只允许 INSERT/SELECT';
日志数据增长快,单表撑不住时按 op_time 做月度分表或归档。索引不要多建,最常见的查询只有两类:
-- 某个实体最近所有变更
SELECT * FROM operation_log
WHERE entity_type = 'order' AND entity_id = '3001'
ORDER BY id DESC LIMIT 50;
-- 某个人在某段时间内做了什么
SELECT * FROM operation_log
WHERE operator_id = 'u_1001'
AND op_time >= ? AND op_time < ?
ORDER BY op_time DESC;
数据量大时,按 op_time 先收敛时间范围,避免第二个查询扫全月表。
trace_id 用来串一次请求
一个接口经常同时改多个实体,比如“更新订单”顺带“扣减库存”。如果只按实体记录,事后要看“这笔请求到底都干了什么”就很碎片化。
解决方式是每进来一次请求生成一个 trace_id,写日志时带上。同一次请求产生的多条日志 trace_id 相同,但每张业务表、每个实体各写一条。排查时直接按 trace_id 拉全链路:
SELECT * FROM operation_log
WHERE trace_id = ?
ORDER BY id;
落字段级 diff,不落整份快照
只存 before/after 整份快照,表面上“信息完整”,实际排查时要把两份 JSON 拉出来人眼对。改了一个状态字段,也要对着几十个字段找差异,很容易漏。
做法是在业务代码里拿到旧值和新值之后,写日志前先算 diff,只保留有变化的字段:
[
{
"field": "status",
"before": "pending",
"after": "paid"
},
{
"field": "amount_cents",
"before": 10000,
"after": 15000
}
]
diff_json 里就是字段名、旧值、新值三元组。查询“谁把状态从 pending 改成 paid”时直接能回答,不需要人肉对比。
需要配合字段类型比较时注意:金额字段最好用整型 amount_cents,避免 0.1 + 0.2 这类浮点误差导致的假 diff。
哈希链:让日志被改过这件事能被发现
追加写只能约束应用层,防不住有数据库权限的人直接改表。要防篡改,常用的做法是哈希链:
hash_i = sha256(prev_hash_i + canonical(record_i))
每条记录存两个 hash 字段:
prev_hash:上一条记录的 hash。hash:本条记录规范化序列化后,拼上prev_hash再算出的 sha256。
canonical(record) 是把某一行的内容按固定顺序、固定 JSON 序列化规则生成字符串。字段顺序变化会影响 hash,所以序列化时要先排序。
如果中间某一行被改,它的 hash 就对不上;即使重算这一行的 hash,下一行的 prev_hash 又会断裂。要整条链一起改,成本会高很多。
更强一点的不可抵赖做法是:每天把链头 hash 同步到外部系统,或做一次私钥签名。比如写到一个独立对象存储、另一个区域的最小权限账号里,作为外部对账锚点。
哈希链的代价也很直接:写日志必须串行。每条新日志都要知道上一条的 hash,多个业务实例并发写最后一条时会互相覆盖。批量落库只能攒一批后串行插入,不能多个 worker 并行写。
如果只是内部系统排障,不需要对外举证,哈希链可以不挂,保留 append-only 结构就够用。
异步写入:审计失败要有明确策略
不建议把审计 INSERT 塞进业务主事务里,否则每多一次日志写入,业务主链路就多一次数据库 IO。通常做法是:
业务操作完成后
-> 把审计事件投递到内存队列
-> 独立 worker 批量 INSERT 到 operation_log
真正要提前定死的是:队列满了、INSERT 失败后怎么办?
- 吞掉异常:业务正常继续,但这条审计记录从此消失,事后无从追溯。
- 返回错误:审计完整,但日志系统抖动可能连累正常业务一起失败。
这个选择没有标准答案,取决于业务合规要求:
- 资金、权限、合同这类强合规场景:审计不完整,宁可业务先停,也不能无痕。
- 普通后台排障场景:业务连续性优先,审计失败要立刻告警,而不是静默吞掉。
但无论选哪种,都要在日志写入前明确写下来,不能等 MySQL 抖动时在 catch 里拍脑袋决定。