小心延迟清理的BUG
1前言
接上期,上一期和各位分享了 relallvisible 和 pg_visibility 均不可见的异常行为。现在让我们继续分析,Go on。为什么我会取名标题叫小心 vacuum_defer_cleanup_age 参数呢?没错,因为我又发现了 BUG... 10版本也有
2现象
谜底揭晓,上期的现象其实是因为开启了 vacuum_defer_cleanup_age 参数,该参数在我之前验证页剪枝 BUG 的时候打开了,于是阴差阳错地搞出了这么一个场景。
首先看看这个参数的作用:
Specifies the number of transactions by which
VACUUMand HOT updates will defer cleanup of dead row versions. .... This does not prevent cleanup of dead rows which have reached the age specified byold_snapshot_threshold.
简而言之就是让数据库延迟多少个事务,再去清理对事务不可见的元组,场景之一便是处理复制冲突,不过此参数无法防止达到 old_snapshot_threshold 年龄的死元组的清理。那为什么设置了这个参数会导致无法元组无法可见呢?此处偷下懒,懒得去追踪具体代码流程了,我直接把全部流程抓下来
[postgres@xiongcc ~]$ cat new_with_defer.out | egrep 'vm|visibi|visible' | uniq
postgres: heap_page_is_all_visible
postgres: visibilitymap_count
postgres: visibilitymap_get_status
postgres: visibilitymap_pin
postgres: vm_extend
postgres: vm_readbuf
lazy vacuum 的大致流程就不细说了,自己参照 vacuumlazy.c ,网上也有很多资料。
主要做的事情包括
遍历所有页面,标记死元组为可用 清理无用的索引 (cleanup index) 更新 visibility map 更新数据统计信息
和各位分享一张有用的流程图 👇🏻
第一个抓取到的是 heap_page_is_all_visible,顾名思义,判断某个页面内的元组是否全部可见,然后同步更新至 vm 文件中。
至于 visibilitymap_count,位于 pg_visibility.c 中,核心函数包括以下几个:
visibilitymap_clear :清理标记位,比如原来不含死元组,更新某个页面之后便要同步修改 visibilitymap_pin:"钉住"缓冲区要修改的 VM 块 visibilitymap_pin_ok:确保缓冲区内容已经被成功"钉住" visibilitymap_set:修改 VM 块中的指定标志位 visibilitymap_get_status:用于获取标记位的状态 visibilitymap_count:用于计算可见性映射表中标记位的数量 visibilitymap_truncate:用于截断可见性映射位图
heap_page_is_all_visible 里面的核心逻辑位于 HeapTupleSatisfiesVacuum 中
/*
* Check if every tuple in the given page is visible to all current and future
* transactions. Also return the visibility_cutoff_xid which is the highest
* xmin amongst the visible tuples. Set *all_frozen to true if every tuple
* on this page is frozen.
*/
static bool
heap_page_is_all_visible(LVRelState *vacrel, Buffer buf,
TransactionId *visibility_cutoff_xid,
bool *all_frozen)
{
Page page = BufferGetPage(buf);
BlockNumber blockno = BufferGetBlockNumber(buf);
OffsetNumber offnum,
maxoff;
bool all_visible = true; *visibility_cutoff_xid = InvalidTransactionId;
*all_frozen = true;
...
...
switch (HeapTupleSatisfiesVacuum(&tuple, vacrel->OldestXmin, buf))
{
case HEAPTUPLE_LIVE:
{
TransactionId xmin;
/* Check comments in lazy_scan_heap. */
if (!HeapTupleHeaderXminCommitted(tuple.t_data))
{
all_visible = false;
*all_frozen = false;
break;
}
/*
* The inserter definitely committed. But is it old enough
* that everyone sees it as committed?
*/
xmin = HeapTupleHeaderGetXmin(tuple.t_data);
if (!TransactionIdPrecedes(xmin, vacrel->OldestXmin))
{
all_visible = false;
*all_frozen = false;
break;
}
/* Track newest xmin on page. */
if (TransactionIdFollows(xmin, *visibility_cutoff_xid))
*visibility_cutoff_xid = xmin;
/* Check whether this tuple is already frozen or not */
if (all_visible && *all_frozen &&
heap_tuple_needs_eventual_freeze(tuple.t_data))
*all_frozen = false;
}
break;
case HEAPTUPLE_DEAD:
case HEAPTUPLE_RECENTLY_DEAD:
case HEAPTUPLE_INSERT_IN_PROGRESS:
case HEAPTUPLE_DELETE_IN_PROGRESS:
{
all_visible = false;
*all_frozen = false;
break;
}
default:
elog(ERROR, "unexpected HeapTupleSatisfiesVacuum result");
break;
}
} /* scan along page */
/* Clear the offset information once we have processed the given page. */
vacrel->offnum = InvalidOffsetNumber;
return all_visible;
}
让我们复现一下,打个断点
postgres=# create table t2(id int);
CREATE TABLE
postgres=# insert into t2 values(generate_series(1,100));
INSERT 0 100
postgres=# delete from t2 where id <5;
DELETE 4
postgres=# select pg_backend_pid();
pg_backend_pid
----------------
2473
(1 row)
有看过页剪枝 BUG 文章的读者,到这里应该就看明白了,还是那个问题,转化成补码的时候,计算出来的 OldestXmin 太大了,导致返回了 false,我们站在上帝视角,这个页其实已经全部可见了,此例中执行 vacuum 的事务是 810,而 vacuum_defer_cleanup_age 我配置的 1000,所以又出现了这个问题。
postgres=# select relallvisible,relpages,reltuples from pg_class where relname = 't2'; ---依旧不可见
relallvisible | relpages | reltuples
---------------+----------+-----------
0 | 1 | 96
(1 row)postgres=# select * from pg_visibility('t2'); ---依旧不可见
blkno | all_visible | all_frozen | pd_all_visible
-------+-------------+------------+----------------
0 | f | f | f
(1 row)
那么当 xmin 大于 vacuum_defer_cleanup_age 的时候, OldestXmin 又会被相应截断
postgres=# create table t2(id int);
CREATE TABLE
postgres=# select txid_current();
txid_current
--------------
1066
(1 row)postgres=# insert into t2 values(generate_series(1,100));
INSERT 0 100
postgres=# delete from t2 where id < 5;
DELETE 4
postgres=# vacuum t2;
VACUUM
postgres=# vacuum verbose t2;
INFO: vacuuming "public.t2"
INFO: table "t2": found 0 removable, 100 nonremovable row versions in 1 out of 1 pages
DETAIL: 4 dead row versions cannot be removed yet, oldest xmin: 70
Skipped 0 pages due to buffer pins, 0 frozen pages.
CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s.
VACUUM
此例是 70,比 xmax 小,因此也无法回收,所以依旧不是 allvisible。并且更为尴尬的是,我在 13 和 10 的版本里都测了一下,都是会返回一个很大的 OldestXmin
postgres=# create table t3(id int);
CREATE TABLE
postgres=# insert into t3 values(generate_series(1,10000));
INSERT 0 10000
postgres=# create index on t3(id);
CREATE INDEX
postgres=# show vacuum_defer_cleanup_age ;
vacuum_defer_cleanup_age
--------------------------
1000000
(1 row)postgres=# explain select id from t3 where id = 99; ---执行计划显式 index only scan
QUERY PLAN
-------------------------------------------------------------------------
Index Only Scan using t3_id_idx on t3 (cost=0.29..8.30 rows=1 width=4)
Index Cond: (id = 99)
(2 rows)
postgres=# vacuum analyze t3;
VACUUM
postgres=# select * from pg_visibility('t2');
blkno | all_visible | all_frozen | pd_all_visible
-------+-------------+------------+----------------
0 | f | f | f
(1 row)
postgres=# explain analyze select id from t3 where id = 99;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------
Index Only Scan using t3_id_idx on t3 (cost=0.29..8.30 rows=1 width=4) (actual time=0.017..0.019 rows=1 loops=1)
Index Cond: (id = 99)
Heap Fetches: 1 ---回表了
Planning time: 0.153 ms
Execution time: 0.060 ms
(5 rows)
postgres=# select version();
version
----------------------------------------------------------------------------------------------------------
PostgreSQL 10.23 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 4.8.5 20150623 (Red Hat 4.8.5-44), 64-bit
(1 row)
3小结
所以 vacuum_defer_cleanup_age 这个参数有很大的问题,简而言之,在事务号推进到 vacuum_defer_cleanup_age 值之前都会存在下面的问题
10 以后的版本(10 之前的版本读者可以自行测试,11、12我也还没测,目测也会)会导致 index only scan 这种不需要回表的查询依旧需要回表,白白损失性能而不知! 14 以后的版本,还会影响一致性,页剪枝会删除不应该删除的死元祖!
核心原理还是因为 PostgreSQL 代码里面在处理负数补码形式的时候出错了。
我去,真是个天大的坑啊 ... 所以各位在网上看到的处理流复制冲突的方式之一便是 vacuum_defer_cleanup_age,性能损失其实还好,但是一致性的问题就严重了。所以,各位知道怎么处理了吧?要处理复制冲突,hot_standby_feedback 即可。也难怪社区在讨论要移除 vacuum_defer_cleanup_age 该参数