深入理解二阶段提交协议(DDB对XA悬挂事务的处理分析)(一)

达芬奇密码2018-06-14 11:25

你知道吗?
你知道二阶段提交协议?嗯,你可能知道,那你知道各种宕机、通信异常情况下的故障恢复细节?
你知道DDB是分布式数据库,那你知道DDB如何保证跨多个DBN的数据一致性?
你知道什么是悬挂事务?它何时出现?会有什么影响?如何处理它们?
你知道DDB可能出现数据不一致?那是在什么场景?
…………
这些可能也没人跟你说。

本文从原理出发……
从深入理解二阶段提交协议开始,
从事务处理模型到XA规范,
从MySQL各版本对XA的支持情况到各种坑,
从反编译DDB窥视其内部,深入理解其事务协调者实现,再到畅想实现更高的数据一致性容错机制

这篇万字长文希望能给你带来一些收获。

本文背景
前段时间在了解分布式场景下的事务处理套路。二阶段提交是一个非常经典且应用极为广泛的分布式协议,那就先从它开始吧。 网上关于2PC的文章很多,但对在出现各种故障后,如何进行故障恢复讲的较少。所以,我想着,要找个经典的中间件好好分析下。

DDB从06年开始为网易核心产品提供分库分表服务,十多年风雨,见证了各大产品兴衰,自己始终屹立不倒,是杭研后台产品中资历最老, 最为稳定的产品。DDB是怎么处理跨DBN节点的事务呢?我很感兴趣,可惜DDB是商业闭源的,核心文档也保密,因此我只能反编译DDB, 只为理解各种场景产生XA悬挂事务时,如何进行补救,如何保证数据一致性。 巧的是在理解反编译代码时找到了一个DDB的BUG,在我跟马进、劲松确认后确实存在问题。 这个BUG会导致部分异步提交的分布式分支事务状态未知(实际应该是已提交,不影响业务数据)。

本文大致分为5个部分:

  • 理解二阶段提交协议及故障恢复
  • 了解DTP模型&XA规范
  • 了解MySQL对XA的支持
  • 分析DDB对XA悬挂事务的处理
  • 一点点想法,能否做的更好?

本文的讨论范围是二阶段提交,不对其他分布式事务解决方案做讨论,如有理解不到位的地方,欢迎大家指正。


理解二阶段提交协议及故障恢复

在分布式系统中,每个节点无法知道其他节点的操作是成功或失败。 当一个事务跨多个节点时,为了保持事务的ACID特性,需要引入一个协调者组件统一来管控所有参与者。 分布式系统的难点之一是如何在多个节点进行事务性操作时保持一致性。 二阶段提交就是为保持事务提交一致性而设计的一种协议,二阶段提交具有比较强的一致性。

二阶段提交算法的成立基于以下假设:

  • 所有节点不会永久性损坏,即使损坏后仍然可以恢复
  • 所有节点都采用WAL,日志写入后就被保存在可靠的存储设备上
  • 所有节点上的本地事务即使机器crash也可从WAL日志上恢复

网上看到的二阶段提交的图基本如上图,比较简单,很多细节没有讲到,我画了两张细图,结合着我们看下细节。

回滚流程

提交流程

准备阶段

  • 协调者记录事务开始日志
  • 协调者向所有参与者发送prepare消息,询问是否可以执行事务提交,并等待参与者响应
  • 参与者收到prepare消息后,根据自身情况,进行事务预处理,执行询问发起为止的所有事务操作
    • 如果能够提交该事务,将undo信息和redo信息写入日志,进入预提交状态
    • 如果不能提交该事务,撤销所做的变更,并记录日志
  • 参与者响应协调者发起的询问。如果事务预处理成功返回commit,否者返回abort。

准备阶段只保留了最后一步耗时短暂的正式提交操作给第二阶段执行。

