单个 Elasticsearch 集群能回答查询,但把它接到业务系统、跟上数据变更、扛住高峰流量,需要一套搜索服务架构。本文从搜索服务分层讲起,覆盖索引更新策略、CDC 数据同步、多索引路由、缓存与容错,帮助你构建可扩展、可演进的搜索系统。
1. 搜索服务分层
一句话总结: 搜索服务拆成接入层、业务层与存储层,各层独立扩展、职责单一。
1.1 三层结构
接入层:API Gateway / 网关,限流、鉴权、协议转换
业务层:搜索服务,拼 DSL、解析结果、业务排序
存储层:Elasticsearch 集群,索引分片、副本
接入层负责流量入口的统一鉴权与限流;业务层把业务查询翻译成 Query DSL、把 ES 响应加工成前端模型;存储层只负责存储与检索。三层分离后,业务层可以无感切换 ES 版本或集群,接入层可以独立扩容扛峰值。
1.2 搜索服务的职责
def search(query, page, filters):
dsl = build_query_dsl(query, filters) # 拼 DSL
resp = es.search(index=resolve_index(query), body=dsl)
return build_result_model(resp) # 加工结果
搜索服务内部至少包含三件事:build_query_dsl 按业务规则组装查询(关键词、类目、价格区间);resolve_index 决定查哪个索引或别名;build_result_model 把 ES 响应映射成前端需要的字段与排序。DSL 组装与结果加工是搜索服务最容易积累逻辑的地方,应独立成模块便于测试。
1.3 分层带来的扩展性
接入层无状态可水平扩容,扛住大促峰值;业务层按业务域拆服务(商品搜索、订单搜索、日志搜索);存储层按数据域拆索引与集群。任何一层出问题都不影响其他层,这是搜索系统可扩展性的来源。
2. 索引更新策略与读写分离
一句话总结: 写入与查询分离,用别名切换与重建实现无间断更新,避免读写互相拖累。
2.1 别名与重建
{
"actions": [
{ "remove": { "index": "products-v2", "alias": "products" } },
{ "add": { "index": "products-v3", "alias": "products" } }
]
}
全量重建索引时,先离线构建 v3,构建完成用 _aliases 原子切换别名,应用侧无感知。旧索引 v2 保留一段时间用于回滚,确认稳定后再删除。别名切换是索引更新最安全的手段,比直接删索引重建稳得多。
2.2 增量更新
{
"update": {
"_id": "p1001",
"doc": { "stock": 5, "updated_at": "2026-10-01T10:00:00+08:00" }
}
}
价格、库存等高频字段用 update 增量更新,只改变化的字段;整体重灌时用 index 全量覆盖。bulk API 批量合并更新,减少请求数与 refresh 次数。写入走独立写入客户端,与查询 QPS 隔离,避免写放大拖慢读。
2.3 读写分离的取舍
ES 本身是近实时(refresh 间隔内新数据不可见),业务上要接受秒级延迟。对强一致需求(下单后立刻搜到)可调短 refresh_interval 或用实时双写;对搜索场景(允许秒级延迟)用默认 refresh。读写分离不是指主从集群,而是指写入频率与查询负载在时间与资源上的隔离。
3. CDC 同步与数据管道
一句话总结: CDC 把数据库变更实时搬运到 ES,用日志追加方式实现准实时同步。
3.1 CDC 思路
MySQL binlog → Canal/Debezium → Kafka → Logstash → Elasticsearch
数据库是主数据源,ES 是检索副本。CDC(Change Data Capture)监听 MySQL binlog,把增删改事件按主键同步到 ES。相比定时全量导入,CDC 同步延迟低(秒级)、不重导全量、天然记录变更顺序。
3.2 幂等写入
def sync_event(event):
if event["op"] == "delete":
es.delete(index="products", id=event["id"])
else:
es.index(index="products", id=event["id"], document=event["after"])
同步逻辑必须以主键 id 做幂等:同一事件重复投递时结果一致。delete 事件删除 ES 文档,update 事件按主键覆盖最新快照。binlog 是追加流,配合 Kafka 分区键保证同一主键事件有序,避免乱序覆盖。
3.3 全量与增量配合
首次全量同步 + 后续增量同步是标准流程:全量用 scroll 或 SQL 分批拉取建索引,增量走 CDC 实时跟上。全量与增量并存时用「时间水位」衔接(全量导到某时刻,增量从该时刻续),避免窗口期数据丢失。数据管道要监控消费延迟与积压,延迟超过阈值告警。
3.4 同步管道的可观测
{
"metric": "sync_lag_ms",
"topic": "cdc.products",
"partition": 3,
"current_offset": 1048576,
"latest_offset": 1048640,
"lag": 64
}
同步管道要暴露三个指标:消费 lag(当前 offset 与最新 offset 的距离)、同步失败数、ES 写入拒绝数。lag 持续增长说明消费能力不足,先加消费者并发,再排查单条同步是否慢查询拖后腿。同步失败事件进死信队列(DLQ),人工核查后重放,避免静默丢数据。
4. 多索引路由与统一入口
一句话总结: 用别名、路由与多索引搜索把异构数据统一到一个入口,隔离写入与查询负载。
4.1 多索引搜索
{
"query": { "match": { "title": "手机" } },
"size": 20
}
curl 'localhost:9200/products-mobile,products-pc,products-app/_search'
多索引搜索把多个索引合并查询,适合不同数据源(移动端/PC/App)独立建索引但统一检索的场景。跨索引查询要保证字段口径一致,否则同一字段在不同索引类型不同会报错或结果异常。
4.2 routing 路由
{
"index": "orders",
"routing": "user_10086",
"query": {
"bool": {
"filter": [
{ "term": { "user_id": 10086 } }
]
}
}
}
写入与查询都指定 routing,同一用户的数据落同一分片,查询只扫一个分片而非全部。路由能让「我的订单」类查询稳定、快速,但滥用 routing 会造成分片倾斜。按业务键路由时确认该键能均匀分布。
4.3 数据分区策略
| 场景 | 分区方式 | 说明 |
|---|---|---|
| 多租户 | 按租户 routing | 隔离查询与写入 |
| 大日志 | 按天索引 | 配合 ILM 滚动 |
| 多语言 | 多索引/多字段 | 统一入口检索 |
| 冷热分离 | 按热度索引 | 热索引内存,冷索引磁盘 |
数据分区策略决定查询路径:按天索引便于删旧、按租户路由便于隔离、按热度分层控制成本。分区粒度太细会索引碎片化,太粗查询面过大,按业务读写模式平衡。
5. 缓存策略
一句话总结: 缓存放在 ES 与应用两层,Filter Cache 与查询结果缓存各管一段,降低重复计算。
5.1 ES 内缓存
{
"query": {
"bool": {
"filter": [
{ "term": { "category": "electronics" } },
{ "range": { "price": { "lte": 1000 } } }
]
}
}
}
ES 的 Filter Cache 缓存 filter 子句的命中位图,重复 filter 复用位图几乎零成本;Query Cache 缓存整个查询的响应分片级结果(基于段)。让纯过滤条件走 filter、排序字段用 Doc Values,是让缓存生效的前提。缓存按 LRU 淘汰,靠命中率指标观察是否有效。
5.2 应用层缓存
key = f"search:{normalize(query)}:{page}:{hash(filters)}"
resp = cache.get(key)
if resp is None:
resp = es.search(...)
cache.set(key, resp, ttl=60)
应用层把热门查询的结果缓存到 Redis,命中时直接返回,ES 只处理未命中流量。缓存键要归一化(去空白、统一大小写),带个性化排序的查询不宜缓存。缓存击穿用热点键预热,缓存雪崩用随机 TTL 错峰。
5.3 缓存一致性
缓存与索引更新天然有时差:索引刚更新,缓存还是旧结果。业务可接受秒级不一致时直接靠 TTL 过期;要求高一致时更新索引后主动失效相关缓存键。搜索结果缓存只适合「非个性化、排序稳定」的查询,个性化与实时性强的查询直接穿透。
6. 容错与降级
一句话总结: 搜索系统要在 ES 抖动或故障时优雅降级,而不是整站雪崩。
6.1 超时与重试
resp = es.search(index="products", body=dsl, request_timeout=1.5)
搜索服务对 ES 请求设超时,超过阈值返回降级结果,不让单次慢查询拖垮线程池。重试只对读幂等,用指数退避 + 少量重试(如 2 次),避免重试风暴。ES 节点故障时客户端会切换可用节点,配合重试实现故障转移。
6.2 降级策略
| 故障 | 降级动作 |
|---|---|
| ES 超时/熔断 | 返回本地缓存快照 |
| 部分分片不可用 | 降级部分结果 + 提示 |
| 写入失败 | 写本地队列补偿 |
| 集群只读 | 读缓存,写进 MQ |
降级的核心是「保住主链路」:搜索主链路挂了,从本地兜底数据或缓存出结果,同时告警;写入链路挂了,先落 MQ 补偿再重放。熔断器按错误率打开后直接走兜底,保护 ES 不被压垮。
6.3 兜底数据源
def search_with_fallback(query):
try:
return es.search(...)
except SearchTimeoutError:
log.warning("es timeout, fallback to cache")
return cache.get(cache_key(query)) or local_index(query)
兜底可以是 Redis 缓存快照、本地小型索引或数据库 LIKE 查询。兜底结果质量下降但服务不中断,比「搜索白屏」好得多。兜底命中率与覆盖率要在监控里,兜底调用频繁说明主链路健康度差,需要排查。
7. 可观测与容量
一句话总结: 搜索架构要可观测,用指标、日志与追踪持续衡量健康度与容量水位。
7.1 核心指标
{
"cluster": "search-cluster",
"health": "green",
"search_qps": 12000,
"search_p99_ms": 45,
"indexing_qps": 800,
"heap_usage_pct": 61,
"disk_usage_pct": 58
}
搜索服务要观测四类指标:QPS 与延迟(P50/P95/P99)、错误率(超时/熔断/降级)、ES 集群健康(堆、磁盘、拒绝数)、同步延迟(CDC 积压)。延迟 P99 与降级率是搜索体验的最直接信号,进入告警体系。
7.2 容量规划
评估维度:数据量、文档数、QPS、写入速率、单查询成本
容量决策:分片数、副本数、节点规格、内存与磁盘
分片数按「单分片数据量 ≤ 30GB、单分片 QPS 可控」估算;副本数决定读吞吐(一主一备约提升读一倍)。堆内存遵循 32GB 法则、留足 Page Cache。容量规划是持续动作,数据增长与 QPS 增长都要周期性复核。
7.3 发布与演练
索引别名切换、字段变更、DSL 修改都要可回滚:先切到影子索引对比新旧结果,再灰度流量,最后全量。故障演练(节点宕机、磁盘满、证书过期)定期做,验证降级链路真实可用。可观测不是看板摆设,而是每次变更后回答「有没有变差」的依据。
8. 总结
一句话总结: 搜索架构以分层隔离、CDC 同步、多索引路由、缓存与降级兜底,构建可扩展、可演进、可容错的搜索系统。
| 环节 | 要点 |
|---|---|
| 分层 | 接入层、业务层、存储层独立扩展 |
| 索引更新 | 别名切换重建,update 增量,读写隔离 |
| 数据同步 | CDC 追加流,主键幂等,全量+增量衔接 |
| 多索引 | 别名统一入口,routing 均匀路由 |
| 缓存 | Filter Cache 位图 + 应用层结果缓存 |
| 容错 | 超时重试、熔断降级、本地兜底 |
| 可观测 | QPS/延迟/错误率/同步积压四类指标 |
| 容量 | 分片副本按数据量与 QPS 评估 |
搜索架构的核心是隔离与兜底:读写隔离、缓存隔离、故障降级。分层让各层独立扩展,CDC 让数据实时跟上,缓存与降级让系统在高峰与故障中存活。ES 集群运维参考《部署运维与备份恢复》与《集群分片与高可用架构》,查询语法参考《Query DSL 与相关性打分》。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。