资讯动态

Apache Calcite 多数据源统一查询:Spring Boot 3 跨库 JOIN 实战

发布时间:2026/10/20 8:59:10 来源:尧图企业网站定制
最怕听到的一句话是“报表那边要个数据把订单库和库存库的字段合一下今天下班前要。”前几年听到这种需求我第一反应是导数据、写同步Job、再做汇总直到用 Apache Calcite 在 Spring Boot 3 项目里搭了一层多数据源统一查询的服务才真正把这类需求从半天压缩到十分钟。这篇文章把整套落地过程完整过一遍Calcite 的模型设计、Spring Boot 3 的整合方式、跨库 JOIN 的验证 SQL以及实际运行中踩过的坑。适合被多库联合查询折磨过的后端开发也适合准备做报表查询中台的团队参考。我先说清楚背景当时手上有个报表系统订单数据在 MySQL库存和商品信息在 PostgreSQL。一个常见需求是“查出所有已付款订单里对应商品库存不足 100 的明细”。数据量不大但每天都有而且两个库的表结构都不归我们管。用传统方案怎么都得写一堆临时同步逻辑或者干脆用 Java 在内存里循环 join。后来选了 Calcite整体体验是一套 JDBC 连接、一份 model 配置、一条 SQL跨库查询就结束了。下面从原理讲到实战。1. 为什么 Calcite 能解决“跨库地狱”从三个常见方案对比说起1.1 定时同步与应用层聚合的两座大山很多人遇到跨库查询第一反应是建一个汇总库定时把两个库的数据同步过去。这个方案的主坑不在代码而在维护源表结构变了同步脚本要跟着改字段编码、时区、类型映射哪一个不对都能埋一晚上最关键的是数据天然滞后做不到准实时。我们之前的订单表同步过来要晚三个小时业务方查的是“当前库存不足的订单”滞后三小时等于废掉。第二个常见做法是应用层多数据源。项目里配置两个 DataSourceService 里先查订单库再查库存库最后在内存里循环拼接。这个方案在小数据量下能跑但问题非常隐蔽数据量一旦上万内存里做嵌套循环 join 就开始发飘分页、排序只能自己写性能一塌糊涂更麻烦的是 join 逻辑散落在业务代码里后来的人根本不敢动。传统方案和 Calcite 的差异我用一张表总结方案数据是否复制维护成本实时性跨库 join 复杂度定时同步到汇总库是高依赖同步任务和字段映射低存在延迟低查询简单但代价大应用层多数据源 内存 join否中join 逻辑写在业务代码里实时高写起来痛苦性能差Calcite 统一查询层否数据仍留在源库低只维护 model 配置实时低跨库 SQL 直接查1.2 Calcite 的“虚拟 schema”思路到底做了什么Calcite 和数据库中间件最大的区别是它本身不存数据。你可以把它当成一个 SQL 指挥中心——把外部数据源注册成 schema上层执行 SQL 时Calcite 负责解析、优化、把能下推的查询推给底层数据库执行最后自己只做那些跨库才能完成的聚合和 join。具体到我们的场景就是把 MySQL 的 order_db 注册成一个名为ORDER_DB的 schemaPostgreSQL 的 inventory 库注册成INVENTORY_DB的 schema。对应用来说这两个库仿佛变成同一个数据库里的两个 schema一条 SQL 就能 join 两边的表。数据没有搬动只是元数据被 Calcite 接管了。这个思路听起来简单实际价值非常大没有同步链路没有缓存副本查询永远是最新的。后面所有代码都围绕这个核心展开。2. 看懂 Calcite 的模型文件与查询下推配置前先想明白这些2.1 model JSON 注册多个数据源的两套写法Calcite 的多数据源配置核心是一个 model JSON 文件。这个文件定义了有哪些 schema、每个 schema 对应哪个物理数据源、用哪种适配器。最常用的写法是 custom factory 方式直接指定JdbcSchema$Factory{ version: 1.0, defaultSchema: ORDER_DB, schemas: [ { name: ORDER_DB, type: custom, factory: org.apache.calcite.adapter.jdbc.JdbcSchema$Factory, operand: { jdbcDriver: com.mysql.cj.jdbc.Driver, jdbcUrl: jdbc:mysql://localhost:3306/order_db, jdbcUser: report_user, jdbcPassword: report_password, jdbcSchema: order_db } }, { name: INVENTORY_DB, type: custom, factory: org.apache.calcite.adapter.jdbc.JdbcSchema$Factory, operand: { jdbcDriver: org.postgresql.Driver, jdbcUrl: jdbc:postgresql://localhost:5432/inventory, jdbcUser: report_user, jdbcPassword: report_password, jdbcSchema: public } } ] }这个 JSON 里面有几个关键点。name是虚拟 schema 名也就是你在 SQL 里看到的前缀jdbcSchema对 MySQL 来说可以省略因为 URL 里已经带了库名但对 PostgreSQL 基本必须填因为 PG 的表归属在 schema 下而不是 database 下。相当多的人第一次配 PG 数据源报“表找不到”就是没填jdbcSchema。还有一种简化的type: jdbc写法底层同样走JdbcSchema只是把 operand 字段简写成了 driver、url 那套风格。两种都可以但我自己习惯用 custom factory 写法因为可以更精确地控制适配器后面如果要在 CSV、内存表、Kafka 这些来源上做扩展配置风格是统一的。2.2 查询下推为什么能避免“全量拉到内存”理解 Calcite 性能的关键是“查询下推”这四个字。Calcite 拿到一条跨库 SQL 后不是简单地把源表全量拉进内存再算而是先做逻辑优化把过滤条件、投影列尽量翻译成各源数据库的 SQL推给底层执行。拿刚才那条报表 SQL 举例SELECT o.order_no, p.product_name FROM ORDER_DB.orders o JOIN INVENTORY_DB.products p ON o.product_id p.id WHERE o.status PAID AND p.stock 100优化器实际下推给 MySQL 的可能是SELECT order_no, product_id FROM orders WHERE status PAID下推给 PostgreSQL 的可能是SELECT id, product_name FROM products WHERE stock 100两边数据库先把数据过滤到只剩满足条件的行Calcite 再内存 join 这两份很小的结果集。这才是 Calcite 跨库查询能用的原因。反过来说如果你在 SQL 里写了SELECT *又没把过滤条件写在子查询里优化器再聪明也帮不了你整表拉过来的痛苦会原样奉还。2.3 方言与大小写容易被低估的配置项Calcite 内置的 SQL 解析器默认按标准 SQL 处理这是个很多人栽过跟头的点。如果你把 MySQL 团队写的 SQL 原样搬过来比如带反引号的表名、或者用了LIMIT默认配置下大概率报语法错误。解决办法是在 Calcite JDBC URL 里指定解析器风格jdbc:calcite:modelclasspath:calcite/model.json;lexMYSQL加了lexMYSQL之后反引号、LIMIT这些 MySQL 习惯写法都能被接受大小写敏感性也向 MySQL 靠拢写 SQL 的时候体感会好很多。还有一点Calcite 对 schema 名、表名的大小写处理比较严格。我建议 model 里的虚拟 schema 名统一用大写SQL 里的 schema 前缀也保持大写表名和字段名用源库的真实小写。这样能绕开绝大多数“Object not found”问题。后面排查章节还会再展开。3. Spring Boot 3 中把 Calcite 接入项目依赖、DataSource 与第一条跨库 SQL3.1 依赖与版本选择JDK 17 环境Spring Boot 3 要求 JDK 17 起步Calcite 从 1.32 开始对 JDK 17 的支持已经很稳。我们项目用的是 calcite-core 1.35.0依赖配置如下dependency groupIdorg.apache.calcite/groupId artifactIdcalcite-core/artifactId version1.35.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency只需要引入 calcite-coreJDBC 驱动类会一起带进来。不加 spring-boot-starter-data-jpa因为这里走的是最朴素的 JDBC 路线不想让 ORM 参与进来。3.2 绕过 Spring Boot 数据源自动配置Spring Boot 看到 classpath 里有 spring-jdbc就会自动装配一个 DataSource。这个自动装配在你的场景里可能帮倒忙尤其是项目里已经存在多数据源配置时容易互相覆盖。我建议在主启动类上显式排除自动配置SpringBootApplication(exclude DataSourceAutoConfiguration.class) public class ReportApplication { public static void main(String[] args) { SpringApplication.run(ReportApplication.class, args); } }然后自己声明 Calcite 数据源。这样数据源的生命周期、连接池参数完全由我们掌控不会被 Spring Boot 的默认行为干扰。3.3 用 Hikari 包一层 Calcite 连接Calcite 连接本身的创建成本比普通数据库连接高因为它每次新建连接都要解析 model、连接源库读元数据。所以我建议把 Calcite 连接交给 Hikari 连接池管理连接复用的收益非常明显Configuration public class CalciteDataSourceConfig { Bean public DataSource calciteDataSource() { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:calcite:modelclasspath:calcite/model.json;lexMYSQL); config.setDriverClassName(org.apache.calcite.jdbc.Driver); config.setUsername(report_user); config.setPassword(report_password); config.setMaximumPoolSize(5); config.setConnectionTimeout(30000); // 针对 Calcite 连接探活的坑见第 4 章 config.setConnectionTestQuery(SELECT 1); return new HikariDataSource(config); } Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); } }连接池大小不需要设置很大。Calcite 本身是 CPU 密集型解析优化5 个连接对内部报表足够。如果你的查询并发高再适当上调但别忘记每个 Calcite 连接背后还挂着一个到源库的长连接池子过大对源库压力不小。3.4 一条跨库 JOIN 的完整验证配置好之后最激动人心的时刻就是跑一条跨库 SQL。我在项目里用一个 Service 封装报表查询Component public class FusionOrderReport { private final JdbcTemplate jdbcTemplate; public FusionOrderReport(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } public ListMapString, Object findLowStockPaidOrders() { String sql SELECT o.order_no, o.amount, p.product_name, p.stock FROM ORDER_DB.orders o JOIN INVENTORY_DB.products p ON o.product_id p.id WHERE o.status PAID AND p.stock 100 ORDER BY p.stock ASC ; return jdbcTemplate.queryForList(sql); } }这条 SQL 在应用层看起来就是查一个数据库但底层同时访问了 MySQL 和 PostgreSQL。第一次跑通之后那几个写惯“内存循环 join”的同事直接沉默了。跨库 join 的返回结果和普通单库查询完全一致Spring 的 JdbcTemplate 不需要任何特殊处理。4. 实战踩坑记录驱动注册、自动装配冲突与执行计划排查4.1 启动时报“Failed to configure a DataSource”的根因先说一个最典型的启动异常“Failed to configure a DataSource: url attribute is not specified”。这个报错在排除了 DataSourceAutoConfiguration、但没有提供任何 DataSource Bean 时一定出现。我踩过的版本是另一个变体我没排除自动配置直接在图省事阶段用了spring.datasource.urljdbc:calcite:...结果项目里原有的一个动态多数据源配置也被自动装配覆盖两边在启动时互相抢配置最终业务库的连接全被 Calcite URL 替代线上数据源瞬间混乱。这个问题的根因是 Spring Boot 的DataSourceAutoConfiguration会无条件读取spring.datasource.*并创建一个默认 HikariDataSource。只要你的项目有多数据源需求就必须排除它然后手动声明每一个数据源。规则很简单谁的数据源谁自己声明不要依赖自动装配去猜。4.2 表名找不到、反引号报错与 lex 配置第一次跑跨库查询最容易碰到的错误是Object orders not found或者Column status not found。我总结下来有三个来源。第一SQL 里漏了 schema 前缀。Calcite 的跨库查询必须带schema.表名即使设置了defaultSchema跨 schema 查询时也一定要全限定。第二model 里的 schema 名大小写和 SQL 里的不一致。我们统一规定 schema 名大写这个问题就基本消失。第三MySQL 团队习惯写反引号Calcite 默认解析器不认识报语法错误。这个前面说过在 JDBC URL 里加;lexMYSQL就好了。为了少踩这个坑建议从项目一开始就把连接 URL 固定成jdbc:calcite:model...;lexMYSQL别等 SQL 报错了再补。4.3 用 EXPLAIN 确认查询是否真的下推了一条跨库查询如果很慢第一件事不是加缓存而是看执行计划。Calcite 支持EXPLAIN PLAN FOR用法和普通数据库一样EXPLAIN PLAN FOR SELECT o.order_no, p.product_name FROM ORDER_DB.orders o JOIN INVENTORY_DB.products p ON o.product_id p.id WHERE o.status PAID AND p.stock 100正常下推计划里你会看到类似JdbcProject、JdbcFilter、JdbcTableScan这样的算子说明过滤和投影已经翻译成源库 SQL 去跑了。如果看到的是EnumerableTableScan、EnumerableCalc那就要警惕说明 Calcite 把整张表捞进了内存再做过滤。我遇到过一次 join 字段类型不匹配导致下推失效的情况MySQL 的product_id是 intPostgreSQL 的id是 bigint两边 join 时 Calcite 没法直接把 join 下推成单条 SQL只能把两边数据拉出来做内存 join。解决办法是在 SQL 里显式转类型比如o.product_id CAST(p.id AS INTEGER)让优化器能对上类型。这个坑非常隐蔽排查了整整一下午。4.4 连接池探活失败的处理Hikari 默认在创建连接后会对连接做基本探活Calcite 的 JDBC 连接对 isValid 这类操作支持得并不完美实际测试中遇到过初始化时报错“Connection is not available, request timed out”。当时第一反应是 Calcite 连接建立太慢后来又发现是探活 SQL 的问题。最终方案就是前面代码里的config.setConnectionTestQuery(SELECT 1)让 Hikari 用真正能执行的语句去探活而不是依赖 JDBC 接口里的 isValid 方法。如果你不想用连接池直接用 Spring 的DriverManagerDataSource也行只是每次查询都要重新解析 model数据量上来之后明显变慢。连接池 探活 SQL 是最省心的组合。这里还有一个补充经验Calcite 连接池大小不要贪多。池子里每个连接背后的元数据都是启动时缓存的如果源库表结构变更频繁长连接反而拿不到新表。真遇到表结构变更重启服务或者重连一次就行。5. 上生产前值得做的优化与边界约定5.1 把 model 的明文密码抽到配置中心model JSON 里直接写数据库账号密码开发环境还能忍生产环境就是安全隐患。Calcite 支持 inline model也就是把 JSON 内容直接编码进 JDBC URL 里这给了我们动态生成 model 的空间String modelTemplate { version: 1.0, defaultSchema: ORDER_DB, schemas: [ { name: ORDER_DB, type: custom, factory: org.apache.calcite.adapter.jdbc.JdbcSchema$Factory, operand: { jdbcDriver: com.mysql.cj.jdbc.Driver, jdbcUrl: jdbc:mysql://localhost:3306/order_db, jdbcUser: %s, jdbcPassword: %s, jdbcSchema: order_db } } ] } ; String filled String.format(modelTemplate, readUser(), readPassword()); String encoded URLEncoder.encode(filled, StandardCharsets.UTF_8); String url jdbc:calcite:modelinline: encoded;把用户和密码从配置中心读出来拼进模板再编码成 URL。这样 model 文件里不会出现任何真实凭据每个环境也能复用同一套代码。注意inline model 的 JSON 必须做 URL 编码否则冒号、引号都会把 JDBC URL 搞乱。5.2 只读账号、命名空间规范与最小表暴露跨库查询接入生产前我强烈建议给 Calcite 单独申请只读账号权限只给 SELECT。原因有两个一是 Calcite 本身支持把 INSERT、UPDATE 下推到源库一旦应用层出现注入或者误操作后果比单库严重得多二是只读账号能逼着所有团队遵守“查询层只查不改”的原则。命名空间方面不要在 SQL 里出现schema1.表A这种随意的名字。统一按业务域命名比如交易域叫TRADE库存域叫INVENTORY这样跨库 join 的可读性会好很多。如果源库表很多不希望所有表都暴露给报表团队可以在 model 里定义 view 来裁剪。Calcite 的 model 支持直接声明视图{ name: V_PAID_ORDER, type: view, sql: SELECT order_no, amount FROM ORDER_DB.orders WHERE status PAID }这样报表团队只能看到V_PAID_ORDER底层表结构完全隐藏权限边界清晰。5.3 什么时候不该用 Calcite与同步方案的分界线Calcite 不是万能的。大数据量的聚合分析、需要长期历史快照的报表还是交给实时数仓或者定时同步更合适。Calcite 的价值区间是“数据量可控、实时性要求高、不想维护同步链路”的跨库联合查询。我自己的判断标准很简单如果跨库 join 的中间结果集超过几万行先看执行计划确认下推是否生效如果下推已经到位还是慢那就说明 Calcite 不是最佳方案该上数仓了。反过来如果只是几十万行以内、实时性敏感、查询频率不高的场景Calcite 的性价比非常高。最后提一个我个人的使用心得。把 Calcite 这层包成一个独立查询服务之后团队里写报表的人根本不需要知道底层有几个库它对外就是一个只读数据库JDBC 连上来就能跑标准 SQL。跨库 join 对使用者来说只是 schema 前缀不同而已。如果你也被多数据源报表折腾过建议先拿一个最小的订单加库存场景做一次 PoC跑通一条跨库 SQL 之后你大概率会回来把定时同步的代码删掉大半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