暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

[译文] PostgreSQL 中的锁:4. 内存中的锁

原创 Egor Rogov 2021-08-04
1441

本文将对RAM 中的锁进行讨论,介绍自旋锁、轻量级锁和缓冲区引脚,以及事件监控工具和采样。

image.png

自旋锁

与普通的“重量级”锁不同,为了保护共享内存中的结构,使用了更轻量级和更便宜(开销成本)的锁。

其中最简单的是自旋锁。它们旨在以非常短的时间间隔(一些处理器指令)获取,并且它们保护单独的内存区域免受同时更改。

自旋锁是基于原子处理器指令实现的,例如比较和交换。它们支持唯一的、排他的、模式。如果获取了锁,则等待进程执行忙等待——重复该命令(在循环中“旋转”,因此得名)直到它成功。这是有道理的,因为自旋锁用于估计冲突概率非常低的情况。

自旋锁不能检测死锁(PostgreSQL 开发人员会处理这个问题)并且不提供监控工具。本质上,我们对自旋锁唯一能做的就是意识到它们的存在。

轻量级锁

接下来是所谓的轻量级锁(lwlocks)。

它们在处理数据结构(例如哈希表或指针列表)所需的短时间内获得。通常,轻量级锁会被短暂持有,但有时轻量级锁会保护输入/输出操作,因此一般来说,时间也可能相当可观。

支持两种模式:独占(用于数据修改)和共享(仅用于读取)。实际上没有等待队列:如果有几个进程等待释放锁,其中一个进程或多或少会以随机的方式获得访问权限。在高并发和大负载系统中,这可能很麻烦(例如,请参阅此讨论)。

没有检查死锁的技术,因此这由核心开发人员负责。然而,轻量级锁具有监控工具,因此,与自旋锁不同,我们可以“看到”它们(稍后我将展示如何做到这一点)。

缓冲引脚

另一种类型的锁,是缓冲区 pin。

不同的操作,包括数据修改,都可以用一个固定的缓冲区来执行,但条件是由于多版本并发控制,更改对其他进程不可见。也就是说,例如,我们可以向页面添加新行,但不能用另一个页面替换缓冲区中的页面。

如果一个缓冲区引脚阻碍了一个进程,通常,后者只是跳过这个缓冲区并选择一个不同的缓冲区。但在某些情况下,正是需要这个缓冲区的地方,进程会排队并“睡着”;当缓冲区被取消固定时,系统将唤醒它。

监控可以访问与缓冲区引脚相关的等待。

示例:缓冲区缓存

image.png

现在,为了深入了解锁的使用方式和位置(尽管不完整!),让我们以缓冲区缓存为例。

要访问包含缓冲区链接的哈希表,进程必须以共享模式获取轻量级缓冲区映射锁,如果需要更新表,则以独占模式获取。为了减小粒度,该锁被构造为档,它由128个单独的锁,每个保护其自身的哈希表的一部分。

该进程使用自旋锁访问缓冲区头。某些操作(例如计数器增量)也可以在没有显式锁定的情况下通过原子处理器指令执行。

为了读取缓冲区的内容,需要缓冲区内容锁。它通常仅在读取元组指针所需的时间内获取,之后,缓冲区引脚提供足够的保护。要更改缓冲区的内容,必须以独占模式获取此锁。

当从磁盘读取缓冲区(或写入磁盘)时,还会获取正在进行的IO锁,这向其他进程表明页面正在被读取(或写入)——如果它们需要做某事,它们可以排队这一页。

指向空闲缓冲区和下一个受害者的指针受到一个缓冲区策略锁自旋锁的保护。

示例:WAL 缓冲区

WAL 缓冲区提供了另一个例子。

WAL 缓存还使用一个哈希表,其中包含页面到缓冲区的映射。与缓冲区缓存不同,这个哈希表由唯一的轻量级WALBufMappingLock锁保护,因为 WAL 缓存的大小更小(通常是缓冲区缓存的 1/32)并且对缓冲区的访问更加规律。

