DuckDB Elasticsearch 扩展
原文地址:https://github.com/tlinhart/duckdb-elasticsearch 一个 DuckDB 扩展,允许直接使用 SQL 查询 Elasticsearch 索引。无需 ETL 管道或数据移动,即可为您的 Elasticsearch 数据带来 SQL 分析能力。
概述
此扩展提供了一个表函数,使您可以:
- 使用熟悉的 SQL 语法查询 Elasticsearch 索引。
- 利用 DuckDB 的查询优化器进行谓词、投影和限制条件下推。
- 将 Elasticsearch 数据与本地表、Parquet 文件或其他数据源进行连接。
该扩展能够自动从 Elasticsearch 索引映射中推断模式,处理类型转换,并支持高级功能,如嵌套对象、地理类型和多索引查询。
功能特性
查询优化
- 谓词下推 – WHERE 子句自动转换为 Elasticsearch Query DSL 并在服务器端执行,减少数据传输。
- 投影下推 – 仅通过 _source 过滤获取请求的列。
- 限制条件下推 – LIMIT 和 OFFSET 子句通过优化器扩展下推到 Elasticsearch。
自动模式推断
- 模式在查询时从 Elasticsearch 索引映射中推断。
- 支持多索引查询(例如 logs-*),并自动合并映射。
- 通过采样文档检测数组字段。
- 未映射/动态字段被收集到一个 JSON 列中。
类型支持
- 全面支持 Elasticsearch 标量类型(text、keyword、integer、float、date、boolean、ip 等)。
- 嵌套对象映射为 DuckDB 的 STRUCT 类型。
- 嵌套数组映射为 LIST(STRUCT(…)) 类型。
- 地理类型(geo_point、geo_shape)转换为 GeoJSON 格式。
- WKT 几何字符串自动解析和转换。
可靠性
- 使用 Scroll API 高效检索大型结果集。
- 对暂时性错误具有自动重试和指数退避机制。
- 可配置的超时和重试参数。
- 支持 SSL/TLS 和可选的证书验证。
安装
安装 Elasticsearch 扩展最简单的方法是从 DuckDB 社区扩展 仓库安装:
INSTALL elasticsearch FROM community;
LOAD elasticsearch;
从源代码构建
先决条件
- C++11 兼容的编译器
- CMake 3.5 或更高版本
- vcpkg(用于依赖管理)
- Ninja(推荐用于并行化构建过程)
- ccache(推荐用于缓存编译结果和加速重建)
克隆仓库
git clone –recurse-submodules https://github.com/tlinhart/duckdb-elasticsearch.git
cd duckdb-elasticsearch
–recurse-submodules 标志是必需的,用于拉取 DuckDB 核心和扩展 CI 工具子模块。如果您已经克隆了仓库但没有包含子模块,请运行:
git submodule update –init –recursive
设置 vcpkg
此 DuckDB 扩展使用 vcpkg 管理外部依赖。设置步骤如下:
git clone https://github.com/microsoft/vcpkg.git
cd vcpkg
git checkout ce613c41372b23b1f51333815feb3edd87ef8a8b
./bootstrap-vcpkg.sh -disableMetrics
export VCPKG_TOOLCHAIN_PATH=$(pwd)/scripts/buildsystems/vcpkg.cmake
设置 VCPKG_TOOLCHAIN_PATH 环境变量后,构建系统将自动使用 vcpkg。依赖关系在 vcpkg.json 中声明。
使用 Make 构建
构建扩展最简单的方法是使用 Make:
make
这将创建静态和可加载扩展的发布版本构建。
DuckDB 扩展在构建过程中会构建 DuckDB 本身,以便于测试和分发。为了显著加速重建,强烈建议安装 Ninja 和 ccache。构建系统会自动检测并使用 ccache 缓存构建工件。要使用 Ninja 并行化构建:
GEN=ninja make
要限制并行作业的数量(如果内存不足):
CMAKE_BUILD_PARALLEL_LEVEL=4 GEN=ninja make
构建产生的主要二进制文件有:
- build/release/duckdb – 已预加载扩展的 DuckDB shell。
- build/release/test/unittest – 链接了扩展的测试运行器。
- build/release/extension/elasticsearch/elasticsearch.duckdb_extension – 可加载的扩展二进制文件,即分发的版本。
运行测试
Elasticsearch 扩展在 test 目录下配备了全面的测试套件。构建完成后运行测试:
make test
有关集成测试设置的更多信息,请参阅 test/README.md。
加载扩展
要运行扩展代码,只需启动已构建并预加载扩展的 shell:
./build/release/duckdb
或者,使用 -unsigned 标志启动 DuckDB shell 并手动加载扩展:
LOAD 'build/release/extension/elasticsearch/elasticsearch.duckdb_extension';
表函数
elasticsearch_query
elasticsearch_query 表函数允许查询 Elasticsearch 索引。
参数
下表列出了该函数支持的参数:
| host | VARCHAR | localhost(必需) | Elasticsearch 主机名或 IP 地址 |
| port | INTEGER | 9200 | Elasticsearch HTTP 端口 |
| index | VARCHAR | –(必需) | 索引名称或模式(例如 logs-*) |
| query | VARCHAR | – | 可选的 Elasticsearch 查询子句 |
| username | VARCHAR | – | HTTP 基本认证的用户名 |
| password | VARCHAR | – | HTTP 基本认证的密码 |
| use_ssl | BOOLEAN | false | 使用 HTTPS 代替 HTTP |
| verify_ssl | BOOLEAN | true | 验证 SSL 证书 |
| timeout | INTEGER | 30000 | 请求超时时间(毫秒) |
| max_retries | INTEGER | 3 | 对暂时性错误的最大重试次数 |
| retry_interval | INTEGER | 100 | 初始重试等待时间(毫秒) |
| retry_backoff_factor | DOUBLE | 2.0 | 指数退避乘数 |
| sample_size | INTEGER | 100 | 用于数组检测的文档采样数量 |
query 参数接受一个 Elasticsearch 查询子句(例如 {"match": {"name": "alice"}}),而不是完整的请求体。如果提供,该查询会与从 SQL WHERE 子句下推的任何过滤器使用 bool.must 合并。
输出模式
elasticsearch_query 函数返回一个包含以下内容的表:
工作原理
示例
获取所有文档的基本查询:
SELECT * FROM elasticsearch_query(
host := 'localhost',
index := 'test',
username := 'elastic',
password := 'test'
);
包含谓词和投影下推的查询:
SELECT name, amount FROM elasticsearch_query(
host := 'localhost',
index := 'test',
username := 'elastic',
password := 'test'
)
WHERE deprecated = true;
该扩展会将其转换为以下 Elasticsearch 查询:
{
"query": {
"term": { "deprecated": true }
},
"_source": ["name", "amount"]
}
基础查询与 SQL 过滤器结合:
SELECT name, price FROM elasticsearch_query(
host := 'localhost',
index := 'test',
username := 'elastic',
password := 'test',
query := '{"exists": {"field": "employee"}}'
)
WHERE price BETWEEN 2000 AND 6000;
基础查询和 SQL 过滤器使用 bool.must 合并:
{
"query": {
"bool": {
"must": [
{ "exists": { "field": "employee" } },
{
"bool": {
"must": [
{ "range": { "price": { "gte": 2000 } } },
{ "range": { "price": { "lte": 6000 } } }
]
}
}
]
}
},
"_source": ["price", "name"]
}
谓词下推
以下 SQL 表达式会被转换为 Elasticsearch Query DSL:
| column = value | {"term": {"column": value}} |
| column != value | {"bool": {"must_not": {"term": {"column": value}}}} |
| column < value | {"range": {"column": {"lt": value}}} |
| column > value | {"range": {"column": {"gt": value}}} |
| column <= value | {"range": {"column": {"lte": value}}} |
| column >= value | {"range": {"column": {"gte": value}}} |
| column IN (a, b, c) | {"terms": {"column": [a, b, c]}} |
| column LIKE 'prefix%' | {"prefix": {"column": "prefix"}} |
| column LIKE '%suffix' | {"wildcard": {"column": {"value": "*suffix"}}} |
| column LIKE '%pattern%' | {"wildcard": {"column": {"value": "*pattern*"}}} |
| column ILIKE 'pattern' | 不区分大小写的通配符查询 |
| column IS NULL | {"bool": {"must_not": {"exists": {"field": "column"}}}} |
| column IS NOT NULL | {"exists": {"field": "column"}} |
下表总结了谓词下推的行为:
| numeric | 已下推 | 已下推 | 已下推 | 不适用 | 已下推 |
| date | 已下推 | 已下推 | 已下推 | 不适用 | 已下推 |
| boolean | 已下推 | 不适用 | 已下推 | 不适用 | 已下推 |
| keyword | 已下推 | 已下推 | 已下推 | 已下推 | 已下推 |
| text with .keyword | 已下推 | 已下推 | 已下推 | 已下推 | 已下推 |
| text without .keyword | 错误 | 错误 | 错误 | 错误 | 已下推 |
| nested object fields | 已下推 | 已下推 | 已下推 | 已下推 | 已下推 |
| array element access | 过滤 | 过滤 | 过滤 | 过滤 | 过滤 |
已下推 – 过滤器已转换为 Elasticsearch Query DSL。 错误 – 抛出错误。 过滤 – 过滤器无法下推;在扫描后由 DuckDB 的 FILTER 操作符处理。 不适用 – 此字段类型不适用。
Elasticsearch 的 text 字段是经过分析的(分词化的),不支持 term 这样的精确匹配查询。对于具有 .keyword 子字段的字段,过滤器会自动重定向到 .keyword 子字段进行精确匹配。对于没有 .keyword 子字段的字段,会抛出错误。一种可能的解决方案是向 Elasticsearch 映射添加 .keyword 子字段。另一种解决方法是使用 query 参数:
SELECT * FROM elasticsearch_query(
host := 'localhost',
index := 'test',
query := '{"match": {"description": "wireless headphones"}}'
);
投影下推和过滤器剪枝
执行查询时,扩展通过仅请求实际需要的列来优化数据传输:
- 投影下推 – 只有 SELECT 子句(以及查询的其他部分)中引用的列包含在 Elasticsearch 的 _source 过滤中。这通过从响应中排除不必要的字段,减少了网络带宽和解析开销。
- 过滤器剪枝 – 仅用于已下推 WHERE 子句过滤器的列会从 _source 请求中排除。由于这些过滤器在 Elasticsearch 服务器端进行评估,因此无需将这些字段的实际值传输回 DuckDB。
考虑以下查询:
SELECT title, amount FROM elasticsearch_query(...)
WHERE in_stock = true AND category = 'electronics';
如果两个过滤器都下推到 Elasticsearch,_source 过滤将只包含 ["title", "amount"]。in_stock 和 category 列会被剪枝,因为在服务器端过滤后就不再需要它们的值。
限制和偏移量下推
LIMIT 和 OFFSET 子句通过优化器扩展下推到 Elasticsearch。这意味着:
- 无需滚动遍历所有文档即可高效获取小型结果集。
- 当下推成功时,优化器会从查询计划中移除 LIMIT 节点。
- 对于 LIMIT N OFFSET M,扩展会获取 N + M 个文档并跳过前 M 个。
类型映射
下表总结了 Elasticsearch 到 DuckDB 的类型映射:
| text | VARCHAR | 经过分析的文本;使用 .keyword 进行精确匹配 |
| keyword | VARCHAR | 未经分析,精确值 |
| long | BIGINT | 64 位有符号整数 |
| integer | INTEGER | 32 位有符号整数 |
| short | SMALLINT | 16 位有符号整数 |
| byte | TINYINT | 8 位有符号整数 |
| double | DOUBLE | 64 位浮点数 |
| float | FLOAT | 32 位浮点数 |
| half_float | FLOAT | 16 位浮点数 |
| boolean | BOOLEAN | 真/假 |
| date | TIMESTAMP | 从 ISO8601 或纪元时间解析 |
| ip | VARCHAR | IP 地址作为字符串 |
| geo_point | VARCHAR | 转换为 GeoJSON Point 类型 |
| geo_shape | VARCHAR | 转换为相关的 GeoJSON 几何类型 |
| object | STRUCT(…) | 嵌套属性成为结构体字段 |
| nested | LIST(STRUCT(…)) | 始终视为对象数组 |
数组处理
Elasticsearch 映射不区分标量字段和数组。扩展通过采样文档检测数组:
- 采样 sample_size 个文档。
- 如果任何文档的某个字段具有数组值,则将类型包装在 LIST(…) 中。
- 设置 sample_size := 0 以禁用数组检测(所有字段将被视为标量)。
地理空间类型
geo_point 值转换为 GeoJSON Point 类型:
| object | {"lat": 40.7128, "lon": -74.006} | {"type":"Point","coordinates":[-74.006,40.7128]} |
| array | [-74.006, 40.7128] | {"type":"Point","coordinates":[-74.006,40.7128]} |
| string | "40.7128,-74.006" | {"type":"Point","coordinates":[-74.006,40.7128]} |
| WKT | "POINT (-74.006 40.7128)" | {"type":"Point","coordinates":[-74.006,40.7128]} |
geo_shape 值转换为相关的 GeoJSON 几何类型:
| GeoJSON | Point、LineString、Polygon、MultiPoint、MultiLineString 等 |
| WKT | POINT、LINESTRING、POLYGON、MULTIPOINT、MULTILINESTRING 等 |
未映射字段
_unmapped_ 列(JSON 类型)捕获存在于文档中但未在索引映射中定义的字段。这在以下情况下很有用:
- 索引的 dynamic 设置为 true,且文档包含临时字段。
- 不同文档具有不同的结构。
- 在定义严格模式之前想要探索数据。
以下查询展示了如何从 _unmapped_ 列中提取值:
SELECT _unmapped_–>>'$.extra.note' FROM elasticsearch_query(...)
WHERE _unmapped_ IS NOT NULL;
HTTP 日志记录
该扩展支持 DuckDB 的 HTTP 日志记录 功能。启用它以调试发送到 Elasticsearch 的请求:
CALL enable_logging('HTTP', storage = 'stdout');
SELECT * FROM elasticsearch_query(...);





