MySQL事务在MGR中的漫游记 - 事务认证

在上篇的基础上,本篇展开分析在pipeline中事务认证全过程,对应的handler为Certification_handler::handle_transaction_id()。应该说这篇的内容非常实用,在使用MGR时可能遇到的不少问题,都跟事务认证机制有关。不管是DBA还是MySQL开发都应该对此有所了解。

事务认证模块组成

在介绍事务认证过程之前,需要对事务认证模块建立一个基本的认识:事务认证模块由冲突检测数据库、事务gtid分配器和事务组提交信息分配器等三部分组成。

冲突检测数据库

冲突检测数据库Certification_info是由一个std::map,key是数据库名+表名+主键id经过哈希后的字符串,value为Gtid_set_ref对象(是gtid_set的超集),包含了主键版本信息(表示为更新了该主键id的最后一个事务执行完后的系统gtid_executed)。随着事务不断被认证,事务修改的主键版本信息不断加入到数据库中,数据库大小不断膨胀,所以需要定期将数据库中无用的主键版本清理掉。清理机制包括2个步骤,第一步是各节点周期性发送本节点的gtid_executed信息,第二步是节点在收到集群中所有节点的gtid_executed信息后,取交集,并使用该交集来清除掉数据库中无用信息。事务认证模块的变量stable_gtid_set维护了这个交集。


gtid分配器

基于冲突检测数据库事务认证模块能够进行事务认证,决定事务提交还是回滚。除此之外,事务认证模块还需要为认证通过的事务分配gtid,为此,认证模块建立了基于节点的gtid分配机制,MGR会计算可用的(未分配的)gtid序列,将其保存在std::list<Gtid_set::Interval> group_available_gtid_intervals上,需要注意的是,计算可用的gtid序列不是基于节点的gtid_executed信息,而是MGR自己维护的Gtid_set* group_gtid_executed信息,后者是前者的超集,前者未包括已经分配但还完成提交的事务集合,比如还在relay-log中的远端事务,以及认证通过但还未完成组提交的本地事务。

group_available_gtid_intervals又会被细分为最长group_replication_gtid_assignment_block_size的gtid区间Gtid_set::Interval(一段连续的gtid序列),并在需要时分配给某个节点,并以节点的member_uuid为key保存在std::map<std::string, Gtid_set::Interval> member_gtids上。举个例子,集群中有2个节点,group_replication_gtid_assignment_block_size为100,那么为节点A分配的Gtid_set::Interval为group_name:1-100,节点B分配的Gtid_set::Interval为group_name:101-200,则group_name:1-100和group_name:101-200分别作为Gtid_set::Interval保存在member_gtids上。A节点的事务T1认证通过后,分配gtid为group_name:1,接着A节点事务T2分配group_name:2,然后B节点事务进入认证模块,认证通过后,为其分配group_name:101,每分配一次gtid则gtids_assigned_in_blocks_counter增一。如果gtids_assigned_in_blocks_counter达到一轮gtid_assignment_block_size,或预分配的gtid用完,或集群的成员发生了增减,则会重新分配Gtid_set::Interval。正因为MGR这样的gtid分配机制,所以会造成MGR的gtid_executed有时是不连续的多段,如aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:1-2:101-105。


组提交信息分配器

除了分配事务gtid,对于远端事务,事务认证模块还需要决定其组提交信息(last_commited,sequence_number)。为此,模块中维护了全局的parallel_applier_last_committed_global和parallel_applier_sequence_number,并在Certification_info的每个Gtid_set_ref对象中保存了更新该主键的最后一个事务的sequence_number(item_previous_sequence_number)。事务进入认证模块时,会将当前的parallel_applier_last_committed_global作为事务的last_commited(transaction_last_committed),在每个远端事务认证通过后,会将当前的parallel_applier_sequence_number赋予该事务并增一。在该事务主键版本信息插入到冲突检测数据库时,会获取该主键的上一次事务提交序号item_previous_sequence_number,那么该事务更新的所有主键中最大的item_previous_sequence_number序号就是当前事务的last_committed。


