一篇已经发布的文章,在搜索里消失了
「回声」的内容团队在周五下午发布了一篇长文。作者从后台点下“发布”,详情页立刻可以打开,分享链接也正常。十分钟后,运营在搜索框里输入标题里的关键词,结果列表里没有这篇文章。她以为是搜索延迟,又等了半小时,仍然没有。最后只能让作者先改标题、重新保存一次,文章才出现。
排查时,业务库里的记录完好:状态是已发布,updated_at 是 14:02:11。搜索索引里也能查到同一个 ID,但内容是空的,version 还停在 3。也就是说,发布动作把业务库从版本 3 推到了版本 4,而索引只收到了版本 3 的旧消息。那条版本 4 的同步消息,在服务重启时丢了。
这个故障最迷惑人的地方在于“详情页正常”。团队一开始怀疑搜索集群、分词器、索引 mapping,查了两个小时才发现索引里根本没有新内容。详情页读的是业务库,搜索读的是另一套存储;一边成功不代表另一边成功。把索引当成数据库的副本,就会默认“库里有,索引就该有”,从而漏掉同步链路本身的失败。
这一节讨论的不是怎么选搜索引擎,而是一个更前置的问题:业务库的每次变更,怎样可靠地变成索引里的读模型,并且在重复、乱序、重启之后仍然收敛到同一个结果。
索引是另一种读模型
业务库和搜索索引解决的是不同问题。业务库要对写入负责,索引要对“怎么被找到”负责。两者的字段、更新频率和失败代价都不一样,不能指望一套写入路径同时满足。
| 维度 | 业务库 | 搜索索引 |
|---|---|---|
| 主要职责 | 保存事实、支撑事务 | 提供按关键词、作者、时间的检索 |
| 数据形态 | 规范化表,按实体拆开 | 宽文档,一条记录包含检索所需字段 |
| 一致性要求 | 提交后立即可读 | 允许秒级到分钟级窗口,但必须可观测 |
| 写入方式 | 事务内单点写入 | 异步消费变更,可能重复或乱序 |
| 删除语义 | 行可以物理删除 | 需要墓碑或软删除,防止旧版本复活 |
| 重建成本 | 备份恢复,按库计算 | 全量重建,按文档数与分词成本计算 |
| 失败后果 | 写不进去,用户立刻感知 | 搜不到或搜到旧内容,往往延迟发现 |
把这张表读透,就能理解为什么“业务库提交成功”不能作为“索引已更新”的证据。索引是派生数据,派生数据的正确性依赖同步链路,而不是依赖源库本身。
一个常见的偷懒做法是让搜索请求直接查业务库,用 LIKE 拼关键词。数据量小的时候能跑,内容一多就会拖垮主库,而且分页、相关度和多字段匹配都很难做好。索引存在的价值,是把“读”的压力和“检索”的语义从业务库里拿出来,交给专门的读模型。
双写、binlog 与 outbox
把变更送进索引,常见有三条路,区别在于“谁保证消息不丢”。
第一条是双写:业务代码在同一个请求里先写数据库,再调用索引写入接口。看起来最直接,但两个系统没有共同事务。数据库提交成功、索引调用超时,请求返回失败,用户重试又可能写两次;数据库提交成功、进程在调用前崩溃,索引就永远缺这一条。双写把一致性问题藏进了业务代码,而且每个写入口都要重复一遍。
第二条是订阅数据库变更日志,也就是常说的 binlog 或 CDC。业务代码只写库,变更由独立进程解析日志后投递。它不侵入业务代码,天然覆盖所有写入口;代价是需要处理日志位点、重复消费和 schema 变化,还要接受“索引总比库慢一点”。
第三条是 outbox:在业务事务里同时写一条“待同步”记录,提交后由后台任务读取并投递,成功再标记完成。它把“业务变更”和“待发送消息”放进同一个本地事务,解决了双写的原子性问题;代价是多一张表、多一次扫描,以及投递侧的幂等要求。
三条路没有绝对优劣。小团队、写入口少,outbox 最容易讲清楚;已有成熟 CDC 基础设施,binlog 更省业务代码;双写只适合“丢了也能接受、并且有全量对账兜底”的场景。无论选哪条,索引侧都必须假设消息会重复、会乱序、会迟到。
选择同步路径时,先回答四个问题:
- 写入入口有多少个?入口越多,双写越容易漏。
- 能不能接受秒级延迟?不能接受时,先确认“立即”是否真的必要。
- 有没有可重放的日志或队列表?没有就只能靠全量对账兜底。
- 删除和恢复是否走同一条通道?两条通道最容易出现语义分叉。
失败场景:事务提交了,索引消息丢了
回到开头的事故。那套系统用的是“事务提交后发消息”的写法,伪代码大致是:
- 开启事务,更新文章表,写入版本 4。
- 提交事务。
- 调用消息队列,发送
article.updated事件。 - 消息消费者拉取事件,写入搜索索引。
问题出在第 2 步和第 3 步之间。进程在提交后被发布系统滚动重启,第 3 步没有执行。消息队列里没有这条事件,消费者自然也不会写索引。业务库已经是版本 4,索引还停在版本 3。因为没有任何对账任务比较两边的版本,这个缺口一直存在。
更麻烦的是后续写入。作者修改了一次正文,产生版本 5。版本 5 的事件正常投递,索引更新为版本 5,看起来“恢复了”。但版本 5 的文档是基于版本 4 的内容改的,而索引从未有过版本 4;如果版本 5 只携带增量字段,索引就会缺一段正文。搜索能搜到标题,却搜不到那一段关键词。
这类故障有三个特征:第一,用户侧表现为“搜不到”而不是“报错”,监控很容易漏;第二,补齐动作依赖人工,恢复时间以小时计;第三,同样的缺口可能不止一条,只是还没被发现。
把这条链路画成时间线,缺口非常清楚:
- 14:02:10 业务库事务开始。
- 14:02:11 事务提交,版本从 3 变为 4。
- 14:02:11 进程收到重启信号,发送消息的代码没有执行。
- 14:02:20 新进程启动,内存里没有待发送任务。
- 14:02:30 消费者继续处理更早的事件,索引停在版本 3。
所以同步链路至少要有三样东西:可重放的变更来源、按版本收敛的写入逻辑、以及能发现缺口的对账指标。前两样决定“能不能补回来”,第三样决定“多久能发现”。
用版本号收敛重复与乱序
索引写入必须是幂等的。最直接的幂等手段是给每条文档带一个单调递增的版本号,写入时只接受更大的版本。这样重复消息不会造成重复文档,乱序到达的旧消息也不会覆盖新内容。
删除同样要走版本号。物理删除文档后,如果一条迟到的旧版本消息到达,文档会被重新创建,用户会看到已经删掉的内容。正确做法是保留一条 Deleted=true 的墓碑,并让它参与版本比较;旧版本小于墓碑版本,自然被拒绝。
版本号还有一个隐含要求:它必须由业务库生成,而不是由索引侧生成。索引侧生成版本只能保证“写入顺序”,无法保证“业务顺序”;两个消费者各自编号,仍然会打架。版本可以来自自增版本列、更新时间戳加序列,或者独立的变更序号,关键是同一个文档的版本严格单调。
合并逻辑要守住三条不变量:
- 同一个 ID 的版本只增不减。
- 墓碑与普通文档走同一条比较路径。
- 合并结果与消息到达顺序无关。
下面的 Apply 把合并规则写成纯函数:复制当前视图,逐条比较版本,返回新的 map。它不修改入参,因为调用方可能还在用旧视图响应请求;它也不依赖消息顺序,只依赖版本单调。
// file: internal/search/index.go
// tier: L1
// verify: go test ./internal/search/
package search
import (
"fmt"
"strings"
)
// Doc 是搜索索引里的一条最小读模型。它不追求和业务库表一一对应,
// 只保留检索真正要用的字段:搜得到什么、作者是谁、何时发布、是否已删除。
type Doc struct {
ID string
Title string
Body string
AuthorID string
At int64
Version int64
Deleted bool
}
// Apply 把一批变更合并进当前索引视图,返回新的 map。
// 入参 current 不会被修改:调用方可能仍在用它响应旧请求。
//
// 合并规则只有一条:同一个 ID 只接受更大的 Version。
// 这样即使 binlog 重复投递、乱序到达,最终结果仍然收敛。
// Deleted 为 true 的变更也必须保留,形成“墓碑”;
// 否则一条迟到的旧版本会把已经删除的文档重新写回来。
func Apply(current map[string]Doc, changes []Doc) (map[string]Doc, error) {
next := make(map[string]Doc, len(current)+len(changes))
for id, doc := range current {
next[id] = doc
}
for i, change := range changes {
if strings.TrimSpace(change.ID) == "" {
return nil, fmt.Errorf("search: change[%d] has empty id", i)
}
old, ok := next[change.ID]
if ok && change.Version <= old.Version {
continue
}
next[change.ID] = change
}
return next, nil
}
这段代码有两个刻意的选择。第一,空 ID 直接报错而不是跳过,因为空 ID 通常意味着上游序列化出了问题,静默跳过会让缺口更难发现。第二,相同版本不覆盖,保证同一版本重复投递时结果稳定;如果业务需要“同版本修正”,应该由上游递增版本,而不是让索引侧猜哪条更新。
Apply 只解决单批合并。工程上还要配合全量重建:定期从业务库导出快照,按同样的版本规则写入,覆盖历史缺口。增量负责实时性,全量负责兜底,两者用同一套合并逻辑,才不会出现“重建后又被旧消息改回去”的怪异现象。
测试:墓碑不能被旧版本复活
合并逻辑的测试要覆盖四类情况:新版本覆盖旧版本、旧版本被忽略、删除留下墓碑、入参不被修改。尤其是最后一条,很多实现直接在入参 map 上改,单测不写断言就很难发现。
测试里还专门构造了“版本 5 先到、版本 4 后到”的顺序,以及“墓碑版本 8、迟到写入版本 7”的场景。这两个用例对应线上最常见的两类事故:乱序投递和删除后旧消息回流。
// file: internal/search/index_test.go
// tier: L1
// verify: go test ./internal/search/
package search
import "testing"
func TestApplyKeepsNewestVersion(t *testing.T) {
current := map[string]Doc{
"post-1": {ID: "post-1", Title: "旧标题", Version: 2},
}
changes := []Doc{
{ID: "post-1", Title: "中间版本", Version: 3},
{ID: "post-1", Title: "最新标题", Version: 5},
{ID: "post-1", Title: "迟到旧版本", Version: 4},
{ID: "post-2", Title: "新文章", Version: 1},
{ID: "post-3", Version: 7, Deleted: true},
}
got, err := Apply(current, changes)
if err != nil {
t.Fatalf("Apply() error = %v, want nil", err)
}
if got["post-1"].Title != "最新标题" || got["post-1"].Version != 5 {
t.Fatalf("post-1 = %+v, want newest version 5", got["post-1"])
}
if got["post-2"].Title != "新文章" {
t.Fatalf("post-2 = %+v, want inserted", got["post-2"])
}
if !got["post-3"].Deleted {
t.Fatalf("post-3 = %+v, want tombstone", got["post-3"])
}
if current["post-1"].Title != "旧标题" || current["post-1"].Version != 2 {
t.Fatalf("input map was mutated: %+v", current["post-1"])
}
}
func TestApplyTombstoneBlocksLateWrite(t *testing.T) {
current := map[string]Doc{
"post-9": {ID: "post-9", Title: "已删除", Version: 8, Deleted: true},
}
got, err := Apply(current, []Doc{
{ID: "post-9", Title: "迟到的旧版本", Version: 7},
})
if err != nil {
t.Fatalf("Apply() error = %v, want nil", err)
}
if !got["post-9"].Deleted || got["post-9"].Version != 8 {
t.Fatalf("post-9 = %+v, want version 8 tombstone", got["post-9"])
}
}
func TestApplyRejectsEmptyID(t *testing.T) {
got, err := Apply(nil, []Doc{{ID: " ", Title: "无名"}})
if err == nil {
t.Fatalf("Apply() = %+v, want error", got)
}
if got != nil {
t.Fatalf("Apply() map = %+v, want nil on error", got)
}
}
TestApplyKeepsNewestVersion 同时验证了覆盖、插入、墓碑和入参不变。TestApplyTombstoneBlocksLateWrite 锁定删除语义:墓碑版本更高时,迟到的普通写入必须被拒绝。TestApplyRejectsEmptyID 则确认错误路径返回 nil map,避免调用方在出错后继续使用半成品结果。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/search/ -run 'TestApply' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestApplyKeepsNewestVersion
--- PASS: TestApplyKeepsNewestVersion (0.00s)
=== RUN TestApplyTombstoneBlocksLateWrite
--- PASS: TestApplyTombstoneBlocksLateWrite (0.00s)
=== RUN TestApplyRejectsEmptyID
--- PASS: TestApplyRejectsEmptyID (0.00s)
PASS
ok webapp/internal/search 0.242s
三个用例都通过,说明版本比较、墓碑和错误路径符合预期。这里没有连接真实数据库或搜索引擎,验证的是合并规则本身;线上还要补消息位点监控和全量对账。
小结
索引不是业务库的副本,而是为检索场景单独维护的读模型。同步路径可以是双写、binlog 或 outbox,但无论选哪条,索引侧都要假设消息会重复、乱序、丢失。版本号加墓碑,是让重复与乱序收敛的最小机制;全量重建和对账,是发现并补齐缺口的兜底。
Apply 只做了合并,没有解决“消息为什么丢”。下一节把视角移到查询侧:一条已经进入索引的文档,怎样经过召回、排序与过滤,最终出现在用户面前,又怎样保证不该出现的文档不会提前泄露。
阅读导航:上一节:目录与学习路径 · 下一节:11.2 召回、排序与过滤 。
常见错误与边界
- 把索引当数据库副本,认为“库里有,索引就该有”。详情页正常不代表搜索正常,两条读路径要分别监控。
- 在事务提交后直接发消息,没有 outbox 或 CDC 兜底。提交与发送之间崩溃,就会留下永久缺口。
- 写入不带版本号,靠“最后写入获胜”。网络重试和乱序会让旧内容覆盖新内容。
- 删除时物理移除文档。迟到的旧消息会把文档重新创建,用户看到已删除内容。
- 相同版本重复写入时随机覆盖。字段不完整的重复消息可能把完整文档改坏,同版本应保持稳定。
- 只做增量同步,从不全量重建。历史缺口不会被发现,重建时又可能被旧消息改回去。
- 入参 map 直接被修改。并发读取旧视图的请求会看到半更新状态,排查时极难复现。
- 没有水位与对账指标。同步停了几天也可能没人知道,直到用户投诉“搜不到”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。