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

【干货分享】深度解析Buffer Manager、Index-Only Scans、HOT

云贝教育 2022-01-25
560





解析PostgreSQL系列已经过半

今天给大家分享的是Buffer Manager、Index-Only Scans、HOT

我们具体来看看崔鹏老师是怎么分析的吧



NO.1

体系架构概览






缓冲管理器结构


Buffer Manager与backend process之间的关系:
管理共享内存和持久存储之间的数据传输,对DBMS的性能有着重要的影响
1.缓冲表
2.缓冲区描述符
3.缓冲池
存储形式数据、每个槽中存储一个页面、数据组索引需要Buffer_id。
存储数据文件页面、表页、索引页、VM页面、FSM页面等。





NO.2 

Buffer Tag(标签)





缓冲区标签,数据页面被分配到的唯一标签标识。缓冲管理器收到请求后,通过缓冲区标签找到对应的数据页面。Buffer Tag标识了缓冲区中包含着哪一个磁盘块。

1.代表表空间、数据库、表的oid

2.关系表的分支号

  • 2.1 主体数据文件 0

  • 2.2 _fsm 空闲空间映射文件 1

  • 2.3 _vm可见性映射文件 2

  • 2.4 init文件 3 unlogged表

3.页面号

缓冲区标签由3个值组成



Buffer Tag(标签)

postgres=# select oid from pg_class where relname='test';
oid
-------
16972
(1 row)


postgres=# select pg_relation_filepath('test');
pg_relation_filepath
----------------------
base/13593/16972
postgres=# select * from pg_tablespace;
oid | spcname | spcowner | spcacl | spcoptions
------+------------+----------+--------+------------
1663 | pg_default | 10 | |
1664 | pg_global | 10 | |
postgres=# select ctid from test;
ctid
-------
(0,1)
(0,2)
(0,3)
(0,4)
(0,5)
(5 rows)

缓冲区标签举例:

表oid            16972
数据库oid 13593
表空间oid 1663
主文件分支 0
空闲空间映射fsm分支编号 1


缓冲区标签:

{(表空间oid,数据库oid,表oid),FSM文件分支编号,FSM文件的页面号}






NO.3

 Backend Process Reads Pages





读操作


1.后端进程向缓冲管理器发出请求,请求带有buffer_tag。
2.缓冲管理器会根据buffer_tag返回一个buffer_id,即目标页面存储在数组中的槽位序号,如果请求的页面没有在缓冲区中,那么缓冲管理器会将页面从持久存储位置加载到其中一个缓冲池槽位中,然后在返回该槽位的buffer_id。
3.后端进程访问buffer_id对应的槽位,读取所需要的数据页面。


写操作


当后端进程修改缓冲池中的页面时(插入、修改、删除),这种尚未刷新到持久存储,但已被修改的页面被称为脏页。





NO.4

缓冲管理器结构




缓冲区管理器由三层组成
1.缓冲表层
   存储页面的buffer_tag与描述符的buffer_id之间的映射关系。

2.缓冲区描述符层
   缓冲区描述符组成的数组,每个描述符与缓冲池槽对应,并保存着相应槽的元数据。

3.缓冲池层
   数组形式存储,每个槽都存储一个数据文件页面。数组槽位的索引称为buffer_id。




缓冲表层

缓冲表分为三部分:
1.散列函数
2.散列桶槽
3.数据项
内置散列函数将buffer_tag映射到哈希桶槽。数据项包括两个值,即页面的buffer_tag和包含页面元数据描述符的buffer_id。
例:数据项Tag_A,id=1表示,在buffer_id=1对应的缓冲区描述符中,存储着页面Tag_A的元数据。


缓冲区描述符层

缓冲区描述符保存着页面的元数据,这些与缓冲区描述符相对应的页面保存在缓冲池槽中。
缓冲区描述符层的集合构成了一个数组。当PostgreSQL服务器启动时,所有缓冲区描述符的状态都为空。在PostgreSQL,描述符组成了一个freelist链表。


页面加载过程:
1.freelist的头部取一个空描述符。
2.在缓冲表中插入新项,缓冲表项保存了页面的Buffer_tag与所获描述符buffer_id之间的关系。
3.将新页面从存储器加载至相应的缓冲池槽中。
4.将新页面的元数据保存至所获取的描述符中。



缓冲池层

缓冲池只是一个用于存储关系数据文件页面得到简单数组,缓冲池数组的序号索引(buffer_id)。
缓冲池槽的大小8KB与页面大小相同,因而每个槽都能存储下整个页面。





NO.5 缓冲管理器锁




缓冲区管理器锁-缓冲表锁


