Backend

生产数据库占用 3 GiB,逐项瘦身并放弃了一项优化

2761 字约 10 分钟

摘要Job Market Monitor 上线不到三周,生产库已经有 3.15 GiB。先把监控采样改成近期原始、早期汇总,再重新设计采集批次编号、趋势分类组合和模型结果三处存储,一项正文拆表的方案实测收益太小,被放弃。最终从 2.76 GiB 降到 2.32 GiB,查询没有变慢。

Job Market Monitor 是我维护的一个全网招聘信息追踪项目:爬虫每天抓取各大互联网公司官网上的职位描述,记录岗位的上下线和内容变化,再用大模型生成岗位画像。它部署在一台和其他应用共用的生产服务器上,数据库用的是服务器里现成的共享 PostgreSQL。

上线不到三周,我看服务器磁盘时发现,这个项目的数据库已经有 3.15 GiB,而整台服务器剩余的空间只有 18 GiB 左右。这个增长速度让我有点不放心,于是让助手先查清楚空间到底花在了哪里。

空间用在了哪里

助手只读地统计了每类数据的大小,包含各自的索引。

内容占用说明
岗位历史观测547 MiB约 258 万条,每天记录采集到了哪些岗位
运维监控采样523 MiB约 185 万条,只保留最近 7 天
两类岗位搜索表528 MiB为搜索准备的副本,含全文检索索引
岗位历史版本362 MiB约 14.4 万个不同内容的版本
岗位当前资料209 MiB约 8.6 万个岗位
模型调用记录197 MiB生成画像时留下的调用记录
岗位画像结果191 MiB已生成的结构化画像

最先让我意外的是监控采样。它只保留 7 天,过期清理也在正常工作,已经累计删掉了约 300 万条,但还是占了第二名。原因是系统每分钟都会把每个采集渠道的状态存一遍,这类记录占了监控数据的 74%,而它们的值变化得很慢,同一个渠道连续几个小时都是同一个状态。

先动监控采样

我的要求是实时检查和告警的频率不能降,所以不缩短保留时间,改成“近期看细节,早期看趋势”。最近一天保留每分钟的原始数据,更早的数据按 15 分钟汇总,一共仍然保留 7 天。页面上也加了均值、最大值和最小值的切换,渠道状态这类离散的值用阶梯图显示。

监控这部分从 523 MiB 降到了 116 MiB,数据库一下子回到约 2.76 GiB。

删了数据,磁盘不会立刻变小

PostgreSQL 删除记录之后,空出来的空间一般留在表文件内部等着复用,操作系统看到的占用并不会下降。要让磁盘真正变小,需要重写表文件。所以每次优化之后,都要再单独做一次“整理表文件”,实际占用才会跟着降下来。

从表设计重新想一遍

监控处理完,我还想继续压,于是问助手能不能从数据库表设计的角度,减少冗余,但查询不能变慢,最好还能更快。我要求它先从理论上推测哪几项既省空间又不拖慢查询,再一项一项做,每一项单独提交。

它列了四个方向,分别是采集批次编号、岗位正文、趋势分类组合和模型结果。其中岗位正文那一项最后没有采用,放在后面讲。其余三项按实现的顺序记录如下。

采集批次编号

历史观测表有 258 万行,每行都存着一个 36 个字符的采集批次编号,也就是文本形式的 UUID。实测还发现,同一个批次里所有观测的时间完全一样,这个时间在每一行里重复存了几百万次。

做法是把批次编号改成 PostgreSQL 原生的 UUID 类型,对外的字符串形式保持不变,同时调整索引,让常用的批次查询直接从索引里拿到岗位和版本编号,少回表读一次。这一项涉及 9 张表,改完后逐行核对了记录数和内容,257 万条观测一条不差,节省约 157 MiB。

原生 UUID 还带来一个小麻烦。Python 这一侧从数据库读出来的值类型变了,几处拿它做比较和缓存键的地方需要转回字符串,否则采集批次的判断会出错。这是在隔离的恢复库里跑测试时发现的。

趋势里的分类组合

趋势汇总表有 44 万行,却只对应约 7,500 种不同的“岗位方向、细分方向、招聘对象”组合,同一个组合被重复存了几十次,每次还带着一长串数组。

做法是新建一张分类组合的字典表,每种组合只存一次,汇总表只记录“批次、组合编号、岗位数量”。441,410 行重复的组合压成了 7,488 条字典记录,这部分的存储从 103 MiB 降到 35 MiB。

