☰
SpringBoot + MyBatis 集成 ClickHouse:配置、插入查询与避坑指南
2026/10/15 2:24:36 网站建设 项目流程

1. 为什么要在 SpringBoot 里接 ClickHouse 而不是继续用 MySQL

做过数据类项目的朋友大概率都遇到过这个场景:业务表数据量一过千万,MySQL 的查询就开始肉眼可见地变慢,加索引、分库分表、读写分离一套组合拳打下来,维护成本高得离谱,而业务方要的只是"按时间维度做个聚合统计"这种看似简单的需求。这时候 ClickHouse 就该登场了。它是一个列式存储的 OLAP 数据库,天生为海量数据的聚合分析而生,单表几十亿行做 count、sum、group by 这类操作,响应时间经常能压到毫秒级,这是行式存储的 MySQL 很难做到的。

但问题来了,ClickHouse 虽然查询猛,可它毕竟不是给业务系统当主库用的,我们的业务代码还是跑在 SpringBoot 里,数据访问层习惯用 MyBatis。于是"SpringBoot + MyBatis 配置 ClickHouse,实现插入和查询"就成了一个非常典型的落地需求。这篇内容就是围绕这个场景展开的,我会把从依赖引入、数据源配置、Mapper 编写到实际插入查询的完整链路讲清楚,同时把我在实操中踩过的坑一并交代出来。适合已经有 SpringBoot 和 MyBatis 基础、正准备把 ClickHouse 接进项目的开发者参考,纯新手也能跟着走通,因为关键的地方我都会解释清楚为什么这么做。

先说一个很多人会犯的认知错误:以为 ClickHouse 可以像 MySQL 一样直接替换。实际上 ClickHouse 对标准 SQL 的支持是"够用但不完整"的,它不支持完整的事务,不支持高频的单行 update/delete(早期版本基本没有,后来有了也是异步的、代价很大),JOIN 的性能也远不如单表聚合。所以正确的姿势是把它当成一个"分析型副库",业务写入走 MySQL,通过同步或者异步的方式把数据灌到 ClickHouse,查询分析走 ClickHouse。理解了这层定位,后面的配置和代码才不会走偏。

2. 依赖选型:JDBC 驱动到底该用官方版还是社区版

2.1 两种驱动的本质区别

接 ClickHouse 第一步就是选驱动。市面上主要有两个选择:一个是 ClickHouse 官方提供的clickhouse-jdbc,另一个是早期社区常用的ru.yandex.clickhouse那个老包。这里有个非常容易踩的坑——包名不一样,类名也不一样,混用会直接报 ClassNotFound。

官方驱动现在的坐标是com.clickhouse:clickhouse-jdbc,它同时支持 HTTP 和 TCP 两种协议,推荐用 HTTP 协议(默认 8123 端口),因为兼容性最好,穿透各种网络环境也更省心。老版本的ru.yandex.clickhouse:clickhouse-jdbc已经停止维护了,新项目千万别再用。

在pom.xml里引入的时候,我一般会带上allclassifier 或者配合clickhouse-http-client,这样能把依赖的 httpclient 一起拉进来,省得后面手动补包:

<dependency> <groupId>com.clickhouse</groupId> <artifactId>clickhouse-jdbc</artifactId> <version>0.6.0</version> <classifier>all</classifier> </dependency>

注意:classifier用all会把 shaded 过的依赖打进来,好处是省心,坏处是可能和你项目里已有的 httpclient 版本冲突。如果你的项目本身对 httpclient 版本敏感,那就去掉 classifier,改成单独引clickhouse-http-client,自己控制版本。

2.2 版本兼容性这件事必须提前确认

ClickHouse 服务端的版本和 JDBC 驱动的版本之间是有兼容关系的。我遇到过最典型的问题就是:服务端是比较新的版本,驱动用的是很老的 0.3.x,结果连接能建立,但一执行某些带新类型(比如Nullable、LowCardinality)的查询就报解析异常。所以选驱动版本时,尽量和服务端版本保持接近,或者至少用官方文档里标注兼容的版本区间。

