DeepSeek总结的如何在 PostgreSQL 中删除一张 4 TB 的表

如何在 PostgreSQL 中删除一张 4 TB 的表

2026-10-31

· 19 分钟 ·

Radim Marek

目录

GitLab 有一个关于删除错误东西的公开故事。2017 年 1 月,一位工程师试图恢复复制,擦除了一个他们认为是备库上的数据目录。它实际上在主库上。他们事后发布的尸检报告仍然是关于数据库故障的写得较好的文章之一。

上周在瓦伦西亚的 PGConf.EU 上,GitLab 的几个人带着相反的问题来找我。他们有一张表,4 TB,位于一个带有十个副本的主库上,他们想要它消失,有意的,尽可能减少影响。这是一张归档表:没有东西写入它,也不应该有东西读取它。

DROP TABLE 是显而易见的答案,大多数时候也是正确的答案。在十一台服务器、一个 WAL(预写日志)归档和一个按副本延迟路由读取的负载均衡器上,成本取决于三件事。一是是否还有东西读取这张表。其他的是内核缓存了它多少以及文件在磁盘上的分布方式。所以我在一对 Hetzner 机器上(有真实的流式副本)和一台 GCE 机器上(1 TB 的表无法放入页缓存)各测量了两次。另一半是当"不应该有东西读取它"被证明只是大致属实时会发生什么。

一切都在 PostgreSQL 18.6 上运行,数据校验和开启(18 的默认值),autovacuum 关闭,所以 vacuum 只在我要求时运行。大小按 pg_size_pretty 打印的,所以 1 GB 是 2^30 字节。有三个设置。

一台笔记本电脑(Docker,aarch64,3 个 vCPU,12 GB RAM)带一张 9.2 GB 的表,用于所有需要重复的事情。两台在私有网络上的 Hetzner 机器,一台 ccx43 主库(16 个专用 vCPU,64 GB RAM,本地 NVMe)和一台 cpx62 副本(16 个共享 vCPU,32 GB RAM),都是 ext4 无 discard,shared_buffers = 16GB,持有同一张表为 192 GB,由 16 个并行会话加载。

还有一台 GCE n2-standard-32(128 GB RAM)带 16 块本地 NVMe SSD,大多数运行作为一个 ext4 阵列使用,文件系统比较时作为各 8 块 SSD 的 ext4 阵列和 XFS 阵列,副本运行时在同一台机器上有一个 standby 容器。那里的大表是 unlogged 的,7.5 kB 行,最多 1 TB;副本运行使用 40 GB 的 logged 表。没有一张表是 4 TB,我外推的地方会说明。

测试表

sql 复制代码
CREATE TABLE build_logs (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  project_id bigint NOT NULL,
  created_at timestamptz NOT NULL,
  status smallint NOT NULL,
  payload text NOT NULL
);

INSERT INTO build_logs (project_id, created_at, status, payload)
SELECT (random()*50000)::bigint,
       timestamptz '2019-01-01' + (i * interval '4 seconds'),
       (random()*5)::smallint,
       md5(i::text) || repeat('x', 150 + (i % 80))
FROM generate_series(1, 28000000) i;

CREATE INDEX build_logs_project_idx ON build_logs (project_id, created_at);
  heap   |   idx   |  total
---------+---------+---------
 7747 MB | 1442 MB | 9192 MB

两千八百万行,id 随时间增长,以及每张日志形状的表最终都会有的那种二级索引。我把它放在一个模板数据库中,并为每个测试用 CREATE DATABASE ... STRATEGY FILE_COPY 克隆一份新副本,所以每个实验都从主库和副本上相同的字节开始。

DROP TABLE 的成本

sql 复制代码
DROP TABLE build_logs;
DROP TABLE
Time: 67.751 ms

对该范围的 pg_waldump --stats=record:7 条 XLOG/FPI_FOR_HINT 记录(43,631 字节,目录页自检查点以来首次被触及,所以整页记录),77 条 Heap/DELETE(35,673 字节),6 条 Standby/LOCK(252 字节)和一条 2,181 字节的 Transaction/COMMIT。在主库上,每个关系的第一个段会滞留,截断为零字节,直到下一个检查点,这样它的 relfilenumber 就无法被重用。副本一次性 unlink 所有东西。

