搜索:从倒排索引到 PostgreSQL、Elasticsearch 选型

2026/11/4 · Stack Frame

分类:后端架构 · 标签:后端 搜索 数据库 架构设计

适用读者:正在用 LIKE '%关键词%' 做站内搜索、发现数据一多就慢得没法用的人;在 PostgreSQL 全文检索和 Elasticsearch 之间摇摆的人;已经上了搜索引擎,但搜索结果不相关、索引还经常不同步的人 结论先行:模糊匹配慢,不是索引没加对,而是 B+ 树按完整字符串排序,天生回答不了「中间包含什么」。搜索需要的是倒排索引:从词出发,直接找到包含它的文档。选型的分界线也不是「数据量大小」,而是你要不要分词控制、相关性调权、聚合和横向扩展;要,就上搜索引擎,不要,PostgreSQL 的全文检索往往够用。 阅读时间:约 34 分钟 | 难度:进阶 | 收获:倒排索引的物理结构、分词与 BM25 的因果、PostgreSQL 与 Elasticsearch 的能力边界、索引同步的三种方案,以及一套可观测的搜索指标
目录
  • 事故:LIKE '%关键词%' 为什么越跑越慢
  • 为什么 B+ 树救不了中缀匹配
  • 倒排索引:词典、posting、位置与跳表
  • 从文本到 posting:分词与归一化决定上限
  • 相关性:从 TF-IDF 到 BM25
  • PostgreSQL 全文检索:tsvector、tsquery、GIN
  • Elasticsearch:mapping、analyzer 与查询 DSL
  • NRT、副本与深分页
  • Meilisearch 与 Typesense:容错换可控
  • 索引同步:双写、Outbox 与全量重建
  • 查询设计与安全:filter/query、零结果、多租户
  • 观测:延迟、零结果率、点击率、索引延迟
  • 小结
  • 相关阅读

事故:LIKE '%关键词%' 为什么越跑越慢

站内搜索最早只有一行代码:Article.objects.filter(title__icontains=q)。数据量几万条时,它快得让人忘记它的存在;到几十万条,接口开始偶尔超过一秒;到一百多万条,搜索框每敲一个字都要等三四秒,用户索性不搜了。 我一开始的反应很典型:加索引。给 title 建了一个普通的 B+ 树索引,重新发布,结果没有任何变化。EXPLAIN 显示优化器仍然选择顺序扫描,索引安静地躺在那里,一点忙都帮不上。 第二个问题是结果不相关。搜「Django 部署」,标题里同时出现这两个词但隔得很远的文章,和标题就是「Django 部署到 PythonAnywhere」的文章,排在差不多的位置;搜「缓存」,正文里提了一百次「缓存」的长文压过了真正讲缓存架构的短文。数据库的 icontains 只能回答「包含或不包含」,它没有「这个词有多重要」的概念,更没有「标题比正文重要」的概念。 这两个问题看起来是性能和相关性的两件事,根因却是同一个:用有序数组的查找方式,去回答集合成员的查找问题。B+ 树适合「等于」和「从某个前缀开始的范围」,不适合「任意位置包含」;LIKE 也没有能力表达相关性。要同时解决,需要换一种索引结构。

为什么 B+ 树救不了中缀匹配

B+ 树的键是按字典序排列的。给定一个键 K,它能快速定位到 K 所在的位置,也能沿着叶子节点顺序扫描 K 之后的所有键。这决定了它支持两类查询:精确匹配,以及「以某段固定前缀开头」的范围扫描。 LIKE 'Django%' 之所以可能用上索引,是因为所有以 Django 开头的字符串在 B+ 树里是连续的一段,优化器可以把 Django 当成下界,把 Django 后面追加一个最大字符当成上界,只扫这一小段。 LIKE '%Django%' 不行。以 Django 结尾、或者在中间包含 Django 的字符串,在字典序里散落在整棵树中:A Django book、My Django、Using Django 彼此不相邻。没有一个连续区间能覆盖它们,优化器只能退化成全表扫描,对每一行做一次子串查找。 全表扫描的成本是 O(N·L):N 是行数,L 是参与匹配的字符串平均长度。一百多万行、标题平均三十个字符,就是几千万次字符比较;如果还带 icontains 的大小写折叠,每次比较还要做 Unicode 归一化,成本再往上抬。数据量线性增长,延迟也线性增长,这就是「越来越慢」的来源。 排序规则(collation)会在这个问题上再插一脚。MySQL 的 utf8mb4_0900_ai_ci 是大小写不敏感、重音不敏感的排序规则,LIKE 的语义跟着它走;PostgreSQL 的 LC_COLLATE 决定比较行为,不同 locale 下同一段文本的排序可能不同。索引是按某一种排序规则建的,查询如果用了另一种语义,优化器更不敢用索引。很多「加了索引还是不生效」的案例,最后都落到排序规则不匹配上。 PostgreSQL 有一个例外:pg_trgm 扩展把字符串切成三元组,建 GIN 或 GiST 索引后,LIKE '%x%'、ILIKE、正则匹配都能被索引加速。但这不是 B+ 树的功劳,而是另建了一套倒排式的结构;它索引体积大、写入变慢,对短查询词和常见词的效果也会打折。MySQL 没有等价的内置三元组索引,LIKE '%x%' 基本只能扫。知道这个例外很重要,它说明「中缀匹配」并非无解,只是解法不是普通 B+ 树。

