搜索结果里有一个点进去 403 的标题
「回声」上线了付费专栏。一位普通用户搜索“增长”,结果列表里出现了一条标题为“增长实验复盘”的内容,摘要也能看到一半。他点进去,页面返回 403,提示需要订阅。用户把截图发到客服群:既然不让看,为什么搜索里能看见?
排查发现,搜索请求先向索引要了 200 条候选,按相关度排序后取前 20 条,最后才在服务端做权限过滤。那篇付费文章相关度很高,稳定排在前 5,过滤后自然被删掉,但它的标题和摘要已经在前端渲染过一瞬。另一个更严重的入口是缓存:搜索结果按关键词缓存了 5 分钟,没有把用户权限纳入缓存键,A 用户看到的标题被 B 用户复用。
还有一个版本把过滤放在了分页之后。第一页取 20 条,过滤掉 12 条无权限内容,只剩 8 条;前端仍然按“每页 20 条”渲染,用户看到大片空白。运营以为是搜索坏了,其实是过滤位置错了:先截断再过滤,等于让无权限内容占掉了本该属于可见内容的名额。
这三个现象指向同一个问题:召回、排序、过滤被混成了一段代码。三个阶段的职责不同,失败方式也不同;位置一错,不是“结果少一点”,而是泄露、空洞或者翻页错乱。
召回、排序、过滤是三件事
把搜索链路拆开看,每个阶段只回答一个问题。
| 阶段 | 回答的问题 | 典型手段 | 放错位置的后果 |
|---|---|---|---|
| 召回 | 哪些文档“可能相关” | 倒排索引、向量检索、关键词匹配 | 漏掉本该出现的内容,用户搜不到 |
| 排序 | 这些文档里哪些“更该靠前” | 相关度、时效、互动、业务权重 | 好内容被埋,流量集中在少数旧文 |
| 过滤 | 哪些文档“根本不该出现” | 权限、删除、地区、年龄、黑名单 | 泄露标题或摘要,点进去 403 |
召回负责“不漏”,排序负责“靠前”,过滤负责“不该出现”。三者的目标函数不一样:召回追求覆盖率,排序追求点击或满意度,过滤追求零泄露。把过滤塞进排序之后,等于先让不该出现的文档参与竞争,再事后删除;名额已经被占用,结果自然又少又怪。
用一句话区分三个阶段的失败:
- 召回错了,用户搜不到,表现为“没有结果”。
- 排序错了,用户搜得到但不想点,表现为“结果很差”。
- 过滤错了,用户看到了不该看的,表现为“泄露”或“点进去 403”。
一个实用的判断方法是看条件能否在索引里表达。作者、时间、删除标记、可见范围这类结构化条件,应该在召回阶段就作为过滤条件传下去;关键词相关度、互动量这类排序信号,才留给排序阶段。权限判断如果依赖实时关系(比如“是否关注了作者”),至少要放在返回结果之前,并且对标题、摘要、高亮片段一视同仁。
过滤必须发生在召回之前或之内
搜索结果的“可见性”不只是一条文档,它包括标题、摘要、作者名、封面和高亮片段。任何一处提前返回,都是一次泄露。只过滤正文、不过滤标题,等于告诉用户“这里有一篇你不能看的文章,标题是……”。
从工程上说,过滤条件分两类。第一类是索引字段能直接判断的:deleted、author_id、published_at、visibility。这类条件应该在查询里下推,让搜索引擎先缩小候选集,既安全又省算力。第二类是需要实时查询外部系统的:用户与作者的关系、当前订阅状态、地区限制。这类条件无法完全下推,但必须在结果返回前统一执行,并且不能只作用于正文。
按条件来源再分一层,位置会更清楚:
| 条件 | 例子 | 能否下推 | 执行位置 |
|---|---|---|---|
| 删除标记 | deleted=true | 能 | 召回查询 |
| 可见范围 | 公开、仅粉丝、付费 | 能 | 召回查询 |
| 作者 | 指定作者主页内搜索 | 能 | 召回查询 |
| 时间范围 | 最近七天 | 能 | 召回查询 |
| 订阅关系 | 是否订阅了作者 | 通常不能 | 返回前过滤 |
| 拉黑关系 | 是否被作者拉黑 | 不能 | 返回前过滤 |
| 地区与年龄 | 内容分级 | 部分能 | 召回加返回前兜底 |
能下推的条件不要留在应用层,否则每次都要把大量候选拉回来再删;不能下推的条件也不要假装能下推,老老实实扩大候选集再过滤。
还有一个容易被忽略的点:过滤要发生在分页之前。假设每页 20 条,召回 200 条,过滤后剩 120 条,正确的分页是基于 120 条算的。如果先按 200 条分页再过滤,第 2 页可能只剩 3 条,用户会以为内容不够。产品侧看到的是“翻页体验差”,根因却在链路顺序。
反过来,排序也不能放到过滤之前做截断。先取 Top 20 再过滤,会让无权限内容占掉高相关名额;正确顺序是“召回时就带上可见性条件,得到候选集,再排序,再分页”。如果外部条件无法下推,就扩大候选集、过滤后再截断,用算力换正确性。
判断顺序是否正确的三个检查:
- 随机抽一条被过滤的文档,看它是否曾经出现在任何响应、日志或缓存里。
- 把每页条数改成 1,连续翻十页,确认没有空白页和重复。
- 用两个权限不同的账号请求同一个关键词,确认缓存键不同。
失败场景:先排序取 Top 20,再过滤权限
那套系统的查询大致是:按关键词召回 200 条,按相关度排序,取前 20 条,再逐条查权限,删掉不可见的。代码看起来“也过滤了”,但顺序错了。
具体后果有三层。第一层是名额浪费:前 20 条里有 12 条付费内容,过滤后只剩 8 条,用户看到的结果数远少于预期。第二层是摘要泄露:前端拿到的响应里曾经包含被过滤前的标题和摘要,即使服务端最终删掉了,日志、埋点和缓存仍可能留下副本。第三层是缓存污染:结果按关键词缓存,没有带用户维度,低权限用户命中高权限用户的缓存,直接看到完整标题。
更隐蔽的是“翻页空洞”。第一页过滤后剩 8 条,系统仍然认为已经消费了 20 条,第二页从第 21 条开始取。中间那 12 条不可见内容对应的名额被永久跳过,用户永远看不到本该排在那里的可见内容。
修复分三步:把 deleted、visibility、author_id 这类索引字段下推到召回查询;把订阅关系这类外部条件放进结果返回前的统一过滤层;分页在过滤之后计算。三步都做完,才谈得上“不漏、不泄露、不空洞”。
三步里最容易漏的是第二步。外部条件如果只在详情页校验,搜索仍然会泄露标题;如果只在搜索校验,详情页又可能被直接访问。正确做法是把“可见性判断”抽成一个共用函数,搜索、详情、推荐都调用它,而不是各写一份。
用 Match 把可测条件收敛
查询条件里最容易被写错的是“多关键词”和“大小写”。用户输入 Go 和 go,应该命中同一批文档;输入两个词,应该要求两个词都出现,而不是任意一个出现。把这些规则写成一个纯函数,才能用表驱动测试锁住。
下面的 Match 只负责“这条文档该不该出现”,不负责打分。它先处理删除、作者、时间三类硬条件,再做关键词匹配;顺序本身也是语义的一部分:不该出现的文档不应该进入后续排序。
// file: internal/search/query.go
// tier: L1
// verify: go test ./internal/search/
package search
import "strings"
// Query 是搜索请求里与“这条文档该不该出现”有关的最小集合。
// 它不负责排序,只回答一个布尔问题:文档是否命中。
type Query struct {
Terms []string
AuthorID string
Since int64
IncludeDeleted bool
}
// Match 判断文档是否命中查询条件。
// 顺序很重要:先做过期与权限过滤,再做关键词匹配,
// 避免把不该出现的文档带进后续排序阶段。
func Match(d Doc, q Query) bool {
if !q.IncludeDeleted && d.Deleted {
return false
}
if q.AuthorID != "" && d.AuthorID != q.AuthorID {
return false
}
if q.Since != 0 && d.At < q.Since {
return false
}
title := strings.ToLower(d.Title)
body := strings.ToLower(d.Body)
for _, term := range q.Terms {
t := strings.ToLower(strings.TrimSpace(term))
if t == "" {
continue
}
if !strings.Contains(title, t) && !strings.Contains(body, t) {
return false
}
}
return true
}
几个细节值得说明。空 term 被忽略,而不是当成“匹配空字符串”放行所有文档;大小写统一用 strings.ToLower,对中文没有影响,对英文和混合标题是必要的;Since 采用包含边界,At == Since 算命中,避免同一秒发布的内容被漏掉;IncludeDeleted 是显式开关,默认关闭,防止调用方“顺手”把墓碑带出来。
这个函数不处理权限关系。真实系统里,AuthorID 只是硬条件之一,订阅状态、拉黑关系、地区限制需要更完整的过滤层。但把能收敛的条件先收敛,能让测试聚焦在确定性逻辑上,剩下的动态条件再用集成测试覆盖。
Match 的边界也值得单独说。AuthorID 为空表示“不限作者”,而不是“作者为空”;如果业务需要搜索匿名内容,应该用单独的字段表达,不能复用空字符串。Since 为 0 表示“不限时间”,负数则没有定义,调用方应在入口拒绝。IncludeDeleted 只给后台审核和作者本人使用,普通搜索请求不应该暴露这个开关。
测试:大小写、多词、时间与删除
测试分三组:关键词组合、作者与时间过滤、删除语义。每组都用表驱动,失败时能直接看到是哪个条件没有生效。
关键词组覆盖“跨标题与正文命中”“大小写不敏感”“缺一个词就不命中”“空 term 被忽略”。作者与时间组覆盖精确匹配、非作者不命中、Since 的包含边界。删除组确认默认隐藏,只有显式打开 IncludeDeleted 才能看到墓碑。
// file: internal/search/query_test.go
// tier: L1
// verify: go test ./internal/search/
package search
import "testing"
func TestMatchRequiresEveryTerm(t *testing.T) {
doc := Doc{ID: "p1", Title: "Go 并发", Body: "Search Index Sync"}
cases := []struct {
name string
terms []string
want bool
}{
{"all-terms-across-fields", []string{"go", "sync"}, true},
{"case-insensitive", []string{"GO", "INDEX"}, true},
{"missing-term", []string{"go", "redis"}, false},
{"empty-terms-ignored", []string{"", " "}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Match(doc, Query{Terms: c.terms}); got != c.want {
t.Fatalf("Match(%v) = %v, want %v", c.terms, got, c.want)
}
})
}
}
func TestMatchAppliesAuthorAndSince(t *testing.T) {
doc := Doc{ID: "p1", AuthorID: "alice", At: 100}
if !Match(doc, Query{AuthorID: "alice"}) {
t.Fatal("same author should match")
}
if Match(doc, Query{AuthorID: "bob"}) {
t.Fatal("different author must not match")
}
if !Match(doc, Query{Since: 100}) {
t.Fatal("Since is inclusive")
}
if Match(doc, Query{Since: 101}) {
t.Fatal("older document must be filtered")
}
}
func TestMatchExcludesDeletedByDefault(t *testing.T) {
doc := Doc{ID: "p1", Title: "摘要", Deleted: true}
if Match(doc, Query{Terms: []string{"摘要"}}) {
t.Fatal("deleted document must be hidden by default")
}
if !Match(doc, Query{Terms: []string{"摘要"}, IncludeDeleted: true}) {
t.Fatal("IncludeDeleted should surface the tombstone")
}
}
TestMatchRequiresEveryTerm 锁定“所有词都要出现”,如果实现误写成“任意一个词出现”,missing-term 用例会失败。TestMatchAppliesAuthorAndSince 覆盖作者精确匹配与时间边界,Since: 100 必须命中 At: 100。TestMatchExcludesDeletedByDefault 则确认默认行为是安全的:删除的内容不会因为调用方忘了传参数而泄露。
除了表驱动用例,线上还需要一组“顺序”检查:
- 过滤前后候选数是否一致,差额是否能被权限规则解释。
- 第一页与第二页的可见文档是否连续,没有因过滤产生空洞。
- 同一关键词在两个账号下的缓存结果是否隔离。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/search/ -run 'TestMatch' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestMatchRequiresEveryTerm
=== RUN TestMatchRequiresEveryTerm/all-terms-across-fields
=== RUN TestMatchRequiresEveryTerm/case-insensitive
=== RUN TestMatchRequiresEveryTerm/missing-term
=== RUN TestMatchRequiresEveryTerm/empty-terms-ignored
--- PASS: TestMatchRequiresEveryTerm (0.00s)
--- PASS: TestMatchRequiresEveryTerm/all-terms-across-fields (0.00s)
--- PASS: TestMatchRequiresEveryTerm/case-insensitive (0.00s)
--- PASS: TestMatchRequiresEveryTerm/missing-term (0.00s)
--- PASS: TestMatchRequiresEveryTerm/empty-terms-ignored (0.00s)
=== RUN TestMatchAppliesAuthorAndSince
--- PASS: TestMatchAppliesAuthorAndSince (0.00s)
=== RUN TestMatchExcludesDeletedByDefault
--- PASS: TestMatchExcludesDeletedByDefault (0.00s)
PASS
ok webapp/internal/search 0.259s
所有用例通过。这里验证的是单条文档的布尔判断,不包含索引查询、权限服务调用和分页;线上链路还要用集成测试覆盖“过滤后再分页”的顺序。
真实链路里,Match 通常会被编译成查询条件的一部分,而不是逐条在应用层调用。把它写成纯函数的价值在于给查询构造器提供一份可执行的语义说明:下推条件、缓存键和权限兜底,都应该和这份语义保持一致。
小结
召回负责不漏,排序负责靠前,过滤负责不该出现。过滤条件如果能下推到索引,就应在召回阶段执行;依赖实时关系的条件,必须在结果返回前统一执行,并且覆盖标题、摘要和高亮片段。分页要在过滤之后计算,否则会出现名额浪费和翻页空洞。
Match 把删除、作者、时间和多关键词命中收敛成一个可测函数,但它只是过滤层的一部分。下一节讨论删除之后的事:墓碑怎样进入索引,搜索一致性窗口怎样写进产品预期,以及为什么“删了但还能搜到”往往不是缓存问题。
阅读导航:上一节:11.1 索引与业务库的同步 · 下一节:11.3 搜索一致性与删除 。
常见错误与边界
- 先排序取 Top N,再过滤权限。无权限内容占掉名额,过滤后结果又少又空洞。
- 只过滤正文,不过滤标题、摘要和封面。任何一处提前返回都是泄露。
- 搜索结果按关键词缓存,缓存键不含用户或权限维度。低权限用户会命中高权限结果。
- 先分页再过滤。页码基于过滤前的候选集,用户会看到空白页或重复内容。
- 把空关键词当成“匹配所有”。空 term 应忽略,而不是让查询退化成全量扫描。
- 时间过滤用开区间,漏掉边界那一秒发布的内容。
Since应采用包含语义并写进测试。 - 默认把删除文档带回结果,指望前端隐藏。前端不是权限边界,墓碑必须在服务端过滤。
- 把过滤逻辑散落在多个 handler 里。条件一旦分叉,总有一个入口忘记加。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。