WAL 位置移动了 81,737 字节,没有一个是表数据。大部分是目录行离开 pg_class、pg_attribute 等,加上六条 Standby/LOCK 记录,每个有存储的关系一个 AccessExclusiveLock(堆、它的 identity 序列、它的 TOAST 表和索引,以及两个索引)。做实际工作的记录是提交:

复制代码
rmgr: Transaction len (rec/tot): 2181/2181, tx: 762, lsn: 6/29014870,
  desc: COMMIT 2026-10-23 06:21:56.998901 UTC; rels: base/16400/16385
  base/16400/16384 base/16400/16393 base/16400/16394 base/16400/16395
  base/16400/16397; dropped stats: ...

它携带一个要删除的 relfilenode 列表,任何重放它的人------副本或崩溃后的主库本身------都会删除那些文件。数据在出去的路上从不经过 WAL。(PostgreSQL 将关系存储为称为段的 1 GB 文件,副本一次性 unlink 所有段。)

查明是否还有人在读它

DROP TABLE 需要 AccessExclusiveLock,它与一切冲突,包括普通 SELECT。对于没人读的表,这没有成本,所以真正的工作是查明是否有人读。让一个会话在一个忙于其他事情的事务中持有对表的读,然后执行 drop,然后三个普通应用查询:

复制代码
 pid | application_name | wait_event_type | wait_event | blocked_by | query
-----+------------------+-----------------+------------+------------+--------------------------------
 204 | psql             | Timeout         | PgSleep    | {}         | BEGIN; SELECT count(*) FROM bu
 210 | dropper          | Lock            | relation   | {204}      | DROP TABLE build_logs
 216 | app_1            | Lock            | relation   | {210}      | SELECT * FROM build_logs WHERE
 222 | app_2            | Lock            | relation   | {210}      | SELECT * FROM build_logs WHERE
 228 | app_3            | Lock            | relation   | {210}      | SELECT * FROM build_logs WHERE

应用查询的锁会与长读取者的锁兼容,但 blocked_by 指向 DROP。它坐在它们前面的队列中,而 PostgreSQL 不允许后来的请求跳过排他等待者。所以一张"没人真正再用"的表在触及它的最旧事务存活期间不可用。

标准的修复是一个短锁超时(SET lock_timeout = '2s')和围绕 DROP 的重试循环,GitLab 已经有一个:他们的迁移框架用 with_lock_retries 包装 DDL。他们的迁移风格指南还警告说,当表有指向另一张表的外键时,DROP TABLE"很可能使并发流量停滞",所以每个外键先在各自的迁移中处理。我在 pg_locks 中检查过:删除子表会对父表(比如一张 projects 表)取 AccessExclusiveLock,因为强制触发器住在那里。整个队列问题现在适用于你最热的表。

如果我在自己的系统上做这件事,我还会在删除前重命名表并等一个发布周期。重命名取同样的锁但只持有片刻,另一次重命名可以撤销它。任何仍然读取该表的代码路径都会大声失败,而数据还在那里可以放回去。

TRUNCATE 是更温和的变体。它只锁子表,因为外键的触发器留在原地,并且它给表新的空 relfilenode(一张 956 MB 的表产生 47 KB 的 WAL)。旧文件在主库和每个副本上走相同的 unlink 路径。被遗忘的代码路径得到零行而不是"关系不存在",而表可以稍后再走,当没有东西需要 unlink 时。

副本看到什么

DROP TABLE 通过 WAL 到达每个副本,并让它付出两次代价,一次为锁,一次为文件。

锁。 六条 Standby/LOCK 记录让每个热备在重放期间取相同的 AccessExclusiveLock。如果副本上的一个查询在记录到达时正在读表,startup 进程会等待它。那是在 standby 上应用 WAL 的进程,所以它等待时什么都不会被重放,对任何表都是。我在副本上运行了一个长读,并在主库上删除表:

复制代码
06:25:07.602 LOG:  recovery still waiting after 1002.443 ms: recovery conflict on lock
06:25:07.602 DETAIL:  Conflicting process: 236.
06:25:07.602 CONTEXT:  WAL redo at 6/290175D0 for Standby/LOCK: xid 766 db 16401 rel 16385
06:25:36.602 ERROR:  canceling statement due to conflict with recovery
06:25:36.602 DETAIL:  User was holding a relation lock for too long.
06:25:36.607 LOG:  recovery finished waiting after 30008.064 ms: recovery conflict on lock

