单库表太多对 PostgreSQL 的危害
relcache 每后端内存膨胀、系统目录变大、元数据操作变慢——诊断并治理表数量爆炸
一个有几十万张表的数据库,通常是通过三条路之一走到这一步的:
- 每租户一套表:每个客户拥有自己的一组表(
tenant_1234.orders、tenant_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_class、pg_attribute、pg_index、pg_constraint、pg_depend、pg_description、pg_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_mem 或 shared_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 上线恰恰是最值得演练的变更。
相关页面
- 行级安全配置 → 安全
- 表结构布局选型 → 数据建模
- 连接与内存预算 → 服务器配置
- 当元数据内存撞上 cgroup limit → 容器环境下 PostgreSQL 的内存管理与 OOM 防治
Last updated on