目录
- 1. 全文检索在 ClickHouse 中的定位
- 2. tokenbf_v1 与 ngrambf_v1 跳数索引
- 3. text index 倒排索引的原理与建表
- 4. 分词器:splitByNonAlpha 与 ngram
- 5. hasToken 与 match 系列函数
- 6. 索引选择与 EXPLAIN 验证
- 7. 模糊匹配与正则的性能边界
- 8. 与 Elasticsearch 的取舍
- 9. 生产实践与调优清单
- 10. 速查表与一句话记忆
- 延伸阅读
1. 全文检索在 ClickHouse 中的定位
ClickHouse 的强项是列式扫描与聚合,而不是文档检索。但在日志、事件、内容等场景里,按关键词过滤是刚需,于是它提供了三层递进的能力:跳数索引、原生倒排索引 text index、以及函数层的子串与正则匹配。理解三者的适用边界,才能在写入放大、索引体积和查询延迟之间取得平衡。
-- 三种能力对应三种典型的过滤写法
SELECT count() FROM logs WHERE hasToken(message, 'timeout'); -- 函数 + token 跳数索引
SELECT count() FROM logs WHERE message LIKE '%timeout%'; -- 子串匹配,通常退化为全扫
SELECT count() FROM articles WHERE hasAnyTokens(title, ['database', 'storage']); -- 多词任一命中
SELECT count() FROM docs WHERE match(body, '(?i)\\bmerge\\w*'); -- 正则,代价最高
SELECT count() FROM docs WHERE positionCaseInsensitive(body, 'merge') > 0; -- 子串,大小写不敏感
hasToken 依赖 token 类跳数索引才能裁剪数据块,LIKE 在没有 text index 时只能逐行扫描,而 match 走的也是同样的路径。因此判断一条查询是否「全文检索友好」,第一步是看它的谓词能否映射到某个索引结构上。函数本身不做任何加速,加速全部来自索引对 granule 的裁剪。
能力层次 依赖结构 典型延迟(1 亿行日志)
函数层 hasToken / match 无索引则全列扫描 3 ~ 20 s
跳数索引 tokenbf_v1 每 granule 的 bloom filter 200 ms ~ 1.5 s
倒排索引 text index 每 granule 的 posting list 30 ~ 300 ms
工程要点:先明确查询模式,再选索引类型。如果只是偶尔查一次,全扫可能比维护索引更划算;如果是高频的固定字段检索,text index 的收益最大,但要接受写入变慢和存储增长。选型前先用真实数据量跑一遍基线,不要凭直觉。
2. tokenbf_v1 与 ngrambf_v1 跳数索引
tokenbf_v1 和 ngrambf_v1 是最早可用的全文索引方案,本质是「每个 granule 一个 bloom filter」。tokenbf_v1 按非字母数字字符切词,把每个 token 哈希进布隆过滤器;ngrambf_v1 则切固定长度的 n-gram。查询时先用过滤器判断该 granule 是否可能包含目标,不可能则整块跳过,从而减少扫描量。
CREATE TABLE logs
(
ts DateTime,
level LowCardinality(String),
message String,
INDEX idx_msg_token message TYPE tokenbf_v1(10240, 3, 0) GRANULARITY 4,
INDEX idx_msg_ngram message TYPE ngrambf_v1(3, 10240, 3, 0) GRANULARITY 4
)
ENGINE = MergeTree
ORDER BY ts;
-- tokenbf_v1 只做整词匹配,查子串命中不了
SELECT count() FROM logs WHERE hasToken(message, 'timeout'); -- 走 idx_msg_token
SELECT count() FROM logs WHERE message LIKE '%timeout%'; -- 走不了,只能全扫
tokenbf_v1 的三个参数依次是布隆过滤器字节数、哈希函数个数、随机种子;ngrambf_v1 在此基础上多一个前置的 n-gram 长度。过滤器越大、哈希函数越多,误报率越低但索引体积越大。granule 默认 8192 行,GRANULARITY 4 表示每 4 个 granule(即 32768 行)合并成一个索引块。
-- 查看索引体积:tokenbf 索引通常占原列 3% ~ 8%
SELECT
name,
formatReadableSize(sum(data_compressed_bytes)) AS idx_size,
formatReadableSize(sum(data_uncompressed_bytes)) AS idx_raw
FROM system.data_skipping_indices
WHERE table = 'logs'
GROUP BY name;
-- name idx_size idx_raw
-- idx_msg_token 12.4 MiB 41.0 MiB
-- idx_msg_ngram 38.7 MiB 128.5 MiB
工程要点:tokenbf_v1 只能整词匹配、大小写敏感、对短 token(如 id、ok)误报率极高——因为短词几乎出现在每个 granule 的 bloom 里,裁剪率接近零。日志场景优先用 tokenbf_v1 而非 ngrambf_v1,后者索引体积常达前者三倍。若必须做子串匹配,请直接上 text index。
3. text index 倒排索引的原理与建表
text index 是 ClickHouse 的原生倒排索引,它不再依赖 bloom 的「可能包含」判断,而是直接存储 token 到行号的 posting list。查询时读取 postings 后仍要回表校验,但因为命中信息精确,裁剪率远高于跳数索引。它支持自定义分词器、字典和多种粒度,是当前推荐的全文检索方案。
CREATE TABLE articles
(
id UInt64,
title String,
body String,
INDEX idx_body body TYPE text(tokenizer = 'splitByNonAlpha') GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY id;
-- 大小写不敏感的分词 + 过滤停用词
ALTER TABLE articles
ADD INDEX idx_title title TYPE text(tokenizer = 'splitByNonAlpha')
GRANULARITY 2;
-- 使用倒排索引反查
SELECT id, title FROM articles WHERE hasToken(body, 'database');
SELECT id FROM articles WHERE hasAllTokens(body, ['distributed', 'consensus']);
GRANULARITY 1 表示每个 granule 单独建索引,精度最高但索引最大;调大粒度会牺牲精度换取体积。text index 属于实验特性,早期版本需要设置 allow_experimental_inverted_index = 1,24.x 起已逐步转正。建表后新写入的数据会自动维护索引,已有分区需要 MATERIALIZE INDEX 才会补齐。
-- 对已有数据补建索引,会触发一次后台合并
ALTER TABLE articles MATERIALIZE INDEX idx_body;
-- 查看索引占用的磁盘与内存
SELECT
name,
formatReadableSize(sum(data_compressed_bytes)) AS compressed,
sum(granules) AS granules
FROM system.data_skipping_indices
WHERE table = 'articles'
GROUP BY name;
工程要点:text index 的收益来自「高选择性的词」。如果一个词在超过 30% 的行里出现,倒排表的裁剪几乎没有价值,反而不如直接全扫。建索引前用 uniqExact 估算目标词的选择性,选择那些能砍掉九成以上数据块的字段与词。参考 MergeTree 合并原理与数据生命周期 理解 granule 与 part 的组织方式。
4. 分词器:splitByNonAlpha 与 ngram
分词器决定了文本如何被切成 token,也直接决定了哪些查询能被索引命中。splitByNonAlpha 按非字母数字字符切分,适合英文与结构化日志;ngram 切固定长度片段,适合中文或需要子串匹配的场景;splitByString 允许指定分隔符;较新版本还提供了 sparseGrams,在 n-gram 基础上做稀疏化以控制索引体积。
-- 英文:按非字母数字切词,大小写不敏感
INDEX idx_en body TYPE text(tokenizer = 'splitByNonAlpha') GRANULARITY 1
-- 中文:3-gram,可支持任意子串匹配
INDEX idx_zh body TYPE text(tokenizer = 'ngram(3)') GRANULARITY 1
-- 按指定分隔符切分,适合路径、标签类字段
INDEX idx_path path TYPE text(tokenizer = 'splitByString(/)') GRANULARITY 1
分词粒度直接决定召回与体积的平衡。ngram 的 n 越小,召回越全但索引越膨胀;n 越大,体积越小但只能匹配长度不小于 n 的片段。中文场景常用 2-gram 或 3-gram,实测 3-gram 在保证召回的同时能把索引体积控制在正文的 15% 以内。
-- 用 tokens 函数验证分词结果,避免索引建错
SELECT tokens('ClickHouse text index 倒排索引');
-- ['clickhouse', 'text', 'index', '倒排索引']
SELECT tokens('ClickHouse text index 倒排索引', 'ngram(3)');
-- ['cli', 'lic', 'ick', 'ckh', 'kho', 'hou', 'ous', 'use', ...]
-- 查询必须使用与索引一致的分词,否则索引失效
SELECT count() FROM articles WHERE hasToken(body, '倒排索引'); -- splitByNonAlpha 下不命中
工程要点:查询谓词里的 token 必须与索引分词结果对齐,否则索引形同虚设。中文用 splitByNonAlpha 会把整句当成一个 token,导致几乎无法命中;此时必须改用 ngram。上线前务必用 tokens() 函数打印实际切词结果。参考 ClickHouse JSON 与半结构化数据处理:导入、提取、性能陷阱与建模 处理嵌套字段中的文本。
5. hasToken 与 match 系列函数
函数是全文检索的入口。hasToken 判断单个 token 是否存在,hasAllTokens / hasAnyTokens 处理多词与或,multiSearchAny 一次匹配多个子串,match 与 like 走正则与通配符路径。它们的成本差异很大,选错函数会让索引失效,代价是数量级的延迟差距。
-- 单 token:可走 tokenbf_v1 与 text index
SELECT count() FROM logs WHERE hasToken(message, 'error');
-- 全词命中 / 任一命中:多条件检索
SELECT count() FROM logs WHERE hasAllTokens(message, ['disk', 'full']);
SELECT count() FROM logs WHERE hasAnyTokens(message, ['timeout', 'refused']);
-- 一次匹配多个子串,比多个 LIKE 拼 OR 更快
SELECT count() FROM logs WHERE multiSearchAny(message, ['timeout', 'refused', 'reset']);
-- 正则与通配符:精度高但代价大
SELECT count() FROM logs WHERE match(message, '(?i)connection (timeout|refused)');
SELECT count() FROM logs WHERE message LIKE '%timeout%';
hasToken 是大小写敏感的,且要求查询词本身是一个完整 token。match 使用 RE2 语法,功能强于 like,但两者在没有倒排索引时都要逐行求值。multiSearchAny 底层用 Hyperscan 预编译模式,在无索引的场景下通常比 LIKE OR LIKE 快 2~5 倍。
-- 大小写不敏感的子串匹配
SELECT count() FROM logs WHERE positionCaseInsensitive(message, 'Timeout') > 0;
-- ngram 索引配套的子串检索
SELECT count() FROM logs WHERE ngramSearch(message, 'timeout');
-- 统计子串出现次数
SELECT countSubstrings(message, 'timeout') AS cnt FROM logs WHERE cnt > 0 LIMIT 5;
工程要点:优先用 hasToken 系列,它们能吃到索引;LIKE 与 match 只在没有更好选择时使用。multiSearchAny 是「多关键词无索引」场景的最优解。注意 hasToken 的大小写敏感特性,日志字段若大小写混杂,先统一小写再建索引。参考 数组与高阶函数:arrayMap、arrayFilter 与 Lambda 表达式 组合数组函数做多词统计。
6. 索引选择与 EXPLAIN 验证
建完索引不代表查询会用它。EXPLAIN indexes = 1 会打印每个索引的裁剪情况,包括参与裁剪的 granule 总数与剩余数。只有当剩余 granule 明显少于总数时,索引才真正生效;否则说明谓词没匹配上索引,或者选择性太差。
EXPLAIN indexes = 1
SELECT count() FROM logs WHERE hasToken(message, 'timeout');
-- 输出(节选)
-- Expression ((Projection + Before ORDER BY))
-- Aggregating
-- Expression (Before GROUP BY)
-- ReadFromMergeTree (logs)
-- Indexes:
-- PrimaryKey
-- Keys: ts
-- Condition: true
-- Parts: 12/12
-- Granules: 480/480
-- Skip
-- Name: idx_msg_token
-- Description: message
-- Condition: hasToken(message, 'timeout')
-- Parts: 4/12
-- Granules: 53/480
上面的输出说明 token 索引把 480 个 granule 裁到了 53 个,裁剪率约 89%,索引有效。如果 Granules 显示 480/480,则索引完全没起作用:可能是函数与索引类型不匹配(例如对 tokenbf 用 LIKE),也可能是查询词太常见导致每个 granule 都命中。
-- 对比:LIKE 无法被 tokenbf_v1 裁剪
EXPLAIN indexes = 1
SELECT count() FROM logs WHERE message LIKE '%timeout%';
-- Skip
-- Name: idx_msg_token
-- Condition: ...
-- Granules: 480/480 -- 未裁剪
-- 查看某列所有可用索引
SELECT name, type, expr, granularity
FROM system.data_skipping_indices
WHERE table = 'logs';
工程要点:把 EXPLAIN indexes = 1 当作建索引的验收步骤,裁剪率低于 50% 就要重新审视选型。注意 Parts 与 Granules 两个维度,前者反映分区裁剪,后者反映索引裁剪。优化器行为可参考 查询优化器与执行引擎深入。
7. 模糊匹配与正则的性能边界
模糊匹配是全文检索里最贵的一环。前缀通配符(LIKE 'abc%')尚可利用字典序做范围裁剪,但前导通配符(LIKE '%abc')会彻底禁用裁剪,只能逐行扫描。正则更甚,match 虽然用 RE2 保证线性时间,但每一行都要完整求值,数据量一大就线性膨胀。
-- 前缀匹配:可利用主键或 minmax 索引
SELECT count() FROM logs WHERE message LIKE 'disk%';
-- 前导通配符:无法裁剪,逐行扫描
SELECT count() FROM logs WHERE message LIKE '%disk full%';
-- 正则:RE2 线性时间,但每行都要求值
SELECT count() FROM logs WHERE match(message, '.*disk (full|space).*');
-- 用子串函数替代正则,通常更快
SELECT count() FROM logs WHERE position(message, 'disk full') > 0;
实测在 1 亿行日志上,前导通配符的 LIKE 约需 12 秒,而 hasToken 配合 tokenbf 索引只需 300 毫秒,差距近 40 倍。正则再慢一倍。所以性能优化的核心不是调参,而是「把模糊匹配改写成可索引的精确匹配」。
匹配方式 可裁剪 1 亿行延迟
hasToken + tokenbf_v1 是 0.3 s
LIKE 'disk%'(前缀) 部分 1.8 s
LIKE '%disk full%'(前导) 否 12 s
match 正则 否 24 s
multiSearchAny(多子串) 否 4 s
工程要点:能精确匹配就不要模糊匹配。若业务确实需要子串检索,用 text index 的 ngram 分词器把 LIKE '%x%' 转成 hasToken 形态,是最实际的优化路径。字段设计阶段就要考虑检索模式,参考 ClickHouse Schema 建模最佳实践:主键、分区、压缩与宽窄表设计。
8. 与 Elasticsearch 的取舍
Elasticsearch 是专门的搜索引擎,在相关性打分、分词器生态、聚合分析上远胜 ClickHouse。但 ClickHouse 的优势在于「一份数据同时做检索与分析」,省去了跨系统同步的复杂度与延迟。选型的核心问题是:你需要的是「搜得准」还是「查得快且能和明细一起聚合」。
维度 ClickHouse Elasticsearch
检索能力 整词 / ngram / 正则 丰富分词、相关性打分、高亮
分析能力 列式聚合,秒级 聚合较弱,重查询易 OOM
数据一致性 写入即可查(秒级) 需 refresh,默认 1 s 可见
运维复杂度 单系统,SQL 统一 需额外集群与同步链路
典型场景 日志过滤 + 聚合、埋点分析 站内搜索、文档检索、全文排序
实践中常见的架构是「ClickHouse 做主存储与分析,必要时把需要相关性排序的字段同步到 ES」。但如果你的检索只是「按关键词过滤出明细再聚合」,ClickHouse 完全够用,而且避免了双写不一致的风险。
-- ClickHouse 里一次查询同时完成检索与聚合
SELECT
toStartOfHour(ts) AS h,
count() AS errors,
uniqExact(user_id) AS users
FROM logs
WHERE hasAnyTokens(message, ['timeout', 'refused', 'reset'])
AND ts >= now() - INTERVAL 1 DAY
GROUP BY h
ORDER BY h;
若要同步到 ES,要注意字段映射与刷新延迟。常见做法是用物化视图或 Kafka 引擎把需要相关性排序的字段投递到外部搜索系统,ClickHouse 侧保留全量明细。这样既能用 ES 做排序,又能用 ClickHouse 做聚合,代价是双份存储与一条需要长期维护的同步链路。
-- 用 Kafka 引擎把待检索数据投递到外部搜索系统
CREATE TABLE search_outbox
(
id UInt64,
body String
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka:9092',
kafka_topic_list = 'search_sync',
kafka_format = 'JSONEachRow';
工程要点:不要为了「全文检索」四个字就上 ES。先问清楚需求是关键词过滤还是相关性排序:前者用 ClickHouse 的 text index 足够,后者才需要 ES。跨系统同步带来的运维与一致性成本,往往比想象中高。相关场景参考 ClickHouse 实时分析场景。
9. 生产实践与调优清单
把全文检索搬上生产,问题大多不在「能不能用」,而在「稳不稳、贵不贵」。下面是踩坑与调优的高频清单,每一条都来自真实事故或性能回归。
踩坑清单
1. tokenbf_v1 只做整词匹配,用它配合 LIKE 永远裁剪不了
2. hasToken 大小写敏感,日志字段大小写混杂时命中率骤降
3. 短 token(id、ok、err)在 bloom 里几乎全命中,误报率极高
4. text index 对高基数字段收益大,对超过 30% 行出现的词无价值
5. 中文用 splitByNonAlpha 分词会导致整句变一个 token,必须用 ngram
6. 查询分词必须与索引分词一致,否则索引失效且无任何告警
7. 忘记 MATERIALIZE INDEX,历史数据不会进索引
8. ngrambf_v1 索引体积常达 tokenbf_v1 三倍,慎用
9. 前导通配符 LIKE 与正则无法裁剪,务必压测后再上线
10. 索引过多会拖慢写入与合并,按查询模式取舍
调优方面,先把高频查询模式列出来,只为这些模式建索引;其次控制索引数量,每张表建议不超过 5 个跳数索引;最后定期用 system.data_skipping_indices 与 EXPLAIN indexes = 1 复盘裁剪率,淘汰长期零收益的索引。
-- 找出从未被使用的索引(结合查询日志分析)
SELECT name, type, expr
FROM system.data_skipping_indices
WHERE table = 'logs';
-- 监控索引带来的写入与合并开销
SELECT
table,
formatReadableSize(sum(bytes_on_disk)) AS total_size
FROM system.parts
WHERE table = 'logs' AND active
GROUP BY table;
工程要点:索引是「用写入换读取」的交易,没有免费的午餐。上生产前必须压测「建索引 vs 不建索引」的写入吞吐差异,通常跳数索引会让写入慢 5% ~ 20%。同时把 EXPLAIN indexes = 1 纳入查询上线检查项。整体调优思路参考 ClickHouse 生产性能调优实战:写入、查询、内存与集群优化。
10. 速查表与一句话记忆
下表汇总了本文涉及的核心索引与函数选型要点,可作为日常开发的对照卡。
| 索引 / 函数 | 适用场景 | 关键注意点 |
|---|---|---|
| tokenbf_v1 | 英文日志整词过滤 | 只整词匹配、大小写敏感、短词误报高 |
| ngrambf_v1 | 子串 / 中文检索 | 索引体积大,约为 tokenbf 三倍 |
| text index | 高频精确关键词检索 | 需与分词器对齐,历史数据要 MATERIALIZE |
| hasToken 系列 | 单 / 多 token 过滤 | 大小写敏感,能吃到索引 |
| multiSearchAny | 多子串无索引匹配 | 底层 Hyperscan,优于 LIKE OR 拼接 |
| match / LIKE | 正则与通配符 | 前导通配符与正则无法裁剪,慎用 |
一句话记忆:整词检索靠 hasToken 加 token 索引,子串检索靠 ngram 分词器加 text index,正则与前导通配符永远是最慢的那条路,能用精确匹配就别用模糊匹配。
延伸阅读
- 跳数索引与数据裁剪的完整原理
- MergeTree 的 granule 与 part 组织方式
- 优化器如何选择与使用索引
- SQL 性能分析与执行计划解读
- 面向检索场景的字段与表设计
- 生产环境的整体性能调优
- 半结构化文本字段的检索处理
- 数据库专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。