《互联网产品的服务端架构·内容、社交与实时互动》11.3 搜索一致性与删除

从作者删帖三天后搜索仍能看到摘要的事故写起,说明删除必须留下墓碑而不能等索引重建。文章用 Lag 与 Stale 判断索引水位是否落后预算,用 Retry 保持失败重试顺序,并给出时间倒退、零预算与墓碑重试的测试、真实运行输出和一致性窗口的产品口径。

作者删了帖子,搜索里还能看到摘要

「回声」的一位作者在周三晚上删除了一篇旧文。详情页立刻返回 404,作者以为事情结束了。周四上午,有读者在搜索“副业”时仍然能看到这篇文的标题和两行摘要,点进去才是 404。作者又删了一次,结果一样。直到周六凌晨索引做了一次全量重建,这条结果才消失,前后接近三天。

排查发现,删除走的是物理删除:业务库直接 DELETE,搜索索引收到一条“按 ID 删除”的指令。指令失败了一次,重试队列又没有持久化,进程重启后队列清空。索引里那条文档就留了下来。因为业务库里已经没有这行记录,后续的增量同步也不会再提到它;只有全量重建才能把它清掉。

另一个入口是缓存。搜索结果接口按关键词缓存 10 分钟,删除事件只清了详情页缓存,没有清搜索缓存。即使索引已经删了,缓存里的旧响应还能继续返回。CDN 上的摘要卡片同理,回源之前会一直显示旧标题。

这类事故的共同点是:删除被当成“一次写操作”,而不是“一条需要传播、需要确认、需要观测的时间线”。只要链路上任何一环没有留下痕迹,删除就可能停在半路,而系统还以为已经完成。

删除不是一次写操作

一次删除要在多个读模型里生效:业务库、搜索索引、缓存、CDN、推荐候选、消息卡片。每一层都有自己的延迟和失败方式。产品说的“删除成功”,通常只代表业务库成功,不代表用户在所有入口都看不到。

方案索引里的形态优点风险
物理删除文档被移除索引干净,不占空间无法判断“没这条”是删除还是从未同步;迟到的旧消息会重建文档
软删除字段文档保留,deleted=true可查询、可对账、可恢复每个查询都必须记得排除,漏一处就泄露
墓碑保留最小字段与更高版本能挡住旧版本回流,可观测需要版本比较和定期清理策略

墓碑是软删除的一种更严格形态:它不追求保留全文,只保留 ID、版本、删除时间和必要的归属信息。它的作用不是“让用户恢复”,而是“让旧消息无法复活文档”。当一条版本 7 的旧消息迟到,而墓碑已经是版本 8,索引会拒绝写入;如果没有墓碑,文档会被重新创建,删除等于没做。

墓碑要回答四个问题:

  • 谁删的?保留操作者 ID,便于审计和申诉。
  • 什么时候删的?保留删除时间,用于计算保留期。
  • 删的是哪个版本?墓碑版本必须大于所有已投递的旧版本。
  • 什么时候可以清理?确认所有增量通道都越过该版本之后。

墓碑还需要清理策略。无限保留会占用索引空间,也会让“已删除关键词”仍然参与某些统计。常见做法是按业务保留 30 到 90 天,确认所有增量通道都已经越过该版本后再物理清理;清理前必须确保没有更旧的变更还在队列里。

一致性窗口要写进产品预期

删除不可能在所有读模型上同时生效。搜索索引通常有秒级到分钟级的延迟,缓存有 TTL,CDN 有回源周期。工程上能做的是把窗口压到可接受范围,并且让它可观测;产品上能做的是把这个窗口写进预期,而不是承诺“立即全网消失”。

一个可落地的口径是:详情页删除立即生效;搜索索引在 5 分钟内生效;搜索接口缓存 TTL 不超过 1 分钟;CDN 摘要卡片通过主动刷新在 10 分钟内更新。超过窗口就告警,而不是等用户投诉。窗口值不是拍脑袋,要按链路实测:从删除事件产生到索引可见,再到缓存失效,逐段打点。