三十秒后读取者被杀,这是默认的 max_standby_streaming_delay。hot_standby_feedback 帮不上忙,因为它关于行版本而不是锁。日志行在那里是因为 log_recovery_conflict_waits = on,我会在任何这样的更改之前打开它。没有它,等待是不可见的。

GitLab 的读负载均衡让这更重要。根据他们的负载均衡文档,一旦副本落后超过 max_replication_lag_time(默认 60 秒),它就会被跳过。max_replication_difference(8 MB)覆盖最近没有复制数据的副本,当没有可用备库时读取回退到主库。卡在锁上的副本看起来与滞后的副本一模一样,如果每个副本上都有东西在读表,全部十个会在同一时刻卡在同一条 WAL 记录上。我会怀疑这在实践中能走多远:在默认 30 秒下,仅锁等待就保持在他们的 60 以下,所以下面的文件是更大的风险。

文件。 在主库上,后端在释放锁后 unlink 它们,xact.c 说明了原因:删除"可能花费许多秒"。备库保持相同的顺序(xact_redo_commit() 先调用 StandbyReleaseLockTree(),之后调用 DropRelationFiles()),所以读取者不会被 unlink 本身拖住。不同的是谁做工作。在主库上是提交的后端,其他人继续。在 standby 上,startup 进程做它,重放等待。

在 9 GB 的笔记本表上你看不到这些:提交和重放相隔约 70 ms。Hetzner 上的 192 GB 表(162 个堆段加上索引)在主库上花了 15.9 秒删除。提交在 DROP 开始后 15 ms 落地,后端用剩下的时间 unlink 文件,没有人在等它,但每 10 ms 轮询一次的副本在那 11 秒里什么也没应用,而那个窗口内主库上的每个其他更改都排在它后面。第二张 33 GB 的表:主库上 4.9 秒,2.9 秒的持有重放。

我先试了文件系统:在同一 Hetzner NVMe 上的 loop 设备上的 ext4 和 XFS,各删除一张 33 GB 的表,3.5 秒对 2.7 秒。drop_caches 之后两者都降到 0.3 秒以下,所以看起来像页缓存成本。

Hetzner 设置是一台 ccx33,在同一 NVMe 支持的 loop 设备上有 ext4 和 XFS 文件系统,原生 ext4 根作为对照。同一张 33 GB 的表(33 个段,由 8 个并行会话加载)从各自的表空间删除。原生 ext4 和 loop 设备上的 ext4 都花了约 3.5 秒,XFS 2.7 秒。XFS 将每个段保持在一两个区段中,而 ext4 有几十个,当时看起来像次要问题。就在 drop 之前 echo 3 > /proc/sys/vm/drop_caches 将其在 ext4 上降到 288 ms,XFS 上 250 ms。

在 4 TB 时这个算术变得难看。将热速率乘以 4,000 个段,你会得到四到六分钟的冻结重放。跟随页缓存的成本受 RAM 限制,所以对于没人读的表,乘法高估了它。为了分离每段成本、每缓存 GB 成本和布局,我需要一张内核无法容纳的表。于是有了 GCE 机器:128 GB RAM,16 块本地 NVMe SSD,以及 7.5 kB 行的表(每页一行,所以 1 GB 段正好是 131,072 行,大小算出来是整数)。它们是 unlogged 的,以 1.7 GB/s 加载,走相同的 unlink 路径。二十八个会话同时向每张表追加,这对分配器很苛刻,每次 drop 之前都有 sync 和 drop_caches。

没有 loop 设备的同样测试:两个 8 块 SSD 的阵列,一个 ext4 一个 XFS,相同的表加载到每个中。

文件系统 表 每段区段数(第一、中间、最后) 冷 DROP
ext4 256 GB 898, 4,785, 2,845 4.38 秒
XFS 256 GB 1, 4, 13 39 ms
ext4 1 TB 2,577, 4,706, 1,760 16.1 秒
XFS 1 TB 6, 14, 2 80 ms

XFS 在两个大小上都快一百倍以上。

这不是延迟工作(XFS 阵列上的已用空间在 DROP 开始后 0.4 秒内从 201 GB 降到 21 GB)。冷 unlink 成本跟随要释放的区段数,而并行追加者对 ext4 的分配器接近最坏情况。这与 Hetzner 相矛盾,那里 loop 设备上的 XFS 只快四分之一。我不能说 loop 设备还是机器解释了差距。