提交阶段

  1. 协调者等待所有参与者准备阶段的反馈

    • 如果收到某个参与者发来的abort消息或者迟迟未收到某个参与者发来的消息
      • 标识该事务不能提交,协调者记录abort日志
      • 向所有参与者发送abort消息,让所有参与者撤销准备阶段的预处理
    • 如果协调者收到所有参与者发来的commit消息
      • 标识着该事务可以提交,协调者记录commit日志
      • 向所有参与者发送commit消息,让所有参与者提交事务
  2. 参与者等待协调者的指令

    • 如果参与者收到的是abort消息
      • 中止事务,利用之前写入的undo日志执行回滚,释放准备阶段锁定的资源
      • 记录abort日志
      • 向协调者发送rollback done消息
    • 如果参与者收到的是commit消息
      • 提交事务,释放准备阶段锁定的资源
      • 记录commit日志
      • 向协调者发送commit done消息
  3. 协调者等待所有参与者提交阶段的反馈

    • 如果协调者收到所有参与者发来的commit done消息
      • 完成事务,记录事务完成日志
    • 如果协调者收到所有参与者发来的rollback done消息
      • 取消事务,记录事务取消日志

为什么先写日志
为什么在执行任务前需要先写日志?如果没有日志,宕机后重启将无法知道事务状态,无法进行故障恢复。

宕机异常处理
正常情况下,两阶段提交机制都能较好的运行,当在事务进行过程中,参与者或协调者宕机了怎么办?

  1. 阶段一,协调者记录全局事务开始日志前宕机
    协调者重启后,所有参与者均未开始分支事务,不做任何处理
  2. 阶段一,协调者记录全局事务开始日志后,发出prepare消息后宕机
    协调者重启后,可能部分参与者已经完成一阶段准备,所以需要进行回滚
  3. 阶段一,协调者收到参与者响应后,记录准备完成日志前宕机
    协调者重启后,因为没有记录准备完成日志,所以需要进行回滚
  4. 阶段一,协调者收到参与者响应后,记录准备完成日志后宕机
    协调者重启后,已经记录准备完成日志,可以根据协商结果进行提交或回滚
  5. 阶段二,协调者发出commit消息后宕机
    协调者重启后,已经记录准备完成日志,可以根据协商结果进行提交或回滚
  6. 阶段二,协调者收到参与者响应后,记录事务完成日志前宕机
    协调者重启后,已经记录准备完成日志,可以根据协商结果进行提交或回滚
  7. 阶段二,协调者收到参与者响应后,记录事务完成日志后宕机
    协调者重启后,发现事务已经完成,不做任何处理
  8. 阶段一,某个参与者宕机(未完成prepare)
    参与者重启后,需要进行回滚(分布式事务的状态是abort)
  9. 阶段二,某个参与者宕机(已完成prepare)
    参与者重启后,参与者如果没有询问其他参与者或协调者事务是否提交的能力,恢复后事务处于悬挂状态,等待协调者指令(分布式事务的决策可能是提交,可能是回滚)

超时异常处理

  1. 阶段一,协调者发出prepare消息,但没有收到所有参与者的响应
    协调者给所有参与者发送回滚指令
  2. 阶段二,协调者发出commit消息,但没有收到所有参与者的响应
    协调者可异步不断对超时节点尝试提交

二阶段提交的缺点

  1. 同步阻塞
    事务执行过程中,所有参与节点都是事务阻塞的。当参与者占有资源时,其他访问相关资源的进程也将处于阻塞状态。 参与者对锁资源的释放必须等到事务结束,所以与一阶段提交相比,执行同样的事务,二阶段会耗费更多时间。 事务执行时间的延长意味着锁资源发生冲突的概率增加,当事务并发量达到一定数量时,会出现大量事务积压甚至出现死锁,系统性能会严重下滑。

  2. 单点故障
    一旦协调者发生故障,参与者会一直阻塞。参与者完成准备阶段后,协调者发生故障,所有的参与者都将处于锁定事务资源的状态中(事务悬挂状态),无法继续完成事务操作。

  3. 数据不一致
    在提交阶段中,当协调者向参与者发送commit消息后,发生了局部网络异常或者在发送commit消息过程中协调者发生了故障, 这会导致只有一部分参与者接受到了commit消息。而这些参与者收到commit消息后就会执行commit操作。 但是其他未收到commit消息的参与者无法执行commit。于是整个分布式系统出现了数据不一致性。

对于问题1,同步阻塞这是二阶段提交协议自身决定的,我们无能为力(有其他的方式优化,可能会引起数据不一致)。
对于问题2、3呢?我们是否也无可奈何?来,我们慢慢分析。


了解DTP模型&XA规范