窗口值要写进接口文档和客服话术,而不是只存在于监控面板。作者删除后看到“处理中”,读者在窗口内仍可能搜到摘要,这些都需要提前说明,避免把正常的收敛过程当成故障。

一致性窗口还要区分“看不见”和“搜不到”。删除后最危险的是“还能看到标题和摘要”,其次才是“还能搜到但点进去 404”。前者是泄露,后者是体验问题。过滤层应该在返回前再检查一次删除标记,作为索引延迟的兜底;这个检查会增加一次读取,但比泄露便宜。

对于作者侧,产品可以提供“删除处理中”的状态,而不是立刻显示“已删除”。这不是把工程问题推给用户,而是诚实表达系统状态:详情已下线,搜索和缓存仍在收敛。状态要有明确的最长等待时间,到点必须变成“已删除”或触发人工介入。

失败场景:物理删除后等全量重建

回到开头的事故。时间线大致是这样:

  1. 周三 21:14,作者删除文章,业务库执行 DELETE,详情页开始 404。
  2. 21:14,删除事件发送到消息队列,消费者调用索引删除接口。
  3. 21:15,索引接口超时,消费者把消息放入内存重试队列。
  4. 21:18,发布系统滚动重启,内存队列丢失。
  5. 21:20 到周六 02:00,索引里仍保留标题、摘要和正文。
  6. 周六 02:00,全量重建任务运行,按业务库快照覆盖索引,文档才消失。

三天里,搜索结果持续返回已删除内容。因为业务库没有这行记录,任何“按更新时间增量同步”的任务都不会再处理它;只有全量重建能发现“索引有、库里没有”的差异。团队没有做双向对账,也没有删除事件的持久化重试,所以缺口一直存在。

修复要同时动三处。第一,删除改为写墓碑,保留 ID、版本和删除时间,让迟到的旧消息无法复活。第二,删除事件进入持久化队列,失败可重试、可观测,不依赖进程内存。第三,增加对账任务,定期比较索引与业务库的 ID 集合,发现“索引多出来”的文档并补删。缓存侧则要给搜索接口设置更短的 TTL,并在删除事件里主动失效相关关键词。

对账任务本身也要有幂等性:同一批残留被扫到两次,补删两次不能报错;补删失败要进入同一个持久化重试队列,而不是另起一套内存任务。否则修了一个缺口,又制造了另一个缺口。

用 Lag 与 Stale 观测水位

删除和同步都需要“现在落后多少”的指标。最直观的做法是记录两个水位:业务库已提交到的最大事件时间,索引已消化到的最大事件时间。两者之差就是同步延迟。延迟超过预算就告警,而不是等搜索结果出错。

下面的 Stale 把判断写成纯函数。它拒绝负预算和负时间戳,也拒绝“索引水位超过业务库水位”这种时间倒退;后者通常意味着时钟错乱或消息被伪造,继续算出一个负数只会掩盖问题。

// file: internal/search/sync.go
// tier: L1
// verify: go test ./internal/search/

package search

import "fmt"

// Lag 描述两个水位:业务库已提交到的最大时间,和索引已消化到的最大时间。
// 两者都是“源事件时间”,不是处理时间,因此索引水位不应超过业务库水位。
type Lag struct {
	DBUpdatedAt int64
	IndexedAt   int64
}

// Stale 判断索引是否落后超过预算。预算按秒计,0 表示只容许零延迟。
// 时间倒退(索引水位超过业务库水位)视为异常而不是负延迟,
// 因为那通常意味着时钟错乱或消息被伪造,继续计算只会掩盖问题。
func Stale(l Lag, budgetSeconds int64) (bool, error) {
	if budgetSeconds < 0 {
		return false, fmt.Errorf("search: negative budget %d", budgetSeconds)
	}
	if l.DBUpdatedAt < 0 || l.IndexedAt < 0 {
		return false, fmt.Errorf("search: negative timestamp db=%d indexed=%d", l.DBUpdatedAt, l.IndexedAt)
	}
	if l.IndexedAt > l.DBUpdatedAt {
		return false, fmt.Errorf("search: indexed watermark %d is ahead of database %d", l.IndexedAt, l.DBUpdatedAt)
	}
	return l.DBUpdatedAt-l.IndexedAt > budgetSeconds, nil
}

