海山数据库(He3DB)源码详解:主备复制start过程

70 阅读13分钟

海山数据库(He3DB)源码详解:主备复制start过程

基础备份——概述

   在数据库主备复制(如 PostgreSQL 的流复制等场景)中,基础备份是建立主备数据库一致性的初始备份。它是一个数据库在某个时间点的完整副本,包含了数据库的数据文件、目录结构等所有用于恢复数据库到该时间点状态的必要信息。

基础备份——pg_backup_start

pg_backup_start函数表示开始基础备份。

graph TD;
    A[开始] --> B["调用 pg_backup_start 函数"];
    B --> C["调用 PG_GETARG_TEXT_PP(0)获取备份 ID ;调用 PG_GETARG_BOOL(1)获取快速备份参数"];
    C --> D["调用 text_to_cstring 函数将备份 ID 从 text 转换为 C 字符串"];
    D --> E["调用 get_backup_status 函数检查当前会话备份状态"];
    E -- 已有备份在进行中 --> F["调用 ereport 函数报告错误"];
    E -- 无备份在进行中 --> G["调用 MemoryContextSwitchTo(TopMemoryContext)切换到顶级内存上下文"];
    G --> H["调用 makeStringInfo 函数初始化标签文件 StringInfo"];
    H --> I["调用 makeStringInfo 函数初始化表空间映射文件 StringInfo"];
    I --> J["调用 register_persistent_abort_backup_handler 函数注册中断备份处理程序"];
    J --> K["调用 do_pg_backup_start 函数执行备份初始化,传入备份 ID 字符串、快速备份标志、标签文件和表空间映射文件等参数,设置备份状态、记录备份开始时间、LSN 等,并填充标签文件和表空间映射文件的内容"];
    K --> L["调用 PG_RETURN_LSN(startpoint)返回备份开始的 LSN"];
    L --> M[结束];
  1. 参数解析与初始化并完成状态检查 获取备份ID以及是否进行快速备份的参数,将text类型的备份ID转换为C字符串,检查当前会话的备份状态(status),如果已经是SESSION_BACKUP_RUNNING,则报告错误,指出已经有一个备份在进行中。
Datum
pg_backup_start(PG_FUNCTION_ARGS)
{
	// 获取第一个参数:备份ID,类型为text的指针的指针 
	text	   *backupid = PG_GETARG_TEXT_PP(0);
	// 获取第二个参数:是否进行快速备份,类型为bool 
	bool		fast = PG_GETARG_BOOL(1);  
	char	   *backupidstr;  
	XLogRecPtr	startpoint; // 用于存储备份开始的日志序列号(LSN)
	SessionBackupState status = get_backup_status(); // 获取当前会话的备份状态  
	MemoryContext oldcontext; // 用于保存原始的内存上下文 

    // 将text类型的备份ID转换为C字符串    
	backupidstr = text_to_cstring(backupid);

    // 检查当前会话是否已经有备份正在进行 
	if (status == SESSION_BACKUP_RUNNING)
	// 如果有,则报错
		ereport(ERROR,
				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
				 errmsg("a backup is already in progress in this session")));
  1. 资源准备 为了保证标签文件和表空间映射文件在整个会话中保持有效,切换到顶级内存上下文(TopMemoryContext),初始化用于存储标签文件和空间映射文件的StringInfo,切换回原始上下文
// 切换到顶级内存上下文,因为标签文件和表空间映射文件需要在整个会话中保持有效 
	oldcontext = MemoryContextSwitchTo(TopMemoryContext);
	// 初始化用于存储标签文件内容的StringInfo
	label_file = makeStringInfo();
	// 初始化用于存储表空间映射文件内容的StringInfo 
	tblspc_map_file = makeStringInfo();
	// 切换回原始的内存上下文
	MemoryContextSwitchTo(oldcontext);
  1. 注册中断备份的处理程序 调用register_persistent_abort_backup_handler函数,注册一个持久化的中断备份的处理程序,以便在异常情况下能够清理资源
register_persistent_abort_backup_handler();
  1. 执行备份初始化 调用do_pg_backup_start函数传入备份ID字符串(backupidstr)、快速备份标志(fast)、标签文件(label_file)和表空间映射文件(tblspc_map_file)等参数,设置备份状态、记录备份开始时间、LSN等,并填充标签文件和表空间映射文件的内容