另外 SpringBoot 的版本也要留意。SpringBoot 2.x 和 3.x 在数据源自动配置上有差异,3.x 默认用的是 HikariCP,而且对 JDBC URL 的解析更严格。如果你是从 2.x 升级上来的项目,配置项名字可能要对一遍。

2.3 为什么不用 MyBatis-Plus 直接接

有朋友会问,既然用了 MyBatis,能不能直接上 MyBatis-Plus?我的建议是:可以,但要谨慎。MyBatis-Plus 的很多便利功能(比如自动填充、逻辑删除、分页插件)都是围绕 MySQL 这类支持事务和标准 SQL 的数据库设计的,套到 ClickHouse 上不一定好使。比如它的分页插件生成的LIMIT ?, ?语法 ClickHouse 是支持的,但一些批量操作生成的 SQL 可能就不兼容。所以接 ClickHouse 我更倾向于用原生 MyBatis,SQL 自己写,可控性最强,出问题也好排查。

3. 数据源配置:一个项目里同时挂 MySQL 和 ClickHouse 怎么搞

3.1 单数据源配置的写法

如果你的项目里只有 ClickHouse 一个数据源,那配置相对简单。在application.yml里这样写:

spring: datasource: driver-class-name: com.clickhouse.jdbc.ClickHouseDriver url: jdbc:clickhouse://127.0.0.1:8123/default username: default password: "" hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000

这里的 URL 格式是jdbc:clickhouse://host:port/database,默认端口 8123 是 HTTP 协议。用户名密码按你服务端实际配置来,很多测试环境默认就是default空密码。

连接池这块我要多说一句。ClickHouse 的查询往往比较重,单个查询可能占用较长时间,所以连接池的maximum-pool-size不要设太大,否则容易把服务端的连接数打满。我一般控制在 10 到 20 之间,配合合理的connection-timeout,避免请求堆积。

3.2 多数据源共存才是真实场景

实际项目里更常见的是:MySQL 作为业务主库,ClickHouse 作为分析库,两个都要用。这时候就得配多数据源。核心思路是手动定义两个DataSourceBean,分别绑定不同的Mapper包路径,然后用@Configuration类区分开。

先定义 ClickHouse 的数据源配置类:

@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().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(); } }

MySQL 那边同理,只是包路径和前缀换成对应的。这里的关键点是@MapperScan的basePackages一定要分开,sqlSessionFactoryRef也要指向各自的工厂,否则 MyBatis 会不知道某个 Mapper 该用哪个数据源,启动就报错。

提示:多数据源场景下,@Transactional注解要特别小心。它默认只对主数据源生效,如果你在操作 ClickHouse 的方法上加了事务注解,要么不生效,要么报错。ClickHouse 本身也不支持传统事务,所以涉及 ClickHouse 的操作就别加事务了。

3.3 配置文件里的前缀要对应上

多数据源时,application.yml里要写两组配置,前缀和上面@ConfigurationProperties里的一致:

spring: datasource: mysql: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/biz?useSSL=false username: root password: 123456 clickhouse: driver-class-name: com.clickhouse.jdbc.ClickHouseDriver url: jdbc:clickhouse://127.0.0.1:8123/analytics username: default password: ""

这样两个数据源互不干扰,各管各的 Mapper,逻辑清晰。

4. 建表与实体映射:ClickHouse 的表设计有几个反直觉的点

4.1 引擎选择决定了这张表能干什么

ClickHouse 建表必须指定引擎,这是和 MySQL 最大的不同之一。最常用的就是MergeTree家族。一个典型的日志分析表长这样:

CREATE TABLE user_event ( event_date Date, user_id UInt64, event_type String, amount Float64, create_time DateTime ) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, user_id);

这里有几个点要解释。PARTITION BY决定数据按什么分区,一般按时间月或者天来分,方便过期数据整体删除。ORDER BY是 ClickHouse 的排序键,它直接决定了主键索引的构建方式,查询时如果 where 条件命中了排序键的前缀,性能会非常好。所以ORDER BY的字段顺序要按查询频率来排,最常用的过滤字段放前面。

