一条错误日志在排查时"查不到"。开发确认代码里有 log.error,也确认走了同一个 logger——问题出在采集链路的第三段:容器日志驱动把行截断在 16KB,而这条日志带着完整堆栈,超出的部分被丢掉,于是在日志平台上它只剩第一行,关键字匹配也就失败。
日志从代码里的一行 log.error 到能在平台上被搜到,中间至少有六段。任何一段失败,表现都是"没有这条日志",而"没有日志"是最难排查的现象——因为你不知道自己丢的是哪一段。
1. 六段链路
| 段 | 环节 |
|---|---|
| 1 | 应用写 stderr/stdout 或文件 |
| 2 | 容器/进程日志驱动收集 |
| 3 | 采集代理读文件并解析 |
| 4 | 传输(缓冲、压缩、重试) |
| 5 | 平台入库(索引、分片) |
| 6 | 查询(时间范围、字段映射) |
六段的失败模式不同:第三段失败是"整批丢",第四段失败是"延迟",第五段是"能查到一部分",第六段是"存在但搜不到"。表现不同,排查入口就不同。
2. 每段的典型断点
| 段 | 断点 |
|---|---|
| 1 | 日志级别过滤、异步队列满 |
| 2 | 单行长度限制、驱动配置 |
| 3 | 路径不匹配、多行解析失败 |
| 4 | 缓冲溢出、重试窗口过短 |
| 5 | 字段类型冲突、索引被拒 |
| 6 | 时间时区、字段名不一致 |
六段之外还有两个"跨段"的东西:时钟与关联键。各段的时间戳可能来自不同机器,采集侧写的是采集时刻、应用侧写的是产生时刻,两者差几秒到几分钟;而 trace_id 如果在某一段被丢弃,跨服务关联就断了。
第二段的"单行长度限制"是最常见的隐形杀手:带堆栈的错误日志通常 20–60KB,而多数驱动的默认单行上限远小于此。
第四段的"重试窗口过短"很典型:平台抖动两分钟,代理重试三轮后放弃,那两分钟的日志永久丢失,而系统看起来一切正常。
// 采集侧:把容易踩的默认值显式写出来
filebeat.inputs:
- type: container
paths: ["/var/lib/docker/containers/*/*.log"]
max_bytes: 204800 # 200KB,默认往往只有 10KB
multiline:
pattern: '^\d{4}-\d{2}-\d{2}'
negate: true
match: after
output.logstash:
hosts: ["logstash:5044"]
bulk_max_size: 2048
backoff.max: 60s # 抖动时的重试窗口
3. 多行日志:最常见的解析断点
| 现象 | 原因 |
|---|---|
| 堆栈被拆成几十行 | 未配置多行合并 |
| 合并把多条日志粘成一条 | 行首正则太宽 |
| 合并后时间戳错乱 | 用行内时间而非采集时间 |
| 首行丢失 | 采集从中间开始(rotate 竞争) |
第三项(时间戳错乱)的机制值得说明:多行合并后,整条日志的时间取第一行的时间,如果正则把上一条的尾部也括进来,时间就会提前一条,导致"日志序号乱了"这类难以解释的现象。
第二项与第一项是一对:正则太窄则堆栈被拆,太宽则相邻日志被粘。稳妥的做法是让应用输出 JSON 单行日志,把多行合并这件事从采集侧移到应用侧。
// 应用侧输出单行 JSON:彻底绕开多行解析
{"ts":"2026-11-10T12:00:01.123Z","level":"error","msg":"pay failed",
"err":"...","stack":"at a.b.C(D.java:12)\n at a.b.E(F.java:34)"}
// 堆栈转义进一个字段,采集侧无需任何多行规则
4. 传输:缓冲与背压
| 参数 | 影响 |
|---|---|
| 缓冲大小 | 能扛多久的平台不可用 |
| 批量大小 | 吞吐与延迟的权衡 |
| 重试上限 | 丢失窗口的长度 |
| 磁盘缓冲 | 是否落盘(重启不丢) |
缓冲还有个"隐性成本":缓冲越大,丢失窗口越长。平台恢复后一次性推上去的几十万条日志,会把入库侧打满,反而造成二次积压——所以缓冲要配合限速,而不是单纯加大。
第四项决定"重启会不会丢日志":内存缓冲在容器重启时全部消失,而日志丢失恰好常发生在重启前后——也就是最需要日志的时刻。
// 断点自检:三段各问一句
1. 应用侧:有没有因队列满丢弃?(看 app 的 discarded 计数)
2. 传输侧:缓冲能不能扛住 5 分钟平台不可用?(内存还是磁盘)
3. 平台侧:入库被拒的条数是多少?(看 bulk rejected)
// 三个数都能查到,链路才算可观测
5. 入库与查询
| 问题 | 表现 |
|---|---|
| 字段类型冲突 | 部分文档被拒,日志"少了一些" |
| 索引模板不匹配 | 字段无法过滤 |
| 时间时区不一致 | 查某时段查不到 |
| 分片与保留策略 | 老日志被提前删除 |
排查这类问题有个固定动作:在平台的写入侧看 rejected 计数,而不是在查询侧看结果。查询侧只会告诉你"少了一些",写入侧才知道少了多少、为什么。
// 三处计数对齐,才能判断"丢了哪一段"
应用:logger 丢弃计数 (async queue full)
代理:read_lines vs sent_lines 的差
平台:rejected / mapping_exception 计数
// 三个数都对得上 → 链路无丢;任一处有差 → 该段是断点
第一项在动态映射的平台上很常见:同一个字段这天是数字、那天是字符串,后写入的文档被拒,而拒绝信息只在平台的写入日志里,采集侧看不到。
平台侧的保留策略也要与"什么时候还会被查"对齐:保留 7 天而复盘周期是两周,等于永远查不到上周的事故。这个数字应当由复盘习惯决定,而不是由存储预算单方面决定。
第三项是"存在但搜不到"的头号原因:应用写本地时间,平台按 UTC 索引,于是排查时按本地时间选窗口,永远差八小时。
// 验证一条日志能否被查到:端到端最小用例
logger -t probe "PROBE-$(date +%s)"
// 三步验证
kubectl logs <pod> | grep PROBE- # 段 1–2
tail /var/log/containers/*.log | grep PROBE- # 段 2–3
curl -s "$ES/prod-logs/_search" -H 'Content-Type: application/json' \
-d '{"query":{"match_phrase":{"message":"PROBE-"}}}' # 段 5–6
6. 排错:五种"日志查不到"
| 现象 | 段 | 处理方向 |
|---|---|---|
| 容器里有、平台没有 | 3/4 | 查代理路径与缓冲 |
| 平台有但只有第一行 | 2 | 提高单行上限 |
| 能查到但过滤不到 | 5/6 | 查字段映射与类型 |
| 某时间段整体缺失 | 4 | 查重试窗口与缓冲 |
| 重启前后的日志缺失 | 4 | 改磁盘缓冲 |
第四项要特别注意:"整体缺失一段时间"往往被误认为平台故障,实际是代理在抖动期间放弃重试。判定方法是看代理自身指标里的 rejected/retry 计数。
7. 边界:什么情况不必做全链路治理
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单机小服务 | 段 3/4 不存在 | 只保证段 1 |
| 日志量极小 | 丢一点影响小 | 保留基本采集 |
| 只用指标不用日志 | 无链路 | 不做 |
| 合规要求保留原文件 | 平台只是辅助 | 以文件为准 |
还有一条与"自检"有关的边界:端到端探针应当每天自动跑一次。一条带唯一标记的日志从应用到查询的完整链路,能在十秒内验证六段全通——这比事故时临时排查便宜得多。
判断口径一句话:这次排查如果丢了日志,会不会无法复原事故。会,六段都要有可观测的自检点;不会,前两段做好就够。
8. 自测
- 六段链路里,哪一段失败表现为"延迟",哪一段表现为"存在但搜不到"?
- 为什么"带堆栈的错误日志"最容易被单行长度限制吃掉?对策是什么?
- 多行合并的正则"太窄"与"太宽"各导致什么问题?为什么推荐让应用输出单行 JSON?
- 为什么"内存缓冲"在最需要日志的时刻最不可靠?该改成什么?
- "某时间段整体缺失"最可能的段与原因是什么?怎么和平台故障区分?
- 判断"是否需要全链路治理"的那个问题是什么?