单个 16-SSD ext4 阵列给出每段和每缓存 GB 的项。列出缓存的地方,我用 cat 读回前 N 个段,并让 vmtouch 报告就在 DROP 之前驻留了什么。

表 段 在页缓存中 DROP
256 GB 256 无 4.25 秒
512 GB 512 无 8.50 秒
1 TB 1,024 无 18.5 秒
100 GB 100 无 2.01 秒,2.03 秒
100 GB 100 50 GB 3.69 秒
1 TB 1,024 100 GB 20.9 秒

从 256 GB 到 1 TB,冷 drop 每段花费约 17 ms,是一条直线,每个驻留 GB 增加约 34 ms。两个项加在一起与 1 TB 行相差不到一秒。

副本从另一边讲述同样的故事。它是同一台机器上的 standby 容器,由 pg_basebackup 供给,使用 logged 的 40 GB 表,因为 logged 表只以 0.4 GB/s 加载。我每 20 ms 轮询 standby 上的 pg_last_xact_replay_timestamp(),取提交时间与显示它的第一次轮询之间的差距:

40 GB 表,40 个段 主库 DROP standby 上提交的重放
两个节点都冷 572 ms 约 80 ms
两个节点都缓存 15 GB 1.62 秒 约 0.76 秒

两个节点同时在同一阵列上 unlink,每一行是单次运行。

vmtouch -e 要求内核提前丢弃文件的缓存页,在一个没人等待的进程中。在 GCE 上,完全缓存的 100 GB 表驱逐花了 5.6 秒,之后的 DROP 花了 0.77 秒。路径来自 pg_relation_filepath(),在每个副本上都相同。驱逐和 drop 之间的 base backup 或 pg_dump 会直接把文件读回来。

当表还有流量时

"没人读它"这样的前提往往以无聊的方式失败。我预期最多的是仅追加日志:插入仍然到达,没有更新或删除,存在的少数读取查看最近的行。对那张表的 DROP 排在进行中的每个插入事务后面,锁队列完全像前面的输出那样形成,同样适用短超时和重试循环。更有用的改变在于你想要什么。你想要旧行消失,新行保留,而 DELETE 是错误的工具。

如果表按定义"旧"的列(这里是 created_at)进行范围分区,退役一个月就是 ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY 然后 DROP 一张没有东西再引用的表。那是上面章节中的 80 KB WAL 和 unlink 成本,每个分区一次,没有死元组,也不需要后续 vacuum。那篇困难的 DELETE 帖子结尾用一小段推荐了正是这个,在这些数字之后我会把它移到顶部。月度分区还限制了重放暂停,因为暂停随你删除的东西缩放,而一个月是 4 TB 的一小片。

分区对接受旧行更新的表没有任何帮助,或者本周无法重新分区的表。

分批删除

如果行必须离开一张留在原地的表,2024 年帖子的建议仍然成立:分批删除。那篇帖子称批处理比一个大 DELETE 慢,值得换取喘息空间,但没有给出这个空间在 WAL 中花费多少的数字。我分批删除了最旧的 90%(2520 万行),每批 50,000 行,每批之后提交:

每个 COMMIT 结束一个真实事务,不是子事务。CALL 必须在 BEGIN 之外运行(否则无效事务终止),而且过程不能将 DELETE 包装在 EXCEPTION 块中。

sql 复制代码
CREATE PROCEDURE purge_batched(p_upto bigint, p_batch int) LANGUAGE plpgsql AS $$
DECLARE v_lo bigint := 0;
BEGIN
  WHILE v_lo < p_upto LOOP
    DELETE FROM build_logs WHERE id > v_lo AND id <= least(v_lo + p_batch, p_upto);
    v_lo := v_lo + p_batch;
    COMMIT;
  END LOOP;
END $$;

CALL purge_batched(25200000, 50000);

| | 笔记本电脑 | Hetzner

---|---|---|---

删除的行 | 2520 万 | 5.4 亿

时间 | 158.9 秒 | 36 分 37 秒

WAL 记录 | 25,202,282 | 551,220,723

全页映像(wal_fpi) | 892,603 | 27,650,079

WAL | 8179 MB | 229 GB