名词介绍
DTP(Distributed Transaction Process):一种实现分布式事务处理系统的概念模型
AP(Application Program):应用程序实现所需的业务功能,定义事务边界,使用资源管理器访问资源,通常决定是否提交或回滚全局事务
TM(Transaction Manager):事务管理器负责分配事务标识码,负责事务完成和失败恢复,协调AP和RM实现分布式事务的完整性
RM(Resource Manager):资源管理器提供访问共享资源,DTP要求RM必须支持事务,并能够将全局事务标识定位到自己的内部事务
XA(eXtended Architecture):XA是DTP的一部分接口规范;是RM和TM的接口定义和交互规范,实现了DTP环境中的二阶段提交
全局事务:对于一次性操作多个资源管理器的事务,就是全局事务
分支事务:每个RM的本地事务是这个全局事务的一个事务分支
悬挂事务:完成准备阶段的分支事务

DTP模型


  • AP通过TM控制事务边界
  • AP声明需要哪些RM,TM注册RM
  • AP使用RM完成分支事务
  • AP通过TM提交事务
  • TM通知RM提交事务

XA接口规范定义

ax_reg          向事务管理器注册资源管理器
ax_unreg        向事务管理器取消注册资源管理器
xa_close        终止应用程序对资源管理器的使用
xa_commit        通知资源管理器提交事务分支
xa_complete        询问指定的异步xa_操作是否完成
xa_end          解除线程与事务分支的关联
xa_forget        允许资源管理器丢弃自行完成的事务分支信息
xa_open         初始化资源管理器,供应用程序使用
xa_prepare        通知资源管理器准备提交事务分支
xa_recover        获取资源管理器已准备或自行完成的事务标识符XID列表
xa_rollback        通知资源管理器回滚事务分支
xa_start        启动或恢复事务分支,将XID与资源管理器请求线程的未来工作关联

ax_开头的,是TM提供给RM调用,用于支持RM加入/退出集群时的动态注册机制

xa_开头的,是RM提供给TM调用,用于实现二阶段提交中的各种事务提交、恢复

 


了解MySQL对XA的支持

MySQL从5.0.3开始支持分布式事务,仅InnoDB存储引擎支持MySQL XA事务。 MySQL XA是基于XA规范实现的,支持分布式事务,允许多个数据库实例参与一个全局事务。

MySQL XA命令操作

  • xa start xid: 开启一个XA事务
  • xa end xid: 将事务设为idle状态,表示事务内的SQL操作完成
  • xa prepare xid: 实现事务提交的准备工作,事务设为prepared状态。如果无法完成提交前的准备操作,该语句会执行失败
  • xa commit xid: 提交事务
  • xa rollback xid: 回滚事务
  • xa recover: 查看prepared状态的XA事务

MySQL XA状态转换


  1. 执行xa start来启动一个XA事务,事务处于active状态
  2. active状态的XA事务,可以执行SQL语句,这些语句都将处于该事务中
  3. active状态的XA事务,可以执行xa end命令。执行后事务进入idle状态
  4. idle状态的XA事务,可以执行xa prepare或xa commit…one phase命令
    • 执行xa prepare命令,事务进入prepared状态。
    • 执行xa commit…one phase命令,直接提交事务。因为事务已终止,xid将不会被xa recover列出。
  5. prepared状态的XA事务,可以执行commit或者rollback命令
    • 执行xa commit命令提交事务
    • 执行xa rollback命令回滚事务

MySQL内部XA原理
MySQL对binlog做了优化,prepare不写binlog日志,commit才写日志。 在开启binlog后,binlog会被当做事务协调者,binlog event会被当做协调者日志,MySQL内部会自动将普通事务当做一个XA事务来处理。 由binlog通知InnoDB引擎来执行prepare,commit或者rollback。事务提交的整个过程如下:

  1. 准备阶段
    1. 通知InnoDB prepare:更改事务状态,将undo、redo log落盘
  2. 提交阶段
    1. 记录协调者日志(binlog日志),并通过fsync()永久落盘
    2. 通知InnoDB commit

内部XA异常恢复

  1. 准备阶段redo log落盘前宕机
    InnoDB中还没prepare,binlog中也没有该事务的events。通知InnoDB回滚事务
  2. 准备阶段redo log落盘后宕机(binlog落盘前)
    InnoDB中是prepared状态,binlog中没有该事务的events。通知InnoDB回滚事务
  3. 提交阶段binlog落盘后宕机
    InnoDB中是prepared状态,binlog中有该事务的events。通知InnoDB提交事务

