很多团队都是在一个特别狼狈的下午,才开始正视“分布式日志系统”这件事的。我当时也一样。线上一个支付回调处理服务突然大面积报错,用户反馈“钱付了但订单没更新”,业务方和技术负责人全拉进群里。我打开跳板机,逐台登录那三十多台应用服务器,用 grep 和 less 去翻各自的本地日志文件。日志倒是有,每台机器上都有,但不同机器时间差了十几秒,服务之间的调用链要靠文件名和时间段硬拼,最后花了两个多小时才把问题根因拼出来:某个下游接口在连接池耗尽前返回了超时,而上游服务把超时错误当成了业务失败直接抛给了用户。
就是那个下午,我把“分布式日志系统实现”从愿望清单里挪到了必做列表。所以这篇文章不会去讲“什么是分布式日志系统”这种教科书概念。我会从一线项目推进的角度,讲清楚当时我怎么判断要不要自建、怎么设计链路、怎么选组件、怎么一步步写出一个能落地的实现,以及上线前踩过和演练过的坑。内容偏工程实操,适合后端开发、运维和架构师参考,也适合刚接触日志平台、想理清设计思路的人做路线图。
1. 先回答“要不要自建”:不是所有场景都值得从零搭一套
1.1 判断自研边界的三个标准:规模、检索复杂度、成本可控性
日志系统最容易犯的错,不是技术选型选错,而是压根没想清楚“为什么需要自己搭”。市面上现成的方案太多:买云厂商的日志服务、部署一套 ELK、用 Loki 搭配 Grafana,都能解决一部分问题。如果你们系统日活不高,每天日志量只有几十 GB,检索需求也只是“出错时去翻一翻”,那买云服务或部署一套简化 ELK 就足够,没必要自研。
我判断要不要自研,主要看三个标准。
第一是规模。当每天新增日志量到数百 GB 甚至 TB 级,存储成本和检索性能就成了硬约束,通用云日志服务按量计费的费用会让人肉疼;自建后可以用冷热存储、采样、TTL 等策略去控成本。第二是检索复杂度。如果查询诉求不只是“按关键字搜”,而是要按 traceId 把一次分布式请求的所有链路日志串起来、按业务维度做聚合统计、甚至把日志数据清洗后导入数仓做分析,通用日志平台往往很难覆盖。第三是团队已有的技术栈和运维能力。如果公司本来就有 Kafka、ClickHouse 或 Elasticsearch,那在已有组件上长出一层日志系统,边际成本并不高;如果要从零维护一套 Kafka + ES 集群,那还不如买云服务。
1.2 半自研才是最稳的演进路径:先做好“采集与检索”,再逐步加能力
当时我们团队盘点完发现,直接用 ES 搭 ELK 成本其实很高:需要额外维护三台 ES 节点、两台 Kafka、一台 Logstash,还要处理索引生命周期。而云厂商日志服务虽然能开箱即用,但跨服务追踪和自定义解析非常受限,数据出网也要额外收费。
我在实际项目里推荐的是“半自研”路径:底层存储用现成组件,上层采集和查询服务自研。第一版只做两件事——统一采集所有应用日志,提供按 traceId 和时间范围检索的查询接口。这样一个最小闭环只需要日志采集 Agent、一个消息队列、一套存储和一层查询 API。存储先用 ES 或者 ClickHouse 都行,后面我会单独讲这两个怎么选。等跑通了,再逐步加上告警、监控大盘、日志离线分析、数据脱敏这些能力。
这种演进方式有几个好处:不会一上来就因为周边模块拖累核心链路;每一阶段都能快速上线给开发用,能及时获得反馈;并且不会“做了一年还在搭平台,没有实际价值”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一条日志的完整生命线:先拆链路,才能定数据模型
2.1 从业务代码到查询结果,日志中间经历了六层
动手写代码前,我先把整条日志流动链路画了出来。一条日志从业务代码里 logger.info() 被调用,到最后能在页面上被搜索到,通常要经历六个环节:
| 环节 | 职责 | 典型组件 | 关键问题 |
|---|---|---|---|
| 1. 日志产生 | 应用进程内记录一条结构化或半结构化的文本 | 应用日志库(log4j2、logback、zap 等) | 是否带 traceId,是否 JSON 化 |
| 2. 日志采集 | 从本地文件或标准输出读取增量日志 | Filebeat、Vector、自研 Agent | 读取速度、断点续传、多行聚合 |
| 3. 数据缓冲 | 削峰填谷,解耦采集端和存储端 | Kafka、Pulsar 或 Redis Stream | 分区策略、消息大小限制 |
| 4. 解析清洗 | 把文本解析成结构化字段 | Logstash、自研 Consumer | 解析失败怎么处理、字段冗余设计 |
| 5. 存储检索 | 写入索引或列存,提供查询 | Elasticsearch、ClickHouse、Loki | 分片数、生命周期、冷热策略 |
| 6. 查询展示 | 对外提供 API 或 UI 界面 | Kibana、Grafana、自研页面 | 查询语法、权限控制、限流 |
图中最容易忽略的其实是“日志产生”这一环。很多团队把大量精力花在搭 Kafka、搭 ES 上,结果日志本身是一堆没有统一格式的字符串,到检索端发现根本没有可用的字段,只能把整条日志当文本去全文匹配,性能和效果都差。日志系统能不能发挥作用,一半以上取决于业务日志打得好不好。
2.2 统一字段字典:traceId、requestId、appId、host、env 一个都不能少
在设计数据模型前,我先定了一份整套系统必须遵守的字段字典,每种语言、每个服务打印日志时都必须带这些字段。这样做一是后续查询时可以按任意字段索引,二是跨服务排障时有统一关联键。
我当时要求的核心字段包括这些:
timestamp:日志产生时间,统一到毫秒级,用 ISO8601 格式带时区。traceId:一次外部请求从入口网关开始生成的全局唯一 ID,贯穿所有下游调用,存到 ThreadLocal 或 Context 里,由日志框架自动注入。requestId:一个服务实例内单次业务处理请求的 ID。如果没引入全链路追踪系统,至少要有这个。appId:应用唯一标识,比如order-service,用来区分不同服务。host:宿主机 IP 或 Pod 名,便于定位具体实例。env:环境标识,dev、test、prod分开,避免查询互相干扰。level:ERROR、WARN、INFO、DEBUG。loggerName:产生日志的类名或模块名。message:业务日志正文。
在落地时,为了降低开发接入成本,我提供了一个统一日志依赖。它做两件事:自动把 traceId 从上游透传的 Header 里取出并注入日志上下文;自动把上述字段组装成一个 JSON 字符串后再写盘。所以业务方打印日志的代码几乎不用改,logger.info("xxx") 照旧,只是底层输出格式变了。
2.3 为什么顺序是先定数据模型,再定技术栈
许多技术方案上来先选存储,然后再想数据怎么塞进去。这会带来隐患:日志是典型的分层结构数据,如果一开始不定清楚哪些字段需要建索引、哪些只是展示、哪些要聚合统计,后期做存储时就只能为“所有字段都能检索”买单——这在 ES 里会变成严重的索引膨胀,在 ClickHouse 里也可能因为乱建索引导致写入变慢。
我当时的做法是让查询需求驱动模型。统计了现有排查凭据后,发现几乎每次问题都要用 appId + timestamp 先缩小范围,再用 traceId 或 requestId 去捞完整链路。所以数据模型里就明确规定 appId 和 timestamp 是每个分区的第一排序键,traceId 是高频检索键。至于 message 里的业务细节,只做全文搜索,不做字段拆解,除非真的有结构化分析需求。这个决定后面在存储选型上帮我少走了很多弯路。
3. 链路组件选型:采集端、缓冲层、存储端分别怎么选
3.1 采集端不是“越强越好”,而是要能匹配你的部署环境
日志采集器的选择,决定了数据能不能稳定地从应用机器上流出去。当时我评估过 Filebeat、Vector、Promtail 和自研 Agent 四种方案。
| 采集器 | 优点 | 不适用场景 | 适合场景 |
|---|---|---|---|
| Filebeat | 轻量、稳定、断点续传成熟,和 ES 天然集成 | 需要复杂的数据清洗、跨服务关联 | 应用日志直接采集到 Kafka 或 ES,最省心 |
| Vector | 支持数据管道编排,可做解析、过滤、转发 | 配置较复杂,社区相对小 | 有多种数据源、需要做高度定制路由时 |
| Promtail | 和 Loki/Prometheus 深度集成 | 只能推给 Loki,生态锁定 | 已经用 Grafana/Loki 做可观测性时 |
| 自研 Agent | 可以完全按需控制读取、过滤、压缩、背压节奏 | 需要自己维护布丁、升级和故障恢复 | 对端到端链路有强需求且资源充足时 |
最终我们选择了自研一个轻量 Agent,原因主要是两个:一是多行异常栈合并规则、字段裁剪和脱敏都要定制,通用采集器也能写,但后期维护成本很高;二是我们希望采集端具备“客户端限流”能力,当后端存储异常时,采集器能把日志按级别做降级写入,而不是直接把进程拖垮。
3.2 Kafka 和 Pulsar 都不是唯一解,关键在于消费模型
消息队列是日志链路最重要的稳定器。应用日志的特点是流量毛刺非常严重,平时每秒几百条,遇到线上事故时可能就是上万条 QPS。如果没有缓冲层,写 ES 或 ClickHouse 的消费端会被直接打爆。Kafka 的设计天然适合日志这类超大数据吞吐的流式写入,而且能按 partition 保存一定时间的数据,消费端可以做灵活的重放。
当时没有引入 Pulsar,因为团队对 Kafka 运维已经比较熟,而且日志场景其实用不到 Pulsar 的多租户、V1/V2 分离等特性。日志系统的消费模型相对单纯:写是顺序追加,读是游标推进,Kafka 反而是最成熟的解。
如果不想引入 Kafka,也可以先用 Redis Stream 或 RabbitMQ 顶一顶,只适合量级比较低、对吞吐没有那么高诉求的场景。真要支撑每日几十 GB 到 TB 级日志,我还是建议上 Kafka。partition 分多少,取决于下游消费者的吞吐量和需要保证的顺序粒度。日志是允许全局乱序的,只要同一 traceId 或同一 host 内不乱序,分析时基本够用。我在最初方案里把 traceId 作为 key,让同一条调用链的所有日志落到同一个 partition,这样消费端可以用状态机按时间组合出完整调用路径。如果完全不用考虑追踪,按 appId + host 做 key 也可以。
还需要注意日志消息体大小。Kafka 默认单条消息上限是 1MB,但一条应用日志如果包含了完整调用参数或数据库 SQL,很容易超过几百 KB。我是建议在 Agent 端就做单条日志截断,把超过 64KB 的日志切掉多余内容,并且只保留 message 的前 64KB 和 metadata 字段,避免单条超大日志拖垮整个生产者、Broker 和消费者。
3.3 检索与存储的取舍:ES、ClickHouse、Loki 的定位完全不同
日志链路里最影响查询体验的是存储组件。做方案时我对比了三种,谁也不是万能的。
Elasticsearch 是日志检索的老牌选择,特点是全文检索能力和基于 Lucene 的倒排索引,对关键字搜索、正则、范围查询支持得非常顺滑。适合灵活检索、按任意字段过滤、日志即时可查的场景。问题是索引膨胀和写入吞吐受限于分片数,当写入量太大时,如果分片规划不合理,集群很容易出现写入拒绝或 yellow/red 状态。
ClickHouse 则适合“大量日志写入后做聚合分析”的场景。它是一种列式存储数据库,压缩比高,存储成本比 ES 低很多。你可以把它当作一个基于时间的日志仓库,查询时按时间分区扫描,性能在 PB 级数据下依旧能打。但它的全文检索能力相对弱,如果要搜索 message 里的任意关键字,需要上 LIKE 或 inverted index,灵活性不如 ES。
Loki 是一个轻量级的“日志索引”模式,它不对日志原文建全文索引,只为日志的标签建索引,核心是压缩存储日志流并按标签检索。在 Kubernetes 场景下用 Promtail 采集 Pod 日志后,拿它做简单过滤和定位非常适合。但它不适合复杂的聚合分析和毫秒级交互式搜索。
我当时给的选型原则很简单:查询以关键字搜索和 traceId 拉全链路为主,选 ES;查询以聚合分析、趋势统计、大跨度历史检索为主,选 ClickHouse;只想花最少运维成本看 Pod 日志,选 Loki。
按照这个逻辑,我们最终的主存储选的是 ES,旁边挂了一台 ClickHouse 做离线分析。业务日志写入 Kafka 后分成两路,一路给实时检索,一路做清洗入仓,点击“历史归档”时走离线链路。如果团队运维能力有限,我会更推荐 ClickHouse 单存储方案,因为同样数据量下它的存储成本和写入稳定性比 ES 更好控制。
4. 上手写一个最小可用系统:采集、缓冲、ACK 和背压
4.1 Agent 端核心能力:异步读、内存队列、批量上传
真正写代码时,我先把 Agent 的流程收窄成四个阶段:读取、解析、发送、确认。这里最核心的心法是“日志读取不能阻塞业务进程,日志发送更不能阻塞读取”。Agent 采用双缓冲结构,主线程读文件或 stdin 后放入内存队列,发送线程以批量方式把队列内容取走,这样就把磁盘 IO 和业务日志的写入操作分离开来。
队列既要设上限,否则应用打印日志过于密集时,Agent 内存会膨胀到不可控。当时我设的是 4096 条缓冲,超过之后走一个丢弃策略:只保留 WARN/ERROR 级日志,INFO/DEBUG 直接丢弃,同时累计丢弃计数上报。这样即使发生突发流量,最多也只是丢一部分低优先级日志,不会把系统本身拖垮。
如果日志源是文件,读取模块需要记录当前读取到的文件 offset。只在内存里记不够,因为 Agent 会重启。我当时就踩过这个坑:Agent 第一次上线没有做 offset 持久化,结果重启后导致大量日志重复推入 Kafka。后来实现了每一分钟把读取位置同步到本地 .offset 文件的机制,重启后先从文件恢复,保证了“至少一次”语义下不会出现无上限的重复发送。
Agent 向服务端发送时,最重要的是批量。批量越大吞吐越高,但超过一定阈值会带来延迟问题。我经过实测后选定的策略是:攒够 256 条或等待 2 秒,两个条件先到即发,然后开启 gzip 压缩,压缩后日志体量能降到原来的三分之一左右。
4.2 接收端高并发写入的防护:限流、幂等、隔离
Agent 发过来的日志,如果直接写 Kafka,遇到突发峰值时 Broker 端问题不大,但客户端和服务端之间的网络以及序列化可能成为瓶颈。接收服务其实就是一个 Proxy,它只做接收和转发,不在这个节点上做任何解析和转换。每个请求必须校验 Agent Token,并把批量日志按 appId 写入对应 topic。这里有个容易漏掉的注意点:如果接收端和 Kafka 之间的距离较远,建议把 gzip 压缩放在 Agent 端做,而不是接收端做。因为网络传输的消耗往往大于压缩和解压的消耗。
因为系统要支持重试,所以每条日志都要带一个全局唯一的消息 ID。Kafka 本身不负责去重,消费端做幂等有几种常见做法:用 ES document ID 做去重、ClickHouse 里用 ReplacingMergeTree 业务键去重、或者只在消费端缓存最近几秒的消息 ID 并在写入前过滤。真实场景里 Kafka 重复消息无法完全避免,我通常用 Redis 布隆过滤器加最近消息 ID Set 的方式做近实时去重。
需要提前隔离的其实是“大应用”和“小应用”之间的互相挤占。最开始我们只建了一个日志 topic,全公司的日志往一个消息通道里灌,一次订单服务的高频日志,能让全公司其他服务的日志消费延迟从几百毫秒涨到几十秒。后面改成按 appId 拆分多个 topic,消费端也对大流量应用设置单独的消费线程池,问题的隔离性才好了很多。
4.3 服务端到存储层的背压处理:宁可丢低优先级,也不能雪崩
日志系统尤其要处理的问题是存储层写入变慢。ES 集群有时会因为 merges、GC 或磁盘 IO 波动出现写入拒绝;ClickHouse 也会有 Too many parts 等问题。偏偏日志系统最不能接受的就是:在故障时想查日志,结果整套日志系统已经雪崩了。
所以我在消费端做了一套背压机制。消费端消费到日志后,先在本地攒一个小批次,比如 2MB 或 2 千条,再一次性写入存储引擎。如果写入失败,进入“存储降级模式”:对 ERROR 日志进行无限重试,并一直阻塞;对 INFO 和 DEBUG 日志则进入重试队列,队列满后直接丢弃。同时把这个降级事件上报给监控。这样设计的理论基础是:出故障时,高价值日志要尽量保,低价值日志要优先弃。
4.4 消费端并发模型与顺序保证
消费端拿到 Kafka 消息之后,能否并发解析并写入,影响到吞吐量。最初的实现是每个 partition 单线程消费,因为单线程能保证每个日志在同一个 traceId 下的顺序。但日志量大时,单线程处理已经不够,后来我用动态线程池来优化:先按照 traceId 的哈希值分桶,每个桶内单一顺序,桶之间并发。这样即保证了同一条请求链路的日志相对有序,也提升了吞吐。实践中这个方案在 24 个 Kafka partition、每个 partition 4 个并发槽的情况下,能把消费速度提升近六倍,却不会打乱核心顺序诉求。
Kafka 的 enable.auto.commit 我建议设置为 false,采用消费成功后手动提交 offset 的方式。否则一旦日志入库成功但 offset 没提交,消费者重启后会重复消费大量数据;反过来如果先提交 offset、再入库,存储抖动时则会丢日志。权衡之下,日志系统宁可重复,不能丢,所以顺序是先成功写入存储,再手动提交 offset。
5. 从“一条文本”到“一个可检索的字段”:解析、聚合和索引策略
5.1 日志格式的设计:优先保证可机器解析
如果应用打印的日志不是 JSON,而是可读性很强的字符串混排版,到存储端解析会非常痛苦。做这个系统时,我在技术组里强推了 JSON 单行格式。例如:
code复制{"timestamp":"2025-05-11T12:00:00.123Z","traceId":"a1b2c3d4E5F6","requestId":"abc","appId":"order-service","host":"10.0.1.12","env":"prod","level":"ERROR","loggerName":"com.example.OrderService","message":"update order failed","stackTrace":"java.lang.Exception: timeout ..."}
业务方其实不用手动写,因为统一日志库已经定好了这个格式,他们调用 logger.warn 即可。JSON 风格的优点是既有可读性,能直接被 ES/ClickHouse 解析,又不容易因日志里有空格、换行导致多行拆分问题。
多行异常栈是另外一个麻烦事。一次 Java 异常常常占据十几行,如果不对它们做一次合并,到查询端会被拆成十几条独立日志。Filebeat 这类采集器有 multiline 能力,配置也比较复杂;我自研 Agent 时是采用“行缓冲匹配”策略:当检测到新日志行的起始字符是 { 或符合 ISO8601 时间格式时,判断为新的日志开始;如果行首是空白或 at xxx.java:xxx,就把它追加进当前缓冲的日志正文里。这样一份异常堆栈就能完整保留在一份日志记录中。
5.2 解析策略:JSON 和正则混用,解析失败走死信
消费端拿到日志后,第一步是尝试按照统一 JSON 格式解析。如果解析成功,所有 metadata 字段直接提取;如果解析失败(比如有些历史版本应用还没升级日志库),则套用一组降级正则,提取日志级别、时间、loggerName,message 部分保留原始文本。
这个过程中需要处理的细节是,解析失败不能阻塞主链路。每条解析失败数据我会打上 parse_status: failed 字段,写入一个名为死信的 topic 或存储表。这些死信数据可以帮助发现哪些服务还没有升级规范、哪些应用打印了非标准内容,排查时仍然可以把原始文本拉出来人工观察。定期通过 dashboard 查看解析成功率,会发现大概两三个星期后主流服务都能达到 99.9% 以上的成功率。
5.3 ES 索引的规划:按天分片、别名查询、生命周期管理
如果用 ES 存储,最佳实践是按天建索引。日志有非常明显的时间属性,几乎不会有跨几个月更新一次的需求,所以按天建索引既能保持索引体积可控,又便于过期数据清理。索引名可以设计成 logs-{appId}-{yyyy.MM.dd} 或统一为 logs-{yyyy.MM.dd} 再通过一个 appId 字段区分。我的建议是不要拆太细,每个应用一个索引会导致小索引过多,集群分片管理开销反而更大。
索引创建时需要预先定义号 mapping,避免非结构化数据中的随机字段造成字段爆炸。ES mapping 关闭 dynamic: true,会拒绝未知字段;改为 dynamic: false 或 dynamic: runtime 更安全。对于高频检索字段加倒排索引,日志内容跑全文。典型 mapping 里,traceId、requestId、appId 用 keyword 类型,host、level 用 keyword,timestamp 用 date,message 用 text 并挂 standard analyzer。
字段过多会增加索引膨胀和写入开销。有些字段如果不检索,只做展示,就把它们设置成 index: false;否则所有字段默认建倒排索引,最终是给集群塞满无用索引,查询性能不升反降。
按天建索引之后,查询时要用别名而不是直接查具体索引名,比如 GET logs-query-2025.05.11/_search,否则应用侧代码要跟着日期变化而变。我们实现里的查询服务会自动拼出当天或者用户所选时间范围内的索引列表去轮询,时间跨度超过三十天直接在索引名层被拦截,如果需要访问冷数据则走另一套归档路径。保留期限以需求为准,热数据保留 15 天就走 ILM 策略冷到低配节点,30 天之后从 ES 删除;需要长期留存的离线归档进 ClickHouse。
5.4 如果选 ClickHouse,表引擎怎么设计
如果你选择 ClickHouse 作为存储,这里提供一个可以照爬的建表思路。表结构沿用统一字段字典,额外加上 JSON 原始内容列,排序键设计为 (appId, timestamp),分区用 toYYYYMMDD(timestamp) 按天分区。这能让最常见的“指定应用 + 时间范围”查询,通过分区裁剪有效减少扫描量。
表引擎有三种通常选择。MergeTree 是最基本的选择,能支撑日志存储;如果消费者有重复写入概率,用 ReplacingMergeTree 按消息 ID 去重更适合日志的可重放场景;如果怕去重状态很大,也可以采用简单 MergeTree 表,配合消费端 Redis 去重。写入方面,大批量使用异步写入,每批 2000 到 10000 条,插入间隔不小于 500ms。ClickHouse 在乎的是每次插入的段落数量,如果过于频繁地两三条一插,会导致后台 merge 跟不上,出现 Too many parts。我之前在一次压测里见过类似情况:每秒设置 10 个并发循环,每个循环只插入一条,写了不到十分钟就报错,改为分批写入后异常彻底消失。
6. 上线前,我建议你亲手做一轮“日志系统故障演练”
6.1 不丢数据的底线怎么压测出来
日志系统上线前,光看功能跑通是不够的,要对系统的不丢数据底线做确认。日志场景里端到端链路长,任何一个进程重启,都可能丢一小段时间的数据。
我用的一个有效验证方法是“环形写号验证”。选一台测试服务器,让应用打出带自增序号 seq-000001、seq-000002 这样的日志;Agent、Kafka、消费端、ES 都部署好以后,开始持续写入;我在不同的时间点做动作:先重启 Agent,再重启 Kafka broker,再把消费端进程直接 kill -9,等 30 秒后启动,然后用查询接口拉取最终写入 ES 的日志,按序号排序,检查缺失率。实测中,如果 offset 提交逻辑没做好,大概率会出现数百到数千条重复或缺失。这个验证方法能直接检验“至少一次”语义的实现效果。
除了杀进程测试,还要测“磁盘写满”和“网络隔离”这两个不太容易模拟但最容易发生的故障。最简单的验证方式是给 Agent 的日志缓冲目录挂一个配额,然后模拟文件系统写满;正常设计下,Agent 感知到磁盘空间不到阈值即暂停读取,不再从原文件搬运数据,保留原始文件,等磁盘恢复后再继续。网络隔离更难判断,却更值得模拟。当 Kafka 完全不可达时,Agent 本地既然做了内存缓冲,就一定还需要一层磁盘缓冲,因为我们测试时发现,网络中断半小时后,内存队列会严重堆积,最后被迫丢弃。于是我把 Agent 加了本地磁盘 spill 目录:发送失败时队列先写临时文件,网络恢复后再读取补发。这样网络抖动时也不会丢太多日志。
6.2 压测时最该盯的四个指标
在流量还没放大时,压测通常会暴露一批你之前没有意识到的问题。设计压测场景时,不要只盯着写入吞吐,建议同样关注这四类指标。
- 端到端延迟:从一条日志写入到它在 ES 能被检索到的时间,通常要求 5 秒内延迟。延迟超过 30s 基本等于不可用。
- 写入端丢弃率:Agent 因队列满而丢弃的日志条数。正常运行时要求 0 丢弃;故障降级时允许丢弃低于级别的日志。
- 存储写入失败率:ES 拒绝写入或 ClickHouse 报错的比例。理想状态是 0,如果存储写阻塞,启动背压后最终仍是 0 失败率。
- Kafka lag:消费者的消费速率是否跟得上生产速率。如果 lag 持续上涨,说明消费端或存储写入是瓶颈。
我压测时发现过一个问题:日志在 Agent 端压缩后,打开 Kafka 的 topic 数据压缩,结果 CPU 消耗反而上涨了一大截。后来定位到是双重重压,Kafka broker 对已经 gzip 压缩过的数据段再次做 lz4 压缩,纯属浪费 CPU。关闭 broker 端压缩或只在生产端压一次,CPU 下降了近 40%。写代码时如果不实测,这些隐藏成本根本不会发现。
压测样本建议用真实业务日志,不要去造一些干干净净的 fake 日志,因为真实日志平均一行 0.5KB 到 2KB,且夹杂很多大字段;用 fake 日志容易得出一个漂亮但不可靠的压测结论。日志峰值倍数建议按日常平均 QPS 的 10 到 20 倍设计压一版,观察系统的毛刺吸收能力。
6.3 容量评估与成本估算的公式
如果没有人问过你“这套系统跑一年要多少钱”,这个项目还不算真正立项。日志系统存储费用是最大变量。我当时先用了一个简单的容量估算公式:单日新增量 = 日QPS × 高峰倍数 × 平均单条日志大小 × 86400秒。假设平均 2000 QPS,峰值是平均的 10 倍不太可能持续,粗略按日总量约 400 GB 计算,如果保留 30 天,约 12 TB 原始数据。ES 加上副本和倒排索引的膨胀,通常要乘以 2.5 到 4,实际占用可能到 30 TB 到 50 TB。ClickHouse 列式存储加压缩,大约可以降到原始数据的三分之一到十分之一,也就是 4 到 8TB。
这里就能看得到两种存储路线的巨大成本差。也是为什么很多团队最终选择双写方案:让热数据在 ES 保留 7 天,其余数据落到 ClickHouse。如果你们公司云资源紧张,我会更推荐 ES 只做 3 天、剩下的全部走 ClickHouse,日志的实用价值随时间的衰减非常快,大多数问题复盘都在当天。
7. 写在最后:那些“不写文档就不会注意”的实战细节
项目收尾阶段,很多琐碎的坑会被忽略,但它们往往决定整套系统能否真正在团队里被用起来。
第一件事是时区问题。有一次测试环境里,开发反馈“日志里的时间比实际早八个小时”。排查后发现,日志字段里的时间来自业务字符串,而不是框架注入的 ISO 时间,而业务服务器的默认时区被设置成 UTC。解决办法很直接:规定任何时间字段在打印前都转成带时区的 ISO8601 格式,统一用 UTC 存储;展示时按用户的浏览器时区转换,而不是各服务用本地时间写日志。后来再也没有出现过这种“看着乱了”的问题。
第二件事是日志脱敏不能放到存储之后才做。某个请求日志里包含了用户手机号,在导出到离线分析时才发现敏感信息问题。日志系统设计阶段没有把脱敏考虑到数据链路里,导致原始数据早已流出去了。后来把脱敏统一放在 Agent 端和消费端两个环节:Agent 端用正则识别手机号、身份证、银行卡,匹配到的字符替换成 ***;消费端对几个高风险字段再做一道兜底。这个兜底位置很重要:应用侧可能一行日志里有多种不规则敏感信息,绝不能只依赖 Agent 规则。
第三件事是要给日志查询接口做限流。日志系统做得越好用,来找日志的人就越多。后来甚至有人拿日志查询接口去扫业务数据,query 可能把 ES 集群瞬间打满。现在每套 API 都要求按应用鉴权,并提供按 appId、时间范围、结果条数的限制,超限直接报错,避免“日志系统变成故障的来源”。
第四件事是业务同学和开发同学使用日志的方式完全不同。开发想搜异常堆栈,看 traceId 链路;业务想要的关键字段是订单号、用户ID、支付单号。所以在日志中规划业务维度字段是很有必要的,比如在消息正文里把 orderId=12345 这种键值放在统一位置,或专门加一个 bizParams JSON 字段。做日志平台时多听业务方的查询需求,能显著提高这个系统的认同度。
回顾这套系统的落地过程,我最大的体会是:分布式日志系统本质不是复杂的技术系统,而是把日志当作数据产品来设计——定义字段、保证可靠传输、控制成本、设计查询交互。技术选型反而是最不费力的一步,更难的在于想清楚你的团队到底愿意为日志付出多少运维成本。如果决定自建,不要一上来就铺开所有特性,先把“采集 - 缓冲 - 存储 - 检索”这条主链路做硬,把丢数据、雪崩、存储膨胀这三个核心问题提前用故障演练验证掉。上线之后再慢慢加,你会发现这个系统在关键时刻真的能救命。
