去年我们把日志平台从 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差别比较大,不要用错坐标。
<dependency><groupId>com.clickhouse</groupId><artifactId>clickhouse-jdbc</artifactId><classifier>all</classifier></dependency><dependency><groupId>com.zaxxer</groupId><artifactId>HikariCP</artifactId></dependency>application.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 包扫描单独指定,避免和业务库混在一起。
@Configuration@MapperScan(basePackages="com.youdian.log.mapper.ck",sqlSessionFactoryRef="clickHouseSqlSessionFactory")publicclassClickHouseConfig{@Bean("clickHouseDataSource")publicDataSourceclickHouseDataSource(){HikariDataSourceds=newHikariDataSource();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{SqlSessionFactoryBeanbean=newSqlSessionFactoryBean();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")List<HourlyStat>queryHourlyStat(@Param("date")Stringdate,@Param("appId")StringappId);}表引擎和分布式表设计
ClickHouse 表设计是重中之重,搞不好后面查询全得跟着遭罪。
引擎用ReplicatedMergeTree+Distributed组合,前者负责副本同步,后者是逻辑表,把查询分发到底层分片。集群里需要提前配置好 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))ENGINE=ReplicatedMergeTree('/clickhouse/tables/{shard}/app_log','{replica}')PARTITIONBYtoYYYYMMDD(event_date)ORDERBY(event_date,app_id,api_path,event_time)TTL event_date+INTERVAL30DAYDELETESETTINGS index_granularity=8192;再建分布式表:
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))ENGINE=Distributed('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
日志链路就是经典的三段式:业务日志进 Kafka,Spring Boot 消费,批量写入 ClickHouse。Kafka 在这里主要是削峰,日志量瞬时猛增时 ClickHouse 不用硬扛。
消费端配置:
spring:kafka:consumer:max-poll-records:5000enable-auto-commit:falseauto-offset-reset:latestlistener:type:batchack-mode:manual_immediate消费代码:
@KafkaListener(topics="app-log",containerFactory="kafkaListenerContainerFactory")publicvoidonMessage(List<ConsumerRecord<String,String>>records,Acknowledgmentack){List<LogEntity>list=records.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(List<LogEntity>logs){Stringsql="INSERT 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(Connectionconn=dataSource.getConnection();PreparedStatementps=conn.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{privatefinalBlockingQueue<LogEntity>queue=newArrayBlockingQueue<>(100000);@PostConstructpublicvoidstart(){ScheduledExecutorServicescheduler=Executors.newSingleThreadScheduledExecutor();scheduler.scheduleWithFixedDelay(this::flush,5,5,TimeUnit.SECONDS);}publicvoidadd(LogEntitylog){queue.offer(log);}publicvoidflush(){List<LogEntity>batch=newArrayList<>(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)ENGINE=SummingMergeTree()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_status>=500)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_date=today()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(List<LogEntity>logs){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_time>now()-INTERVAL1HOURANDquery_duration_ms>3000ORDERBYquery_duration_msDESCLIMIT20;数据治理上还有几件事值得做:健康检查和调试日志在消费端直接丢掉,别浪费存储;Kafka 重复消费导致的重复数据,自己做好幂等控制,别依赖 ClickHouse 做精确去重;ReplacingMergeTree属于迫不得已的方案,合并开销大,能不用就不用;不固定的扩展字段继续放 Map 里,等字段稳定了再拆出来单独成列;后台定期跑一次OPTIMIZE TABLE,把小分区合并掉,减少零碎文件。
容量估算和监控
容量这步容易拍脑袋,算一下还是很有必要的。假设每天 20 亿条日志,平均单条约 500 字节,原始量大概是每天 1TB。ClickHouse 压缩比我们实测大概 5:1,落地就是 200GB。保留 30 天,单副本 6TB,双副本就需要 12TB。我们集群 3 台机器,每台 8 块 2TB NVMe SSD,内存 64GB,CPU 16 核,跑到现在余量还算充足。
监控用的 Prometheus + Grafana,节点层看 CPU、内存、磁盘水位,Kafka 看消费 Lag,ClickHouse 看查询并发、慢查询数、分区数量。告警阈值是实际踩坑后定下来的:磁盘使用率超过 80%,Kafka Lag 超过 100 万,平均查询耗时超过 2 秒并持续 5 分钟,写入延迟超过 3 秒。这几个指标任何一个报警,基本都说明容量或性能到了需要提前介入的状态。
最后
我们这套系统上线到现在一年多了,现在 50 多个应用接入,每天日志量 20 亿条以上,查询 P99 稳定在 200ms 以内,存储成本相比原来的 ES 方案省了 60% 左右。如果也想做类似改造,我的建议是先把表设计想清楚,再考虑扩节点和调参数;ClickHouse 这东西,设计合理的情况下确实很强,但设计不合理的时候,填的坑也真不少。有更好方案的朋友欢迎多交流。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/