Spring Boot + ClickHouse 重构日志平台:表设计、物化视图与调优记录

发布时间:2026/10/13 3:23:31
Spring Boot + ClickHouse 重构日志平台:表设计、物化视图与调优记录 去年我们把日志平台从 Elasticsearch 迁移到了 ClickHouse。说白了就是 ES 在日志量上来之后成本和查询性能两头都不占优势生产环境已经快扛不住了。这篇文章是项目落地之后的一次复盘重点讲 ClickHouse 表怎么设计、Spring Boot 怎么接、物化视图怎么做预聚合以及我们在调优中踩过的坑。为什么放弃 ES 选 ClickHouse我们的日志来源比较杂网关、微服务、消息队列、慢查询、前端埋点全都有高峰期每天处理 20 亿条以上。查询场景主要是两类第一类是按订单号、用户 ID、trace_id 精确查找某条日志第二类是聚合统计比如按接口、状态码、时间段算成功率、平均耗时和 P99。用 ES 的问题很直接。第一是存储膨胀日志在 ES 里要建倒排索引和副本原始数据 1TB落到磁盘基本 3~5TB内存更是吃紧。第二是聚合查询慢按小时维度做 group by 这种统计要扫大量文档数据量到几十亿条后经常跑到几十秒实时监控大屏根本没法用。日志本身还有一个特点写入后基本不更新、查询集中在最近几天、字段结构相对稳定这三条和 ClickHouse 的列式存储、向量化执行、批量写入天然匹配。我们用同样配置的机器跑过对比压测ClickHouse 聚合查询比 ES 快一个数量级以上存储压缩比大约 5:1。Druid 当初也评估过它实时摄入能力确实不错但组件多、运维复杂最终没选。Spring Boot 接入 ClickHouse连接池和 MyBatis先加 JDBC 驱动和连接池。目前com.clickhouse:clickhouse-jdbc已经整合了新客户端版本号可以去 Maven Central 看最新的API 和老的ru.yandex.clickhouse差别比较大不要用错坐标。dependencygroupIdcom.clickhouse/groupIdartifactIdclickhouse-jdbc/artifactIdclassifierall/classifier/dependencydependencygroupIdcom.zaxxer/groupIdartifactIdHikariCP/artifactId/dependencyapplication.yml 里的配置spring:datasource:clickhouse:jdbc-url:jdbc:clickhouse://ck-node1:8123,ck-node2:8123/logdbdriver-class-name:com.clickhouse.jdbc.ClickHouseDriverusername:defaultpassword:${CK_PASSWORD}maximum-pool-size:20minimum-idle:5connection-timeout:30000ClickHouse 不是 OLTP 引擎连接池不用开太大。我们生产上最多开到 20 个写入全是批量查询虽然多但大部分走聚合表连接复用率很高。项目原本用了 MyBatis所以我们把 ClickHouse 配成独立数据源Mapper 包扫描单独指定避免和业务库混在一起。ConfigurationMapperScan(basePackagescom.youdian.log.mapper.ck,sqlSessionFactoryRefclickHouseSqlSessionFactory)publicclassClickHouseConfig{Bean(clickHouseDataSource)publicDataSourceclickHouseDataSource(){HikariDataSourcedsnewHikariDataSource();ds.setJdbcUrl(jdbc:clickhouse://ck-node1:8123/logdb);ds.setDriverClassName(com.clickhouse.jdbc.ClickHouseDriver);ds.setMaximumPoolSize(10);returnds;}Bean(clickHouseSqlSessionFactory)publicSqlSessionFactorysqlSessionFactory(Qualifier(clickHouseDataSource)DataSourceds)throwsException{SqlSessionFactoryBeanbeannewSqlSessionFactoryBean();bean.setDataSource(ds);bean.setTypeAliasesPackage(com.youdian.log.domain);returnbean.getObject();}}Mapper 直接用注解 SQL 就行少写 XML。ClickHouse SQL 语法跟 MySQL 有不少相似的地方但事务、更新这类不该碰就别碰查询也别SELECT *只需要查真正用到的列。publicinterfaceLogStatMapper{Select(SELECT toHour(event_time) AS hour, count() AS total, avg(latency) AS avg_latency FROM app_log WHERE event_date #{date} AND app_id #{appId} GROUP BY hour ORDER BY hour)ListHourlyStatqueryHourlyStat(Param(date)Stringdate,Param(appId)StringappId);}表引擎和分布式表设计ClickHouse 表设计是重中之重搞不好后面查询全得跟着遭罪。引擎用ReplicatedMergeTreeDistributed组合前者负责副本同步后者是逻辑表把查询分发到底层分片。集群里需要提前配置好 macros 和 remote_servers建表时直接引用{shard}、{replica}这两个占位符。先建本地表每个分片上都会实际存储这么一张CREATETABLElogdb.app_log_local(event_dateDate,event_timeDateTime,app_id String,trace_id String,user_id UInt64,order_id String,log_level LowCardinality(String),api_path String,http_status UInt16,latency UInt32,message String,extra Map(String,String))ENGINEReplicatedMergeTree(/clickhouse/tables/{shard}/app_log,{replica})PARTITIONBYtoYYYYMMDD(event_date)ORDERBY(event_date,app_id,api_path,event_time)TTL event_dateINTERVAL30DAYDELETESETTINGS index_granularity8192;再建分布式表CREATETABLElogdb.app_log(event_dateDate,event_timeDateTime,app_id String,trace_id String,user_id UInt64,order_id String,log_level LowCardinality(String),api_path String,http_status UInt16,latency UInt32,message String,extra Map(String,String))ENGINEDistributed(log_cluster,logdb,app_log_local,rand());一些设计细节值得展开说。分区键用toYYYYMMDD(event_date)按天分区。日志查询几乎都集中在最近几天按天分区能快速裁剪数据过期数据清理也可以直接按分区做。不值得再建小时级二级分区分区文件太多时后台 merge 会很吃力查询反而变慢。排序键(event_date, app_id, api_path, event_time)直接决定数据在磁盘的物理排序是查询性能的核心。这个组合让我们最常见的“某天某个应用某个接口的明细”能直接从对应数据块里捞不用扫全表。如果你更看重 trace_id 检索就把 trace_id 往排序键前面挪例如(event_date, trace_id, event_time)。这个排序键选错等于写查询时天天全表扫描。TTL 设的是 30 天过期自动删。如果想做分层存储可以改成TTL event_date INTERVAL 30 DAY TO DISK cold_volume历史数据会移到冷盘。需要说明TTL 的清理是按分区周期性执行的不是实时发生。log_level枚举值少用LowCardinality(String)可以明显压缩存储查询性能也好。扩展字段类似extra这种统一用Map类型后面加字段不用再跑 ALTER TABLE。分片键我们用的rand()数据随机打散到各分片。日志场景不依赖某个 key 强制落同一分片随机分片最均匀。如果你希望同一 trace_id 的日志分到同一个分片可以改用trace_id做分片键但要小心数据倾斜。关于分片和副本多说一句分片数是把数据横向切几份副本数是一份数据存几份。3 节点集群如果做 3 分片 1 副本那就没有高可用节点挂了数据就少一块。生产要双副本的话需要把节点数翻倍比如 3 分片 2 副本就是 6 台机器。我们一开始 3 台后来补了 3 台把双副本补齐了中间还吃过单副本的亏重建副本的过程很折腾。数据接入Kafka - Spring Boot - ClickHouse日志链路就是经典的三段式业务日志进 KafkaSpring Boot 消费批量写入 ClickHouse。Kafka 在这里主要是削峰日志量瞬时猛增时 ClickHouse 不用硬扛。消费端配置spring:kafka:consumer:max-poll-records:5000enable-auto-commit:falseauto-offset-reset:latestlistener:type:batchack-mode:manual_immediate消费代码KafkaListener(topicsapp-log,containerFactorykafkaListenerContainerFactory)publicvoidonMessage(ListConsumerRecordString,Stringrecords,Acknowledgmentack){ListLogEntitylistrecords.stream().map(r-parseLog(r.value())).filter(Objects::nonNull).collect(Collectors.toList());if(!list.isEmpty()){logBatchService.batchInsert(list);}ack.acknowledge();}批量写 ClickHouse 时必须用PreparedStatement.addBatch()一次提交 1000~5000 行不要一条条 insert。我们曾经试过单次插 10 万行ClickHouse 服务端内存压力明显增大merge 也受影响后来稳定在 5000 行一批效果最好。publicvoidbatchInsert(ListLogEntitylogs){StringsqlINSERT INTO app_log (event_date, event_time, app_id, trace_id, user_id, order_id, log_level, api_path, http_status, latency, message, extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);try(ConnectionconndataSource.getConnection();PreparedStatementpsconn.prepareStatement(sql)){for(LogEntitylog:logs){ps.setDate(1,java.sql.Date.valueOf(log.getEventDate()));// 其他字段类似注意类型对应ps.addBatch();}ps.executeBatch();}}消费端我们还加了一层有界队列做异步缓冲。Kafka 消费速度和 ClickHouse 写入速度偶尔会不匹配用ArrayBlockingQueue先兜一下再定时批量 flush。队列大小别设太大100000 左右比较安全太大了 JVM GC 会很难受。ComponentpublicclassLogBatchService{privatefinalBlockingQueueLogEntityqueuenewArrayBlockingQueue(100000);PostConstructpublicvoidstart(){ScheduledExecutorServiceschedulerExecutors.newSingleThreadScheduledExecutor();scheduler.scheduleWithFixedDelay(this::flush,5,5,TimeUnit.SECONDS);}publicvoidadd(LogEntitylog){queue.offer(log);}publicvoidflush(){ListLogEntitybatchnewArrayList(5000);queue.drainTo(batch,5000);if(batch.isEmpty())return;insertClickHouse(batch);}}聚合查询优化物化视图日志场景里有一类查询特别高频按小时统计某个应用某个接口的总次数、错误次数、平均耗时。直接在原始表上跑 group by 的话几十亿行扫描一次多少资源都不够用。物化视图的做法数据写入原始表时顺带把聚合结果写进一张独立的聚合表查询直接查聚合表。名字叫“物化视图”本质是数据写入时触发一段 SQL 计算把结果落到另一张表里等查询时就不用现场算了。聚合表CREATETABLElogdb.api_hourly_agg(event_dateDate,hourDateTime,app_id String,api_path String,total_count UInt64,error_count UInt64,sum_latency UInt64,max_latency UInt32)ENGINESummingMergeTree()PARTITIONBYtoYYYYMMDD(event_date)ORDERBY(event_date,hour,app_id,api_path);物化视图CREATEMATERIALIZEDVIEWlogdb.api_hourly_agg_mvTOlogdb.api_hourly_aggASSELECTevent_date,toStartOfHour(event_time)AShour,app_id,api_path,count()AStotal_count,countIf(http_status500)ASerror_count,sum(latency)ASsum_latency,max(latency)ASmax_latencyFROMlogdb.app_log_localGROUPBYevent_date,hour,app_id,api_path;注意这里 source 表是本地表app_log_local不是分布式表app_log。物化视图是跟着 insert 事件走的得挂在本地的物理表上挂到分布式表上会产生不可控的重复触发或数据错乱。聚合表用了SummingMergeTree相同排序键的数据在后台 merge 时会把数值列自动求和。物化视图在写入时先把每小时的数据 group by 成一行查询时再按天、接口维度过滤响应时间基本能压到几十毫秒级。Projection 也能做类似的预聚合查询优化器可以自动路由。但项目里我更倾向物化视图它不影响原始表的写入路径维护起来直观思路也好理解。Projection 挂在原表内部对已有大表执行 ALTER 加投影非常耗时适合新建表时就规划好的场景。查询优化细节几个实际调优过程中遇到的问题简单记一下。高基数去重别用 count(distinct)。统计用户数、订单数这些字段时如果基数高COUNT(DISTINCT user_id)会吃大量内存甚至 OOM。用uniqCombined做近似去重误差一般在 1% 以内性能和内存都可控。SELECTapp_id,uniqCombined(user_id)ASuv_countFROMapp_logWHEREevent_datetoday()GROUPBYapp_id;跳数索引绕不开。排序键没有覆盖的字段比如 user_id、order_id经常作为查询条件单独建个 minmax 索引能帮助跳过无关数据块ALTERTABLElogdb.app_log_localADDINDEXidx_user_id user_idTYPEminmax GRANULARITY2;这个索引只对新写入数据有效如果表里已经有历史数据还要执行ALTER TABLE ... MATERIALIZE INDEX idx_user_id IN PARTITION ...让老数据补上索引。全文检索别指望排序键。如果查询里常见message LIKE %xxx%这种条件那不是简单排序列就能解决的得用 tokenbf_v1 或 ngrambf_v1 索引。不过我们日志场景大多数是精确匹配和范围扫描暂时没往这个方向投入。分布式聚合查询存在网络开销。ClickHouse 会把查询下发到每个分片各分片聚合完再汇总。如果 group by 基数特别高分片返回的中间结果会很大网络传输变成瓶颈。遇到这种情况要么缩小查询维度要么让业务层先按时间范围和 app_id 做裁剪减少参与计算的数据量。写入限流和服务端调优Kafka 消费积压严重时如果一次性把几百万条数据全部灌进 ClickHouse写入分片瞬间会非常忙查询也会跟着受影响。我们在消费端加了一个速率限制把单次批量的写入速度控制在合理范围。AutowiredprivateRateLimiterrateLimiter;// 每秒 50000 条publicvoidbatchInsertWithRateLimit(ListLogEntitylogs){rateLimiter.acquire(logs.size());insertClickHouse(logs);}ClickHouse 建表时可以设置max_insert_threads插入线程多几个大批量写入的吞吐能上去一些。还有一点insert 时尽量避免混入多个分区的数据跨分区写入会导致需要 merge 的分区过多后台负担加重。慢查询定位和数据治理ClickHouse 的system.query_log表里记录了每个 SQL 的执行时间、扫描行数、内存占用。我们写了一个定时任务把超过 3 秒的查询抓出来送往告警平台值班同学收到告警后再去分析是否需要优化 SQL 或表结构。SELECTquery,query_duration_ms,read_rows,memory_usageFROMsystem.query_logWHEREevent_timenow()-INTERVAL1HOURANDquery_duration_ms3000ORDERBYquery_duration_msDESCLIMIT20;数据治理上还有几件事值得做健康检查和调试日志在消费端直接丢掉别浪费存储Kafka 重复消费导致的重复数据自己做好幂等控制别依赖 ClickHouse 做精确去重ReplacingMergeTree属于迫不得已的方案合并开销大能不用就不用不固定的扩展字段继续放 Map 里等字段稳定了再拆出来单独成列后台定期跑一次OPTIMIZE TABLE把小分区合并掉减少零碎文件。容量估算和监控容量这步容易拍脑袋算一下还是很有必要的。假设每天 20 亿条日志平均单条约 500 字节原始量大概是每天 1TB。ClickHouse 压缩比我们实测大概 5:1落地就是 200GB。保留 30 天单副本 6TB双副本就需要 12TB。我们集群 3 台机器每台 8 块 2TB NVMe SSD内存 64GBCPU 16 核跑到现在余量还算充足。监控用的 Prometheus Grafana节点层看 CPU、内存、磁盘水位Kafka 看消费 LagClickHouse 看查询并发、慢查询数、分区数量。告警阈值是实际踩坑后定下来的磁盘使用率超过 80%Kafka Lag 超过 100 万平均查询耗时超过 2 秒并持续 5 分钟写入延迟超过 3 秒。这几个指标任何一个报警基本都说明容量或性能到了需要提前介入的状态。最后我们这套系统上线到现在一年多了现在 50 多个应用接入每天日志量 20 亿条以上查询 P99 稳定在 200ms 以内存储成本相比原来的 ES 方案省了 60% 左右。如果也想做类似改造我的建议是先把表设计想清楚再考虑扩节点和调参数ClickHouse 这东西设计合理的情况下确实很强但设计不合理的时候填的坑也真不少。有更好方案的朋友欢迎多交流。 福利时间如果你正在备战面试或者想要学习其他知识给大家推荐一个宝藏知识库作者整理了一些列 Java 程序员需要掌握的核心知识有需要的自取不谢。知识库地址https://farerboy.com/

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询