缓冲区管理器的锁指的是缓冲区管理器同步机制的一部分~BufMappingLock保护整个缓冲表的数据完整性, BufMappingLock是一种轻量级的内存锁。
分为共享模式与独占模式。在缓冲表中查询条目时,后端进程会持有共享的BufMappingLock。插入或删除条目时,后端进程会持有独占的BufMappingLock。
BufMappingLock会被分为多个分区,以减少缓冲表中的争用(默认为128个分区)。每个BufMappingLock分区都保护着一部分相应的散列桶槽。
两个后端进程可以同时持有各自分区的BufMappingLock独占锁,以插入新的数据项。如果BufMappingLock是系统级的锁,那么其中一个进程就需要等待另一个进程完成后处理。




缓冲区管理器锁-缓冲区描述符相关的锁

缓冲区管理器的锁指的是缓冲区管理器同步机制的一部分~每个缓冲区描述符都会用到内容锁(content_lock) 与 IO进行锁(io_in_progress_lock)这两个轻量级锁,以控制对相应缓冲池槽页面的访问.当检查或更改描述符本身字段的值时,就会用到自旋锁。


自旋锁(Spin lock)


自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,“自旋锁”的作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远高于互斥锁。
  • 自旋锁的不足之处:

自旋锁一直占用着CPU,他在未获得锁的情况下,一直运行(自旋),所以占用着CPU,如果不能在很短的时间内获得锁,这无疑会使CPU效率降低。

  • 缓冲区管理器锁-内容锁

缓冲区管理器的锁指的是缓冲区管理器同步机制的一部分~内容锁(Content_lock)是一个典型的强制限制访问的锁,它有共享与独占两种模式。当读取页面时,后端进程以共享模式获取页面相应缓冲区描述符中的content_lock。

以下操作会获取独占模式的content_lock:

1.在相关元组被删除或更新行时发生更改(xmin/xmax变更)写操作

2.物理移除元组(vacuum和HOT触发)

3.FREEZE冻结




缓冲区管理器锁-IO进行锁


缓冲区管理器的锁指的是缓冲区管理器同步机制的一部分~IO进行锁(io_in_progress_lock)用于等待缓冲区上的I/O完成。当PostgreSQL进程加载/写入页面数据时,该进程在访问页面期间。

持有对应描述符上独占的io_in_progres_lock

typedef struct sbufdesc
{
BufferTag tag; /* 存储在缓冲区中页面的标识 */
BufFlags flags; /* 标记位 */
uint16 usage_count; /* 时钟扫描要用到的引用计数 */
unsigned refcount; /* 在本缓冲区上持有PIN的后端进程数 */
int wait_backend_pid; /* 等着PIN本缓冲区的后端进程PID */
slock_t buf_hdr_lock; /* 用于保护上述字段的锁 */
int buf_id; /* 缓冲的索引编号 (从0开始) */
int freeNext; /* 空闲链表中的链接 */
LWLockId io_in_progress_lock; /* 等待I/O完成的锁 */
LWLockId content_lock; /* 访问缓冲区内容的锁 */
} BufferDesc;


缓冲区管理器锁-自旋锁


缓冲区管理器的锁指的是缓冲区管理器同步机制的一部分~自旋锁当检查或更改标记字段与其他字段时,例如 refcount和 usage_count,会用到自旋锁。


下面是两个使用自旋锁的具体例子

  • 钉住缓冲区描述符:

1.获取缓冲区描述符上的自旋锁。
2.将其refcount和usage_count的值增加1。
3.释放自旋锁
  • 将脏位设置为"1":

         1.获取缓冲区描述符上的自旋锁。

         2.使用位操作将脏位置位为"1"。

         3.释放自旋锁。



缓冲区管理器的工作原理


访问存储在缓冲池中的页面
1.当从缓冲池槽中的页面里读取行时,PostgreSQL 进程获取相应缓冲区描述符的共享content_lock,因而缓冲池槽可以同时被多个进程读取。
2.当向页面插入(及更新、删除)行时,该 postgres后端进程获取相应缓冲区描述符的独占content_lock(注意,这里必须将相应页面的脏位置设为"1")。
3.访问完页面后,相应缓冲区描述符的引用计数值减1。