startpoint = do_pg_backup_start(backupidstr, fast, NULL, label_file,
									NULL, tblspc_map_file);
  1. 返回结果 返回备份开始的LSN(startpoint),使用PG_RETURN_LSN宏将LSN转换为Datum格式
PG_RETURN_LSN(startpoint);

基础备份——do_pg_backup_start

pg_backup_start函数中的do_pg_backup_start表示备份初始化。

  1. 检查状态及一系列准备工作 首先检查状态是否在恢复模式中,如果不是,则报错,检查提供的备份标签字符串长度是否过长,以独占模式获取WAL插入锁,增加正在运行的备份计数,启用强制全页写入,释放WAL插入锁,设置错误清理回调,以便在后续操作中出现错误时能够正确地恢复状态,检查是否需要强制WAL文件切换,检查备份是否不是在恢复过程中开始的(backup_started_in_recoveryfalse)。如果是这样,它调用RequestXLogSwitch(false)来强制进行WAL文件切换。这样做的目的是确保在创建检查点之前,WAL文件不会包含具有旧时间线ID的页面,这可能会导致在特定恢复场景下出现问题
XLogRecPtr
do_pg_backup_start(const char *backupidstr, bool fast, TimeLineID *starttli_p,
				   StringInfo labelfile, List **tablespaces,
				   StringInfo tblspcmapfile)
{
	bool		backup_started_in_recovery = false;
	XLogRecPtr	checkpointloc;
	XLogRecPtr	startpoint;
	TimeLineID	starttli;
	pg_time_t	stamp_time;
	char		strfbuf[128];
	char		xlogfilename[MAXFNAMELEN];
	XLogSegNo	_logSegNo;


    // 检查当前是否处于恢复模式  
	backup_started_in_recovery = RecoveryInProgress();

	/*  
     * 在恢复模式下,我们不需要检查WAL级别。因为,如果WAL级别不足,在恢复期间是不可能到达这里的。  
     * 这意味着,如果系统正在恢复,它已经有了足够的WAL级别来支持恢复操作。  
     */  
	if (!backup_started_in_recovery && !XLogIsNeeded())
	// 如果不在恢复模式下,并且当前不需要WAL(即WAL级别可能不足),则报告错误 
		ereport(ERROR,
				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
				 errmsg("WAL level not sufficient for making an online backup"),
				 errhint("wal_level must be set to \"replica\" or \"logical\" at server start.")));

    // 检查提供的备份标签字符串长度是否过长 
	if (strlen(backupidstr) > MAXPGPATH)
		ereport(ERROR,
				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
				 errmsg("backup label too long (max %d bytes)",
						MAXPGPATH)));

	
	WALInsertLockAcquireExclusive();// 以独占模式获取WAL插入锁  
	XLogCtl->Insert.runningBackups++;// 增加正在运行的备份计数
	XLogCtl->Insert.forcePageWrites = true;// 启用强制全页写入
	WALInsertLockRelease(); // 释放WAL插入锁  

	/* Ensure we release forcePageWrites if fail below */
	PG_ENSURE_ERROR_CLEANUP(pg_backup_start_callback, (Datum) 0);
	{
		bool		gotUniqueStartpoint = false; // 标记是否已获取到唯一的起始点
		DIR		   *tblspcdir; // 指向表空间目录的DIR指针
		struct dirent *de; // 用于读取目录条目的指针
		tablespaceinfo *ti; // 指向表空间信息结构体的指针 
		int			datadirpathlen; // 数据目录路径的长度

		if (!backup_started_in_recovery)
			RequestXLogSwitch(false);
  1. 检查备份完整性和一致性 调用RequestCheckpoint函数,强制进行CHECKPOINT以防止页面撕裂问题,并确保两次连续的备份运行将有不同的检查点位置,从而有不同的历史文件名,接着获取对控制文件的共享锁(ControlFileLock)读取检查点的信息,并释放对控制文件的锁,最后在非恢复模式下,通过检查XLogCtl->Insert.lastBackupStart(记录最后一个备份起始点的WAL位置)与当前检查点的REDO指针(startpoint)来确保唯一起始点。如果startpoint大于lastBackupStart,则更新lastBackupStart并标记为已获得唯一起始点(gotUniqueStartpoint = true),如果当前检查点的REDO指针不是唯一的(即gotUniqueStartpoint仍为假),则循环会再次执行,强制进行另一次CHECKPOINT,并重复上述步骤,一旦获得唯一起始点(gotUniqueStartpoint = true),循环就会结束,并且备份过程可以继续进行,使用当前检查点的信息作为备份的起始点
do
		{
			// 用于存储检查点时的fullPageWrites状态  
			bool		checkpointfpw;

			/*  
             * 强制进行一次CHECKPOINT。这不仅是防止页面撕裂问题所必需的,而且确保两次连续的备份运行将有不同的检查点位置,  
             * 从而有不同的历史文件名,即使两次备份之间没有任何操作发生。  
             * 如果用户请求了快速模式(通过传递fast = true),则使用CHECKPOINT_IMMEDIATE;否则,可能会花费一些时间。  
             */  
			RequestCheckpoint(CHECKPOINT_FORCE | CHECKPOINT_WAIT |
							  (fast ? CHECKPOINT_IMMEDIATE : 0));

			/*  
             * 现在我们需要获取检查点记录的位置,以及它的REDO指针。从检查点开始恢复所需的最早的WAL位置正是REDO指针。  
             */ 
			LWLockAcquire(ControlFileLock, LW_SHARED); // 获取对控制文件的共享锁
			checkpointloc = ControlFile->checkPoint;  // 读取检查点的位置  
			startpoint = ControlFile->checkPointCopy.redo; // 读取REDO指针
			starttli = ControlFile->checkPointCopy.ThisTimeLineID; // 读取时间线ID
			checkpointfpw = ControlFile->checkPointCopy.fullPageWrites; // 读取检查点时的fullPageWrites状态 
			LWLockRelease(ControlFileLock); // 释放对控制文件的锁 

			if (backup_started_in_recovery) // 如果备份是在恢复模式下启动的
			{
				XLogRecPtr	recptr; // 用于存储最后一次禁用fullPageWrites的WAL记录指针
				/*  
                 * 检查自上次用作备份起始检查点的重启点以来,在线备份期间重放的所有WAL是否都包含全页写入。  
                 */
				SpinLockAcquire(&XLogCtl->info_lck); // 获取XLogCtl的自旋锁
				recptr = XLogCtl->lastFpwDisableRecPtr;// 读取最后一次禁用fullPageWrites的WAL记录指针
				SpinLockRelease(&XLogCtl->info_lck); // 释放XLogCtl的自旋锁

                /*  
                 * 如果检查点时的fullPageWrites未启用,或者REDO指针小于或等于最后一次禁用fullPageWrites的WAL记录指针,  
                 * 则报错,因为这意味着在备库上进行的在线备份可能已损坏。  
                 */  
				if (!checkpointfpw || startpoint <= recptr)
					ereport(ERROR,
							(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
							 errmsg("WAL generated with full_page_writes=off was replayed "
									"since last restartpoint"),
							 errhint("This means that the backup being taken on the standby "
									 "is corrupt and should not be used. "
									 "Enable full_page_writes and run CHECKPOINT on the primary, "
									 "and then try an online backup again.")));
				/*  
                 * 在恢复模式下,由于我们不使用备份结束WAL记录,也不写入备份历史文件,  
                 * 因此起始WAL位置不需要唯一。这意味着同时开始的两个基础备份可能会使用相同的检查点作为起始位置。  
                 */  
				gotUniqueStartpoint = true;// 标记为已获得唯一起始点(在恢复模式下总是为真)
			}
            WALInsertLockAcquireExclusive();// 以独占模式获取WAL插入锁  
			if (XLogCtl->Insert.lastBackupStart < startpoint)// 如果当前检查点位置大于之前的最后一个备份起始点
			{
				XLogCtl->Insert.lastBackupStart = startpoint;// 更新最后一个备份起始点 
				gotUniqueStartpoint = true;// 标记为已获得唯一起始点 
			}
			WALInsertLockRelease();// 释放WAL插入锁  
		} while (!gotUniqueStartpoint); // 如果未获得唯一起始点,则继续循环
  1. 获取表空间信息 如果平台支持符号链接,遍历pg_tblspc收集关于所有表空间的信息,读取并处理表空间的符号链接,最终将表空间的信息收集起来并写入到映射文件tblspcmapfile中;如果平台不支持符号链接,那么理论上不应该存在表空间,检测到表空间的话,可能是由其他机制或外部工具创建的,发出警告并忽略它们
         // 分配并初始化一个目录对象tblspcdir,用于读取"pg_tblspc"目录(存储表空间链接的目录)。
		// 调用XLByteToSeg函数,将startpoint(一个表示WAL位置的字节偏移量)和日志段大小(wal_segment_size)  
        // 转换为日志段编号(_logSegNo)。这通常用于找到WAL日志文件中特定位置所在的段。
		XLByteToSeg(startpoint, _logSegNo, wal_segment_size);
		// 调用XLogFileName函数,根据事务日志标识符(starttli),日志段编号(_logSegNo),  
        // 和日志段大小(wal_segment_size)来生成WAL日志文件的名称,并将该名称存储在xlogfilename中。
		XLogFileName(xlogfilename, starttli, _logSegNo, wal_segment_size);
		// 计算DataDir(数据库数据目录的路径)的长度,并将其存储在datadirpathlen中
		datadirpathlen = strlen(DataDir);
		tblspcdir = AllocateDir("pg_tblspc");
		// 使用ReadDir函数遍历"pg_tblspc"目录下的所有条目
		while ((de = ReadDir(tblspcdir, "pg_tblspc")) != NULL)
		{
			 // 定义一个足够大的字符数组fullpath来存储表空间目录的完整路径
			char		fullpath[MAXPGPATH + 10];
			 // 定义一个字符数组linkpath来存储表空间链接的目标路径(如果表空间是符号链接的话)。  
			char		linkpath[MAXPGPATH];
			 // 定义一个字符指针relpath,用于存储相对于DataDir的表空间路径(初始化为NULL)
			char	   *relpath = NULL;
			// 定义一个整数rllen来存储relpath的长度(稍后可能使用)
			int			rllen;
			 // 定义一个StringInfoData类型的escapedpath,用于存储转义后的路径(这里未直接使用,但可能用于后续处理)
			StringInfoData escapedpath;
			// 定义一个字符指针s,用于遍历字符串(未直接使用)
			char	   *s;

			/* Skip anything that doesn't look like a tablespace */
			// 跳过那些名字不包含纯数字的条目,因为表空间目录名通常只包含数字。
			if (strspn(de->d_name, "0123456789") != strlen(de->d_name))
				continue;

            // 构造表空间目录的完整路径
			snprintf(fullpath, sizeof(fullpath), "pg_tblspc/%s", de->d_name);

			 // 跳过那些不是符号链接或junction的条目。这里检查条目类型,确保它确实是一个指向表空间的链接。  
             // 注意:在测试中,如果启用了allow_in_place_tablespaces,可能会直接在pg_tblspc下创建目录,这将不满足条件。
			if (get_dirent_type(fullpath, de, false, ERROR) != PGFILETYPE_LNK)
				continue;
#if defined(HAVE_READLINK) || defined(WIN32)
			rllen = readlink(fullpath, linkpath, sizeof(linkpath));
			if (rllen < 0)
			{
				ereport(WARNING,
						(errmsg("could not read symbolic link \"%s\": %m",
								fullpath)));
				continue;
			}
			else if (rllen >= sizeof(linkpath))
			{
				ereport(WARNING,
						(errmsg("symbolic link \"%s\" target is too long",
								fullpath)));
				continue;
			}
			linkpath[rllen] = '\0';

			initStringInfo(&escapedpath);
			for (s = linkpath; *s; s++)
			{
				if (*s == '\n' || *s == '\r' || *s == '\\')
					appendStringInfoChar(&escapedpath, '\\');
				appendStringInfoChar(&escapedpath, *s);
			}

			// 检查linkpath是否以PGDATA目录的路径开头,并且紧接着是目录分隔符  
            // 如果是,那么计算相对路径,否则relpath为NULL  
			if (rllen > datadirpathlen &&
				strncmp(linkpath, DataDir, datadirpathlen) == 0 &&
				IS_DIR_SEP(linkpath[datadirpathlen]))
				relpath = linkpath + datadirpathlen + 1; //计算相对路径的起始位置

            // 为tablespaceinfo结构体分配内存
			ti = palloc(sizeof(tablespaceinfo));
			// 设置tablespaceinfo结构体的字段  
            // 注意:这里将de->d_name作为OID可能是一个错误,因为OID通常是整数类型  
			ti->oid = pstrdup(de->d_name);
			ti->path = pstrdup(linkpath);//设置原始路径
			ti->rpath = relpath ? pstrdup(relpath) : NULL;// 如果计算了相对路径,则设置,否则为NULL
			ti->size = -1;//初始化大小为-1,可能表示未知或未计算
                   
            // 如果tablespaces列表不为空,则将ti添加到列表中  
			if (tablespaces)
				*tablespaces = lappend(*tablespaces, ti);

            // 将tablespace的OID和转义后的路径写入到tblspcmapfile中  
            // 假设tblspcmapfile是一个用于存储映射信息的文件  
			appendStringInfo(tblspcmapfile, "%s %s\n",
							 ti->oid, escapedpath.data);
            // 释放escapedpath.data占用的内存
			pfree(escapedpath.data);
#else

			/*  
			 * 如果平台不支持符号链接,那么理论上不应该存在表空间,因为表空间通常是通过符号链接实现的。  
			 * 如果检测到表空间,那么可能是由其他机制或外部工具创建的。这里发出警告并忽略它们。  
			 */  
			ereport(WARNING,
					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
					 errmsg("tablespaces are not supported on this platform")));
#endif
		}
		//释放之前打开的目录句柄
		FreeDir(tblspcdir);
  1. WAL备份 向标签文件中添加WAL开始位置信息,检查点的位置信息,流式备份,指出是主库还是备库开始的,添加备份开始的时间信息,标签信息以及开始时的时间线 starttli_p: 备份开始时的时间线 ID 被写入此参数指向的变量中 labelfile tblspcmapfile: 这两个 StringInfo 结构的内容会被更新,以包含备份的标签信息和表空间映射信息(如果适用)
		//向标签文件中添加wal开始位置信息,检查点的位置信息,流式备份,指出是主库还是备库开始的,添加备份开始的时间信息,标签信息以及开始时的时间线
		stamp_time = (pg_time_t) time(NULL);
		pg_strftime(strfbuf, sizeof(strfbuf),
					"%Y-%m-%d %H:%M:%S %Z",
					pg_localtime(&stamp_time, log_timezone));
		appendStringInfo(labelfile, "START WAL LOCATION: %X/%X (file %s)\n",
						 LSN_FORMAT_ARGS(startpoint), xlogfilename);
		appendStringInfo(labelfile, "CHECKPOINT LOCATION: %X/%X\n",
						 LSN_FORMAT_ARGS(checkpointloc));
		appendStringInfo(labelfile, "BACKUP METHOD: streamed\n");
		appendStringInfo(labelfile, "BACKUP FROM: %s\n",
						 backup_started_in_recovery ? "standby" : "primary");
		appendStringInfo(labelfile, "START TIME: %s\n", strfbuf);
		appendStringInfo(labelfile, "LABEL: %s\n", backupidstr);
		appendStringInfo(labelfile, "START TIMELINE: %u\n", starttli);
	}
	//结束错误处理保证块的定义
	PG_END_ENSURE_ERROR_CLEANUP(pg_backup_start_callback, (Datum) 0);


	//标记备份的开始阶段已成功完成
	sessionBackupState = SESSION_BACKUP_RUNNING;

	//返回wal的开始位置
	if (starttli_p)
		*starttli_p = starttli;
	return startpoint;
}

