自从人人影视分享站被攻击之后,我便一直在想如何提升搜索性能。
问题分析
人人影视分享站的搜索功能主要包括这三大模块:
- 搜索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理想多了。
后续
我也不知道这个人是谁,还在孜孜不倦的爬呀爬😂 虽然并无任何影响,但是看着就很恶心人,有什么好的办法吗