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 社区扩展 仓库安装:
sql
INSTALL elasticsearch FROM community;
LOAD elasticsearch;
从源代码构建
先决条件
克隆仓库
shell
git clone --recurse-submodules https://github.com/tlinhart/duckdb-elasticsearch.git
cd duckdb-elasticsearch
--recurse-submodules 标志是必需的,用于拉取 DuckDB 核心和扩展 CI 工具子模块。如果您已经克隆了仓库但没有包含子模块,请运行:
shell
git submodule update --init --recursive
设置 vcpkg
此 DuckDB 扩展使用 vcpkg 管理外部依赖。设置步骤如下:
shell
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:
shell
make
这将创建静态和可加载扩展的发布版本构建。
DuckDB 扩展在构建过程中会构建 DuckDB 本身,以便于测试和分发。为了显著加速重建,强烈建议安装 Ninja 和 ccache。构建系统会自动检测并使用 ccache 缓存构建工件。要使用 Ninja 并行化构建:
shell
GEN=ninja make
要限制并行作业的数量(如果内存不足):
shell
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 目录下配备了全面的测试套件。构建完成后运行测试:
shell
make test
有关集成测试设置的更多信息,请参阅 <test/README.md>。
加载扩展
要运行扩展代码,只需启动已构建并预加载扩展的 shell:
shell
./build/release/duckdb
或者,使用 -unsigned 标志启动 DuckDB shell 并手动加载扩展:
sql
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 函数返回一个包含以下内容的表:
_id(VARCHAR) -- Elasticsearch 文档 ID。- 映射字段 -- 索引映射中每个字段对应的列,具有推断的类型。
_unmapped_(JSON) -- 一个 JSON 对象,包含存在于文档中但未在映射中定义的任何字段。
工作原理
- 绑定阶段 -- 从 Elasticsearch 获取索引映射,推断 DuckDB 模式,并可选择采样文档以检测数组字段。
- 谓词下推 -- DuckDB 的优化器将
WHERE子句下推到扩展,扩展将其转换为 Elasticsearch Query DSL。 - 投影下推 -- 只有请求的列包含在
_source过滤中。 - 限制条件下推 --
LIMIT和OFFSET子句通过优化器扩展下推。 - 扫描阶段 -- 使用 Scroll API 执行优化后的查询,分批获取文档,并将 JSON 转换为 DuckDB 值。
示例
获取所有文档的基本查询:
sql
SELECT * FROM elasticsearch_query(
host := 'localhost',
index := 'test',
username := 'elastic',
password := 'test'
);
包含谓词和投影下推的查询:
sql
SELECT name, amount FROM elasticsearch_query(
host := 'localhost',
index := 'test',
username := 'elastic',
password := 'test'
)
WHERE deprecated = true;
该扩展会将其转换为以下 Elasticsearch 查询:
json
{
"query": {
"term": { "deprecated": true }
},
"_source": ["name", "amount"]
}
基础查询与 SQL 过滤器结合:
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 合并:
json
{
"query": {
"bool": {
"must": [
{ "exists": { "field": "employee" } },
{
"bool": {
"must": [
{ "range": { "price": { "gte": 2000 } } },
{ "range": { "price": { "lte": 6000 } } }
]
}
}
]
}
},
"_source": ["price", "name"]
}
谓词下推
以下 SQL 表达式会被转换为 Elasticsearch Query DSL:
| SQL 表达式 | Elasticsearch 查询 |
|---|---|
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"}} |
下表总结了谓词下推的行为:
| 字段类型 | =, != |
<, >, <=, >= |
IN |
LIKE, ILIKE |
IS NULL, IS NOT NULL |
|---|---|---|---|---|---|
| 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 参数:
sql
SELECT * FROM elasticsearch_query(
host := 'localhost',
index := 'test',
query := '{"match": {"description": "wireless headphones"}}'
);
投影下推和过滤器剪枝
执行查询时,扩展通过仅请求实际需要的列来优化数据传输:
- 投影下推 -- 只有
SELECT子句(以及查询的其他部分)中引用的列包含在 Elasticsearch 的_source过滤中。这通过从响应中排除不必要的字段,减少了网络带宽和解析开销。 - 过滤器剪枝 -- 仅用于已下推
WHERE子句过滤器的列会从_source请求中排除。由于这些过滤器在 Elasticsearch 服务器端进行评估,因此无需将这些字段的实际值传输回 DuckDB。
考虑以下查询:
sql
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 的类型映射:
| 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_ 列中提取值:
sql
SELECT _unmapped_->>'$.extra.note' FROM elasticsearch_query(...)
WHERE _unmapped_ IS NOT NULL;
HTTP 日志记录
该扩展支持 DuckDB 的 HTTP 日志记录 功能。启用它以调试发送到 Elasticsearch 的请求:
sql
CALL enable_logging('HTTP', storage = 'stdout');
SELECT * FROM elasticsearch_query(...);