数据库内核月报

数据库内核月报 - 2022 / 04

MariaDB · 功能特性 · 无DDL延迟的主备复制

Author: 甄平

背景介绍

基于Binlog主备复制是MySQL社区最广泛采用的高可用架构。长期以来,MySQL引入了很多特性来优化备库的复制延迟,比如多线程并行回放、LOGICAL_CLOCK并行复制、WRITESET并行复制。但是DDL延迟一直是MySQL主备复制的顽疾。

这个问题可以参考下图的时间轴理解。DDL语句一般都非常耗时,目前MySQL常用的DDL都支持Online模式,主库执行过程中是不干扰正常读写请求的。不过,这在备库是完全不一样的故事。主库DDL执行成功后会向Binlog写入一条Query_log_event,同步到备库的Relaylog后,备库会切换为单线程回放该DDL,执行结束后才再次恢复为多线程并行回放,继续应用DDL执行期间落下的DML日志。可以看出,一条DDL要经过至少2倍的执行时间,才能应用到各个备库。同时因为主库持续有DML写入,备库会积累数小时的复制延迟,因此还需要花费额外的时间去追平日志。最终导致两个问题,一是备库无法提供低延迟的读扩展,二是基于Binlog复制的高可用架构失效。

image.png

针对这个问题,PolarDB等云原生数据库给出的解决方法是使用基于Redo物理复制。Redo描述物理页级别的修改,本质上不区分一条日志是属于DDL还是DML。DDL边执行边产生Redo,备库收到日志后可以立即进行回放,因此能够确保低延迟。那么,在Binlog逻辑复制的场景下,MySQL官方似乎没有优化的计划,2014年有人给官方提过issue,不了了之。事实上,主备架构下的DDL复制延迟问题极大地削弱了Online DDL的价值,毕竟没有人会在生产环境跑单机数据库。

MariaDB方案

平时我很少关注MariaDB的社区动态,近期翻阅他们的ReleaseNote,很惊喜的发现MariaDB正开始尝试彻底解决这个问题。MariaDB在今年2月份的10.8版本中发布了新功能 —— Lag Free Alter On Slave。文档说明如下:

Normally, ALTER TABLE gets fully executed on the primary first and only then it is replicated and starts executing on replicas. With this feature ALTER TABLE gets replicated and starts executing on replicas when it starts executing on the primary, not when it finishes. This way the replication lag caused by a heavy ALTER TABLE can be completely eliminated.

其实在MySQL官方issue的讨论中,已经提到了类似的思路。让主库先写Binlog再执行,备库提前同步到DDL语句,立刻执行,达到主备同时执行的效果。这个方法看似简单直接,实现中还需要考虑很多细节。假设我有一个加列的DDL操作,主库先执行成功,备库还没执行完。此时备库可能会同步到主库包含这个新列的Insert或Update的Binlog,备库直接回放就会出现“列不存在”的报错。因为备库没有足够的信息知道这个新旧表结构的分界线。另一个场景,如果主库的DDL最终执行失败,或者中途kill,就需要实现一个机制让备库放弃相应的操作,否则主备表结构最终会不一致。那么MariaDB是如何解决这些问题的,可以从代码中略知一二。

功能初探

先从整体视角介绍下这个功能。Lag Free Alter On Slave把ALTER请求的日志记录分为两个部分,分别是START ALTER和COMMIT/ROLLBACK ALTER。首先,DDL的MDL锁/表锁获取之后,就可以提前记录START ALTER到Binlog。这个逻辑保证了对于Copy类型的DDL来说,所有START ALTER之前的Binlog都使用旧的表结构,这是备库需要感知的第一个Barrier。在DDL执行快结束的时候,即新的表结构生效前,如果成功则写入COMMIT ALTER,否则ROLLBACK ALTER,这是备库的第二个Barrier。备库可以先完成DDL所需的大部分工作,在等待读到COMMIT/ROLLBACK ALTER之后,才真正生效,以保证基于新表结构的Binlog在DDL提交之后再开始回放。下图中的时间轴进一步解释了这个方案。

