作者删了帖子,搜索里还能看到摘要
「回声」的一位作者在周三晚上删除了一篇旧文。详情页立刻返回 404,作者以为事情结束了。周四上午,有读者在搜索“副业”时仍然能看到这篇文的标题和两行摘要,点进去才是 404。作者又删了一次,结果一样。直到周六凌晨索引做了一次全量重建,这条结果才消失,前后接近三天。
排查发现,删除走的是物理删除:业务库直接 DELETE,搜索索引收到一条“按 ID 删除”的指令。指令失败了一次,重试队列又没有持久化,进程重启后队列清空。索引里那条文档就留了下来。因为业务库里已经没有这行记录,后续的增量同步也不会再提到它;只有全量重建才能把它清掉。
另一个入口是缓存。搜索结果接口按关键词缓存 10 分钟,删除事件只清了详情页缓存,没有清搜索缓存。即使索引已经删了,缓存里的旧响应还能继续返回。CDN 上的摘要卡片同理,回源之前会一直显示旧标题。
这类事故的共同点是:删除被当成“一次写操作”,而不是“一条需要传播、需要确认、需要观测的时间线”。只要链路上任何一环没有留下痕迹,删除就可能停在半路,而系统还以为已经完成。
删除不是一次写操作
一次删除要在多个读模型里生效:业务库、搜索索引、缓存、CDN、推荐候选、消息卡片。每一层都有自己的延迟和失败方式。产品说的“删除成功”,通常只代表业务库成功,不代表用户在所有入口都看不到。
| 方案 | 索引里的形态 | 优点 | 风险 |
|---|---|---|---|
| 物理删除 | 文档被移除 | 索引干净,不占空间 | 无法判断“没这条”是删除还是从未同步;迟到的旧消息会重建文档 |
| 软删除字段 | 文档保留,deleted=true | 可查询、可对账、可恢复 | 每个查询都必须记得排除,漏一处就泄露 |
| 墓碑 | 保留最小字段与更高版本 | 能挡住旧版本回流,可观测 | 需要版本比较和定期清理策略 |
墓碑是软删除的一种更严格形态:它不追求保留全文,只保留 ID、版本、删除时间和必要的归属信息。它的作用不是“让用户恢复”,而是“让旧消息无法复活文档”。当一条版本 7 的旧消息迟到,而墓碑已经是版本 8,索引会拒绝写入;如果没有墓碑,文档会被重新创建,删除等于没做。
墓碑要回答四个问题:
- 谁删的?保留操作者 ID,便于审计和申诉。
- 什么时候删的?保留删除时间,用于计算保留期。
- 删的是哪个版本?墓碑版本必须大于所有已投递的旧版本。
- 什么时候可以清理?确认所有增量通道都越过该版本之后。
墓碑还需要清理策略。无限保留会占用索引空间,也会让“已删除关键词”仍然参与某些统计。常见做法是按业务保留 30 到 90 天,确认所有增量通道都已经越过该版本后再物理清理;清理前必须确保没有更旧的变更还在队列里。
一致性窗口要写进产品预期
删除不可能在所有读模型上同时生效。搜索索引通常有秒级到分钟级的延迟,缓存有 TTL,CDN 有回源周期。工程上能做的是把窗口压到可接受范围,并且让它可观测;产品上能做的是把这个窗口写进预期,而不是承诺“立即全网消失”。
一个可落地的口径是:详情页删除立即生效;搜索索引在 5 分钟内生效;搜索接口缓存 TTL 不超过 1 分钟;CDN 摘要卡片通过主动刷新在 10 分钟内更新。超过窗口就告警,而不是等用户投诉。窗口值不是拍脑袋,要按链路实测:从删除事件产生到索引可见,再到缓存失效,逐段打点。
窗口值要写进接口文档和客服话术,而不是只存在于监控面板。作者删除后看到“处理中”,读者在窗口内仍可能搜到摘要,这些都需要提前说明,避免把正常的收敛过程当成故障。
一致性窗口还要区分“看不见”和“搜不到”。删除后最危险的是“还能看到标题和摘要”,其次才是“还能搜到但点进去 404”。前者是泄露,后者是体验问题。过滤层应该在返回前再检查一次删除标记,作为索引延迟的兜底;这个检查会增加一次读取,但比泄露便宜。
对于作者侧,产品可以提供“删除处理中”的状态,而不是立刻显示“已删除”。这不是把工程问题推给用户,而是诚实表达系统状态:详情已下线,搜索和缓存仍在收敛。状态要有明确的最长等待时间,到点必须变成“已删除”或触发人工介入。
失败场景:物理删除后等全量重建
回到开头的事故。时间线大致是这样:
- 周三 21:14,作者删除文章,业务库执行
DELETE,详情页开始 404。 - 21:14,删除事件发送到消息队列,消费者调用索引删除接口。
- 21:15,索引接口超时,消费者把消息放入内存重试队列。
- 21:18,发布系统滚动重启,内存队列丢失。
- 21:20 到周六 02:00,索引里仍保留标题、摘要和正文。
- 周六 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。索引已经删了,旧响应还能继续返回。
- 墓碑保留时间短于最慢的增量通道。旧消息在墓碑清理后到达,会把文档重新创建。
- 把“已删除”当成“不用重试”。删除指令失败时更需要重试,否则泄露持续时间更长。
- 水位只做一个总分,不分段。告警响了也不知道卡在队列、索引还是缓存失效。
- 时间倒退当成负延迟继续计算。时钟错乱或伪造消息会被静默吞掉,指标失去意义。
- 对账只做“库里有、索引没有”,不做反向检查。删除残留恰恰是“索引有、库里没有”。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。