MySQL外部XA问题
看起来很完美,然而在MySQL5.7.7以前,MySQL对外部XA的支持是有限的,主要存在两个问题:

  1. 问题一:准备阶段的事务被回滚
    已经prepare的事务在连接断开后事务会被回滚(不符合2PC协议规范)

  2. 问题二:数据不一致
    MySQL主备库的同步是通过binlog复制完成的。prepare状态的事务在节点宕机后重启,引擎层通过recover机制可以恢复该事务:

    • 如果选择commit,在主库中会提交事务,但未写binlog(宕机后保存binlog的cache丢失,server已经不知道事务的情况了),也就不能复制到备库,从而导致主备库数据不一致
    • 如果选择rollback,可以保证此节点主备库一致,然而如果该分布式事务对应的节点,部分已经提交(无法回滚),而部分节点回滚。最终导致同一分布式事务,在各参与节点最终状态不一致。

上述问题在MySQL中存在了很久,直到MySQL5.7.7版本才修复。解决方案是区分XA事务和本地事务,本地事务还是在commit时记录binlog,对于XA事务在prepare阶段也记录binlog。 同时为了避免prepare事务阻塞session,SQL Thread线程在回放prepare阶段时会把相关cache与SQL Thread的连接句柄脱离(类似于客户端断开连接的处理)。

针对这两个问题,大家可以用docker跑两个不同版本的mysql容器测试下,这样理解会深刻一点。我这边演示下MySQL5.7.18的主从同步。

MySQL复制


(本图是通过binlog进行数据复制回放的大体流程,不同的复制方式执行细节有些不一样)

主要原理

  • master执行sql之后记录二进制binlog
  • slave连接master,从master获取binlog存到本地relaylog,并从上次记住的位置开始执行sql,一旦遇到错误则停止同步

MySQL复制分为异步复制、半同步复制、无损半同步复制、MGR(MySQL Group Replication)、同步复制(MySQL Cluster)。 异步复制当master出现故障后,binlog未及时传到slave,如果master切换,会导致主从数据不一致。 下面我们验证下无损半同步。

1. 启动主从两个容器

version: '3'
services:
  mysql-master:
    hostname: mysql-master
    image: mysql:5.7
    ports:
      - "3305:3307"
    volumes:
      - ~/dockermapping/mysql5.7-master-slave/mysql-master/config:/etc/mysql/conf.d
      - ~/dockermapping/mysql5.7-master-slave/mysql-master/mysql:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
  mysql-slave:
      hostname: mysql-slave
      image: mysql:5.7
      ports:
        - "3304:3307"
      volumes:
        - ~/dockermapping/mysql5.7-master-slave/mysql-slave/config:/etc/mysql/conf.d
        - ~/dockermapping/mysql5.7-master-slave/mysql-slave/mysql:/var/lib/mysql
      environment:
        MYSQL_ROOT_PASSWORD: root

2. MySQL主从配置

#master mysql.cnf
[mysqld]
port=3307
log-bin=mysql-bin
server-id=1
plugin-load=rpl_semi_sync_master=semisync_master.so
rpl_semi_sync_master_enabled=1
sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES

#slave mysql.cnf
[mysqld]
port=3307
log-bin=mysql-bin
server-id=2
plugin-load=rpl_semi_sync_slave=semisync_slave.so
rpl_semi_sync_slave_enabled=1
sql_mode=NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES

3. 账户同步配置

#master
grant replication slave on *.* to 'rep1'@'mysql-slave' identified by '123456';
flush privileges;

#slave
change master to 
master_host='mysql-master',
master_port=3307,
master_user='rep1',
master_password='123456',
master_log_file='mysql-bin.000004',
master_log_pos=4328;

4. 启动XA完成一阶段提交

mysql> xa start '1';
Query OK, 0 rows affected (0.01 sec)

mysql> insert into t values(1);
Query OK, 1 rows affected (0.01 sec)

mysql> xa end '1';
Query OK, 0 rows affected (0.01 sec)

mysql> xa prepare '1';
Query OK, 0 rows affected (0.01 sec)

5. 观察master的binlog

