拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

几万条村务公开信息要在手机端秒回,检索方案到底怎么选?

几万条村务公开信息要在手机端秒回,检索方案到底怎么选?

一、一个村里搜低保要等三秒的下午

去年秋天我们去一个县里做信息公开栏目的现场支持,村务公开的数据已经攒到四万八千多条,分成公告、财务、党务三大类。村委的同事拿手机在页面上搜「低保」两个字,输入框转了三秒多才有结果刷出来,中间还报过一次请求超时。那天下午我们连着调试工具反复跑了十几遍,平均耗时二点九秒,最久的一次到了五秒一,用户看到的就是一个空白的列表骨架一直在那里转圈。

数据量其实不算离谱。四万八千条记录,字段也就标题、正文、分类、发布时间、来源单位这五六个,正文平均两百四十个汉字,全表算下来不到一百二十兆。这个体量放在任何一台普通配置的服务器上都不该是瓶颈,问题出在我们最初写检索的那行 SQL 上,条件里直接挂了两个 like,前后都带通配符,数据库没有索引可以借力,只能老老实实一行一行读。

查询频次也有它自己的脾气。后台一个月的检索日志统计下来,日均查询一千一百次左右,高峰集中在晚上七点到九点,单分钟并发能冲到四十次以上。单条查询本身并不重,可每一条都要扫一遍全表,连接池很快被占满,同一时段里别的事务开始排队,财务公开列表页在晚高峰跟着一起变慢,根子就在这里。

二、慢的根因是全表扫描而不是数据量

把慢查询日志拉出来看,单条检索的命中率只有千分之三左右,四万八千条里平均十几条能匹配上「低保」。数据库对前后都带通配符的 like 用不上索引,只能一行一行把正文读出来做子串比较,读了一万行才留下十几行,剩下九千九百多行的读取全是白费。执行计划里 key 那一列是空的,访问类型写的是 ALL,预估扫描行数就是整张表的行数。

直觉上的第一反应是加索引。我们最初的讨论里也这么想过,标题上建一个普通索引,分类上再建一个联合索引,至少先按分类把候选范围缩下来。可业务上的搜索框是全局的,用户输一个词不会先去选分类,标题匹配和正文匹配又必须同时成立,索引在「正文任意位置包含」这种语义下帮不上忙,最多在标题上做一次前缀匹配,覆盖面连三分之一都到不了。

更麻烦的地方在于这种写法把成本压在了连接和缓存上。一次查询要扫一百二十兆的数据页,一旦缓存命中率下滑就得读磁盘,单条查询的响应时间随并发数上升近似平方级恶化。表面上看到的是搜索慢,实际发生的是把一次检索变成了一次全表扫描,而这个代价跟搜索词几乎没有关系,词长词短都差不多慢,因为执行计划压根没用到那个词。

三、三条路各自要付的代价

第一条路最省事,继续用 like,但把正文单独抽到一张检索宽表里,字段只留记录编号和一段拼好的纯文本,让被扫的数据瘦下来。改造大概两个工作日,代价是治标不治本,扫描的行数少了,复杂度还是线性的,数据量翻一倍,耗时跟着翻一倍。我们按现在的增长曲线估了一下,两年内涨到二十万条,这条路会重新回到两秒以上,等于把问题往后推了一年。

第二条路是用数据库自带的全文索引。MySQL 在 InnoDB 上提供 ngram 解析器,把 ngram_token_size 配成二就能在中文上做二元切分,查询换成 match 加 against 的写法,性能改善是实打实的。代价有两处,一是加全文索引要改表结构,四万八千条建索引的过程会压住写入,只能挑维护窗口做;二是二元切分不看词边界,「宅基地使用权」会被切得很碎,召回里混进不少不相关的记录。

第三条路是引入一个独立的检索引擎,倒排、分词、相关度排序都是现成的,能力上能把前面的问题一次解决。代价是我们得多运维一个组件,多一套进程和一份常驻内存,还要处理它跟业务库之间的同步。这套系统部署在县里的内网机房,运维只有一个人,加一个中间件意味着开机自启、日志轮转、故障恢复都得重新交代一遍。权衡到最后我们走了一条折中的路,在业务库内部自己维护一张倒排表。

四、先分区再倒排的两层结构

结构分两层。第一层是业务分区,四万八千条记录按公告、财务、党务分成三个分区,写入时就带上分区编号,检索时先由用户当前所在的栏目定下要看哪几个分区,候选集合从四万八千缩到一万六千上下。第二层是倒排表,每个词占一行,记下这个词出现在哪些记录的哪个位置上,命中之后按记录编号回原表取标题和正文,两层的职责切得很干净。

这套检索表结构,是万村乐数字乡村当初为了一个县客户的信息公开栏目补出来的,落点就在业务库旁边,跟主库共用一台实例,不引入新进程。做倒排的时候我们把分词词表换成了业务词表,收的是「低保」「宅基地」「危房改造」「产业奖补」这类村里真正会搜的词,再加上单字兜底,一共一千二百多个词条,比通用词典小了两个数量级,召回质量反而更贴合。

