UniSearch统一检索层:多源搜索融合架构与实践
UniSearch:我如何用一套统一检索层,把三个“各自为政”的搜索系统揉成一个
接到这个“UniSearch”项目的时候,我一开始是想拒绝的,并不是因为技术有多难,而是我知道这类“统一搜索”的需求,十有八九不是在做一个新搜索引擎,而是在给一堆历史包袱擦屁股。果不其然,业务方那边的情况非常典型:订单数据在MySQL里,文档和日志在Elasticsearch里,商品和库存部分数据散落在几个内部API里,用户只想在一个输入框里搜出所有东西,并且希望结果按相关度混排。UniSearch这个名字听起来很唬人,落地就是一个把各种数据源串起来的“总调度层”,只是这个调度层要处理的坑,远比想象中多。
如果你也在做类似的事,或者正准备碰企业内部搜索聚合这类需求,这篇东西可以帮你少走很多弯路。我讲的是自己实际落地的一套实现,覆盖从整体架构选型、索引策略、查询分发、结果融合排序,到上线后各种疑难杂症的排查实录。所有方案都基于我踩坑后的最终版,不是说它唯一正确,但至少是能扛得住线上流量和业务方花式蹂躏的一种设计。
1. 统一搜索到底在解决什么问题
先说一个最容易混淆的认知:UniSearch不是搜索引擎,它是一层“调度和融合中间件”。搜索引擎关心的是怎么从海量文档里快速捞相关文档,而UniSearch关心的是怎么把已经存在于不同系统里的检索能力,以统一的接口暴露出去。你可以把它理解成公司前台的接待员——你不会直接去找财务、去找法务、去找技术部,而是把诉求告诉前台,由前台判断该找谁,然后把多个部门的答复整理成一份简报交给你。
1.1 三种典型接入场景拆解
第一类场景是结构化数据源的直查。比如MySQL或PostgreSQL里的订单表、用户表,检索条件通常很固定:按订单号、按用户名、按时间范围、按状态。这部分完全不值得自建倒排索引,直接通过关键词解析生成SQL条件即可,难点只在于如何把用户输入的模糊关键词安全且高效地转换成查询条件。
第二类场景是全文检索引擎的代理。Elasticsearch这类系统本身已经做得很好,UniSearch在这里的角色不是替代它,而是把ES的DSL翻译成内部统一的查询模型,避免上游系统直接与ES耦合。这样做的直接好处是:哪天你想从ES换成OpenSearch,或者把一部分索引迁到ZincSearch,上游业务代码一行都不用改。
第三类场景是实时API的在线聚合。商品库存状态、配送进度这类数据,时效性极高,而且经常不在你自己掌控的数据库里,只能通过供应商或兄弟团队提供的HTTP接口实时获取。UniSearch需要在这种场景下做并发请求、超时控制和结果合并。
1.2 为什么必须做统一查询模型
我见过很多团队做统一搜索,最后做成了一个“大杂烩”网关:每个数据源各写一套查询逻辑,对外暴露的参数五花八门,调用方得看文档才能搞清楚什么时候传filters,什么时候传q,什么时候要拼search_type。这种方案上线半年后基本就进入维护地狱。
真正的UniSearch项目,第一步不是写代码,而是定义一套“万能查询中间格式”。这套格式一般包含query(关键词)、filters(结构化过滤条件)、sort(排序规则)、from/size(分页)、highlight(高亮需求)、aggs(聚合需求)这几个核心部分。所有数据源适配器只认这一套格式,对外也只暴露这一个格式。Elasticsearch适配器负责把它翻译成DSL,MySQL适配器负责把它拆解成WHERE条件,API适配器负责把它映射成对方接口的请求参数。这才是“统一”二字的精髓。
2. 架构分层与核心技术选型
确定要做统一查询模型之后,接下来的任务就是搭架子。UniSearch整体分为三层:接入层、编排层、适配层。这三层各管各的事,互不越界。
2.1 接入层:标准REST接口与参数设计
接入层是一层薄薄的HTTP API,核心就是参数校检和统一鉴权。我建议接口体设计成这样:
{ "query": "华为手机", "filters": { "status": "ON_SALE", "category_id": [12, 13, 14] }, "sort": "_score", "from": 0, "size": 20, "highlight": true, "data_sources": ["mysql_products", "es_docs", "api_stock"] }这个设计里有一个容易被人忽略的点:data_sources字段。默认情况下UniSearch会查所有已注册的数据源,但很多调用方其实只关心其中某几个。显式声明之后,编排层能省掉一大半无意义的查询分发。鉴权方面我只推荐两种:内部系统用签名校验,外部系统走OAuth2的client credentials模式。不要用简单的API Key打在URL上,线上日志一刷全是泄露的凭证。
2.2 编排层:查询分发与并行调度
编排层是整个UniSearch的心脏,负责决定“这个查询要怎么拆”。一个查询进来后,先做意图识别——业务简单时可以用基于规则的方式来识别,比如命中某些正则就路由到对应数据源;复杂度上来之后,可以训练一个轻量级分类器来做这件事。我这次没有上分类模型,因为业务场景足够收敛:凡是带status过滤的基本都查订单和商品,凡是带highlight的在ES查询里一定用得上。
分发策略上我最想强调一点:并行永远比串行好,但要有下限保护。所有数据源同时发起请求,等待最慢的那个返回后统一合并。这样响应时间等于最慢数据源的耗时,而不是所有数据源耗时之和。但要给每个数据源的请求设置超时上限,比如统一1000毫秒,超时的数据源直接降级——返回空结果并记录一条warning日志,而不是让整个UniSearch都跟着卡死。
2.3 适配层:五种数据源连接器的实现要点
我这次接的适配器包括MySQL、Elasticsearch、HTTP API、Redis缓存和CSV静态文件,每种都有各自的实现要点:
MySQL连接器要处理的是动态SQL拼接。关键词中的特殊字符必须用参数绑定而不是字符串拼接,防止注入。排序字段要做白名单校验,绝不允许把前端传来的字符串直接拼进ORDER BY。
Elasticsearch连接器要做的是查询语法翻译。用户输入的name:华为 AND price:[1000 TO 3000]这种Lucene语法,需要由适配器解析后在内部转换成结构化Query对象,而不是直接把字符串透传给ES的query_string查询,否则用户一旦传错语法,ES直接返回400。
HTTP API连接器最麻烦的是参数映射和重试。不同系统对参数命名习惯完全不同:有的叫keyword,有的叫search_text,有的叫q。适配器里维护一份字段映射表,通过配置而不是硬编码来管理。
Redis连接器在这里不是做缓存,而是做热搜榜和联想词数据源。如果业务里需要“搜索框下面显示热门搜索词”这种功能,直接从Redis的ZSET里取top N即可。
CSV连接器适合处理那种低频更新的基础数据,比如产品规格表、地区代码表。每当文件更新后把数据加载进内存建立简单的倒排索引,查询时直接在内存里跑。
2.4 数据一致性方案:双写与事件同步
做统一搜索时一个无法回避的问题:如果一个数据源里的数据变了,UniSearch怎么感知?我采用的方案是“业务主库负责写,UniSearch负责读”的事件驱动模式。具体做法是业务系统在数据变更时发送MQ消息,UniSearch消费消息后更新对应的ES索引或清理Redis缓存。这里有一个大坑:如果只依赖MQ,很容易出现消息丢失导致数据不一致。所以我还保留了一个每天凌晨的全量对账任务,比对MySQL和ES里的记录数及关键字段的校验值,发现不一致的自动重新同步。
提示:没有对账机制的统一搜索,本质上就是一个定时会“出现脏数据”的搜索。生产环境上线之前,一定要把全量对账定时任务安排上。
3. 核心流程的工程化实现
理念讲得再多,最后还是要落到代码。我按一个真实的查询请求从进来到返回的完整链路,把每个关键环节的工程实现都拆开讲一遍。
3.1 查询预处理:同义词、纠错、分词策略
原始查询词不能直接拿去查数据源。首先要做的是查询预处理。其中同义词扩展,能让“笔记本”同时命中“笔记本电脑”和“laptop”。实现上可以通过配置一个同义词词典,在查询时做检索词扩展。其次是纠错,这里我用的是编辑距离算法,配合业务词表库,当用户输入“华伪手机”时,能识别出“华为手机”才是正确的查询词。需要注意的是,纠错一定要区分“建议纠正”和“自动纠正”,前者是在搜索结果下方提示“您是不是要找:华为手机”,后者是直接拿修正后的词去查询。我个人的经验是默认用建议式纠正,只有用户明确点击后再执行新查询,自动纠正太容易翻车。
分词策略上有一个重要原则:索引期分词和查询期分词必须保持一致,否则会出现明明数据在那、却搜不出来的情况。比如MySQL数据源针对中文商品名做检索,如果索引时期用的是普通的ngram分词,查询时期的预处理分词也必须支持同种切法。如果一致性无法保证,宁可用简单的contains匹配,也不要上一个花哨的、不可控的分词算法。
3.2 多路结果的归一化与字段对齐
并行查询返回的四五份结果形态各异,需要把他们转成统一的结果对象。这个过程我称之为归一元组。具体的归一化规则如下表:
| 数据源类型 | 原始返回结构 | 归一化后的关键字段 |
|---|---|---|
| MySQL行记录 | 每行是一个dict | id,title,summary,url,score,source |
| Elasticsearch命中 | _source及highlight | id,title,summary,url,score,source |
| HTTP API返回值 | 自定义JSON结构 | id,title,summary,url,score,source |
| Redis热词 | ZSET member和score | id为空,title为热词,score为热度 |
| CSV内存索引 | 内存对象 | id,title,summary,url,score,source |
这里有一个细节值得注意:score的归一化。MySQL和API适配器本来是不产生相关性分数的,我通常会在适配器里根据命中字段的重要程度去手动计算一个基础分值。比如命中标题给3分,命中摘要给1分,命中标签给2分,这个逻辑放在适配层做。
3.3 融合排序策略:加权、归一化与去重
所有数据源的结果汇集到一起之后,面临的难题是这些分数并不可直接比较。ES返回0.8的分数和MySQL里我手算的3分,量纲完全不同。直接按原始分数排序没有任何意义。所以我采用了先归一化、再加权、再融合的三步策略。
归一化可以用min-max归一化,或者使用Wilson下限来平滑处理,避免单条极端分数带偏整体。加权则需要按照业务定义数据源权重。比如订单这个数据源权重是1.0,商品是0.9,文档是0.6,热词联想是0.3,最终综合得分的公式大致是:
final_score = normalized_score(source) * source_weight去重策略上面,我处理的是跨数据源重复的情况。比如商品和文档同时引用了同一篇文章的地址,或订单和商品数据源里回到同一个商品ID。去重逻辑是基于ID加URL的联合唯一键来判断。保留哪条优先级顺序是:API结果 > MySQL > ES > CSV,高优先级的保留,其余的被排除。
3.4 分页与深翻页问题的工程解法
统一搜索还有个细节让很多人头疼:分页。Elasticsearch超过1万条深翻页时会报错,MySQL深度分页也慢得离谱,而API接口压根不支持from加size特别大的查询。所以UniSearch的默认分页上限是100页,每页最多50条。超过这个范围的查询,有两种降级方案可选:一种是返回提示让用户收窄条件;另一种是利用ES的search_after做深翻页游标,同时MySQL侧改用“最后一个可见ID”来做keyset分页。我的建议是优先用方式一,毕竟正经用户不会翻到100页以后。
3.5 结果缓存与性能兜底
在缓存层上,我用了两级缓存策略。L1是进程内缓存,使用TTL为30秒,适合命中热点查询;L2是Redis缓存,TTL为5分钟。查询请求进来先查L1,没命中再查L2,最后才真正分发到底层数据源。但缓存粒度不应该是整个查询结果,而应该是数据源级别的结果片段。因为当四个数据源里只有一个数据变了,如果缓存的是合并后的完整结果,其他三个数据源的数据会被无谓地过期。单独缓存各数据源返回的原始片段,再在编排层做融合,可以最大程度提高缓存命中率。
4. 上线调试与问题排查实录
UniSearch这种糅合了多个系统的中间层,一旦出问题,表象往往是一坨乱麻。这一章把我线上遇到过的典型问题和排查思路整理出来,你可以当做一个速查表来用。
4.1 请求超时,罪魁祸首往往是“串行”
第一次上线压测时,P99延迟高达4.8秒,这显然不可接受。排查发现,问题不在某个数据源性能差,而在于我的初版编排逻辑是按顺序调用各个适配器。每个适配器平均耗时200毫秒,串行调五个就是1秒,加上网络开销和数据库慢查询,延迟自然爆炸。改造方案是把串行调用改成Java虚拟线程或Python asyncio并发调度,整体吞吐从每秒300涨到2000。如果你用的是Java项目,CompletableFuture或者虚拟线程都行;如果是Go,goroutine加channel天然支持;Python的话,我会夸一句asyncio。但我不推荐用裸线程池,因为线程池耗尽太容易造成雪崩。
经验:编排层强制要求所有数据源调用必须并行发起,并且设置统一的超时上限,这个是UniSearch性能的第一命门。
4.2 数据源返回慢,不能拖死整体
压测中还发现,如果其中一个API数据源响应卡顿几秒,所有请求都会被阻塞。后来我把超时控制做成“逐源设置”的配置项:默认统一1000毫秒,但对关键核心数据源可以放宽到1500毫秒,对非关键数据源(比如热词、联想)只给300毫秒。超时的数据源不再等待,直接降级为空结果,在返回体里带上timeout_sources字段告诉前端这次查询哪些源超时了。这样即使某一个下游系统故障,主链路依然是稳定的。
4.3 数据源返回慢,不能拖死整体
调参时发现,ES查询耗时最大的瓶颈来自两次查询:一次普通请求,一次获取total命中数。ES默认会在每次查询时计算精确总数,当索引体量很大的时候,这种计算代价很高。我把ES的track_total_hits设置为false,返回总数改为近似值,查询性能提升35%。对搜索结果而言,100页以内的总数用近似值完全能满足需求,没必要让精确计数拖慢整体响应。
4.4 Redis缓存穿透与更新风暴
上线初期缓存穿透非常严重。热词数据源本身数据量不大,但是用户搜索时经常输入一个冷门词,结果Redis没缓存,直接穿透到后端,而MySQL又拿不到结果,就变成“缓存击穿+空结果”的双重浪费。我的解法除了常规的空值缓存之外,还加了一层布隆过滤器,在业务侧维护一个基于所有已知有效查询词的布隆过滤器。拦截掉明显不存在的查询,MySQL的压力直接下降了一个数量级。
4.5 结果排序不稳定导致投诉
有一次业务方反馈,同一个关键词搜索的结果,过几分钟再搜就发现顺序变了。查下来是融合排序时分段归一化算法的问题:对于数量很少的分片结果,min-max归一化会放大微小差异,同一句话在不同时间点检索,甚至只因ES分片间的tie_breaker不同就让分数发生细微抖动。最后我把归一化的下限做了钳制,设置MIN_SCORE=0.1,保证所有结果的基础分不低于0.1,就不会出现因为微小波动导致的排序漂移。
4.6 日志与追踪:统一检索必须带TraceId
这是我觉得整块项目里最值得做的事情之一。UniSearch一次查询会触达多个数据源,一旦线上有问题,没有日志链路就是在黑暗里摸大象。我强制要求日志系统里集成TraceId,从接入层生成,贯穿编排层、适配层直到底层数据源。每个数据源的耗时、返回条数、异常堆栈都挂在这个TraceId下。后续排查的时候,只需要用TraceId搜索一次完整的调用链就能定位问题。这个习惯我从一开始就建了,中间有一次线上故障能十分钟之内定位,全靠它。
5. 工具、参数与调优细节总结
最后把我在整个把UniSearch项目中实际使用过的工具和一些重要的调优参数整理一下。不是每一家公司的技术栈一样,所以工具选型仅作参考。
5.1 基础设施选型参考
| 组件 | 选用方案 | 理由 |
|---|---|---|
| 接入层API | FastAPI / Spring Boot | 高并发性能好,社区资源丰富 |
| 异步编排 | Python asyncio / Java CompletableFuture | 天然支持并发调用 |
| 全文检索引擎 | Elasticsearch 8.x | 成熟稳定,生态完善 |
| 业务数据库 | MySQL 8.0 | 业务主存储,稳定可靠 |
| 缓存 | Redis 7.x | 支持ZSET热榜及多级缓存 |
| MQ消息 | RabbitMQ | 支撑双写事件同步 |
| 监控 | Prometheus + Grafana | 覆盖接口QPS、P99时延、各适配器状态 |
5.2 一键查询链路耗时监控
每个适配器的查询耗时必须拆开观察。我在Grafana里建了十几个看板,但日常最常用的是三个:总接口请求量及响应时间曲线、各数据源平均耗时对比柱状图、各数据源超时次数统计表。每次发布新版本之后,第一件事不是看功能对不对,而是看适配器耗时有没有异常波动。如果某个源的平均耗时从200毫秒突增到800毫秒,不管业务方怎么说“没问题”,都要主动去查一下对方系统的负载。
5.3 上线前必做的三件准备工作
第一件,梳理清楚哪些数据源是强依赖、哪些是弱依赖。强依赖挂了整个搜索不可用,弱依赖挂了可以容忍。这个梳理结果决定了超时和降级方案怎么设计。
第二件,建立一套全链路的自动化回归用例。针对每个数据源准备标准查询集,预期结果集锁定在固定基线里。每次代码改动,先跑这组用例,避免只修了A数据源却把B数据源的适配逻辑弄坏了。
第三件,准备好对账脚本。我建议做两级对账:数据记录数对账和字段级对账。先比数量,再比关键字段的哈希一致性,这是防数据漂移的兜底。
提示:不要在下班前一小时首次灰度发布统一搜索项目。第一晚大概率会有各种数据源连接和权限的幺蛾子,留足排查时间比什么都重要。
6. 下一阶段演进方向
UniSearch做到这里,已经能稳定支撑日常查询了。但如果业务量再往上走,有几个演进点是我明确计划要做的。第一个是引入真正的个人化排序:基于用户的历史点击、收藏行为训练一个轻量级的learning to rank模型,把融合排序从手工加权升级为模型打分。第二个是增加搜索热力统计:从Redis热词出发,把用户每天的搜索词、点击结果、跳失率汇入数据仓库,形成业务洞察报表。第三个是更强的同义词动态挖掘:现在用的静态词表维护成本高,后续计划基于用户搜索日志里的共现词挖掘同义词,实现词表的自我更新。
我个人在这整件事中最大的体会是:做统一搜索,难点从来不在搜索本身,而在“统一”二字。统一意味着你要面对各种历史系统的怪癖,面对无法控制的字段命名,面对每一个数据源独有的查询语法和限制。与其想着一套代码适配全部,不如把适配层的边界划清楚,把统一查询模型设计得足够通用。只要这两件事做踏实,UniSearch这个名字才真正配得上。最后再分享一个小技巧:上线那天,先在测试环境用真实用户搜索日志回放压测一遍,不要用模拟流量,因为只有真实搜索日志才会暴露出那些你完全想不到的奇葩查询词和冷门字段组合,这一条比任何架构设计都更能帮你省去后续的救火时间。