资讯动态

自建分布式日志系统:ClickHouse与Kafka全链路实践

发布时间:2026/10/23 3:29:41 来源:尧图企业网站定制
1. 整体设计与技术选型思路1.1 为什么需要自建分布式日志系统先聊个实在的问题为什么放着现成的方案不用非要自己搭一套现在市面上的日志系统其实不少开源的ELK全家桶、Loki、SigNoz云厂商的日志服务也都挺成熟的。但分布式日志系统这个需求往往不是“有日志就行”这么简单而是要在一个复杂环境里做到“日志什么时候都能查到、高峰期不丢不堵、排查问题能快速定位”。我这次做这套系统背景是某平台的微服务架构已经铺开了服务实例加起来几百个一天产生的日志量在TB级别。之前用的方案是每台机器本地落盘出了问题再登到机器上翻文件。初期确实够用但服务一多、调用链一长这种方式的几个致命问题就暴露出来了日志散落在各个机器上查一个问题要登录好几台服务器效率极低磁盘空间有限日志保留周期短经常出现“想查的时候文件已经被覆盖”的情况没有全局视角流量高峰时段某个服务到底发生了什么根本说不清楚。所以自建这套系统的出发点很简单把分散在各个节点的日志集中起来统一存储、统一检索、统一告警。这套系统的目标不是做一个“日志盒子”或者“集中展示面板”而是要能支撑日常调试、线上问题追踪、监控告警、以及业务数据分析的多功能基础设施。当然也要说明一下自建这套东西不是所有场景都合适。小规模项目直接上云服务或者单机ELK就够了自建分布式日志系统的成本主要在运维和架构设计层面。我这次的判断标准是日志量日均过亿、查询频率高、需要多维度检索、对数据保留周期有明确要求满足这些条件才值得投入做一套完整的系统。1.2 技术栈选型与方案取舍选型这块是整套系统里最纠结的部分因为每一条路都有自己的优势和坑。我先对比了一下主流的几套方案再结合自己的场景做了取舍。先说存储选型。日志系统的核心其实是存储引擎采集和展示倒是次要的。市面上常见的选项有Elasticsearch、ClickHouse、Loki以及直接用KafkaHDFS这种偏批处理的方案。Elasticsearch是最经典的选择生态成熟Kibana做可视化非常顺手全文检索能力强。但它的问题在于数据量大以后索引膨胀、内存吃紧集群运维成本直线上升。几百GB的日志量还好到了TB级别ES集群的节点数、分片规划、冷热分离都得专门投入精力去打理。ClickHouse是我最终选择的方案。因为日志数据有很明显的特征写入量大、更新极少、按时间范围查询多、聚合分析需求多。这正是ClickHouse擅长的场景列式存储加压缩写入吞吐极高查询响应也快。加上它单机就能扛很大的数据量前期不需要搭特别大的集群对运维的压力小很多。Loki的思路是只索引元数据不索引内容存储成本很低适合K8s环境下的轻量级日志。但它的问题在于全文检索能力弱复杂查询支持不够好。对于只需要“看日志内容”的简单场景够用但一旦要做统计分析类的查询就很吃力。采集端的选型相对简单Filebeat和Vector是主流选择。Filebeat轻量、稳定、资料多但配置相对死板Vector性能强、支持度高配置灵活缺点是资料相对少一些、调试成本高一些。我这次选了Vector主要是看中了它对多源输入和复杂转换的支持后面做日志解析和字段清洗会方便很多。传输层直接用Kafka这个基本没悬念。Kafka的高吞吐、持久化、多消费者订阅特性天然适合作为日志系统的缓冲层。日志采集端不直接写入存储而是先打进Kafka再由消费端写入ClickHouse这样即使存储端挂了也不会丢数据。整个链路是应用实例采集 → Vector → Kafka → 消费写入 → ClickHouse → 查询API → Web界面/告警通知。1.3 架构设计背后的逻辑架构上看起来是经典的数据管道模式但实际上有几个关键决策点值得展开说说。第一是为什么中间非得插一个Kafka而不是让采集端直接写ClickHouse。直接写入当然简单但会带来两个问题一是采集端和存储端耦合存储端抖动会影响采集端导致应用日志阻塞二是峰值流量处理不过来ClickHouse批量写入性能最好如果采集端逐条写入吞吐根本打不上去。有了Kafka做缓冲采集端只需要把数据发到Kafka消费端按自己的节奏批量写入两边互不影响。第二是为什么日志要分成实时链路和离线链路两条。实时链路承担查询和告警要求延迟低离线链路承担统计分析和长期归档要求吞吐大、成本低。两条链路从Kafka里各取所需消费逻辑完全独立互不干扰。这个拆分在初期可能看不出多大价值但日志量上来以后实时查询和跑批任务之间的资源争抢问题会非常突出提前拆开能省掉很多麻烦。第三是整个系统的状态监控。日志系统本身也是分布式系统如果它挂了没人能查到日志来定位它为什么挂。所以必须给日志系统做健康检查采集端心跳、Kafka消费积压监控、ClickHouse查询延迟监控以及独立的告警通道。这个监控体系和被监控的业务系统分开避免“监控系统出问题导致无法感知日志系统故障”的尴尬局面。2. 采集层与传输链路搭建2.1 日志采集Agent的部署与管理采集层是整个系统的入口它的稳定性和性能直接决定后面所有环节。我先说Agent这块怎么做再讲格式规范和踩过的坑。采集Agent我选择的是Vector按照服务实例的部署方式进行分布。Kubernetes环境下用DaemonSet方式部署每个节点一个Agent传统虚拟机直接部署的话每个实例装一个Agent进程。两种模式都验证过整体差别不大核心的是配置管理。Vector配置里比较核心的部分是source、transform、sink三块的组合source定义日志从哪里来常见的是文件系统读取或者K8s容器的stdouttransform负责对日志进行解析、过滤、字段提取sink定义往哪里发一般是Kafka的某个topic。以文件采集为例一段简化配置长这样[sources.file_source] type file include [/var/log/app/*.log] read_from beginning [transforms.parser] type regex_parser inputs [file_source] pattern ^(?Ptimestamp\S) (?Plevel\S) (?Pservice\S) (?Pmessage.*)$ [sinks.kafka_sink] type kafka inputs [parser] bootstrap_servers kafka-1:9092,kafka-2:9092 topic app-logs encoding.codec json这里有个容易被忽略的点read_from beginning。如果是新部署的Agent第一次启动时会从文件头开始读可能会把历史日志全部灌进Kafka造成大量重复数据。更合理的做法是第一次部署时设置为end后续再改回beginning或者直接依赖Vector的checkpoint机制让它记住读取位置。Agent本身的资源占用也要关注。每个实例上跑一个AgentCPU和内存都会有一些消耗尤其是日志量大的时候正则解析是典型的CPU密集操作。我的建议是给Vector设置资源上限比如CPU最多用1核、内存最多用512MB超出部分让它排队等待。2.2 日志格式规范与结构化改造这是整个系统里投入产出比最高的一件事没有之一。如果日志格式不统一后面所有解析、查询、告警规则都要围绕着“猜格式”来做累死人不偿命。我在这次项目中定的规范是JSON结构化日志。每个服务输出的日志都要求是单行JSON包含这些固定字段timestampRFC3339格式的时间戳精确到毫秒level日志级别debug/info/warn/errorservice服务名trace_id链路追踪ID用于关联一次请求跨多个服务的日志message日志正文host主机名或者Pod名extra额外的业务字段可以是嵌套JSON。之所以要求单行JSON而不是多行是因为多行日志在采集端处理时非常容易出问题。一条日志被拆成多行Agent要花额外的状态去拼接拼接错误的概率也不小。单行JSON可以做到“一行就是一条完整日志”采集端不需要做任何状态维护直接按行读取、按行发送。这里用生活类比解释一下多行日志就像一个人说话说了三段录音的时候分成三个文件听的时候还得自己拼。单行JSON就是每句话单独录一个文件每个文件的上下文都完整处理起来自然简单得多。这个改造对开发者来说是一次性成本但换来的是后续的永久的省心。改造过程会碰到一个比较棘手的问题历史代码里的日志都是字符串拼接的改成JSON结构化日志要动到日志框架的配置和调用方式。我当时的做法是统一封装一个日志工具库对外提供类似logger.info(msg, fields)的接口内部自动把fields和msg打包成JSON字符串。各服务只替换调用方式不用关心底层实现。2.3 Kafka主题设计与容灾机制Kafka这一层看着简单但主题设计是有讲究的。如果所有日志都进一个topic虽然省事但不同服务的数据混在一起消费端要做大量过滤分流效率很低也没法独立设置不同topic的保留策略和消费优先级。我的做法是按服务分topic再按日志级别做二次分流。具体来说topic命名规则是log.{service}.{level}比如log.order.info、log.order.error、log.payment.warn。这样有几个好处error级别的日志量通常远小于info可以单独消费、单独告警、单独保留更长时间某个服务的日志量暴涨时不会影响其他服务的消费进度消费者可以按需订阅多个topic灵活组合。分区数的设置也别随意拍脑袋。分区数要跟消费者的并发度匹配分区太少消费者并行度上不去分区太多Kafka的元数据开销变大。我每个服务topic至少设置6个分区最多12个消费者组里跑4到6个实例基本能做到吞吐和资源占用之间的平衡。容灾这块Kafka本身的多副本机制要用上副本数设为3。日志数据丢了就真没了多副本带来的磁盘浪费在这个场景下完全值得。还需要给broker设置好磁盘告警阈值磁盘写满后Kafka会停止接收新数据采集端会因为写入超时不断重试但这总比日志丢了强。3. 存储层与查询链路实现3.1 ClickHouse表结构与存储参数设计ClickHouse虽然是列式存储但表结构设计对查询性能的影响极大。我在这次项目中踩过几个坑最初按ES的思路设计表结构结果查询响应完全没达到预期。后来按照ClickHouse的最佳实践重新设计才把性能拉上来。先说表引擎的选择。日志场景用MergeTree家族我选的是ReplacingMergeTree。日志数据原则上不做更新但有的时候消费端重放Kafka消息会导致重复数据ReplacingMergeTree可以在后台合并时去掉重复记录。这里注意去重不是即时的合并是异步的所以查询时可能还会看到重复数据需要用final关键字或者在做查询时用聚合函数规避。表结构的关键字段设计如下CREATE TABLE logs.logs_all ( timestamp DateTime64(3) CODEC(Delta, ZSTD), level LowCardinality(String), service LowCardinality(String), trace_id String CODEC(ZSTD), message String CODEC(ZSTD), host LowCardinality(String), extra String CODEC(ZSTD) ) ENGINE MergeTree() PARTITION BY toYYYYMMDD(timestamp) ORDER BY (timestamp, service, level) TTL timestamp INTERVAL 30 DAY;几个设计点的解释timestamp用DateTime64(3)保留毫秒精度日志查询经常要精确定位到毫秒级level和service用LowCardinality因为这两个字段的取值种类少但重复度高低基数类型能显著减少存储空间并加速查询message和extra用ZSTD压缩日志正文重复度极高压缩比能做到10倍以上排序键是(timestamp, service, level)查询通常按时间过滤再加服务名条件这个顺序能最大化过滤效率。分区按天来主要是为了TTL删除方便。ClickHouse删除数据不是按行删除而是按分区删除。按天分区30天后直接丢弃过期分区代价极小。3.2 从Kafka到ClickHouse的写入管道消费Kafka写入ClickHouse这步有现成的组件比如ClickHouse官方提供的Kafka引擎表也有像Vector这个采集端自带的ClickHouse sink。但我这次是用自研的消费服务来做的原因有三个一是方便做批量大小的动态控制二是可以精确控制Kafka的offset提交时机三是在写入失败时可以做更精细的重试和分流。消费服务的核心逻辑不复杂但要处理好几个边界条件批量写入的节奏控制。ClickHouse对批量写入的性能远优于逐条写入我把每批大小设为2万条或者5秒攒一批哪个先到就触发写入。这样在流量低谷时延迟不高在流量高峰时又能保证高吞吐。失败重试的策略。写入失败分两种情况ClickHouse暂时不可用数据要留在Kafka里重试数据本身有问题比如字段类型不对这种如果一直重试就会卡死整个消费队列。我做了个“隔离队列”机制一条数据连续失败3次就单独隔离不阻塞主流程。数据清洗在消费端做而不是采集端。采集端只负责采集和简单解析复杂的字段校验、补全、过滤都放在消费端。这样如果后续调整清洗逻辑只需要重新部署消费服务不需要去动遍布全网的采集Agent。一个思考点点击House都做了哪些优化分享一个最实在的经验就是在写入并发上一个消费服务实例通常会起8到16个并发写入协程每个协程负责一批数据。ClickHouse建了分布式表写入会按分片分散到各个节点不需要额外做负载均衡。3.3 查询API设计与性能优化存储建好了查询这层不能直接让用户裸查ClickHouse。因为日志查询的场景比较复杂有按关键字搜的、有按Trace ID精确找的、有按服务聚合统计的如果每个人都写SQL一方面不安全另一方面业务人员也用不了。我设计了一套查询API核心功能包括按时间范围 服务 级别查询日志列表全文搜索关键字这里用ClickHouse的ilike或者position函数虽然不如ES的全文检索强大但日常调试够用按Trace ID查询一条完整调用链的日志聚合统计接口比如按分钟统计错误日志的量、按服务统计日志总量的趋势。查询API要控制两个风险。一是单次查询的数据量上限日志查询经常有用户一个请求查几百万条数据把ClickHouse的查询连接打满影响其他业务。我在API层加了限制默认只返回前1000条需要更多走翻页或者导出接口。二是缓存高频查询关键字比如某个特定错误信息短时间内的重复查询结果基本一致我用Redis做了一层简单缓存缓存时间60秒命中率很可观。查询性能的另一个关键是ClickHouse的查询优化。ClickHouse对查询有内置的优化机制但前提是查询条件能匹配到排序键。我之前见过有同事的查询时间范围写法是toDateTime(2024-01-01 00:00:00)这种但表里的timestamp字段是DateTime64(3)类型类型不一致会导致索引失效查询就变成全表扫描了。这类细节需要特别注意我在项目里让每个查询方法都带好类型转换避免隐式转换拖垮性能。4. 告警体系与问题排查实战4.1 告警规则设计与实现日志系统不只是用来查的更重要的价值是发现问题和预警问题。我在自建这套系统时把告警作为一等功能来设计而不是事后补充。告警规则的来源有几类。一是基于日志内容的关键字匹配比如出现OutOfMemoryError、Connection refused这类明显异常的关键字立即触发告警二是基于日志量的统计比如某个服务的error级别日志在5分钟内超过某个阈值说明可能正在发生故障三是基于链路的完整性比如有请求的trace_id只拿到了入口日志但没有下游服务的日志说明调用链路在这中间断了。告警通道用的是Webhook加短信Webhook推送到内部IM群短信只留给P0级别的告警。分级策略是P0故障级别如整个服务不可用、大量5xx错误立刻短信通知P1异常级别如某个接口错误率飙升推送到IM群并相关负责人P2告警级别比如某个服务延迟变高、磁盘即将满在IM群里播报即可不做额外打扰。告警系统的核心不是“发出去”就完了而是尽量避免误报和噪音。我在每个告警规则里都加了静默期配置同一类告警在静默期内的重复触发不会重复发消息而是聚合成一条“累计N次”的更新消息。静默期时间我设置为30分钟既不会漏报严重故障也不会被高频触发的告警轰炸到麻木。告警规则本身也要有版本管理。规则配置存在数据库里每次修改记录变更人和变更内容出问题能回溯。我吃过一次亏某次调大了一个告警阈值结果线上故障时因为阈值设得太宽松告警没有触发。后来就把规则变更加入审批流程线上修改必须经过双人确认。4.2 常见问题排查速查表在实际运行中分布式日志系统本身也会出各种问题。我整理一个速查表列出我实际遇到过的几种典型问题、排查思路和解决办法问题现象可能原因排查方法解决方案某服务日志缺失Agent采集失效或日志路径变更检查Agent状态、查看文件是否被轮转确认Agent配置里的路径与服务实际路径一致Kafka消费积压持续增长消费服务性能瓶颈或ClickHouse写入慢查看消费组lag、检查写入延迟和clickhouse慢查询增加消费服务实例数优化批量大小和并发数查询响应越来越慢数据量增大但分区和索引规划不合理Explain分析查询计划调整排序键、增加并发、考虑本地表查询或预聚合日志中message字段出现乱码原始日志编码与Agent解析编码不一致检查原始输出编码和Vector配置统一服务日志编码为UTF-8告警频繁误报规则阈值不匹配实际数据分布查看规则触发历史数据基于历史基线动态调整阈值或加静默期与审计字段丢失服务更新后日志格式不符合规范解析失败的数据需要重新检查消费端设置失败数据落盘单独排查后重放排查日志系统问题最有效的方法是“自食其力”用日志系统自己来监控“日志系统产生的日志”。我把采集Agent、Kafka、消费服务、ClickHouse的关键指标吞吐、错误数、消费lag、查询RT全部以结构化日志输出再回流到系统里做分析和告警。这样一轮排查下来所有环节的状态都在同一套系统里不需要东翻一个工具西翻一个面板。4.3 性能调优心得与踩坑记录最后分享几个我真实踩过的坑有的是文档里查不到的有的则是看了文档也不一定能体会到的。第一个坑是关于ClickHouse内存占用。日志场景做查询时常见的就是GROUP BY service如果有大量基数很高的字段同时出现在查询里内存会暴涨。我第一次上线时只压测了单维度聚合结果在多维度聚合的查询中ClickHouse直接OOM了。解决思路是控制聚合维度最多同时聚合三个字段高基数字段不在OLAP这层做精确统计而是落到单独的离线任务去处理。第二个坑是Kafka的Topic数不能太多。我一开始严格按服务加级别拆topic结果服务一多topic数几百个Kafka的元数据压力上来了生产者和消费者的连接数也暴涨。后来把topic数量控制在40个以内按业务域来分比如订单域、支付域、用户域级别维度放在日志内容里而不是topic维度上。第三个坑是日志采集的正则parser性能。Vendor切换成Vector之后正则解析的CPU开销非常大高峰期单Agent的CPU占用能到80%。后来优化了正则表达式把贪婪匹配改成非贪婪匹配并优先用固定分隔符解析CPU直接降了一半。写日志解析的正则时永远别用那种能匹配一切的表达式尽量用锚定、限定长度、必要时的字符类缩小范围。还有一个经验是关于冷热数据的。日志数据的访问热度很不均匀最近7天的日志几乎占了99%的查询量但更早的日志偶尔也需要查。我给ClickHouse按时间做了冷热分层热数据放在SSD上超过7天的数据自动迁移到普通HDD存储成本降了不少查询性能也没明显变差。这个操作在ClickHouse里其实就是配置TTL的storage policy改完之后不需要手动干预系统自动迁移。5. 查询界面与使用体验优化5.1 前后端交互的设计要点日志系统做得好不好用查询界面的体验占一半。如果只能通过API来查日志普通开发者的使用门槛会很高。我在系统里做了一个轻量的Web查询界面核心功能是时间范围选择、服务选择、关键字搜索、日志级别过滤、以及基于Trace ID的链路查询。界面设计上有一个点我觉得是成功的关键搜索结果默认只展示摘要点开才能看完整内容。日志内容经常很长还有转义字符如果全量展开在列表上用户得翻半天才能找到关键信息。默认摘要展示时间和消息片段用户判断这条日志是否相关只需几秒大幅提升了筛选效率。另一个交互细节是时间线的展示。查询结果里加了一个小的时间分布图显示筛选时间段内各分钟日志量的变化用户能一眼看出异常高峰发生在哪个时刻再点击对应时间区段结果列表自动缩窄到那个时间段。这个功能实现不复杂但对排查效率提升明显。界面还提供查询模板保存功能。常见的排查路径比如“查某个服务最近15分钟所有5xx错误”、“查某个Trace ID的完整链路”都可以保存为模板一键运行不用每次重复设置筛选条件。日常工作里这个功能用得最多。5.2 与链路追踪系统的联动日志和链路追踪是两套体系但实际排查问题时必须打通。我在日志规范里定义了trace_id字段就是为了和链路追踪系统联动。当用户在前端看到一条日志包含trace_id时可以一键跳转到链路追踪系统查看该请求的完整调用链条。这个联动的实现方式是约定跳转不依赖复杂的集成。日志服务存储trace_id链路追踪服务也通过trace_id关联所有span两边通过一个统一的URL模式拼接参数互相跳转即可。不需要把两边的数据物理打通只要trace_id格式统一、生成规则统一就能做到无缝衔接。这个体验上线后排查跨服务问题的效率提升非常大。以前要先把一个请求的链路span列表导出来再拿着时间逐条翻日志现在直接在日志里看到一个异常span的trace_id点一下就能看到这条链路里所有服务的日志和耗时配合顺畅很多。5.3 日志数据的长期价值挖掘日志系统如果只用来“查问题”价值只发挥了一小半。积累了几个月的数据之后可以做很多深度的分析。我在这套系统上做了一些探索性的工作比如基于日志数据的接口错误率趋势分析、基于错误日志的服务健康度评分、以及基于用户请求日志的行为分析。接口错误率趋势分析相对容易实现在日志中提取每个接口的请求和响应状态按天聚合得出接口维度的错误率变化错误率突增的接口能自动标记出来。服务健康度评分则是把所有error日志按类型聚类统计每个错误类型的发生频率、影响范围、持续时间再综合成一个健康指数。这类分析不需要很复杂的算法关键是把日志数据结构化做得足够好分析逻辑就有了可靠的数据基础。说实话日志分析目前还谈不上做得多深但这些尝试证明了这套分布式日志系统的数据价值远不止于监控告警。数据的沉淀在开始建设系统时就要考虑进去否则后面想用但没有数据就真的束手无策了。6. 写在最后的几点提醒按我个人的经验分布式日志系统这类基础设备建设最怕的是前期设计不充足、后期无限填坑。几个重要的提醒供参考第一不要一上来就追求功能大而全先把“采集、传输、存储、查询”这条主链路跑通跑稳再逐步叠加告警、分析、链路联动这些附加能力。第二日志规范要强制执行但落地要有耐心。存量系统改造日志格式是个漫长的过程我的做法是“新服务必须用新规范老服务分批改造”过渡期内新旧格式在消费端做兼容。这种渐进式方案不会让团队有太大抵触情绪实际上也更稳妥。第三关注成本。日志系统的存储成本增长很快因为日志数据只增不减。除了TTL和压缩外我在每个服务上线时都会和开发团队一起评估日志量为每个服务设置日志量的上限。如果某个服务日志量异常膨胀多半是代码里打了大量无意义的日志。日志打点应该谨慎只记录有意义的信息能不打的内容尽量不打。这套系统运行到现在每天处理几十亿条日志查询延迟基本控制在秒级以内故障时的定位时间从小时级缩短到分钟级。分布式日志系统的建设不需要什么高深的技术技巧关键在于每一步都足够细致——格式的规范、存储的设计、链路的容灾、告警的降噪、界面的易用性每一个环节都脚踏实地做好系统的价值自然就能体现出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