倒排索引:词典、posting、位置与跳表

倒排索引把关系反过来存。正排是「文档 → 它包含哪些词」,倒排是「词 → 哪些文档包含它」。搜索时从查询词出发,直接拿到文档列表,不需要逐篇扫描。 一个倒排索引至少有三层结构。 第一层是词典(term dictionary)。它保存所有出现过的词项,按字典序排列,支持按词快速定位。词典的实现可以是排序数组加二分,也可以是 FST(有限状态转换器)或前缀树。Lucene 用 FST 把词项前缀共享,既省内存又能做前缀遍历;词典本身不存 posting,只存指向 posting 的偏移。 第二层是倒排表(postings list)。每个词项对应一条文档列表,列表项至少包含文档 ID 和词频。文档 ID 通常递增,所以可以用差值编码压缩:存 1, 3, 7, 12 变成 1, 2, 4, 5,每个数占的位数大幅下降。词频用来算相关性分数。 第三层是位置(positions)。如果只做「包含这个词」的布尔检索,有文档 ID 就够了;一旦要支持短语查询、邻近查询和高亮,就必须记录每个词在文档中的位置。位置列表同样用差值编码。代价是索引变大,所以有些字段会关掉 positions,只保留文档级信息。 多个词项的 posting 求交集时,如果逐项线性推进,成本是各列表长度之和。跳表(skip list)在 posting 里每隔一段建一个跳跃指针,让「推进到下一个大于等于 X 的文档」可以跳过整段,而不是一个一个走。Lucene 的 posting 里还有更细的跳表层级,查询计划的顺序也会按词项稀有度排序:先处理文档频率最低的词,候选集缩得最快。 文档频率(df)在这里是一个贯穿始终的量。一个词出现在越少的文档里,它的 posting 越短,查询越快,信息量也越大;出现在几乎所有文档里的词,posting 很长,查询慢,区分度也低。这既是性能问题,也是后面 BM25 里 idf 的来源。 用一次查询走一遍流程:查询被分词成若干词项;每个词项在词典里定位,拿到 posting 的偏移;按稀有度排序后求交集,求交过程中用跳表跳过不可能命中的文档;对命中的文档,用 posting 里的词频、字段长度和 idf 算分;最后用一个大小为 K 的最小堆维护 top-K,而不是把所有命中文档全排序。整套流程的成本大致与「参与求交的 posting 总长度」成正比,而不是与文档总数成正比。这就是为什么倒排索引在百万、千万文档上仍然能保持可接受的延迟。 Lucene 还把索引切成不可变的段(segment)。新写入先进入内存缓冲,攒够一定量后 flush 成一个新段;段一旦写出就不再修改,删除通过墓碑标记;后台的 merge 把多个小段合并成大段,减少查询时要打开的段数。这个「不可变段 + 合并」的设计,是后面近实时搜索和副本同步的基础。

从文本到 posting:分词与归一化决定上限