回表这一步同样做了取舍。倒排表里只存词编号、记录编号、位置和权重,不存正文,正文改了只动倒排不动原表。查询时先用倒排把候选记录编号算出来做一次加权排序,再按排序结果批量取前若干条正文,一次取二十条,避免为了排序把整段正文都读进内存。这套结构上线之后,四万八千条数据下的检索耗时稳定在八十六毫秒上下。

五、词表、切分与分页的具体参数

倒排表叫 t_search_index,一共六个字段,token_id、doc_id、part、pos、weight 和 updated_at,联合索引建在 token_id 与 weight 上。词表 t_search_token 存 token_id、word 和 category,category 分业务词、单字、数字三类,数字类用来兜住「二〇二四」这种年份查询。两张表的数据量分别是九万行和一千二百行,合起来比业务主表还小,备份和迁移的负担几乎可以忽略。

切分规则是顺序扫描加最长匹配。给定一句话,从左到右拿业务词表去匹配,匹配得上就切出来,匹配不上就按单字切,单字也入倒排但权重压到零点零五,业务词给一点零。这套做法不追求通用分词精度,「低保金的发放」会被切成「低保」「金」「的」「发放」,可对我们有意义的那几个词一个都不会漏。切分在写入时做一次,结果落进倒排表,查询时对搜索词做同样的处理。

分页的口径必须写死。列表默认二十条一页,翻页上限设在第五十页,也就是最多能看一千条,超过之后前端把下一页按钮置灰,提示用户把搜索词写细一点。深翻的性能问题不靠优化解决,靠不让它发生,因为在五千条候选上排序还好,在五万条候选上排序就会在内存里抖出几十毫秒,那种抖动很难提前压住。

六、踩过的三个坑

第一个坑是倒排表和业务表不同步。现象是有一次财务公开批量导入了一千四百条记录,脚本绕过了写入服务直接进了业务表,倒排表里没有这批数据,用户搜「产业奖补」一条都搜不到,而列表页里明明看得到。根因是倒排的写入挂在服务层,谁绕过服务谁就绕过了倒排。改法是两件事,服务层的写入同步照旧保留,另外加一个每日凌晨的对账任务,按 doc_id 区间比对两边的记录数,缺的补上,多的删掉。

第二个坑是搜索词过长导致误命中。现象是有人把一整段话粘进搜索框,五十多个字,结果返回了几千条,排序还很靠后,用户以为搜到了一堆没用的东西。根因是长词被切碎之后产生了三十几个单字,单字权重虽然低,但条数多,累加起来把真正相关的记录压了下去。改法是对搜索词先做长度截断,有效词长限制在二十个汉字以内,超出部分只取前一段,同时把单字命中的权重再压一档。

第三个坑是深翻页把数据库拖垮。现象是有人在列表页一直点下一页,点到第三十多页时接口耗时从一百二十毫秒涨到一秒多,同一时刻别的查询也开始变慢。根因是 offset 越大,数据库要先扫过前面那些行再丢掉,第九百行到第九百二十行的查询实际上处理了近千行。改法是三个动作,翻页上限收到第五十页,超过之后引导用户细化搜索词,回表取数改成按 doc_id 游标向前走而不是用 offset。

七、这套检索表管不了什么

相关度排序做得很粗。我们只用了词权重和位置两个因子,没有做词频归一化,也没有做同义词扩展,搜「危房」搜不到「危旧房改造」的记录,除非两个词都录进了词表。真正的相关度模型需要文档长度归一化和逆文档频率,那是独立检索引擎的活,我们这张表不打算做,词表里补同义词是更省事的办法,效果也够用。

数据规模也有天花板。按现在的实测,倒排表在十万条记录以内,单次检索的响应稳定在八十毫秒以下,到三十万条时开始出现两百毫秒以上的毛刺,因为词表命中后的候选集太大,排序和回表都要更多内存。真到那个量级,该换的就是独立检索引擎了,这张表的价值是把换的时间点往后推了两年,而不是替代它。

还有一层是权限,检索表本身不感知。村务公开里有部分记录是限定范围的,比如只对村内公开的财务明细,我们的做法是在检索前先用权限过滤出一个记录编号白名单,倒排命中的结果要落在白名单里才算数。过滤是业务层做的,检索表只认编号。这一层如果做错,漏出去的就是敏感信息,所以我们宁可把白名单算得慢一点。

八、小结

回过头看,这件事的转折点不在于用了什么高级结构,而在于承认 like 加通配符的那条路不该出现在有一定数据量的检索里。我们把代价算清楚之后选了一条最不花哨的路,用一张九万行的倒排表加一张一千二百行的业务词表,换来的是四万八千条记录下平均八十六毫秒的响应,晚高峰单分钟四十次并发里没有再出现过超时。

万村乐数字乡村里这套检索建在业务库旁边,不额外占机器,几万条数据下响应一直稳定,从上线到现在七个多月,检索链路上报的错误一共十一条,其中九条是搜索词为空这种参数问题,跟检索本身无关。往后再接新的数据类型,我们会先把词表补一轮,再考虑动结构,这两件事的先后顺序是这两年最不后悔的一个判断。

返回列表