自从人人影视分享站被攻击之后,我便一直在想如何提升搜索性能。
问题分析
人人影视分享站的搜索功能主要包括这三大模块:
- 搜索 yyets 数据库的
cnname
、enname
和aliasname
三个字段,也就是之前爬下来的老的数据库的内容 - 搜索评论信息,同时找到评论所在页面
- 如果以上都无结果,那么去搜索其他网站
三大模块的优化方案
- 1 和 2 都是使用了正则作为模糊搜索,因此加索引是没用的。虽然数据库不算很大,但是毕竟要全表检索,数量多就容易过载
- 搜索评论时,是 Python 拿到结果再去请求 yyets,其实可以通过聚合查询一次性搞定。让 MongoDB 去做这件事情肯定比 Python 去做要方便得多,也省得写乱七八糟的处理代码
- 站外查询这个功能可以禁用了
- 升级服务器
但是以上方案无论怎么折腾,都逃不过搜索时要全表检索,只要涉及到全表检索那肯定会重 IO 重 CPU。
那不请求数据库,加 redis 做缓存呢?好是好,但是命中率会很低,因为每个人搜索关键词可能差很多,而且缓存多久合适?很难搞的问题
那有没有更好的办法呢,那种搜索很快的数据库?当然有的了,比如知名的 Elastic Search
全文搜索引擎
ElasticSearch 可谓是最致命知名的全文搜索引擎了,几年前在做实习生的时候简单地接触过。那时候在我的眼中,ES 似乎和 MongoDB 没什么区别。
为了能够让人人影视分享站的搜索功能更上一个台阶,我需要选择一个全文搜索引擎。
ES 确实是非常知名的全文搜索引擎,但是同时也非常致命:需要非常的配置,尤其是内存,官方建议似乎最少 8G 了,我真的很穷😭
我对于这个全文搜索的要求如下:
- 轻量级,对配置要求比较低
- 开发活跃,有相应的 Python SDK
- 支持 CJK
- 最好再支持 docker,真的不想自己写
Dockerfile
,太累了 - 使用简单,像 MongoDB 那样随便插入,不需要定义 schema,心智负担小一点吧
网上搜索了一圈,发现了这么几个比较不错的产品
- sonic 官方宣称轻量级的 ES 替代品,支持几十种语言。坏消息是 Python library 没一个能用的,全挂了。官方维护的 nodejs 的倒还可以,不愧是亲儿子啊
- Tantivy 官方说也支持十几种语言,并且可以通过插件支持 CJK,还有一个
tantivy-py
,rust 的 bingding,虽然没啥自动补全了,安装时说不准还得来个 rust,但是也能用。缺点就是,这个tantivy-py
,不支持第三方 tokenizer,那和不支持 CJK 有什么区别嘛…… - Typesense 也是非常有名的全文搜索引擎,不过对 CJK 的支持不太完善,并且需要定义 schema
最终我找到了今天的主角,Meilisearch,美丽搜索。支持 CJK,可以容忍错别字,有官方的各种语言的 SDK,自带一个简单的检索页面,使用 rust 构建,同样轻量级
Meilisearch 消费数据
对于全文检索来说最重要的无非是两件事情,存储数据和检索。对 Meilisearch 来说这很简单:
- import Meilisearch
- client = Meilisearch.Client("http://127.0.0.1:7700", "masterKey")
- index = client.index("demo")
- documents = [
- {
- "id": 123,
- "title": "后端的接口大概在半年前就写好了"
- },
- {
- "id": 456,
- "title": "我最近才学了一点点 React做好了邮件验证的功能"
- }
- ]
- index.add_documents(documents)
可一次性提交很多数据,Meilisearch 会异步处理。如果想要知道处理结果可以看 tasks 结果,如:
- task = index.add_documents(documents)
- time.sleep(1)
- print(index.get_task(task.task_uid))
Meilisearch 的 ID
Meilisearch 要求数据包含 ID,如果你提交的数据是这样的
- documents = [
- {
- "title": "后端的接口大概在半年前就写好了",
- "titleId": "123a",
- "content": "好的",
- "contentId": "yagwah2"
- },
- {
- "title": "我最近才学了一点点 React做好了邮件验证的功能",
- "titleId": "456b",
- "content": "不好",
- "contentId": "9777"
- },
- ]
你会发现在网页上看不到这些索引,这是因为 Meilisearch 要求数据中包含唯一 ID,这也是以后更新索引的依据。给每一条数据加上一个唯一的 ID 就好了。或者:
如果你的数据中不包括 id 字段,那么 Meilisearch 将会尝试自动从其他类似 ID 的字段中推导,比如你的数据是:
- {
- "username": "abcdefg",
- "date": "2021-06-13 22:32:48",
- "comment": "很好看",
- "commentID": "60c61710004833fc8cd4b240",
- "origin": "comment",
- "hasAvatar": None,
- "resourceName": "急诊室的故事",
- }
Meilisearch 就会把commentID
当作 ID 去使用。但是如果非常不巧你的数据中包含多个 ID 结尾的字段,如
- {
- "username": "abcdefg",
- "date": "2021-06-13 22:32:48",
- "comment": "很好看",
- "commentID": "60c61710004833fc8cd4b240",
- "origin": "comment",
- "hasAvatar": None,
- "resourceID": 10323,
- "resourceName": "急诊室的故事",
- }
那么就会推导失败,此时要么自己添加好 id,要么指定主键名
- index.add_documents(documents, primary_key="commentID")
Meilisearch 检索数据
非常简单高效,唯一的缺点是没有提供 confidence score 之类的东西,有的时候搜出来的东西实在是离谱🤡
- result = index.search("棒")
不用考虑简体繁体,有非常多的参数可以选择,具体可以看文档
集成 Meilisearch
把 Meilisearch 集成到人人影视分享站,要考虑的问题如下:
- 数据持久化,我的所有应用都是 docker 跑的,这样就一定要考虑数据持久化的事情
- 消费数据,要保证 Meilisearch 的数据和 MongoDB 同步更新,并且在每次重启容器时也不会有丢失
- 保证返回一样的数据结构,避免前端改来改去
数据持久化
Meilisearch 的数据并不重要,因此不需要映射宿主机的目录到容器之中,只需要 docker volume 就可以了。
- docker volume create meili
然后
- docker run -v meili:/meili_data getmeili/meilisearch:v1.0.2
用docker-compose
的话,也基本一样
- version: '3.1'
- services:
- meili:
- image: getmeili/meilisearch:v1.0.2
- volumes:
- - meilisearch:/meili_data
- volumes:
- meilisearch:
第一次消费数据
由于 Meilisearch 是异步处理数据的,也就意味着提交一大堆数据之后,我们并不会阻塞。
因此问题就变成了如何更优雅的从 MongoDB 中查询到我们想要的数据,然后一股脑的塞过去。
此时就要用到 MongoDB 的aggregate
了,非常强大的功能,一个查询就可以全部搞定,不用拿 Python 再遍历然后二次查
我的第一次消费数据也非常简单粗暴,程序启动时丢个线程去跑就好了,反正也没多少,第一次几秒钟就结束
- threading.Thread(target=engine.run_import).start()
同步数据
MongoDB 有一个功能叫做 Change Stream,这是一种发布订阅的模式,简单的说就是当数据库发生变更,MongoDB 会 “推送” 数据过去。
唯一的小缺点是,数据库药需要配置成 replica set 才可以。当然了,只有一个节点的 replica set 也没问题啦。
用起来很容易,先在配置文件中启用 replica set,如果用的 docker,那么 command 追加--replSet rs0
即可,然后启动 MongoDB,
配置 replica set,比如我要配置两个节点的
- rs.initiate({
- _id: "rs0",
- members: [{
- _id: 0,
- host: "localhost:27017"
- },
- {
- _id: 1,
- host: "mongo2:27017"
- }]
- })
rs.status()
可以查看配置结果
多个节点的话,等到数据同步好了,可选配置一下优先级
- cfg = rs.conf()
- cfg.members[0].priority = 0.5
- cfg.members[1].priority = 0.5
- cfg.members[2].priority = 1 # 最高
- rs.reconfig(cfg)
配置成功之后,比如我想监听comment
这个集合的变化
- cursor = self.db.comment.watch()
- for change in cursor:
- print(change)
无论是增删改都会在这个change
中体现。我同样也很简单粗暴,拿到数据了add_documents
就好。
数据结构
由于在喂数据的时候就是根据前端的要的数据构造的,所以这里根本不用管,直接把 Meilisearch 返回的结果返回给前端就好了
性能对比
在开了省电模式的 M1 Macbook 单核心、2G 内存的 docker engine 环境,用ab -c 100 -n 2000
的跑分,优化前:
优化后:
每秒请求次数从 20 左右提升到 200 多,直接 10 倍,这不比升级机器配置好多啦!并且 IO、CPU、RAM 占用也比 MongoDB 理想多了。
后续
我也不知道这个人是谁,还在孜孜不倦的爬呀爬😂 虽然并无任何影响,但是看着就很恶心人,有什么好的办法吗
-- 本评论由 Telegram Bot 回复~❤️
-- 本评论由 Telegram Bot 回复~❤️
-- 本评论由 Telegram Bot 回复~❤️