出事那天,运维要在日志里看出"哪个接口先慢、哪个依赖先错、时间顺序对不对";审计要问的是"这条删除是谁做的、删了哪些行、依据是什么"。两个问题翻向同一个日志文件,结果两头落空:运维嫌它字段不够、看不清串行关系;审计发现关键那一行被采样掉了。
日志之所以经常同时被赋予这两种职责,是因为它是最便宜的"什么都写"的地方。但两种读者的判据方向相反:运维要时序与关联,审计要完整与不可改。一个要求"多写一行不亏",另一个要求"少写一行就废"。
1. 两个读者,两套判据
| 比较项 | 运维读者 | 审计读者 |
|---|---|---|
| 判据 | 能否还原时间顺序与因果链 | 能否独立复述"谁、在何时、对什么、做了什么、依据什么" |
| 时间尺度 | 事件前后几分钟到几小时 | 数月到数年 |
| 详细程度 | 越高越好,含上下文与堆栈 | 只需关键字段齐全,不需要上下文 |
| 对"多写一行"的态度 | 欢迎(可能有用) | 谨慎(写错一行即误导) |
| 对"少写一行"的态度 | 可接受(还能从别处拼) | 致命(缺口无法事后补) |
| 对采样 | 必需(量太大) | 毁灭性(被采掉的可能就是证据) |
| 保留期诉求 | 短而细 | 长而稳 |
| 可变性 | 无所谓,能查就行 | 必须不可篡改、可追溯写入者 |
两者的冲突不在格式,而在**"哪一行可以不要"**:运维可以容忍任何单行缺失,只要整体现象还在;审计不能容忍任何单行缺失,因为它无法证明"缺的那行不是被删掉的那行"。
共用一份日志时,被牺牲的几乎总是审计。原因不是有人偏心,而是两边失效的时间尺度不同:运维的缺口当天就会被人抱怨,于是当天就有人补字段、加检索;审计的缺口要等到下一次外部质询才暴露,而那时改动日志结构已经来不及,只能补一份"情况说明"。这份说明由当事人写,恰恰是审计最不认的那类材料。
2. 级别、采样、字段:三处相反的态度
<!-- 运维视角的采样配置:为了控量,采样率随流量自动下调 -->
<appender name="APP" class="...AsyncAppender">
<filter class="...ThresholdFilter"><level>INFO</level></filter>
</appender>
<!-- ─── 同一份 appender 一旦承载审计行,采样就等于对证据做抽检 -->
| 维度 | 运维要的 | 审计要的 | 同一份日志里的结果 |
|---|---|---|---|
| 级别 | DEBUG 在需要时打开 |
只保留操作事实,级别高低无关 | 调级别时审计行跟着被关掉 |
| 采样 | 必须,否则存储爆 | 禁止,任何比例都不可接受 | 采样率按运维定,审计行被连带采掉 |
| 保留 | 7–14 天足够定位 | 按合规期,典型以年计 | 长保留的那份被高详细度拖贵,最后被砍 |
| 字段 | traceId、耗时、状态、异常 |
操作者、目标、前后值、依据 | 两套字段都写则行宽翻倍,只写一套则一边失效 |
// 运维要的行:能串起来
2026-11-09T10:21:03Z INFO [trace=9f2a] order-pay cost=412ms status=200 downstream=inv
// 审计要的行:能定责
2026-11-09T10:21:03Z actor=u_8821 action=order.delete target=order:77120
before={status:PAID} after={deleted:true} reason=ticket:4412
// ─── 两行合成一行,通常两边都不合格:运维嫌冗余,审计嫌字段被截断
"审计字段平时没人看"正是它被砍掉的直接原因:它在正常日子里只增加行宽与存储,不产生任何可感知的价值;而它唯一的价值场景,是出事后你希望它还在。
保留期是这笔账里最容易被算错的一项,因为两个读者对"每天多少 GB"的敏感度不同:
// 同一个日志流,两种保留诉求的存储账(数量级按自己的日均量核对)
日均 50 GB × 14 天(运维) = 700 GB
日均 50 GB × 730 天(审计两年) = 36 TB
// ─── 决策通常落成"保留 30 天":运维嫌短但能忍,审计从头到尾没被问过
// ─── 正确的拆法:审计行只占总量的一小部分,单独按长保留存
3. 一份日志承担两种职责:三个具体失效
| 失效 | 机制 | 谁被牺牲 |
|---|---|---|
| 审计缺行 | 异步队列满、采样、级别过滤、日志轮转覆盖 | 审计 |
| 运维被淹没 | 审计行数量与业务写入同量级,翻页即疲劳 | 运维 |
| 保留期两头不讨好 | 按运维配 14 天(审计不够),按审计配 3 年(成本被砍到 30 天) | 两者同时 |
第一条的机制值得具体说:审计行往往在"写库成功之后"才打,而日志管道是异步的——队列满时丢弃策略是 discardOldest 或 discard,丢弃的恰好是峰值期那批,而峰值期也恰好是事故期。日志系统在压力下的行为,决定了审计在最需要它的时候最不可靠。
第二条的镜像同样真实:把 before/after 全量写进业务日志,单条日志从几百字节涨到几 KB,运维的检索与翻页成本随之翻倍,最后没人再用它排查——于是"有日志"和"能排查"变成两件事。
审计读者的另一条要求是写入后不可改,这一条与"运维随时调整日志级别"直接对立:
// 追加式写入 + 链式校验:任何删改都会让后续记录失配
AuditRecord r = AuditRecord.of(actor, action, target, before, after, reason, at);
r.prevHash = lastHash; // 与上一行绑定
r.selfHash = sha256(r.canonicalForm());
appendOnlyStore.append(r); // 只追加,不提供 update/delete 接口
lastHash = r.selfHash;
// ─── 校验失败不代表"没人动过",它代表"无法证明没被动过"
4. 分离的两种做法与各自成本
// 做法 A:同采集、双输出。一次写入,两个 sink,字段集不同
─── inputs: [app_stdout]
─── outputs:
─── - ops_index: 字段全量,保留 14 天,允许采样
─── - audit_index: 只留审计行(action 白名单),不采样,保留按合规期
─── relabel: 审计行带固定标记 audit=true,由写入侧打,不由采集侧猜
// 做法 B:双通道。审计不走日志框架,走独立的追加式写入
auditLog.append(new AuditRecord(actor, action, target, before, after, reason, at));
// ─── 与业务日志共用一套管道 = 共用同一套丢弃策略与同一套采样开关
| 做法 | 成本 | 失效风险 | 适用 |
|---|---|---|---|
| 同采集双输出 | 低,改动在采集侧 | 写入侧一旦没打标记,审计行整批漏 | 已有成熟日志管道 |
| 双通道(独立存储) | 高,多一套写入与保留 | 需要保证两条通道的写入顺序与失败处理 | 有对外承诺或受监管 |
| 单份日志加完整字段 | 最低 | 采样与保留期仍在,只是把缺口藏得更深 | 内部工具、操作人极少 |
无论哪种做法,有一条硬约束:关联键必须在写入时生成。traceId、操作序号、请求入口时间,事后无法补——日志里没有的东西,不可能通过分析得到。这也是"日志证明过程、账目证明结果"的分界:账目能告诉你金额少了,只有日志能告诉你少在哪一步;反过来,日志能告诉你谁点了按钮,账目才能证明钱去了哪。
5. 排错:五种现场
| 现象 | 真实原因 | 处理方向 |
|---|---|---|
| 审计要求的那一行查不到 | 采样、级别过滤或异步丢弃 | 审计行走独立输出且不采样 |
| 同一次操作在日志里有三条时间 | 多实例时钟未同步,或写入时刻与业务时刻混用 | 统一时间源,记录"发生时刻"而非"打印时刻" |
| 排查时无法串起调用链 | traceId 未跨线程/跨服务传递,或写入时才生成 |
在入口生成并透传,禁止下游另生成 |
| 保留期总被缩短 | 高详细度与长保留同时要,成本无人认领 | 拆两份:详细短保留、审计长保留 |
| 日志量与业务量同比例增长 | 审计行与业务行同粒度 | 审计只记状态变更,不记读取 |
度量四个量:审计字段完整率(关键字段非空比例)、采样误伤率(审计行被采掉的比例)、平均排查时延(从告警到定位根因)、保留成本(按 GB·天 折算)。前两个衡量审计读者,第三个衡量运维读者,第四个是两个读者共同的预算约束。
完整率可以直接用一次统计得出,不必等外部质询:
// 审计行的字段完整率:空值即缺口,缺口即下次质询的答案
$ zcat audit-2026-11-09.log.gz | awk -F'\t' '
$0 ~ /audit=true/ { total++;
if ($4 == "" || $5 == "" || $7 == "") miss++ }
END { printf "total=%d missing=%d rate=%.2f%%\n", total, miss, miss*100/total }'
6. 边界:什么时候可以只用一份日志
| 场景 | 结论 | 理由 |
|---|---|---|
| 内部工具、无对外承诺 | 一份带完整字段的日志即可 | 审计读者不存在外部质询,缺口可由人证补 |
| 操作人只有几个、动作可追溯 | 不加独立审计通道 | 责任人集合小,交叉验证成本低 |
| 状态变更已由业务表留痕(含操作者与时间) | 日志不必重复记录 | 结果侧已有事实,日志只补过程 |
| 事件量极小、检索靠人眼 | 不做双输出 | 双输出的维护成本高于缺口风险 |
| 无监管要求且有明确的数据删除承诺 | 短保留是正确选择 | 长保留与删除承诺直接冲突 |
一句话选择:**运维读者买"能查",审计读者买"不缺",两者的成本必须分开列账。**合并成一份时,被牺牲的几乎总是审计——因为它的失效只在出事那天显形,而那天没人有余力改日志结构。
自测
- 运维读者与审计读者对"少写一行"的态度,为什么必然相反?
- 异步日志队列在峰值期的丢弃策略,对审计意味着什么?
- 为什么"审计字段平时没人看"会导致它被优先砍掉?
- 采样对运维是必需、对审计是毁灭,根本差别在哪个判据上?
traceId为什么必须写入时生成,事后补不上?- 什么场景下一份带完整字段的日志就足够,不必分双读者?