「搜索服务架构:从索引到容错」

讲解搜索服务架构:搜索服务分层设计、索引更新策略与读写分离、CDC 同步与数据管道、多索引路由、缓存策略与容错降级,构建可扩展的搜索系统。

单个 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 与相关性打分》。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「elasticsearch」更多文章

  1. 「安全加固与访问控制:从角色到审计」
  2. 「地理空间搜索:从坐标到地图」
  3. 「高级文本检索:同义词、补全与纠错」