注意缺失的外键索引:Postgres 性能陷阱
过去一周,我被 PostgreSQL 的一些性能问题彻底搞垮了。这很痛苦,但也让我受益匪浅。我不仅解决了问题,还对 Postgres 有了更深入的理解。现在,我能够更好地诊断下一次遇到的性能问题。我将通过这篇文章来分享我的经验教训。
我来给你演示一下它有多糟糕。我有一个查询,在大约 100 毫秒内删除了一个表上的 5 万条记录。这对我来说已经足够快了。然后我又有一个类似的查询,从另一个表中删除相同数量的记录,运行了大约 30 分钟。慢了 10,000 倍!😱 这真是太糟糕了。
在诊断并解决问题后,我将第二次删除查询的时间缩短到了大约 1 秒。好多了!在文章的最后,我们将讨论如何让它更快。
但在我重建一个重现问题的最小示例并深入了解细节之前,这里是......
总结
外键对于确保数据的结构和完整性至关重要。索引可以提高查询速度。我们可以将两者结合起来,充分利用数据库。
我通常不认为外键约束会影响性能。但正如我们即将看到的,它们确实会影响性能。
如果没有该索引,我们必然会遇到一些真正隐蔽的性能问题。
让我们深入研究一下。
一个最小的例子
这个简单的例子也是一个真实的例子。大多数软件系统都有用户,这些用户需要被赋予各种角色。这些角色帮助系统确定每个用户的访问权限。
如果您想跳过示例设置,您可以直接跳到详细信息。
数据模型
首先是users
表格。它在示例中不太突出,所以我把它放在id
列中。
然后我们有了一个roles
表,其中可以包含全局角色,如admin
和,support
以及与特定资源相关的角色,如team_owner
和team_collaborator
。
在两者之间建立关系是通过users_roles
连接表来实现的。此表中将我的记录user
与admin
角色绑定的记录会告诉系统我是管理员。此表中将我的user
记录与team_collaborator
红队角色绑定的另一条记录会告诉您,除了管理员之外,我还是红队的合作者。
构建模式
以下是一些 SQL,它们将生成用于重现性能问题的模式。其具体工作原理超出了本文的讨论范围。如果您对此感兴趣,请告诉我,我会写一篇后续文章。
完整的查询嵌入在这些代码块中,但您也可以查看此 repo中的代码。
create table users (
id bigint generated by default as identity primary key
);
create table roles (
id bigint generated by default as identity primary key,
name varchar not null,
resource_type varchar,
resource_id bigint
);
create table users_roles (
user_id bigint references users,
role_id bigint references roles
);
我把它放在一个名为的文件中schema.sql
。我从psql
会话连接到一个空数据库,在那里我可以安全地进行实验。然后我告诉psql
它执行脚本,它将创建这三个表及其序列。
> \i schema.sql
CREATE TABLE
Time: 2.605 ms
CREATE TABLE
Time: 2.615 ms
CREATE TABLE
Time: 1.141 ms
Time: 0.099 ms
> \d
List of relations
Schema | Name | Type | Owner
--------+--------------+----------+------------
public | roles | table | jbranchaud
public | roles_id_seq | sequence | jbranchaud
public | users | table | jbranchaud
public | users_id_seq | sequence | jbranchaud
public | users_roles | table | jbranchaud
(5 rows)
生成一些数据
让我们借助generate_series
函数来填充这些表格中的数据。同样,我不会在这里详细介绍。如果您对这部分的后续文章感兴趣,请留言。
-- create 50,000 users
insert into users
select from generate_series(1,50000);
-- create 100,000 roles
insert into roles (
name,
resource_type,
resource_id
)
select
(array['admin', 'support', 'member'])[floor(random() * 3 + 1)],
null,
null
from generate_series(1,50000);
insert into roles (
name,
resource_type,
resource_id
)
select
'team_collaborator',
'Team',
floor(random() * 1000 + 1)
from generate_series(1,50000);
-- create 500,000 connections between users and roles
-- start with 225,000 random global roles
insert into users_roles (
user_id,
role_id
)
select
floor(random() * 50000 + 1),
floor(random() * 50000 + 1)
from generate_series(1,225000);
-- then 50,000 for the team collaborator role
insert into users_roles (
user_id,
role_id
)
select
floor(random() * 50000 + 1),
floor(50000 + g.id)
from generate_series(1,50000) as g(id);
-- then another 225,000 random global roles
insert into users_roles (
user_id,
role_id
)
select
floor(random() * 50000 + 1),
floor(random() * 50000 + 1)
from generate_series(1,225000);
这将创建 50,000 个用户和 100,000 个角色。然后在users_roles
连接表中创建它们之间的 500,000 个连接,其中 50,000 个连接专门用于该team_collaborator
角色。
所有数据都到位后,我们来找出一些特定的数据并将其删除。这时,我们会看到一个合理的查询和一个速度明显变慢的查询。
快速查询、慢速查询
在我处理的实际生产数据集中,我需要从表中清除大量数据roles
。这些数据大约有 5 万条记录。由于存在外键关系,我必须首先清除users_roles
表中的依赖记录。
以下内容可能感觉有点牵强,但它是基于真实场景的。
以下是我们要删除的 50k 个角色。
> select count(*)
from roles
join users_roles
on roles.id = users_roles.role_id
where roles.name = 'team_collaborator';
count
-------
50000
(1 row)
为什么要删除所有team_collaborator
关联数据?因为这样一来,我们就会剩下大约 5 万行数据。就像我说的,这有点牵强。不过,我们还是可以从中学到一些性能方面的知识。
快速删除
让我们首先删除相关记录users_roles
。
每当我要修改或删除数据时,我都喜欢开启一个事务。这样,如果出现任何问题,就可以安全地回滚更改。这可以通过 来实现begin
。
> begin;
> delete from users_roles
using roles
where users_roles.role_id = roles.id
and roles.name = 'team_collaborator';
DELETE 50000
Time: 116.313 ms
看起来不错。该查询似乎处理了users_roles
我想要删除的 5 万条记录。因为我处于事务中,所以我总是可以用几个选择查询来深入研究数据以确保万无一失。而且它足够快,在这个例子中大约 100 毫秒。
我还没准备好提交此事务。接下来我想尝试删除roles
记录。
缓慢删除
处理完所有外键依赖关系后,我们就可以清楚地继续我们的主要目标,删除roles
记录。
这个查询比上一个更简单。我把它打出来后,确信它会运行得一样快。
> delete from roles
where roles.name = 'team_collaborator';
-- ... keep waiting ...
不过说实话,如果你在家跟着做,就不用太担心了。根据你的机器配置,这个过程大概需要 30 分钟左右。
怎么回事?
我们能解释一下吗?
这个查询和上一个查询没什么不同。它的目标是相同的行数。是什么原因导致速度变慢的?
让我们首先看一下explain
。
> explain delete from roles
where roles.name = 'team_collaborator';
QUERY PLAN
------------------------------------------------------------------
Delete on roles (cost=0.00..1937.00 rows=49990 width=6)
-> Seq Scan on roles (cost=0.00..1937.00 rows=49990 width=6)
Filter: ((name)::text = 'team_collaborator'::text)
(3 rows)
这部分没什么特别的。但有一点seq scan
让我印象深刻。这意味着查询规划器预期查询必须按顺序查看每一行。对于仅有约 5 万行的数据来说,这似乎不成问题。
相比之下,这是explain
前一个查询的。
> explain delete from users_roles
using roles
where users_roles.role_id = roles.id
and roles.name = 'team_collaborator';
QUERY PLAN
--------------------------------------------------------------------------------
Delete on users_roles (cost=2561.88..11577.43 rows=249950 width=12)
-> Hash Join (cost=2561.88..11577.43 rows=249950 width=12)
Hash Cond: (users_roles.role_id = roles.id)
-> Seq Scan on users_roles (cost=0.00..7703.00 rows=500000 width=14)
-> Hash (cost=1937.00..1937.00 rows=49990 width=14)
-> Seq Scan on roles (cost=0.00..1937.00 rows=49990 width=14)
Filter: ((name)::text = 'team_collaborator'::text)
(7 rows)
这个比较复杂。我注意到它有(50万行)的数据seq scan
,users_roles
下面嵌套着另一个(约5万行)的数据seq scan
。roles
就预计的工作量而言,这个数据还有很多工作要做,但它只用了100毫秒就完成了。计算机的速度真是快啊。
尽管进行了这样的比较,但从运行查询的结果来看,第一个(更简单的)查询将比第二个查询花费更长的时间。一些粗略的计算表明,用于删除数据的更简单的查询计划roles
所需的时间将是第二个查询的 10,000 倍😱。
这是这篇文章中的第二个尖叫表情符号——我们必须弄清楚发生了什么。
这个“简单”的删除查询有什么作用?
揭开谜底
> delete from roles
where roles.name = 'team_collaborator';
这个两行、5 万行的查询的问题与表本身无关,而是与该表与其他表的roles
关系有关。roles
该users_roles
表依赖于roles
表本身,具体来说,依赖于其id
列。这种依赖roles.id
是通过其自身列上的外键约束来实现的role_id
。
外键导致此问题
有点。
为了证明外键约束是此性能问题的核心,让我们首先删除外键约束。
> begin;
> delete from users_roles
using roles
where users_roles.role_id = roles.id
and roles.name = 'team_collaborator';
DELETE 50000
Time: 120.267 ms
> alter table users_roles
drop constraint users_roles_role_id_fkey;
ALTER TABLE
Time: 0.561 ms
> delete from roles
where roles.name = 'team_collaborator';
DELETE 50000
Time: 54.115 ms
> rollback;
取消外键约束后,删除操作非常快。请注意,我立即回滚了这些更改。这是为了强调约束的影响。这不是解决方案。
保持外键约束
外键约束对于可靠的模式设计至关重要,也是我最喜欢的 Postgres 功能之一。它有助于确保两个表之间的数据关系始终完整(这称为引用完整性)。有了这种保证,我再也不用担心数据会无意中被孤立。
外键约束如何对性能产生如此大的影响?
对于您指示 Postgres 从表中删除的每一行roles
,它都会首先检查所有依赖表,以确保可以执行此操作。如果关联表中的任何记录均未使用外键值,则删除操作可以继续。即使只有一条记录依赖于该值,外键约束也会标记该情况并中止删除操作。
因此,即使我们预先删除了所有相关数据users_roles
,Postgres 仍然必须在删除时检查是否仍然如此。
Postgres 会遍历users_roles
整个表,并按顺序扫描每一条记录。对于每条roles
记录,Postgres 将检查 50 万条users_roles
记录。
这就是问题的根源。
查询计划器需要更多信息
Postgres 将利用其掌握的信息来验证外键约束。目前,它没有足够的信息来做除了查看每一行之外更好的事情。但如果 Postgres 有索引,它就能获得必要的信息,从而减少很多工作。
数据库索引大致类似于图书馆使用的组织系统。书籍被分类,然后根据同一类别的其他书籍进行排序。使用这个系统,您可以快速导航到书籍应该在的位置。
没有索引users_roles.role_id
就像走进一个没有组织系统的图书馆,你必须从一端开始一本一本地翻阅,可能要翻阅数万本书,直到找到你要找的东西。
通过为 添加索引,我们可以将查询时间缩短到1 秒delete
左右。索引为 Postgres 提供了所需的信息,以便快速找到任何依赖行。users_roles.role_id
create index role_id_index
on users_roles (role_id);
CREATE INDEX
Time: 342.591 ms
这会为该列添加一个索引users_roles.role_id
。它是一个B 树索引。“每当比较涉及索引列时(使用诸如=
、<
、>
等运算符),PostgreSQL 查询规划器都会考虑使用 B 树索引”。
使用此索引,我们通常会获得更快的查询速度,尤其是在执行删除操作时。但有一个缺点,索引会占用少量磁盘空间。由于 Postgres 会在每次写入索引列时自动更新索引,因此这会增加一点点的写入时间。在绝大多数情况下,这两种缺点都是值得的。
让我们看看该指数的表现如何。
删除速度更快
有了这个索引,让我们再试一次我们的删除查询。
> begin;
> delete from users_roles
using roles
where users_roles.role_id = roles.id
and roles.name = 'team_collaborator';
DELETE 50000
Time: 111.732 ms
> delete from roles
where roles.name = 'team_collaborator';
DELETE 50000
Time: 590.668 ms
眨眼间就搞定了。第二个删除查询从大约 30 分钟缩短到了 600 毫秒以内。这是一个巨大的进步。这就是索引能帮我们做到的。
这让我回想起这篇文章的tl;dr 。
外键对于确保数据的结构和完整性至关重要。索引可以提高查询速度。我们可以将两者结合起来,充分利用数据库。
如果没有该索引,您肯定会遇到一些真正隐蔽的性能问题。
但是我们可以走得更快吗?!
删除速度更快
由于我们处于事务中并且正在进行大量删除操作,如果我们告诉 Postgres 推迟验证外键约束直到完成删除操作,结果会怎样呢?
> begin;
> alter table users_roles
alter constraint users_roles_role_id_fkey
initially deferred;
ALTER TABLE
Time: 0.455 ms
> delete from users_roles
using roles
where users_roles.role_id = roles.id
and roles.name = 'team_collaborator';
DELETE 50000
Time: 114.574 ms
> delete from roles
where roles.name = 'team_collaborator';
DELETE 50000
Time: 68.433 ms
哇,这又使其速度提高了 10 倍,达到约 68 毫秒。
即使使用索引,它也无需检查外键约束 50,000 次,只需在最后检查一次即可。太棒了!
总结
在遇到这个极其缓慢的删除查询后,我花了很长时间试图找出导致性能下降的原因。即使你每次只能理解一点点,只要你愿意深入研究一个难题并努力理解,就能学到很多东西。我最终得到了更好的结果。我希望你读完这篇文章后也能有所收获。
外键有助于确保数据的完整性。索引有助于提高查询速度。将两者结合起来,可以真正发挥 PostgreSQL 的速度和强大功能。
如果您喜欢我的作品,请考虑订阅我的时事通讯或在推特上关注我。
笔记
-
对此模式的一个改进是向外键约束添加一条指令
on delete cascade
。role_id
这意味着我们可以直接删除roles
记录,相应的users_roles
记录也会被自动移除。但这仍然会受到相同的性能问题的影响。 -
Postgres 允许并发添加索引,以避免锁定对整个表的写入。虽然添加索引是一个相当快速的操作,但并发添加是一个很好的经验法则。在生产环境中处理实时表时尤其如此。为了避免再引入一个概念,我选择在本文中不并发添加索引。
致谢
非常感谢几位阅读本文初稿并提出反馈的人。他们太棒了。看看他们的作品吧。
- Simon Willison ,他在datasette上做出了出色的工作
- Chris Toomey,他在 Vim 和 Postgres 方面撰写了精彩的文章
- Jordan Lewis,每周都会发布有关围棋和数据库的精彩内容
- 狄龙·哈弗
- 马克斯·罗森
封面照片由Ryan Johnston在Unsplash上拍摄
鏂囩珷鏉ユ簮锛�https://dev.to/jbranchaud/beware-the-missing-foreign-key-index-a-postgres-performance-gotcha-3d5i