但这一项第一次实测就出了问题,长时间范围的趋势查询变慢了,而且可以稳定复现。看执行计划后发现,数据库对同一个分类字典做了十几万次重复查找。我把查询改成先对每个分类组合判断一次是否符合筛选条件,再汇总对应的数量,不带筛选的趋势则直接用已经校验过的快照总数。改完之后,30 天和 90 天的趋势比原来更快了。

通俗比喻:把重复的地址抄成通讯录

原来每张快递单都完整地抄一遍收件地址,同一个地址抄了几十次。改成先建一本通讯录,每个地址只写一次,快递单上只写通讯录里的编号。

省纸是显而易见的。可是如果每查一张单子,都要把通讯录从头翻一遍,反而更慢。得先把通讯录里每个地址“符不符合条件”算一遍,再去数每个编号对应多少单。

模型结果

岗位画像是模型生成的 JSON。同一份成功结果,会同时存在任务表、调用记录和复用记录里,我之前测过重复的输出大约有 103 MiB。

做法是把完整的成功结果放进一张独立的、不可修改的结果表,任务、调用和复用记录只引用结果的编号。这里有两个我比较在意的地方。

  • 判断两份结果是否相同,先用规范化 JSON 的 SHA-256 找候选,再逐字比较内容确认,不会因为哈希碰撞把两份不同的结果当成同一份。
  • 调用记录引用的是不可变的结果,而不是一条可能被重试覆盖的任务记录。即使任务后来换了结果,之前的调用记录指向的内容也不会变,所以审计信息不会被悄悄改写。

运行账号只给这张表 SELECT 权限,采集账号可以 INSERT,UPDATE、DELETE 和 TRUNCATE 都被收回。迁移后核对了 12.9 万条任务、11.5 万条调用记录和 1.5 万条复用记录,内容全部一致。这一项又省了约 105 MiB。

公司筛选的查询计划

前三项都改完之后,我让助手拿相同的数据、相同的资源对比新旧结构。其他查询都没问题,只有按公司筛选的搜索出现了可以重复的变慢。

原因在执行计划里:数据库的关联顺序变了,它先搬运了大量岗位正文,甚至读取了不需要的历史观测,最后才按公司过滤。修法是把顺序明确下来,先确定有效采集批次里的岗位范围,再去取正文。

助手一开始还试过把这种“先缩小范围”的策略全局套用,结果宽范围的全文搜索直接超时,所以这个方案被拒绝了,只在带公司、来源或渠道限定的查询里使用。

被放弃的一项

岗位正文拆表,是四项里从数字上看最诱人的一项。统计发现,14.4 万个历史版本里,“职责加要求”完全相同的正文只有约 7.6 万组,理论上能省掉接近一半。

实测的结果是只省了约 11 MiB,同时搜索还变慢了。PostgreSQL 对较长的文本字段本来就会压缩存储,重复的内容压缩之后已经占不了多少空间,拆表带来的额外关联却是实打实的。所以这一项没有采用。

我觉得这个结果比前三项更有参考价值。“重复存储”和“占用空间多”并不是一回事,字节数重复得再多,只要存储层已经处理过,就不能直接按重复量估算收益。

上线和结果

这次改动会改变表结构,旧版本的程序无法读取新结构,所以上线时需要短暂停止本项目的后端和采集服务,共享的 PostgreSQL 和其他应用不受影响。流程是这样的:

  1. 在一份完整恢复出来的隔离库里先跑通迁移、数据核对和接口对照。
  2. 生产上先做迁移前备份,再构建正式镜像和一个能读取新结构的兼容回退镜像。
  3. 停服务后,逐表计算迁移前的数据指纹,在一个事务里完成迁移,再整理表文件,重新计算指纹确认一致,最后恢复服务。
阶段数据库大小
上线不到三周约 3.15 GiB
监控采样改为近期原始、早期汇总约 2.76 GiB
三项结构优化加表文件整理约 2.32 GiB

后面这一步释放了约 445 MiB,其中约 332 MiB 来自结构本身,其余来自整理表文件时去掉的碎片。没有删除任何历史,也没有缩短保留期。

查询方面,我让助手在同一份恢复数据、没有应用缓存的情况下比较新旧结构,15 组接口的完整响应哈希全部一致,岗位列表和翻页的耗时大约减半,30 天和 90 天的趋势明显更快。

这些耗时的适用范围

这些对比是在一个限制为 1 个 CPU 的恢复库上,直接调用处理函数测得的,没有经过 HTTP 和应用缓存,只能说明新结构没有稳定变慢,不等于线上用户感受到的耗时会减半。

还有一点要留意。岗位历史观测每天大约新增 6.8 万条,光这一张表加索引,每个月就会增加约 0.4 GiB。这次只是把存量压小了,长期还是需要为历史数据设计归档的办法。

评论