PostgreSQL Internals之路 Part-III 第15章 内存结构上的锁详细介绍

文摘   科技   2024-10-23 09:00   北京  


微信群:数据库Hacker,  已超200人,无法通过扫描直接加入。需要入群的朋友,请直接微信联系我(个人微信:_iihero),标上您的全名_数据库Hacker作为备注。


15. 内存结构上的锁

15.1 自旋锁

要保护共享内存上的数据结构,PostgreSQL使用了几种轻量级的、不那么昂贵的锁,而没有使用普通的重量级锁。

最简单的锁就是自旋锁(spinlocks)。它们通常在非常短的时间内获得(不超过几个CPU时钟周期),用于在并发更新中保护指定的内存单元。

自旋锁基于原子的CPU指令,例如比较和交换(compare-and-swap[1])。它们只支持排它的锁模式。如果需要的资源已经被锁,进程处于忙等待,重复该命令(它在循环中“旋转”,因此得名)。如果锁在指定的时间间隔内没能得到,进程将暂停一段时间,接着启动另一个循环。

在冲突预计比较低的情况下,这个策略还是有效的,在不成功的尝试下,锁有可能在几条指令以内就能得到。

自旋锁既没有死锁检测,也没有可调性(instrumentation)。从实践角度来看,我们应该简单的知道它们的存在;它们正确的实现的整个责任完全在于PostgreSQL的开发人员。

15.2 轻量级锁

下一节,有所谓的轻量级锁,或叫lwlocks[2]。轻量级锁获取它,并处理一个数据结构所花的时间一般都很短(如,hash表或者指针链表);但是,在保护I/O操作方面,所花的时间就会长些。

轻量级锁支持两种模式:排它模式(用于数据修改)和共享模式(用于只读操作)。没有这样的队列:如果多个进程都在等一个锁,其中一个将以或多或少随机的方式去访问该资源。在高负载多个并发进程的系统中,它会导致令人不愉快的结果。

没有提供死锁检测;我们不得不相信PG开发人员,轻量级锁的实现是正确的。但是,这些锁提供有调试的方法,所以,不像自旋锁,它们是可以被观察的。

15.3 实例

想知道自旋锁和轻量级锁如何用和在哪里被用,我们可以看看两种共享存储的结构:缓冲缓存和WAL缓冲。我会只对某些锁命名;完整的图太复杂了,应该只对PG核心开发人员有吸引力。

缓冲缓存(Buffer Cache)

要访问用于定位特定缓存中缓冲的hash表,进程必须获得一个BufferMapping的轻量级锁,可以是用于读操作的共享模式,也可以是用于任意修改操作的排它模式。


hash表访问起来非常频繁,这样该锁就经常会成为瓶颈。为了最大化锁粒度,它的结构是tranche,128个单个轻量级锁的一部分,每个锁保护哈希表[3]的单独部分。

一个hash表锁会被转换成16个锁的层级(tranche),于2006年实现,对应于PG8.2; 10年过去了,当9.5发布时,这个层级的大小增加到128,但是对于现在多核系统而言,可能仍显不够。

要想访问缓冲头,进程需要获得缓冲头的自旋锁[4](名字是随意的,因为自旋锁没有用户可见的名字)。有些操作,像增加使用的计数器,不需要显式地获取锁,完全可以借用原子的CPU指令得以执行。

要读缓冲中的一页,进程需要获得在这个缓冲的头部的BufferContent锁。它通常在元组指针被读的时候持有;后来,由缓冲绑定提供保护就已足够。如果缓冲内容必须要修改,这个BufferContent锁就必须以排它模式获得。

当缓冲要从磁盘上读取(或写入磁盘),PG也要获得缓冲头部的BufferIO锁;它事实上只是锁的一个属性,而不是一个真正的锁。它通知其它进程要请求该页,必须等到I/O操作完成。

指向空闲缓冲的指针和刷出机制的时钟指针都被单个通用的缓冲策略自旋锁保护[5]

WAL缓冲


WAL缓存也使用了hash表,将页映射到缓冲。不像buffer cache那样的hash表,它是被单个WALBufMapping轻量级保护,因为WAL缓存很小(通常只是buffer cache大小的1/32),并且缓冲的访问更加有序[6]

将WAL页写到磁盘受WALWrite轻量级锁保护,它也确保了该操作同时只被一个进程执行。

要创建一个WAL条目,进程先预留WAL页上的一些空间,然后向里边添加数据。空间预留严格有序;进程必须获得一个插入位置的自旋锁来保护插入指针。但是一旦空间被预定,它将会被多个并发进程填充。基于此目的,每个进程必须获取8个轻量级锁中的任意一个,来组成WALInsert的层级(tranche[7])。

15.4 监控等待 (Monitoring Waits)

毫无疑问,正确的PG操作离不开锁,但是它们也会导致不必要的等待。跟踪这样的等待有助于更好的理解它们的初衷。

最简单的方式是将log_lock_waits(默认为off)开关打开,然后观察一下长期锁的全貌;它支持大量记录所有导致事务等待时间超过deadlock_timeout(默认1秒)的锁。此数据在死锁检查完成时显示,因此有了参数名。

