PostgreSQL Field Guide

单库表太多对 PostgreSQL 的危害

relcache 每后端内存膨胀、系统目录变大、元数据操作变慢——诊断并治理表数量爆炸

一个有几十万张表的数据库,通常是通过三条路之一走到这一步的:

  • 每租户一套表:每个客户拥有自己的一组表(tenant_1234.orderstenant_1234.invoices……),因为这样看起来隔离得干净;
  • 分区狂魔:几十张表全部按天分区且永久保留,每个分区又各自带着索引、约束和统计信息;
  • ORM 与工具失控:框架为每个实体版本、每张报表、每次导入任务建表,且从不清理。

三条路通向同一种故障形态,而且它是渐进的:5,000 张表时一切正常,50,000 张时某些东西说不清地慢,500,000 张时你在排查内存压力和以小时计的 pg_dump

为什么表多是病

relcache:按后端缓存的元数据

每个后端进程都维护自己的 relation cache——relcache——存放它访问过的每个关系的解析后元数据:tuple descriptor、索引、规则、触发器、统计信息指针。这是后端私有内存中的按进程缓存,首次访问时惰性填充,随后跟随后端整个生命周期(见 PostgreSQL 源码 src/backend/utils/cache/relcache.c)。由此得出两个结论:

  • 一张被触碰过的表,其内存代价每个后端各付一次,所以 200 个连接各自触碰 20,000 张表,元数据就存了 200 份;
  • 连接存活期间没有任何机制回收它,所以 session 模式下长命的连接池连接内存单调增长——在容器里,这正是最终以 OOM Kill 收场的那种匿名内存增长。

PostgreSQL 14+ 可以通过 pg_backend_memory_contexts 观察到这一点:元数据累积在 CacheMemoryContext 及其子上下文下,可以看着它随后端触碰的关系数增长。

系统目录变大,所有遍历它的操作都变慢

一张表不是一行目录记录。一张有五列、一个主键、一个索引的表,会向 pg_classpg_attributepg_indexpg_constraintpg_dependpg_descriptionpg_statistic 等目录各写入若干行——按系统目录的结构,每张表至少十几行。规模化的后果:

  • 元数据内省变慢:psql 的 \d、应用启动时 ORM 的 schema 反射、枚举目录的 GUI 工具;
  • pg_dump 要遍历并锁定每个关系,所以即使数据量不变,备份时长也随表数量增长;
  • 目录本身也会像普通表一样膨胀、需要 vacuum——巨大 pg_class 上的 autovacuum 是真实存在的负载。

多少算多

以下是经验值,不是文档化的阈值——实际的临界点取决于连接数、每张表的列数、以及每个后端触碰多少关系:

  • 几千张表:任何合理配置下都没问题;
  • 几万张:开始能感到——连接预热变慢、dump 变慢、relcache 内存在监控里可见;
  • 几十万张:事故区——后端持有数 GB relcache、容器内 OOM 风险、pg_dump 以小时计。

真正关键的乘数是 每后端触碰的表数 × 并发后端数,而不是原始表数。50,000 张表但每个请求只碰 50 张,远比 50,000 张表每个请求全碰便宜。

诊断

按类型统计关系数,排除系统 schema:

SELECT c.relkind, count(*)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
GROUP BY c.relkind
ORDER BY count(*) DESC;

relkind 含义:r 普通表,p 分区表,i/I 索引,S 序列,t TOAST 表,v/m 视图。如果计数被索引主导,底下的表仍是根因——每张表都拖着自己的索引。

找出表集中在哪里:

SELECT n.nspname,
       count(*) FILTER (WHERE c.relkind = 'r') AS tables,
       count(*) FILTER (WHERE c.relkind = 'i') AS indexes
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
GROUP BY n.nspname
ORDER BY tables DESC
LIMIT 20;

成千上万个 schema、里面的表名完全相同,就是每租户一套表的签名。

测量目录大小和单后端的元数据内存:

