生产数据库占用 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 和其他应用不受影响。流程是这样的:
- 在一份完整恢复出来的隔离库里先跑通迁移、数据核对和接口对照。
- 生产上先做迁移前备份,再构建正式镜像和一个能读取新结构的兼容回退镜像。
- 停服务后,逐表计算迁移前的数据指纹,在一个事务里完成迁移,再整理表文件,重新计算指纹确认一致,最后恢复服务。
| 阶段 | 数据库大小 |
|---|---|
| 上线不到三周 | 约 3.15 GiB |
| 监控采样改为近期原始、早期汇总 | 约 2.76 GiB |
| 三项结构优化加表文件整理 | 约 2.32 GiB |
后面这一步释放了约 445 MiB,其中约 332 MiB 来自结构本身,其余来自整理表文件时去掉的碎片。没有删除任何历史,也没有缩短保留期。
查询方面,我让助手在同一份恢复数据、没有应用缓存的情况下比较新旧结构,15 组接口的完整响应哈希全部一致,岗位列表和翻页的耗时大约减半,30 天和 90 天的趋势明显更快。
这些耗时的适用范围这些对比是在一个限制为 1 个 CPU 的恢复库上,直接调用处理函数测得的,没有经过 HTTP 和应用缓存,只能说明新结构没有稳定变慢,不等于线上用户感受到的耗时会减半。
还有一点要留意。岗位历史观测每天大约新增 6.8 万条,光这一张表加索引,每个月就会增加约 0.4 GiB。这次只是把存量压小了,长期还是需要为历史数据设计归档的办法。