基础备份——pg_backup_start_callback

pg_backup_start_callback是错误清理回调函数。

  1. 获取WAL插入锁(独占模式) 函数通过调用 WALInsertLockAcquireExclusive() 来获取WAL插入锁的独占访问权。接下来要修改的是与WAL插入相关的全局状态,需要确保没有其他线程或进程同时修改这些状态
static void
pg_backup_start_callback(int code, Datum arg)
{
	WALInsertLockAcquireExclusive();
  1. 断言备份计数大于0 使用Assert宏来验证XLogCtl->Insert.runningBackups的值大于0
Assert(XLogCtl->Insert.runningBackups > 0);
  1. 减少正在运行的备份计数XLogCtl->Insert.runningBackups的值减1,以反映一个备份过程已经失败或完成,并且不再需要跟踪它
XLogCtl->Insert.runningBackups--;
  1. 检查是否所有备份都已完成 函数检查XLogCtl->Insert.runningBackups是否变为0。如果是,这意味着没有更多的备份正在运行,因此可以安全地禁用强制全页写入,函数将XLogCtl->Insert.forcePageWrites设置为 false
if (XLogCtl->Insert.runningBackups == 0)
	{
		XLogCtl->Insert.forcePageWrites = false;
	}
  1. 释放WAL插入锁 调用WALInsertLockRelease()来释放WAL插入锁
WALInsertLockRelease();

作者介绍

周雨慧 中移(苏州)软件技术有限公司 数据库内核开发工程师