【摘要】ES DSL 到 Apache Doris 的 SQL 是逻辑转换,没有官方转换工具,但对照关系很固定。这篇把子句对照表放在最前面,20 条覆盖过滤、聚合、排序、分页、检索五类写法:bool.must → AND,bool.should → OR(带括号),bool.must_not → NOT / <>,term → =,terms → IN,range → 比较运算符,exists → IS NOT NULL,match → MATCH_ANY / MATCH_ALL,match_phrase → MATCH_PHRASE,query_string → SEARCH(),aggs.terms → GROUP BY + COUNT + ORDER BY + LIMIT,date_histogram → date_trunc 后分组,sort → ORDER BY,from / size → LIMIT / OFFSET。后面给一条完整改写示例、三段常用片段和三步验证。全部基于 Apache Doris。
一、先说结论
改写顺序建议固定,先过滤、再聚合、最后检索。前两类是结构映射,错了能用行数立刻发现;检索涉及分词行为,要单独验。
- 先翻
bool的形状,每一层bool一对括号。 - 再翻叶子条件,
term/terms/range/exists直接映射。 - 然后翻聚合,
GROUP BY后面必须跟ORDER BY,TopN 才成立。 - 最后翻
sort与分页,深翻页不要照翻from/size。 - 检索函数单独验,
match默认对应MATCH_ANY,写了"operator": "and"才对应MATCH_ALL。
| 手上的查询属于哪类 | 先看速查表的哪几行 | 最该验什么 |
|---|---|---|
| 只有过滤和排序 | 第 1~9 行 | 总行数 |
| 带分组统计 | 第 10~14 行 | 分组名、顺序、组内数值 |
| 带全文检索 | 第 15~18 行 | 是否走倒排索引 |
| 带分页或深翻页 | 第 19~20 行 | 翻页是否漏行 |
二、问题怎么产生
- DSL 是描述式的,SQL 是声明式的。 DSL 用嵌套 JSON 描述"要什么",过滤、评分、聚合写在同一个结构里,层级靠缩进表达;SQL 把这些拆成 SELECT / WHERE / GROUP BY / ORDER BY 几个独立子句,层级只能靠括号表达。翻译时最直观的变化是:
bool里filter与must的区别消失了,因为 SQL 里没有评分这个概念。这件事对迁移其实是好事,少了一处要判断的地方。 - 评分语义在迁移中消失。 ES 里
must参与_score计算、filter不参与且结果可被缓存;SQL 里两者都是 WHERE 里的合取条件。代价是boost、min_score、function_score、explain这些依赖评分的写法没有对应物。我的处理是先全部标出来,再逐条回问使用方这条查询的结果用来做什么------实践中一大半是可以安全丢弃的,看板和告警真正依赖的是过滤条件与聚合值。 - 聚合粒度从参数变成表达式。
date_histogram的calendar_interval: "1h"是一个参数,SQL 里要写成"先把时间截断到小时,再按这个表达式分组",粒度从参数变成了date_trunc(log_time, 'hour')这样的表达式。这是从 DSL 转到 SQL 时最容易卡住的一处,也是翻完之后最容易被忽略的一处:粒度错了,曲线形状还在,只是横坐标不对。 - 分页机制不同。 ES 的
from+size受max_result_window限制,深翻页要换成search_after传上一页的排序值;SQL 的LIMIT ... OFFSET理论上有同样的问题,offset 越大扫描越多。两边都推荐"按排序键切分",所以正确做法不是照翻,而是把深翻页场景改成排序键范围条件。 - 缺失值的口径不同。 ES 的
terms聚合默认跳过字段缺失的文档,must_not又会把字段缺失的文档算进来;SQL 侧 NULL 会单独成为一个分组,而col <> 'x'遇到 NULL 得到 NULL、行被过滤掉。同一份数据,两边在边界上的口径不一样,这也是"行数看着差不多但差一点"的主要来源。
三、怎么做:实操路径
3.1 子句对照速查表(这张表是主轴)
| # | DSL | SQL | 说明 |
|---|---|---|---|
| 1 | query.bool.must |
WHERE 条件1 AND 条件2 |
多条件取交集 |
| 2 | query.bool.should |
WHERE (条件1 OR 条件2) |
必须带括号,OR 优先级低于 AND |
| 3 | query.bool.must_not |
WHERE NOT 条件 / col <> '值' |
字段可空时补 col IS NULL OR 分支 |
| 4 | query.bool.filter |
WHERE 里的普通条件 | 与 must 同样处理,无评分差异 |
| 5 | term: {status: 500} |
status = 500 |
精确等值 |
| 6 | terms: {method: ["GET","POST"]} |
method IN ('GET','POST') |
多值枚举 |
| 7 | range: {request_time: {gte: 1, lte: 5}} |
request_time >= 1 AND request_time <= 5 |
gte / lte 闭区间,gt / lt 开区间 |
| 8 | exists: {field: "user_agent"} |
user_agent IS NOT NULL |
字段存在性 |
| 9 | must_not + exists |
user_agent IS NULL |
取反后变成空值判断 |
| 10 | aggs.terms: {field: "uri", size: 10} |
GROUP BY uri ORDER BY cnt DESC LIMIT 10 |
分组计数取 TopN,ORDER BY 不能省 |
| 11 | aggs.date_histogram + calendar_interval: "1h" |
GROUP BY date_trunc(log_time, 'hour') |
粒度从参数变表达式 |
| 12 | aggs 里的 avg / sum / max / min |
AVG() / SUM() / MAX() / MIN() |
指标聚合直接对应 |
| 13 | 两级 aggs 桶(a 套 b) |
GROUP BY a, b |
扁平化;要分层 TopN 需套窗口函数 |
| 14 | bucket_selector(阈值过滤桶) |
HAVING 聚合值 > 阈值 |
比的是聚合值,不能写进 WHERE |
| 15 | match: {message: "timeout error"} |
MATCH_ANY(message, 'timeout error') |
分词后命中任一词 |
| 16 | match + "operator": "and" |
MATCH_ALL(message, 'timeout error') |
全部命中才匹配 |
| 17 | match_phrase: {message: "connect timeout"} |
MATCH_PHRASE(message, 'connect timeout') |
保持词序,需索引开 support_phrase |
| 18 | query_string: "a AND (b OR c)" |
SEARCH('a AND (b OR c)') |
4.0 起提供的统一检索入口,兼容 query_string 风格 |
| 19 | sort + from + size |
ORDER BY ... LIMIT size OFFSET from |
浅翻页可用,offset 大了会变慢 |
| 20 | search_after |
排序键范围条件 + ORDER BY + LIMIT |
深翻页用唯一排序键,不用 OFFSET |
_source: ["log_time","uri"] 对应 SELECT log_time, uri;"size": 0(只要聚合不要明细)在 SQL 里不需要特殊写法,直接不 SELECT 明细列即可。
3.2 检索函数速查
| 写法 | 用途 | 前置条件 |
|---|---|---|
MATCH_ANY(col, 'a b') |
命中任一词 | 列上有带 parser 的倒排索引 |
MATCH_ALL(col, 'a b') |
全部命中 | 同上 |
MATCH_PHRASE(col, 'a b') |
相邻且同序 | 索引属性 support_phrase = "true" |
MATCH_PHRASE(col, 'a b ~3') |
允许间隔 3 个词 | 同上 |
MATCH_PHRASE_PREFIX(col, 'a b') |
最后一个词按前缀匹配 | 用于输入补全类场景 |
MATCH_REGEXP(col, 'key.*') |
分词后的正则匹配 | 同上,注意匹配的是词项不是原文 |
SEARCH('col:a AND col2:ANY(x y)') |
多字段布尔组合,一次下推 | 4.0 起提供 |
score() |
BM25 相关性打分 | 4.0 起提供,配 ORDER BY score() DESC 用 |
3.3 检索参数表
| 参数 | 建议值 | 作用 | 适用场景 |
|---|---|---|---|
parser |
枚举 / 标识符写 none,正文写 unicode |
决定是否分词 | 建索引时按字段用途选 |
support_phrase |
需要短语检索时写 "true" |
开启位置信息,支撑 MATCH_PHRASE |
日志正文、堆栈 |
lower_case |
按是否要区分大小写决定 | 影响词项大小写归一化 | 英文关键词检索 |
SEARCH 的 default_field |
多数字段检索时指定 | 裸词项自动扩展到该列 | 跨列检索 |
SEARCH 的 default_operator |
or(默认)或 and |
多词项默认布尔关系 | 对应 ES 的 default_operator |
minimum_should_match |
Lucene 模式下可设 | 至少命中几个 SHOULD 子句 | 对应 ES 的同名参数 |
date_trunc 单位 |
hour / day,字符串常量 |
时间粒度 | 趋势图与按小时告警 |
EXPLAIN |
每条翻完跑一次 | 确认走倒排索引 | 全场景 |
3.4 一条完整改写示例
原 DSL 是"慢请求看板 + 全文过滤"的组合:
css
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "range": { "log_time": { "gte": "2026-09-01 00:00:00", "lt": "2026-09-02 00:00:00" } } },
{ "terms": { "status": [500, 502, 504] } },
{ "bool": { "must_not": [ { "term": { "app": "internal-tool" } } ] } }
],
"must": [
{ "match": { "message": "timeout upstream" } }
]
}
},
"aggs": {
"by_uri": {
"terms": { "field": "uri_kw", "size": 10 },
"aggs": { "avg_rt": { "avg": { "field": "request_time" } } }
}
}
}
vbnet
SELECT uri_kw,
count(*) AS cnt,
avg(request_time) AS avg_rt
FROM demo.nginx_access
WHERE log_time >= '2026-09-01 00:00:00'
AND log_time < '2026-09-02 00:00:00'
AND status IN (500, 502, 504)
AND (app IS NULL OR app <> 'internal-tool')
AND MATCH_ANY(message, 'timeout upstream')
GROUP BY uri_kw
ORDER BY cnt DESC
LIMIT 10;
三处值得记:filter 与 must 合并进同一个 WHERE,没有区别对待;must_not 作用在可空字段上,补了 IS NULL 分支;terms 合并成 IN,倒排索引一样能加速。
3.5 三段常用片段
sql
-- 1. 按小时趋势:粒度从参数变成截断表达式
SELECT date_trunc(log_time, 'hour') AS h, count(*) AS cnt
FROM demo.nginx_access
WHERE status >= 400
GROUP BY date_trunc(log_time, 'hour')
ORDER BY h;
-- 2. 分层 TopN:两级桶扁平化后再按层取前几名
SELECT uri_kw, status, cnt
FROM (
SELECT uri_kw, status, count(*) AS cnt,
row_number() OVER (PARTITION BY uri_kw ORDER BY count(*) DESC) AS rn
FROM demo.nginx_access
GROUP BY uri_kw, status
) t
WHERE rn <= 3
ORDER BY cnt DESC;
-- 3. 深翻页:用排序键范围代替大 OFFSET
SELECT log_time, trace_id, uri_kw, status
FROM demo.nginx_access
WHERE status = 500
AND (log_time < '2026-09-01 11:00:00'
OR (log_time = '2026-09-01 11:00:00' AND trace_id > '0a1b2c3d'))
ORDER BY log_time DESC, trace_id ASC
LIMIT 1000;
3.6 我记的三条改写习惯
- 不做等价化简。
NOT (A AND B)就原样写成NOT (A AND B),不去手工展开成NOT A OR NOT B。化简省下的几个字符不值得赌一次返工,而且 SQL 优化器本身会做等价改写。 - 同结构的 DSL 一次翻一组。 把字段名统一换成同一个记号之后骨架相同的归为一组,一组翻一条模板,剩下的填字段名。过滤类查询的结构重复度非常高,这个习惯能省掉一半时间。
- 翻完立刻记录两端结果。 行数、分组名与顺序、耗时,三项一起记进清单。后面再出现同类现象时能直接翻记录比对,不用重新跑一遍,也不用在脑子里回忆"上次好像是对的"。
3.7 翻完之后的三步验证
sql
-- 1. 行数:不带分页,两端同一时间窗各跑一次
SELECT count(*) FROM demo.nginx_access WHERE status IN (500, 502, 504);
-- 2. 聚合:比分组名、顺序、组内数值
SELECT uri_kw, count(*) AS cnt FROM demo.nginx_access
WHERE status IN (500, 502, 504) GROUP BY uri_kw ORDER BY cnt DESC LIMIT 10;
-- 3. 索引:确认走倒排索引而不是全表扫描
EXPLAIN SELECT count(*) FROM demo.nginx_access WHERE MATCH_ANY(message, 'timeout');
顺序不要变:行数不对,先查括号与开闭区间;行数对但分组不对,查 ORDER BY 与 NULL 分组;都对但慢,查 EXPLAIN。
四、关键维度对照表
| 维度 | Apache Doris | Elasticsearch |
|---|---|---|
| 布尔组合 | WHERE 里用括号表达层级 | bool 节点树,层级由嵌套决定 |
| 过滤与评分 | WHERE 统一处理,无评分维度 | must 参与评分,filter 不参与且可缓存 |
| 检索入口 | MATCH_ANY / MATCH_ALL / MATCH_PHRASE / SEARCH() |
match / match_phrase / query_string |
| 相关性打分 | 4.0 起提供 BM25 的 score() |
_score 原生支持 |
| 聚合表达 | GROUP BY + 聚合函数,粒度用 date_trunc |
聚合桶类型 + 参数 |
| 聚合后过滤 | HAVING |
bucket_selector / bucket_sort |
| 分页 | LIMIT ... OFFSET,深翻页建议排序键切分 |
from / size 受窗口限制,深翻页用 search_after |
| 关联分析 | 标准 SQL JOIN,可与检索谓词混写 | 无 JOIN,需应用层拼或 denormalize |
| 国产化适配 / 信创 | 已完成鲲鹏 / 海光 / 飞腾等国产 CPU 与麒麟 / 统信 UOS / openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 由 Elastic(美国)运营;公开合规体系以国际认证为主,未见面向国产 CPU 与国产操作系统的官方信创适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;商业化由国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配,与开源 100% 兼容 | 开源版本受 ELv2 / SSPL 条款约束;Elastic Cloud 由 Elastic 提供,国内缺少本地化信创与等保适配团队 |
五、已知约束与规避方式
| 约束 | 表现 | 处理方式 |
|---|---|---|
should 漏括号 |
结果与预期不符,但行数接近 | OR 条件一律加括号 |
range 开闭区间写反 |
边界数据多算或少算 | gte / lte 对应 >= / <= |
must_not 遇 NULL |
两边行数差一小截 | 补 col IS NULL OR col <> 'x' |
聚合漏写 ORDER BY |
TopN 拿到任意 n 组 | 显式写 ORDER BY cnt DESC |
match 用 LIKE 兜底 |
全表扫描,数据量大就超时 | 改用 MATCH_ANY / MATCH_PHRASE / SEARCH() |
| 短语检索退化 | 未开 support_phrase 时退化成全表扫描 |
建索引时显式声明 |
date_trunc 单位写成列 |
报错 | 时间单位必须是字符串常量 |
| 深翻页用大 OFFSET | 越翻越慢 | 改成排序键范围条件 |
minimum_should_match 大于 1 |
无法用一个布尔表达式表达 | 用 CASE WHEN 累加命中数再过滤,或用 Lucene 模式的同名参数 |
六、常见问题(FAQ)
Q:有没有官方的 DSL 转 SQL 工具?
没有。DSL 与 SQL 之间是逻辑转换,工具判断不了"这个 match 该翻成 MATCH_ANY 还是 MATCH_ALL",也判断不了分层桶要不要保留。按上面的速查表逐条翻,再跑三步验证。
Q:match 到底翻成 MATCH_ANY 还是 MATCH_ALL?
看 operator 参数。默认 or,对应 MATCH_ANY;显式写 "operator": "and" 才对应 MATCH_ALL。这个参数在原 DSL 里很容易被忽略,翻之前先看一眼。
Q:4.0 之后是不是所有检索都该用 SEARCH()?
不一定。SEARCH() 的优势是多字段布尔组合能一次下推,写法更短;单字段的关键词与短语检索用 MATCH_* 更直观。我的习惯是简单条件用 MATCH_*,跨字段组合用 SEARCH()。
Q:翻完之后性能一定更好吗?
取决于有没有用对索引。走倒排索引的等值、范围、全文检索会明显快;翻成 LIKE 或对高基数字段大范围扫描,性能可能不如 ES。所以每条翻完都要用 EXPLAIN 确认。
Q:速查表里没有的写法怎么办?
先判断它属于哪一类。评分、高亮、_source 折叠这类属于检索体验层,SQL 没有对应语法,要在应用层实现或确认业务是否真的需要;其余的按"过滤 / 聚合 / 检索"归类,总能落到速查表的某一行附近。
测试结论出处(参考来源)
- Apache Doris 官方文档 · 全文检索与查询加速算子(MATCH_ANY / MATCH_ALL / MATCH_PHRASE / MATCH_PHRASE_PREFIX / MATCH_REGEXP 语义):doris.apache.org/docs/4.x/ta...
- Apache Doris 官方文档 · SEARCH 函数(DSL 语法、ANY / ALL、布尔组合、default_field、minimum_should_match):doris.apache.org/docs/4.x/ta...
- Apache Doris 官方文档 · 倒排索引总览(parser、support_phrase、score() 与 BM25):doris.apache.org/docs/dev/ta...
- Apache Doris 官方文档 · DATE_TRUNC(时间单位常量与截断语义):doris.apache.org/docs/2.1/sq...
- Apache Doris 官方文档(SELECT、WHERE、GROUP BY、HAVING、ORDER BY、LIMIT/OFFSET 语法,可在文档站内检索):doris.apache.org
- 为什么 Apache Doris 是比 Elasticsearch 更好的实时分析替代方案(检索与聚合能力对照):selectdb.com/blog/1385