做者: jiaminchen,微信终端开发团队的一员
本文首次发表在《程序员》杂志 2017 年 09 月期。程序员
基于本地数据的全文搜索(Full-Text-Search,FTS)在移动应用上扮演着重要的角色。与基于服务端提供的搜索服务不一样,移动端受硬件条件限制,尤为在数据量相对较大的状况下,搜索性能问题表现得十分突出。本文以移动平台普遍采用的SQLite FTS Extension为例,介绍了移动平台FTS的基本原理,结合微信安卓客户端自身实践,重点讲述微信在FTS上的一些性能优化经验。数据库
SQLite FTS Extension是SQLite为全文搜索开发的一个插件,它是内嵌在标准的SQLite分布版本当中,它具备以下的特色:性能优化
搜索速度快:使用倒排索引加速查找过程微信
稳定性好:目前SQLite在移动端的稳定性比较好,FTS Extension就是SQLite的基础上搭建的数据结构
接入简单:Android和IOS平台自己就支持SQLite,而且FTS Extension的使用就和正常使用SQLite表同样。架构
兼容性好:受益于SQLite自己兼容性很好,SQLite FTS Extension也有很好的兼容性。函数
目前SQLiteFTSExtension发布了5个版本,我简单说下三个主流的版本。性能
FTS3:基础版本,具备完整的FTS特性,支持自定义分词器,库函数包括Offsets,Snippet。学习
FTS4:在FTS3的基础上,性能有较大优化,增长相关性函数计算MatchInfo。字体
FTS5:和FTS4有较大变更,储存格式上有较大改进,最明显就是Instance-List的分段存储,可以支持更大的Instance-List的存储;而且开放ExtensionApi,支持自定义辅助函数。FTS5发布于2015年中。
微信全文搜索在2014 年末上线,最初主要服务于联系人和聊天记录的业务搜索。在方案设计之初,为了让这个功能有很好的体验,同时考虑到将来接入业务的会不断增多,咱们设计目标是:
微信全文搜索使用SQLite FTS4 Extension,经过倒排索引提升搜索速度。
微信的核心业务是联系人和消息,而微信全文搜索不管是在创建索引、更新索引或者删除索引时,都须要处理大量数据,为了使得全文搜索不影响微信的核心业务,采用以下的存储架构:
独立DB、读写分离:微信全文搜索在总体架构上独立于主业务,搜索DB也是独立于主业务DB;当主业务数据发生更新时,主业务经过EventBus方式通知搜索对应的业务数据处理模块,业务数据处理模块会经过一个独立的ReadOnly数据库链接接访问主业务数据库,不和主业务存储层共享数据库链接。
减小数据库操做:在搜索模块中,会有专门处理业务数据的模块,对一些复杂的数据结构作一些特殊的处理。例如对于一个500成员的群聊,若是把500个群成员分次插入搜索DB当中,会形成过多的数据库操做。因此,微信会把全部的群成员拼接为单个字符串,插入搜索DB中。
热数据延迟更新: 针对更新频率很是高的热数据,采用延迟更新的策略。全部的索引数据分为正常数据和脏数据。当数据发生更新时,先把对应的数据标记为脏数据,而后有一个定时器,每隔10分钟,把数据更新到索引中。
高可扩展性要求搜索表结构和业务解耦。SQLite FTS官网上的例子,都是以单索引表的方式,每一列对应业务的某一个属性,当对应业务发生变化,须要修改索引表的结构。为了解决业务变化而带来的表结构修改问题,微信把业务属性数字化,设计以下的表结构:
IndexTable负责全文搜索的索引创建,它和逻辑无关,当搜索关键词时,只须要找到对应的DocId便可。MetaTable负责业务逻辑的过滤,经过Type和SubType来过滤对应业务的数据,最后输出BusItemId。
微信全文搜索于2014年1月26日5.4版本上线,到2017年春节后的6.5.7版本,整体用户量从4亿增长到9亿,重度用户数量也大幅度增加,微信本地搜索的数据量也大幅度增加,形成了搜索速度不断降低,用户投诉不断增长。咱们统计过,从微信5.4版本到6.5.7版本,微信全文搜索各个任务的平均搜索时间增加超过10倍,给微信全文搜索带来巨大挑战。
为了优化搜索时长,先看下搜索的流程图:
经过每一个阶段的耗时,发如今取数据阶段,时间占比达到80%以上,而且搜索的结果集数据量越大,时间占比越高,最高能够达到95%。取数据阶段是一个循环的过程,因此优化一个循环须要从两方面着手,减小单次循环耗时和减小整体循环次数。
深刻SQLite FTS4 Extension源码,发现FTS4的库函数Offsets耗时占单次循环执行耗时70%以上,而且数据量越大耗时越长。
FTS4库函数Offsets:用于把词语偏移转为字节偏移,微信当中使用字节作结果排序和结果高亮。
函数输入:
Query:用户查找的关键词
命中Doc:关键词所命中的文档。文档就是全文搜索中的基本单位,能够是一个网页,一篇文章或者是一条聊天记录
目标词语偏移:在搜索阶段,经过关键词查找搜索索引能够拿到目标词语偏移
函数输出:
例如:
Query=我 命中Doc=我和我弟弟去逛街 目标词语偏移=0、2
把命中Doc通过分词器分词,能够获得下表:
最后计算能够得出目标字节偏移=0、6
下图是Offsets函数处理命中Doc字节数和耗时的关系:
Offsets函数的处理过程当中包括分词,因此第一步就优化分词器。
要优化分词器,分词规则是重中之重。微信的分词规则为英文和数字合并分词,非英文和数字单独分词。举个例子,如对于昵称“Hello520中国”,分词结果为“Hello”、“520”、“中”、“国”。这个分词规则的缘由主要是在微信对全文搜索的结果排序需求主要是其余的属性排序,并不是依据文档的相关性排序。即,全文搜索部分只须要找到存在关键词的文档,并不关心文档中存在几个关键词。并且用户的输入Query大部分状况都不能组成词语,存在方言,因此把整个词语所有拆开创建索引是符合需求的。
微信全文搜索最先开发于2013年末,FTS4是SQLite FTS Extension的最高版本,可是FTS4自带的分词器不能很好的支持中文,只能使用ICU分词器,当时ICU分词器的接入比较简单,对中文支持较好,因此使用了ICU分词器。
对于昵称“Hello520中国”输出分词器中,开始是UTF8编码,分词器会作一次转化为Unicode编码,接着查找词典,最后进行后处理获得分词结果。从输入输出中能够发现,转化编码和查找词典这两步实际上是多余的,因此微信舍弃ICU分词器,自定义了Simple分词器。
Simple分词直接处理的UTF8编码的Doc内容,经过单个char,判断当前字符的Unicode编码范围和Unicode编码长度,根据不一样的状况作出不一样的处理。
通过分词器优化后Offsets函数耗时在处理10万Byte的耗时下降为21ms,可是这样的优化还不够,当处理超过10个10W结果Doc时,仍然会超过200ms,因此有了下一步的优化。
在移动端因为屏幕的限制,每每在最后显示搜索结果时,只会高亮少许命中的关键词,而Offsets函数会计算命中Doc中全部目标词语偏移,因此须要对Offsets函数进行改造。
最开始我尝试的方案是直接修改Offsets函数源码,发现FTS4对API的封装比较难使用,Offsets函数的依赖也比较多,修改出来的代码很难维护,可读性也很差,因此须要寻找新的方法来优化。在一番研究之后,我发现FTS5支持自定义辅助函数,而且有比较好的API的封装,因此最后使用FTS5自定义辅助函数(MMHighLight)从新实现Offsets函数的功能,并加入优化逻辑。
输入:Query=我 命中Doc=我和我弟弟去逛街 目标词语偏移=0、2 目标返回个数=1
分词器分步回调,当分词器第一次返回“我”,符合目标词语偏移的第一个0,而且此时已经知足目标返回个数1个,函数直接返回目标字节偏移=0。
减小取数据阶段的整体循环次数,比较容易想到的就是在SQL层作数据的分页返回,分页返回就意味着须要在DB层排序,在DB层排序的决定因素就是排序因子。可是微信全文搜索面对的业务排序因子多而且复杂,没法直接使用SQL中的ORDER BY,因此须要经过一个中间函数转化,把全部的排序因子经过一个可比较的数字体现,最后再使用ORDER BY排序。
这里简单说下,比较复杂的排序因子以下:
时间分段排序:时间范围在半年内,排序因子取决于下一级排序因子,时间范围在半年外,取决于时间的远近。
函数结果排序:排序因子是一个函数计算的结果,不是一个直接的数据库Column,而且函数计算结果不可直接使用ORDER BY,例如字符串形式的数字。
经过以上的分析,减小整体循环次数的核心点就在于,把Java层的排序转移到SQL层去作,优势以下:
减小I/O
减小C层到Java层的数据拷贝
因此这里关键的实现点在于中间转化函数的实现,微信的中间转化函数MMRank是经过FTS5的辅助函数实现的。
MMRank的实现原理就是经过把全部的排序因子转化到一个64位的Long数值当中,高优先级的排序因子置高位,低优先级的排序因子置低位。最后的SQL以下:
微信全文搜索中有一个比较特殊的搜索任务,就是聊天记录。
如图所示:
图中的红色圈内的数字表示,此会话中,包含关键字“我”的聊天记录的个数,而会话的排序规则就是会话的活跃时间。
微信聊天记录的搜索有一下两个特色:
有统计属性
数量很是多(单关键词命中最高可达到20万条)
从搜索流程图中能够看出,微信最初采用的方案是在Java层统计个数和排序,此方法在大数据的状况下不可取。鉴于以前分析过减小循环次数能够经过分页返回,其核心点在于把排序从Java层转移到SQL层,因此就有了优化方案一。
实现SQL以下:
此方案经过Group By在SQL层直接统计出命中聊天记录的个数,并按照最近的时间排序,可是也有明显的缺陷:
没法使用索引加速:当GroupBy和OrderBy同时使用是,OrderBy中必须包含GroupBy的字段才能够命中索引,缘由是使用GroupBy会生成中间子表。
全量计算:GroupBy在SQL层统计命中聊天记录个数是统计了全部会话,上图中只须要统计3个会话,浪费了大量资源。
鉴于方案一全量计算的问题,采用分步计算的方式。
第一步:找出最近活跃的3个会话
获得会话conv1,conv2,conv3,而后执行如下SQL,能够分别获得三个会话的命中个数
可是这种方法也存在问题,须要执行多条SQL。
鉴于方案二须要多条SQL的问题,能够经过自定义聚合函数实现一次性统计。执行步骤以下:
第一步:找出最近活跃的3个会话
获得会话conv1,conv2,conv3,而后执行如下SQL
能够一次性获得三个会话的命中个数。
通过优化后,微信全文搜索全体用户各个任务平均耗时都在50ms如下,而重度用户各个任务的平均搜索耗时都在200ms如下,平均时间优化的幅度达到5倍以上。
后续还有不少值得优化的地方,例如,在计算高亮时,若是在DocList的数据结构中,直接加入字节偏移,那么还能够节省一部分时间。
最后但愿个人分享可以对你们有些价值,欢迎留言交流。
微信“ 15。。。。。。。。。”前因后果
腾讯的一个应用服务,让全国短信诈骗发案率降低74%…
微信OCR(2):深度序列学习助力文字识别
此文已由做者受权腾讯云技术社区发布,转载请注明文章出处原文连接:https://cloud.tencent.com/community/article/381004