文件检索服务全链路:元数据索引、中文分词与限流运维
做文件检索这件事,我前前后后折腾过三个版本:最早是一个只给自己用的本地文件索引脚本,后来扩展成团队内部资料检索,再后来帮人评估过一个面向公众的网盘搜索网站项目。三次经历下来,最大的感受是——技术难度远没有想象中高,真正难的是在"能搜到"和"该不该被搜到"之间划线。很多人一上来就想着爬虫怎么写、索引怎么建,结果站点跑到第三天就被投诉、被限流、被上游封掉,前面搭的东西全白费。所以我这篇不打算只讲技术,而是把定位、采集、索引、接口、前端、运维整条链路按我实际做过的顺序拆开讲,重点讲每一步为什么这么选、哪些地方容易翻车。适合两类人看:一是想自己搭一个文件检索服务的开发者,二是手上有大量分散文件、想做个内部搜索入口的团队。哪怕你只是想搞清楚"搜索引擎到底怎么把一堆文件名变成可检索的东西",看完也能自己动手跑一个最小可用版本出来。
1. 动手之前先把"搜什么"定死:定位与合规边界
1.1 网盘搜索的三种形态,技术栈完全不同
很多人把"网盘搜索网站"当成一个东西,其实这里面至少是三种完全不同的产品形态,选错了形态,后面所有的技术选型都是白费。
第一种是个人/团队文件索引。你把散落在各个网盘账号、不同目录、不同共享空间里的文件元数据(文件名、大小、修改时间、所在路径、分享状态)同步到自己维护的一个索引库里,只给自己或团队成员用。这种形态不涉及公开采集,数据源是授权过的,技术上就是个增量同步 + 全文检索,一个 SQLite 加 FTS5 或者单机 Meilisearch 就能扛住几十万条。
第二种是公开资源导航。它不存文件,甚至不做全量采集,而是维护一份人工审核过的公开分享页面清单,提供分类浏览和关键词跳转。这种形态的核心工作量在审核和分类,不在技术,索引量可能只有几万条,但每一条都要有人看过。
第三种是聚合检索。从公开可访问的页面上采集分享链接的元数据,建成索引对外提供搜索。这种形态技术复杂度最高,也最容易踩线——采集边界、更新频率、失效链接处理、投诉响应,每一项都得有明确策略。
我在第一次做的时候就犯过混形态的错:本想做第二种,结果爬虫写顺手了变成第三种,站点的定位一下子就模糊了。后来我把三者的差异列了个表,每次启动新项目都先对一遍:
| 形态 | 数据来源 | 索引量级 | 主要技术难点 | 主要风险点 |
|---|---|---|---|---|
| 个人/团队索引 | 自有账号授权同步 | 万到百万级 | 增量同步一致性 | 权限隔离 |
| 公开导航 | 人工审核录入 | 千到万级 | 分类体系维护 | 链接失效 |
| 聚合检索 | 公开页面元数据采集 | 十万到千万级 | 采集调度与索引更新 | 采集边界、投诉响应 |
形态定不下来,后面所有决策都是摇摆的。比如你做的是第一种,那就不需要分布式爬虫、不需要反爬对抗、不需要内容审核队列;你做的是第三种,那索引重建、失效检测、投诉处理链路一个都不能少。我见过一个团队用聚合检索的架构去做内部文件搜索,结果光爬虫调度就写了两个月,实际上完全没必要。
1.2 为什么"只索引元数据"是这类站点的生存线
这条是我踩过最深的坑之后总结出来的:任何文件检索服务,索引层和存储层必须彻底分开。
所谓"只索引元数据",指的是你的数据库里只保留文件名、文件类型、大小、创建时间、来源页面地址、可访问状态这些描述性字段,绝不缓存文件本体,也不提供绕过来源页面直接获取文件内容的通道。用户搜到结果之后,点击跳转到原始来源页面,由来源方决定是否允许访问。
这样做有三个实打实的好处,不只是为了合规好看:
第一是存储成本。一个索引条目大概 300 到 800 字节,一百万条也就几百兆,一台小机器完全放得下。你要是缓存文件本体,哪怕只缓存缩略图和预览片段,容量也会瞬间失控。
第二是失效处理简单。文件本体被删了你没法感知,但元数据里的"来源页面可访问状态"可以定期探测,失效就打标记或者下线,用户搜到的结果不会大量 404。
第三是责任边界清晰。你只描述"某处存在一个叫这个名字的文件",不参与分发,来源方删除之后你的索引跟着失效,链路是闭合的。
提示:如果你在设计阶段就规划了"文件预览""在线解压""直链接口"这类功能,几乎可以肯定这个架构迟早要推倒重来。这些功能的成本和法律风险都远高于搜索本身,收益却很低。
1.3 上线前必须自己过的几道检查
我在第二次做这类项目时,给自己定了一个上线前清单,后来每次都会拿出来对一遍。这个清单不涉及任何具体法规条文,纯粹是从工程可控性角度出发的自我检查:
- 采集来源是否全部是无需登录即可访问的公开页面,有没有任何一步是绕过访问控制的
- 是否遵守了来源站点的 robots 约定和访问频率限制,有没有并发过高导致对方服务受影响的可能
- 索引里是否存在文件本体、缩略图、内容摘要等非元数据字段
- 结果页是否有明确的"内容由来源方提供"的标识,用户是否能清楚知道点击后会跳到哪里
- 是否有可用的投诉/下架通道,收到反馈后多久能把某条索引摘掉,这个流程是否演练过
- 查询接口是否有频率限制,单个 IP 每分钟能发多少次请求,超了怎么处理
- 是否有敏感词过滤,用户搜某些词时返回空结果而不是报错
最后一条尤其容易被忽略。早期的版本我压根没做关键词过滤,结果某些明显不该被检索的词也能搜出东西来,虽然后来加了黑名单,但日志里留下的记录清理起来很麻烦。正确做法是在查询入口和索引写入两个环节都做过滤,前者挡住用户输入,后者挡住脏数据入库。
2. 元数据从哪来:采集层的设计与取舍
2.1 采集的边界感,比采集能力更重要
聊采集之前先说清楚一件事:这一节讲的所有内容,前提都是目标页面公开可访问、且对方没有明确拒绝采集。任何需要登录、需要绕过验证、需要伪造身份才能拿到的数据,都不在讨论范围内。这不是保守,是因为绕过访问控制拿到的数据,你既没法保证稳定性(对方一改策略你就断供),也没法保证可持续性(量越大风险越高)。
在合法边界内,元数据的来源其实有这么几类:
- 公开的分享页面:很多服务提供分享链接,未设密码的分享页面对任何人可见,页面里就包含文件名、大小等信息
- 公开的站点地图和目录页:有些资源站本身提供分类列表,这类页面结构规整,是最理想的采集对象
- 开放数据接口:少数服务提供公开的检索接口,有明确的调用配额
- 用户主动提交:让用户提交自己的公开分享链接,审核后入库,这条路的量小但质量最高
我在实际项目里,用户主动提交 + 公开目录页这两类占了入库量的绝大部分,反而是"全网扫分享页"这种听起来最猛的方式,投入产出比最低——因为大量分享页面是私有的、带密码的、早就失效的,扫一万个页面可能只有几十条可用数据,还容易触发对方的风控。
2.2 爬虫调度:队列、去重、增量这三件事
采集层的核心其实就三个问题:任务怎么排队、重复的怎么去掉、已经采过的怎么知道要不要重采。这三个问题不解决,爬虫跑到一定规模必然失控。
我用的是一套很朴素的组合:Redis 做任务队列,布隆过滤器做 URL 去重,MySQL 存采集状态。整体结构如下:
# 任务入队:只入队未访问过的 URL import redis from bloom_filter2 import BloomFilter # 内存布隆过滤器 r = redis.Redis(host="127.0.0.1", port=6379, db=0) bf = BloomFilter(max_elements=50_000_000, error_rate=0.001) def push_task(url, priority=5): # 布隆过滤器判断"可能访问过",误判率千分之一 if url in bf: return False bf.add(url) # 用有序集合做优先级队列,score 越小越优先 r.zadd("crawl:queue", {url: priority}) return True这里有几个细节值得说:
为什么用有序集合而不是列表。列表只能先进先出,但采集任务是有优先级的——新发现的目录页应该优先于深度翻页,即将失效的链接应该优先于陈年老链接。用ZADD加分数,消费端ZPOPMIN取最小分,天然就是个优先队列。
布隆过滤器的误判要接受。它只会误判"存在"(把没访问过的当成访问过),不会误判"不存在"。也就是说少量 URL 会被漏采,但绝不会重复采。对于百万级 URL 的场景,千分之一的误判率意味着漏掉一千条左右,完全可以接受。真要精确去重,就把 URL 做 MD5 前 16 位存到 MySQL 的唯一索引里,代价是每次都要查库。
增量策略比全量重采重要得多。早期我图省事,每周全量重跑一遍,结果跑一次要十几个小时,中途挂了还得从头来。后来改成按"上次探测时间"排序,只重采超过 7 天没探测的,并且把探测结果分成三态:可访问、已失效、需要复核。失效的连续三次探测都失败才真正下线,避免因为对方临时故障误杀。
2.3 文件名的清洗,决定了搜索质量的天花板
这一步是整个项目里最不起眼、但影响最大的环节。原始采集回来的文件名是这样的:
【某某工作室】2023年度合集[高清版](完整)- 第01集.mkv [某某组] 某某作品 1080P 中文字幕.mp4 某某资料 最终版(1)(1).zip如果直接把这些字符串塞进索引,用户搜"2023"能搜到一堆噪音,搜"第01集"又会因为全角半角、空格差异匹配不上。所以要有一层规范化处理。
我做的规范化包括这几步:
- 全角转半角,统一括号、冒号、数字
- 去除装饰性符号,把
【】[]()()这些统一替换成空格 - 大小写归一,扩展名和英文单词统一转小写
- 连续空白折叠,多个空格合成一个
- 提取结构化字段,用正则捞出年份、分辨率、集数、语言标记,单独存成字段而不是留在标题里
import re import unicodedata def normalize_filename(name: str) -> dict: # 全角转半角 name = unicodedata.normalize("NFKC", name) # 装饰符号统一成空格 name = re.sub(r"[【】\[\]()()《》<>]", " ", name) # 提取扩展名 ext = "" if "." in name: base, ext = name.rsplit(".", 1) if len(ext) <= 5 and ext.isalnum(): name, ext = base, ext.lower() else: ext = "" # 提取年份(1900-2099) years = re.findall(r"\b(19\d{2}|20\d{2})\b", name) # 提取分辨率标记 res = re.findall(r"\b(480|720|1080|2160)[pPiI]\b", name, re.I) # 折叠空白 clean = re.sub(r"\s+", " ", name).strip().lower() return { "clean_name": clean, "ext": ext, "year": years[0] if years else None, "resolution": res[0].lower() + "p" if res else None, }为什么要把年份、分辨率单独抽出来。因为用户搜"2023 1080p"这种行为非常普遍,如果这些都混在标题字符串里做全文匹配,相关度会非常差——一个标题里恰好含"2023"但其实是文件大小 2023KB 的条目,会排到真正 2023 年发布的内容前面。抽成独立字段之后,就可以做精确过滤而不是模糊匹配。
我实测下来,加了这一层清洗之后,同一个查询的相关度主观评分大概能提升三成以上,尤其是那种关键词很短、结果很多的查询。
3. 索引与检索:为什么最后我没用数据库直接查
3.1 量级决定选型,别一上来就上重型方案
选型这件事,我的原则永远是按数据量倒推,而不是按流行度选。我做过三档:
| 索引量级 | 方案 | 单次查询耗时 | 说明 |
|---|---|---|---|
| 10 万以内 | SQLite + FTS5 | 5~30ms | 单文件,零运维,最省事 |
| 100 万以内 | Meilisearch 单机 | 3~20ms | 自带中文分词和拼写容错 |
| 1000 万以上 | OpenSearch/ES 集群 | 20~100ms | 需要分片规划 |
最开始我用的是 MySQL 的LIKE '%关键词%',十万条数据的时候勉强能跑,五十万条的时候一个查询要两秒多,因为前置通配符导致索引完全失效,每次都是全表扫描。这个坑很小但很典型:很多人以为给字段加个索引就行了,却不知道前置通配符会让 B+ 树索引彻底失效。
换成 FTS5 之后,同样的查询降到十几毫秒。FTS5 是 SQLite 内置的全文检索模块,用倒排索引实现,建表非常简单:
-- 建虚拟表,tokenize 用 unicode61 处理多语言 CREATE VIRTUAL TABLE file_fts USING fts5( clean_name, tokenize = "unicode61 remove_diacritics 2" ); -- 插入数据时同步写入 FTS 表 INSERT INTO file_fts(rowid, clean_name) SELECT id, clean_name FROM files; -- 查询:按相关度排序,bm25 越低越相关 SELECT f.id, f.clean_name, bm25(file_fts) AS score FROM file_fts JOIN files f ON f.id = file_fts.rowid WHERE file_fts MATCH '关键词' ORDER BY score LIMIT 20;数据量涨到百万级之后,FTS5 的写入性能开始成为瓶颈——每次插入都要更新倒排表,批量导入的时候速度掉得很厉害。这时候才考虑换 Meilisearch 这类独立服务。我的建议是先用 FTS5 把整个链路跑通,确认产品形态成立之后再迁移,不要一开始就上分布式,否则光是集群运维就能把你拖死。
3.2 中文分词的坑:文件名其实不是自然语言
这是我在这个项目里踩得最深的一个技术坑。
标准的中文分词器(比如 IK、jieba)是按自然语言的词边界切的。但文件名不是自然语言,它是一堆关键词的堆砌,没有语法结构。比如"某某项目2023年度总结报告最终版",自然语言分词可能切成"某某/项目/2023/年度/总结/报告/最终/版",用户搜"年度总结"能匹配上,但用户搜"项目总结"就匹配不上——因为"项目"和"总结"之间隔了别的词。
更麻烦的是中英文混排和缩写。用户搜"AI绘画",标题里写的是"人工智能绘画",语义上是一回事,字面上完全匹配不上。
我最后的解决方案是双路索引:
- 一路用分词后的词项,处理正常的关键词查询
- 一路用二元切分(bigram),把"年度总结"切成"年度""度总""总结",虽然索引体积会膨胀两三倍,但能保证任意两字组合都能命中
def bigram_tokenize(text: str) -> list[str]: """中文二元切分 + 英文按空格切,用于兜底匹配""" tokens = [] buf = "" for ch in text: if "\u4e00" <= ch <= "\u9fff": # 中文字符 if buf: tokens.append(buf) buf = "" tokens.append(ch) elif ch.isalnum(): buf += ch else: if buf: tokens.append(buf) buf = "" if buf: tokens.append(buf) # 中文相邻字组合 result = [] for i, t in enumerate(tokens): result.append(t) if i + 1 < len(tokens) and len(t) == 1 and len(tokens[i + 1]) == 1: result.append(t + tokens[i + 1]) return result代价是索引体积。一百万条记录,纯分词索引大概 200MB,加了 bigram 之后涨到 600MB 左右。用磁盘换召回率,我觉得这个交易很划算,因为用户搜不到东西的挫败感,远比多花几百兆硬盘严重。
另外一个技巧是建同义词表。把常见的等价表达维护成一张小表,查询时自动扩展。比如"高清"和"1080p"、"合集"和"全集"、"资料"和"文档"。这张表我是一边看搜索日志一边手工加的,加了两百多条之后,零结果查询的比例从 12% 降到了 4% 以下。
3.3 排序不只是相关度:热度、时间、可用性都要算进去
纯按相关度排序,用户体验会很差。原因很简单:相关度只看文本匹配,不看内容质量。一个标题里恰好堆满关键词的失效链接,会排在一个标题朴素但真实可用的条目前面。
我的排序公式大概是这样:
final_score = relevance * 0.6 + hot_score * 0.2 + freshness * 0.15 + availability * 0.05其中:
relevance来自 BM25,做归一化处理hot_score是近 7 天的点击量取对数后归一化,用对数是为了防止个别爆款条目长期霸榜freshness按exp(-λ * 天数)衰减,λ 取 0.02 左右,大概一个月衰减到 55%availability是最近一次探测的可访问状态,可访问为 1,未探测为 0.5,失效为 0
这几项权重是可以调的,但不要频繁调。我一开始每天都改权重,结果搜索结果的排序天天变,用户根本建立不起稳定的预期。后来固定成上面这组,每季度review一次,反而效果更好。
3.4 索引重建:别名切换是零停机的关键
索引需要定期全量重建(比如改了分词策略、加了新字段),重建期间不能停机,这是基本要求。做法是双索引 + 别名切换:
# 1. 建一个新索引,写入全量数据 curl -X PUT "localhost:9200/files_v2" -H 'Content-Type: application/json' -d' { "settings": { "number_of_shards": 3, "number_of_replicas": 1 }, "mappings": { "properties": { "clean_name": { "type": "text" } } } }' # 2. 全量导入(这一步可能跑几小时,期间旧索引继续服务) python import_full.py --target files_v2 # 3. 原子切换别名,毫秒级完成 curl -X POST "localhost:9200/_aliases" -H 'Content-Type: application/json' -d' { "actions": [ { "remove": { "index": "files_v1", "alias": "files" } }, { "add": { "index": "files_v2", "alias": "files" } } ] }' # 4. 观察几天没问题再删旧索引 curl -X DELETE "localhost:9200/files_v1"应用层永远只查别名files,不查具体索引名。这是整个方案的核心,别名切换是原子的,切换瞬间所有请求就走新索引了,不需要重启任何服务。
我第一次做重建的时候犯过一个错:直接把旧索引删了重建,结果重建了四个小时,站点整整挂了四个小时。用户当天就走了大半。从那以后我就把别名切换写进了运维脚本,再也没出过问题。
4. 后端接口:限流和反滥用是逃不掉的
4.1 查询接口的字段设计要克制
接口设计上,我的原则是返回字段越少越好。早期我把数据库里所有字段都返回给前端,包括内部分类 ID、采集时间戳、来源网站的原始 URL 参数,结果被人拿去做了二次采集——人家直接拿你的接口当数据源,一晚上把你全站数据拉走。
现在的返回结构大概是这样:
{ "code": 0, "data": { "total": 1284, "items": [ { "id": "f_8a3c9e", "name": "某项目2023年度总结报告.pdf", "size_text": "12.4 MB", "type": "pdf", "updated": "2023-11-20", "source_label": "公开目录页", "available": true } ] } }注意几点:
- ID 用不可枚举的随机串,不用自增 ID。自增 ID 太容易被人按序遍历,等于把你的全量数据白送
- 不返回原始 URL,只返回一个内部跳转 ID,用户点击走
/go/{id},服务端做 302 跳转,顺便记一次点击 - 不返回文件大小字节数,只返回格式化后的文本,减少信息泄露
- 不返回任何内部字段,尤其是不返回采集时间和采集器标识
这个改动看起来微小,但对防止数据被批量拉取非常有效。
4.2 缓存分层:热词预加载比想象中有效
搜索接口的流量分布极其不均匀。我统计过自己的日志,前 100 个查询词占了总查询量的 60% 以上,前 10000 个占了 90%。这种情况下,做热词缓存收益巨大。
我的做法是三层:
- 本地内存缓存(进程内 LRU),存 top 200 查询词的结果,命中率大概 25%,响应时间 1ms 以内
- Redis 缓存,存 top 50000 查询词的结果,TTL 20 分钟,命中率再增加 40% 左右
- 回源查索引,剩下 35% 的请求走这里
import hashlib import json import redis r = redis.Redis(host="127.0.0.1", port=6379, db=1) def search_cached(keyword: str, page: int = 1): key_raw = f"{keyword.strip().lower()}:{page}" cache_key = "s:" + hashlib.md5(key_raw.encode()).hexdigest()[:16] cached = r.get(cache_key) if cached: return json.loads(cached) result = do_real_search(keyword, page) # 只有结果数量足够才缓存,避免缓存空结果导致永久空 if result["total"] > 0: r.setex(cache_key, 1200, json.dumps(result, ensure_ascii=False)) return result一个坑:空结果千万不要缓存(或者只给很短 TTL 比如 60 秒)。我早期设置成所有结果都缓存 20 分钟,结果刚上线的那几天,索引里数据还很少,大量查询返回空,这些空结果被缓存下来,后来数据补全了用户还是搜不到东西,排查了半天才发现是缓存的问题。
另一个坑:缓存键要考虑分页和排序参数。只按关键词做 key,用户翻到第二页会拿到第一页的结果。必须把页码、排序方式这些都拼进 key 里。
4.3 被刷了之后我才开始做限流
这个事说起来有点丢人。站点上线第二周,有人写了个脚本按字母序把所有查询词刷了一遍,想把我全站数据拉下来。因为我没做任何限流,那次直接把数据库打到了连接数上限,站点挂了两个小时。
之后我补了三层防护:
第一层是 Nginx 层的粗粒度限流:
limit_req_zone $binary_remote_addr zone=search:10m rate=20r/m; limit_req_zone $server_name zone=global:10m rate=500r/s; server { location /api/search { limit_req zone=search burst=5 nodelay; limit_req zone=global burst=100; limit_req_status 429; proxy_pass http://backend; } }单个 IP 每分钟 20 次,突发允许 5 次。这个阈值对正常用户完全够用(没人会一分钟搜 20 次),但能挡住绝大多数脚本。
第二层是应用层的令牌桶,按 IP + 用户标识(如果有)做细粒度控制,主要防的是分布在多个 IP 上的慢速刷取。令牌桶比漏桶更适合搜索场景,因为它允许短时突发。
第三层是行为检测,记录每个 IP 的查询词序列,如果发现连续 100 次查询的词都是按字母序递增的,或者每次都能翻到很深的页码,直接临时封禁。这一层容易误伤,所以只做临时封禁(比如 30 分钟),不做永久封禁。
注意:限流阈值定得太严会把正常用户也挡住,尤其是那种打开了多个标签页同时搜索的用户。我一开始设的是每分钟 10 次,结果投诉说"搜着搜着就不让搜了",后来放宽到 20 次才消停。上线前最好自己用真实使用节奏压一遍。
5. 前端体验:让用户在三次输入之内找到东西
5.1 搜索建议的防抖和取消请求
搜索框的即时建议看起来简单,做不好就是灾难。我见过最差的实现是每敲一个字符发一次请求,敲"项目总结报告"七个字发了七次请求,每次返回一堆结果,最后显示的是第一个字符的结果,因为请求乱序返回。
正确的做法是防抖 + 请求取消:
let controller = null; let timer = null; function onInput(e) { const q = e.target.value.trim(); clearTimeout(timer); if (controller) controller.abort(); // 取消上一次未完成的请求 if (q.length < 2) { renderSuggestions([]); return; } timer = setTimeout(async () => { controller = new AbortController(); try { const res = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`, { signal: controller.signal, }); const data = await res.json(); renderSuggestions(data.items); } catch (err) { if (err.name !== "AbortError") console.error(err); } }, 250); }防抖延迟 250 毫秒是个比较甜的点。低于 150 毫秒用户还没打完就发请求了,高于 400 毫秒用户会觉得输入有延迟感。
建议词的来源不要直接用搜索日志。日志里全是长尾词,直接展示会很奇怪。我的做法是:只取最近 7 天查询次数超过 50 次的词,按前缀匹配取 top 8,再加上几条同义词扩展。这样既有热度保证,又不会推荐一堆只被搜过一次的怪词。
5.2 结果页的信息密度,比美观重要得多
这个页面我改过七版,最后发现用户真正关心的就三件事:文件名够不够清楚、这东西看起来是不是我要的、点进去能不能打开。
所以每一条结果的信息排布是:
- 文件名(高亮匹配词)
- 一行灰色小字:类型、大小、更新时间、来源标识
- 右上角一个可用性状态标记
关键词高亮必须做,而且要处理分词边界。用户搜"项目总结",标题是"某项目2023年度总结报告",高亮的时候应该把"项目"和"总结"分别高亮,而不是找连续的"项目总结"四个字。做法是拿分词结果的每个词项去标题里找,逐个替换成高亮标签。
结果数量多的时候必须做分组。一百万条数据搜"报告"能出十万条结果,全部平铺毫无意义。我加了一层按文件类型聚合的筛选栏(文档、视频、压缩包、其他),用户点一下就能把范围缩到万级以内。
每页条数不要太多。我做的是默认 20 条,最多支持到 50 条。早期设成 100 条一页,结果页面渲染慢、用户也不看,纯属浪费。
5.3 零结果的兜底,决定用户会不会再来
零结果页是绝大多数搜索产品做得最烂的地方。用户搜不到东西,页面就一句"没有找到相关内容",然后就走了。
我做了三层兜底:
第一层是查询改写。用户搜"2023年总结",如果零结果,自动去掉"年"字再搜一次;搜"项目 总结"(带空格)如果零结果,去掉空格变成一个词再搜。这些规则很简单,但能把零结果率降两个百分点左右。
第二层是模糊匹配。用编辑距离做一次容错查询,允许一个字符的差异。比如用户把某个词打错了一个字,也能搜到。这一层要用在最后,否则会引入大量噪音。
第三层是相关推荐。从查询词里提取出能匹配上的部分词项,给出"你是不是想找 XX"的链接,同时展示几条热门内容。用户在这里流失率最高,但也最容易挽回——只要给他一个能点的东西,很多人就留下了。
我统计过改造前后的数据:零结果页的跳出率从 91% 降到了 68%,虽然还是很高,但已经好太多了。
6. 部署运维:小成本方案和几次真实故障复盘
6.1 一台四核八G能撑到什么程度
很多人一听说"搜索网站"就觉得要上集群,实际上百万级索引、日均几万查询,一台四核八G的机器完全够用。我的实际配置:
- 应用服务:2 个进程跑 API,占用约 800MB 内存
- 索引服务:Meilisearch 单机,百万条索引占用约 1.2GB 内存
- 缓存:Redis,限制 512MB,淘汰策略 LRU
- 数据库:SQLite(存采集状态和点击日志),文件约 3GB
- 反向代理:Nginx,做限流、静态文件、gzip
整体内存占用稳定在 5.5GB 左右,CPU 峰值在查询量突增时会到 70%,平时 10% 以下。磁盘用 80GB SSD,索引加数据库加上日志轮转后大概占 15GB。
这个配置下,单机能扛住每秒 300 次左右的查询。再往上就要考虑加机器了,但说实话,除非你的站点已经小有名气,否则这个量级远远够用。
日志我建议一定要做轮转,按天切割保留 14 天。我有一次忘了配轮转,日志文件涨到 40GB 把磁盘写满了,整个服务全部挂掉,从报警到恢复用了半小时。
6.2 那几次让我印象深刻的故障
故障一:索引和数据库不同步,搜出来的条目点进去 404
现象是用户搜到结果,点进去提示"内容不存在"。排查下来发现,数据库里的条目已经被标记删除,但索引里没同步更新。
根本原因是删除操作只写了数据库,忘了更新索引。修复方案是把所有涉及状态变更的操作都改成"先写数据库,再发一条消息到队列,由消费者异步更新索引",同时在索引里加了一个available字段,查询时加过滤条件available:true。
故障二:某个查询词把内存打爆
有一次监控报警说内存占用 98%,服务响应超时。登录上去一看,是一个查询词触发了超长正则匹配,把 CPU 打满了,同时请求堆积导致内存暴涨。
原因是我的查询改写逻辑里有个用户可控的正则构造,用户输入特殊字符时正则引擎回溯爆炸。修复很简单:所有用户输入在进入正则之前先转义,并且给正则匹配加超时限制。
故障三:来源站点改了页面结构,采集全量失败
这个最典型。采集脚本依赖特定的 HTML 结构提取文件名,对方改版之后所有采集任务都失败,但脚本没有明显的错误日志,因为它"成功执行"了,只是提取到 0 条数据。
后来我加了一个采集健康度监控:每次采集任务完成后统计本次提取到的有效条数,如果连续三次都是 0,就发告警。这个监控非常简单,但救过我好几次。
6.3 监控要看哪些指标
我的监控面板上就四个核心指标,多了也看不过来:
| 指标 | 正常范围 | 异常时的含义 |
|---|---|---|
| 查询 P99 延迟 | < 200ms | 索引或缓存出问题 |
| 零结果率 | < 5% | 索引数据可能缺失 |
| 429 响应占比 | < 3% | 限流过严或遭刷 |
| 采集有效条数/小时 | 相对稳定 | 来源改版或网络异常 |
零结果率这个指标特别值得盯。它突然升高,通常意味着索引出了问题(比如重建期间的别名没切对),或者是采集停了导致新数据进不来。我设的告警阈值是 15 分钟窗口内零结果率超过 10%,触发过一次,果然是索引别名切错了。
6.4 关于内容反馈通道的一点经验
最后一件事,也是我认为最重要的一件事:必须有一个真实可用的反馈通道,而且必须演练过。
不是页脚放个邮箱就完事了。我设计的是一个表单,提交后进入队列,后台有一个处理界面,运营人员可以一键把某条索引下线。关键指标是"从提交到下线"的时间,我给自己定的目标是 24 小时内。
更重要的是定期演练。我每个月会自己提一条测试反馈,走完整流程,看看是不是真的能在 24 小时内下线。第一次演练就发现,表单提交后其实没有通知任何人,邮件进了一个没人看的邮箱。这种问题不演练永远发现不了。
做这类服务,技术上其实没有多难的东西,难的是把每一个环节的边界都守好,把每一个故障都当回事。我做了三年多,最大的体会是:能搜到多少是能力问题,该不该被搜到是底线问题,后者永远优先于前者。当你发现某个功能能让搜索结果多出几十万条,但需要模糊一下边界的时候,最好的选择往往是放弃它。少几十万条数据,站点照样跑;边界模糊了,前面所有工作可能一夜归零。这个判断我做过不止一次,每次事后看都是对的。