将页面从存储加载到空槽
在第二种情况下,假设所需页面不在缓冲池中,且 freelist 中有空闲元素(空描述符)。这时,缓冲区管理器将执行以下步骤:
1)查找缓冲区表(本节假设页面不存在,找不到对应页面)。
创建所需页面的 buffer_tag(本例中 buffer_tag 为 ‘Tag_E’ )并计算其散列桶槽。以共享模式获取相应分区上的 BufMappingLock。查找缓冲区表(根据假设,这里没找到)。释放 BufMappingLock。
2)从 freelist 中获取空缓冲区描述符,并将其钉住。在本例中所获的描述符:buffer_id=4
3)以独占模式获取相应分区的 BufMappingLock(此锁将在步骤(6)中被释放)。
4)创建一条新的缓冲表数据项:buffer_tag=‘Tag_E’, buffer_id=4,并将其插入缓冲区表中。
5)将页面数据从存储加载至 buffer_id=4 的缓冲池槽中,如下所示:
以排他模式获取相应描述符的 io_in_progress_lock。将相应描述符的 IO_IN_PROGRESS 标记位设置为1,以防其他进程访问。将所需的页面数据从存储加载到缓冲池插槽中。更改相应描述符的状态,将 IO_IN_PROGRESS 标记位设置为"0",且 VALID 标记位设置为"1"。释放 io_in_progress_lock。
6)释放相应分区的 BufMappingLock。
7)访问 buffer_id=4 的缓冲池槽。


页面从存储加载到受害者缓冲池槽
在这种情况下,假设所有缓冲池槽位都被页面占用,且未存储所需的页面。



缓冲区管理器将执行以下步骤:
(1)创建所需页面的 buffer_tag 并查找缓冲表。在本例中假设 buffer_tag 是 ‘Tag_M’ (且相应的页面在缓冲区中找不到)。
(2)使用时钟扫描算法选择一个受害者缓冲池槽位,从缓冲表中获取包含着受害者槽位 buffer_id 的旧表项,并在缓冲区描述符层将受害者槽位的缓冲区描述符钉住。本例中受害者槽的 buffer_id=5,旧表项为 Tag_F, id = 5。
(3)如果受害者页面是脏页,则将其刷盘(write & fsync),否则进入步骤(4)。
在使用新数据覆盖脏页之前,必须将脏页写入存储中。脏页的刷盘步骤如下:
1. 获取 buffer_id=5 描述符上的共享 content_lock 和独占 io_in_progress_lock。
2. 更改相应描述符的状态:相应 IO_IN_PROCESS 位设置为"1",JUST_DIRTIED 位设置为"0"。
3. 根据具体情况,调用 XLogFlush() 函数将WAL缓冲区上的WAL数据写入当前WAL段文件。
4. 将受害者页面的数据刷盘至存储中。
5. 更改相应描述符的状态;将 IO_IN_PROCESS 位设置为"0",将 VALID 位设置为"1"。
6. 释放 io_in_progress_lock和 content_lock。以排他模式获取缓冲区表中旧表项所在分区上的 BufMappingLock。
(5)获取新表项所在分区上的 BufMappingLock,并将新表项插入缓冲表:
创建新表项:由 buffer_tag='Tag_M’与受害者的 buffer_id组成的新表项。
以独占模式获取新表项所在分区上的 BufMappingLock。
将新表项插入缓冲区表中。
(6)从缓冲表中删除旧表项,并释放旧表项所在分区的 BufMappingLock。
(7)将目标页面数据从存储加载至受害者槽位,然后用 buffer_id=5 更新描述符的标识字段,将脏位设置为0,并按流程初始化其他标记位。
(8)释放新表项所在分区上的 BufMappingLock。
(9)访问 buffer_id=5 对应的缓冲区槽位。



页面替换算法:时钟扫描


时钟扫描算法。该算法是NFU(Not Frequently Used)算法的变体,开销较少,能高效地选出较少使用的页面。我们将缓冲区描述符想象为一个循环列表,如下图所示,缓冲区描述符为深蓝色或浅蓝色的方框,框中的数字显示每个描述符的 usage_count。而 nextVictimBuffer是一个 32位的无符号整型变量,它总是指向某个缓冲区描述符并按顺时针顺序旋转。



环形缓冲区


在读写大表时,PostgreSQL 会使用环形缓冲区而不是缓冲池。环形缓冲器是一个很小的临时缓冲区域。当满足下列任一条件时,PostgreSQL 将在共享内存中分配一个环形缓冲区:

批量读取:当扫描关系读取数据的大小超过缓冲池的四分之一时,环形缓冲区的大小为 256 KB。
批量写入:当执行下列 SQL 命令时,环形缓冲区大小为 16 MB。
  • COPY FROM 命令

  • CREATE TABLE AS 命令

  • CREATE MATERIALIZED VIEW 或 REFRESH MATERIALIZED VIEW 命令

  • ALTER TABLE 命令

清理过程,当自动清理守护进程执行清理过程时,环形缓冲区大小为 256 KB。分配的环形缓冲区将在使用后被立即释放。




脏页刷盘


