45.3 搜索引擎架构
一句话
搜索引擎的架构由三个数字决定:分片数(决定并行度与单分片大小)、副本数(决定查询吞吐与故障域)、刷新间隔(决定“多快能搜到”)——三者都在拿“资源/延迟/一致性”做交换。
一条写入、一条查询的路径
写入:客户端 → 协调节点 → 按 _id 路由到主分片 → 主分片写内存 buffer + translog
→ refresh(默认 1s)生成新 segment(可被搜索)→ flush 落盘 → 段合并
查询:客户端 → 协调节点 → 广播到每个分片(主或副本)→ 各分片本地打分取 top-K
→ 协调节点归并 → 取回文档 → 返回
| 阶段 | 关键点 | 代价 |
|---|---|---|
| 路由 | hash(_id) % 分片数,写入只进一个主分片 | 分片数固定后难改(改要 reindex) |
| refresh | 内存 buffer → 新 segment,才可被搜索 | 越频繁 → segment 越多 → 合并压力越大 |
| flush | 落盘 + 清 translog | 影响持久化与恢复时间 |
| 查询扇出 | 每个分片都要查,协调节点归并 | 分片越多,扇出与归并开销越大 |
代码:分片数与刷新间隔的账
package main
import "fmt"
func main() {
const (
docs = 100_000_000 // 1 亿文档
docKB = 1.0 // 每文档索引后约 1KB
writePerSec = 5_000 // 写入 QPS
)
fmt.Printf("数据量 %.0f 亿文档 × %.0fKB ≈ %.0f GB\n\n", docs/1e8, docKB, docs*docKB/1024/1024)
fmt.Println("=== 分片数:单分片大小 vs 查询扇出 ===")
fmt.Printf("%-10s %-14s %-12s %-16s %s\n", "分片数", "单分片大小", "查询扇出", "归并开销(相对)", "建议")
for _, shards := range []int{1, 4, 12, 48, 200} {
perShardGB := docs * docKB / 1024 / 1024 / float64(shards)
fanout := shards
mergeCost := float64(fanout) / 12.0 // 以 12 分片为基准
advice := "合理"
switch {
case perShardGB > 50:
advice = "单分片过大:恢复慢、合并重"
case fanout > 100:
advice = "扇出过大:协调节点与网络成瓶颈"
}
fmt.Printf("%-10d %-14s %-12d %-16.1f %s\n", shards,
fmt.Sprintf("%.1f GB", perShardGB), fanout, mergeCost, advice)
}
fmt.Println("\n=== 刷新间隔:可见延迟 vs segment 数量 ===")
fmt.Printf("%-14s %-14s %-16s %s\n", "refresh", "可见延迟", "每天新 segment", "代价")
for _, iv := range []string{"1s", "5s", "30s", "60s"} {
var sec float64
fmt.Sscanf(iv, "%fs", &sec)
segs := 86400 / sec * writePerSec / 1000 // 每次 refresh 每 1000 条一个 segment(示意)
note := "搜索实时性好,合并压力大"
if sec >= 30 {
note = "合并压力小,但写入后要等一会儿才能搜到"
}
fmt.Printf("%-14s %-14s %-16.0f %s\n", iv, iv, segs, note)
}
fmt.Println("\n=== 副本数:查询吞吐与存储成本 ===")
fmt.Printf("%-8s %-14s %-14s %s\n", "副本数", "查询能力", "存储成本", "故障容忍")
for _, r := range []int{0, 1, 2} {
fmt.Printf("%-8d %-14s %-14s %s\n", r,
fmt.Sprintf("%dx", r+1), fmt.Sprintf("%dx", r+1),
fmt.Sprintf("挂 %d 个节点仍可读", r))
}
}
本机实测(macOS,Apple M1,Go 1.27):
数据量 1 亿文档 × 1KB ≈ 95 GB
=== 分片数:单分片大小 vs 查询扇出 ===
分片数 单分片大小 查询扇出 归并开销(相对) 建议
1 95.4 GB 1 0.1 单分片过大:恢复慢、合并重
4 23.8 GB 4 0.3 合理
12 7.9 GB 12 1.0 合理
48 2.0 GB 48 4.0 合理
200 0.5 GB 200 16.7 扇出过大:协调节点与网络成瓶颈
=== 刷新间隔:可见延迟 vs segment 数量 ===
refresh 可见延迟 每天新 segment 代价
1s 1s 432000 搜索实时性好,合并压力大
5s 5s 86400 搜索实时性好,合并压力大
30s 30s 14400 合并压力小,但写入后要等一会儿才能搜到
60s 60s 7200 合并压力小,但写入后要等一会儿才能搜到
=== 副本数:查询吞吐与存储成本 ===
副本数 查询能力 存储成本 故障容忍
0 1x 1x 挂 0 个节点仍可读
1 2x 2x 挂 1 个节点仍可读
2 3x 3x 挂 2 个节点仍可读
读数:
| 维度 | 结论 |
|---|---|
| 分片 1 个 | 单分片 95GB:恢复要几十分钟,段合并拖慢写入 |
| 分片 12 个 | 单分片约 8GB:大小合适,扇出可接受(多数业务的起点) |
| 分片 200 个 | 扇出 200:协调节点归并、网络与堆内存压力陡增,而单分片只有 0.5GB |
| refresh 1s → 60s | 可见延迟从 1s 变 60s,segment 数量降 60 倍 |
🔑 三个经验值:单分片 10–50GB(再大恢复慢、合并重)、分片数 ≈ 数据量/30GB 并向上取整到节点数的倍数、refresh 间隔按“业务能忍多久搜不到”定(日志类可以 30s,商品搜索要 1s)。
段合并:写入放大的来源
| 阶段 | 做什么 | 放大 |
|---|---|---|
| refresh | buffer → 新 segment | 小段很多 |
| merge | 多个小段合并成大段 | 同一份数据被反复读写,写放大 3–10 倍 |
| flush | 落盘、清 translog | 与 merge 叠加,磁盘 IO 峰值高 |
⚠️ 高写入场景下,“refresh 太频繁 + 段合并跟不上”会表现为:写入延迟升高、
iowait高、merge线程占满。调优顺序是“先降 refresh 频率 → 再限写入速率 → 最后才加节点”。
反模式
| 反模式 | 后果 |
|---|---|
| 一开始就建 100+ 分片(“为将来扩展”) | 扇出与元数据开销立刻出现,单分片过小 |
| 分片数按“节点数”而不是“数据量”定 | 数据涨了要 reindex |
| refresh 设 1s 却抱怨写入慢 | 每秒生成新段,合并压力大 |
| 不做副本(副本数 0) | 节点故障即不可用,且查询无法并行 |
| 用搜索库当主库 | 它不是事务型存储,丢数据无法恢复 |
查询不限制返回条数(from+size 很深) | 协调节点归并内存爆炸(深分页问题) |
自检
Q1 为什么“分片数一次定好”很重要?
▸ 答案:因为文档的路由是 hash(_id) % 分片数,分片数一变,几乎所有文档的归属都变,只能 reindex 重建。虽然可以用 alias + 双写切换,但成本高(全量重建 + 双倍存储)。所以建索引时按“3 年数据量 / 单分片 30GB”估算,宁可多分几个(软上限),也别后期频繁改。
Q2 refresh 间隔为什么影响“能不能立刻搜到”,而不影响“数据是否丢”?
▸ 答案:refresh 是把内存 buffer 变成可搜索的 segment,影响的是可见性;持久化靠 translog(每次写都追加),所以 refresh 慢不会丢数据,只会在崩溃恢复时重放更多 translog。要“写完立刻搜到”,可以显式 ?refresh=true,代价是产生大量小段与合并压力。
Q3 查询为什么要扇出到所有分片,能不能只查一个?
▸ 答案:因为文档按 _id 哈希分散在所有分片里,只有查全部(或按路由键定位到特定分片)才能保证不漏。若查询总是带“租户 ID/用户 ID”这类路由键,可以在写入时用 routing 把同一租户的文档放到同一分片,查询只打一个分片——这是多租户搜索最常见的优化。
空间检索(附近的人、按距离排序)是另一类索引问题:GeoHash 与 Redis GEO 的取舍见 45.5 地理空间检索 (GeoHash / 空间索引)。
关联
- 前置:45.1 倒排索引与分词、45.2 相关性排序
- 并列:44.14 分库分表、19.1 分区键设计
- 下游:45.4 产品 (Elasticsearch / Solr / OpenSearch)、50.1 三支柱与成本模型
- 待补:39.2 容量规划(未写,无链接)