在笔记本电脑上,删除的行约占 7 GB 堆,所以删除写入的 WAL 比它移除的数据更多。每行一条 WAL 记录是预期之中的,因为 DELETE 只在每个元组上盖 xmax。体积来自全页映像,几乎正好是持有被删除行的堆页数。检查点后对页面的第一次更改记录整个 8 KB 页,而从前到后遍历表的分批删除会对它触及的每一页做第一次更改。

DELETE 不触及索引。Vacuum 稍后连同堆一起清理它们,它的全页映像更便宜,因为修剪过的页大部分是空空间,映像会跳过。Vacuum 不能做的是只在文件最末尾归还空页。这里幸存的 10% 是最新的行,位于末尾,所以没有什么可供 vacuum 截断。

在 Hetzner 上,映像比删除触及的堆页多约 800 万。那张表由 16 个会话加载,所以 id 散布在文件中,删除在 15 分钟的检查点之间重新访问页面,而笔记本电脑的顺序加载不会。删除从未让副本落后超过 127 MB。之后的 vacuum 做到了:它运行了 15 分钟,又写了 36 GB,副本最多落后 7 GB 和 38 秒。它只截断了 5,670 页,因为同样的散布让幸存行留在整个文件中,堆保持在 162 GB。线性缩放到 4 TB,删除、首次读取和 vacuum 加起来大约 9 TB 的 WAL。如果散布和校验和以相同方式表现,那会去到十个副本和归档。

收回空间需要重写:VACUUM FULL(整个持续期间持有排他锁,所以不能用于活跃表)、pg_repack,或 PostgreSQL 19 的 REPACK (CONCURRENTLY),我写作时处于候选发布阶段。它们每个都写入幸存行并重建索引,这是在删除和 vacuum 已经写入的 WAL 之上更多的 WAL。

删除后的第一次 SELECT

删除后的第一次 count(*) 在笔记本电脑上写了 7.8 GB 的 WAL。在 192 GB 的 Hetzner 表上,它运行了 41 分钟并写了 151 GB,而第二次是 87 秒,它的每一个字节都去了副本。大规模删除后第一个读表的人------比如一份报告或一个 pg_dump------最终在自己的查询中做提示和修剪。Vacuum 之后仍然做自己的完整遍历。

为了单独观察那个读取者,我在一个全新容器中重复了笔记本电脑上的加载和分批删除,运行了一个 CHECKPOINT(服务器最终会自己那样做),并让 SELECT count(*) FROM build_logs 先走。删除清除了它触及的每一页上的可见性映射位(让 index-only 扫描跳过页堆的标志),所以扫描做了 2520 万次堆获取。同一实验较早运行的 pg_waldump 视图:

复制代码
类型                       N    记录大小        FPI 大小    合并大小
XLOG/FPI_FOR_HINT     959025       46992220      7688857048       7735849268
Heap2/PRUNE_ON_ACCESS 888284       98127982               0         98127982

每次堆获取找到一行,其删除事务已提交,读取者将其记录在页上(一个提示位,加上索引条目上的死标记),这样下一个就不必检查。校验和开启(PostgreSQL 18 默认值)时,即使那个小更改也意味着检查点后的第一次记录整个 8 KB 页。那就是 959,025 个映像:删除触及的堆页加上主键的叶页。

wal_log_hints = on 在没有校验和时也有同样效果。pg_rewind 需要其中之一,所以依赖它的集群已经记录提示。

没有校验和或 wal_log_hints,提示映像消失而修剪不消失。我在用 --no-data-checksums(wal_log_hints = off)初始化的容器中重复了运行:

| | 校验和开启 | 校验和关闭

---|---|---|---

删除 WAL | 8253 MB | 8252 MB

首次 count() WAL | 7850 MB | 173 MB
首次 count(
) 中的全页映像 | 961,036 | 890,644

首次 count(*) 时间 | 34.8 秒 | 14.1 秒

检查点后每页的第一次修剪仍然记录页,每个堆页一个映像,但充满死元组的页几乎全是空洞,所以那些映像平均约 137 字节。7.7 GB 是提示映像,而校验和是产生它们的原因。第二次 count(*) 花了 1.5 秒,做了 2 次堆获取,什么也没写。

