查询执行着突然遇到 terminating connection due to conflict with recovery 报错,这就是所谓的流复制冲突。
流复制冲突有很多类,本篇我们主要分析锁复制冲突(备库锁阻塞)以及快照复制冲突(Vacuum冲突),pg对它们又提供了哪些解决方法。
一、 流复制冲突
本质上,流复制冲突产生的原因是主库对备库信息了解太少(例如备库WAL日志接收进度、备库正在执行的查询、查询使用的元组情况等),等到主库将自己的操作发送至备库希望它进行应用时,才发现它和备库在执行的操作有冲突。由于终止掉备库Startup进程代价太大,通常被终止的都是备库的查询操作,并抛出terminating connection due to conflict with recovery 报错,有时导致使用从库的业务怨声载道。
包含自上次重置统计信息以来发生的所有复制冲突的信息,注意是在备库执行,因为主库是不会有流复制冲突的。
select * from pg_stat_database_conflicts;
源码中在postgres.c文件的errdetail_recovery_conflict函数,可以看到比上面要多一种冲突类型。
static int
errdetail_recovery_conflict(void)
{
switch (RecoveryConflictReason)
{
case PROCSIG_RECOVERY_CONFLICT_BUFFERPIN:
errdetail("User was holding shared buffer pin for too long.");
break;
case PROCSIG_RECOVERY_CONFLICT_LOCK:
errdetail("User was holding a relation lock for too long.");
break;
case PROCSIG_RECOVERY_CONFLICT_TABLESPACE:
errdetail("User was or might have been using tablespace that must be dropped.");
break;
case PROCSIG_RECOVERY_CONFLICT_SNAPSHOT:
errdetail("User query might have needed to see row versions that must be removed.");
break;
case PROCSIG_RECOVERY_CONFLICT_STARTUP_DEADLOCK:
errdetail("User transaction caused buffer deadlock with recovery.");
break;
case PROCSIG_RECOVERY_CONFLICT_DATABASE:
errdetail("User was connected to a database that must be dropped.");
break;
default:
break;
/* no errdetail */
}
return 0;
}
- CONFLICT_BUFFERPIN: 任何在主库上访问仅包含死元组(dead tuple)的页面并且获取到了该页面排它锁的查询都会删除HOT链,pg总是在很短的时间内持有这种页面锁,因此不会与主库上的vacuum操作发生冲突。但是,当备库在replay申请此排它页面锁,同时有查询正在访问该页面数据,则会发生 buffer pin 复制冲突
- CONFLICT_LOCK: 锁复制冲突,后面会介绍
- CONFLICT_TABLESPACE: 当主库删除表空间时,备库的查询恰好在项该表空间写临时文件,会发生冲突,此时pg会取消备库上的所有查询
- CONFLICT_SNAPSHOT: 快照复制冲突,后面会介绍
- CONFLICT_STARTUP_DEADLOCK: 死锁复制冲突,后面会介绍
- CONFLICT_DATABASE: 主库执行删除数据库,而备库刚好有活动会话在连接该数据库时,导致冲突,此时pg会取消备库上的所有连接,这也是pg_stat_database_conflicts视图不需要展示此类冲突的原因
二、 锁复制冲突
1. 备库阻塞是怎么出现的?
好多开发都很好奇,备库是只读库为什么也会有阻塞,阻塞为什么还会导致主备延迟?这里通过源码学习一下。
当然不通过源码,从锁和备库的原理也能知道为什么,通常场景是这样的:
- 备库:执行对A表大查询,获取1级锁
- 主库:执行对A表的DDL(例如drop表、drop索引),获取8级锁
- 主库将申请8级锁的WAL日志传到备库进行应用
- 备库:应用到该语句时,startup进程被大查询阻塞(8级锁与1级锁是冲突的),无法继续应用后面的日志,导致主从出现延迟
2. canAcceptConnections函数
该函数在postmaster.c文件中,用于判断当前实例能否接收用户请求。当hot_standby参数设置为on时,pmState为PM_HOT_STANDBY,此时备库可以接收用户请求。
在下面的代码中,pmState==PM_HOT_STANDBY不符合前面各if的情况,而result默认值是CAC_OK,因此PM_HOT_STANDBY状态下可以接收用户请求进行读操作。
/*
* canAcceptConnections --- check to see if database state allows connections
*/
static CAC_state
canAcceptConnections(int backend_type)
{
CAC_state result = CAC_OK;
if (pmState != PM_RUN && pmState != PM_HOT_STANDBY &&
backend_type != BACKEND_TYPE_BGWORKER)
{
if (Shutdown > NoShutdown)
return CAC_SHUTDOWN; /* shutdown is pending */
else if (!FatalError && pmState == PM_STARTUP)
return CAC_STARTUP; /* normal startup */
else if (!FatalError && pmState == PM_RECOVERY)
return CAC_NOTCONSISTENT; /* not yet at consistent recovery
* state */
else
return CAC_RECOVERY; /* else must be crash recovery */
}
…
return result;
}
3. 备库的只读限制
上面的还是只是说明备库可以接受用户请求了,但是没限制是读还是写请求。那么,备库只能进行读操作是在哪设置的呢?答案是好早好早以前学习的 startTransaction函数。
static void
StartTransaction(void)
{
TransactionState s;
VirtualTransactionId vxid;
…
if (RecoveryInProgress())
{
s->startedInRecovery = true;
XactReadOnly = true; // 这里
}
…
}
另外在分配事务id的GetNewTransactionId函数,如果是备库,则分配时也会报错。
/*
* Allocate the next FullTransactionId for a new transaction or
* subtransaction.
*/
FullTransactionId
GetNewTransactionId(bool isSubXact)
{
FullTransactionId full_xid;
TransactionId xid;
…
/* safety check, we should never get this far in a HS standby */
if (RecoveryInProgress())
elog(ERROR, "cannot assign TransactionIds during recovery");
…
}
因此,备库只能进行只读操作,并且只能使用1级锁(AccessShareLock)。在备库执行DDL,DML,SELECT FOR UPDATE/FOR SHARE语句都会报错。
主库申请8级锁时,将其记录到WAL日志
LockAcquireResult
LockAcquireExtended(…)
{
/*
* 只有8级锁可能在从库replay申请获取时会有冲突,这里要提前准备。准备指的是分配事务id,事务id分配会产生xlog,以尽早让从库知道该xlog
*/
if (lockmode >= AccessExclusiveLock &&
locktag->locktag_type == LOCKTAG_RELATION &&
!RecoveryInProgress() &&
XLogStandbyInfoActive())
{
// 这个函数就是唯一作用就是调用GetCurrentTransactionId函数
LogAccessExclusiveLockPrepare();
log_lock = true;
}
…
/*
* 真正产生8级锁对应的WAL日志
*/
if (log_lock)
{
/*
* Decode the locktag back to the original values, to avoid sending lots of empty bytes with every message. See lock.h to check how a locktag is defined for LOCKTAG_RELATION
*/
LogAccessExclusiveLock(locktag->locktag_field1,
locktag->locktag_field2);
}
return LOCKACQUIRE_OK;
}
LogAccessExclusiveLocks里这一系列眼熟的函数,老熟人了
/*
* Wholesale logging of AccessExclusiveLocks. Other lock types need not be logged, as described in backend/storage/lmgr/README.
*/
static void
LogAccessExclusiveLocks(int nlocks, xl_standby_lock *locks)
{
xl_standby_locks xlrec;
xlrec.nlocks = nlocks;
XLogBeginInsert();
XLogRegisterData((char *) &xlrec, offsetof(xl_standby_locks, locks));
XLogRegisterData((char *) locks, nlocks * sizeof(xl_standby_lock));
XLogSetRecordFlags(XLOG_MARK_UNIMPORTANT);
(void) XLogInsert(RM_STANDBY_ID, XLOG_STANDBY_LOCK);
}
8级锁在WAL中被发往备库,备库的Startup进程在replay到该语句时就会申请8级锁。与主库不同的是,主库是用户进程申请,备库则是Startup进程申请。
void
standby_redo(XLogReaderState *record)
{
…
if (info == XLOG_STANDBY_LOCK)
{
xl_standby_locks *xlrec = (xl_standby_locks *) XLogRecGetData(record);
int i;
for (i = 0; i < xlrec->nlocks; i++)
StandbyAcquireAccessExclusiveLock(xlrec->locks[i].xid,
xlrec->locks[i].dbOid,
xlrec->locks[i].relOid);
}
…
}
假如此时备库有一个读会话持有A表的1级锁,而Startup进程申请A表的8级锁,则Startup进程需要等待读会话结束才能继续replay WAL日志,从库会出现延迟。
另外,Startup进程等待锁的过程会触发死锁检测。当检测到死锁时,我们不希望Startup进程被kill掉,因此应该kill掉的是读会话进程。
因此,pg提供了max_standby_streaming_delay参数来处理超时情况(类似还有一个max_standby_archive_delay参数),如果Startup进程等待超过了该参数的限制,则考虑终止读事务。注意这个参数指的不是备库查询的超时时间,而是备库WAL应用延迟的时间,备库上的查询有可能执行不到30秒就被断开了。
/*
* Determine the cutoff time at which we want to start canceling conflicting transactions.
Returns zero (a time safely in the past) if we are willing to wait forever.
*/
static TimestampTz
GetStandbyLimitTime(void)
{
TimestampTz rtime;
bool fromStream;
/*
* The cutoff time is the last WAL data receipt time plus the appropriate delay variable. Delay of -1 means wait forever.
*/
GetXLogReceiptTime(&rtime, &fromStream);
/* 流复制 */
if (fromStream)
{
if (max_standby_streaming_delay < 0)
return 0; /* wait forever */
return TimestampTzPlusMilliseconds(rtime, max_standby_streaming_delay);
}
/* 归档复制 */
else
{
if (max_standby_archive_delay < 0)
return 0; /* wait forever */
return TimestampTzPlusMilliseconds(rtime, max_standby_archive_delay);
}
}
同时,在备库申请锁时,它不再进行死锁检测,而尝试终止读事务。
/*
* ProcSleep -- put a process to sleep on the specified lock
*/
ProcWaitStatus
ProcSleep(LOCALLOCK *locallock, LockMethod lockMethodTable)
{
…
do
{
if (InHotStandby)
{
bool maybe_log_conflict =
(standbyWaitStart != 0 && !logged_recovery_conflict);
/* Set a timer and wait for that or for the lock to be granted */
ResolveRecoveryConflictWithLock(locallock->tag.lock,
maybe_log_conflict);
…
三、 快照复制冲突
主库执行Vacuum时,会依据主库当前的最小事务id进行元组的清理,但它并不了解备库的replay进度和元组使用情况。这些清理操作写入WAL日志中传送到备库replay,如果备库恰好有读会话在读取该元组,就会产生冲突。
hot_standby_feedback参数,指定备库是否向主库反馈已经完成replay的事务信息(最小事务id,xmin)。主库接收到备库的最小事务id,以此作为元组的清理依据,便不会去清理备库还在使用的元组。
/*
* Send hot standby feedback message to primary, plus the current time,
* in case they don't have a watch.
*
* If the user disables feedback, send one final message to tell sender
* to forget about the xmin on this standby. We also send this message
* on first connect because a previous connection might have set xmin
* on a replication slot. (If we're not using a slot it's harmless to
* send a feedback message explicitly setting InvalidTransactionId).
*/
static void
XLogWalRcvSendHSFeedback(bool immed)
{
TimestampTz now;
FullTransactionId nextFullXid;
TransactionId nextXid;
uint32 xmin_epoch,
catalog_xmin_epoch;
TransactionId xmin,
catalog_xmin;
static TimestampTz sendTime = 0;
/* initially true so we always send at least one feedback message */
static bool primary_has_standby_xmin = true;
/*
* If the user doesn't want status to be reported to the primary, be sure to exit before doing anything at all. 若hot_standby_feedback设置为off
*/
if ((wal_receiver_status_interval <= 0 || !hot_standby_feedback) &&
!primary_has_standby_xmin)
return;
/* Get current timestamp. */
now = GetCurrentTimestamp();
if (!immed)
{
…
if (!HotStandbyActive())
return;
/*
* Make the expensive call to get the oldest xmin once we are certain
* everything else has been checked.
*/
if (hot_standby_feedback)
{
// 获得备库当前最小事务id xmin
GetReplicationHorizons(&xmin, &catalog_xmin);
}
else
{
xmin = InvalidTransactionId;
catalog_xmin = InvalidTransactionId;
}
/*
* 考虑事务id已经回卷
*/
nextFullXid = ReadNextFullTransactionId();
nextXid = XidFromFullTransactionId(nextFullXid);
xmin_epoch = EpochFromFullTransactionId(nextFullXid);
catalog_xmin_epoch = xmin_epoch;
if (nextXid < xmin)
xmin_epoch--;
if (nextXid < catalog_xmin)
catalog_xmin_epoch--;
elog(DEBUG2, "sending hot standby feedback xmin %u epoch %u catalog_xmin %u catalog_xmin_epoch %u",
xmin, xmin_epoch, catalog_xmin, catalog_xmin_epoch);
/* Construct the message and send it.构建各类消息并发送 */
resetStringInfo(&reply_message);
pq_sendbyte(&reply_message, 'h');
pq_sendint64(&reply_message, GetCurrentTimestamp());
pq_sendint32(&reply_message, xmin);
pq_sendint32(&reply_message, xmin_epoch);
pq_sendint32(&reply_message, catalog_xmin);
pq_sendint32(&reply_message, catalog_xmin_epoch);
walrcv_send(wrconn, reply_message.data, reply_message.len);
if (TransactionIdIsValid(xmin) || TransactionIdIsValid(catalog_xmin))
primary_has_standby_xmin = true;
else
primary_has_standby_xmin = false;
}
当然,这种feedback是有风险的,如果备库严重落后于主库、或者备库执行非常慢的大查询,其最小事务id会远小于主库,导致主库可以清理的元组非常有限,加剧主库表膨胀问题。
四、 物理复制槽
1. 作用及原理
hot_standby_feedback 比较好地解决了获取备库最小xmin的问题,但还有一个问题:当备库宕机时,主库还是无法知道备库的WAL日志接收信息。虽然 wal_max_size 也可以使主库尽量多保留日志,但它并不是绝对的。
在pg 9.4,新引入了复制槽(Replication Slot),它有两个作用:
- 防止流复制中主库删除尚未传送到备库的WAL日志(物理复制槽)
- 支持逻辑复制(逻辑复制槽)
创建物理复制槽后,备库会不断反馈接收WAL日志进度的LSN给主库,这个LSN就被保存在复制槽中。当主库需要删除WAL时,参考复制槽中的LSN即可避免清理备库还需要用的日志。
类似Oracle RMAN的 CONFIGURE ARCHIVELOG DELETION POLICY TO SHIPPED TO ALL STANDBY;
2. 分类与创建方法
物理复制槽也分两类:永久的,和临时的。永久复制槽可以起到上面说的备库宕机后主库依然保留其所需日志的作用。临时复制槽则,只能在备库walsender进程正常工作时生效。
① 永久复制槽
主库执行创建命令
postgres=# select pg_create_physical_replication_slot('node1');
pg_create_physical_replication_slot
-------------------------------------
(node1,)
(1 row)
postgres=# select * from pg_replication_slots;
slot_name | plugin | slot_type | datoid | database | temporary | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn
-----------+--------+-----------+--------+----------+-----------+--------+------------+------+--------------+-------------+---------------------
node1 | | physical | | | f | f | | | | |
(1 row)
备库设置primary_slot_name参数
- primary_slot_name参数一定要设成跟主库slot名字一样,这样它会在主库查找同名slot并使用。否则主库slot在备库中没有对应,会认为WAL需要一直保留,导致堆积
- pg 12开始设置在postgresql.conf文件,之前则在recovey.conf文件
- pg 13开始reload可以生效,之前需要重启从库生效
-- pg 12开始设置在postgresql.conf文件,之前则在recovey.conf文件
echo "primary_slot_name = 'node1' " >> postgresql.conf
pg_ctl -D ./ restart
postgres=# select * from pg_stat_wal_receiver;
-[ RECORD 1 ]---------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
pid | 23468
status | streaming
receive_start_lsn | 0/3000000
receive_start_tli | 1
received_lsn | 0/6D6F5D0
received_tli | 1
last_msg_send_time | 2020-01-21 22:38:27.882638-08
last_msg_receipt_time | 2020-01-21 22:38:27.882767-08
latest_end_lsn | 0/6D6F5D0
latest_end_time | 2020-01-21 22:38:27.882638-08
slot_name | node1
sender_host | localhost
sender_port | 54121
conninfo | user=postgres passfile=/home/postgres/.pgpass dbname=replication host=localhost port=54121 application_name=standby_node fallback_application_name=walreceiver sslmode=disable sslcompression=0 gssencmode=disable target_session_attrs=any
② 临时复制槽
主库设置wal_receiver_create_temp_slot参数为on,会自动给每个没有指定primary_slot_name的复制连接都创建一个临时复制槽,并且在主库的 walsender进程退出时会被清理掉。
3. max_slot_wal_keep_size参数
如果备库WAL传输延迟过大,或者primary_slot_name参数配置错误,它可能会导致主库WAL日志量持续增加,最终打爆主库磁盘。
pg 13 引入了 max_slot_wal_keep_size参数,控制复制槽最多保留多少WAL。如果超过这个阈值,复制槽会失效,默认值是-1。
这个参数其实之前在学习WAL清理机制时也提到过,在 KeepLogSeg函数 中,感兴趣可以看 postgresql源码学习(35)—— 检查点⑤-检查点中的XLog清理机制_Hehuyi_
参考
《PostgreSQL技术内幕:事务处理深度探索》第5章
PostgreSQL standby conflict replay分析和解决方案 - 墨天轮