首页 / 资讯中心 / 文章详情

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

几万条村务公开信息要在手机端秒回,检索方案到底怎么选? ★ FEATURED ARTICLE
一、一个村里搜低保要等三秒的下午去年秋天我们去一个县里做信息公开栏目的现场支持村务公开的数据已经攒到四万八千多条分成公告、财务、党务三大类。村委的同事拿手机在页面上搜「低保」两个字输入框转了三秒多才有结果刷出来中间还报过一次请求超时。那天下午我们连着调试工具反复跑了十几遍平均耗时二点九秒最久的一次到了五秒一用户看到的就是一个空白的列表骨架一直在那里转圈。数据量其实不算离谱。四万八千条记录字段也就标题、正文、分类、发布时间、来源单位这五六个正文平均两百四十个汉字全表算下来不到一百二十兆。这个体量放在任何一台普通配置的服务器上都不该是瓶颈问题出在我们最初写检索的那行 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 和 categorycategory 分业务词、单字、数字三类数字类用来兜住「二〇二四」这种年份查询。两张表的数据量分别是九万行和一千二百行合起来比业务主表还小备份和迁移的负担几乎可以忽略。切分规则是顺序扫描加最长匹配。给定一句话从左到右拿业务词表去匹配匹配得上就切出来匹配不上就按单字切单字也入倒排但权重压到零点零五业务词给一点零。这套做法不追求通用分词精度「低保金的发放」会被切成「低保」「金」「的」「发放」可对我们有意义的那几个词一个都不会漏。切分在写入时做一次结果落进倒排表查询时对搜索词做同样的处理。分页的口径必须写死。列表默认二十条一页翻页上限设在第五十页也就是最多能看一千条超过之后前端把下一页按钮置灰提示用户把搜索词写细一点。深翻的性能问题不靠优化解决靠不让它发生因为在五千条候选上排序还好在五万条候选上排序就会在内存里抖出几十毫秒那种抖动很难提前压住。六、踩过的三个坑第一个坑是倒排表和业务表不同步。现象是有一次财务公开批量导入了一千四百条记录脚本绕过了写入服务直接进了业务表倒排表里没有这批数据用户搜「产业奖补」一条都搜不到而列表页里明明看得到。根因是倒排的写入挂在服务层谁绕过服务谁就绕过了倒排。改法是两件事服务层的写入同步照旧保留另外加一个每日凌晨的对账任务按 doc_id 区间比对两边的记录数缺的补上多的删掉。第二个坑是搜索词过长导致误命中。现象是有人把一整段话粘进搜索框五十多个字结果返回了几千条排序还很靠后用户以为搜到了一堆没用的东西。根因是长词被切碎之后产生了三十几个单字单字权重虽然低但条数多累加起来把真正相关的记录压了下去。改法是对搜索词先做长度截断有效词长限制在二十个汉字以内超出部分只取前一段同时把单字命中的权重再压一档。第三个坑是深翻页把数据库拖垮。现象是有人在列表页一直点下一页点到第三十多页时接口耗时从一百二十毫秒涨到一秒多同一时刻别的查询也开始变慢。根因是 offset 越大数据库要先扫过前面那些行再丢掉第九百行到第九百二十行的查询实际上处理了近千行。改法是三个动作翻页上限收到第五十页超过之后引导用户细化搜索词回表取数改成按 doc_id 游标向前走而不是用 offset。七、这套检索表管不了什么相关度排序做得很粗。我们只用了词权重和位置两个因子没有做词频归一化也没有做同义词扩展搜「危房」搜不到「危旧房改造」的记录除非两个词都录进了词表。真正的相关度模型需要文档长度归一化和逆文档频率那是独立检索引擎的活我们这张表不打算做词表里补同义词是更省事的办法效果也够用。数据规模也有天花板。按现在的实测倒排表在十万条记录以内单次检索的响应稳定在八十毫秒以下到三十万条时开始出现两百毫秒以上的毛刺因为词表命中后的候选集太大排序和回表都要更多内存。真到那个量级该换的就是独立检索引擎了这张表的价值是把换的时间点往后推了两年而不是替代它。还有一层是权限检索表本身不感知。村务公开里有部分记录是限定范围的比如只对村内公开的财务明细我们的做法是在检索前先用权限过滤出一个记录编号白名单倒排命中的结果要落在白名单里才算数。过滤是业务层做的检索表只认编号。这一层如果做错漏出去的就是敏感信息所以我们宁可把白名单算得慢一点。八、小结回过头看这件事的转折点不在于用了什么高级结构而在于承认 like 加通配符的那条路不该出现在有一定数据量的检索里。我们把代价算清楚之后选了一条最不花哨的路用一张九万行的倒排表加一张一千二百行的业务词表换来的是四万八千条记录下平均八十六毫秒的响应晚高峰单分钟四十次并发里没有再出现过超时。万村乐数字乡村里这套检索建在业务库旁边不额外占机器几万条数据下响应一直稳定从上线到现在七个多月检索链路上报的错误一共十一条其中九条是搜索词为空这种参数问题跟检索本身无关。往后再接新的数据类型我们会先把词表补一轮再考虑动结构这两件事的先后顺序是这两年最不后悔的一个判断。
阅读完成 · 觉得有帮助?
咨询建站