而索引仍然每行持有一个死条目。在 Hetzner 上,ORDER BY id LIMIT 1 遍历了 148 万个索引页,直到 vacuum 赶上之前每次调用花 1.7 秒。

wal_compression = zstd 缩小这些映像:在这张表上 WAL 从 7517 MB 降到 1458 MB,当我用七份 md5(random()::text) 填充它时从 7496 MB 降到 3825 MB。

改为复制保留者

把问题反过来,它就变小了。如果 90% 的行要消失,将保留的 10% 复制到新表并删除旧表。删除是前半部分的廉价操作,而复制只为保留的内容写 WAL。

我在一个全新克隆上运行了这个,pgbench 每秒对表做 300 个事务,20% 插入和 80% 对最近行的主键查找。脚本首先记录高水位标记(下面的 hwm,那一刻的最高 id)并复制到它为止的所有内容:

sql 复制代码
CREATE TABLE build_logs_new (LIKE build_logs INCLUDING DEFAULTS INCLUDING CONSTRAINTS EXCLUDING IDENTITY);
ALTER TABLE build_logs_new ALTER id ADD GENERATED ALWAYS AS IDENTITY;

SELECT max(id) AS hwm, clock_timestamp() AS t0 FROM build_logs \gset

-- 等到没有其他在 t0 之前开始的事务仍然打开
SELECT pid, state, xact_start FROM pg_stat_activity
WHERE pid <> pg_backend_pid() AND datname = current_database() AND xact_start < :'t0'
\watch 0.5 min_rows=1

INSERT INTO build_logs_new OVERRIDING SYSTEM VALUE
SELECT * FROM build_logs WHERE id > 25200000 AND id <= :hwm;

ALTER TABLE build_logs_new ADD PRIMARY KEY (id);
CREATE INDEX build_logs_new_project_idx ON build_logs_new (project_id, created_at);
INSERT 0 2801207
Time: 5227.946 ms (00:05.228)
ALTER TABLE
Time: 1365.478 ms (00:01.365)
CREATE INDEX
Time: 992.124 ms

 wal_records | wal_fpi |  wal
-------------+---------+--------
     2803416 |    8155 | 882 MB

在 vacuum 甚至还没算之前,大约是分批删除 WAL 的十分之一,而且旧表还没被锁。复制前的等待对正确性很重要:identity 值按顺序分发但按事务完成的任何顺序提交,所以没有它,一行可能最终两张表都不在。我用两个 psql 会话重现了丢失;等待比读取 hwm 那一刻更旧的所有事务可以防止它。

具体来说,竞争:一个会话取 id 1 并保持打开,另一个会话取 id 2 并提交,max(id) 读到 2。复制看到高达 2 的 id 而没有未提交的行,追赶只查找 2 以上,id 1 两张表都不在。等待比读取 hwm 那一刻更旧的所有事务意味着复制的快照看到高达 hwm 的每个 id。

插入一直在到达,所以交换追赶 :hwm 之后写入的行,并且在持有锁的同时这样做:

sql 复制代码
BEGIN;
SET LOCAL lock_timeout = '2s';
LOCK TABLE build_logs IN ACCESS EXCLUSIVE MODE;
INSERT INTO build_logs_new OVERRIDING SYSTEM VALUE
SELECT * FROM build_logs WHERE id > :hwm;
SELECT setval(pg_get_serial_sequence('build_logs_new', 'id'),
              (SELECT max(id) FROM build_logs_new));
ALTER TABLE build_logs RENAME TO build_logs_old;
ALTER TABLE build_logs_new RENAME TO build_logs;
COMMIT;

将每个事务的 pgbench 日志与交换对齐,从锁到提交花了 184 ms,带过来 442 个迟到的行。排在锁后面的查询最多花了 172 ms,全都在 COMMIT 后一毫秒内完成,而这次运行平均为 2 ms。阻塞在锁上的查询在锁被授予后会重新解析表名,所以它们落在新表上;在一个有三个等待会话的测试中,一个阻塞的 SELECT 和一个阻塞的 INSERT 都这样做了。

之后 DROP TABLE build_logs_old 花了 114 ms,新表是 919 MB 而不是 9,192 MB。交换也写 Standby/LOCK 记录,所以副本章节的一切都适用于它,副本上的长读出于同样原因拖住重放。