image.png

从原理上分析,我个人认为主要有以下关键点。COMMIT ALTER之前的Binlog都是采用旧表结构,因此Slave在收到START ALTER之后是可以安全的并行回放的。如果DDL支持Online,即使是同一张表的DML,也可以并行的执行。在DDL完成了大部分工作之后,可以进入一种待提交的状态。这里其实分两种情况。场景1,DDL待提交之前已经收到COMMIT ALTER,那么如果是Copy DDL就可以直接提交;如果是Online DDL,则需要等COMMIT ALTER之前的Binlog都应用完毕才能提交。假设考虑官方MySQL的Online DDL实现方式,这里就应该继续收集row_log,并完成所有增量Online日志的应用。场景2,DDL待提交之前已经收到了COMMIT ALTER,那么COMMIT ALTER之后的Binlog都应该停止回放并暂时等待。等DDL执行完毕提交后,才能继续回放后续的Binlog。如果是ROLLBACK ALTER的场景,由于待提交状态的DDL在备库并没有真正的生效,可以直接丢弃DDL产生的临时文件。

这个功能还可以引申出两点优势:1. Master-Slave-Slave这类场景下,相比原先,DDL随着级联成倍放大延迟,两阶段日志的方案可以获得级联Lag Free的收益;2. 分库分表业务场景下,Master会基于同样的模板在各个分表批量执行DDL,两阶段方案允许分表DDL在Slave也能像Master一样并行执行,耗时上会远远优于原先无脑串行的逻辑。

代码简读

在读代码的过程中,我发现MariaDB的GTID、DDL、并行复制这些模块的实现和官方的MySQL有不小的差异。这些模块的具体逻辑我并不熟悉,因此以上提到的关键点暂时还不能确定MariaDB实现到了什么程度。本文仅参考核心的Patch,列举关键的函数。

整个功能是由变量binlog_alter_two_phase控制的,这是一个session级别的变量,所以支持针对某些连接的单个DDL专门开启两阶段模式的开关:

static Sys_var_mybool Sys_binlog_alter_two_phase(
       "binlog_alter_two_phase",
       "When set, split ALTER at binary logging into 2 statements: "
       "START ALTER and COMMIT/ROLLBACK ALTER",
       SESSION_VAR(binlog_alter_two_phase), CMD_LINE(OPT_ARG),
       DEFAULT(FALSE));

每一个DDL是由一个Gtid_log_event和一个Query_log_event组成。三种不同类型的Binlog是通过Gtid_log_event中的标识体现出来的,代码新增了三个标识:

  static const uchar FL_START_ALTER_E1= 2;
  static const uchar FL_COMMIT_ALTER_E1= 4;
  static const uchar FL_ROLLBACK_ALTER_E1= 8;

每一个ALTER的两部分日志,通过Gtid_log_event中新增的sa_seq_no数据关联起来。Binlog解析出来会是如下的状态。COMMIT/ROLLBACK ALTER中的id,对应START ALTER日志gtid的seq_no。

image.png

主库写入DDL的调用链:

dispatch_command
- mysql_parse
  ...
  - mysql_alter_table
    - mysql_inplace_alter_table
      - MDL_context::upgrade_shared_lock
      - write_bin_log_start_alter
        - write_bin_log_with_if_exists // 写ALTER第一部分
    - write_bin_log_with_if_exists // 写ALTER第二部分,如果失败,会调用write_bin_log_start_alter_rollback

备库的Master_info结构中维护了List <start_alter_info> start_alter_list,备库回放执行到write_bin_log_start_alter的时候,会将相关的DDL信息start_alter_info维护在start_alter_list中。start_alter_list是一个链表,当存在多个元素时,说明系统正在备库并行回放多个DDL。start_alter_info中也记录了DDL回放的状态start_alter_state,包括REGISTERED、COMMIT_ALTER、ROLLBACK_ALTER和COMPLETED四种。