注意:ClickHouse 的主键和 MySQL 的主键概念不一样。它没有唯一约束,你插入两条完全一样的记录它不会拦你。所以去重逻辑得在业务层或者用ReplacingMergeTree引擎自己处理。

4.2 数据类型映射要一一对应

Java 类型和 ClickHouse 类型的映射关系,如果对不上,插入时就会报类型转换异常。常见的对应关系我整理成表格,方便对照:

ClickHouse 类型Java 类型说明
UInt8/16/32/64Integer / Long无符号整数,注意范围
Int8/16/32/64Integer / Long有符号整数
Float32/64Float / Double浮点数
StringString变长字符串
Datejava.sql.Date / LocalDate日期
DateTimejava.sql.Timestamp / LocalDateTime日期时间
Nullable(T)包装类型允许为 null,基础类型不行

最容易踩的坑是Nullable。ClickHouse 里如果一个字段声明成Nullable(String),那 Java 侧必须用包装类型或者 String(String 本身可为 null),但如果是Nullable(Int32),Java 侧就得用Integer而不是int,否则插入 null 会直接抛异常。

4.3 实体类怎么写才不出错

实体类本身没什么特别的,就是普通的 POJO,字段名和表字段对应即可。但有个细节:ClickHouse 的字段名是大小写敏感的,而 MyBatis 默认的驼峰转下划线映射在 ClickHouse 上不一定按你预期工作。我的做法是老老实实在 XML 里写resultMap,把列名和属性名显式对应上,别偷懒靠自动映射。

