3. 系统架构设计:整体架构图、模块划分与数据流设计

好,咱们进入第三个章节。说实话,很多初学者一上来就撸代码,结果写到一半发现各个模块耦合在一起,改一个地方崩一片。我早期做搜索项目时就吃过这个亏——爬虫和索引写在一个类里,后来想换一个爬虫策略,差点把整个索引重建了。

所以,在动手之前,先把架构想清楚。这就像盖房子,先画好图纸再砌砖。今天我就带你把这个「图纸」画出来。

3.1 整体架构图:四层结构

我个人习惯把搜索引擎拆成四个核心层。你想想看,用户输入一个关键词,到看到结果,中间经历了什么?

  • 数据采集层:负责从互联网上抓取网页内容。说白了就是爬虫。
  • 数据处理层:把抓回来的原始网页清洗、解析、建立索引。这是最脏最累的活。
  • 检索服务层:接收用户的查询,去索引里找匹配的文档,然后排序返回。
  • API 网关层:对外提供统一的接口,比如 RESTful API,顺便做限流、鉴权。

这四层之间是单向依赖的。数据从采集层往下流,最终流到 API 层。千万别搞成循环依赖,否则调试起来你会想哭的。

核心原则:每一层只依赖它的下一层,绝不跨层调用。爬虫不直接访问索引,API 不直接操作爬虫。

3.2 模块划分:五个核心模块

再往下拆,每个层里都有具体的模块。我按实际项目经验给你列一下:

3.2.1 爬虫模块 (Crawler)

  • URL 管理器:维护待爬取队列和已爬取集合。我用 Redis 的 Set 来去重,效率很高。
  • 下载器:发送 HTTP 请求,获取网页内容。记得设置 User-Agent 和超时时间,不然容易被封。
  • 解析器:从 HTML 里提取正文、标题、链接。BeautifulSoup 够用,但追求性能可以用 lxml。

避坑指南:我曾经在解析器里直接用正则表达式提取正文,结果遇到嵌套标签就全乱了。后来改用基于 DOM 树的提取方式,稳定多了。

3.2.2 索引模块 (Indexer)

  • 分词器:把中文句子切成一个个词。比如「搜索引擎实战」切分成「搜索」「引擎」「实战」。
  • 倒排索引构建器:建立词到文档的映射。这是搜索引擎的核心数据结构。
  • 索引存储器:把索引持久化到磁盘。我一般用倒排文件格式,按块存储,方便增量更新。

3.2.3 检索模块 (Searcher)

  • 查询解析器:把用户输入的查询字符串解析成内部查询对象。比如处理布尔操作符、短语查询。
  • 评分排序器:计算每个文档和查询的相关性分数。TF-IDF 是基础,BM25 是进阶。
  • 结果缓存:对热门查询缓存结果,减少重复计算。我用 LRU 缓存,命中率大概在 30% 左右。

3.2.4 API 模块

  • REST 接口:提供 /search?q=关键词 这样的接口。
  • 限流器:防止恶意调用。我用令牌桶算法,每秒限制 100 个请求。
  • 结果格式化:把内部数据结构转成 JSON 返回给前端。

3.2.5 存储模块

  • 原始网页库:存储抓取到的 HTML 原文。用 MongoDB 或者直接存文件。
  • 元数据库:存储 URL、标题、抓取时间等结构化信息。MySQL 或者 SQLite 都行。
  • 索引库:倒排索引文件。自己定义二进制格式,或者用 LevelDB 这种嵌入式数据库。

3.3 数据流设计:一条查询的完整旅程

光有模块还不够,你得知道数据是怎么在这些模块之间流动的。我画个流程图给你看:

用户输入 "搜索引擎实战"
        ↓
API 网关接收请求
        ↓
查询解析器分词 → ["搜索", "引擎", "实战"]
        ↓
检索模块去索引库查找每个词对应的文档ID
        ↓
合并结果集,计算 BM25 分数
        ↓
按分数排序,取 Top 10
        ↓
从原始网页库获取标题和摘要
        ↓
格式化 JSON 返回给用户

整个过程大概在 50 毫秒以内完成。如果超过 200 毫秒,用户就会觉得卡。嗯,这里要注意,缓存一定要加在查询解析器之后、检索模块之前,这样能省掉最耗时的磁盘 I/O。

警告:千万别把缓存放在 API 网关层。我见过有人这么干,结果不同用户的不同查询参数混在一起,返回了错误的结果。缓存一定要基于查询的哈希值来做。

3.4 模块间通信:接口约定

模块之间怎么通信?我建议用明确的接口定义,而不是共享内存或者全局变量。举个例子:

模块 接口 输入 输出
爬虫 → 索引 index_document(doc_id, title, content) 文档 ID、标题、正文 成功/失败状态
检索 → 索引 search_index(term) 查询词 文档 ID 列表及位置信息
API → 检索 search(query, page, size) 查询字符串、分页参数 搜索结果列表

这样做的好处是,每个模块都可以独立测试、独立部署。我当年重构爬虫模块时,只要保证接口不变,索引模块完全不用动。

3.5 部署视角:单机 vs 分布式

我们这个课程的项目,初期跑在单机上就够了。但架构设计时要留好扩展点。比如:

  • 爬虫模块可以拆成多个 worker,用消息队列分发任务。
  • 索引模块可以分片存储,每台机器只存一部分索引。
  • 检索模块可以做成无状态服务,前面加负载均衡。

说白了,先让它在单机上跑通,再考虑分布式。别一开始就上微服务,那是给自己找麻烦。

我的建议:先实现一个最小可行产品(MVP),只包含爬虫、索引、检索三个核心模块,API 用 Flask 简单封装。等跑通了再逐步优化。

3.6 小结

这一章我们画了整体架构图,拆了五个核心模块,理清了数据流动的方向。你可能会觉得有点抽象,没关系,后面每一章都会对应到具体的代码实现。

下一章,咱们就开始写爬虫了。到时候你会看到,今天画的这些模块,是怎么一行一行变成代码的。