除了置换受害者页面之外,检查点进程和后台写入器进程也会将脏页刷盘至存储中。尽管两个进程都具有相同的功能(脏页刷盘),但是它们有着不同的角色和行为。检查点进程将检查点记录写入WAL段文件,并在检查点开始时进行脏页刷盘。后台写入器的目的是通过少量多次的脏页刷盘,减少检查点带来的密集写入的影响。后台写入器会一点点地将脏页落盘,尽可能减少对数据库活动造成的影响。在默认情况下,后台写入器每 200ms 被唤醒一次(由参数 bgwriter_delay 定义),且最多刷写 bgwriter_lru_maxpages个页面(默认为100个页面)。



为什么检查点进程与后台写入器相分离?


在 9.1及更低版本中,后台写入器会规律性地执行检查点进程。在 9.2版本中,检查点进程从后台写入被单独
剥离出来。原因在一篇题为“将检查点进程与后台写入器相分离”的提案中有介绍。
下面是一些摘录:当前(在 2011年)后台写入器进程既执行后台写入,又负责检查点,还处理一些其他的职责。
这意味着我们没法在不停止后台写入的情况下执行检查点最终的fsync。因此,在同一个进程中做两件事会有负面
的性能影响。此外,在9.2版本中,我们的一个目标是通过将轮询循环替换为锁存器,从而降低功耗。
bgwriter中的循环复杂度太高了,以至于无法找到一种简单的使用锁存器的方法。




Index-Only Scans仅索引扫描


为了降低 I/O(输入/输出)成本,当 SELECT 语句的所有目标条目都包含在索引键。几乎所有商业 RDBMS 都提供这种技术,例如 DB2 和 Oracle。PostgreSQL 从 9.2 版本开始引入了这个选项。


下面通过一个具体的例子,说明PostgreSQL中的index-only扫描是如何进行的。

testdb=# \d tbl
Table "public.tbl"
Column | Type | Modifiers
--------+---------+-----------
id | integer |
name | text |
data | text |
Indexes:
"tbl_idx" btree (id, name)




SELECT id, name FROM tbl WHERE id BETWEEN 18 and 19;
id | name
----+--------
18 | Queen
19 | Boston
(2 rows)



仅索引扫描


此查询从表的两列获取数据:'id' 和'name',索引'tbl_idx' 由这些列组成。因此,当使用索引扫描时,乍一看似乎不需要访问表页,因为索引元组包含必要的数据。但是,实际上,PostgreSQL 原则上必须检查元组的可见性,索引元组没有任何有关事务的信息,例如堆元组的 t_xmin 和 t_xmax。

因此,PostgreSQL 必须访问表数据来检查索引元组中数据的可见性。这就像把车放在马之前。为了避免这种困境,PostgreSQL 使用了目标表的可见性映射。如果存储在一个页面中的所有元组都可见,则PostgreSQL使用索引元组的键,不访问索引元组指向的表页来检查其可见性;否则,PostgreSQL 从索引元组中读取指向的表元组,并检查元组的可见性,这是普通的过程。





Heap Only Tuple (HOT)


  • HOT作用

数据库行数据更新时,索引也需要进行维护,如果是高并发的情况下,索引维护的代价很大,可能造成索引分裂。Pg为了避免这个问题,采用了HOT(堆内元组技术)解决这个问题,下面我们就这个技术详细探讨一下。

  • 没有HOT更新后的索引

没有HOT更新后的索引,引用情况。
堆元祖(5,1) id=1000,  索引key=1000 tid=(5,1),更新堆元祖后索引key=1000 tid=(5,2)


  • 使用hot特性更新行

当使用hot特性更新行时,如果更新后的行存储在同旧行同一个页面中,PostgreSQL 不会插入相应的索引元组,而是分别设置老元组的 HEAP_HOT_UPDATED 标记位和新元组的 HEAP_ONLY_TUPLE 标记位,存储在 t_informask2 字段中。






  • HOT-修剪

a.修剪前
1.索引找到行指针1
2.行指针1找到堆元祖1
3.堆元祖1的ctid找到堆元祖2,完成
b.修剪后
1.索引找到行指针1
2.行指针1找到行指针2
3.行指针2找到堆元祖2,完成



  • HOT-碎片整理

a.碎片整理前
b.碎片整理后



  • HOT 不可用的情况

1.当更新的元组存储在另一个页面中时,该页面没有存储旧的元组,指向该元组的索引元组也被插入到索引页面中。
2.当索引元组的键值更新时,新的索引元组被插入到索引页中。



E

N

D




往期回顾


浅析VACUUM清理

PostgreSQL并发控制实现机制

Foreign Data Wrappers

PostgreSQL-查询执行计划浅析

进程与内存架构


温馨提示




如果你喜欢本文,请分享到朋友圈,想要获得更多信息,请关注我 !


文章转载自云贝教育,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论