事务认证流程

Certification_handler::get_transaction_context()获取缓存在transaction_context_packet上的事务认证所需信息,将其携带的server_uuid跟本节点uuid进行对比来确定是否为本地事务local_transaction。将其携带的事务执行时的snapshot_version(gtid_executed),事务更新/插入的记录主键信息write_set,事务gtid是否已经制定,事务执行节点server_uuid,事务gtid_log_event对象和local_transaction信息作为Certifier::certify()进行事务认证。

certify()函数不是简单的事务冲突检测处理函数,而是会根据是否为本地事务,是否启动了冲突检测(多主模式?正在主从切换?),事务是否已经有gtid等多种场景分别进行不同的处理。


冲突检测

如果启用了冲突检测conflict_detection_enable,那么需要从冲突检测数据库获取事务更新的每个主键的版本信息,与事务的快照版本snapshot_version进行一一比对,只有事务的snapshot_version不是冲突检测数据库中对应主键版本的子集时,事务才能够判定为认证通过。下图展示了事务冲突检测流程:




事务gtid分配

认证通过的事务,判断事务是否已存在gtid,默认情况下,MGR中认证的事务都是没有gtid的,只有当该MGR节点为另一个复制系统的从节点(slave)时才会有gtid。对于没有gtid的场景,需要调用Certifier::get_group_next_available_gtid(const char *member_uuid)来获取该事务执行节点member_uuid对应的下一个可用的gtid。

get_group_next_available_gtid()首先判断member_uuid是否为NULL和/或group_replication_gtid_assignment_block_size为1,前者表示MGR正在执行视图变更,后者表示不为节点预留gtid区间,即每次分配一个gtid。这两种场景下都是从还未分配的gtid序列中获取最小的gtid分配给事务。之后若是member_uuid为NULL场景而不是gtid_assignment_block_size为1场景,则调用Certifier::compute_group_available_gtid_intervals()重新计算集群可用(未分配)的gtid序列group_available_gtid_intervals。在重计算时gtids_assigned_in_blocks_counter、member_gtids和group_available_gtid_intervals均会重新进行初始化。

对于member_uuid不为NULL且gtid_assignment_block_size大于1时,若分配次数gtids_assigned_in_blocks_counter已达到gtid_assignment_block_size,则也需要compute_group_available_gtid_intervals()重新计算。基于member_uuid找到该成员可用的gtid区间,若还没为该成员分配gtid,则调用Certifier::reserve_gtid_block(longlong block_size)进行分配,block_size即为gtid_assignment_block_size。需要注意的是,reserve_gtid_block()是最多分配而不是一定分配block_size大小的gtid序列,是否等于block_size依赖于group_available_gtid_intervals的第一个可用的连续gtid序列大小是否等于或大于block_size。

正常情况下,get_group_next_available_gtid()会以member_uuid对应的预分配gtid区间起止边界为传入参数调用get_group_next_available_gtid_candidate()来获取对应区间内的gtid作为事务gtid,若在指定区间上未找到可用的gtid,则需重新调用reserve_gtid_block()获取新的gtid区间,并再次调用get_group_next_available_gtid_candidate()。获取事务gtid后,需更新预分配gtid区间,以便下次分配时能够快速定位到正确位置,同时gtids_assigned_in_blocks_counter增一。除外,还会将刚分配的事务gtid加入到事务的快照版本snapshot_version中,更新全局变量last_conflict_free_transaction,表示最后一次没有冲突的事务认证过程。

对于事务已经存在gtid的场景,会判断其gtid是否合法,最终也会将该gtid加入到事务的snapshot_version并更新last_conflict_free_transaction。


更新冲突检测数据库并确定组提交信息

对于非DDL事务,其事务包含的write_sets会被添加到冲突检测数据库中。结合“组提交信息分配器”小节,在将主键版本插入到冲突检测数据库前,会先构造Gtid_set_ref对象snapshot_version_value,并将事务已更新的snapshot_version和当前全局的parallel_applier_sequence_number加入其中,最后调用Certifier::add_item()将每个主键和其版本构成的key-value信息插入到certification_info中。

