1. 为什么要在 SpringBoot 里接 ClickHouse
先把场景说清楚。ClickHouse 这类列式数据库,最擅长的就是海量数据的聚合分析——几亿行数据做 group by、算 PV/UV、跑漏斗,响应时间经常能压到秒级甚至毫秒级。但它的定位不是 OLTP,不是拿来替 MySQL 做增删改查的。所以真实项目里常见的架构是:业务数据走 MySQL,分析类、日志类、埋点类数据走 ClickHouse,两边各司其职。
那问题就来了:Java 后端主流是 SpringBoot + MyBatis 这套组合,怎么让它顺滑地连上 ClickHouse?这就是这篇要聊的核心。我这次做的项目是一个埋点分析后台,每天大概几千万条事件数据往 ClickHouse 里灌,前端要按维度做实时聚合查询。踩过的坑不算少,从驱动选型到批量插入性能,再到 MyBatis 映射的各种别扭,下面一条条拆开讲。
适合谁看:已经会 SpringBoot 和 MyBatis 基本用法,想接 ClickHouse 但不确定怎么配、怎么写的同学;或者已经接上了但插入慢、查询报错、类型对不上的同学。如果你连 MyBatis 的 Mapper 都没写过,建议先把基础补一下再回来,不然中间有些细节会看得云里雾里。
先说结论性的东西,省得你翻到最后:ClickHouse 官方提供了 JDBC 驱动,SpringBoot + MyBatis 接它本质上就是换一个 JDBC URL 和驱动类,但真正决定成败的是批量写入方式和数据类型映射这两块。把这两块搞明白,剩下的都是常规操作。
2. 整体方案设计与选型思路
2.1 驱动怎么选:官方 JDBC 还是第三方封装
ClickHouse 的连接方式有好几种,Java 生态里最常见的是官方 JDBC 驱动(clickhouse-jdbc)。我对比过几个方案,最后选了官方驱动,理由很直接:
- 官方驱动维护活跃,版本迭代快,对新版 ClickHouse 的兼容性好;
- 支持批量插入,
PreparedStatement的 addBatch/executeBatch 能走原生协议,性能比拼 SQL 高一个量级; - 和 MyBatis 天然兼容,因为它就是个标准 JDBC 驱动,MyBatis 底层走 JDBC,不需要额外适配层。
第三方封装(比如某些 ORM 框架的 ClickHouse 方言)我也试过,好处是 API 更"对象化",坏处是版本一升级就容易出兼容问题,而且出问题时排查链路长——你不知道是框架的锅还是驱动的锅。对于生产项目,我倾向于少一层封装,直接用官方驱动 + MyBatis 原生 XML,可控性最强。
提示:驱动版本和 ClickHouse 服务端版本尽量对齐大版本。我用的是驱动 0.6.x 配服务端 23.x,实测稳定。跨大版本(比如驱动 0.4 配服务端 23)偶尔会出现协议不兼容的报错。
2.2 连接方式:HTTP 还是 TCP
官方驱动支持两种底层协议:HTTP(8123 端口)和 TCP/native(9000 端口)。这个选择很多人会忽略,但它直接影响性能和功能。
| 对比项 | HTTP 协议 | TCP/native 协议 |
|---|---|---|
| 默认端口 | 8123 | 9000 |
| 批量插入性能 | 一般 | 更好 |
| 压缩支持 | 支持 | 支持 |
| 兼容性 | 好,穿透代理方便 | 需要端口直连 |
| 推荐场景 | 简单查询、跨网络 | 大批量写入、内网 |
我的项目是内网部署,写入量大,所以选了 TCP/native。JDBC URL 里通过socket_timeout、dataTransferTimeout这些参数调优。如果你只是做点轻量查询,HTTP 也够用,配置更简单。
2.3 为什么不用 JPA / Hibernate
有人会问,SpringBoot 里不是有 Spring Data JPA 吗,能不能用?技术上能,但我不推荐。原因有两个:一是 ClickHouse 的 SQL 方言和标准 SQL 差异不小(比如FINAL、PREWHERE、各种聚合函数),JPA 的自动生成 SQL 基本用不上;二是 ClickHouse 的写入模型和 JPA 的实体管理机制不搭,批量插入反而更麻烦。MyBatis 的好处就是 SQL 完全自己掌控,想怎么写就怎么写,这对 ClickHouse 这种"SQL 就是核心竞争力"的数据库来说太重要了。
3. 环境准备与依赖配置
3.1 Maven 依赖清单
先把依赖列清楚。核心就两个:ClickHouse 驱动和 MyBatis 的 SpringBoot starter。
<dependency> <groupId>com.clickhouse</groupId> <artifactId>clickhouse-jdbc</artifactId> <version>0.6.0</version> <classifier>all</classifier> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>5.1.0</version> </dependency>这里有个细节值得说:clickhouse-jdbc的classifier要选all,它会把依赖的 httpclient、jackson 等一起打进来,避免版本冲突。我第一次没加all,结果运行时报NoClassDefFoundError,排查了半天才发现是传递依赖缺失。
3.2 数据源配置
SpringBoot 的application.yml里配置数据源。注意 ClickHouse 和 MySQL 通常要共存,所以要么用多数据源,要么把 ClickHouse 单独配一个 DataSource Bean。我这里用的是多数据源方案,ClickHouse 单独一个。
spring: datasource: clickhouse: driver-class-name: com.clickhouse.jdbc.ClickHouseDriver jdbc-url: jdbc:clickhouse://127.0.0.1:9000/analytics?socket_timeout=300000&dataTransferTimeout=300000&compress=1 username: default password: "" maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000几个参数解释一下,都是踩坑踩出来的:
socket_timeout和dataTransferTimeout设大一点(我设的 300 秒),因为大批量写入或复杂聚合查询可能跑很久,默认值容易超时断开;compress=1开启压缩,网络传输量能降不少,尤其是文本类字段多的表;- 连接池大小别设太大,ClickHouse 单连接能扛的并发不低,10 个连接足够支撑中等规模应用,设太大反而增加服务端压力。
3.3 多数据源配置类
因为要和 MySQL 共存,得手动配 DataSource。下面这个配置类是我实际在用的,ClickHouse 数据源用@ConfigurationProperties绑定上面的配置。
@Configuration @MapperScan(basePackages = "com.example.mapper.ck", sqlSessionFactoryRef = "ckSqlSessionFactory") public class ClickHouseDataSourceConfig { @Bean(name = "ckDataSource") @ConfigurationProperties(prefix = "spring.datasource.clickhouse") public DataSource ckDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } @Bean(name = "ckSqlSessionFactory") public SqlSessionFactory ckSqlSessionFactory( @Qualifier("ckDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/ck/*.xml")); return bean.getObject(); } @Bean(name = "ckSqlSessionTemplate") public SqlSessionTemplate ckSqlSessionTemplate( @Qualifier("ckSqlSessionFactory") SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } }关键点是@MapperScan里指定sqlSessionFactoryRef,把 ClickHouse 的 Mapper 和 MySQL 的 Mapper 分开扫描,各走各的 SqlSessionFactory。Mapper 接口放在com.example.mapper.ck包下,XML 放在mapper/ck/目录下,物理隔离,避免串味。
注意:如果你只有一个数据源(纯 ClickHouse),那上面这套多数据源配置可以省掉,SpringBoot 自动装配就够了。多数据源只在需要和 MySQL 共存时才必要。
4. 建表与数据类型映射
4.1 建表语句设计
ClickHouse 建表和 MySQL 思路完全不同,核心是选对引擎和排序键。我这张埋点事件表大概长这样:
CREATE TABLE analytics.event_log ( event_date Date, event_time DateTime, app_id UInt32, user_id UInt64, event_name String, platform LowCardinality(String), props String, duration UInt32 ) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (app_id, event_date, event_name) TTL event_date + INTERVAL 90 DAY;几个设计决策解释一下:
- 引擎选 MergeTree,这是 ClickHouse 最核心的引擎,适合绝大多数分析场景;
- 分区键用月份,
toYYYYMM(event_date),按月分区,查询时能快速裁剪分区,也方便按分区删旧数据; - 排序键
(app_id, event_date, event_name),把最常用的查询过滤字段放前面。ClickHouse 的排序键决定了数据物理存储顺序,查询时能利用索引跳过大量数据块,这个顺序设计得好不好,直接决定查询快不快; LowCardinality(String)用于低基数列(比如 platform 只有几个值),ClickHouse 会做字典编码,存储和查询都更省;- TTL 自动过期,埋点数据保留 90 天,到期自动删,省得手动清理。
4.2 Java 类型和 ClickHouse 类型的对应关系
这块是新手最容易翻车的地方。ClickHouse 的类型系统和 Java 不完全对应,映射错了要么插入报错,要么精度丢失。我整理了一张常用对照表:
| ClickHouse 类型 | Java 类型 | 说明 |
|---|---|---|
| UInt8/16/32 | Integer / Long | 无符号,注意别用负数 |
| UInt64 | Long / BigInteger | 超过 Long 范围要用 BigInteger |
| Int32/64 | Integer / Long | 有符号 |
| Float32/64 | Float / Double | 浮点 |
| String | String | 万能类型,啥都能塞 |
| Date | java.sql.Date / LocalDate | 日期 |
| DateTime | java.sql.Timestamp / LocalDateTime | 时间戳 |
| Decimal | BigDecimal | 金额必用 |
| Nullable(T) | 对应包装类型 | 可空 |
重点说两个坑:
第一个坑是 UInt64。ClickHouse 的 UInt64 最大值是 2^64-1,超过 Java Long 的正数范围(2^63-1)。如果你的 user_id 可能很大,用 Long 接会溢出成负数。稳妥做法是用BigInteger,或者在建表时就用 String 存。我一开始用 Long,结果某些大 ID 插进去变成负数,查出来对不上,排查了好久。
第二个坑是 Nullable。ClickHouse 默认字段不可空,要可空必须显式声明Nullable(String)。而 Java 侧如果用基本类型(int、long),遇到 null 会直接 NPE。所以可空字段一律用包装类型(Integer、Long),并且建表时加 Nullable。
4.3 实体类设计
实体类用 Lombok 简化,字段类型严格按上面的映射来:
@Data public class EventLog { private LocalDate eventDate; private LocalDateTime eventTime; private Long appId; private BigInteger userId; private String eventName; private String platform; private String props; private Long duration; }注意userId用了BigInteger,eventDate用LocalDate,eventTime用LocalDateTime。MyBatis 从 3.4.5 开始对 JSR-310 时间类型支持得不错,配合 ClickHouse 驱动能自动转换,不用额外写 TypeHandler。但如果你用的是老版本 MyBatis,可能得自己写 TypeHandler,这个后面问题排查章节会讲。
5. 插入操作的实现与性能优化
5.1 单条插入:能跑但别用
先给个最基础的写法,让你知道 MyBatis 接 ClickHouse 长什么样:
<insert id="insertOne" parameterType="com.example.entity.EventLog"> INSERT INTO analytics.event_log (event_date, event_time, app_id, user_id, event_name, platform, props, duration) VALUES (#{eventDate}, #{eventTime}, #{appId}, #{userId}, #{eventName}, #{platform}, #{props}, #{duration}) </insert>Mapper 接口:
public interface EventLogMapper { int insertOne(EventLog log); }这个写法能跑通,但千万别用在生产的高频写入场景。ClickHouse 每次插入都会生成一个新的数据 part,单条插入会产生海量小 part,后台合并线程疲于奔命,最后写入性能急剧下降,甚至报 "Too many parts" 错误。我实测过,单条插入每秒也就几百条,而且跑一会儿就卡。
5.2 批量插入:性能的关键
批量插入才是 ClickHouse 的正确打开方式。有两种实现路径,我都试过,说下区别。
路径一:MyBatis 的 foreach 拼批量 VALUES。
<insert id="insertBatch" parameterType="java.util.List"> INSERT INTO analytics.event_log (event_date, event_time, app_id, user_id, event_name, platform, props, duration) VALUES <foreach collection="list" item="item" separator=","> (#{item.eventDate}, #{item.eventTime}, #{item.appId}, #{item.userId}, #{item.eventName}, #{item.platform}, #{item.props}, #{item.duration}) </foreach> </insert>这个写法简单,但有个问题:SQL 文本会随着批次大小线性增长。一批 1000 条,SQL 就好几万字符,解析开销大,而且容易触发服务端的max_query_size限制。适合小批量(几百条以内)。
路径二:JDBC 的 addBatch/executeBatch。
这是性能最好的方式,走驱动的原生批量协议。MyBatis 里可以通过ExecutorType.BATCH开启:
@Autowired private SqlSessionFactory ckSqlSessionFactory; public void batchInsert(List<EventLog> list) { try (SqlSession session = ckSqlSessionFactory.openSession(ExecutorType.BATCH)) { EventLogMapper mapper = session.getMapper(EventLogMapper.class); for (EventLog log : list) { mapper.insertOne(log); } session.commit(); } }注意这里复用的还是insertOne那条 SQL,但因为是 BATCH 模式,MyBatis 会把它们攒起来一次性发给驱动。实测下来,这种方式比 foreach 拼 SQL 快 3 到 5 倍,而且内存占用更平稳。
实操心得:批次大小控制在 1000 到 5000 条之间比较合适。太小了网络往返开销大,太大了单次请求体太大容易超时。我最后定的是 2000 条一批,配合 300 秒的 socket_timeout,跑得很稳。
5.3 写入性能实测对比
为了让你有个直观感受,我把三种方式在同一台机器上跑了一遍,数据量 100 万条:
| 写入方式 | 耗时 | 平均吞吐 | 备注 |
|---|---|---|---|
| 单条插入 | 约 40 分钟 | 约 400 条/秒 | 后期明显变慢,part 过多 |
| foreach 批量(1000/批) | 约 90 秒 | 约 1.1 万条/秒 | SQL 文本大,偶发超时 |
| JDBC Batch(2000/批) | 约 35 秒 | 约 2.8 万条/秒 | 最稳,推荐 |
差距非常明显。所以如果你发现写入慢,第一件事就是检查是不是在单条插。
5.4 写入时的几个注意事项
- 别频繁小批量写。ClickHouse 喜欢"大批次、低频次",宁可攒够 2000 条再写,也别 10 条一写写 200 次;
- 写入前关掉不必要的索引和约束。ClickHouse 本身约束就少,但如果你建了物化视图,写入会触发视图更新,量大时会有开销,评估清楚再建;
- 异步写入要考虑失败重试。批量写入失败时整批都丢,得做好重试和落盘兜底。我一般把失败的批次写进本地文件,后台定时重灌。
6. 查询操作的实现与优化
6.1 基础查询映射
查询这块,MyBatis 的 resultMap 要写清楚字段和属性的对应关系,尤其是下划线转驼峰。ClickHouse 返回的列名默认是下划线风格,Java 属性是驼峰,得手动映射或者开mapUnderscoreToCamelCase。
<resultMap id="EventLogMap" type="com.example.entity.EventLog"> <result column="event_date" property="eventDate"/> <result column="event_time" property="eventTime"/> <result column="app_id" property="appId"/> <result column="user_id" property="userId"/> <result column="event_name" property="eventName"/> <result column="platform" property="platform"/> <result column="props" property="props"/> <result column="duration" property="duration"/> </resultMap> <select id="selectByApp" resultMap="EventLogMap"> SELECT event_date, event_time, app_id, user_id, event_name, platform, props, duration FROM analytics.event_log WHERE app_id = #{appId} AND event_date = #{eventDate} ORDER BY event_time DESC LIMIT #{limit} </select>这里有个细节:查询一定要带分区键或排序键的过滤条件。ClickHouse 没有传统意义上的索引,全靠分区裁剪和主键(排序键)跳数。如果你WHERE里不带app_id和event_date,它会全表扫描,几亿行数据能把你等哭。
6.2 聚合查询:ClickHouse 的主场
分析类查询才是 ClickHouse 的价值所在。比如算某个 App 每天的活跃用户数:
<select id="countDau" resultType="java.util.Map"> SELECT event_date AS statDate, uniqExact(user_id) AS dau, count() AS totalEvents FROM analytics.event_log WHERE app_id = #{appId} AND event_date BETWEEN #{startDate} AND #{endDate} GROUP BY event_date ORDER BY event_date </select>uniqExact是精确去重,uniq是近似去重(HyperLogLog),后者快很多但有小误差。数据量上亿时,如果业务能接受 1% 以内的误差,用uniq性能提升非常明显。我一般 UV 类指标用uniq,涉及钱的用uniqExact。
6.3 分页的正确姿势
ClickHouse 分页和 MySQL 不一样,LIMIT offset, size在 offset 很大时会很慢,因为它还是要扫描前面所有数据。正确做法有两种:
方式一:基于游标分页。用上一页最后一条的排序键值作为下一页的起点:
SELECT ... FROM analytics.event_log WHERE app_id = #{appId} AND (event_date, event_time) > (#{lastDate}, #{lastTime}) ORDER BY event_date, event_time LIMIT #{size}这种方式不管翻到第几页,性能都一样,适合无限滚动场景。
方式二:先聚合再分页。如果是聚合结果分页,先用子查询把聚合结果算出来,再对结果分页,避免对原始大表反复扫描。
踩坑记录:我一开始用
LIMIT 100000, 20这种写法,前几页很快,翻到后面直接超时。换成游标分页后,翻到第一万页也是毫秒级。
6.4 查询参数与超时控制
复杂聚合查询可能跑很久,得给单条查询设超时。ClickHouse 支持在 SQL 里加SETTINGS:
SELECT ... FROM analytics.event_log WHERE ... SETTINGS max_execution_time = 30, max_memory_usage = 10000000000max_execution_time是秒,max_memory_usage是字节。这两个参数能防止一条烂查询把整个服务拖垮。我在 Mapper 里对分析类查询都加了这两个设置,实测能有效兜底。
7. 常见问题与排查技巧实录
7.1 问题速查表
把我在项目里遇到的和社区里高频出现的问题整理成表,方便你对照排查:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 连接超时 | socket_timeout 太小 | 调大到 300 秒 |
| 插入报 Too many parts | 单条或小批量频繁写 | 改大批量写入 |
| 查询超时 | 没带分区/排序键过滤 | 补上 WHERE 条件 |
| 时间字段差 8 小时 | 时区不一致 | 统一用 UTC 或配时区 |
| UInt64 变负数 | Java 用 Long 接 | 改用 BigInteger |
| Nullable 字段 NPE | Java 用基本类型 | 改用包装类型 |
| 中文乱码 | 字符集问题 | 连接串加 charset |
| 驱动类找不到 | 缺 classifier=all | 补上依赖 |
7.2 时区问题:最隐蔽的坑
时间字段差 8 小时,这个坑我踩得最深。原因是 ClickHouse 服务端默认时区、JDBC 连接时区、JVM 时区三者可能不一致。ClickHouse 的DateTime类型存储的是 UTC 时间戳,但显示时按服务端时区转换。
解决办法是统一时区。我在 JDBC URL 里显式指定:
jdbc:clickhouse://127.0.0.1:9000/analytics?use_server_time_zone=false&use_time_zone=Asia/Shanghai同时 JVM 启动参数加-Duser.timezone=Asia/Shanghai。三处对齐后,时间就准了。如果你对时区不敏感,全用 UTC 也行,但千万别混着用。
7.3 批量插入偶发失败的处理
批量插入不是 100% 成功的,网络抖动、服务端压力大、单批数据太大都可能失败。我的处理策略是:
- 批次分片:2000 条一批,失败时拆成 200 条的小批重试;
- 重试上限:最多重试 3 次,还失败就落盘;
- 落盘兜底:失败数据写本地文件,后台定时任务扫描重灌;
- 监控告警:失败率超过阈值就告警,人工介入。
这套机制上线后,写入成功率稳定在 99.99% 以上。
7.4 MyBatis 映射的常见错误
有几个映射错误特别常见,列出来提醒:
- 字段名大小写:ClickHouse 列名默认小写,Java 属性驼峰,resultMap 里 column 要写实际列名;
- resultType 用 Map 时:返回的 key 是列名,不是属性名,前端取值要注意;
- 批量插入的 parameterType:foreach 里 item 的属性名要和实体类一致,写错了报
There is no getter; - LocalDateTime 转换:老版本 MyBatis 需要自定义 TypeHandler,新版本(3.4.5+)内置支持。
独家技巧:调试 MyBatis 时,把日志级别调到 DEBUG,能看到实际执行的 SQL 和参数。ClickHouse 的报错信息通常很详细,会告诉你哪一列、哪个类型出问题,顺着报错查基本都能定位。
8. 一些实战中的经验补充
8.1 连接池的坑
HikariCP 配 ClickHouse 有个细节:ClickHouse 的连接不像 MySQL 那样"用完即还",长连接更合适。所以max-lifetime别设太短,我设的 30 分钟。另外connection-test-query对 ClickHouse 要用SELECT 1,别用 MySQL 那套。
8.2 大批量写入的内存控制
一次查太多数据到内存再批量写,容易 OOM。我的做法是流式读取 + 分批写:从数据源(比如 Kafka 或文件)读一批,写一批,清一批。每批 2000 条,内存占用很平稳。
8.3 物化视图加速查询
如果某些聚合查询特别频繁,可以建物化视图预聚合。比如按天按 App 的 PV 统计,建个物化视图,查询时直接查视图,速度提升几十倍。但物化视图会增加写入开销,得权衡。
8.4 监控指标要看哪些
生产环境我重点监控这几个:写入 QPS、写入延迟 P99、查询延迟 P99、连接池活跃连接数、失败率。ClickHouse 服务端还有system.parts表能看 part 数量,part 太多说明写入策略有问题。
8.5 版本升级的注意事项
ClickHouse 版本迭代快,升级前一定要在测试环境验证。重点看驱动兼容性、SQL 方言变化、默认参数调整。我有次升级服务端,结果某个聚合函数的行为变了,查询结果对不上,回滚才恢复。所以升级前备份、灰度、回滚预案一个都不能少。
这套 SpringBoot + MyBatis + ClickHouse 的组合,我从零搭到稳定运行大概花了两周,其中大部分时间花在写入性能调优和时区排查上。如果你照着上面的配置和思路走,应该能少走不少弯路。真正上手之后你会发现,ClickHouse 的查询能力确实对得起它的口碑,只要写入这块理顺了,剩下的就是享受秒级聚合的快感。