Mybatis操作Clickhouse数组的最佳实践分享
作者:Jaising666
ClickHouse Array 类型概述
ClickHouse 的 Array(T) 数据类型支持任意有效数据类型作为元素,包括基本类型、嵌套数组和可空类型,关键特性包括:
- 索引从1开始:区别于多数编程语言的 0 索引机制
- 自动类型推断:选择最窄兼容类型以优化存储
- 严格类型检查:混合不兼容类型将导致异常
- NULL值处理:包含 NULL 值时自动转换为 Nullable 类型
-- 有效用法 SELECT array(1, 2, 3); -- Array(UInt8) SELECT array('a', 'b', 'c'); -- Array(String) SELECT array([1, 2], [3, 4]); -- Array(Array(UInt8)) -- 无效用法:类型不兼容 SELECT array(1, 'a'); -- Error: There is no supertype for types UInt8, String
MyBatis 写入 ClickHouse 数组的两种方法比较
JPA 与 Mybatis 是常见的两种 ORM 框架。JPA 主要为 OLTP 设计,ClickHouse 是 OLAP 数据库,JPQL 难以表达 ClickHouse 的复杂分析查询,而这正好可以发挥 Mybaits 灵活控制 SQL 的特性。对于 Clickhouse 的数组类型写入一般有两种方法:
方法一:使用 ClickHouse array() 函数 + ${} 参数替换
Mybatis XML 代码:
insert into xxx_base (array1) values (array(${array1Value}))
Java 代码:
// 手动格式化数组 List<String> userIds = Arrays.asList("user1", "user2", "user3"); String userIdsStr = userIds.stream() .map(s -> "'" + s.replace("'", "\\'") + "'") // 转义单引号 .collect(Collectors.joining(",")); // userIdsStr = "'user1','user2','user3'" // 处理空值 String deviceIdsStr = deviceIds.isEmpty() ? "" : deviceIds.stream() .map(s -> "'" + s.replace("'", "\\'") + "'") .collect(Collectors.joining(","));
方法二:使用自定义 TypeHandler + #{} 参数绑定
Mybatis XML 代码:
insert into xxx_base (array1) values (#{array1Value,typeHandler=com.test.clickhousemybatisdemo.typehandler.ClickHouseArrayTypeHandler})
Java 代码:
// 直接使用List对象 List<String> userIds = Arrays.asList("user1", "user2", "user3"); List<String> deviceIds = new ArrayList<>(); // 空列表也可以直接使用 // TypeHandler会自动处理转换和空值情况
方案对比分析
维度 | array() + ${} | TypeHandler + #{} |
---|---|---|
安全性 | ❌ SQL注入风险 | ✅ 预编译安全,类型安全 |
可读性 | ❌ 需要格式化处理 | ✅ 直接使用List |
可维护性 | ❌ 逻辑分散 | ✅ 逻辑集中 |
性能 | ⚠️ 字符串拼接开销 | ✅ 预编译缓存 |
得到的结论是推荐方法二:
- 安全性差异:
${}
参数替换存在 SQL 注入漏洞,#{}
预编译机制提供安全保障 - 开发复杂度:字符串拼接方案需要复杂的格式化与转义处理,TypeHandler 方案支持直接对象操作
自定义 TypeHandler 的优势
MyBatis 内置的 TypeHandler 主要针对关系型数据库的标准 SQL 类型设计,对于 ClickHouse 这样的分析型数据库的特殊数据类型支持有限,Java 的List<T>
与 ClickHouse 的Array(T)
之间缺少直接的类型转换机制。而如果使用 Java String 来处理又会带来数据类型频繁转换的工程问题,代码可读性与可维护性都会受到影响。
ClickHouse JDBC 驱动对数组类型的处理与传统关系型数据库存在差异:
// 传统数据库的数组处理(如PostgreSQL) Array sqlArray = connection.createArrayOf("varchar", stringArray); // ClickHouse需要特殊的类型名称映射 Array sqlArray = connection.createArrayOf("String", stringArray); // 注意:"String"而非"varchar"
于是,扩展 TypeHandler 实现处理数组问题就变得有必要:
- 双向转换:实现 Java
List<T>
↔ ClickHouseArray(T)
的无缝转换 - 类型安全:确保编译期和运行期的类型一致性
- 空值处理:正确处理 null 值和空数组的边界情况
- 性能优化:避免不必要的字符串拼接和解析开销
扩展 TypeHandler 支持 Clickhouse Array
TypeHandler 接口设计
Mybatis TypeHandler 的设计就是为了缓解 JDBC 与 Java 数据类型不匹配的问题,通过扩展 TypeHandler 可以对各种数据库的各种数据类型予以支持。
TypeHandler 接口很简洁,一个是 setParameter 方法通过 PreparedStatement 为 SQL 语句绑定参数,实现 JDBC 到 Java 的数据类型转换;另外三个 getResult 重载方法通过 ResultSet 获取数据时,将 Java 转换成 JDBC 数据类型。
public interface TypeHandler<T> { void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException; T getResult(ResultSet rs, String columnName) throws SQLException; T getResult(ResultSet rs, int columnIndex) throws SQLException; T getResult(CallableStatement cs, int columnIndex) throws SQLException; }
BaseTypeHandler 抽象类设计
BaseTypeHandler 实现 TypeHandler 接口抽象成基类,setParameter 提取出参数是否为空的条件判断,如果为空就会调用 PreparedStatement#setNull,如果不为空就会调用 setNonNullParameter。前者会委托给具体的数据库驱动,这里引入的 clickhouse-jdbc 就会实现 setNull 方法;后者则会交给具体的 TypeHandler 实现类。
类似的,BaseTypeHandler 实现了 TypeHandler#getResult 后抽象出了 getNullableResult 方法,委托给具体的 TypeHandler 实现。
public abstract class BaseTypeHandler<T> extends TypeReference<T> implements TypeHandler<T> { @Override public void setParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException { if (parameter == null) { if (jdbcType == null) { ... ps.setNull(i, jdbcType.TYPE_CODE); ... } else { ... setNonNullParameter(ps, i, parameter, jdbcType); ... } } @Override public T getResult(ResultSet rs, String columnName) throws SQLException { ... return getNullableResult(rs, columnIndex); ... } @Override public T getResult(ResultSet rs, int columnIndex) throws SQLException { ... return getNullableResult(rs, columnIndex); ... } @Override public T getResult(CallableStatement cs, int columnIndex) throws SQLException { ... return getNullableResult(rs, columnIndex); ... } public abstract void setNonNullParameter(PreparedStatement ps, int i, T parameter, JdbcType jdbcType) throws SQLException; public abstract T getNullableResult(ResultSet rs, String columnName) throws SQLException; public abstract T getNullableResult(ResultSet rs, int columnIndex) throws SQLException; public abstract T getNullableResult(CallableStatement cs, int columnIndex) throws SQLException; }
要想扩展简单的 TypeHandler 就可以继承 BaseTypeHandler,实现设置非空 Java 数据类型和获取非空 JDBC 数据类型的 4 个抽象方法。
Mybatis 内置了一些常用的 BaseTypeHandler 实现类,比如 ArrayTypeHandler(当然,这个指的是 Java 中的基础类型 Array 而不是 List)、ClobTypeHandler、LocalDateTimeTypeHandler 等。
实现 BaseTypeHandler<List<String>>
参考这些内置的 BaseTypeHandler,容易实现支持 Java 的 List 与 ClickHouse 的 Array 的类型绑定,首先支持 List<String> 与 Array(String) 的类型绑定。
/** * ClickHouse Array(String) 类型处理器 * 处理 Java List<String> 和 ClickHouse Array(String) 之间的转换 */ @MappedTypes(List.class) @MappedJdbcTypes(JdbcType.ARRAY) public class ClickHouseArrayTypeHandler extends BaseTypeHandler<List<String>> { @Override public void setNonNullParameter(PreparedStatement ps, int i, List<String> parameter, JdbcType jdbcType) throws SQLException { if (parameter == null || parameter.isEmpty()) { ps.setArray(i, null); } else { // 将 List<String> 转换为数组 String[] array = parameter.toArray(new String[0]); Array sqlArray = ps.getConnection().createArrayOf("String", array); ps.setArray(i, sqlArray); } } @Override public List<String> getNullableResult(ResultSet rs, String columnName) throws SQLException { Array array = rs.getArray(columnName); return convertArrayToList(array); } @Override public List<String> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { Array array = rs.getArray(columnIndex); return convertArrayToList(array); } @Override public List<String> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { Array array = cs.getArray(columnIndex); return convertArrayToList(array); } /** * 将 SQL Array 转换为 List<String> */ private List<String> convertArrayToList(Array sqlArray) throws SQLException { if (sqlArray == null) { return new ArrayList<>(); } Object[] array = (Object[]) sqlArray.getArray(); if (array == null || array.length == 0) { return new ArrayList<>(); } List<String> result = new ArrayList<>(); for (Object item : array) { if (item != null) { result.add(item.toString()); } } return result; } }
实现 BaseTypeHandler<List<T>>
进一步的,可以将 Array(String) 扩展为支持 Array(T),这样可以处理 Clickhouse 通用数组类型。
通过动态类型检测实现多数据类型支持,getSqlTypeName 方法提供 Java 类型到 ClickHouse 类型的映射机制:
/** * ClickHouse Array(T) 类型处理器 * 处理 Java List<T> 和 ClickHouse Array(T) 之间的转换 * 支持 String, Integer, Long, Double, Float, Boolean 等基本类型 */ @MappedTypes(List.class) @MappedJdbcTypes(JdbcType.ARRAY) public class ClickHouseArrayTypeHandler<T> extends BaseTypeHandler<List<T>> { @Override public void setNonNullParameter(PreparedStatement ps, int i, List<T> parameter, JdbcType jdbcType) throws SQLException { if (parameter == null || parameter.isEmpty()) { ps.setArray(i, null); } else { // 检测元素类型并创建相应的数组 Object firstElement = parameter.get(0); String sqlTypeName = getSqlTypeName(firstElement); Object[] array = parameter.toArray(); Array sqlArray = ps.getConnection().createArrayOf(sqlTypeName, array); ps.setArray(i, sqlArray); } } @Override public List<T> getNullableResult(ResultSet rs, String columnName) throws SQLException { Array array = rs.getArray(columnName); return convertArrayToList(array); } @Override public List<T> getNullableResult(ResultSet rs, int columnIndex) throws SQLException { Array array = rs.getArray(columnIndex); return convertArrayToList(array); } @Override public List<T> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { Array array = cs.getArray(columnIndex); return convertArrayToList(array); } /** * 将 SQL Array 转换为 List<T> */ @SuppressWarnings("unchecked") private List<T> convertArrayToList(Array sqlArray) throws SQLException { if (sqlArray == null) { return new ArrayList<>(); } Object[] array = (Object[]) sqlArray.getArray(); if (array == null || array.length == 0) { return new ArrayList<>(); } List<T> result = new ArrayList<>(); for (Object item : array) { if (item != null) { result.add((T) convertToTargetType(item)); } } return result; } /** * 根据 Java 对象类型获取对应的 SQL 类型名称 */ private String getSqlTypeName(Object obj) { if (obj instanceof String) { return "String"; } else if (obj instanceof Integer) { return "Int32"; } else if (obj instanceof Long) { return "Int64"; } else if (obj instanceof Double) { return "Float64"; } else if (obj instanceof Float) { return "Float32"; } else if (obj instanceof Boolean) { return "UInt8"; } else { // 默认转换为字符串 return "String"; } } /** * 将对象转换为目标类型 */ private Object convertToTargetType(Object obj) { // 对于基本类型,直接返回 if (obj instanceof String || obj instanceof Integer || obj instanceof Long || obj instanceof Double || obj instanceof Float || obj instanceof Boolean) { return obj; } // 对于其他类型,转换为字符串 return obj.toString(); } }
Docker 容器化测试
这里引入 TestContainers 实现自动化测试,避免安装 Clickhouse 的繁琐、避免使用替身数据库无法还原真实依赖,确保 MyBatis 与ClickHouse 数组操作的可靠性。
环境配置
Docker Compose 配置(docker-compose.yml
):
version: '3.8' services: clickhouse: image: clickhouse/clickhouse-server:latest ports: - "8123:8123" - "9000:9000" environment: CLICKHOUSE_DB: test_db CLICKHOUSE_USER: test_user CLICKHOUSE_PASSWORD: test_password healthcheck: test: ["CMD", "wget", "--spider", "http://localhost:8123/ping"] interval: 30s timeout: 10s
Maven 依赖:
<dependencies> <dependency> <groupId>org.testcontainers</groupId> <artifactId>testcontainers</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> </dependencies>
集成测试实现
测试类核心实现:
@SpringBootTest @ActiveProfiles("test") @Testcontainers class ClickHouseIntegrationTest { @Container static GenericContainer<?> clickhouseContainer = new GenericContainer<>( DockerImageName.parse("clickhouse/clickhouse-server:latest")) .withExposedPorts(8123, 9000) .withEnv("CLICKHOUSE_DB", "test_db") .withEnv("CLICKHOUSE_USER", "test_user") .withEnv("CLICKHOUSE_PASSWORD", "test_password") .waitingFor(Wait.forHttp("/ping").forPort(8123)); @Autowired private xxxMapper xxxMapper; @Autowired private JdbcTemplate jdbcTemplate; @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", () -> "jdbc:clickhouse://localhost:" + clickhouseContainer.getMappedPort(8123) + "/test_db"); registry.add("spring.datasource.username", () -> "test_user"); registry.add("spring.datasource.password", () -> "test_password"); } @BeforeEach void initializeDatabase() { jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS test_db"); jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS xxx_base (" + "uuid String, name String, xxx_type Array(String), userIds Array(String), " + "deviceIds Array(String)) ENGINE = MergeTree() ORDER BY uuid"); } }
核心测试用例
容器状态验证:
@Test void testContainerIsRunning() { assertTrue(clickhouseContainer.isRunning()); }
数组 CRUD 操作测试:
@Test void testArrayInsertAndQuery() { int result = xxxMapper.insert(testxxx); assertEquals(1, result); List<xxxBase> list = xxxMapper.selectByPage(0, 10); assertNotNull(list); xxxBase found = list.stream() .filter(a -> test.getUuid().equals(a.getUuid())) .findFirst().orElse(null); assertNotNull(found.getUserIds()); }
执行测试
命令行执行:
# 使用TestContainers自动化测试 mvn test -Dtest=ClickHouseIntegrationTest # 或使用Docker Compose手动环境 docker-compose up -d && mvn test
测试配置(application-test.properties
):
mybatis.type-handlers-package=com.test.clickhousemybatisdemo.typehandler logging.level.com.test.clickhousemybatisdemo=DEBUG
验证结果
容器化读写测试验证 TypeHandler 实现了 Java List<T> 与 Clickhouse Array(T) 的映射关系。
以上就是Mybatis操作Clickhouse数组的最佳实践分享的详细内容,更多关于Mybatis操作Clickhouse数组的资料请关注脚本之家其它相关文章!