十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PostgreSQL外键缺失拖垮级联删除?索引与约束的优化实战

PostgreSQL外键缺失拖垮级联删除?索引与约束的优化实战 那天凌晨的告警我现在还记得一个清理任务要删除一批状态为“已注销”的用户本来应该几秒钟跑完结果订单表被拖进全表扫描数据库IO直接飙满线上慢查询一屏一屏往外冒。等定位到根因发现又是熟悉的配方——订单表当初为了“写入性能”没加外键结果删数据的时候把整个库都坑了。PostgreSQL 性能优化做到一定深度你会发现最值钱的往往不是那些花哨的索引调参技巧而是把最基本的约束和元信息补全。外键缺失这个问题平时安安静静躺着一旦涉及父表删除尤其是需要级联清理子表数据时就会变成一颗定时炸弹。这篇文章不劝你无脑堆约束而是把这个场景彻底拆开外键缺失到底怎么影响级联删除、为什么有外键反而删得快、缺了约束又该怎么低成本补上。适合DBA、后端开发以及所有在表结构评审时说过“外键影响性能、先不加”的伙伴。1. 外键缺失为什么删个用户能把数据库拖垮1.1 一次真实的删除“事故”还原先说我遇到的场景。业务是电商后台核心就两张表用户表user_accounts订单表orders。由于项目迭代快早期为了追求写入吞吐订单表的user_id没有建外键连普通索引都没建。当时的设计理由很典型订单写入量大外键约束在插入时要额外校验父表怕影响性能加上业务代码里已经做了关联逻辑就先把约束砍了。结果线上清理注销用户时业务代码是这么干的DELETE FROM orders WHERE user_id IN ( SELECT id FROM user_accounts WHERE status cancelled LIMIT 3000 ); DELETE FROM user_accounts WHERE status cancelled LIMIT 3000;orders表当时已经积累到320万行user_id列没有索引执行计划直接走了Seq Scan on orders。第一条SQL为了找出3000个用户对应的订单把320万行整表扫了一遍。这还不是最惨的因为清理任务按批次循环执行等于每批都来一次全表扫描。数据库的IO和CPU瞬间被打满业务查询全部排队。这里要澄清一个误区数据库没有外键约束时并不会在删除用户时自动去扫描订单表检查引用完整性——它根本没这个义务。慢的真正原因是你在业务代码里手动做了级联删除而关联列上既没有约束也没有索引PostgreSQL只能用最原始的“整表过滤”方式去定位目标行。换句话说你砍掉的不只是一个约束而是优化器判断执行路径时最关键的那张地图。1.2 没有外键时PostgreSQL到底在“怕”什么很多人对外键的理解停留在“防止脏数据”上觉得只要应用层逻辑控制得好约束就是多余的。但在数据库层面外键除了约束数据完整性还承担着两个非常重要的职责提供引用关系和影响执行计划。有外键约束时优化器可以确定“哪些表引用了当前表”从而做很多激进优化。比如 JOIN 消除查询里 LEFT JOIN 了一张父表但投影列全部来自子表优化器确认外键能保证引用关系后直接把这个 JOIN 优化掉少一次大表关联。更重要的是当你执行ON DELETE CASCADE时系统元数据明确告诉执行器“删父表时要同步清理哪些子表”配合子表外键列上的索引删除路径是一条清晰的索引点查链路。而没有外键时情况就变成业务层手动逐条处理关联数据要么循环发 DELETE要么用 IN 列表拼大SQL。优化器对“到底有没有其他表引用这张表”一无所知自然无法做引用相关的优化。最致命的是外键缺失往往和关联列无索引同时出现——因为当初设计时已经把约束砍了索引也经常被顺手省掉于是原本一次索引点查就能完成的删除退化成一次全表顺序扫描。打个生活化的比方有外键约束索引的数据库就像图书馆有一套完整的索引卡片你要找某本书按卡片的编号直奔书架就行。没有外键、没有索引那就只能挨个书架翻一遍确认每一本书是不是你要找的。删100个用户翻100次全馆数据库不炸才怪。2. 用一套对照实验看清外键对删除性能的真实影响2.1 实验环境和数据准备光靠想象不够我搭了一套对照实验环境把几种情况分别测了一遍。环境是 PostgreSQL 158核16GSSD参数基本默认关掉了 fsync 相关的干扰项以保证测试时间可比。构造了两张业务表CREATE TABLE user_accounts ( id bigserial PRIMARY KEY, username text NOT NULL, status text NOT NULL DEFAULT active ); CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id bigint, order_no text, amount numeric(10,2), created_at timestamptz DEFAULT now() );插入50万用户、500万订单每个用户大约10条订单。注意这个实验里orders.user_id故意先不建索引也不加外键用来模拟最“裸”的存量表。实验分成四组A组orders.user_id无外键、无索引B组orders.user_id无外键、但有普通索引C组orders.user_id有外键ON DELETE CASCADE子表有索引D组orders.user_id有外键ON DELETE CASCADE但子表没有索引每组分别删除1000个用户观察执行计划和耗时。2.2 删除1000个用户四种方案的耗时对比先看最关键的对比结果再说为什么。场景删除方式执行计划形态耗时约A组无外键、无索引业务手动DELETE FROM orders WHERE user_id ANY(...)Seq Scan全表扫描500万行5~8秒B组无外键、有索引业务手动DELETE FROM orders WHERE user_id ANY(...)Bitmap Index Scan只扫目标行80~150毫秒C组有外键、子表有索引数据库自动级联删除索引点查逐级下钻200~400毫秒D组有外键、子表无索引数据库自动级联删除子表全表扫描5~10秒注意这里A组是“一次删除1000个用户”的结果5到8秒只是全表扫了一遍。如果是业务循环处理每批删1000个用户要扫一遍那耗时就变成5秒乘以批次数。我线上遇到的那个任务有几十批算下来二三十分钟都跑不完数据库中途就扛不住了。C组比B组慢一点是因为级联删除由数据库内部完成除了删父表还要在子表执行删除并处理触发器逻辑多了一道内部开销。但这点差距是完全可以接受的换来的是数据一致性和可预测的执行计划。D组则说明一个容易被忽视的问题只加外键、不给子表外键列建索引删除一样会全表扫描外键和索引必须同时到位。2.3 执行计划拆解慢的不是DELETE是定位目标行的路径为什么同样是DELETE FROM orders耗时能差两个数量级看执行计划就明白了。A组无索引时计划长这样Delete on orders - Seq Scan on orders Filter: (user_id ANY ({123,456,...}::bigint[]))这代表数据库把500万行从磁盘读进内存逐行判断user_id是否属于列表中的ID命中的行才标记删除。全表扫描的代价是O(n)n是整表行数目标行只有1万行绝大多数IO都浪费在无关数据上。B组有索引时计划变成Delete on orders - Bitmap Heap Scan on orders Recheck Cond: (user_id ANY ({123,456,...}::bigint[])) - Bitmap Index Scan on idx_orders_user_id索引扫描直接定位到1万行目标数据读取的页面数量级从百万降到几千。删除操作的耗时主要消耗在“找到要删的行”这一步真正执行删除反而很快。这就是为什么我反复强调性能瓶颈不在 DELETE 本身而在 SQL 执行计划里定位数据的方式。3. 关键认知有外键也不等于安全子表索引和锁策略同样重要3.1 外键约束与子表索引的一体两面PostgreSQL 官方文档其实写得很清楚在子表的外键列上应该建立索引否则父表执行删除或更新被引用列时会触发子表的全表扫描。这里要解释一下底层机制。外键约束在 PostgreSQL 里是通过触发器实现的。当父表删除一行时系统需要检查或者级联处理子表中引用这一行的数据这个检查动作在子表上的执行方式完全取决于子表有没有可用的索引。有索引就是点查没索引就是全表扫。所以补外键时最标准的姿势是“索引先行”-- 1. 先建索引用 CONCURRENTLY 避免长时间阻塞写入 CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id); -- 2. 再补外键约束 ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user_accounts (id) ON DELETE CASCADE;顺序很重要。如果先加外键再建索引加外键的ACCESS EXCLUSIVE锁已经把写操作堵住了建索引又要额外锁表业务中断窗口被拉长。先并发建索引再把约束加上锁窗口可以压到很短。3.2 线上补外键的锁表风险与在线操作手法很多团队知道要补外键但一直不敢动核心顾虑是锁表。老版本的 PostgreSQL 加外键确实比较粗暴会拿ACCESS EXCLUSIVE锁期间所有读写都进不了表对7x24小时的业务来说几乎不可接受。PG 12 之后这套流程已经成熟很多可以采用三步走-- 第一步并发创建索引不阻塞DML CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id); -- 第二步加 NOT VALID 约束只校验新数据不动存量 ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user_accounts (id) ON DELETE CASCADE NOT VALID; -- 第三步后台校验存量数据持有 SHARE UPDATE EXCLUSIVE 锁允许DML并发 ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;三步的关键在于NOT VALID阶段只加元数据不扫描全表锁时间极短VALIDATE CONSTRAINT阶段在后台扫描存量数据校验持有的是SHARE UPDATE EXCLUSIVE锁和ONLINE建索引类似不阻塞普通查询和写入只是不能同时进行表结构变更。需要提醒的是即使VALIDATE阶段允许DML它仍然要读全表IO压力不小建议放在业务低峰期执行。如果表特别大可以先分几次小事务执行一个“数据清洗补约束”脚本或者干脆先把历史数据分区再逐步补约束。3.3 业务代码里的“伪级联删除”整改方案如果数据库外键一时半会加不上比如存量数据有几千万残留大量孤儿数据清洗和校验需要很长时间那么业务代码层面也得同步整改。否则就算你补了约束以前那些裸奔式的 DELETE 逻辑也不会自动变优雅。整改的核心原则就一条避免逐条循环删除把所有关联操作下推到数据库一次性完成。原来的代码如果是这样for user_id in user_ids: cursor.execute(DELETE FROM orders WHERE user_id %s, (user_id,)) cursor.execute(DELETE FROM user_accounts WHERE id %s, (user_id,))改成这样-- 在一个事务里完成两个表的关联删除 WITH deleted_orders AS ( DELETE FROM orders WHERE user_id IN (SELECT id FROM user_accounts WHERE status cancelled LIMIT 500) RETURNING user_id ) DELETE FROM user_accounts WHERE id IN (SELECT DISTINCT user_id FROM deleted_orders);这样做的收益有两层一是从“每删一个用户扫一遍订单表”变成“批量定位一次”即使订单表user_id暂时没有索引全表扫描的次数也大幅下降二是减少应用与数据库之间的往返长事务和锁持有时长都更可控。但这终归是临时方案根治还是要回到补外键。4. 系统化排查怎么发现库里哪些表缺外键、哪些删除操作有隐患4.1 用系统目录快速定位疑似“裸外键”生产环境通常有几百张表靠人工一个个看根本不可能。借助 PostgreSQL 的系统目录可以写一段查询找出所有“列名看起来像外键但实际没有任何外键约束”的列。一个相对实用的初筛SQLSELECT ns.nspname AS schema_name, child.relname AS child_table, child_col.attname AS column_name FROM pg_attribute child_col JOIN pg_class child ON child.oid child_col.attrelid JOIN pg_namespace ns ON ns.oid child.relnamespace WHERE child.relkind r AND ns.nspname public AND child_col.attnum 0 AND NOT child_col.attisdropped AND child_col.attname LIKE %\_id AND NOT EXISTS ( SELECT 1 FROM pg_constraint con WHERE con.conrelid child.oid AND con.contype f AND child_col.attnum ANY(con.conkey) ) ORDER BY child.relname, child_col.attname;这段SQL会把所有schema为 public、列名以_id结尾、但没有任何外键约束覆盖的列列出来。它属于初筛因为有些_id列本来就是业务ID不一定引用别的表但至少能帮你快速圈定可疑目标再结合业务语义人工确认。反过来也可以先列出所有真实外键看哪些常见的关联命名没有出现在结果里SELECT conrelid::regclass AS child_table, confrelid::regclass AS parent_table, conname, pg_get_constraintdef(oid) AS constraint_def FROM pg_constraint WHERE contype f ORDER BY 1;把两条SQL的结果放在一起对比基本就能把“该有外键却没有”的地方找全。4.2 从统计信息和慢SQL反推隐藏问题有时候问题不是一上来就能从表结构里看出来尤其是历史库字段命名乱七八糟光靠命名规则定位不到。这时候可以换一个思路从数据库的统计信息和慢SQL日志反查。pg_stat_user_tables里记录了每张表的顺序扫描次数和顺序扫描读取的行数SELECT relname, seq_scan, seq_tup_read, idx_scan, idx_tup_fetch, CASE WHEN idx_scan 0 THEN round(seq_tup_read::numeric / idx_tup_fetch::numeric, 2) ELSE NULL END AS seq_to_idx_ratio FROM pg_stat_user_tables ORDER BY seq_tup_read DESC LIMIT 20;如果某张表的seq_tup_read是idx_tup_fetch的上百倍说明这张表经常被全表扫描。再结合pg_stat_statements里的 DELETE 语句统计基本能锁定是哪些删除操作在以全表扫描方式执行SELECT query, calls, total_exec_time, mean_exec_time, max_exec_time FROM pg_stat_statements WHERE query ILIKE delete% ORDER BY total_exec_time DESC LIMIT 20;把这两个信息一交叉即使你不知道表结构长什么样也能快速找出“删除慢的大户”然后顺蔓摸瓜看到底是缺索引还是缺约束。4.3 检测“孤儿数据”的临时SQL在补外键之前还有一道绕不开的坎存量数据是否干净。如果子表里已经存在大量引用不到父表的孤儿数据直接加外键约束会失败VALIDATE CONSTRAINT也会报错。建议先用这个SQL摸一下底SELECT count(*) FROM orders o LEFT JOIN user_accounts u ON u.id o.user_id WHERE u.id IS NULL;这个查询会统计出orders表里所有user_id在user_accounts中找不到对应记录的行数。如果有孤儿数据补外键之前必须先处理好。处理方式取决于业务语义能补的补上确认废弃的直接删掉实在确定不了就归档到一张侧表保证待加约束的表数据干净。提示这个查询在user_id上没有索引时会全表扫描大表执行前先确认一下当前IO水位最好放到低峰期跑。5. 一套可以立刻执行的优化落地清单5.1 短期、中期、长期三步走外键缺失这种问题是长期积累的不要指望一个操作瞬间全解决。我复盘线上几次故障后总结了一套三步走方案实战下来比较稳。短期当天到1周内先把最痛的删除路径止血。给所有高频关联表的关键_id列补上普通索引尤其是那些每天会被 DELETE 或 UPDATE 扫描的列。同时把业务代码里的循环删除改成批量下推就算一时没有外键也不会因为逐条扫描把库拖垮。同步开启auto_explain和pg_stat_statements把执行计划收集起来作为后续判断依据。中期1到4周挑选影响最大的几组关联表执行“索引先行 NOT VALID VALIDATE”的外部键补全流程。每张表单独处理不要批量操作避免多个DDL同时抢锁。补完一批观察一段时间确认业务无异常再做下一批。长期持续把外键缺失检测SQL和孤儿数据检测SQL固化进日常巡检脚本定期执行。表结构评审阶段把“外键列必须建索引”写进规范新表上线前用脚本自动检查约束和索引是否齐备。5.2 关键操作模板索引与约束脚本这是一个可以在目标表上反复套用的模板-- 1. 检查子表外键列是否已有索引 SELECT indexname, indexdef FROM pg_indexes WHERE tablename orders AND indexdef ILIKE %user_id%; -- 2. 并发建索引注意不在事务里执行 CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id); -- 3. 加 NOT VALID 外键 ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user_accounts (id) ON DELETE CASCADE NOT VALID; -- 4. 校验存量数据 ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;注意CREATE INDEX CONCURRENTLY不能在事务块中执行也不要在同一个事务里连续执行两条并发建索引语句。另外父表的被引用列必须先有主键或唯一约束否则外键根本加不上。user_accounts.id如果是bigserial PRIMARY KEY这一点天然满足。5.3 如何评估线上加外键对业务的影响补外键虽然收益大但也不是点一下鼠标就完事的零风险操作。上线前按下面几个维度评估一下锁影响NOT VALID阶段锁窗口极短VALIDATE阶段锁级别不高但如果表上有长时间运行的事务DDL 一样会被阻塞。操作前查一下pg_stat_activity确认没有长事务。全表扫描成本VALIDATE会扫描全表做校验如果表有几百GBIO压力会持续几分钟到几十分钟必须安排在业务低峰期。应用兼容性加外键前检查应用代码里有没有先删父表再删子表的逻辑有没有批量插入时子表引用不存在父表的场景。有的话约束会直接报错需要先修复应用逻辑。我在一次补外键操作时就是因为没先查长事务VALIDATE卡了快20分钟。后来学乖了操作前固定跑一遍pg_stat_activity把长事务列出来有问题先处理掉再做DDL。6. 实战避坑来自生产环境的教训6.1 曾经被我忽略的隐患子表没索引外键约束形同虚设线上有一次补完外键后我原本以为万事大吉结果第二天看慢SQL日志几条删除语句仍然出现全表扫描。查了一下才发现D组的情况被我碰上了——约束是加了但子表orders.user_id上一直没有索引ON DELETE CASCADE触发器每次执行都在全表扫描。这是个非常典型的盲区。很多人以为加完外键就自动有索引其实 PostgreSQL 不会因为外键约束而自动在子表创建索引必须手动建。所以补外键的操作清单里第一步永远是检查并创建子表索引顺序反了后面调试起来会非常痛苦。6.2 分批删除时如何控制锁与膨胀即使有外键、有索引删除超大表的数据也不能一次梭哈。一千万行数据的业务表在一个事务里删掉几百万行锁持有时间极长事务内的旧版本数据全部堆积事务结束后的膨胀量能把磁盘空间瞬间撑爆。推荐的做法是控制批量大小比如每批处理2000到5000行一批一提交并且用FOR UPDATE SKIP LOCKED避免多个删除任务互相干扰WITH ids AS ( SELECT id FROM user_accounts WHERE status cancelled ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED ) DELETE FROM orders WHERE user_id IN (SELECT id FROM ids);外层再包一层循环脚本直到没有行为止。这样做的好处是单次事务短、锁时间短、WAL写入节奏可控autovacuum 也能跟上进度不会出现删除完之后表膨胀好几倍的情况。6.3 什么时候可以“故意”不用外键聊了这么多外键的好处最后也必须说一句公道话不是所有表都应该加外键。有一种场景我认为不加外键是合理的就是以分区裁剪为核心的大流水表。比如日志表按天分区查询和删除都通过分区键定位老数据直接DETACH PARTITION或者TRUNCATE整个分区根本不涉及单行删除和逐行关联。这种情况下加外键反而会在插入时多一次父表校验对写入吞吐有影响而级联删除的场景几乎不存在所以不加也可以接受。这里的原则是外键缺失的危害主要体现在“父表删除时被迫处理子表关联数据”的场景。如果表设计层面能保证删除不经过行级关联才适合放弃外键。大多数业务表并不满足这个条件。我在实际运维中还有一个习惯把外键缺失检测放进发版流水线里。每次新表结构合并前跑一遍那个pg_constraint查询如果发现新增表里有明显的_id关联列但没有外键就自动在评论区挂一个提醒。刚开始开发同事觉得烦后来有人真的因为在测试环境模拟删用户时全表扫描回来跟我说“这条检查救了一命”。补外键这个事技术上不复杂难的是下决心。但只要你经历过一次删除时全表扫描拖垮整个库的报警就会明白约束从来不是性能的敌人没有约束的裸奔才是。
返回列表