先从一个真实场景说起。做过企业级应用的朋友应该都遇到过这种需求:用户基本信息在 MySQL,订单数据在 PostgreSQL,物流轨迹在 Oracle,查一个“订单全链路”要把三个库拧在一起,更别提可能还有 Elasticsearch 里的检索数据、第三方接口返回的 JSON。以前最常见的做法是把数据同步到一张大宽表或数仓里,但实时性、同步任务维护成本都让人头疼。用 MyBatis 配多个数据源也只是解决“切换”,解决不了“关联”。我最近在 Spring Boot 3 项目里把 Apache Calcite 完整集成了一遍,实现了任意 JDBC 数据源动态注册、统一 SQL 查询入口、跨库关联查询、条件自动下推,整套链路跑通之后,发现这确实是多数据源查询场景里非常优雅的解。这篇文章把整体设计、核心代码、源码层面的关键机制以及我踩过的坑完整记录下来,适合正在做数据中台、数据联邦、统一查询引擎的团队参考。
1. 为什么是 Calcite:多数据源查询的两条技术路线
1.1 我们真正要解决的问题是什么
多数据源查询的难点从来不是“能不能连上多个库”,而是“能不能让上层业务像查一个库那样查多个库”。你当然可以让业务代码里写两次查询、内存里做关联,但一旦表多、查询维度多了,代码会膨胀到完全不可维护。
我把它拆成三个具体诉求:
- 统一入口:上层只需要面对一个 SQL 接口,不需要知道数据在哪个库。
- 跨库关联:一条 SQL 能 join 不同物理库的表,而不是在应用层手工组装。
- 性能基本可控:能下推到目标库的过滤条件、分页条件必须下推,不能每次都是全表拉取再到内存里面算。
这三个诉求叠加在一起,需要的其实是一个“数据联邦”层。它具备 SQL 解析、语法校验、执行计划优化、物理执行的能力,同时又要保持轻量,能嵌入到 Java 应用里。
1.2 四条主流路线的横向对比
在确定方案之前我认真评估过四条路线,简单说下结论。
| 方案 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| Spring Boot 多数据源 + 动态路由 | 根据请求头或注解切换 DataSource | 实现简单,侵入小 | 无法跨库 join,无法统一 SQL 入口 |
| 定时同步到数仓 / ES | 把多个库数据汇聚到统一存储 | 查询性能好 | 实时性差,同步链路复杂,数据冗余 |
| ShardingSphere | 数据分片 + 统一 SQL 入口 | 功能强大,生态完善 | 强项是分库分表,异构源适配一般,体量偏重 |
| Apache Calcite | 数据联邦,SQL 解析与执行引擎 | 轻量可嵌入,支持任意异构源,下推能力强 | 需要自己写适配层,源码上手有一些门槛 |
关于 ShardingSphere 多说一句,它确实也能做多数据源联邦查询,但它的定位更偏向分库分表中间件,做联邦查询时要引入整套 ShardingSphere-JDBC 的规则引擎。而 Calcite 本身就是“无数据”的 SQL 引擎,它不管理任何数据存储,只负责把 SQL 翻译成对各类数据源的访问计划,这个定位天然适合做数据联邦。
1.3 Calcite 的核心设计:解析、优化、执行完全解耦
Calcite 最特别的一点是它不是一个数据库,它只有一个 SQL 引擎。它把整个处理链路抽象成几个独立阶段:解析把 SQL 文本变成 SqlNode 语法树,校验阶段根据 Schema 元数据验证表的字段、类型,优化阶段由 Volcano/CostBased 优化器基于成本模型把逻辑计划转成物理计划,执行阶段再交给具体的 Enumerable 实现或适配器去完成。
这种分层带来的最大好处是“任意数据源都能接入”。你只需要在 Calcite 的 Schema 体系里暴露一张虚拟表,实现好它的行类型和扫描逻辑,Calcite 就能把 SQL 中对这张表的过滤、投影、聚合等操作翻译成它内部的执行算子。对于 JDBC 类数据源,Calcite 甚至自带 JdbcAdapter,你只需注册一个 JdbcSchema,它就能自动把条件翻译成目标数据库的方言 SQL 下发执行。
这也是为什么我把方案定为 Calcite,而不是自己去写一套 SQL 假引擎。我要做的只是把 Spring Boot 这边的数据源管理、元数据加载、动态注册、结果封装等一系列业务逻辑补齐,复杂的内核部分交给 Calcite。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构与核心设计思路
2.1 从一条 SQL 到最终结果的完整链路
先把整体链路画出来,后面所有代码都是围绕这条链路来的。
- 业务层发起查询,调用统一查询服务,传入 SQL 字符串。
- 查询服务创建 Calcite Connection,把已经注册好的 Schema(对应各个数据源)挂载到连接上。
- Calcite 解析 SQL,生成逻辑执行计划。
- 优化器根据表定义、成本模型生成物理执行计划,尽量把过滤、投影、分页条件下推到目标数据源。
- 执行算子(Enumerable 或 JdbcAdapter)连接实际数据源,拿回数据。
- 查询服务把 ResultSet 统一封装成业务模型返回。
这个链路里,业务层完全感知不到底层有多少个数据源,也不知道 SQL 被拆成了几条子查询去执行。
2.2 版本选型与依赖管理
Calcite 的版本兼容性是个容易踩坑的点。我项目里 Spring Boot 用的是 3.2.x,Java 17,Calcite 选的是 1.36.0。这个组合实测是可以稳定工作的。
Spring Boot 3 本身要求 Java 17,Calcite 从 1.32 起也要求 Java 11 以上,1.36 在 Java 17 环境下没有问题。但要注意 Calcite 传递依赖里的 Guava 版本比较新,如果你项目里其他组件对 Guava 版本有强制要求,需要做好统一管理。这里给出我项目里的最小依赖配置。
xml复制<dependency>
<groupId>org.apache.calcite</groupId>
<artifactId>calcite-core</artifactId>
<version>1.36.0</version>
</dependency>
<dependency>
<groupId>org.apache.calcite</groupId>
<artifactId>calcite-linq4j</artifactId>
<version>1.36.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
calcite-linq4j 其实是 calcite-core 的传递依赖,大多数情况下不用显式声明。我之所以单独列出来,是因为某些场景下需要直接用 Linq4j 的 Enumerator 接口,显式声明可以避免版本被其他组件意外覆盖。
2.3 元数据驱动的动态注册机制
多数据源集成的难点之一是怎么让“新接入一个数据源”这件事变成配置化操作,而不是每次加一堆硬编码代码。我的做法是定义一套数据源注册中心,把数据源的连接信息、方言类型、可选表白名单统一管理起来。
注册中心底层维护的是 Calcite 的 SchemaPlus 根 Schema。每个数据源对应根 Schema 下的一个子 Schema,例如 mysql_orders、postgres_user、oracle_logistics。业务 SQL 里直接写 SELECT ... FROM mysql_orders.orders,Calcite 会自动路由到对应 Schema 下的表。
动态注册的核心优点在于:新数据源接入只需要往注册中心写入一条元数据,Java 代码不用改。这正好满足我对“统一查询平台”的预期,后续接几十个数据源只是添加配置的问题。
2.4 一个关键取舍:SQL 下推优先,而不是全量拉取
在设计初期,我纠结过一个问题:跨库 join 时,Calcite 是应该把所有参与 join 的表全量拉回内存再计算,还是把能下推的都下推到各个数据源?答案是必须尽量下推。
举个最简单的例子:SELECT * FROM mysql_orders.orders o JOIN postgres_user.users u ON o.user_id = u.id WHERE o.status = 'PAID' AND u.age > 18。
如果把全量数据都拉回执行引擎做 join,那么 MySQL 表哪怕只有一百万条,也要全部通过网络传到应用内存,性能和稳定性都不可接受。正确的执行方式是:MySQL 端执行 WHERE status = 'PAID',PostgreSQL 端执行 WHERE age > 18,两边各返回过滤后的结果集,Calcite 再对过滤后的结果做 HashJoin。
Calcite 的优化器天生支持谓词下推(Predicate Pushdown)规则。对于 JDBC 适配器,它会自动把 RexNode 形式的条件下推进目标库的 SQL。对于自定义适配器,需要自己实现 FilterableTable 并处理下推条件。这个取舍是整个方案可行性的基础,如果下推做得不好,Calcite 集成得再漂亮也是空中楼阁。
3. 代码落地:Spring Boot 3 集成 Calcite
3.1 准备数据源注册元数据
我设计了一个简单的配置结构,用来描述一个逻辑数据源。这里以 Spring Boot 配置文件为例。
yaml复制calcite:
datasources:
- name: mysql_orders
jdbc-url: jdbc:mysql://localhost:3306/orders
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
dialect: MYSQL
- name: pg_user
jdbc-url: jdbc:postgresql://localhost:5432/userdb
username: user
password: 123456
driver-class-name: org.postgresql.Driver
dialect: POSTGRESQL
这个配置只描述连接信息,具体是哪些表可以访问,优先从数据库的元数据自动拉取。后面注册到 Calcite 时,JdbcSchema 会自动读取目标库的元数据,把所有表都暴露出来。如果需要做表白名单,可以在注册后手动移除不需要的表。
3.2 构建数据源连接池
Spring Boot 环境下最省事的方式是直接用 HikariCP 创建 DataSource,然后把 DataSource 交给 Calcite。
java复制@Component
public class DataSourceRegistry {
private final Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();
public DataSource getOrCreate(JdbcSourceConfig config) {
return dataSourceMap.computeIfAbsent(config.getName(), name -> {
HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setJdbcUrl(config.getJdbcUrl());
hikariConfig.setUsername(config.getUsername());
hikariConfig.setPassword(config.getPassword());
hikariConfig.setDriverClassName(config.getDriverClassName());
hikariConfig.setMaximumPoolSize(config.getMaxPoolSize());
return new HikariDataSource(hikariConfig);
});
}
}
这里有个细节值得一说:Calcite 的 JdbcSchema 只要一个 DataSource,但它会在执行阶段频繁获取连接。如果直接把 Spring 管理的连接池交给 Calcite,建议设置比较合理的最大连接数,因为一次跨库 join 可能同时占用多个数据源各一个连接。
3.3 注册 JdbcSchema 到 Calcite 根 Schema
这是整个集成方案里最核心的一段代码。Calcite 对 JDBC 数据源有成熟的适配器,我们不需要自己写表扫描逻辑。
java复制import org.apache.calcite.adapter.jdbc.JdbcConvention;
import org.apache.calcite.adapter.jdbc.JdbcSchema;
import org.apache.calcite.jdbc.CalciteConnection;
import org.apache.calcite.schema.SchemaPlus;
import org.apache.calcite.sql.dialect.MysqlSqlDialect;
import org.apache.calcite.sql.dialect.PostgresqlSqlDialect;
import org.apache.calcite.sql.dialect.OracleSqlDialect;
public void registerSchema(CalciteConnection calciteConnection,
String schemaName,
DataSource dataSource,
SqlDialect dialect) {
SchemaPlus rootSchema = calciteConnection.getRootSchema();
JdbcConvention convention = JdbcConvention.create(
CalciteConnectionConfig.DEFAULT,
schemaName + "_convention",
dialect
);
JdbcSchema jdbcSchema = JdbcSchema.create(
rootSchema,
schemaName,
dataSource,
convention
);
rootSchema.add(schemaName, jdbcSchema);
}
这里 schemaName 对应 SQL 里的 Schema 前缀。比如注册名为 mysql_orders,业务 SQL 就写 SELECT * FROM mysql_orders.orders。
方言类型要和实际数据库匹配,否则 Calcite 生成的下推 SQL 可能在目标库执行时报语法错误。我用一个简单的工厂类来做方言映射。
java复制public static SqlDialect getDialect(String dialectName) {
return switch (dialectName.toUpperCase()) {
case "MYSQL" -> MysqlSqlDialect.DEFAULT;
case "POSTGRESQL" -> PostgresqlSqlDialect.DEFAULT;
case "ORACLE" -> OracleSqlDialect.DEFAULT;
default -> throw new IllegalArgumentException("Unsupported dialect: " + dialectName);
};
}
dialect 不只是在生成 SQL 时起作用,它还影响 Calcite 对标识符引用方式、字符串函数的解析方式。比如 MySQL 的字符串拼接是 concat(a, b),而 Oracle 是 a || b,这些差异在跨库 SQL 下推时如果方言配错了,会出现诡异的错误。
3.4 统一查询引擎:创建 Calcite 连接并执行
由于 Calcite 的 Connection 不是设计成线程安全共享的,我整体采用“每次查询创建独立连接 + 查询前动态注册 Schema”的方式。数据源数量不多时这种做法足够高效,而且代码清晰。
java复制@Service
public class CalciteQueryEngine {
private final List<JdbcSourceConfig> sourceConfigs;
private final DataSourceRegistry dataSourceRegistry;
public CalciteQueryEngine(List<JdbcSourceConfig> sourceConfigs,
DataSourceRegistry dataSourceRegistry) {
this.sourceConfigs = sourceConfigs;
this.dataSourceRegistry = dataSourceRegistry;
}
public List<Map<String, Object>> execute(String sql) {
Properties info = new Properties();
info.setProperty("lex", "MYSQL");
try (Connection connection = DriverManager.getConnection("jdbc:calcite:", info)) {
CalciteConnection calciteConnection = connection.unwrap(CalciteConnection.class);
for (JdbcSourceConfig config : sourceConfigs) {
DataSource dataSource = dataSourceRegistry.getOrCreate(config);
SqlDialect dialect = DialectFactory.getDialect(config.getDialect());
registerSchema(calciteConnection, config.getName(), dataSource, dialect);
}
try (Statement statement = calciteConnection.createStatement();
ResultSet rs = statement.executeQuery(sql)) {
return ResultSetUtils.toList(rs);
}
} catch (SQLException e) {
throw new RuntimeException("Query execution failed: " + e.getMessage(), e);
}
}
}
jdbc:calcite: 这个 URL 是 Calcite 内嵌模式的连接地址。设置 lex=MYSQL 是因为我们业务侧 SQL 更习惯 MySQL 的大小写不敏感规则和双引号语义。
执行查询后拿到的其实就是普通 JDBC ResultSet,我用一个通用的 ResultSetUtils.toList 把它转成 List<Map<String, Object>>,然后返回给上层接口。
3.5 统一查询接口
为了不让上层业务直接操作 Calcite,我在最外面包了一层 Restful API。这是很多团队做统一查询平台的典型做法:业务方不需要引入 Calcite 依赖,只需要一个 HTTP 调用。
java复制@RestController
@RequestMapping("/api/query")
public class QueryController {
private final CalciteQueryEngine queryEngine;
public QueryController(CalciteQueryEngine queryEngine) {
this.queryEngine = queryEngine;
}
@PostMapping
public QueryResponse query(@RequestBody QueryRequest request) {
List<Map<String, Object>> data = queryEngine.execute(request.getSql());
return QueryResponse.success(data);
}
}
需要注意的是,这种接口本质上是“允许在指定数据源上执行任意 SQL”,权限管理必须跟上。我在生产环境里加了 SQL 白名单和只读账号,业务 SQL 只能以 SELECT 开头,数据库账号也只授予了 SELECT 权限,从源头把风险按住。
4. 源码层面的关键机制:解析、优化、下推与方言
4.1 解析阶段:从 SQL 文本到 SqlNode
用户传入的 SQL 字符串,首先经过 Calcite 的 SqlParser 解析成一颗 SqlNode 语法树。这棵树完整保留了 SQL 中的 SELECT 子句、FROM 子句、WHERE 条件、JOIN 关系、GROUP BY / ORDER BY 等结构。
在源码里可以看到,SqlParser 是通过 JavaCC 生成的解析器,它根据Parser.jj 语法文件把文本解析为 SqlSelect、SqlJoin、SqlIdentifier 等节点。树中的每个节点都带有 SqlParserPos,记录它在原始 SQL 里的行列位置,方便报错时定位。
java复制SqlParser parser = SqlParser.create(sql, SqlParser.Config.DEFAULT);
SqlNode sqlNode = parser.parseStmt();
这一步不涉及任何元数据,哪怕是表不存在,解析也能成功。真正发现表不存在是在下一步的校验阶段。
4.2 校验阶段与 Schema 绑定
校验阶段会把 SqlNode 和当前 CalciteConnection 里的 Schema 元数据做绑定。SqlValidator 会检查表是否存在、字段是否存在、字段类型是否匹配、聚合函数参数是否合法等。
也就是说,你在 SQL 里写 mysql_orders.orders,Calcite 会在根 Schema 下找到名为 mysql_orders 的子 Schema,再在子 Schema 下找到名为 orders 的表,然后取出这张表的 rowType,用于后续逻辑计划生成。如果 Schema 没有注册、或者表名写错,会在这里报错。
4.3 优化阶段:VolcanoPlanner 与执行计划生成
Calcite 的优化器核心是 VolcanoPlanner。它把 SQL 关系表达式(RelNode)和一系列优化规则放在一起,通过成本模型搜索代价最低的执行计划。
拿前面的跨库关联查询举例,初始逻辑计划可能是一个 JoinRelNode,左右分别是两个 TableScan。优化器会尝试应用谓词下推规则,把 status = 'PAID' 下推到 MySQL 那一侧,把 age > 18 下推到 PostgreSQL 那一侧。如果数据源支持 Project 下推,它还会把不必要的字段去掉,只查需要的列。
看执行计划有个很实用的方法:在 Calcite 里调用 RelOptUtil.toString(relRoot.rel),就能把计划打印出来。我建议集成阶段一定要做一次计划检查,确认条件确实下推下去了,而不是整表读取。
text复制LogicalProject(ORDER_ID=[$0], USER_NAME=[$4])
LogicalJoin(condition=[=($1, $6)], joinType=[inner])
LogicalFilter(condition=[=($2, 'PAID')])
LogicalTableScan(table=[[mysql_orders, orders]])
LogicalFilter(condition=[>($5, 18)])
LogicalTableScan(table=[[pg_user, users]])
如果优化器没有下推,LogicalFilter 会在 LogicalJoin 之后再出现,说明计划是先把数据拉回来再过滤,这种情况就要检查方言配置和下推规则是否开启。
4.4 方言转换:RelToSqlConverter 与大数据 SQL 下推
对于 JDBC 数据源,Calcite 的 JdbcAdapter 负责把下推的 RelNode 子计划转回目标数据库的 SQL。这里涉及的类是 RelToSqlConverter。
多数据源查询方案里最容易翻车的就是方言转换。Calcite 默认的方言是 AnsiSqlDialect,它生成的 SQL 是标准 ANSI 样式的。如果你的目标库不是标准的 ANSI 数据库,就必须要指定对应的方言。
举一个我实际踩过的坑:Calcite 给 MySQL 生成分页 SQL 时,如果不配置正确的方言,可能生成 FETCH FIRST n ROWS ONLY,这在 MySQL 8 里是不支持的,MySQL 用的是 LIMIT n。我一开始没配方言,测试跨库分页查询时直接报语法错误,后来指定了 MysqlSqlDialect.DEFAULT 才解决。
4.5 执行阶段与 Lambda 表达式生成
Calcite 默认的物理执行器是 Enumerable 实现。它会把执行计划转换为类似流式传递的代码逻辑,底层依赖 Linq4j 的 Enumerator。每个 TableScan 生成一个 Enumerator,遍历元组交给上层算子处理。
如果你对源码感兴趣,最有意思的是 EnumerableTableScan 的实现。它本身不直接访问任何数据,而是从 DataContext 中拿到 Schema 和 Table,再调用 Table 的 scan 方法。对于 JdbcSchema 的表,scan 最终会生成一条 JDBC 查询去执行;对于自定义 Table,scan 由你自己控制。
5. 踩坑实录与排查方案
5.1 Jackson 序列化 Calcite 对象导致循环引用
我第一次在 Spring Boot 里把 Calcite 的执行计划对象直接作为接口返回时,Jackson 报出了堆栈溢出。原因是 Calcite 的 RelNode 内部存在双向引用,比如 RelNode 的 digest 会引用子节点,子节点又引用父节点。
解决思路有两个:一是只把必要的信息(计划文本、表名、字段名)抽取成自定义 DTO 返回,二是给相关字段添加 @JsonIgnore 注解。我最终选择了第一种,因为从接口层面暴露 Calcite 内部对象本身就是设计缺陷。
5.2 Guava 版本冲突导致的 NoSuchMethodError
Calcite 依赖 Guava,而项目里其他组件可能也依赖 Guava。一旦版本不一致,运行时会报 NoSuchMethodError 或 NoClassDefFoundError。
排查方法是使用 Maven 依赖树看实际生效的 Guava 版本,然后统一依赖版本。我项目里最终把 Guava 显式指定为 Calcite 传递依赖的版本,问题就消失了。
xml复制<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
5.3 MySQL 分页方言问题
前面已经提到了 FETCH FIRST n ROWS ONLY 的问题。这里再补充一个细节:Calcite 对分页语句的下推,需要 SQL 里显式存在 LIMIT 或 OFFSET 子句。如果上层 SQL 没有写分页,但业务又需要分页,最好在下推时自行构造子查询来包裹分页逻辑,否则目标库可能一次返回全量数据。
5.4 大小写与标识符规范化
Calcite 的标识符处理策略由 lex 属性控制。我设置 lex=MYSQL,意味着 SQL 中未加引号的标识符会转换为小写保留,加上反引号的标识符会保留原样。如果你的数据库里表名是大写或混合大小写,务必在元数据同步和 SQL 编写上保持一致,否则很容易出现“表找不到”的情况。
5.5 查询超时与并发控制
跨库查询如果涉及到多个数据源,整体耗时取决于最慢的那个数据源。我给统一查询引擎加了两层控制:第一层是 JDBC 查询超时,通过 Statement 的 setQueryTimeout 设置;第二层是在接口层用并发线程池 + Future 控制整体调用超时。这样即使某个数据源卡住,也不会拖垮整个接口。
5.6 常见问题速查表
| 症状 | 常见原因 | 解决思路 |
|---|---|---|
| NoSuchMethodError | Guava 版本冲突 | 统一 Guava 版本 |
| Jackson 堆栈溢出 | Calcite 对象循环引用 | 返回自定义 DTO |
| FETCH FIRST 语法错误 | 方言未配置或配置错误 | 指定目标库 SqlDialect |
| 表找不到 | 大小写不敏感设置不一致 | 统一 lex 配置和表命名 |
| 查询超时 | 某个数据源响应缓慢 | 设置 JDBC 超时和整体超时 |
| 下推未生效 | 优化规则被关闭 / 方言不支持 | 查看执行计划,开启下推规则 |
| Schema 重复注册 | 同一逻辑名注册多次 | 注册前检查 SchemaPlus 是否已存在 |
6. 整体方案的适用边界与个人体会
这套架构并不是银弹,它比较适合“数据源数量多、跨库关联查询频繁、数据量中等”的场景。如果你的单表数据量已经到亿级,且长时间复杂的关联查询很多,还是需要依赖数仓或 OLAP 引擎来做大吞吐量计算。
我在实际接入过程中最大的体会是,先不要急着写自定义适配器。Calcite 自带 JdbcSchema 已经能覆盖大部分需求,先把链路跑通,再根据业务需要逐步引入自定义过滤条件下推、聚合下推、自定义 Table。这个顺序能少踩很多坑。
另外一个值得投入的方向是执行计划的监控。我在网关层做了一个“慢查询计划输出”功能,当某条 SQL 执行时间超过阈值时,自动把它的 Calcite 执行计划打到日志里。这几次排查跨库性能问题,基本都是靠这个日志定位到了哪些条件是“假装下推其实没下推”。
Calcite 这个项目真正吸引人的地方在于,它把 SQL 引擎最核心的三个阶段拆得清清楚楚。当你把 Spring Boot 和它集成在一起之后,会发现多数据源查询不再是一个需要大量自定义代码的业务难题,而是一个可以被反复复用、持续扩展的技术底座。现在新接入一个数据源,我只需要写几行配置,剩下的交给 Calcite 就够了。
