从一道阿里ES面试题说起:如何手撕一个微型搜索引擎?
你有没有遇到过这样的面试场景?
面试官轻描淡写地抛出一句:“如果让你从零实现一个类似 Elasticsearch 的搜索系统,你会怎么做?”
然后盯着你,等你开口。
这不是理论考八股,也不是背诵API文档。这是典型的阿里系 es 面试题风格——以实战为导向,层层递进,考察的是你对分布式搜索系统的整体架构理解 + 底层机制掌握 + 动手推演能力。
很多人卡在这里,不是因为不会用 ES,而是只停留在“会用”的层面,从未深入到“为什么这么设计”。
今天,我们就来干一件“狠事”:不讲配置、不贴命令行,直接动手,从零构建一个具备核心功能的类ES微内核系统。通过这个过程,彻底打通倒排索引、分片路由、段合并这些在 es 面试题中反复出现的关键概念。
倒排索引:搜索引擎的“字典”
所有问题的起点都是同一个——怎么快速找到包含某个词的文档?
传统数据库是按文档ID查内容(正向索引),而搜索引擎反其道而行之:按词语找文档。这就是倒排索引(Inverted Index)。
它到底长什么样?
想象你在做语文阅读理解,老师让你找出所有提到“春天”的段落。最笨的办法是一篇篇翻;聪明的办法是你先建个表:
| 词语 | 出现的段落 |
|---|---|
| 春天 | 1, 3, 5 |
| 花朵 | 2, 3 |
| 雨水 | 4, 5 |
这个表就是倒排索引。它由两部分组成:
-Term Dictionary:所有的词项集合
-Posting List:每个词对应的文档ID列表(还可以加位置、频率等)
一旦建立,查询效率就从 O(n) 变成了接近 O(1) —— 这正是 ES 能毫秒级响应的原因之一。
手写一个极简版倒排索引
class InvertedIndex: def __init__(self): self.term_to_docs = {} # term -> set of doc_ids self.doc_store = {} # doc_id -> raw text def add_document(self, doc_id: int, text: str): self.doc_store[doc_id] = text tokens = self._tokenize(text) for token in tokens: if token not in self.term_to_docs: self.term_to_docs[token] = set() self.term_to_docs[token].add(doc_id) def _tokenize(self, text: str) -> list: return [word.lower() for word in text.split() if word.isalpha()] def search(self, query: str) -> list: terms = self._tokenize(query) result_sets = [] for term in terms: if term in self.term_to_docs: result_sets.append(self.term_to_docs[term]) else: return [] # 任意一词未命中,则无结果 # 多关键词取交集(AND 查询) final_set = result_sets[0] for s in result_sets[1:]: final_set &= s return sorted(list(final_set))✅ 小提示:别小看这几行代码。在阿里电商业务的初级面试中,“手写倒排索引”是常见编码题。你能写出这个结构,就已经超过一半候选人了。
当我们回答“ES为什么快?”时,真正的答案不是“它用了Java”,而是:“因为它把全文检索转化成了一次或多组哈希查找 + 集合运算。”
分布式扩展:数据太多怎么办?拆!
单机存不下?并发扛不住?解决方案只有一个:横向扩展。
Elasticsearch 的核心设计哲学就是“分而治之”。它的秘密武器叫——分片(Shard)。
主分片 vs 副本分片:分工明确
一个索引被拆成多个主分片(Primary Shard),分布在不同节点上。每个主分片可以有多个副本分片(Replica Shard),用于容灾和读负载均衡。
比如设置number_of_shards=3,number_of_replicas=1,意味着:
- 数据被分成3份,每份都有1个备份
- 总共6个分片(3主 + 3副),最多可部署在6台机器上
这样既提升了存储容量,又增强了可用性。
分片怎么分配?靠哈希!
关键在于:同一条数据必须永远落在同一个主分片上,否则更新会乱。
ES 是怎么做到的?一句话概括:
shard = hash(_id) % number_of_primary_shards
来看一段模拟代码:
import hashlib def calculate_shard(doc_id: str, total_shards: int) -> int: hash_value = int(hashlib.md5(doc_id.encode()).hexdigest(), 16) return hash_value % total_shards # 示例 print(calculate_shard("product_10086", 5)) # 输出 0~4 之间的固定值你看,只要_id不变,算出来的分片号就永远不会变。这就是一致性保证的基础。
⚠️ 注意:
number_of_shards一旦创建就不能改!所以阿里内部有个铁律:上线前必须预估未来三年的数据量,合理规划分片数。否则后期只能重建索引,成本极高。
Lucene 的底层智慧:不可变段与近实时搜索
你以为 ES 直接往磁盘写文件?错了。
实际上,ES 自己并不负责存储细节,它只是 Lucene 的分布式封装层。真正干活的是 Apache Lucene。
而 Lucene 的精髓,在于两个字:不可变性(Immutability)。
段(Segment)模型:每次写入都生成新文件
Lucene 把索引划分为多个小的、只读的“段”(Segment)。每个段都是一个独立的小型倒排索引。
写入流程如下:
1. 新文档进入内存缓冲区(Indexing Buffer)
2. 每隔1秒执行refresh→ 生成一个新的 Segment,此时文档可被搜索
3. 后台定期flush→ 将事务日志(translog)刷盘,确保数据不丢失
这就实现了所谓的“近实时搜索(NRT)”——延迟控制在1秒以内。
为什么删除不能立即释放空间?
因为段是只读的。当你删除一个文档时,Lucene 并不会物理删除它,而是在段中标记为“已删除”(tombstone)。直到后续段合并(Merge)时才会真正清理。
这也解释了为什么有时候你会发现“数据删了,磁盘空间没降”。
我们来模拟一下这个机制:
class Segment: def __init__(self, name): self.name = name self.index = InvertedIndex() self.deleted_docs = set() def delete_document(self, doc_id): self.deleted_docs.add(doc_id) def search(self, query): raw_results = self.index.search(query) # 过滤掉已被标记删除的文档 return [doc_id for doc_id in raw_results if doc_id not in self.deleted_docs] class SearchableIndex: def __init__(self, num_shards=1): self.shards = [Segment(f"shard_{i}") for i in range(num_shards)] self.buffer = {} def refresh(self): """将内存中的文档 flush 到新 segment""" for shard in self.shards: for doc_id, text in self.buffer.items(): shard.index.add_document(doc_id, text) self.buffer.clear() def search_all(self, query): results = set() for shard in self.shards: results.update(shard.search(query)) return sorted(list(results))虽然简化了很多细节(比如没有FST压缩、跳表加速),但已经足够说明本质:搜索是在多个段上并行执行,最后合并结果。
组装起来:我们的微型ES系统雏形
现在,我们把前面几个模块串起来,构建成一个完整的请求处理链路。
系统架构图(文字版)
HTTP API Layer ← 提供 REST 接口,接收 PUT / GET 请求 ↓ Routing & Coord ← 解析 _id,计算 shard_no,转发请求 ↓ Index Management ← 控制 refresh/flush 定时任务 ↓ Segment Engine ← 管理多个 segment,支持增删查 ↓ Inverted Index Core ← 实现 term → docs 映射典型工作流演示
假设用户发送:
PUT /products/product_10086 { "title": "春季新款连衣裙", "category": "女装" }系统内部发生了什么?
- 协调节点提取
_id = product_10086 - 计算
shard_no = hash('product_10086') % 3→ 得到目标分片 - 转发请求至该分片所在节点
- 写入内存 buffer,并记录 translog
- 1秒后触发
refresh,生成新 segment,文档可搜 - 30分钟后或 buffer 满,触发
flush,数据持久化
查询时则更复杂一些:
GET /products/_search?q=连衣裙- 请求到达任意节点(协调节点)
- 广播查询到所有分片(主 or 副本)
- 各分片本地执行搜索,返回匹配的 doc_ids
- 协调节点合并结果、去重、排序、高亮
- 返回最终JSON给客户端
整个过程就像一场精密的分布式协奏曲。
面试实战:如何应对“设计题”?
回到开头的问题:“如果让你设计一个商品搜索系统,如何保证千万级商品在毫秒内返回结果?”
你现在应该知道该怎么答了。不要一上来就说“用ES”,那等于没答。
正确姿势是主动拆解,逐层展开:
第一层:索引结构
“我会使用倒排索引作为基础数据结构,将‘标题’、‘品牌’、‘类目’等字段分词后建立 term → doc 映射,支持关键词匹配。”
第二层:性能优化
“中文需要结合 IK 分词器做细粒度切分,避免‘苹果手机’被切成‘苹果’和‘手机’导致误召回。”
“高频查询字段启用 Field Data Cache,减少重复解析开销。”
第三层:分布式扩展
“根据预估数据量设定合理的主分片数,比如每分片控制在20GB左右,避免过大影响恢复速度。”
“通过 category 或 brand 作为 routing key,使同类商品聚集在同一分片,提升聚合查询效率。”
第四层:高可用保障
“配置至少1个副本分片,实现读写分离和故障转移。”
“设置 minimum_master_nodes 防止脑裂,JVM堆内存不超过32GB以避免指针压缩失效。”
第五层:写入调优
“高峰期采用 Bulk API 批量导入,增大 refresh_interval 至30s降低I/O压力。”
“冷热分离架构,热数据放SSD节点,冷数据归档到HDD。”
看到没?这才是面试官想听的答案:不是工具使用者,而是系统设计者。
那些年踩过的坑:来自阿里实践的经验教训
纸上谈兵容易,真实世界却处处是坑。
分享几个真实的线上案例,帮你避开雷区:
❌ 案例一:分片太小,文件句柄炸了
某业务线初期为了“灵活管理”,每个索引设了10个分片,但每天只写几千条数据。结果半年后打开文件数突破65535限制,节点频繁OOM。
✅ 解决方案:合并小索引,控制单分片大小在10~50GB之间,使用rollover API按大小滚动创建新索引。
❌ 案例二:refresh 太频繁,CPU跑满
日志场景下默认1秒 refresh,每秒生成一个 segment。短时间内产生上百个小段,merge 线程疯狂工作,CPU飙升。
✅ 解决方案:非实时场景调整为"refresh_interval": "30s",批量写入后再开放搜索。
❌ 案例三:routing 设计不合理,热点严重
所有文档使用默认_id哈希路由,导致某些分片数据远多于其他,查询负载不均。
✅ 解决方案:引入业务维度 routing,如"routing": "category_101",让同一类商品尽量落在同一分片。
写在最后:掌握原理,才能超越工具
Elasticsearch 很强大,但它不是魔法。
每一个match_query背后,都是一次倒排链表的加载与集合运算;
每一次search_after分页,都是基于 scroll context 的状态维护;
每一条 slow log 记录,都在提醒你 segment 数量是否失控。
所谓“es面试题”,考的从来不是你会不会敲命令,而是你能不能说清楚:
当一个查询进来时,数据是怎么一步步从磁盘走到你眼前的?
当你能画出这张图,并讲清每一步发生了什么,你就已经站在了大多数人的前面。
如果你正在准备阿里或其他一线大厂的技术面试,不妨试试这个训练方法:
选一个常见功能(比如聚合查询),从头推一遍:
1. 用户输入了什么?
2. 协调节点做了什么?
3. 数据是如何路由到分片的?
4. 每个分片本地如何执行?
5. 结果如何合并排序?
6. 中间可能遇到哪些性能瓶颈?
这种“动手推演”的方式,比背一百道面经都管用。
毕竟,真正的技术深度,永远来自于亲手造一次轮子。
欢迎在评论区留下你的 mini-ES 设计思路,我们一起讨论优化方案。