GitLab 自己的交换表指南有同样的 setval 步骤(reset_pk_sequence!),警告说否则新行会从 id 1 开始。我的版本省略了远不止这些,而真实表可能需要大部分:

  • 对已复制行的更新和删除:追赶只找到新 id。在旧表上记录更改 id 的触发器,在交换内再次应用,是 pg_repack 多年来使用的。PostgreSQL 19 的 REPACK (CONCURRENTLY) 用逻辑解码做到这一点,但没有 WHERE 子句。
  • 外键:传入的指向旧表的 OID,而不是它的名称,必须重新创建。ADD FOREIGN KEY 即使有 NOT VALID 也对被引用表取 SHARE ROW EXCLUSIVE;VALIDATE CONSTRAINT 只需要子表上的 SHARE UPDATE EXCLUSIVE 和父表上的 ROW SHARE。传出的就是前面的 projects 问题。
  • LIKE 不复制什么:授权、触发器、索引(包括主键)、fillfactor 和其他 reloptions、副本标识、注释、发布成员资格、扩展统计信息和所有者。序列和索引保留它们的 _new 名称,新表在你 ANALYZE 它之前没有统计信息。
  • 预备事务:它们不出现在 pg_stat_activity 中,所以上面的等待直接越过它们。也检查 pg_prepared_xacts,并给等待一个超时。

4 TB 的表和它的十个副本

在他们的表上,删除应该让每个副本上的重放停滞从远低于一秒到大约一分钟不等。文件系统主要决定在哪里。4 TB 的表约 4,100 个段。以我在碎片化 ext4 上看到的每段 16 到 18 ms 计算,那是约 70 秒。在 XFS 上是一小部分秒,而 Hetzner 的 ext4 落在 36。缓存最多增加几秒。GitLab 的 60 秒跳过阈值只在慢的一端才重要,而我不知道他们的文件系统。

如果这是我的表,我会按这个顺序处理。每个迁移删除一个外键,每个都在带重试的锁超时下。重命名表并给它一个发布周期,在主库以及每个副本上观察 pg_stat_activity 和 pg_locks,因为其中任何一个上的读取者都会使那个节点上的重放停滞。打开 log_recovery_conflict_waits,检查副本使用哪个文件系统,如果有空间,在一个副本文件的克隆上计时一次 drop。然后在最安静的时段删除它,带短锁超时和重试循环。事先从页缓存驱逐对冷表没有帮助。如果暂停结果很重要,一张 4 TB 的表没有其他杠杆。分区后,相同的 unlink 会以几秒的暂停到来。

有三件事仍未测量。他们的文件系统可能以 discard 挂载,这会为每个释放的块发送 TRIM;我的运行都没有使用它。他们的副本可能或可能不在缓存中持有表的任何部分。而且我不知道他们的集群是否记录提示,这决定首次读取者的成本,如果像这样的表曾经必须删除行而不是被 drop。

相关推荐
ClouGence2 小时前
开源数据库管理工具 CloudDM 发布新版本,支持团队多级审批工单,无需依赖第三方系统
数据库·sql·开源
李纲明2 小时前
WordPress 报建立数据库连接时出错:先核 wp-config 四元组,再查 MySQL 服务与表修复
运维·数据库·mysql·wordpress·排障·devdiy
鸢尾掠地平3 小时前
MySQL之DML(含例子)
数据库·mysql
香蕉大香蕉(06敬畏市场)4 小时前
MySQL 单点详解(06)
java·数据库·mysql·博客·实习
CodeHackerBhx4 小时前
MySQL 迁达梦踩坑实录:FIND_IN_SET、timestamp(6) 和一整批要改的函数
数据库·mysql·达梦·信创·数据库迁移
n 55!w !1084 小时前
MySQL笔记整理2
数据库·笔记·mysql
李纲明4 小时前
WordPress 提示「建立数据库连接时出错」:先核 wp-config 与数据库服务,再查表损坏与磁盘配额
数据库·wordpress·devdiy
freejackman5 小时前
【架构篇】双遍历 + 多栈协同:手撸 SQL 血缘引擎的架构思考
java·大数据·数据库·hive·sql·架构·spark
杨云龙UP5 小时前
TDengine 社区版磁盘空间不足故障处理 SOP:历史日志清理、日志保留策略及数据保留 200 天
数据库·时序数据库·tdengine·tsdb·历史日志清理·trim database·logkeepdays