public class UserEvent { private LocalDate eventDate; private Long userId; private String eventType; private Double amount; private LocalDateTime createTime; // getter setter 省略 }

5. Mapper 层实操:插入和查询的 SQL 到底怎么写

5.1 插入:批量插入才是正确姿势

ClickHouse 最忌讳的就是一条一条插。它的设计是面向批量写入的,单条插入的开销极大,几千条单插能把性能拖垮。所以插入一定要用批量。MyBatis 里用<foreach>拼批量 values:

<insert id="batchInsert" parameterType="java.util.List"> INSERT INTO user_event (event_date, user_id, event_type, amount, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.eventDate}, #{item.userId}, #{item.eventType}, #{item.amount}, #{item.createTime}) </foreach> </insert>

对应的 Mapper 接口:

public interface UserEventMapper { int batchInsert(@Param("list") List<UserEvent> list); }

这里有个经验值:单批次插入的记录数控制在 1000 到 10000 条之间比较合适。太小了网络往返开销占比高,太大了单条 SQL 太长可能触发服务端的max_query_size限制。我一般用 5000 一批,实测下来很稳。

提示:如果数据量特别大,比如一次要灌几十万条,建议在 Service 层做分批,每 5000 条调一次batchInsert,而不是拼一个巨大的 SQL。这样内存占用可控,失败也容易重试。

5.2 查询:聚合统计是 ClickHouse 的主场

查询这块,ClickHouse 的强项是聚合。比如统计某天各事件类型的总金额:

<select id="sumByEventType" resultType="com.example.dto.EventStat"> SELECT event_type AS eventType, sum(amount) AS totalAmount, count() AS cnt FROM user_event WHERE event_date = #{date} GROUP BY event_type ORDER BY totalAmount DESC </select>

注意count()这个写法,ClickHouse 里count()不带参数就是统计行数,比count(*)更地道,性能也更好。另外 ClickHouse 有很多 MySQL 没有的聚合函数,比如uniq()做去重计数、quantile()算分位数,这些在数据分析场景里非常有用,值得花时间了解一下。

5.3 分页查询的坑

ClickHouse 的分页和 MySQL 一样用LIMIT offset, size,但深分页(offset 很大)在 ClickHouse 上性能会急剧下降,因为它还是要扫描前面的数据。如果业务上确实需要深分页,更好的做法是用排序键做游标分页,比如WHERE event_date > #{lastDate} ORDER BY event_date LIMIT 100,每次带上上一页的最后一条记录的排序键值。这个思路和很多大数据场景的翻页优化是一致的。

6. 实测中那些文档不会告诉你的坑

6.1 时间类型和时区问题

ClickHouse 的DateTime默认用的是服务端的时区,而 Java 侧LocalDateTime是没有时区概念的。如果两边时区不一致,插入和查询出来的时间就会差几个小时。我踩过一次,数据插进去看着是对的,查出来全偏了 8 小时。解决办法有两个:一是保证服务端和应用的时区配置一致,二是在 JDBC URL 里显式指定时区参数。我倾向于后者,明确比默认靠谱。

6.2 连接池空闲连接被服务端断开

ClickHouse 服务端有个keep_alive_timeout,默认比较短。如果连接池里的空闲连接长时间不用,服务端会主动断开,而连接池这边还以为连接是好的,下次取出来用就报连接已关闭。解决办法是在 HikariCP 里配置max-lifetime小于服务端的超时时间,并且开启连接有效性检测:

hikari: max-lifetime: 600000 connection-test-query: SELECT 1 validation-timeout: 3000

SELECT 1这个检测语句 ClickHouse 是支持的,可以放心用。

6.3 大批量插入时的内存溢出

前面说了批量插入,但如果你的实体对象本身很大,一次攒几千条在内存里,再加上 MyBatis 拼 SQL 时的字符串拼接,很容易 OOM。我的做法是控制单批大小,并且在 Service 层用流式处理,边读边写,不要一次性把所有数据加载到 List 里。如果数据源本身是文件或者另一个库,用游标分批读,读一批写一批。

6.4 查询结果字段名大小写

ClickHouse 返回的列名默认是原样返回的,如果你 SQL 里写了别名AS eventType,那结果集里就是eventType。但如果你没写别名,直接用event_type,那 MyBatis 映射到 Java 属性eventType时,自动映射可能匹配不上(取决于你的mapUnderscoreToCamelCase配置)。所以我在 ClickHouse 的查询里一律显式写别名,避免这种模糊地带。

7. 一个完整的插入查询链路串起来

把上面的东西串一遍,一个典型的 Service 方法大概长这样:

@Service public class EventService { @Autowired private UserEventMapper userEventMapper; public void saveEvents(List<UserEvent> events) { int batchSize = 5000; for (int i = 0; i < events.size(); i += batchSize) { int end = Math.min(i + batchSize, events.size()); List<UserEvent> sub = events.subList(i, end); userEventMapper.batchInsert(sub); } } public List<EventStat> statByDate(LocalDate date) { return userEventMapper.sumByEventType(date); } }

这段代码没什么花哨的,但每一步都有讲究:分批是为了控制内存和 SQL 长度,查询直接走聚合是为了发挥 ClickHouse 的优势。实际项目里你可能还要加上异常处理、日志记录、慢查询监控,但核心链路就是这样。

8. 关于性能调优,我个人的几条经验

ClickHouse 的性能调优是个大话题,这里只讲和 SpringBoot 集成相关的几条。第一,查询尽量带上分区键的过滤条件,比如WHERE event_date = ?,这样 ClickHouse 能直接跳过不相关的分区,扫描量能降一个数量级。第二,避免SELECT *,列式存储的优势就是只读需要的列,你SELECT *等于把这个优势扔了。第三,聚合查询如果结果集很大,考虑在 ClickHouse 侧用LIMIT或者物化视图预聚合,别把几百万行结果拉回应用层再处理。

还有一点,ClickHouse 的JOIN性能确实一般,尤其是大表 JOIN 大表。如果业务上需要关联,尽量把小表放到右边,或者干脆在写入时就把数据打宽,做成一张大宽表,查询时就不用 JOIN 了。这是 OLAP 场景很常见的思路,用存储换查询性能。

我在实际使用中发现,很多性能问题其实不是 ClickHouse 本身的问题,而是用法不对。比如把 ClickHouse 当 MySQL 用,频繁单条写入、频繁 update、大量 JOIN,这些都会让它很难受。把它放在它擅长的位置上,它就能给你惊喜。踩过几次坑之后我的体会是:理解一个工具的边界,比学会它的语法更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询