// Retry 按失败列表的顺序挑出待重试文档,未知 ID 直接忽略。
// 保序是为了让重试日志和第一次失败日志能够按行对上,
// 不为了“顺便去重”而改变调用方看到的顺序。
func Retry(changes []Doc, failedIDs []string) []Doc {
	byID := make(map[string]Doc, len(changes))
	for _, change := range changes {
		byID[change.ID] = change
	}
	out := make([]Doc, 0, len(failedIDs))
	for _, id := range failedIDs {
		if doc, ok := byID[id]; ok {
			out = append(out, doc)
		}
	}
	return out
}

Retry 解决另一个常见问题:一批变更里只有部分失败,重试时必须按失败顺序挑出对应文档。保序让重试日志和第一次失败日志能按行对上;未知 ID 直接忽略,避免上游列表里混入已经清理的文档导致 panic 或空写入。

水位指标要按链路分段,而不是只有一个总数。删除事件从产生到进入队列、从队列到索引、从索引到缓存失效,每一段都可能卡住。只有总分变慢时,排查仍然要靠猜;分段之后,告警能直接指向某一段。

水位数据最好由同步任务自己上报,而不是从业务库反查。反查会把“任务停了”伪装成“没有新数据”,指标看起来一切正常。

测试:时间倒退、零预算与墓碑重试

Stale 的测试用表驱动覆盖六种正常边界和三种错误:刚好追上、预算内、正好等于预算、超出预算、零预算追上、零预算落后;索引超前、负时间戳、负预算都必须报错。零预算是容易写错的分支,DBUpdatedAt == IndexedAt 必须返回 false,而不是因为“差值为 0 不小于 0”就误判。

Retry 的测试验证三件事:按 failedIDs 顺序返回、未知 ID 被忽略、墓碑也参与重试。墓碑不是“已经删除所以不用重试”,恰恰相反,删除指令失败时更需要重试,否则已删除内容会留在索引里。

// file: internal/search/sync_test.go
// tier: L1
// verify: go test ./internal/search/

package search

import "testing"

func TestStaleBudget(t *testing.T) {
	cases := []struct {
		name    string
		lag     Lag
		budget  int64
		want    bool
		wantErr bool
	}{
		{"caught-up", Lag{100, 100}, 30, false, false},
		{"within-budget", Lag{100, 70}, 30, false, false},
		{"exactly-budget", Lag{100, 70}, 30, false, false},
		{"over-budget", Lag{100, 69}, 30, true, false},
		{"zero-budget-caught-up", Lag{100, 100}, 0, false, false},
		{"zero-budget-lag", Lag{101, 100}, 0, true, false},
		{"indexed-ahead", Lag{100, 101}, 30, false, true},
		{"negative-timestamp", Lag{-1, 0}, 30, false, true},
		{"negative-budget", Lag{100, 100}, -1, false, true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := Stale(c.lag, c.budget)
			if (err != nil) != c.wantErr {
				t.Fatalf("Stale(%+v, %d) error = %v, wantErr %v", c.lag, c.budget, err, c.wantErr)
			}
			if err == nil && got != c.want {
				t.Fatalf("Stale(%+v, %d) = %v, want %v", c.lag, c.budget, got, c.want)
			}
		})
	}
}

func TestRetryPreservesFailedOrder(t *testing.T) {
	changes := []Doc{
		{ID: "a", Title: "A"},
		{ID: "b", Title: "B"},
		{ID: "c", Title: "C", Deleted: true},
	}
	got := Retry(changes, []string{"c", "missing", "a"})
	if len(got) != 2 {
		t.Fatalf("Retry() returned %d docs, want 2", len(got))
	}
	if got[0].ID != "c" || got[1].ID != "a" {
		t.Fatalf("Retry() order = %s,%s, want c,a", got[0].ID, got[1].ID)
	}
	if !got[0].Deleted {
		t.Fatal("tombstone should be retried like any other change")
	}
}