mysql> show binlog events in 'mysql-bin.000004' from 7710;
+------------------+------+----------------+-----------+-------------+--------------------------------------+
| Log_name         | Pos  | Event_type     | Server_id | End_log_pos | Info                                 |
+------------------+------+----------------+-----------+-------------+--------------------------------------+
| mysql-bin.000004 | 7710 | Anonymous_Gtid | 1         | 7775        | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-bin.000004 | 7775 | Query          | 1         | 7862        | XA START X'31',X'',1                 |
| mysql-bin.000004 | 7862 | Table_map      | 1         | 7908        | table_id: 219 (test.t)               |
| mysql-bin.000004 | 7908 | Write_rows     | 1         | 7946        | table_id: 219 flags: STMT_END_F      |
| mysql-bin.000004 | 7946 | Query          | 1         | 8031        | XA END X'31',X'',1                   |
| mysql-bin.000004 | 8031 | XA_prepare     | 1         | 8068        | XA PREPARE X'31',X'',1               |
+------------------+------+----------------+-----------+-------------+--------------------------------------+
我们发现prepare状态的XA分支事务已经写到binlog

6. 再观察slave的relaylog

mysql> show relaylog events in 'mysql-slave-relay-bin.000009' limit 20,20;
+------------------------------+------+----------------+-----------+-------------+--------------------------------------+
| Log_name                     | Pos  | Event_type     | Server_id | End_log_pos | Info                                 |
+------------------------------+------+----------------+-----------+-------------+--------------------------------------+
| mysql-slave-relay-bin.000009 | 1136 | Anonymous_Gtid | 1         | 988         | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| mysql-slave-relay-bin.000009 | 1201 | Query          | 1         | 1075        | XA START X'31',X'',1                 |
| mysql-slave-relay-bin.000009 | 1288 | Table_map      | 1         | 1121        | table_id: 219 (test.t)               |
| mysql-slave-relay-bin.000009 | 1334 | Write_rows     | 1         | 1159        | table_id: 219 flags: STMT_END_F      |
| mysql-slave-relay-bin.000009 | 1372 | Query          | 1         | 1244        | XA END X'31',X'',1                   |
| mysql-slave-relay-bin.000009 | 1457 | XA_prepare     | 1         | 1281        | XA PREPARE X'31',X'',1               |
+------------------------------+------+----------------+-----------+-------------+--------------------------------------+
6 rows in set (0.04 sec)
我们发现master库prepare状态的binlog被同步到slave库上relaybinlog

再做一个异常实验
在主库prepare的过程中,从库挂了是否会导致数据不一致?
1. master上操作

mysql> xa start '6';
Query OK, 0 rows affected (0.01 sec)

mysql> insert into t values(6);
Query OK, 1 rows affected (0.01 sec)

2. slave上操作

mysql> stop slave;
Query OK, 0 rows affected (0.01 sec)

mysql> xa recover;
Empty set (0.01 sec)

3. master上操作

mysql> xa end '6';
Query OK, 0 rows affected (0.01 sec)

mysql> xa prepare '6';
Query OK, 0 rows affected (**10.00 sec**)
此时主库在等待10s后将半同步转为异步

4. slave上操作

mysql> start slave;
Query OK, 0 rows affected (0.01 sec)

mysql> xa recover;
+----------+--------------+--------------+------+
| formatID | gtrid_length | bqual_length | data |
+----------+--------------+--------------+------+
| 1        | 1            | 0            | 6    |
+----------+--------------+--------------+------+
1 rows in set (0.03 sec)
从库跟上主库的binlog,最终一致

通过修改切换复制的timeout时间(rpl_semi_sync_master_timeout默认10s),无损半同步的似乎可以做到强一致性;
通过设置主库等多个slave收到日志后才完成(rpl_semi_sync_master_wait_for_slave_count默认1)又能保证高可用。是否足够完美?

MySQL异步复制满足了可用性,主从互不影响,满足分区容错,但不满足强一致性(最终一致)。
MySQL半同步复制满足较强一致性,但因为要等slave确认,所以性能上差一点。
MySQL Group Replication通过Paxos协议保证集群节点数据强一致性,似乎做到了真正的一致性,但也是以牺牲一定的性能为代价。


本文来自网易实践者社区,经作者陈志良授权发布。