-- 最大的系统目录
SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) AS total
FROM pg_class
WHERE relnamespace = 'pg_catalog'::regnamespace AND relkind = 'r'
ORDER BY pg_total_relation_size(oid) DESC
LIMIT 10;

-- 当前后端的元数据内存(PG 14+;
-- 观察其他后端需要 superuser 或 pg_read_all_stats)
SELECT name, pg_size_pretty(sum(used_bytes)) AS used
FROM pg_backend_memory_contexts
GROUP BY name
ORDER BY sum(used_bytes) DESC
LIMIT 15;

CacheMemoryContext 随后端触碰过的不同关系数增长、且从不收缩,即可确认 relcache 代价。

治理路线

把每租户的表合并为共享表 + 行级安全(RLS)。 一张带 tenant_id 列和 RLS 策略的 orders 表保住了隔离保证,同时把表数量压缩几个数量级;见 Row Security Policies 和本站安全页。迁移本身是机械工作(把各租户的表 union 进去、加列、回填),但要在你自己的查询形态上测试 RLS 的执行计划——策略按查询生效,并与 tenant_id 上的索引相互作用。

刻意给分区数设上限。 分区粒度由保留周期和查询模式决定,而不是由日历习惯决定:按月而不是按天,旧分区 drop 或 detach,而不是让历史永远在线。详见下一节。

按库拆分。 如果租户确实需要硬隔离,每租户一个 database 比每租户一个 schema 更能限制爆炸半径——目录和 relcache 都是按 database 的,连接也是。代价是连接管理和跨租户报表。

清掉 ORM 忘记删的表。pg_stat_user_tables 的一个观察窗口内找出零扫描、零元组的表并删除;schema 卫生比上面任何一项都便宜。

与分区的边界

分区不是这个问题的逃生舱——分区也是表。每个分区有自己的 pg_class 条目、自己在每个触碰它的后端里的 relcache 足迹,通常还有自己的索引。声明式分区只是自动化了路由,元数据代价是累加的。

分区文档指出,规划器可以较好地处理几千个分区的层级,前提是分区剪枝在规划阶段就排除了其中绝大多数;当大量分区在剪枝后存活时,规划时间和内存消耗都会上升。所以实际的限制是叠加的:一张分了 5,000 个子分区的表、查询不带分区键过滤、再乘上 200 连接的连接池,等于最坏情况的规划代价乘上最坏情况的 relcache 放大。分区数尽量保持在几百以内,查询永远带分区键,让保留策略(DROP PARTITION / DETACH)——而不是存储容量——来决定多少分区保持在线。

共享表、schema、分区之间如何选择的数据建模视角,见数据建模

不要靠调大 work_mem 来修表数量爆炸

relcache 不受 work_memshared_buffers 约束;它住在每后端的私有内存里,没有任何配置上限。真正的杠杆只有:更少的表、每个查询触碰更少的表、更少的长命后端,或者更多内存——按这个顺序优先考虑。

审计我的数据库是否存在表数量问题
审计我的 PostgreSQL 18 数据库是否表数量过多。

背景:
- 约 <N> 张表,分布在 <M> 个 schema,<K> 个并发连接,经由 <PgBouncer/直连>
- 疑似模式:<每租户一套 schema / 重度分区 / ORM 失控>
- 容器内存 limit:<值>

1. 给出诊断查询:按 relkind 和 schema 的关系计数、目录大小、用 pg_backend_memory_contexts 观察 CacheMemoryContext。
2. 基于每租户一套 schema 的模式,起草迁移到共享表 + tenant_id + 行级安全的方案,包括回滚步骤。
3. 如果主因是分区,根据我的查询模式 <描述> 提出分区数上限和保留方案。
4. 列出迁移期间和之后要监控的指标,用于证明 relcache 内存和元数据延迟确实改善。

先在恢复出来的副本上验证方案——目录手术和 RLS 上线恰恰是最值得演练的变更。

相关页面

Last updated on

On this page