系统设计 20:社交内容搜索的增量索引与新鲜度
一条动态先写下 blue bird,后来改成 green bird,最后被删除。搜索端如果只追加“新增词项”,blue 和 green 可能同时搜到同一条;即使正确删除了词项,迟到的旧编辑事件还能把已删内容重新放回结果。这是 Twitter Search 类型题目里比“用什么倒排索引”更先要回答的问题:一次修改怎样替换旧词集,重放为什么不能覆盖新版本,以及用户究竟在等哪个“可搜索”时刻。
本文设计公共短文本搜索,不声称是 Twitter 的真实架构;第 19 篇信息流候选排序与权限撤销处理旧候选的读时安全门禁,下一篇输入联想则查询词条前缀而不是全文。分片与排名是教学设计;实验的延迟是本地 SQLite FTS5 查询观测,不代表分布式搜索引擎性能。
“发布成功”和“能搜到”分开定义
允许发帖、编辑、删除、按单词查询公开内容;搜索结果不得混入另一个租户或私密帖子。帖子权威数据与索引分离,发帖成功表示权威状态及可重放变更记录已持久接受,不等于已经刷新可搜索索引。编辑在索引里必须替换旧版本的词项,删除必须同时撤掉词项并保留能挡住旧事件的墓碑。对任一 post_id,索引应用的版本只能单调增加;重复版本幂等跳过,低版本迟到不能复活已删除的文档。这里是设计目标,不是对实际数据库约束或消息系统顺序的实验结论。
教学 SLO:GET /search 从入口至响应结束,滚动 24 小时 p99 < 180 ms;新建或编辑成功后 99% 在 5 s 内可由新查询观察到新状态,分母是成功提交的事件而不是只统计已处理事件。删除索引也希望 5 s 内收敛,但删除提交后的新查询不得展示已删除文本需要额外权威门禁:索引可在这几秒中滞后,查询服务应在结果返回前批量校验当前删除与权限状态,门禁不可用则拒绝返回未检结果。第 19 篇论述了这一强承诺的范围;本篇的倒排模型没有实现这条门禁,所以只验增量索引与已索引状态,不能借模型宣称即时删除已达标。个性化召回、语义向量搜索、中文分词、广告、跨地域写入和付费搜索服务不在此例;词项以空格切分的 ASCII 小样本不代表真实语言处理。
规模先约束索引与查询
教学假设每天新建 200 万帖、编辑 20 万次、删除 5 万次,则权威变更 225 万次/天 ÷ 86400 = 26.04 event/s,峰值系数 10 是约 260.42 event/s;只按新建估算峰值为 231.48 post/s。每条正文与基本元信息按 200 B、保留 30 天计,原文逻辑 200 万 post/天 × 200 B/post × 30 天 = 12 GB,三副本 36 GB,图片、日志、索引另计。
若平均一帖 16 个唯一词,每个词项引用按 16 B,粗估倒排增量 200 万 × 16 × 16 B = 512 MB/天;30 天、三份约 46.08 GB。这是忽略词典、压缩、段合并、编辑导致的旧项清理与墓碑的下限算例,不能当作索引磁盘报价。每天 3000 万次查询,平均 347.22 query/s,峰值系数 6 是 2083.33 query/s;每次最多十项、每项平均传输 200 B,峰值响应正文约 4.17 MB/s(十进制 MB),尚未含跨分片请求及安全检查。均匀分成八个分片时变更峰值平均约 32.55 event/s/shard,热门词查询却可能命中全部八片并在协调端合并;平均负载不能代表热词 p99。
假设词项从 16 涨到 48、其他条件不变,引用粗估就从 46.08 GB 增到 138.24 GB(三副本、30 天),写入词项也大致乘三;如果编辑频率升十倍,编辑工作和旧词项回收的估计会先改变,不能只乘原文存储量。提高 refresh 频率可能换来短新鲜度,却带来更多索引维护成本;真实引擎必须再测分段、合并和尾延迟。关于刷新与可见性的具体选项,可对照 Elasticsearch 官方 refresh 参数文档:refresh=wait_for 等待刷新后才返回该写请求,但它既不是本文运行过的配置,也不保证搜索端忽略版本倒退或通过业务权限校验。
数据契约决定哪些旧事件可以丢弃
POST /posts 返回 post_id, version, accepted_at;PATCH /posts/{id} 传新全文及期望版本,成功时递增该帖版本;DELETE /posts/{id} 写墓碑并递增版本。GET /search?q=blue&cursor=...&limit=10 返回 post_id, indexed_version, snippet, next_cursor,查询词与分页参数都有输入长度上限。这里只描述候选契约,真正公开内容还要在返回正文前经权威权限过滤;不要用客户端给出的版本作为写入顺序的可信来源。
权威帖子可抽象为 post(post_id, author_id, text, visibility, deleted, version, committed_at);变更记录 change(post_id, version, operation, full_text_or_tombstone, committed_at, event_id)。让权威写与出站记录处于同一提交边界可避免成功返回却没有待索引事件;怎样做到原子性要依赖实际存储组件验证,实验仅验证索引版本状态与 FTS5 文档在同一 SQLite 事务更新,不含上游权威库与消息发布事务。索引侧为 document(post_id, version, token_set, created_at, deleted),倒排 term -> {post_id}。编辑事件携带完整词集而不是“只增加了哪个词”,使收到更高版本时能从旧词项删除该 ID、插入新词项;若只收到差量,则跳版本时必须补快照,不能直接套用本文规则。索引应用以文档 ID 路由到同一个分片并比较已应用版本,删除保留版本 4 的墓碑。何时回收墓碑,要满足旧事件重放窗口和可从权威快照恢复版本下界,否则老版本在回收后仍可能复活。
flowchart LR
W[创建 编辑 删除] --> D[(权威帖子与出站变更)]
D --> Q[可重放变更流]
Q --> X[按post_id分片的索引消费者]
X -->|校验版本 替换旧词项 留墓碑| I[(倒排索引与文档版本)]
U[检索请求] --> C[查询协调器]
C -->|检索每片 归并排序| I
C -->|实际服务应批量校验权限| D
C --> U
可以先用单库文本搜索支持小规模:例如 PostgreSQL 官方全文检索索引说明指出 GIN 是倒排形式的文本索引;它并不表示本篇已测过 PostgreSQL。只有当查询负载、写索引开销或独立新鲜度预算要求分离时,才上异步索引服务。专用服务中按帖 ID 分片保证同一 ID 版本检查落到同一权威索引位置;查询按词广播到相关分片取各片 Top-K,再全局排序,不能把各分片局部第一页简单拼接成全局第一页。
候选服务可按帖子最初创建时间降序、同时间按 ID 升序排序;本地实验只查询单个 ASCII 词,不实现业务排名;生产若加入词频、时效或作者权重,评分与分片间的比较必须有一致的字段和并列规则。游标可携带查询条件、排序值、索引代际和分片续读位点;在刷新之间翻页若没有稳定 PIT / 快照,编辑删除仍会使跨页结果漂移,不能保证既不重也不漏。搜索分片按正文词做路由会令同一帖的编辑落到多片;按 post_id 分片的写简单,但每个词查询会有广播开销。这是按写入一致性换取查询扇出的选择。
乱序、删除和停滞画在一条线上
固定事件序列为 p v1(red apple)、p v3(blue berry)、迟到 p v2(red orange)、p v4 删除、重投 p v3,以及 q v1(blue sky)。每个事件以本地单调时钟记录进入处理的时间,再等待至少 10 ms 模拟排队。v3 替换 v1 的词项,v2 被版本检查拒绝;v4 删除后旧 v3 不得使 p 复活。版本来自帖子自身,不使用到达时间判断内容新旧。
以下时间图是候选异步系统的逻辑时间示意,不是实验采样值。
sequenceDiagram
participant P as 权威帖子
participant Q as 变更流
participant I as 索引分片
participant S as 搜索读者
P->>Q: p1 v1 创建 blue
Q->>I: v1 到达 建立 blue
P->>Q: p1 v2 编辑 green
Q->>I: v2 到达 替换词项
Q->>I: v1 迟到重投
I-->>Q: 低版本丢弃
P->>Q: p1 v3 删除
Q->>I: v3 到达 移除词项并留墓碑
Q->>I: v2 迟到重投
I-->>Q: 低版本丢弃
S->>I: 查询 green
I-->>S: 无 p1
Note over P,I: 280至350ms 索引仍可能返回p1 需另设删除门禁
恢复时先从可信快照加载文档版本和墓碑,再从明确的事件水位重放;消费游标和索引更新的提交关系需要检查,不能先确认事件再失败于索引写入,除非还能补回。监控提交至搜索可见的分位数、按分片的最老未处理事件年龄、连续失败版本与墓碑回收水位;队列长度为零不代表已经刷新对外可见。若某个分片停滞,不能用新版本检查“跳过”事实缺口来假装达到 SLO;读端按实际可见版本告警,积压超时则降级搜索新鲜度承诺或暂时限制对应查询。删除的读时门禁独立保持,不跟随索引回退。
什么时候不值得独立建索引
| 选择 | 收益 | 代价和退回条件 |
|---|---|---|
| 单库文本检索、同步读权威状态 | 数据量小、读写复用现有事务;少一条异步数据链 | 大量热门词查询挤占写路径时才拆分;数据库实际并发能力须先实测 |
| 按 ID 分片的异步倒排,应用版本门禁 | 可独立扩容搜索;重投和乱序有确定处理规则 | 刷新滞后、重放及墓碑运维;流量下降或维护成本过高时退回单库 |
| 每次修改重建全部索引 | 最容易解释代际切换和一致词集 | 高频编辑时放大重建成本;只适合小词库或低频批量更新 |
实际 FTS5 查询与本地等待时间
运行 python3 -B examples/system-design/labs/20/search.py,每处理一个事件就实际查询 FTS5 的 blue 词项。v3 后能查到 p,v4 后 p 消失,最终只剩 q;版本 2 和删除后的版本 3 均被拒绝。publish_to_query_ms 从事件进入本地处理开始,包含人为 10 ms 等待、SQL 更新和查询开销,逐事件数值见 examples/system-design/evidence/20/run.json,并非轮询得到的线上首次可见时间。--unsafe 忽略版本后复活 p,退出 2。--stalled 让 p 的版本 1 之后全部不应用,权威索引状态仍是 (1,未删除),red 查询仍命中 p;编辑可见和删除应用均未完成,延迟为 null,退出 3。所有调用及原始输出见 examples/system-design/evidence/20/RUN.md。
该实验验证单连接 SQLite FTS5 的替换、删除、版本墓碑和真实查询;每轮延迟受本机调度影响,不能推出 5 秒 SLO、Elasticsearch refresh 或跨分片性能。它不含中文分词、权限过滤与进程崩溃后的恢复。面试追问写成功是否立刻能搜到,应先区分权威提交和索引可见;重放旧编辑是否会复活删除内容,取决于版本墓碑和回收边界;索引滞后时若要求立即拒绝已删内容,还需第 19 篇的权威读取门禁。
参考资料
- PostgreSQL 全文检索索引类型:倒排索引形式与词项检索;本文未运行该数据库。
- Elasticsearch refresh 参数:写操作与对搜索可见的区分;本文未运行该引擎。
examples/system-design/labs/20/search.py、examples/system-design/evidence/20/RUN.md:固定事件序列的 SQLite FTS5 实验与原始输出。