但是,视图pg_stat_activity提供了更多完整有用的信息。无论何时一个进程 --- 或者是系统进程或者是后端进程---不能继续执行任务,因为它在等待某一样东西,这个等待就反映在wait_event_type和wait_event字段上,它们分别显示了等待的类型和名字。

所有等待可以做如下分类:

等待不同种类的锁形成一个特别大的组:

Lock --- 重量级锁

LWLock --- 轻量级锁

BufferPin --- 栓定的缓冲

但是进程一样也可以等待其它事件:

IO --- 输入/输出,当需要读或写数据的时候

Client --- 客户端发送的数据 (psql有相当大一部分时间是处于此状态)

IPC --- 由其它进程发送的数据

Extension --- 由扩展注册的一种特殊的事件

有时候一个进程简单的不做任何有用的工作。这样的等待通常是“正常的”,意味着它们不会提示有任何问题。这一组由下边的等待构成:

Activity --- 后台进程在它们的主循环当中

Timeout --- 计时器

每个等待类型的锁可以按等待名进一步划分。例如,在轻量级锁上等待,获得锁的名字或者对应的tranche(层级)[8]

您应该记住,pg_stat_activity视图只显示在源码中以恰当方式处理的那些等待[9]。除非等待名在视图中出现,进程不会出现等待任何已知类型的状态。这样的时间应该被认为是没有解释的;这并不一定意味着这个过程不需要等待任何东西——我们只是不知道此刻正在发生什么。

=> SELECT backend_type, wait_event_type AS event_type, wait_event
FROM pg_stat_activity;
         backend_type         | event_type |     wait_event
------------------------------+------------+---------------------
 autovacuum launcher          | Activity   | AutoVacuumMain
 logical replication launcher | Activity   | LogicalLauncherMain
 logical replication worker   | Activity   | LogicalApplyMain
 walsender                    | Client     | WalSenderWaitForWAL
 walsender                    | Activity   | WalSenderMain
 client backend               |            |
 background writer            | Activity   | BgWriterMain
 checkpointer                 | Activity   | CheckpointerMain
 walwriter                    | Activity   | WalWriterMain
(9 rows)

这里所有的后台进程都处于空闲状态,当视图被采样的时候,而客户端后端忙于执行查询,并且不在等待。

15.5 采样

不幸的是,pg_stat_activity视图显示的只是当前的等待信息;而且这些统计不是累积的。随着时间的推移收集等待数据的唯一方法是定期对视图进行采样。

我们必须考虑采样的随机性质。等待相对于采样间隔时间如果越短,发现这个等待的机会就越少。

这样,更长的采样间隔就需要更多的能反映真实状态的采样(您可以增加采样频率,总体开销也会提升)。也是相同的原因,采样对于分析短期会话基本上没有用。

PostgreSQL没有提供构建好的工具用于采样;但是,我们还是可以使用pg_wait_sampling[10]插件来试一下。我们必须在参数shared_preload_libraries里头指定它的库,并且重启服务器:

=> ALTER SYSTEM SET shared_preload_libraries = 'pg_wait_sampling';
postgres$ pg_ctl restart -l /home/postgres/logfile

现在我们将插件装到数据库里头:

=> CREATE EXTENSION pg_wait_sampling;

这个扩展可以显示等待的历史,保存在自己的环缓冲里。但是,更有趣的是获得等待的配置--- 整个会话期间累积的统计信息。

例如,我们可以看下基准测试期间的等待。我们需要启动pgbench工具,确定运行期间它的进程ID:

postgres$ /usr/local/pgsql/bin/pgbench -T 60 internals
=> SELECT pid FROM pg_stat_activity
WHERE application_name = 'pgbench';
pid
−−−−−−−
36380
(1 row)

一旦测试结束,等待的配置看起来如下:

=> SELECT pid, event_type, eventcount
FROM pg_wait_sampling_profile WHERE pid = 36380
ORDER BY count DESC LIMIT 4;
pid | event_type | event | count
−−−−−−−+−−−−−−−−−−−−+−−−−−−−−−−−−−−+−−−−−−−
36380 | IO | WALSync | 4067
36380 | IO | WALWrite | 98
36380 | Client | ClientRead | 26
36380 | IO | DataFileRead | 4
(4 rows)

默认情况下(通过设置pg_wait_sampling.profile_period参数)采样大概是每秒100次。因此预测以秒为单位的持续等待,需要将它除以100。

在这个的例子中,大部分等待都与WAL日志刷盘有关。这是一个不计时间的好示例:WALSync事件直到PG12才可以调试;更低的版本,一个等待的配置不包含第一行,尽管它自己仍在那里。

下边是一个配置的基本情况,如果我们把文件系统的每个I/O操作降慢到0.1秒(使用slowfs[11]达到此目的):

数据库杂记
数据库技术专家,PostgreSQL ACE,SAP HANA,Sybase ASE/ASA,Oracle,MySQL,SQLite各类数据库, SAP BTP云计算技术, 以及陈式太极拳教学倾情分享。出版过三本技术图书,武术6段。
 最新文章