若当前认证的事务不是本地事务,将主键的键值插入冲突检测数据库时,若数据库中已包含该主键前一个版本信息,则add_item()会返回该版本对应事务的sequence_number,综合每个主键返回的前一个事务sequence_number,获取其中最大的一个,该sequence_number即为事务的last_committed。事务的sequence_number即为当前全局的parallel_applier_sequence_number。

基于此完成了事务last_commited和sequence_number的初始化。



非本地事务场景下,certify()最后会调用Certifier::increment_parallel_applier_sequence_number()判断该事务是否为DDL,若是则还需要将parallel_applier_last_committed_global更新为parallel_applier_sequence_number,确保当前的DDL事务独立成为一组单独提交。increment_parallel_applier_sequence_number()还会更新parallel_applier_sequence_number(增一)。


事务认证后处理

返回到handle_transaction_id()后,继续根据是否为本地事务,是否认证通过来进行后续处理。除了认证通过的远端事务需要调用next()进行下一阶段处理外,其他情况均调用Continuation对象signal()方法结束对该事务的处理流程。

本地事务认证后处理

对于本地事务,完成认证级标志着该事务在MGR中处理已结束,初始化Transaction_termination_ctx对象,用于在before_commit钩子返回后MYSQL_BIN_LOG::commit()能够判断事务应该提交还是回滚。Transaction_termination_ctx对象包括如下几个字段:m_thread_id表示事务对应的THD对象线程id,若事务认证通过,则m_rollback_transaction设置为false,否则为true。对于未携带gtid的认证通过事务,将m_generated_gtid设置为true,将m_sidno设置为MGR group_name,m_gno设置为产生的事务gtid。

完成Transaction_termination_ctx对象初始化后,调用rpl_transaction_ctx.cc中set_transaction_ctx()方法基于m_thread_id找到对应的THD,并将Transaction_termination_ctx对象赋予THD的m_transaction_ctx对象。

对于认证通过的事务,不管是否为本地事务,都需要将分配给事务的gtid加入到节点group_gtid_executed中。区分是否携带gtid,分别调用Certifier::add_specified_gtid_to_group_gtid_executed()或Certifier::add_group_gtid_to_group_gtid_executed(),如果是本地事务,还需要将事务的gtid作为最后一个认证通过的事务last_local_gtid(该值即为p_s.replication_group_member_stats中的LAST_CONFLICT_FREE_TRANSACTION)。

完成上述处理或上述任一阶段处理出错,均会如上一篇文章所述,调用certification_latch->releaseTicket(const K& key)来通知在before_commit钩子中等待的事务线程。

不管是否认证通过,本地事务事务请求信息中的第三部分log_event group被丢弃。




远端事务认证后处理

对于非本地事务(远端事务),不需要像本地事务一样构造Transaction_termination_ctx对象通知事务线程。若未认证通过或认证出错,则意味着结束了本事务的(流水线)处理,仅更新统计信息并返回,事务的log_event group丢弃。

若认证通过,与本地事务一样分别调用Certifier::add_specified_gtid_to_group_gtid_executed()或Certifier::add_group_gtid_to_group_gtid_executed()将事务gtid加入到节点group_gtid_executed中。对于不携带gtid场景,基于在认证模块中确定的gtid,last_commited和sequence_number来重新产生一个Gtid_log_event,并依次调用reset_pipeline_event()和set_LogEvent()替换流水线中原来的Gtid_log_event,确保下一个流水线环节写入到relay-log的是正确的Gtid_log_event信息。




冲突检测数据库清理机制

为了能够支持多节点写入,MGR中维护了冲突检测数据库。在前面的小节已经对该数据库做了简单介绍,下面以问答的形式进一步介绍其数据库清理机制:

一、冲突检测数据库的作用?通过上述介绍,冲突检测数据库的核心功能是通过事务的snapshot_version来决定事务是提交还是回滚。除此之外,对于认证通过的远端事务,还会基于数据库中保存的最后一次更新主键的事务sequence_number信息来决定事务的组提交行为。

