openGauss数据库源码解析系列文章——存储引擎源码解析(四)
上篇图文openGauss数据库源码解析系列文章——存储引擎源码解析(三)中,从行存储缓存机制、cstore、日志系统三方面对磁盘引擎进行了分享,本篇将介绍磁盘引擎的持久化及故障恢复机制及主备机制,此外,还将对内存表的总体架构和代码概述、FDW两点展开详细介绍。
(八)持久化及故障恢复机制
1. 行存储持久化和检查点机制
如“日志系统”小节中所述,通过采用WAL日志的方式可以在对性能影响较小的情况下保障用户事务对数据库修改的持久化。然而如果只是依赖日志来保障持久化的话,那么数据库服务(故障)重启之后将需要回放大量的日志数据量,这会导致很大的RTO,对业务的可用性影响极大。因此共享缓冲区中的脏页也需要异步地写入磁盘中,来减少宕机重启后所需要回放的日志数据量,降低系统的RTO时间。
如果数据库系统在事务提交之后、异步写入磁盘的脏页写入磁盘之前发生宕机,那么需要在数据库再次启动之后,首先把那些宕机之前还没有来得及写入磁盘的脏页上的修改所对应的日志进行回放,使得这些脏页可以恢复到宕机之前的内容。
基于如上原理,可以得出数据库持久化的一个关键是:在宕机重启的时候,通过某种机制确定从WAL的哪个LSN开始进行恢复;可以保证在该LSN之前的那些日志,它们涉及的数据页面修改已经在宕机之前完成写入磁盘。这个恢复起始的LSN,即是数据库的检查点。
在“行存储缓存机制”小节介绍行存储缓存加载和淘汰机制中,已经知道参与脏页写入磁盘的主要有两类线程:bgwriter和pagewriter。前者负责脏页持久化的主体工作;后者负责数据库检查点LSN的推进。openGauss采用一个无锁的全局脏页队列数组来依次记录曾经被用户写操作置脏的那些数据页面。该队列数组成员为DirtyPageQueueSlot结构体,定义代码如下,其中:buffer为队列成员对应的buffer(该值为buffer id + 1),slot_state为该队列成员的状态。
typedef struct DirtyPageQueueSlot {
volatile int buffer;
pg_atomic_uint32 slot_state;
} DirtyPageQueueSlot;
|图1 全局脏页队列的运行机制和检查点的推进机制|
全局脏页队列的运作机制如图1所示,它的实现方式是一个多生产者、单消费者的循环数组。单个/多个业务线程是脏页队列的生产者,在其要修改数据页面之前,首先判断该页面buffer desc的首次脏页LSN是否非0:若该脏页buffer desc中的首次脏页LSN已经非0,说明该脏页在之前置脏的时候就已经被加入到脏页队列中,那么本次就跳过加入脏页队列的步骤;否则,对当前脏页队列的tail位置进行CAS加1操作,完成队列占位,同时,在上述CAS操作中,获取了脏页队列的LSN位置lsn1。然后,将占据的槽位位置(即CAS之前的tail值)和lsn1记录到脏页的buffer desc中。接着,将脏页的“buffer id”记录到占位的槽位中,再将槽位状态置为valid。最后,记录页面修改的日志,并尝试将该日志的位置lsn2更新到脏页队列的LSN中(如果此时脏页队列的LSN值已经被其他写业务更新为更大的值,则本线程就不更新了,也是一个CAS操作)。
基于上面这种机制,当将脏页队列中某个成员对应的脏页写入磁盘之后,检查点即可更新到该脏页“buffer desc”中记录的LSN位置。小于该LSN位置的日志,它们对应修改的页面,已经在记录这些日志之前就被加入到脏页队列中,亦即这些脏页在全局脏页队列中的位置一定比当前脏页更靠前,因此一定已经保证写入磁盘了。在图1中,“pagewriter”线程作为全局脏页队列唯一的消费者,负责从脏页队列中批量获取待写入磁盘的脏页,在完成写入磁盘操作之后,“pagewriter”自身不负责检查点的推进,而只是推进整个脏页队列的队头到下一个待写入磁盘的槽位位置。
实际检查点的推进由“Checkpointer”线程来负责。这是因为“pagewriter” 线程的写入磁盘操作,只是将共享缓冲区中的脏页写入到文件系统的缓存中,(由于文件系统的I/O合并优化)此时可能并没有真正写入磁盘。因此,在“Checkpointer”线程中,其先获取当前全局脏页队列的队头位置,以及对应槽位中脏页的首次脏页LSN值,然后对截至目前所有被写入文件系统的文件进行fsync(刷盘)操作,保证文件系统将它们写入物理磁盘中。然后就可以将上述LSN值作为检查点位置更新到control文件中,用于数据库重启之后回放日志的起始位置。
上述这套持久化和检查点推进机制的主要控制信息,保存在knl_g_ckpt_context结构体中,该结构体定义代码如下:
typedef struct knl_g_ckpt_context {
uint64 dirty_page_queue_reclsn;
uint64 dirty_page_queue_tail;
CkptSortItem* CkptBufferIds;
/* 脏页队列相关成员 */
DirtyPageQueueSlot* dirty_page_queue;
uint64 dirty_page_queue_size;
pg_atomic_uint64 dirty_page_queue_head;
pg_atomic_uint32 actual_dirty_page_num;
/* pagewriter线程相关成员 */
PageWriterProcs page_writer_procs;
uint64 page_writer_actual_flush;
volatile uint64 page_writer_last_flush;
/* 全量检查点相关信息成员 */
volatile bool flush_all_dirty_page;
volatile uint64 full_ckpt_expected_flush_loc;
volatile uint64 full_ckpt_redo_ptr;
volatile uint32 current_page_writer_count;
volatile XLogRecPtr page_writer_xlog_flush_loc;
volatile LWLock *backend_wait_lock;
volatile bool page_writer_can_exit;
volatile bool ckpt_need_fast_flush;
/* 检查点刷页相关统计信息(除数据页面外) */
int64 ckpt_clog_flush_num;
int64 ckpt_csnlog_flush_num;
int64 ckpt_multixact_flush_num;
int64 ckpt_predicate_flush_num;
int64 ckpt_twophase_flush_num;
volatile XLogRecPtr ckpt_current_redo_point;
uint64 pad[TWO_UINT64_SLOT];
} knl_g_ckpt_context;
其中和当前上述检查点机制相关的成员有:
(1)dirty_page_queue_reclsn是脏页队列的LSN位置,dirty_page_queue_tail是脏页队列的队尾,这两个成员构成一个16字节的整体,通过128位的CAS操作进行整体原子读、写操作,保证脏页队列中每个成员记录的LSN一定随着入队顺序单调递增。
(2)CkptBufferIds是每批pagewriter待刷脏页数组。
(3)dirty_page_queue是全局脏页队列数组。
(4)dirty_page_queue_size是脏页数组长度,等于“g_instance.attr.attr_storage.NBuffers * PAGE_QUEUE_SLOT_MULTI_NBUFFERS”,当前PAGE_QUEUE_SLOT_MULTI_NBUFFERS取值5,以防止脏页队列因为DDL(data definition language,数据定义语言)等操作引入的空洞过多,导致脏页队列撑满阻塞业务的场景。
(5)dirty_page_queue_head是脏页队列头部。
(6)actual_dirty_page_num是脏页队列中实际的脏页数量。
2. 故障恢复机制
当数据库发生宕机重启之后需要从检查点位置开始回放之后所有的日志。不同类型的日志的回放逻辑由对应的资源管理器来实现。
当用户业务压力较大时会同时有很多业务线程并发执行事务和日志记录的插入,单位时间内产生的日志量是非常大的。对此openGauss采用多种回放线程组来进行日志的并行回放,各个回放线程组之间采用高效的流水线工作方式,各个回放线程组内采用多线程并行的工作方式,以便保证日志的回放速率不会明显低于日志产生的速率。
|图2 openGauss并行回放流程示意图|
openGauss并行回放流程如图2所示,其中每个线程(组)的运行机制如下。
(1)“Walreceiver”线程收到日志成功写入磁盘后,“XLogReadWorker”线程从“Walreceiver”线程的buffer中读取字节流,“XLogReadManager”线程将字节流decode(解码)成redoitem(单个回放对象)。“Startupxlog”线程按照表文件名粒度(refilenode)将redoitem发放给各个“ParseRedoRecord”线程,其他的日志发送给“TrxnManager”线程。
(2)“ParseRedoRecord”线程负责表文件(relation)相关的日志处理,从队列中获取批量的日志进行解析,将日志按照页面粒度进行拆分,然后发给“PageRedoManager”线程。拆分原理如下。
① 针对行存储表、索引等数据页面操作的日志,按照涉及的页面个数拆成多条日志。例如heap_update日志,如果删除的老元组和插入的新元组在不同的页面上,那么会被拆成2条,分别插入到哈希表中。
② xact、truncate、drop database等日志是针对表的,不能进行拆分。针对这些日志,先清理掉哈希表中相关日志,然后等这些日志之前的日志都回放之后,再在PageRedoManger中进行回放,并将该日志分发给所有“PageRedoWorker”线程来进行invalid page(无效页面)的清理、数据写入磁盘等操作。
③ 针对Createdb(创建数据库)操作要等所有“PageRedoWorker”线程将Createdb日志之前的日志都回放后,再由一个“PageRedoManager”线程进行Createdb操作的回放。这个过程中其余线程需要等待Createdb操作回放结束后才能继续回放后续日志。
(3)“PageRedoManager”线程利用哈希表按照页面粒度组织日志,同一个页面的日志按照LSN顺序放入到一个列表中,之后将页面日志列表分发给“PageRedoWorker”线程。
(4)“PageRedoWorker”线程负责页面日志回放功能,从队列中获取一个日志列表进行批量处理。
(5)“TrxnManager”线程负责事务相关的Xlog日志的分发,以及需要全局协调的事务处理。
(6)“TrxnWorker”线程负责事务日志回放功能,从队列中获取一个日志进行处理。当前只有一个“TrxnWorker”线程负责处理事务日志。
为了保证高效的日志分发性能,“PageRedoManager”进程和“PageRedoWorker”进程之间采用了带阻塞功能的无锁单生产者单消费者(single producer single consumer,SPSC)队列。如图3所示,分配线程作为生产者将解析后的日志放入回放线程的列队中,回放线程从队列中消费日志进行回放。另一方面为了提升整体并行回放机制的可靠性,会在一个页面的回放动作中对日志记录头部的LSN和页面头部的LSN进行校验,以保证回放过程中数据库系统的一致性。
|图3 无锁SPSC队列示意图|
3. cstore列存储持久化机制
由于在openGauss中cstore主体数据没有写缓冲区,因此对于所有的插入或更新事务,在拼装完新的CU之后都是直接调用pwrite来写入文件系统缓存,并且在事务提交之前调用“CUStorage::FlushDataFile”接口完成本地磁盘的持久化(该函数内部调用fsync执行写入磁盘)。由于OLAP系统中通常插入事务都是批量导入执行的,因此在这个过程中对于cstore表物理文件的写操作基本都是顺序I/O,可以获得较高的性能。
(九)主备机制
openGauss提供主备机制来保障数据的高可靠和数据库服务的高可用。如图4所示,在主、备实例之间通过日志复制来进行数据库数据和状态的一致性同步。日志同步是指将主机对数据的修改日志同步到备机,备机通过日志回放将日志重新还原为数据修改。
|图4 主备机日志同步示意图|
参与日志同步的主要有“wal sender”(主机端)和“wal receiver”(备机端)两个线程。一个主机上可以由多个“wal sender”线程同时存在,用于给不同的备机进行日志复制;一个备机上同一时刻只会有一个“wal receiver”线程,从唯一一个指定的主机上拷贝日志。
“wal sender”线程的所有关键信息均保存在knl_t_walsender_context结构体中,其定义代码如下:
typedef struct knl_t_walsender_context {
char* load_cu_buffer;
int load_cu_buffer_size;
struct WalSndCtlData* WalSndCtl;
struct WalSnd* MyWalSnd;
int logical_xlog_advanced_timeout;
DemoteMode Demotion;
bool wake_wal_senders;
bool wal_send_completed;
int sendFile;
XLogSegNo sendSegNo;
uint32 sendOff;
struct WSXLogJustSendRegion* wsXLogJustSendRegion;
XLogRecPtr sentPtr;
XLogRecPtr catchup_threshold;
struct StringInfoData* reply_message;
struct StringInfoData* tmpbuf;
char* output_xlog_message;
Size output_xlog_msg_prefix_len;
char* output_data_message;
uint32 output_data_msg_cur_len;
XLogRecPtr output_data_msg_start_xlog;
XLogRecPtr output_data_msg_end_xlog;
struct XLogReaderState* ws_xlog_reader;
TimestampTz last_reply_timestamp;
TimestampTz last_logical_xlog_advanced_timestamp;
bool waiting_for_ping_response;
volatile sig_atomic_t got_SIGHUP;
volatile sig_atomic_t walsender_shutdown_requested;
volatile sig_atomic_t walsender_ready_to_stop;
volatile sig_atomic_t response_switchover_requested;
ServerMode server_run_mode;
char gucconf_file[MAXPGPATH];
char gucconf_lock_file[MAXPGPATH];
FILE* ws_dummy_data_read_file_fd;
uint32 ws_dummy_data_read_file_num;
struct cbmarray* CheckCUArray;
struct LogicalDecodingContext* logical_decoding_ctx;
XLogRecPtr logical_startptr;
int remotePort;
bool walSndCaughtUp;
} knl_t_walsender_context;
其中:
(1)WalSndCtl指向保存全局所有“wal sender”线程控制状态的共享结构体,是一致性复制协议的关键所在。
(2)MyWalSnd指向上述全局共享结构体中当前“wal sender”线程的槽位。
(3)Demotion为当前主机降备模式,分为未降备(NoDemote)、优雅降备(SmartDemote)和快速降备(FastDemote)。
(4)sendFile、sendSegNo、sendOff用于保存当前复制的日志文件的文件操作状态。
(5)reply_message用于保存备机回复的消息。
(6)output_xlog_message为待发送的日志内容主体。
(7)server_run_mode为wal sender线程启动时的HA(high availability,HA)高可靠性)状态,即主机(primary)、备机(standby)或未决(pending)。
(8)walSndCaughtUp指示备机是否已经追赶上主机。
(9)remotePort为wal receiver线程的端口,用于身份验证。
(10)load_cu_buffer 、load_cu_buffer_size 、output_data_message、output_data_msg_cur_len、output_data_msg_start_xlog、output_data_msg_end_xlog、ws_xlog_reader、CheckCUArray为后续支持混合类型(日志+增量页面)复制的预留接口。
wal receiver线程的所有关键信息均保存在knl_t_walreceiver_context结构体中,其定义代码如下:
typedef struct knl_t_walreceiver_context {
volatile sig_atomic_t got_SIGHUP;
volatile sig_atomic_t got_SIGTERM;
volatile sig_atomic_t start_switchover;
char gucconf_file[MAXPGPATH];
char temp_guc_conf_file[MAXPGPATH];
char gucconf_lock_file[MAXPGPATH];
char** reserve_item;
time_t standby_config_modify_time;
time_t Primary_config_modify_time;
TimestampTz last_sendfilereply_timestamp;
int check_file_timeout;
struct WalRcvCtlBlock* walRcvCtlBlock;
struct StandbyReplyMessage* reply_message;
struct StandbyHSFeedbackMessage* feedback_message;
struct StandbySwitchRequestMessage* request_message;
struct ConfigModifyTimeMessage* reply_modify_message;
volatile bool WalRcvImmediateInterruptOK;
bool AmWalReceiverForFailover;
bool AmWalReceiverForStandby;
int control_file_writed;
} knl_t_walreceiver_context;
其中:
(1)walRcvCtlBlock指向“wal receiver”线程主控数据,保存当前日志复制进度,备机日志写盘、写入磁盘进度,以及接受日志缓冲区。
(2)reply_message保存用于回复主机的消息。
(3)feedback_message用于保存热备的相关信息,供主机空闲空间清理时参考。
(4)request_message用于保存主机降备请求的相关信息。
(5)reply_modify_message用于保存请求配置文件复制的相关信息。
(6)AmWalReceiverForFailover表示当前“wal receiver”线程处于failover场景下连接从备进行日志追赶。
(7)AmWalReceiverForStandby表示当前“wal receiver”线程为连接备机进行日志复制的级联备机。
主备日志同步,主要包括以下6个场景:
1. 备机发起复制请求,进入流式复制。
|图5 主备建连和流式复制流程图|
如图5所示,日志复制请求是由“wal receiver”线程发起的。在libpqrcv_connect函数中,备机通过libpq协议连上主机,通过特殊的连接串信息,触发主机侧启动“wal sender”线程来处理该连接请求(相比之下,对于普通客户端查询请求,主机启动backend线程或线程池线程来处理连接请求)。在WalSndHandshake函数中,wal sender线程与wal receiver线程完成身份、日志一致性等校验之后,进入WalSndLoop开始日志复制循环。主要的主、备机握手和校验报文如表1所示,在主机收到T_StartReplicationCmd报文之后,开始进入日志复制阶段。
表1 主、备机握手和校验报文
报文类型 | 报文作用 |
T_IdentifySystemCmd | 请求主机发送主机侧system_identifier,校验是否和备机一致 |
T_IdentifyVersionCmd | 请求主机发送主机侧版本号,校验是否和备机一致 |
T_IdentifyModeCmd | 请求主机发送主机侧HA状态,校验是否是主机状态 |
T_IdentifyMaxLsnCmd | 请求主机发送当前最大的lsn位置(即日志偏移),用于备机重建 |
T_IdentifyConsistenceCmd | 请求主机发送指定lsn位置日志记录的crc值,校验是否和备机一致 |
T_IdentifyChannelCmd | 请求主机校验备机的端口是否在repliconn_info参数中,返回校验结果 |
T_IdentifyAZCmd | 请求主机发送主机侧AZ名字 |
T_BaseBackupCmd | 请求主机开始发起全量重建 |
T_CreateReplicationSlotCmd | 请求主机创建流复制槽 |
T_DropReplicationSlotCmd | 请求主机删除流复制槽 |
T_StartReplicationCmd | 请求主机开始日志复制 |
2. Quorum一致性复制协议
为了保证数据库数据的可靠和高可用,当主机上执行的事务修改产生日志之后,在事务提交之前需要将本事务产生的日志同步到多个备机上。openGauss采用Quorum一致性复制协议,即当多数备机完成上述事务的日志同步之后主机事务方可提交。这个过程中作为事务提交参考的是同步备,其他备机是异步备,作为冗余备份。同步备和异步备的具体选择可以通过配置synchronus_standby_names参数实现。
|图6 事务提交和一致性复制协议|
主机上事务提交和一致性复制协议的工作运行机制如图6所示。主要涉及的数据结构是WalSndCtlData数据结构体,其定义代码如下:
typedef struct WalSndCtlData {
SHM_QUEUE SyncRepQueue[NUM_SYNC_REP_WAIT_MODE];
XLogRecPtr lsn[NUM_SYNC_REP_WAIT_MODE];
bool sync_standbys_defined;
bool most_available_sync;
bool sync_master_standalone;
DemoteMode demotion;
slock_t mutex;
WalSnd walsnds[FLEXIBLE_ARRAY_MEMBER];
} WalSndCtlData;
其中 SyncRepQueue 是等待不同同步方式(备机日志写入磁盘、备机日志接收、备机日志回放等同步方式)的业务线程等待队列,用于当某一种同步方式满足条件之后,唤醒该类型的业务线程完成事务提交。lsn 是上述几种队列队头后台线程等待的日志同步位置。 sync_standbys_defined 表示是否配置了同步备机。 most_available_sync 表示是否配置了最大可用模式;如果已配置,则在没有同步备机连接的情况下,后台业务线程可以直接提交,不用阻塞等待。 sync_master_standalone 表示当前是否有同步备机连接。demotion 表示主机的降备方式。mutex表示保护 walsnds 结构体并发访问的互斥锁。walsnds 表示保存 wal sender 的具体同步状态和进度信息。
3. 计划外切换(failover)
|图7 failover流程示意图|
如图7所示,failover(故障切换)时主机是异常状态,所以只有备机参与failover。failover的核心是让备机在满足一定条件以后退出日志复制和日志恢复流程。当数据库主线程“postmaster”线程(简称PM线程)在reaper中收到“startup”线程(即恢复线程)的停止信号后,将实例状态设置为PM_RUN,并将实例HA状态设置为PRIMARY_MODE。
4. 计划内切换(switchover)
|图8 switchover流程示意图|
如图8所示,switchover的过程比failover多了主机降备的处理,备机的流程和failover流程一致,因此没有在图中标出,参考failover流程即可。
5. 备机重建
|图9 备机重建流程示意图|
如图9所示,备机重建的过程相当于对主机进行了一次全量备份和恢复的操作,主要步骤包括:清理残留数据、全量拷贝数据文件、复制增量日志、启动备实例。这个过程中比较关键的两点是:文件和日志的拷贝顺序,以及备机第一次启动时选择的日志恢复起始位置。
6. cstore数据复制
在openGauss中,对于cstore表的数据复制与上述介绍略有不同。在一主多备部署场景下,每个CU填充写盘之后都会将CU整体数据记录到日志文件中,从而通过主备的日志复制和备机的日志回放,就可以实现cstore表增量数据的主备同步。在主备从部署场景下,每个CU填充写盘之后会直接将该CU数据拷贝到主机与备机之间的数据发送线程的局部内存中,并在事务提交之前阻塞等待数据发送线程传输完增量的CU数据才能完成事务提交,因此也实现了cstore表增量数据的主备同步。
三、内存表
MOT(memory-optimized tables,内存表)是事务性、基于行存储的存储引擎,针对众核和大内存服务器进行了优化。MOT是openGauss数据库的一个先进特性,可提供非常高的事务性工作负载性能。MOT完全符合ACID要求,并支持严格的持久性和高可用性。企业用户可以将MOT用于关键任务、性能敏感的在线事务处理应用程序,以实现高性能、高吞吐量、低且可预测的延迟,并提升众核服务器的利用率。
(一)总体架构和代码概述
MOT引擎架构概述如图10所示。
|图10 MOT总体架构|
MOT的关键技术包括下面几个内容。
(1) 面向内存优化的数据结构。
(2) 乐观并发控制。
(3) 无锁索引。
(4) NUMA感知技术,事务性本地内存。
(5) 即时编译(Just-In-Time,JIT)。
总体而言,MOT的目标是建立一个在当前和未来的众核CPU架构时表现出色的OLTP系统,特别是性能应当可以随核数增加而线性扩展。根据经验实验,Masstree无锁实现和针对Silo(请参阅并发控制)的改进是最佳组合。
索引方面,通过比较各种先进解决方案后选择了Masstree,因为它在点查询、迭代等方面表现出最佳的整体性能。Masstree是Trie和B+树的组合,实现了对缓存、预取和细粒度锁的高效利用。它针对锁冲突严重的情况进行了优化,并在其他先进索引的基础上增加了各种优化。Masstree索引的缺点是内存消耗较大,虽然每行数据消耗的内存大小相同,但是每行每个索引(主索引或次索引)的内存平均高出16个字节:在磁盘表使用的基于锁的B树中为29个字节,而MOT的Masstree为45个字节。
并发控制算法方面,为了从内存架构中获得优势,设计考虑上最大限度地提高OLTP事务处理速度。虽然最近有一些内存多版本并发控制方面的改进,但为了避免迅速的垃圾收集,MOT只维护实际数据。MOT的另一个设计选择是不像HStore那样对数据进行分区,因为在实际的工作负载中事务跨区时性能会急剧下降。尽管已出现一些新的方法通过静态和动态分析来调整并行性,但此类方法会增加时延,并引入额外限制。
内存存储引擎常用的单版本、shared-everything类型的并发控制算法主要分为三类。
(1)优化并发控制有三个阶段。
① 读取阶段,从共享内存中读取事务记录,并将所有记录写入本地的私有副本。
② 验证阶段,执行一系列事务检查以确保一致性。
③ 写阶段,验证成功后,提交该事务;验证失败时将中止该事务,不会提交。两个OCC事务同时执行时不会互相等待。
(2)遭遇时间锁定(encounter time locking,ETL)。在ETL中,读取者是乐观的,但写入者会锁定待访问的数据。因此,来自不同ETL事务的写入者会互相看到,并可据此决定是否中止事务。ETL在两个方面提高了OCC的性能。首先,ETL能尽早发现冲突,而事务处理是有代价的,因为在提交时发现的冲突,需要中止至少一个事务,因此ETL可以在冲突情况下提高事务吞吐量。其次,ETL能够高效地处理写后读(reads-after-writes,RAW)。
(3)悲观的并发控制(以2PL为例)。在读取或写入时锁定一行,提交后释放锁。这些算法需要一些避免死锁的方法。死锁可以通过周期性的计算等待图(wait-for graph)来检测,也可以通过保持TSO(total store ordering)中的时间顺序或一些其他的规避方案来避免。在2PL算法中,如果一个事务正在写一行数据,其他事务就不能访问或写入该行数据,如果正在读一行数据,则不允许任何事务写该行数据,但可以读取这行数据。
对于大多数工作负载而言,OCC是最快的选择。原因之一是当CPU执行多个交互线程时,一个锁很可能被一个切换出去的线程持有。另外一个原因是悲观的算法涉及死锁检测,这会增大开销,同时读写锁比标准的自旋锁效率低。Silo来自Stephen Tu等人在计算机顶级会议SOSP13上发表的《Speedy Transactions in Multicore In-Memory Databases》,可以在现代众核服务器上实现卓越的性能和可扩展性。MOT最终选择了Silo,因为它比其他现有的方案,如TicToc更简单,同时在大多数工作负载下可保持很高的性能。ETL虽然有时比OCC快,但可能会触发不必要的中止退出。相比之下,OCC只在提交时实际发生冲突时中止退出。
目前,与业界其他领先的内存数据库理系统类似,MOT表的数据容量被限制在最大可用内存范围内。通过操作系统的内存页面交换技术可以扩展内存范围,但在这种情况下性能可能会下降。观察近年来业界出现了几种技术来缓解这个问题,包括数据重组、反缓存和分层等,这也是MOT未来的工作方向之一。
与磁盘引擎(包括共享内存等磁盘数据库的内存优化技术)相比,设计内存引擎的挑战主要是避免磁盘引擎那样基于页面的间接访问方式。
MOT存储引擎代码位于src/gausskernel/storage/mot目录下。目录结构如下。
src/gausskernel/storage/mot/ |
├── core |
├── fdw_adapter |
└── jit_exec |
MOT文件夹下有三个顶层子目录。
(1)core:包含MOT引擎的核心模块,如并发控制、事务管理、内存管理、存储、检查点、重做、恢复、事务、基础设施组件、统计、实用程序等。
(2)fdw_adapter:包含FDW适配器接口和实现。
(3)jit_exec:包含MOT JIT(Just-In-Time)组件。它有两种实现,一种使用本地LLVM(low level virtual machine),另一种使用TVM(tiny virtual machine),可以在不提供本地LLVM支持的计算机上使用。
(二)FDW
openGauss使用FDW API与内存引擎进行对接。实现上分为两个层次。
(1)消费者层——FDW API的实现,它由提供数据管理和操作的静态函数组成。这些函数通过fdwapi.h中的FdwRoutine结构以回调的形式暴露给上层。
(2)通信层——连接openGauss其他部分和MOT内部API。这包括数据和数据定义转换和对MOT内部表示的调整。
1. 消费者层
MOT消费者层FDW API的功能和用途如表2所示。其中,计划、执行阶段请参考《查询/更新/删除(计划阶段)》以及《查询/更新/删除(执行阶段)》两小节。
表2 MOT消费者层FDW接口简介
函数名 | 使用阶段(数字为调用顺序) | 描述 |
GetForeignRelSize | 计划1 | 查询过程中表的每个实例均调用,以评估大小 |
GetForeignPaths | 计划2 | 有索引情况下调用,确定哪些索引可用于从当前查询中的表中获取数据 |
GetForeignPlan | 计划3 | 创建用于从表中取数据的执行计划 |
PlanForeignModify | 计划4 | 在数据修改查询时调用。设置数据修改的附加信息 |
AddForeignUpdateTargets | 计划5 | 向查询输出添加其他结果列 |
BeginForeignScan | 执行1 | 在数据提取开始时,对查询中的每个表实例调用 |
BeginForeignModify | 执行1.1 | 修改查询 |
IterateForeignScan | 执行2 | 调用以获取相应的记录 |
ReScanForeignScan | 执行3 | 应重新启动迭代时调用 |
EndForeignScan | 执行4 | 调用以完成取数据 |
EndForeignModify | 执行4.1 | 在通过修改查询启动数据扫描时调用 |
ExecForeignInsert | 执行过程 | 满足其他条件时调用以完成记录插入 |
ExecForeignUpdate | 执行过程 | 满足其他条件时调用以完成记录修改 |
ExecForeignDelete | 执行过程 | 满足其他条件时调用以完成删除记录 |
ExplainForeignScan | 输出计划 | 执行explain时调用以打印详细计划信息 |
AnalyzeForeignTable | 分析1 | 对表做analyze操作 |
AcquireSampleRows | 分析2 | 收集采样信息用来做analyze操作 |
TruncateForeignTable | 截断 | 调用以清空表数据 |
VacuumForeignTable | 垃圾回收 | 清理表 |
NotifyForeignConfigChange | 配置 | 在数据库配置更改事件中调用 |
ValidateTableDef | DDL | 查询数据定义时调用 |
IsForeignRelUpdatable | 信息 | 调用以确定FDW支持的操作(选择/插入/更新/删除) |
GetFdwType | 信息 | 提供FDW类型 |
GetForeignMemSize | 统计信息 | 以字节为单位提供MOT引擎的内存使用情况 |
GetForeignRelationMemSize | 统计信息 | 以字节为单位提供存储表/索引数据的内存使用情况 |
2. 主要流程时序图
请注意,为了便于读者更好的理解正常流程和异常流程的关系,本节中的时序图均将正常流程和异常流程放在同一张图中,其中P1、P2……Pn为异常流程。同时,为简化时序图帮助理解流程,异常流程仅在异常发生的位置进行标识,未完整绘制异常流程的时序。
1) 创建表
|图11 创建表时序图|
用户创建一个新的内存表时,openGauss通过FDW适配器将请求转发给MOT存储引擎。创建表的正常流程和主要异常流程如图11所示。
正常事件流:FDW创建一个新的表对象。然后对每个列执行以下操作。
(1)FDW验证列定义。
(2)MOT引擎进一步验证列定义。
(3)创建给定类型的列对象并将其添加到表中。
(4)对所有列定义重复此过程。
添加完所有列后表定义本身就被验证,表对象已添加到MOT引擎,并通过锁保护,最后,由于表还没有索引,所以会向表中添加一个伪主索引/键。DDL命令会持久化到重做日志中。
P1:在此例外的事件流中,列定义失败时FDW通过ereport函数向openGauss报告无效列定义(invalid column definition)错误。
P2:在此例外的事件流中,以下原因之一会导致列定义验证失败:①不支持的列类型;②字段大小无效;③资源限制:已超过允许的表最大列数;④资源限制:列的总大小已超过最大元组大小;⑤资源限制:列名大小超过允许的最大值。
P4:总元组大小超过了允许的最大元组大小。
2) 删除表
|图12 删除表时序图|
如图12所示,用户删除内存表时,openGauss通过FDW适配器将请求转发给MOT存储引擎。
正常事件流:FDW从MOT引擎中检索表对象,并将删除表的请求转发给MOT引擎。DDL命令在重做日志中持久化,然后对于表中的每个索引,索引数据将被截断并删除索引对象。随后对表中的每个索引重复此过程。在删除所有索引对象之后,MOT将删除表对象并返回给FDW。
P1:在此例外的事件流中,没有找到索引所属的表。此错误条件被静默忽略,FDW不会向openGauss报告错误。
P2:在此例外的事件流中,在表对象中找不到请求的二级索引。此错误条件被静默忽略,FDW不会向openGauss报告错误。
3) 创建索引
|图13 创建索引时序图|
如图13所示,用户希望在现有的内存表中创建新索引时,openGauss通过FDW适配器将请求转发给MOT存储引擎。
正常事件流:FDW从MOT引擎中检索表对象并创建一个索引对象。然后,对每个列执行以下操作:①FDW验证列大小;②FDW验证列类型。对所有列定义重复此过程。验证所有列之后,生成的键大小也会被验证。在创建主索引时,原创建表阶段时添加的伪主索引将被新的主索引替换,应当在表仍然为空时完成。否则,将向表添加二级索引。索引数据本身是由主索引数据创建的。最后,整个DDL命令将持久化到重做日志。
P1:在此例外事件流中,不支持索引类型,FDW通过ereport工具向openGauss报告未支持的特性(feature unsupported)错误。目前只支持BTREE索引类型。
P2:在此例外的事件流中,列大小验证失败,FDW通过ereport实用程序向openGauss报告无效列定义错误。
P3:在此例外的事件流中,列类型验证失败,FDW通过ereport实用程序向openGauss报告未支持的特性(feature unsupported)错误。
P4:在此例外的事件流中,索引的总键大小超过了最大允许的键大小,FDW通过ereport工具向openGauss报告无效列定义错误。
4) 删除索引
|图14 删除索引时序图|
如图14所示,用户希望删除内存表中的现有索引时,openGauss通过FDW适配器将请求转发给MOT存储引擎。
正常事件流:FDW从MOT引擎中检索表对象,并转发从表中删除二级索引的请求。DDL命令在重做日志中持久化,然后截断索引数据并删除索引对象。
P1:在此例外的事件流中,没有找到索引所属的表。此错误条件被静默忽略,FDW不会向openGauss报告错误。
P2:在此例外的事件流中,在表对象中找不到请求的二级索引。此错误条件被静默忽略,FDW不会向openGauss报告错误。
5) 截断表
|图15 截断表时序图|
如图15所示,用户截断现有的内存表内容时,openGauss通过FDW适配器将请求转发给MOT存储引擎。
正常事件流:FDW从MOT引擎中检索表对象并转发截断表的请求。表中每个索引的索引数据被截断,并且将DDL命令持久化到重做日志。
P1:在此例外的事件流中,没有找到该表。此错误条件被静默忽略,FDW不会向openGauss报告错误。
6) 插入行
|图16 插入行时序图|
如图16所示,openGauss通过FDW适配器将请求转发给MOT存储引擎。可以通过自动提交(auto-commit)插入行,也可以在事务中插入行。
正常事件流:FDW从MOT引擎中检索表对象并创建新的行对象。由于内存引擎不同于磁盘引擎,不使用基于页面的间接访问形式,因此需要将行格式从openGauss行格式转换为MOT行格式(MOT将这种行格式转换称为Pack,反向转换称为unpack)后才能插入到表中。随后为该表的每个索引创建一个键。插入行的整个请求被传递到当前Txn,随后将该请求转发到并发控制模块,并持久化到重做日志。
P2:在此例外的事件流中,行插入失败,原因如下:①内存分配失败;②在主节点上违反了唯一约束。在这两种情况下,父事务都将使用正确的错误代码中止。
7) 查询/更新/删除(计划阶段)
|图17 查询/更新/删除(计划阶段)时序图|
用户可以在内存表中查询、更新或删除行。每个操作分为两个阶段:计划阶段和执行阶段。图17主要关注计划阶段。
每个SELECT/UPDATE/DELETE的规划阶段包括选择最佳执行计划。为此,openGauss准备了几个可能的执行路径,并要求FDW估计每个此类路径的开销,以便openGauss可以选择最佳的执行路径。
正常事件流:openGauss调用GetForeignRelSize接口,FDW从MOT中检索相关表对象,并用启动成本和总成本估计初始化此查询的FDW状态。openGauss调用GetForeignPaths触发所有涉及索引对象的开销计算。最后,openGauss调用GetForeignPlan触发结束整个过程,包括查询子句对本地和远程排序,以及根据所选执行路径对FDW状态进行序列化。PREPARE语句的执行在此结束,其他语句待执行的部分将在执行阶段中描述。
8) 查询/更新/删除(执行阶段)
|图18 查询/更新/删除(执行阶段)|
如18所示,计划阶段完成后,执行阶段开始。
正常事件流:openGauss调用BeginForeignScan,FDW检索相关表并初始化查询的FDW状态。在进行UPDATE/DELETE操作时,openGauss通过调用BeginForeignModify接口触发一个额外的初始化阶段,然后返回NULL。openGauss通过调用IterateForeignScan接口进行如下操作:①仅在需要时一次性初始化游标;②在当前事务对象中查找下一行;③将行数据从MOT格式转换为openGauss格式;④游标前进;⑤返回包含unpack行的槽位。重复此过程,直到游标中不再有行,并且返回NULL到openGauss。然后openGauss应用本地条件/查询子句等本地过滤器来决定是否继续处理该行。在进行SELECT操作时,该行将被添加到结果集中,并返回结果集给用户。进行UPDATE和DELETE操作需执行的其余部分将在后文中进行描述。
9)更新(结束执行阶段)
|图19 更新(结束执行阶段)时序图|
执行SELECT、UPDATE和DELETE语句的公共部分后,每个语句的剩余部分有所不同。图19描述UPDATE的剩余部分。
正常事件流:openGauss为特定的更新元组调用ExecForeignUpdate接口。FDW更新当前事务对象中最后一行的状态以进行并发控制,然后FDW将行数据从openGauss格式转换为MOT格式,并通过覆盖该行的方式完成变更字段的更新。该操作在重做日志中持久化,并返回openGauss。
P1:在此例外的事件流中,由于并发控制事务对象的行状态更新失败。在这种情况下,父事务将以适当的错误代码中止。
10) 删除行(结束执行阶段)
|图20 删除行(结束执行阶段)|
图20描述了DELETE操作执行的剩余部分。
正常事件流:openGauss为特定更新的元组调用ExecForeignDelete接口。FDW更新当前事务对象中最后一行的状态以进行并发控制,然后将操作持久化到重做日志中,并返回openGauss。
P1:在此例外的事件流中,由于并发控制事务对象的行状态更新失败。在这种情况下,父事务将以适当的错误代码中止。
由于内容较多,关于内存表其他方面的介绍将在下篇图文进行分享,敬请期待!
文章转自公众号:openGauss