将页面写入磁盘受WALWriteLock锁保护,因此一次只有一个进程可以执行此操作。

要创建 WAL 记录,进程必须首先在 WAL 页中分配空间。为此,它需要一个插入位置锁自旋锁。分配空间后,进程将其记录的内容复制到分配的空间中。复制可以由多个进程同时执行,因此,记录受到 8 个轻量级wal insert锁的保护(进程必须获取其中的任何一个)。

该图并未显示所有与 WAL 相关的锁,但此示例和前面的示例必须说明如何使用 RAM 中的锁。

等待事件监控

从 PostgreSQL 9.6 开始,该pg_stat_activity视图具有内置的事件监控工具。当进程(系统或后端)无法完成其工作并等待某事时,我们可以在视图中看到此等待:该wait_event_type列显示等待类型,该wait_event列显示特定等待的名称。

请注意,该视图仅显示在源代码中正确处理的等待。如果视图没有显示等待,一般来说,这并不意味着进程真的什么都不等待的可能性为 100%。

不幸的是,关于等待的唯一可用信息是当前信息。不维护累积统计数据。及时获取等待图片的唯一方法是在某个时间间隔对视图的状态进行采样。没有为此提供内置工具,但我们可以使用扩展,例如pg_wait_sampling。

我们需要考虑抽样的概率性质。为了获得或多或少可信的图片,测量的数量必须非常大。低频采样可能无法提供可靠的图像,而使用更高的频率会增加开销成本。出于同样的原因,抽样对于分析短期会话是无用的。

所有的等待可以分为几种类型。

所讨论的等待锁构成了一个大类:

  • 等待的对象的锁的值(Lock在wait_event_type列)。
  • 等待轻量级锁 ( LWLock)。
  • 等待缓冲引脚 ( BufferPin)。

但是进程也可以等待其他事件:

  • IO当进程需要读取或写入数据时,等待输入/输出 ( ) 发生。
  • 进程可以等待来自客户端 ( Client) 或另一个进程 ( IPC)所需的数据。
  • 扩展可以注册其特定的等待 ( Extension)。

有时,当一个流程没有做任何富有成效的工作时,就会出现这种情况。该类别包括:

  • 在其主循环 ( Activity) 中等待后台进程。
  • 等待计时器 ( Timeout)。

通常,像这样的等待被视为“正常”并且不表示任何问题。

等待类型后跟特定等待的名称。有关完整表格,请参阅文档。

如果未定义等待的名称,则进程不处于等待状态。我们需要将这个时间点视为下落不明,因为我们实际上不知道那一刻到底发生了什么。

但是,让我们自己看看。

=> SELECT pid, backend_type, wait_event_type, wait_event FROM pg_stat_activity;
pid | backend_type | wait_event_type | wait_event -------+------------------------------+-----------------+--------------------- 28739 | logical replication launcher | Activity | LogicalLauncherMain 28736 | autovacuum launcher | Activity | AutoVacuumMain 28963 | client backend | | 28734 | background writer | Activity | BgWriterMain 28733 | checkpointer | Activity | CheckpointerMain 28735 | walwriter | Activity | WalWriterMain (6 rows)

很明显,所有后台后端进程都处于空闲状态。空值wait_event_type,并wait_event告诉我们,进程正在等待什么; 在我们的示例中,后端进程正忙于执行查询。

采样

为了通过采样获得或多或少完整的等待情况,我们将使用pg_wait_sampling扩展。我们需要从源代码构建它,但我将省略这部分。然后我们将库名称添加到shared_preload_libraries参数并重新启动服务器。

=> ALTER SYSTEM SET shared_preload_libraries = 'pg_wait_sampling';
student$ sudo pg_ctlcluster 11 main restart

现在我们在数据库中安装扩展。
sql

=> CREATE EXTENSION pg_wait_sampling;

该扩展允许我们查看存储在循环缓冲区中的等待历史。但我们最感兴趣的是等待配置文件,即自服务器启动以来累积的统计信息。

这大致是我们几秒钟后将看到的:

=> SELECT * FROM pg_wait_sampling_profile;
pid | event_type | event | queryid | count -------+------------+---------------------+---------+------- 29074 | Activity | LogicalLauncherMain | 0 | 220 29070 | Activity | WalWriterMain | 0 | 220 29071 | Activity | AutoVacuumMain | 0 | 219 29069 | Activity | BgWriterMain | 0 | 220 29111 | Client | ClientRead | 0 | 3 29068 | Activity | CheckpointerMain | 0 | 220 (6 rows)

因为自服务器启动后没有发生任何事情,大多数等待指的是类型Activity(后端进程等待直到有一些工作)和Client(psql等待用户发送请求)。

使用默认设置(pg_wait_sampling.profile_period参数),采样周期等于 10 毫秒,这意味着值每秒保存 100 次。因此,要以秒为单位评估等待的持续时间,我们需要将 的值count除以 100。

要确定等待属于哪个进程,让我们将pg_stat_activity视图添加到查询中:

=> SELECT p.pid, a.backend_type, a.application_name AS app, p.event_type, p.event, p.count FROM pg_wait_sampling_profile p LEFT JOIN pg_stat_activity a ON p.pid = a.pid ORDER BY p.pid, p.count DESC;
pid | backend_type | app | event_type | event | count -------+------------------------------+------+------------+----------------------+------- 29068 | checkpointer | | Activity | CheckpointerMain | 222 29069 | background writer | | Activity | BgWriterMain | 222 29070 | walwriter | | Activity | WalWriterMain | 222 29071 | autovacuum launcher | | Activity | AutoVacuumMain | 221 29074 | logical replication launcher | | Activity | LogicalLauncherMain | 222 29111 | client backend | psql | Client | ClientRead | 4 29111 | client backend | psql | IPC | MessageQueueInternal | 1

让我们使用生成一些工作负载pgbench,看看图片是如何变化的。

tudent$ pgbench -i test

我们将累积的配置文件重置为零,并在单独的过程中运行测试 30 秒。

=> SELECT pg_wait_sampling_reset_profile();
student$ pgbench -T 30 test

我们需要在pgbench过程尚未完成时执行查询:

=> SELECT p.pid, a.backend_type, a.application_name AS app, p.event_type, p.event, p.count FROM pg_wait_sampling_profile p LEFT JOIN pg_stat_activity a ON p.pid = a.pid WHERE a.application_name = 'pgbench' ORDER BY p.pid, p.count DESC;
pid | backend_type | app | event_type | event | count -------+----------------+---------+------------+------------+------- 29148 | client backend | pgbench | IO | WALWrite | 8 29148 | client backend | pgbench | Client | ClientRead | 1 (2 rows)

pgbench进程的等待肯定会因特定系统而略有不同。在我们的情况下,很可能会出现等待 WAL 写入 ( IO/ WALWrite),但是,大部分时间该进程正在做一些可能有成效的事情,而不是闲置。

轻量级锁

我们总是需要记住,如果采样时没有等待,这并不意味着真的没有等待。如果等待时间短于采样周期(在我们的示例中为百分之一秒),则它可能无法进入样本。

这就是为什么配置文件中没有出现轻量级锁的原因,但如果数据收集时间长,它们就会出现。为了能够确定地看到它们,我们可以有意地减慢文件系统的速度,例如,通过使用构建在FUSE文件系统之上的slowfs项目。

如果任何输入/输出操作需要 1/10 秒,这就是我们在同一测试中可以看到的。

=> SELECT pg_wait_sampling_reset_profile();
student$ pgbench -T 30 test
=> SELECT p.pid, a.backend_type, a.application_name AS app, p.event_type, p.event, p.count FROM pg_wait_sampling_profile p LEFT JOIN pg_stat_activity a ON p.pid = a.pid WHERE a.application_name = 'pgbench' ORDER BY p.pid, p.count DESC;
pid | backend_type | app | event_type | event | count -------+----------------+---------+------------+----------------+------- 29240 | client backend | pgbench | IO | WALWrite | 1445 29240 | client backend | pgbench | LWLock | WALWriteLock | 803 29240 | client backend | pgbench | IO | DataFileExtend | 20 (3 rows)