二、什么时候能够清理数据库中的主键版本信息?随着事务不断提交,数据库中累积了越来越多主键记录,出于性能方面的考虑,数据库中的信息只能保持在内存中,而内存是有限而珍贵的,所以,无用的主键记录应该及时清理掉。当我们了解了数据库的作用后,就能确定清理的规则。很显然,如果一个事务A经过认证后,已经在MGR集群的各个节点都已经提交了,也就是说各节点的gtid_executed都包含了该事务gtid,由于事务在集群中的全局有序性,还未被认证的事务一定是在本节点事务A之后或同时(无相互依赖)执行的,那么可以确定后续需要在MGR中认证的事务都不会跟事务A有冲突。

三、怎么清理数据库中的主键版本?显然,某个节点要清理数据库,就需要获取集群中各个节点的gtid_executed信息,取他们的交集并将数据库中每条主键记录的版本跟该交集进行对比,若主键版本是该交集的子集,那么后续事务的认证一定用不到该主键版本信息。可以被安全得清理掉。所以,MGR的每个节点维护一个Certifier_broadcast_thread后台线程,该线程每隔60s发送自己的gtid_executed信息Gtid_Executed_Message(Plugin_gcs_message::CT_CERTIFICATION_MESSAGE),该信息也会跟事务一样,走paxos协议确保全局有序性。消息从paxos协议出来后的处理流程可参考事务消息的处理流程。最终调用Plugin_gcs_events_handler::handle_certifier_message(const Gcs_message& message)交由Certifier::handle_certifier_data()处理。

handle_certifier_data()使用Certifier对象的incoming队列缓存CT_CERTIFICATION_MESSAGE消息,待确定收齐了各节点的信息后调用Certifier::stable_set_handle(),去各节点gtid_executed的交集,作为Certifier::set_group_stable_transactions_set()的传入参数,该方法将传入的交集赋予stable_gtid_set,最终调用Certifier::garbage_collect()基于stable_gtid_set来数据库中无用的主键版本信息。

garbage_collect()完成垃圾清理后,还会额外做2件事情:1是调用Certifier::increment_parallel_applier_sequence_number()更新组提交信息分配器的全局parallel_applier_last_committed_global为当前的parallel_applier_sequence_number,并增一parallel_applier_sequence_number。防止原来的parallel_applier_last_committed_global比stable_gtid_set旧时导致组提交分配出错;2是调用channel_add_executed_gtids_to_received_gtids(const char* channel)更新用于远端事务回放的复制通道group_replication_applier的gtid集合(最终表现为p_s.replication_connection_status下的RECEIVED_TRANSACTION_SET)。


最佳实践

由于冲突检测数据库的存在,使用MGR需要注意哪些问题?显然,数据库中记录数越少占用的内存越小。节点间的延迟越小,gtid_executed约接近,需要记录的主键版本越少,所以应该合理设置MGR的节点间流控参数。尤其是在集群中有节点进行重建的时候,其gtid_executed会落后于其他节点,导致其他节点的冲突数据库无法清理而不断增大。

另一方面,尽可能使用小事务,大事务可能导致一个gtid聚集大量的主键信息,造成虽然节点间事务延迟不大,但是数据库中存在大量的主键无法通过每分钟一次的gtid_executed信息更新来清除,同时也无法通过以事务为计数单位的流控机制来进行事务提交速度限制。

再一方面,在对大表执行ALTER TABLE等DDL操作时,也可能引发冲突检测数据库暴涨的问题,(以单主模式为例)原因是Secondary节点在单线程回放DDL操作期间,Primary节点位于DDL之后的DML操作主键会一直在数据库中无法被清理。如果DML操作非常多,那么很快就会压爆内存。


总结

本篇主要介绍了事务在MGR认证详细过程,分别对认证模块中的冲突检测数据库组成和清理机制,事务冲突检测实施,事务gtid分配机制,事务组提交信息确定机制等做了详细的说明。相信阅读了本篇后能够对MGR中事务过程有深入的理解。

编辑于 2018-07-30

文章被以下专栏收录