倒排索引的第一句话是「文档包含哪些词」,而「哪些词」由分词器决定。分词错了,后面的索引、查询、打分全部跟着错,而且这种错很难在查询层补救。 中文没有天然空格,这是第一道坎。常见的三种切法各有代价。 第一种是词典分词,代表是 jieba 和 zhparser(后者基于 SCWS,用作 PostgreSQL 的解析器)。它按词典和统计模型切出「倒排索引」「断点续传」这样的词,索引体积小,语义单位完整,相关性容易做好。代价是依赖词典:新词、专有名词、网络用语容易切错;「研究生命起源」可能被切成「研究/生命/起源」,也可能切成「研究生/命/起源」,具体取决于词典和模型。业务词必须通过自定义词典补进去,否则核心词会被切碎。 第二种是二元组(bigram),把连续两个汉字作为一个词项。它不依赖词典,召回稳定,任何查询词都能切出可匹配的二元组;代价是索引膨胀,且会产生大量伪匹配,比如「北京大学」和「北京大家」共享「北京」「京大」等二元组。Lucene 的 CJKBigramFilter、Elasticsearch 的 cjk analyzer 都走这条路。它适合词典覆盖不足、且能接受一定噪声的场景。 第三种是混合:用词典分词做主索引,同时保留 bigram 或单字作为兜底字段,查询时两路合并。实现复杂,但对中文站内搜索往往最稳。选择哪一种,取决于你的语料和查询词分布,不存在「中文分词器排行榜第一」这种答案。 英文的分词看起来简单,其实也有坑。标准分词器按空白和标点切,随后要决定是否小写、是否去停用词、是否做词干提取。词干提取(stemming)把 running、runs、ran 归到 run,能提高召回;但它也会把不该合并的词合并,比如 universe 和 university 在某些算法下会被截到同一个前缀。Porter 和 Snowball 是常见算法,Lucene 提供 EnglishAnalyzer,Elasticsearch 的 english analyzer 内置了停用词和 Snowball 词干。 归一化是分词之外的另一半。大小写折叠、全角转半角、Unicode NFKC、重音折叠(café 与 cafe)、空白压缩,这些操作要让索引时和查询时走同一套。最常见的错误是索引用了 ik_smart,查询用了 standard,两边切出来的词项对不上,结果自然为零。排查时不要猜,直接用分析接口看两边分别切成了什么:
POST /articles/_analyze
{
  "field": "title",
  "text": "Django 部署与断点续传"
}
响应里的 tokens 会告诉你每个词项、位置和偏移。索引和查询的分析结果必须一致,这是一条硬约束。 同义词要决定放在索引时还是查询时。索引时扩展同义词,会让 posting 变大,而且同义词表一改就得重建索引;查询时扩展更灵活,改完立即生效,代价是查询词项变多、延迟略增。绝大多数场景应该选查询时扩展,索引只保留原文的词项。

相关性:从 TF-IDF 到 BM25

倒排索引解决了「找得到」,相关性解决「排得对」。最经典的打分是 TF-IDF:
score(q, d) = Σ tf(t, d) × idf(t)
idf(t) = log(N / df(t))
tf 是词在文档里出现的次数,df 是包含这个词的文档数,N 是文档总数。直觉很合理:词出现得越多越相关,越稀有越有区分度。但直接用它会有两个明显问题。 第一,tf 无上界。一个词从出现 1 次到 2 次,分数翻倍;从 10 次到 20 次,分数再翻倍。这等于鼓励堆词,长文只要反复提同一个词就能压过短文,哪怕短文才是主题。 第二,文档长度没有归一化。长文档天然包含更多词、每个词的次数也更多,tf 的总和更大,于是长文系统性占优。这不是因为长文更相关,只是因为它更长。 BM25 针对这两点做了修正。它的常见形式是:
score(q, d) = Σ idf(t) × [ tf × (k1 + 1) ] / [ tf + k1 × (1 - b + b × dl / avgdl) ]
idf(t) = ln(1 + (N - df + 0.5) / (df + 0.5))
dl 是文档长度,avgdl 是平均文档长度,k1 控制词频饱和的速度,b 控制长度归一化的强度。Lucene 的 BM25Similarity 默认 k1 = 1.2、b = 0.75,Elasticsearch 默认就用这套。 k1 的饱和效应可以这样理解:当 tf 很小时,分母里的 tf 占主导,分数随出现次数快速上升;当 tf 已经很大,分母被 k1 × (...) 托住,再增加次数带来的增益越来越小。于是「出现 1 次到 2 次」有意义,「出现 10 次到 20 次」几乎不改变排序。这就是它比 TF-IDF 稳的地方:不奖励堆词。 b 的取值决定长度惩罚。b = 0 完全不归一化,长文占优;b = 1 完全按长度比例惩罚,短文可能被过度抬高。0.75 是一个折中。对于标题这种长度差异不大的字段,长度归一化的影响小;对于正文,它非常关键。 idf 的形式也变了。BM25 用的是 ln(1 + (N - df + 0.5)/(df + 0.5)),即使某个词出现在所有文档里,idf 仍然是一个小的正数,不会变成零或负数。TF-IDF 的 log(N/df) 在 df = N 时直接归零,在极端情况下还会出负值,导致「包含这个词反而扣分」。BM25 的写法避免了这种不稳定性。 字段权重是第二层排序信号。标题命中的权重通常高于正文,正文高于评论;实现上可以给不同字段不同的 boost,或者用多字段查询的合并策略。以 Elasticsearch 的 multi_match 为例,best_fields 取所有字段里得分最高的一个,适合「命中一个字段就够」;most_fields 把各字段分数相加,适合「多处提及更相关」;cross_fields 把多个字段当成一个大字段,适合「一个词在标题、另一个词在正文」的查询。 短语和邻近度是第三层。倒排索引存了位置,才能回答「这两个词是否相邻」。短语查询要求词项位置连续;邻近查询允许中间隔几个词,slop 就是允许的间隔。没有 positions 的字段做不了这件事,只能退化成布尔「都包含」。高亮同样依赖位置或偏移,否则无法知道该给哪一段加标记。 相关性没有一劳永逸的公式。BM25 是一个好的起点,但业务上的「相关」往往还包含时效性、作者权重、点击率、内容质量。把这些信号叠加进去,通常用 function_score 这类机制,而不是修改 BM25 本身。

