1. 从秒杀场景切入:为什么搜索与爬虫是架构师的必修课
秒杀系统、搜索引擎、爬虫,这三个词放在一起,很多刚接触后端架构的朋友会觉得跨度有点大。秒杀是典型的高并发写场景,搜索引擎是典型的全文检索读场景,爬虫则是数据采集的入口。但如果你真正在一线做过电商或者内容平台,就会发现这三者在架构层面是打通的——秒杀活动页需要搜索商品,搜索的商品数据来自爬虫采集或商家录入,而爬虫抓回来的海量数据又必须经过索引构建才能被高效检索。
这一课的核心,就是把 Lucene 和 Elasticsearch 这两块内容放进整个架构体系里讲清楚。Lucene 是搜索的底层库,Elasticsearch 是建立在 Lucene 之上的分布式搜索服务。很多同学学 ES 的时候只停留在会写 DSL 查询,但一旦遇到性能问题、数据一致性问题、集群规划问题就抓瞎,根本原因是对 Lucene 的底层机制没有概念。这节课的价值就在于,它把倒排索引、段合并、近实时搜索这些底层原理和 ES 的工程实践串起来了。
适合谁来参考这份总结?如果你正在做电商、内容社区、日志分析平台,或者你正在准备架构师方向的技术面试,这篇文章会帮你把搜索链路的全貌理清楚。如果你只是会 CRUD,那也没关系,我会尽量用生活化的类比把原理讲透,让你知道每一次搜索请求背后到底发生了什么。
2. 秒杀场景下的搜索架构设计思路
2.1 秒杀为什么需要搜索能力
很多人对秒杀的理解就是“抢购”,觉得只要把库存扣减做好、把限流做好就行了。但真实的秒杀活动页往往不是单一商品,而是一个活动会场,里面有几十上百个商品。用户进入会场后需要快速找到自己想要的品类,比如“手机”“家电”“美妆”,这时候就需要搜索或者筛选能力。
更关键的是,秒杀活动的商品数据是动态变化的。运营在活动开始前几分钟还在调整商品池,活动结束后又要快速下线。如果搜索索引更新不及时,用户搜到的商品点进去发现活动已经结束了,这个体验是非常糟糕的。所以秒杀场景对搜索的要求是:索引更新要快、查询延迟要低、数据一致性要能容忍秒级延迟。
这就引出了 Elasticsearch 的近实时特性。ES 默认的 refresh_interval 是 1 秒,也就是说数据写入后最多 1 秒才能被搜索到。对于秒杀会场来说,这个延迟是可以接受的。但如果你把 refresh_interval 设成 -1 来提升写入性能,那就必须手动 refresh,否则运营改了商品池用户根本搜不到。
2.2 搜索与秒杀链路的解耦设计
在实际架构中,我不会让秒杀主链路直接依赖 ES。原因很简单,秒杀下单是核心交易链路,ES 是查询链路,两者的可用性要求不一样。如果 ES 集群抖动导致搜索超时,不能影响用户下单。
常见的做法是:商品数据写入 MySQL 后,通过 binlog 订阅或者消息队列异步同步到 ES。秒杀会场页的搜索请求走 ES,但商品详情页和下单页走缓存加数据库。这样即使 ES 挂了,用户还是可以通过分类导航进入商品详情完成下单,只是搜索功能暂时不可用。
注意:异步同步方案一定要考虑消息丢失和重复消费的问题。我见过有团队用消息队列同步索引,结果消费者重启期间消息积压,导致 ES 里的商品数据和数据库不一致,活动开始后用户搜到已下架商品,客诉一大堆。
2.3 索引分片与秒杀流量的匹配
秒杀流量有一个特点:瞬时高峰极高,但持续时间短。可能 10 秒钟内涌入几十万次搜索请求,然后迅速回落。如果 ES 索引分片数设置不合理,比如分片太少导致单分片压力过大,或者分片太多导致协调节点合并结果的开销过大,都会影响搜索性能。
我的经验是,对于秒杀会场这种商品数量有限(通常几千到几万)但查询 QPS 极高的场景,可以单独建一个小索引,分片数不用太多,3 到 5 个主分片就够了,但副本数可以适当增加,把读流量分散到多个副本上。同时要确保这个索引的 refresh_interval 设置合理,既不能太长导致数据可见性差,也不能太短导致频繁生成新段影响写入性能。
3. Lucene 核心原理拆解:倒排索引到底是怎么工作的
3.1 正排索引与倒排索引的本质区别
要理解 Lucene,首先要理解正排索引和倒排索引的区别。正排索引就像一本书的目录,你知道第几页讲什么内容,但如果你想知道“某个关键词出现在哪些页”,正排索引就帮不上忙了,你只能一页一页翻。
倒排索引则是反过来,它记录的是“每个关键词出现在哪些文档里”。就像书后面的索引页,你查“架构”这个词,它告诉你第 10 页、第 25 页、第 88 页都出现了。Lucene 的核心就是构建和维护这种倒排索引。
在 Lucene 中,倒排索引的基本单位是term(词项),每个 term 对应一个posting list(倒排列表),列表里记录了包含这个 term 的所有文档 ID,以及 term 在文档中出现的频率、位置等信息。搜索的时候,Lucene 根据查询词找到对应的 posting list,然后做交集、并集等操作,最终算出每个文档的相关性得分。
3.2 分词、归一化与词典构建
倒排索引的第一步是分词。英文分词相对简单,按空格和标点切分就行,但中文分词就复杂得多。Lucene 本身不提供中文分词器,通常需要集成第三方分词组件。分词的粒度直接影响搜索效果,比如“极客大学”如果被切成“极客”和“大学”,那用户搜“极客”能搜到,搜“大学”也能搜到,但搜“极客大学”就不一定能精确匹配。
分词之后还要做归一化处理,包括转小写、去掉停用词、词干提取等。比如用户搜“Running”和“run”,经过词干提取后都变成“run”,这样就能互相匹配。归一化的目的是提高召回率,但也要注意不要过度归一化导致精度下降。
词典构建是另一个关键环节。Lucene 需要把所有的 term 组织成一个有序的词典,通常用 FST(有限状态转换器)来实现。FST 的好处是内存占用小、查询速度快,可以快速定位到某个 term 是否存在于词典中,以及它在索引文件中的偏移量。
3.3 段合并机制与近实时搜索的实现
Lucene 的索引不是一次性写成的,而是分成多个segment(段)。每次写入一批数据就会生成一个新的段,段一旦生成就不可变。搜索的时候需要查询所有段然后合并结果,段越多搜索越慢。所以 Lucene 有一个后台的段合并机制,会把小段合并成大段,减少段的数量。
段合并是 Lucene 最耗资源的操作之一,它会占用大量磁盘 IO 和 CPU。如果合并策略设置不当,可能在业务高峰期触发大规模合并,导致搜索延迟飙升。ES 在 Lucene 之上做了一些优化,比如用 TieredMergePolicy 来控制合并频率和合并后的段大小。
近实时搜索的实现也跟段有关。新写入的数据先进入内存缓冲区,然后定期 refresh 成一个新的段,这个段可以被搜索到,但还没有持久化到磁盘。只有经过 flush 操作后,段才真正落盘。所以 ES 的 refresh 和 flush 是两个不同的概念,refresh 决定数据什么时候可见,flush 决定数据什么时候持久化。
提示:很多同学把 ES 的 refresh_interval 设成 30 秒来提升写入性能,但忘了在批量导入数据后手动 refresh,结果测试的时候发现数据查不到,以为是写入失败了。其实数据已经写进去了,只是还没 refresh 而已。
4. Elasticsearch 工程实践:从索引设计到查询优化
4.1 索引映射设计中的关键决策
ES 的索引映射(mapping)相当于数据库的表结构定义,但比数据库灵活得多,也更容易踩坑。最常见的坑就是动态映射导致字段类型不符合预期。比如你写入一个 JSON,某个字段第一次出现是数字,ES 就把它映射成 long 类型,后面如果写入字符串就会报错。
所以在生产环境中,我强烈建议关闭动态映射,手动定义所有字段。对于不需要搜索的字段,设置 index: false 来节省索引空间。对于需要分词的文本字段,用 text 类型;对于需要精确匹配的字段,比如商品 ID、订单号,用 keyword 类型。对于需要同时支持分词和聚合的字段,可以用 multi-field,一个子字段是 text,另一个是 keyword。
还有一个容易忽略的点是norms和doc_values。norms 用于存储字段长度归一化因子,影响相关性评分,如果字段不需要参与评分可以关闭。doc_values 用于排序和聚合,如果字段不需要排序和聚合可以关闭来节省磁盘空间。
4.2 查询 DSL 的性能陷阱与优化技巧
ES 的查询 DSL 非常灵活,但灵活意味着容易写出性能很差的查询。我总结了几条实战经验:
第一,避免深度分页。from + size 的分页方式在深度分页时性能极差,因为协调节点需要从每个分片取 from + size 条数据然后合并排序。如果确实需要深度分页,用 search_after 或者 scroll。
第二,慎用 wildcard 和 regexp 查询。这两种查询无法利用倒排索引,会退化成全扫描,数据量大时直接拖垮集群。如果确实需要前缀匹配,可以用 edge_ngram 分词器在索引阶段预处理。
第三,filter 和 query 要分清。filter 不计算相关性得分,结果可以被缓存,性能比 query 好很多。对于精确匹配的条件,比如状态筛选、时间范围,尽量用 filter。
第四,控制返回字段。默认情况下 ES 会返回 _source 里的所有字段,如果文档很大,网络传输和序列化开销都很可观。用 _source_includes 指定需要的字段,或者用 stored_fields 只返回存储的字段。
| 查询类型 | 适用场景 | 性能表现 | 优化建议 |
|---|---|---|---|
| term | 精确匹配 keyword 字段 | 高 | 配合 filter 使用 |
| match | 全文检索 text 字段 | 中 | 控制分词粒度 |
| wildcard | 模糊匹配 | 低 | 改用 edge_ngram |
| range | 范围查询 | 中高 | 配合 filter 缓存 |
| bool | 组合查询 | 取决于子查询 | 合理设置 must/should/filter |
4.3 集群规划与容量评估的实操方法
ES 集群规划不是拍脑袋决定的,需要根据数据量、查询 QPS、写入 TPS 来估算。我通常用以下步骤来做容量评估:
第一步,估算索引总数据量。比如每天新增 100GB 原始数据,保留 30 天,那就是 3TB。考虑到副本和索引开销,实际磁盘占用大概是原始数据的 2 到 3 倍,也就是 6 到 9TB。
第二步,确定分片数。单个分片的大小建议控制在 30GB 到 50GB 之间,太小会导致分片过多、集群元数据压力大,太大则影响恢复速度和查询性能。按 3TB 主数据算,大概需要 60 到 100 个主分片。
第三步,确定节点数。每个节点能承载的分片数有限,一般建议单节点分片数不超过 600 个。同时要考虑 CPU 和内存,搜索是 CPU 密集型操作,建议至少 8 核起步,内存方面 ES 的堆内存不要超过 32GB,剩下的留给文件系统缓存。
第四步,压测验证。用真实的查询和写入流量做压测,观察 CPU、内存、磁盘 IO、GC 等指标,根据瓶颈调整分片数和节点数。
注意:ES 的堆内存设置有一个经验法则,不要超过物理内存的 50%,且不要超过 32GB。因为 JVM 在堆内存超过 32GB 时会失去指针压缩优化,反而导致性能下降。
5. 爬虫与搜索引擎的协同:数据采集到索引构建的全链路
5.1 爬虫在搜索架构中的定位
爬虫在搜索架构中扮演的是数据采集的角色。对于电商平台来说,爬虫可能用于采集竞品价格、商品评价;对于内容平台来说,爬虫用于抓取外部资讯、文章。采集回来的数据经过清洗、去重、结构化处理后,写入 ES 构建索引,最终对外提供搜索服务。
这个链路看起来简单,但实际做起来坑很多。首先是反爬对抗,很多网站会检测请求频率、User-Agent、IP 等,如果爬虫不做限速和伪装,很快就会被封。其次是数据质量,抓回来的 HTML 需要解析,不同网站的页面结构不一样,解析规则要经常维护。最后是数据更新,有些数据需要定时重新抓取,如何判断哪些数据需要更新、更新频率怎么定,都是需要设计的问题。
5.2 爬虫数据到 ES 索引的管道设计
从爬虫数据到 ES 索引,中间通常需要一个数据管道。我常用的方案是:爬虫抓取数据后先写入消息队列,然后由消费者程序做清洗和结构化,再批量写入 ES。
为什么要经过消息队列?因为爬虫的抓取速度和处理速度往往不匹配。如果爬虫直接写 ES,抓取高峰期可能把 ES 打挂。消息队列可以起到缓冲作用,同时还能实现削峰填谷和失败重试。
批量写入 ES 的时候,要用bulk API,不要一条一条写。bulk 请求的大小要控制好,太大容易超时,太小则网络开销大。一般建议每批 500 到 1000 条文档,或者总大小控制在 5MB 到 15MB 之间。同时要设置合理的并发数,并发太高会导致 ES 写入队列积压,反而降低吞吐量。
5.3 去重、增量更新与索引生命周期管理
爬虫数据最容易出现的问题就是重复。同一个商品可能被多次抓取,如果不去重,ES 里会有大量重复文档,浪费存储空间还影响搜索体验。去重的常用方法是给每个文档生成一个唯一 ID,比如用 URL 的哈希值或者商品 ID,写入 ES 时用这个 ID 作为 _id,这样重复写入会自动覆盖。
增量更新是另一个关键点。全量重新抓取成本太高,通常只抓取有变化的页面。可以通过对比页面的 Last-Modified 时间或者内容哈希值来判断是否需要更新。更新时用 ES 的 update API 做部分字段更新,而不是整个文档替换。
索引生命周期管理(ILM)对于长期运行的搜索服务非常重要。可以配置策略让索引按时间滚动,比如每天或每周生成一个新索引,旧索引自动降冷或者删除。这样既能控制单个索引的大小,又能方便地做数据保留和清理。
6. 常见问题与排查技巧实录
6.1 搜索延迟飙升的排查思路
搜索延迟突然飙升是最常见的问题之一。我的排查顺序通常是:
先看集群健康状态,用 _cluster/health 看是否有节点掉线、分片未分配。如果有 red 或 yellow 状态,优先解决分片分配问题。
再看线程池队列,用 _cat/thread_pool 看 search 和 write 队列是否有积压。如果 search 队列满了,说明查询请求超过了集群处理能力,需要扩容或者优化查询。
然后看慢查询日志,ES 可以配置慢查询阈值,把执行时间超过阈值的查询记录下来。分析慢查询日志往往能发现性能杀手,比如深度分页、wildcard 查询、大聚合等。
最后看段合并情况,用 _cat/segments 看是否有大量小段或者正在进行的合并。如果段合并过于频繁,可以调整合并策略或者降低写入频率。
6.2 数据不一致的典型场景与修复
ES 和数据库数据不一致是另一个高频问题。典型场景包括:消息队列丢消息导致部分数据没同步、消费者处理失败没有重试、并发更新导致覆盖等。
修复数据不一致的常用方法是对账。定期跑一个对账任务,对比数据库和 ES 的文档数量和关键字段,发现差异后触发修复。修复时要注意幂等性,避免重复修复导致数据错乱。
预防数据不一致的关键是保证消息可靠投递。生产端要开启确认机制,消费端要手动 ACK,处理失败时把消息重新入队或者写入死信队列。同时要监控消息积压情况,积压过多时及时告警。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 搜索延迟高 | 慢查询、段合并、队列积压 | 慢查询日志、线程池监控 | 优化查询、调整合并策略 |
| 数据查不到 | refresh 未执行、分片未分配 | 检查 refresh_interval、集群健康 | 手动 refresh、修复分片 |
| 写入失败 | 映射冲突、磁盘满、队列满 | 查看错误日志、磁盘使用率 | 修改映射、扩容磁盘 |
| 集群 red | 节点掉线、分片损坏 | _cluster/health、_cat/shards | 恢复节点、重新分配分片 |
6.3 我踩过的几个真实坑
第一个坑是映射爆炸。有一次做日志分析,日志字段是动态的,结果 ES 自动创建了几千个字段,导致集群元数据膨胀,最后不得不重建索引。从那以后我所有索引都手动定义映射,并且设置 dynamic: strict。
第二个坑是分片过多。刚开始做 ES 的时候觉得分片越多越好,结果一个索引开了 100 个分片,集群元数据压力巨大,每次创建索引都要等很久。后来才知道分片数要根据数据量和节点数合理规划,不是越多越好。
第三个坑是bulk 请求过大。有一次批量导入数据,每批 10000 条,结果频繁超时。后来改成每批 500 条,反而吞吐量更高了。原因是大批量请求会导致单个节点负载过高,小批量多并发反而能充分利用集群资源。
提示:ES 的默认配置是针对通用场景的,生产环境一定要根据实际业务特点做调优。不要迷信默认值,也不要盲目照搬别人的配置,压测才是检验配置好坏的唯一标准。
7. 从架构师视角看搜索系统的演进方向
7.1 搜索与推荐的融合趋势
传统的搜索系统只负责“用户搜什么就返回什么”,但现在的趋势是搜索和推荐融合。比如用户搜“手机”,除了返回匹配的商品,还会根据用户画像推荐可能感兴趣的型号。这要求搜索系统不仅能做文本匹配,还能做向量检索和个性化排序。
ES 从 7.x 版本开始支持 dense_vector 类型,可以存储向量并做近似最近邻搜索。这为搜索和推荐的融合提供了基础设施。实际应用中,可以用 ES 做粗排召回,然后用独立的排序服务做精排,兼顾性能和效果。
7.2 云原生与存算分离的实践
ES 集群的运维成本很高,尤其是大规模集群。云原生和存算分离是降低运维成本的重要方向。存算分离的核心思路是把存储层和计算层解耦,计算节点无状态,可以弹性伸缩;存储层用对象存储或者分布式文件系统,成本更低。
目前一些云厂商提供了 Serverless 形态的搜索服务,用户只需要关注索引和查询,底层运维完全托管。对于中小团队来说,这种方案可以大幅降低运维负担。但对于有特殊需求的大型团队,自建集群仍然有不可替代的优势,比如更灵活的调优空间和更低的长期成本。
7.3 向量检索与语义搜索的落地思考
语义搜索是另一个热门方向。传统的关键词搜索依赖字面匹配,用户搜“苹果手机”和“iPhone”可能得到不同结果。语义搜索通过向量化模型把文本转换成向量,然后计算向量相似度,可以捕捉到语义层面的匹配。
落地语义搜索的难点在于:向量化模型的选择和部署、向量索引的构建和维护、查询性能和精度的平衡。目前比较务实的做法是混合检索,先用关键词检索召回一批候选,再用向量检索做语义重排,这样既能保证召回率,又能提升相关性。
我个人在实际操作中的体会是,搜索系统的建设没有银弹,关键是要理解业务需求,选择合适的技术方案,然后持续迭代优化。Lucene 和 ES 提供了强大的基础能力,但真正决定搜索体验的,是对业务的理解和对细节的把控。每次遇到性能问题或者数据问题,都是一次深入理解底层原理的机会,把这些经验积累下来,才是架构师真正的核心竞争力。