TestStaleBudget 里 exactly-budget 和 zero-budget-caught-up 两个用例锁定边界:只有“严格超过预算”才算 stale。indexed-ahead 断言时间倒退返回错误而不是负延迟。TestRetryPreservesFailedOrder 则确认重试顺序与失败列表一致,并且 Deleted=true 的文档不会被跳过。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/search/ -run 'TestStale|TestRetry' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestStaleBudget
=== RUN   TestStaleBudget/caught-up
=== RUN   TestStaleBudget/within-budget
=== RUN   TestStaleBudget/exactly-budget
=== RUN   TestStaleBudget/over-budget
=== RUN   TestStaleBudget/zero-budget-caught-up
=== RUN   TestStaleBudget/zero-budget-lag
=== RUN   TestStaleBudget/indexed-ahead
=== RUN   TestStaleBudget/negative-timestamp
=== RUN   TestStaleBudget/negative-budget
--- PASS: TestStaleBudget (0.00s)
    --- PASS: TestStaleBudget/caught-up (0.00s)
    --- PASS: TestStaleBudget/within-budget (0.00s)
    --- PASS: TestStaleBudget/exactly-budget (0.00s)
    --- PASS: TestStaleBudget/over-budget (0.00s)
    --- PASS: TestStaleBudget/zero-budget-caught-up (0.00s)
    --- PASS: TestStaleBudget/zero-budget-lag (0.00s)
    --- PASS: TestStaleBudget/indexed-ahead (0.00s)
    --- PASS: TestStaleBudget/negative-timestamp (0.00s)
    --- PASS: TestStaleBudget/negative-budget (0.00s)
=== RUN   TestRetryPreservesFailedOrder
--- PASS: TestRetryPreservesFailedOrder (0.00s)
PASS
ok  	webapp/internal/search	0.274s

所有用例通过。这里验证的是水位判断和重试挑选的纯逻辑,不包含真实时钟、队列和索引写入;线上还要用端到端指标确认“删除事件到搜索结果消失”的实际耗时。

端到端指标建议直接用时间戳差,而不是计数器。计数器只能说明“处理了多少条”,不能说明“最老的一条等了多久”;P99 延迟才是用户真正感知到的一致性窗口。

小结

删除是一条时间线,不是一次写操作。业务库删除成功,只代表详情页可以 404;搜索索引、缓存和 CDN 还需要各自的传播与确认。墓碑让迟到的旧消息无法复活文档,持久化重试让删除指令不会因为进程重启而丢失,对账任务负责发现“索引有、库里没有”的残留。

一致性窗口要写进产品预期:哪些入口立即生效,哪些入口在几分钟内收敛,超过窗口如何告警。Stale 和 Retry 只是把水位判断与重试挑选写成可测函数;真正的可靠性来自分段监控、持久化队列和定期对账。

下一章转向推荐:当内容不再由用户主动搜索,而是由系统主动分发时,推荐服务与业务服务的边界应该画在哪里。

阅读导航:上一节:11.2 召回、排序与过滤 · 下一节:12.1 推荐服务与业务服务的边界 。

常见错误与边界

  • 物理删除后只发一次删除消息。消息丢失就永久残留,增量同步也不会再提到这行。
  • 删除重试放在进程内存里。发布、重启或崩溃都会清空队列,删除停在半路。
  • 只清详情页缓存,不清搜索缓存和 CDN。索引已经删了,旧响应还能继续返回。
  • 墓碑保留时间短于最慢的增量通道。旧消息在墓碑清理后到达,会把文档重新创建。
  • 把“已删除”当成“不用重试”。删除指令失败时更需要重试,否则泄露持续时间更长。
  • 水位只做一个总分,不分段。告警响了也不知道卡在队列、索引还是缓存失效。
  • 时间倒退当成负延迟继续计算。时钟错乱或伪造消息会被静默吞掉,指标失去意义。
  • 对账只做“库里有、索引没有”,不做反向检查。删除残留恰恰是“索引有、库里没有”。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查