现在该pgbench过程的主要等待与输入/输出有关,更准确地说,与 WAL 写入有关,每次提交都会同步发生。因为(如上述示例之一所示)WAL 写入受到轻量级WALWriteLock锁的保护,因此该锁也存在于配置文件中——这正是我们想要查看的。

缓冲引脚

要查看缓冲区 pin,让我们利用打开的游标持有 pin 以更快地读取下一行的事实。

让我们开始一个事务,打开一个游标并选择一行。

=> BEGIN; => DECLARE c CURSOR FOR SELECT * FROM pgbench_history; => FETCH c;
tid | bid | aid | delta | mtime | filler -----+-----+-------+-------+----------------------------+-------- 9 | 1 | 35092 | 477 | 2019-09-04 16:16:18.596564 | (1 row)

让我们检查缓冲区是否已固定 ( pinning_backends):

=> SELECT * FROM pg_buffercache WHERE relfilenode = pg_relation_filenode('pgbench_history') AND relforknumber = 0 \gx
-[ RECORD 1 ]----+------ bufferid | 190 relfilenode | 47050 reltablespace | 1663 reldatabase | 16386 relforknumber | 0 relblocknumber | 0 isdirty | t usagecount | 1 pinning_backends | 1 <-- buffer is pinned 1 time

现在让我们清空

| => SELECT pg_backend_pid();
| pg_backend_pid | ---------------- | 29367 | (1 row)
| => VACUUM VERBOSE pgbench_history;
| INFO: vacuuming "public.pgbench_history" | INFO: "pgbench_history": found 0 removable, 0 nonremovable row versions in 1 out of 1 pages | DETAIL: 0 dead row versions cannot be removed yet, oldest xmin: 732651 | There were 0 unused item pointers.
| Skipped 1 page due to buffer pins, 0 frozen pages.
| 0 pages are entirely empty. | CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s. | VACUUM

正如我们所看到的,页面被跳过 ( Skipped 1 page due to buffer pins)。实际上,VACUUM 无法处理它,因为禁止从固定缓冲区中的页面物理删除元组。但是真空也不会等待,下次再处理页面。

| => VACUUM FREEZE VERBOSE pgbench_history;

如果明确请求冻结,则不能跳过所有冻结位中跟踪的页面;否则,不可能减少 中非冻结交易的最大年龄pg_class.relfrozenxid。因此,吸尘会挂起,直到光标关闭。

=> SELECT age(relfrozenxid) FROM pg_class WHERE oid = 'pgbench_history'::regclass;
age ----- 27 (1 row)
=> COMMIT; -- cursor closes automatically
| INFO: aggressively vacuuming "public.pgbench_history" | INFO: "pgbench_history": found 0 removable, 26 nonremovable row versions in 1 out of 1 pages | DETAIL: 0 dead row versions cannot be removed yet, oldest xmin: 732651 | There were 0 unused item pointers.
| Skipped 0 pages due to buffer pins, 0 frozen pages.
| 0 pages are entirely empty. | CPU: user: 0.00 s, system: 0.00 s, elapsed: 3.01 s. | VACUUM
=> SELECT age(relfrozenxid) FROM pg_class WHERE oid = 'pgbench_history'::regclass;
age ----- 0 (1 row)

让我们看看第二个psql会话的等待配置文件,在那里执行了 VACUUM 命令:

=> SELECT p.pid, a.backend_type, a.application_name AS app, p.event_type, p.event, p.count FROM pg_wait_sampling_profile p LEFT JOIN pg_stat_activity a ON p.pid = a.pid WHERE p.pid = 29367 ORDER BY p.pid, p.count DESC;
pid | backend_type | app | event_type | event | count -------+----------------+------+------------+------------+------- 29367 | client backend | psql | BufferPin | BufferPin | 294 29367 | client backend | psql | Client | ClientRead | 10 (2 rows)

感谢大家的阅读和评论!

原文链接:https://postgrespro.com/blog/pgsql/5968022

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论