日前,用python经由django ORM在PostgreSQL集群为数十万篇文章建立倒排索引。
数据结构:
- word:单词。varchar(255), unique key。
- documents:倒排表,格式为“文章id => 标题出现与否:标题+正文出现次数:出现位置1,位置2,...。hstore(key-value pairs)。
先用最intuitive的方法快速做了一个实现,如下:
- 从库里循环读取每一篇文章。使用django ORM all。
- 把标题+正文去掉所有的html标签后,分词。使用bleach和jieba。
- 循环每一个词。
- 先从库里查询该词是否已存在。使用django ORM filter。
- 如果已存在,那么就把新文章的倒排信息增加到既有倒排表,然后保存。使用django ORM save。
- 否则,创建该词的新记录。使用django ORM create。
运行后观察到如下现象:
开始时大概每3-5秒能完成一篇文章,运行3-4天完成大概4万篇文章后,每篇文章耗时增加到10-20秒。这样,跑完全部文章粗估得一个多月时间,这还是在假设单篇耗时不再增加的前提下。
于是做profiling,尝试找出瓶颈所在。
用了cProfile,debug-toolbar (manage.py debugsqlshell),和print大法。
debugsql发现ORM生成的update hstore的sql是把单词的原倒排表数据都拿出来,拼接上新的,然后再保存进去。遂改用raw sql "documents = documents || hstore(新数据)"并用cursor.execute执行【注1】。
idx[0].documents[article_id] = pos_list
idx[0].save()
改为:
sql = 'UPDATE search_searchindex SET documents = documents || hstore(\'%s\', \'%s\') WHERE id = %s' % (article_id, pos_list, idx[0].id)
with connection.cursor() as cursor:
cursor.execute(sql)
print大法发现,最慢的瓶颈是query单词既有的倒排表,而不是向hstore里插入新数据。
于是把ORM查询的filter(word=word)后面加上only(id),因为只需要判断一下这个word是不是已经存在,以便分情况决定是update hstore还是create新记录。
idx = SearchIndex.objects.filter(word=w)
改为:
idx = SearchIndex.objects.filter(word=w).only('id')
这一优化大大提升了速度。使得单篇文章耗时缩短到3-5秒,并且不再随着数据量增多而增加。
这一优化是减少数据带宽消耗。
然后,把一次一篇文章改成批处理,每次1000篇文章,如果是新词需要create记录,就立即执行,如果是旧词需要update,就把需要新增的倒排项暂存到内存里,然后1000篇文章的新倒排项全部按词合并后,再集中update hstore存入数据库。一批1000篇文章的总耗时缩短到1分多钟。
这一优化是改为批量处理。
最后,把文章按id划分成若干个区间。同时启动7-8个进程,每个进程跑一个区间。【注2】
这一优化是并行化计算。
经过上述三方面优化,仅用半个多小时就完成了全部数十万篇文章,综合速度大约提升了1000倍。【注3】
【注1】:不要用 SearchIndex.objects.raw(sql) 因为ORM是lazy的,所以sql并不会执行,也就不会update hstore。
【注2】:此处是极为简单的并行化,并未考虑协同问题。此处有小几率出现create冲突,即两个进程都发现同一个单词没有对应记录,于是都执行create去创建,因为word是unique约束,所以有一个会失败。但是考虑到这种概率并不大(实际只遇到一次),可以简单重跑。
【注3】:如此高吞吐量写库会让PG的空间占用迅速增长,所以如果用的是云服务器,注意提前扩容,做完后再降下来。在上面的例子中,文章内容数据量150MB左右,索引构建完毕后也就不到100MB,但是构建过程中云服务的存储消耗迅速飙升爆掉了100GB的预留空间。