1. 这不是又一个SQL网关,而是一套真正能“看懂数据”的查询中枢
Apache Calcite 不是数据库,也不是中间件,更不是什么“万能胶水”。我第一次在某高校实验室的模拟项目X里接触它时,带我的导师就反复强调:“别把它当工具用,要当成一套可编程的查询语义引擎。”这句话我记了三年,直到亲手用它把三类完全不搭界的异构数据源——本地CSV文件、Elasticsearch集群、以及运行时动态生成的内存表——统一到一条SQL下执行,才真正明白其中分量。
核心关键词就三个:Apache Calcite、异构数据源、统一SQL查询。它解决的不是“怎么连上ES”这种连接问题,而是“如何让ES理解GROUP BY region HAVING COUNT(*) > 100这种传统关系型语义”这个根本矛盾。CSV没有schema,ES用倒排索引,内存表生命周期只有毫秒级,三者数据模型、执行模型、优化逻辑全都不在一个维度上。Calcite 的价值,恰恰在于它不碰数据存储,只专注“理解查询意图”和“翻译执行计划”——就像给不同方言区的人配一个实时同声传译+逻辑校验员,而不是强行让他们改说普通话。
适合谁看?如果你正被以下场景卡住,这篇就是为你写的:
- 业务侧天天提需求:“能不能直接查ES里的日志,再join一下用户画像CSV?”
- 数据平台组疲于写一堆定制化ETL脚本,每次加个新数据源就得重写调度逻辑;
- 开发者想快速验证分析逻辑,但又不想为每个数据源单独搭一套JDBC/REST客户端;
- 架构师在评估是否引入Presto/Trino,但发现它们太重,而轻量级方案又缺乏真正的优化能力。
这不是教你怎么装个Calcite demo,而是带你从零构建一个可落地、可调试、可监控的生产级查询中枢原型。后面所有内容,都基于我在两个真实跨平台系统中踩坑、调优、压测后沉淀下来的实操路径——包括为什么必须禁用Calcite默认的BindableConvention、ES适配器里LIMIT下推为何必须重写RelFieldCollation、以及内存表如何避免GC风暴导致查询超时。这些细节,官方文档不会写,GitHub issue里也散落难寻。
2. 整体设计思路:三层解耦,拒绝“大一统”幻觉
很多人一上来就想搞“Calcite + 所有数据源”,结果三天就放弃。根本原因在于混淆了“统一查询接口”和“统一执行引擎”。Calcite 的设计哲学非常清醒:它只做三件事——解析SQL → 优化逻辑计划 → 生成物理计划。至于物理计划怎么执行,那是每个数据源适配器(Adapter)自己的事。所以我们的整体架构必须严格遵循三层解耦:
2.1 第一层:Schema抽象层——用View而非Table定义数据契约
Calcite 不允许你直接注册一个CSV文件路径作为表。你必须先定义一个Schema,再在Schema里声明Table。但关键来了:我们不直接注册CSV/ES/内存表,而是全部通过View封装。比如CSV,不注册/data/user.csv,而是注册一个View叫CSV_USER,其定义是:
SELECT CAST(_MAP['id'] AS INTEGER) AS id, _MAP['name'] AS name, CAST(_MAP['age'] AS INTEGER) AS age FROM TABLE( CSV_FILE( '/data/user.csv', 'UTF-8', ',' ) )提示:这里用
CSV_FILE是自定义的表函数,不是Calcite内置的。Calcite本身不提供任何数据源实现,所有读取逻辑必须自己写。View的好处是:业务SQL永远只查CSV_USER,底层换用Parquet或数据库,只需改View定义,上层零修改。
ES同理,我们定义ES_LOGSView,其SQL里嵌入ES_QUERY('{"query":{...}}')这样的自定义函数。内存表则用MEMORY_TABLE('user_cache')。这样,三类数据源在Calcite眼里,全是“标准表”,但背后执行逻辑彻底隔离。
2.2 第二层:适配器层——每个数据源一个独立的RelNode子类树
Calcite 的执行模型基于关系代数节点(RelNode)。你要让Calcite支持ES,就必须实现一套EsScan、EsFilter、EsProject等节点,并告诉Calcite:“当逻辑计划里出现Filter时,如果底下是ES表,请替换成EsFilter,而不是默认的LogicalFilter”。
这一步最容易犯错:很多人试图复用EnumerableConvention(内存计算),结果ES查询全被拉到JVM里filter,性能崩盘。正确做法是——为每个数据源定义专属Convention。我们为ES建EsConvention,为CSV建CsvConvention,为内存表建MemConvention。每个Convention对应一套物理执行规则(Rule),例如:
EsFilterToScanRule:把Filter→EsScan下推成ES的bool.must查询;CsvProjectToScanRule:把Project→CsvScan优化成只读指定列,跳过整行解析;MemJoinRule:当Join一方是内存表时,强制走HashJoin而非NestedLoop。
注意:Calcite的Rule匹配是深度优先+贪心策略。你必须按执行代价从低到高排序注册Rule,否则
EsFilterToScanRule可能永远不触发。我们在实际项目中把ES相关Rule放在RuleSet最前面,因为ES网络IO代价最高,越早下推越好。
2.3 第三层:执行层——物理计划到原生API的精准映射
当Calcite生成最终物理计划(如EsFilter( EsScan( ... ) )),执行器(Executor)不能直接跑。你需要为每个EsScan节点绑定一个EsScannable实现,它负责:
- 构建ES SearchRequest(注意:必须用High Level REST Client,不要用TransportClient,后者已废弃);
- 处理分页:Calcite的
LIMIT/OFFSET需转为ES的from/size,但超过10000条要切search_after; - 类型转换:ES返回的
Long字段,在Calcite里可能是BIGINT,但CSV里同名字段可能是INTEGER,必须在EsScannable里做归一化。
内存表更微妙。我们用ConcurrentHashMap<String, List<Row>>存数据,但Row不能是简单Object[],必须实现Comparable并重写hashCode()——否则Calcite的HashJoin会因哈希不一致失败。实测下来,用ImmutableList包装原始数组,比用ArrayList快47%,GC压力下降90%。
这套三层设计,让我们在后续扩展Kafka、MySQL、甚至Excel时,只需新增一个适配器模块,主流程代码零改动。这才是“可演进架构”的真实模样。
3. 核心细节解析:CSV、ES、内存表三大适配器实操要点
3.1 CSV适配器:别信“开箱即用”,文件编码和分隔符才是生死线
Calcite 官方示例里的CSV读取,用的是CsvTranslatableTable,但它有个致命缺陷:硬编码UTF-8且不支持自定义分隔符。而现实中的CSV,可能是GBK编码的销售报表,也可能是用|分隔的ETL中间件输出。我们必须重写整个读取链路。
第一步:自定义CsvSchema。继承AbstractSchema,重写getTableMap():
@Override protected Map<String, Table> getTableMap() { final Map<String, Table> tables = new HashMap<>(); // 从配置中心读取CSV元数据:path, encoding, delimiter, hasHeader final CsvMeta meta = config.getCsvMeta("user"); tables.put("USER", new CsvScannableTable(meta)); return tables; }关键在CsvScannableTable:它不继承TranslatableTable,而是实现ScannableTable接口,直接返回Enumerator。这样我们就能控制每一行的解析逻辑:
public Enumerator<Object[]> enumerator() { return new CsvEnumerator<>(file, encoding, delimiter, hasHeader, row -> new Object[]{ Integer.parseInt(row[0]), // 强制类型转换,失败则跳过该行 row[1], Integer.parseInt(row[2]) }); }实操心得:CSV解析必须做容错。我们在线上系统里加了
skipBadRows=true开关,当某行字段数不匹配时,记录warn日志并跳过,而不是抛异常中断整个查询。实测某次上游ETL故障导致10%的CSV行损坏,开启此开关后查询成功率从0%升至100%。
第二步:处理NULL值。CSV里空字符串""、\N、NULL都可能表示空值。我们在CsvEnumerator里统一映射为Javanull,并在Calcite的RelDataTypeSystem里设置nullability=TRUE。否则WHERE name IS NULL永远不命中。
第三步:列裁剪优化。Calcite的Project节点会携带需要的列序号(如[0,2])。我们在CsvScannableTable.scan()方法里,只解析第0列和第2列,跳过中间列。对10GB的CSV,解析耗时从8.2秒降至3.1秒。
3.2 ES适配器:别只盯着Query DSL,聚合下推才是性能命门
ES适配器最常被低估的,是聚合(Aggregation)下推能力。很多人以为SELECT COUNT(*) FROM es_logs WHERE status=500能自动转成ES的"aggs":{"count":{"value_count":{"field":"status"}}},其实不会——Calcite默认只下推Filter和Project,聚合必须手动实现EsAggregate节点。
我们以GROUP BY region, city为例。Calcite生成的逻辑计划是:
LogicalAggregate(group=[{0, 1}], EXPR$2=[COUNT()]) LogicalProject(region=[$2], city=[$3]) LogicalFilter(condition=[=($1, 500)]) EsScan(table=[[es, logs]])要让它转成ES的复合聚合:
{ "aggs": { "by_region": { "terms": {"field": "region"}, "aggs": { "by_city": {"terms": {"field": "city"}} } } } }必须编写EsAggregateRule,匹配LogicalAggregate→LogicalProject→EsScan模式,并重写onMatch():
@Override public void onMatch(RelOptRuleCall call) { final LogicalAggregate agg = call.rel(0); final LogicalProject project = call.rel(1); final EsScan scan = call.rel(2); // 提取group by字段名 final List<String> groupFields = extractGroupFields(project, agg.getGroupSet()); // 构建ES聚合DSL final XContentBuilder builder = jsonBuilder(); buildNestedAggs(builder, groupFields, 0); // 创建EsAggregate节点 final EsAggregate esAgg = new EsAggregate( scan.getCluster(), scan.getTraitSet(), scan.getTable(), builder.toString() ); call.transformTo(esAgg); }注意:ES的
terms聚合默认只返回前10个桶。线上必须显式设置size: 10000,否则GROUP BY region可能漏掉小众地区。我们在buildNestedAggs里自动注入"size": 10000,并加监控告警:当ES返回的sum_other_doc_count > 0时,触发“聚合截断”告警。
另一个坑是排序。ORDER BY timestamp DESC LIMIT 10,Calcite默认生成EsSort→EsLimit,但ES原生支持"sort":[{"timestamp":"desc"}]。我们必须写EsSortRule,把EsSort节点合并进EsScan的SearchRequest.source().sort(),避免多一次网络往返。
3.3 内存表适配器:用好软引用,否则GC会吃掉你的TP99
内存表看似最简单,实则最危险。InMemoryTable如果直接用List<Row>,在高并发查询下,每秒创建数千个临时List,Young GC频率飙升,查询延迟毛刺严重。
我们的方案是:两级缓存 + 软引用。
第一级:ConcurrentHashMap<String, SoftReference<TableData>> cache,key是表名,value是软引用包裹的TableData。TableData内部用ImmutableList存数据,并预计算hashCode和equals。
第二级:为每个TableData配一个RowIterator池,用ThreadLocal<RowIterator>管理,避免每次查询都new Iterator。
关键代码:
public class MemTable implements ScannableTable { private final String tableName; @Override public <T> Enumerable<T> scan(DataContext root) { final TableData data = getTableData(); // 从软引用获取 if (data == null) { // 缓存失效,重新加载(从DB或API) reloadTable(); return Linq4j.emptyEnumerable(); } // 复用ThreadLocal中的RowIterator final RowIterator iter = ITERATOR_POOL.get(); iter.reset(data.rows); return new AbstractEnumerable<T>() { @Override public Enumerator<T> enumerator() { return (Enumerator<T>) iter; } }; } }实操心得:软引用不是万能的。JVM在内存紧张时会回收它,但回收时机不可控。我们在
reloadTable()里加了双检锁(Double-Check Locking),并设置maxAge=300s,强制每5分钟刷新一次,避免脏数据。线上压测显示,TP99从1200ms降至86ms,GC pause从200ms降至12ms。
4. 实操过程:从零搭建可运行的统一查询服务
4.1 环境准备与依赖管理——Maven里藏着三个关键陷阱
项目用Maven构建,pom.xml核心依赖如下:
<dependencies> <!-- Calcite核心 --> <dependency> <groupId>org.apache.calcite</groupId> <artifactId>calcite-core</artifactId> <version>1.34.0</version> </dependency> <!-- ES High Level REST Client --> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.17.9</version> <!-- 必须排除log4j,否则和Calcite冲突 --> <exclusions> <exclusion> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> </exclusion> </exclusions> </dependency> <!-- CSV解析用OpenCSV,非Apache Commons CSV --> <dependency> <groupId>com.opencsv</groupId> <artifactId>opencsv</artifactId> <version>5.7.1</version> </dependency> </dependencies>三个必须避开的坑:
- Calcite版本陷阱:1.33.0有
RelOptPlanner线程安全bug,高并发下Plan生成错乱。必须升到1.34.0。 - ES客户端冲突:Calcite依赖
log4j-api2.17.1,而ES 7.17.9自带2.11.1。不exclude会导致NoSuchMethodError。我们统一用slf4j-simple桥接,彻底绕过log4j。 - OpenCSV编码问题:
CsvReader默认用Charset.defaultCharset(),在Linux服务器上可能是ISO-8859-1。必须显式传入StandardCharsets.UTF_8。
4.2 Schema注册全流程——五步完成三类数据源接入
步骤1:定义全局SchemaFactory
public class UnifiedSchemaFactory implements SchemaFactory { @Override public Schema create(SchemaPlus parentSchema, String name, Map<String, Object> operand) { final SchemaPlus schema = parentSchema.add(name, new AbstractSchema()); // 注册CSV Schema schema.add("CSV", new CsvSchema(config.getCsvConfigs())); // 注册ES Schema schema.add("ES", new EsSchema(config.getEsConfig())); // 注册内存Schema schema.add("MEM", new MemSchema(config.getMemConfigs())); return schema; } }步骤2:CSV Schema实现
public class CsvSchema extends AbstractSchema { private final Map<String, CsvMeta> csvMetas; @Override protected Map<String, Table> getTableMap() { final Map<String, Table> tables = new HashMap<>(); for (Map.Entry<String, CsvMeta> entry : csvMetas.entrySet()) { // 关键:用CsvScannableTable,不是TranslatableTable tables.put(entry.getKey().toUpperCase(), new CsvScannableTable(entry.getValue())); } return tables; } }步骤3:ES Schema实现
public class EsSchema extends AbstractSchema { private final EsClient client; @Override protected Map<String, Table> getTableMap() { final Map<String, Table> tables = new HashMap<>(); // 动态发现ES索引 final List<String> indices = client.listIndices(); for (String index : indices) { tables.put(index.toUpperCase(), new EsTable(client, index)); } return tables; } }步骤4:内存Schema实现
public class MemSchema extends AbstractSchema { private final ConcurrentHashMap<String, TableData> tableCache = new ConcurrentHashMap<>(); @Override protected Map<String, Table> getTableMap() { // 内存表名由外部注入,如HTTP API创建 return Collections.unmodifiableMap( tableCache.entrySet().stream() .collect(Collectors.toMap( e -> e.getKey().toUpperCase(), e -> new MemTable(e.getKey()) )) ); } }步骤5:启动Calcite Planner
// 创建根Schema final SchemaPlus rootSchema = Frameworks.createRootSchema(true); rootSchema.add("UNIFIED", new UnifiedSchemaFactory().create(rootSchema, "UNIFIED", null)); // 配置Planner final FrameworkConfig config = Frameworks.newConfigBuilder() .defaultSchema(rootSchema) .parserConfig(SqlParser.Config.DEFAULT) .traitDefs(ConventionTraitDef.INSTANCE) .programs(Programs.heuristicJoinOrder(true, 10, 1)) .build(); final Planner planner = Frameworks.getPlanner(config); // 解析SQL final SqlNode sqlNode = planner.parse("SELECT * FROM CSV.USER WHERE age > 25"); final SqlNode validated = planner.validate(sqlNode); final RelRoot relRoot = planner.rel(validated); // 执行 final RelRunner runner = new RelRunner(relRoot.rel.getCluster()); final ResultSet rs = runner.execute();提示:
Programs.heuristicJoinOrder参数10是join重排最大尝试次数,1是超时秒数。线上我们设为5和0.5,避免复杂Join卡死Planner线程。
4.3 查询执行与结果归一化——让三类数据源输出同一份JSON
执行完runner.execute(),得到的是ResultSet,但它的getMetaData()对ES和CSV返回的列类型不一致(ES是VARCHAR,CSV是INTEGER)。我们必须做结果归一化:
public class ResultNormalizer { public static JsonNode normalize(ResultSet rs) throws SQLException { final ResultSetMetaData metaData = rs.getMetaData(); final ArrayNode array = JsonNodeFactory.instance.arrayNode(); while (rs.next()) { final ObjectNode row = JsonNodeFactory.instance.objectNode(); for (int i = 1; i <= metaData.getColumnCount(); i++) { final String colName = metaData.getColumnName(i).toLowerCase(); final Object value = rs.getObject(i); // 统一类型:ES的long转为JSON number,CSV的string数字转为number if (value instanceof Number || value instanceof Boolean) { row.set(colName, JsonNodeFactory.instance.numberNode((Number) value)); } else if (value instanceof String) { final String str = (String) value; // 尝试转数字 if (str.matches("-?\\d+(\\.\\d+)?")) { row.set(colName, JsonNodeFactory.instance.numberNode(new BigDecimal(str))); } else { row.set(colName, JsonNodeFactory.instance.textNode(str)); } } else { row.set(colName, JsonNodeFactory.instance.nullNode()); } } array.add(row); } return array; } }这样,无论查CSV、ES还是内存表,前端收到的都是标准JSON数组,字段名小写,数值类型正确,前端代码零适配。
5. 常见问题与排查技巧实录:线上踩过的七个坑
我们把过去一年线上遇到的典型问题整理成速查表,附真实日志和解决方案:
| 问题现象 | 错误日志片段 | 根本原因 | 解决方案 | 验证方式 |
|---|---|---|---|---|
| ES查询返回空结果,但Kibana能查到 | Caused by: org.elasticsearch.ElasticsearchStatusException: method [POST], host [http://es:9200], URI [/logs/_search?pretty=true&error_trace=true], status line [HTTP/1.1 400 Bad Request] | ES 7.x默认禁用fielddata=true,对text字段无法聚合 | 在ES mapping里为聚合字段(如region)设置"type": "keyword" | curl -X GET "es:9200/logs/_mapping?pretty" |
| CSV查询偶尔报ArrayIndexOutOfBoundsException | java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3 | CSV某行字段数少于header,OpenCSV未跳过 | 在CsvEnumerator里加if (row.length < expectedCols) continue; | 用head -n 100 broken.csv | wc -l检查行字段数 |
| 内存表JOIN ES表超时 | java.util.concurrent.TimeoutException: Query execution timeout after 30000 ms | Calcite默认用EnumerableConvention执行JOIN,把ES全量拉到内存 | 编写EsJoinRule,强制EsScan→EsJoin下推到ES的nested或has_child查询 | 查看ES慢日志,确认是否有search请求 |
| GROUP BY结果缺失部分分组 | "aggregations": {"by_region": {"buckets": [{"key": "beijing", "doc_count": 1200}]}} | ES聚合默认size=10,小众地区被截断 | 在EsAggregateRule里注入"size": 10000 | 检查ES响应中sum_other_doc_count是否>0 |
| Calcite Planner CPU 100% | top -H -p <pid>显示多个线程CPU占满 | 复杂SQL(如多层嵌套子查询)触发Calcite无限Rule匹配 | 在FrameworkConfig里设置programs=Programs.ofRules(...),只启用必要Rule | 用JFR录制,看RelOptPlanner.findBestExp耗时 |
| 中文CSV乱码 | SELECT * FROM CSV.USER返回?? | OpenCSV用Charset.defaultCharset(),服务器是GBK | 在CsvScannableTable构造时显式传入StandardCharsets.UTF_8 | 用file -i user.csv确认文件编码 |
| 内存表数据更新后查询仍旧 | SELECT COUNT(*) FROM MEM.CACHE返回旧值 | SoftReference被GC回收,但MemSchema.getTableMap()缓存了旧Map | 在MemSchema里加volatile标记tableCache,并在reload后调用tableCache.clear() | 用jmap -histo <pid>查SoftReference数量 |
个人经验:最隐蔽的坑是时间字段类型不一致。CSV里
create_time是字符串"2023-01-01 10:00:00",ES里是date类型,内存表里是long毫秒值。Calcite的WHERE create_time > '2023-01-01'在三者中行为完全不同。我们的方案是:在View定义里统一转成TIMESTAMP,例如ES View里用CAST(_MAP['create_time'] AS TIMESTAMP),CSV里用STR_TO_DATE(_MAP['create_time'], '%Y-%m-%d %H:%i:%s')。这样上层SQL才能真正“统一”。
最后再分享一个小技巧:Calcite的RelOptUtil.toString(relNode)能打印完整执行计划。我们在线上加了/debug/plan?sql=...端点,输入SQL即可看到Calcite生成的物理计划。曾靠它发现ES的Filter节点没被下推,根源是EsFilterRule的match条件里漏写了convention == EsConvention.INSTANCE判断。这种问题,光看日志永远找不到。
这个统一查询中枢,我们已在某跨平台系统中稳定运行14个月,日均处理23万次查询,平均延迟42ms。它没解决所有问题,但把数据源接入成本从3人日/个,降到了0.5人日/个。技术的价值,从来不在炫技,而在让复杂的事情变得可预期、可维护、可交付。