PostgreSQL 全文检索:tsvector、tsquery、GIN

如果站点已经在用 PostgreSQL,第一站应该是它自带的全文检索,而不是直接上集群。 PostgreSQL 的做法是把文本转成 tsvector:一串按位置排序的词位(lexeme),每个词位可以带位置和权重。to_tsvector 负责分词、归一化和去停用词:
SELECT to_tsvector('english', 'The quick brown foxes are jumping');
-- 'brown':3 'fox':4 'jump':6 'quick':2
foxes 被词干化成 fox,jumping 变成 jump,the 和 are 作为停用词被去掉,数字是词在原文中的位置。权重可以用 setweight 标成 A、B、C、D,分别对应标题、摘要、正文等不同重要程度。 查询侧用 tsquery,支持与、或、非、短语和距离。下面的中文例子统一用 chinese 配置,这个配置的创建命令在本节后半段;没装中文解析器时,可以先用 simple 在英文语料上验证整条链路。
SELECT to_tsvector('chinese', title) @@ to_tsquery('chinese', 'django & 部署');
SELECT to_tsvector('chinese', body) @@ to_tsquery('chinese', '缓存 <-> 穿透');
SELECT to_tsvector('chinese', body) @@ to_tsquery('chinese', '缓存 <3> 雪崩');
<-> 要求相邻,<N> 允许中间隔 N 个词。websearch_to_tsquery 更适合直接接用户输入:按 PostgreSQL docs 的说明,它把引号里的内容当短语,把 OR 当或,把 - 当前面词的排除,并且不会因为用户输入里的特殊字符报错。这是面向搜索框最实用的入口。 索引用 GIN:
CREATE INDEX articles_title_fts
ON articles
USING GIN (to_tsvector('chinese', coalesce(title, '')));

SELECT id, title,
       ts_rank(to_tsvector('chinese', title), query) AS rank
FROM articles, websearch_to_tsquery('chinese', 'django 部署') AS query
WHERE to_tsvector('chinese', title) @@ query
ORDER BY rank DESC
LIMIT 20;
GIN 是倒排式的索引,适合 @@ 这种包含查询;GiST 也能用,但更新更贵,通常只在需要动态重排或有特殊数据类型时才考虑。PostgreSQL docs 对 GIN 的适用场景和更新代价有明确说明。表达式索引要求查询里的表达式和索引定义逐字一致,coalesce 和配置名都不能差,否则规划器不会用它。 中文需要额外的解析器。PostgreSQL 内置的分词器面向空格分隔的语言,直接对中文用 simple 配置,往往把整句当成一个词位,或者按标点切得乱七八糟。常见方案是 zhparser(基于 SCWS)或 pg_jieba。以 zhparser 为例,安装扩展后创建配置,把词性映射到 simple 字典:
CREATE EXTENSION zhparser;
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese
  ADD MAPPING FOR n, v, a, i, e, l WITH simple;

