相关: 《Postgresql源码(18)PGPROC相关结构》 《Postgresql快照优化Globalvis新体系分析(性能大幅增强)》 《Improving Postgres Connection Scalability: Snapshots》
PG的新快照体系Globalvis分析
- 本篇介绍快照优化作者Andres Freund的优化总结文章。
- 本篇中有很多个人理解,不一定准确,推荐阅读:《Improving Postgres Connection Scalability: Snapshots》。
- 本篇文章作为新版本PG快照分析的第一篇,后面几篇新代码再做展开分析。
先看下这个优化的性能提升效果,在高连接数下会有明显提升:
一、概念回顾
1 MVCC
- 传统锁机制无法做到读、写的并发操作,因为数据只有一个版本。引入mvcc后,数据存在多版本,可以做到读一个版本、写另一个版本,实现读写并发。
- 如果实现这样机制,需要每个行版本记录xmin(可见开始位点visible since),xmax(可见结束位点visible until)。
- 位点即时间戳,PG中使用自增正整数表示。
2 快照
- 只有时间戳还是不够的,决定我当前能否看到一个元组,我还必须知道创建、删除元组的时间戳所代表的事务是否已经提交了。为了避免脏读,只有提交的事务才会被看做已生效。
- PG使用快照来记录哪些事务正在运行中。
- PG的SnapshotData结构中,xip记录了所有运行中的事务ID。
typedef struct SnapshotData
{
TransactionId xmin; /* all XID < xmin are visible to me */
TransactionId xmax; /* all XID >= xmax are invisible to me */
TransactionId *xip;
uint32 xcnt; /* # of xact ids in xip[] */
...
3 快照获取
优化之前PG是如何获取快照的?
- 每个PG进程都对应一个PGPROC结构、一个PGXACT结构(PG14把PGXACT合到PGPROC里面了)
- PGPROC数组在初始化的时候就全部申请好了,再找PROC的时候为了避免每次都遍历整个数组,在
procArray->pgprocnos
柔性数组中记录了排序过后的PGPROC数组的索引,参考这篇:《Postgresql源码(18)PGPROC相关结构》。 - 在构建快照时,GetSnapshotData会遍历**
procArray->pgprocnos
**找到所有存在的PGPROC、PGXACT结构,收集所有的xid。 - 在构建快照时,还有一些细节:
- 为了方便,GetSnapshotData会计算全局最久
PGXACT->xmin
,用于在访问的过程中做一些vacuum的事情,回收元组。 - 为了实现savepoint,一个backend需要记录多个事务ID。
- 为了效率,vacuum忽略一些执行中的backend。
- 备机上的快照计算完全不同(以前做过分析,文章写得很简陋,后面再补一篇)
- 为了方便,GetSnapshotData会计算全局最久
4 历史优化
2011年已经发现GetSnapshotData存在瓶颈,当时做的优化是把PGPROC里面把快照需要的变量拆出来,放到PGXACT中,这样数据结构小很多,可以装到一个cpu cache line中。
二、识别当前的瓶颈点
从上述分析中可以看出,遍历所有连接的复杂度为O(#connection)
,快照计算成本随连接数线性增加。
理论上有两种提高扩展性的方法:
- 寻找复杂度更低的算法,避免
O(n)
。 - 对每个连接做更少的操作,减少单次操作的时间。
旧版本的快照获取步骤:
代码语言:javascript复制xmin = global_xmin = inferred_maximum_possible;
for (i = 0; i < #connections; i )
{
int procno = shared_memory->connection_offsets[i];
PGXACT *pgxact = shared_memory->all_connections[procno];
// compute global xmin minimum
// 记录全局做小xmin
if (pgxact->xmin && pgxact->xmin < global_xmin)
global_xmin = pgxact->xmin;
// nothing to do if backend has transaction id assigned
// 未启动事务直接跳过
if (!pgxact->xid)
continue;
// the global xmin minimum also needs to include assigned transaction ids
// 全局最小xmin也要包含最小的事务ID
if (pxact->xid < global_xmin)
global_xmin = pgxact->xid;
// add the xid to the snapshot
// 活跃事务记录到快照的xip中
snapshot->xip[snapshot->xcnt ] = pgxact->xid;
// compute minimum xid in snapshot
// 记录最小的xid到快照中
if (pgxact->xid < xmin)
xmin = pgxact->xid;
}
snapshot->xmin = xmin;
// store snapshot xmin unless we already have built other snapshots
if (!MyPgXact->xmin)
MyPgXact->xmin = xmin;
RecentGlobalXminHorizon = global_xmin;
这里最重要的一点:
- 循环体起始只需要计算活跃事务就好了,但是,这里还顺便计算了全局xmin。这不是快照的一部分,但可以方便的同时计算,只需要很小的代价(or so we thought…)。
- 代码作者花了很长时间,试图去理解为什么遍历几千个元素,要话费如此昂贵的代价。
针对瓶颈点,作者做了三层优化。
1 瓶颈点&优化:ping pong
问题主要来自于这行代码(这里的MyPgXact->xmin
的含义是:最小的运行中的xid,不包括lazy vacuum)
xmin = global_xmin = inferred_maximum_possible;
for (i = 0; i < #connections; i )
{
...
...
if (!TransactionIdIsValid(MyPgXact->xmin))
MyPgXact->xmin = TransactionXmin = xmin; <---------------- 这里
...
}
因为这个值要记录运行中的最小xid,所以连接的事务提交、终止都需要更新这个值,在正常运行的系统中,由于xid是不断推进的,这个值的更新频率非常高。
在当前最常见的CPU微架构中,每个核心私有L1和L2缓存,每个CPU插槽上所有核共享L3缓存。
- 第一点:假设有8核的CPU,8个连接在并发的构建8个快照,每个连接都需要读取他的七个PGXACT,然后修改自己的PGXACT;这时读取的其他七个PGXACT也在被不停的修改中,这样读取的PGXACT就会经常失效了。可能刚刚缓存好,别人把xmin修改了导致缓存的PGXACT失效,需要从内存重新拿上来。
- 第二点:xmin其实是不需要访问的,需要的是计算出一个全局最小xid。但是不访问xmin也没什么用,因为xmin和xid在一个cache line上,xmin虽然不访问,但是会修改。 改了就会让整个cache line失效,导致xid的访问也很慢。
关于第二点:
代码作者优化掉了RecentGlobalXminHorizon(用于清理死元组),引入了两个没那么准确的新值:
- A:确定比A小的都已经可以清理了
- B:确定比B大的都无法清理
在A、B中间的元组会引入昂贵的准确计算,避免在GetSnapshotData中计算引发上述问题。
2 瓶颈点&优化:数据离散访问
PGXACT的存放位置是离散的,可能在allPgXact大数组中的任意几个位置,不连续。(参考《Postgresql源码(18)PGPROC相关结构》)在使用时,通过连续的pgprocnos数组中记录的index找到活跃的PGXACT。
代码语言:javascript复制GetSnapshotData
...
//优化前,通过pgprocnos拿到索引,通过索引在离散的allPgXact数组中拿到xid
numProcs = arrayP->numProcs;
for (index = 0; index < numProcs; index )
{
int pgprocno = pgprocnos[index];
volatile PGXACT *pgxact = &allPgXact[pgprocno];
...
//优化后,在密集数组other_xids中拿xid
for (int pgxactoff = 0; pgxactoff < numProcs; pgxactoff )
{
TransactionId xid = UINT32_ACCESS_ONCE(other_xids[pgxactoff]);
问题就是数据是离散的,将xids单独拿出来放到连续存储的密集数组中,可以显著提高命中率。
3 瓶颈点&优化:快照缓存
- 经过上面两个优化(都在优化O(n)中的n),在连接数很高时计算一个快照仍然是一个昂贵的操作。
- 经过上面优化后,快照中不在维护RecentGlobalXmin,这样就可以做一个更大的改进:如果我们可以确定快照没变,就不用重新计算了。以前这是不可行的,因为RecentGlobalXmin比快照本身的更新频率要高很多。
- 快照唯一需要更新的时机是:之前运行的一个事务提交了。(快照最根本的功能是提供运行事务id列表,列表中最大的那个就是xmax了,事务提交后就相当于不在运行事务列表中了,在做可见性判断时如果不在运行事务列表中,就知道事务已经提交或回滚了,这就需要继续走标志位 或 clog判断了;但是如果事务ID在运行事务列表中,那就一定没提交,不需要做任何进一步判断)
- 因此,作者设计了一个xactCompletion内存计数器,记录提交或终止的事务数量。
- 当要求重新计算快照时,我们检查计数器,如果没变可以直接复用上一个快照。
- 如果计数器变了,那么需要重新计算快照。