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 / 空间索引)。

关联