SELECT to_tsvector('chinese', 'Django 部署与断点续传');
zhparser 的边界要提前知道:词典覆盖有限,专业术语和新词需要 zhparser.zhprs_custom_word 补词;词性映射配错会导致某些词直接被丢掉;它没有 Elasticsearch 那种同义词图和丰富的分析器链。它够用,但不万能。 排序用 ts_rank 或 ts_rank_cd。前者基于词频,后者基于覆盖密度(cover density),对短语和邻近更敏感。它们都不是 BM25,排序质量与 Lucene 有差距,调权手段也有限。如果相关性是核心竞争力,这里会成为瓶颈。 规模上,GIN 索引的大小与词位和 posting 数量相关,通常是原文本的几个到十几个百分点;写入时每个文档都要更新索引,更新频繁的大表会把写放大。查询性能则高度依赖词项的文档频率:搜一个常见词,posting 很长,延迟会明显上升。百万级行、读多写少、查询不复杂的站内搜索,PostgreSQL 完全能扛;到了需要多字段复杂调权、聚合、拼写容错、横向扩展的阶段,就该考虑搜索引擎了。

Elasticsearch:mapping、analyzer 与查询 DSL

Elasticsearch 建立在 Lucene 之上,把「一个索引」拆成多个分片,每个分片是一个 Lucene 索引,再在外面套上分布式协调、副本、REST 接口和聚合能力。 第一件必须做的事是显式 mapping。动态 mapping 会把字符串猜成 text 加 keyword 子字段,把日期猜成日期,把数字猜成 long;猜错之后再改字段类型,通常要重建索引。生产索引应该关掉动态猜测,至少对核心字段写死类型:
PUT /articles
{
  "mappings": {
    "properties": {
      "title":   { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
      "summary": { "type": "text", "analyzer": "ik_max_word" },
      "author":  { "type": "keyword" },
      "tags":    { "type": "keyword" },
      "published_at": { "type": "date" },
      "view_count":   { "type": "integer" }
    }
  }
}
text 用于全文检索,会被分词、建 posting、参与打分;keyword 用于精确过滤、排序和聚合,不分词,走 doc values 的列式存储。把该用 keyword 的字段建成 text,过滤会变慢且语义不对;把该用 text 的字段建成 keyword,全文检索直接失效。 analyzer 和 search_analyzer 可以分开。索引时用细粒度分词(ik_max_word)提高召回,查询时用粗粒度(ik_smart)减少噪声,这是中文场景常见的组合。前提是两者的词项能对上;如果对不上,就会出现「索引里有、查询切不出」的零结果。 查询 DSL 的基本结构是 bool:
GET /articles/_search
{
  "query": {
    "bool": {
      "must": [
        { "multi_match": {
            "query": "Django 部署",
            "fields": ["title^3", "summary^2", "body"],
            "type": "best_fields",
            "tie_breaker": 0.3
        }}
      ],
      "filter": [
        { "term": { "status": "published" } },
        { "range": { "published_at": { "gte": "now-2y" } } }
      ],
      "must_not": [
        { "term": { "tags": "spam" } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "published_at": "desc" }
  ],
  "size": 10
}
must 参与打分,filter 不参与打分且可以被缓存,must_not 排除。把状态、租户、时间范围这类「硬条件」放进 filter,是性能优化的第一步,也是权限过滤的正确位置。 排序信号可以用 function_score 叠加。比如给新文章一个时间衰减,给高阅读量一个加权:
GET /articles/_search
{
  "query": {
    "function_score": {
      "query": { "multi_match": { "query": "缓存", "fields": ["title^3", "body"] } },
      "functions": [
        { "gauss": { "published_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } },
        { "field_value_factor": { "field": "view_count", "factor": 0.1, "modifier": "log1p" } }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}
聚合用于筛选面板和统计:terms 聚合按标签分桶,date_histogram 按时间分桶,cardinality 估算去重数。聚合走 doc values,和全文检索的 posting 是两套结构,所以「搜索 + 聚合」在 Elasticsearch 里是一次请求能完成的事,在纯 PostgreSQL 里往往要额外写 SQL。

NRT、副本与深分页

Elasticsearch 常被称为「近实时」,这个词容易被误解成「写入后立刻能搜到」。准确地说,写入先进入内存缓冲和 translog,只有执行 refresh 之后,数据才进入新的 Lucene 段,对搜索可见。按 Elasticsearch docs 的默认值,refresh_interval 是 1 秒,所以最坏情况下有接近一秒的可见性延迟。需要更强可见性时可以显式 refresh,但频繁 refresh 会产生大量小段,加重 merge。 持久性和可见性是两件事。translog 负责前者:默认 index.translog.durability 是 request,每个写请求都会 fsync;即使进程崩溃,重启后也能从 translog 恢复。refresh 负责后者,它不保证数据落盘。把两者混为一谈,就会在「为什么明明返回 200 却搜不到」和「refresh 了是不是就安全了」之间反复困惑。 副本解决可用性和读吞吐。每个分片有一个主分片和若干副本,写入先到主分片,再复制到副本;搜索可以打到主分片或副本。副本数增加会提高读能力,但也增加写入放大和存储成本。副本的 refresh 跟随主分片,所以可见性延迟不会因为副本而消失。 深分页是另一个常见陷阱。from + size 的查询,每个分片都要取出前 from + size 条,协调节点再归并排序。翻到第 100 页、每页 20 条,意味着每个分片都要计算并丢弃前 2000 条;分片越多,浪费越大。Elasticsearch docs 里 index.max_result_window 默认 10000,就是给这种成本设的上限。 正确的翻页方式是 search_after:用上一页最后一个文档的排序值作为游标,下一页从它之后开始。它要求排序字段组合唯一,通常用 published_at 加 _id。为了避免翻页过程中数据变化导致重复或漏项,配合 PIT(point in time)固定索引视图:
POST /articles/_search
{
  "size": 20,
  "sort": [ { "published_at": "desc" }, { "_id": "asc" } ],
  "search_after": [ "2026-10-01T00:00:00Z", "article-123" ],
  "pit": { "id": "pit_id", "keep_alive": "5m" }
}
search_after 不适合「跳到第 N 页」的交互,它只适合「下一页」。需要随机跳页的产品,要么限制页数,要么换一种呈现方式。导出全量数据用 scroll 或 PIT 加切片,不要用 from + size 硬翻。 分片数量也要克制。每个分片都是一个 Lucene 索引,有固定的段、文件句柄和堆开销;分片过多会导致 cluster state 变大、查询要合并更多结果。常见经验是单分片几十 GB 量级,按数据增长预留,不要一开始就按「未来十倍」切出几百个分片。

Meilisearch 与 Typesense:容错换可控

不是所有搜索都需要 Elasticsearch 的复杂度。Meilisearch 和 Typesense 走的是另一条路:把拼写容错、前缀搜索、即时搜索做成默认能力,用更少的配置换更快的上手。 Meilisearch 的默认行为很「懂用户」:查询词有拼写错误时,它会按编辑距离做容错,词越长允许的错误越多;输入前缀就能匹配,适合搜索框的「边打边搜」;排序规则内置了词频、位置、精确度等维度,还支持字段权重和同义词。它的索引和查询都围绕「低延迟的即时搜索」设计,部署是单个二进制,运维成本低。 Typesense 类似,强调 schema 明确、拼写容错、前缀搜索和地理搜索,另外提供向量检索和 curated results(人工置顶)。它的查询参数更显式,控制感比 Meilisearch 强一些,但同样不追求 Elasticsearch 那种任意 DSL 和复杂聚合。 代价也很明确。这类引擎的查询表达能力有限:复杂的布尔组合、脚本打分、嵌套聚合、跨索引关联,通常做不了或做得别扭;水平扩展能力不如 Elasticsearch 成熟;数据量和分片策略的选择空间小。它们适合中小规模、搜索是「产品体验的一部分但不是分析平台」的场景,比如文档站、电商站内搜索、SaaS 的帮助中心。 选型时可以问三个问题。第一,相关性需要人工精细调权吗?需要,倾向 Elasticsearch。第二,需要复杂的筛选聚合和报表吗?需要,倾向 Elasticsearch 或 PostgreSQL。第三,团队有多少运维预算?很少,且查询以即时搜索为主,Meilisearch 或 Typesense 更合适。

索引同步:双写、Outbox 与全量重建

搜索索引是数据库的派生数据,不是真相来源。真相在数据库,索引只是为了让查询更快。这个定位决定了同步策略的目标:最终一致、可重放、可重建,而不是「写入时必须原子」。 最直接的是双写:业务代码在数据库提交成功后,调用搜索引擎的写入接口。它简单,但有一个无法消除的窗口:数据库提交成功、搜索写入失败,两边就不一致;如果先写搜索再写数据库,搜索里会出现数据库没有的脏数据。加 try/catch 和重试能降低概率,但不能把两个系统变成一个事务。 更稳的是 Outbox。业务事务里同时写一条 outbox 记录,记录文档 ID、操作类型和版本号;事务提交后,由轮询任务或 CDC(变更数据捕获)把 outbox 投递给搜索引擎。因为 outbox 和业务数据在同一个数据库事务里,所以「业务成功但事件丢失」不会发生;投递是至少一次,失败可重试。
CREATE TABLE search_outbox (
    id          BIGSERIAL PRIMARY KEY,
    doc_id      BIGINT NOT NULL,
    op          TEXT NOT NULL,
    version     BIGINT NOT NULL,
    payload     JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    processed_at TIMESTAMPTZ
);
投递端要保证幂等。搜索引擎的写入用外部版本号(version_type=external)或应用层版本比较:只有更大的版本才能覆盖当前文档。这样即使同一事件被投递两次,或者旧事件晚到,也不会把新数据覆盖回旧值。删除也要带版本,否则「删除事件晚于更新事件」会让已删除的文档复活。 版本号从哪里来,是这套机制的关键。可以用数据库的自增序列、行的 updated_at 加版本列,或者 CDC 里的 binlog 位点。无论哪种,必须单调递增且能比较;只靠时间戳在并发写入下不可靠,因为同一毫秒内的顺序不确定。 全量重建是最后的保险。新建一个索引(例如 articles_v2),从数据库或快照全量导入,校验文档数和抽样内容,然后通过别名原子切换:
POST /_aliases
{
  "actions": [
    { "remove": { "index": "articles_v1", "alias": "articles" } },
    { "add":    { "index": "articles_v2", "alias": "articles" } }
  ]
}
切换期间旧索引仍然可读,新索引写好后一次别名操作完成切换,用户无感。重建要能重复执行,导入脚本按主键分页、可断点续跑,不要用一个长时间的事务把数据库拖住。 延迟可见性是必然的。数据库提交到搜索可见,至少经过 outbox 投递、索引写入、refresh 三段。产品上要接受这个窗口:发完文章立刻搜索,可能搜不到。解决办法不是把 refresh 调到毫秒级,而是前端在发布成功后本地插入一条「刚发布」的结果,或者跳转到详情页,避免让用户直面索引延迟。

查询设计与安全:filter/query、零结果、多租户

查询设计的第一条原则是把「硬条件」和「相关性条件」分开。状态、租户、权限、时间范围、是否删除,这些是硬条件,放进 filter 上下文:不参与打分,可以被缓存,也不会因为 boost 被意外抬高。查询词、同义词、模糊匹配,这些是相关性条件,放进 query 上下文。混在一起写,既慢又容易出权限漏洞。 第二条原则是缓存要按「归一化后的查询」做键。同一个搜索,大小写不同、空格不同、参数顺序不同,应该命中同一个缓存。缓存键里必须包含租户、权限范围、分页游标和排序方式;只按查询词缓存,会把 A 租户的结果发给 B 租户。 第三条原则是认真对待零结果。零结果率高,通常不是索引没建好,而是分词不一致、同义词缺失、用户输入里有错别字、或者过滤条件太窄。做法是记录零结果查询词,按频次排序,人工看 Top 20;补同义词、加拼音或拼写容错、放宽时间范围、给出「你是不是想搜……」的提示。不要用一个空页面结束这次交互。 高亮要付出代价。它需要知道词在原文中的位置和偏移,text 字段要保留 positions 或 term vectors;字段很大时,高亮会成为查询里最慢的一段。只对需要展示的字段开高亮,限制片段长度和数量,不要对整个正文做全量高亮。 多租户过滤必须在服务端完成。客户端传来的 tenant_id 只能作为「请求的租户」,服务端要根据登录身份解析出真正允许的租户集合,再拼进 filter。直接把客户端参数塞进查询,等于把租户隔离交给用户。字段级权限同理:敏感字段在 mapping 里单独存放,返回时用 _source 过滤,或者用文档级安全在查询层拦截;不要指望前端「不展示」就是安全。 查询注入是另一个容易忽视的点。query_string 语法支持通配符、正则、字段引用和布尔表达式,用户输入 title:* 或一段超长的嵌套表达式,可能拖垮集群。面向用户的搜索框应该用结构化的 multi_match,把用户输入当纯文本处理,而不是把原始字符串拼进 query_string。必须用 query_string 时,要转义保留字符、限制长度、禁用前导通配符和正则。脚本查询(script_score)如果开放给不可信输入,风险更高,生产里应当关闭动态脚本或使用沙箱。

观测:延迟、零结果率、点击率、索引延迟

搜索的观测要同时看「系统」和「质量」两条线,只看延迟会得到一个「很快但没人用」的假象。 系统侧的核心指标:
  • 查询延迟的 P50、P95、P99,按查询类型和租户分桶;平均值会被大量简单查询稀释。
  • 慢查询日志。Elasticsearch 有 search slowlog,PostgreSQL 有 pg_stat_statements 和 log_min_duration_statement;把超过阈值的查询原文、耗时、命中数记录下来。
  • 错误率与拒绝率。Elasticsearch 的搜索线程池满了会拒绝请求,rejected 指标上升说明容量不够或慢查询过多。
  • 索引延迟。从数据库提交到文档可搜索的时间,拆成 outbox 积压、写入耗时、refresh 三段;任何一段持续上涨都要告警。
  • 段与合并。段数量过多、merge 频繁,会拖慢查询并增加 I/O。
质量侧的核心指标:
  • 零结果率:零结果查询数除以总查询数。按查询词、租户、设备分桶,才能看出是普遍问题还是局部问题。
  • 点击率与转化率:搜索结果里被点击的比例、点击后排在第几位。它不是完美的相关性指标,但比「看起来合理」可靠。
  • 首条点击率、平均点击位置:衡量排序质量的变化。
  • 查询改写率:用户搜了一次没点,换词再搜,说明第一次没满足。
  • 人工评估集:固定一批查询和期望结果,每次调整分词、同义词、boost 都跑一遍,防止「改一个词,坏一片」。
排查顺序也要固定。先看索引是否有数据(文档数、抽样文档),再看索引和查询的分析器是否一致,然后看过滤条件是否把所有结果滤掉,最后才看打分。相关性投诉里,很大一部分根本不是打分问题,而是分词、同义词或过滤条件的问题。

小结

问题 结论
LIKE '%x%' 为什么慢 B+ 树按完整字符串排序,中缀匹配没有连续区间,只能全表扫描
加普通索引为什么没用 索引结构回答不了「任意位置包含」,排序规则不匹配时更不会生效
倒排索引是什么 词项到文档列表的映射,含词典、posting、位置和跳表
中文分词怎么选 词典分词精度高但依赖词典,bigram 召回稳但索引大,混合方案最稳
TF-IDF 的问题 词频无上界、长文占优,容易奖励堆词
BM25 为什么更稳 词频饱和(k1)加长度归一化(b),idf 不会归零
PostgreSQL 全文检索适合谁 已有 PG、百万级数据、查询不复杂、能接受弱于 BM25 的排序
Elasticsearch 强在哪 分析器链、查询 DSL、聚合、横向扩展、近实时
深分页怎么做 search_after 加 PIT,不要 from + size 硬翻
索引怎么同步 Outbox/CDC 至少一次投递,版本号幂等,别名切换做全量重建
多租户怎么隔离 服务端解析租户并放进 filter,永不信任客户端参数
搜索选型经常被简化成「数据大用 ES,数据小用数据库」,这个说法漏掉了真正的分界:你要不要控制分词和相关性。如果只需要「包含这个词」,PostgreSQL 的 tsvector 加 GIN 足够,运维也简单;如果要处理中文新词、同义词、拼写容错、字段调权、聚合面板、横向扩展,那就不是在数据库里加一个索引能解决的事,搜索引擎的复杂度是必须要付的成本。 而无论选哪个,索引都只是数据库的影子。把同步做成可重放、可幂等、可重建,比一开始就追求「实时」更重要。影子偶尔落后不可怕,可怕的是它和真相分叉之后再也没人对账。

相关阅读

专题:后端架构 — Django、并发、限流、灰度与线上部署