struct start_alter_info
{
  /*
    ALTER id is defined as a pair of GTID's seq_no and domain_id.
  */
  decltype(rpl_gtid::seq_no) sa_seq_no; // key for searching (SA's id)
  uint32 domain_id;
  bool   direct_commit_alter; // when true CA thread executes the whole query
  /*
    0 prepared and not error from commit and rollback
    >0 error expected in commit/rollback
    Rollback can be logged with 0 error if master is killed
  */
  uint error;
  enum start_alter_state state;
  /* We are not using mysql_cond_t because we do not need PSI */
  mysql_cond_t start_alter_cond;
};

enum start_alter_state
{
  INVALID= 0,
  REGISTERED,           // Start Alter exist, Default state
  COMMIT_ALTER,         // COMMIT the alter
  ROLLBACK_ALTER,       // Rollback the alter
  COMPLETED             // COMMIT/ROLLBACK Alter written in binlog
};

备库回放DDL的调用链:

handle_slave_sql // 日志分发
- exec_relay_log_event
  - rpl_parallel::do_event
    - rpl_parallel_entry::choose_thread
      - handle_split_alter

handle_rpl_parallel_thread // 日志回放
- rpt_handle_event
  - apply_event_and_update_pos_for_parallel
    - apply_event_and_update_pos_apply
      - Log_event::apply_event
        - Query_log_event::do_apply_event
          - Query_log_event::handle_split_alter_query_log_event
              // 对于log-slave-updates=ON的场景,这一步也会写入binlog,这样级联的备库也能够提前执行DDL
              if (COMMIT/ROLLBACK ALTER binlog)
                // 从start_alter_list查询出start_alter_info,并更新state
          - mysql_parse // START ALTER日志场景下,执行DDL
            ...
            - mysql_alter_table
              - mysql_inplace_alter_table
                - write_bin_log_start_alter // 初始化start_alter_info,添加到start_alter_list
                - wait_for_master
                - process_master_state
                  - alter_committed

其中备库日志分发还提到了一个特殊的需求,多个DDL的START ALTER需要调度给不同的回放线程去执行,这个目的是为了防止Deadlock。假设一个线程在执行START ALTER,且还没有收到COMMIT ALTER,如果把另一个START ALTER分发给这个线程,线程任务流就出现了SA 1、SA 2、CA 1的情况,SA 2等待SA1,CA1等待SA 2,而SA 1又需要等待CA 1,形成了一个死锁环。

再谈一个Corner Case。假设DDL在Master执行成功了,也就意味着存在CA。如果Slave端SA因为一些奇怪的原因执行失败了,会设置一个direct_commit_alter标记位。当处理CA的Binlog时,如果发现direct_commit_alter被设为true,在Query_log_event的apply_event流程中,会重新走一遍mysql_alter_table。作者的这个实现非常巧妙,相当于通过回退到老的模式,在单线程状态下,做了一个简单的DDL重试。

最后,从代码里粗略的还能看到其他逻辑,比如:Shutdown和Cleanup;FTWRT的适配;Crash-Safe的处理;GTID position的维护…这些依赖原有模块的背景知识,暂时都没细看。另外,我认为整个框架的可运维性还有待完善,比如如果因为某些bug,Slave执行了SA,但是CA丢失了,这个时候Slave回放就会永久的Hang在那。是不是可以做一个手动的操作,通过特殊的SQL显式Commit/Rollback这个DDL,来保证和Master的一致性。最后,整个DDL逻辑是不是100%的Crash-Safe,并行回放是否能最高效地执行,这些都有待未来详细的实测。

总结

我把Lag Free Alter On Slave直译成了无DDL延迟的主备复制,实际上,MariaDB的方案还是会存在一定延迟的,也存在不少问题(因为我已经初步实测过 🙂)。一方面来说,想要完美地解决Binlog DDL复制延迟的问题存在很大的挑战性,另一方面,很高兴看到MariaDB率先做出了突破性的成果。从云厂商角度来看,尽管PolarDB这类云原生数据库通过物理复制绕过了这个课题,Binlog依然是MySQL生态中不可或缺的组成部分,那么就看看哪一家大厂能在MySQL框架下首先实现类似的功能了。