你知道吗?
你知道二阶段提交协议?嗯,你可能知道,那你知道各种宕机、通信异常情况下的故障恢复细节?
你知道DDB是分布式数据库,那你知道DDB如何保证跨多个DBN的数据一致性?
你知道什么是悬挂事务?它何时出现?会有什么影响?如何处理它们?
你知道DDB可能出现数据不一致?那是在什么场景?
…………
这些可能也没人跟你说。
本文从原理出发……
从深入理解二阶段提交协议开始,
从事务处理模型到XA规范,
从MySQL各版本对XA的支持情况到各种坑,
从反编译DDB窥视其内部,深入理解其事务协调者实现,再到畅想实现更高的数据一致性和容错机制。
这篇万字长文希望能给你带来一些收获。
本文背景
前段时间在了解分布式场景下的事务处理套路。二阶段提交是一个非常经典且应用极为广泛的分布式协议,那就先从它开始吧。 网上关于2PC的文章很多,但对在出现各种故障后,如何进行故障恢复讲的较少。所以,我想着,要找个经典的中间件好好分析下。
DDB从06年开始为网易核心产品提供分库分表服务,十多年风雨,见证了各大产品兴衰,自己始终屹立不倒,是杭研后台产品中资历最老, 最为稳定的产品。DDB是怎么处理跨DBN节点的事务呢?我很感兴趣,可惜DDB是商业闭源的,核心文档也保密,因此我只能反编译DDB, 只为理解各种场景产生XA悬挂事务时,如何进行补救,如何保证数据一致性。 巧的是在理解反编译代码时找到了一个DDB的BUG,在我跟马进、劲松确认后确实存在问题。 这个BUG会导致部分异步提交的分布式分支事务状态未知(实际应该是已提交,不影响业务数据)。
本文大致分为5个部分:
本文的讨论范围是二阶段提交,不对其他分布式事务解决方案做讨论,如有理解不到位的地方,欢迎大家指正。
在分布式系统中,每个节点无法知道其他节点的操作是成功或失败。 当一个事务跨多个节点时,为了保持事务的ACID特性,需要引入一个协调者组件统一来管控所有参与者。 分布式系统的难点之一是如何在多个节点进行事务性操作时保持一致性。 二阶段提交就是为保持事务提交一致性而设计的一种协议,二阶段提交具有比较强的一致性。
二阶段提交算法的成立基于以下假设:
网上看到的二阶段提交的图基本如上图,比较简单,很多细节没有讲到,我画了两张细图,结合着我们看下细节。
回滚流程
提交流程
准备阶段
准备阶段只保留了最后一步耗时短暂的正式提交操作给第二阶段执行。
提交阶段
协调者等待所有参与者准备阶段的反馈
参与者等待协调者的指令
协调者等待所有参与者提交阶段的反馈
为什么先写日志
为什么在执行任务前需要先写日志?如果没有日志,宕机后重启将无法知道事务状态,无法进行故障恢复。
宕机异常处理
正常情况下,两阶段提交机制都能较好的运行,当在事务进行过程中,参与者或协调者宕机了怎么办?
超时异常处理
二阶段提交的缺点
同步阻塞
事务执行过程中,所有参与节点都是事务阻塞的。当参与者占有资源时,其他访问相关资源的进程也将处于阻塞状态。 参与者对锁资源的释放必须等到事务结束,所以与一阶段提交相比,执行同样的事务,二阶段会耗费更多时间。 事务执行时间的延长意味着锁资源发生冲突的概率增加,当事务并发量达到一定数量时,会出现大量事务积压甚至出现死锁,系统性能会严重下滑。
单点故障
一旦协调者发生故障,参与者会一直阻塞。参与者完成准备阶段后,协调者发生故障,所有的参与者都将处于锁定事务资源的状态中(事务悬挂状态),无法继续完成事务操作。
对于问题1,同步阻塞这是二阶段提交协议自身决定的,我们无能为力(有其他的方式优化,可能会引起数据不一致)。
对于问题2、3呢?我们是否也无可奈何?来,我们慢慢分析。
名词介绍
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模型
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从5.0.3开始支持分布式事务,仅InnoDB存储引擎支持MySQL XA事务。 MySQL XA是基于XA规范实现的,支持分布式事务,允许多个数据库实例参与一个全局事务。
MySQL XA命令操作
MySQL XA状态转换
MySQL内部XA原理
MySQL对binlog做了优化,prepare不写binlog日志,commit才写日志。 在开启binlog后,binlog会被当做事务协调者,binlog event会被当做协调者日志,MySQL内部会自动将普通事务当做一个XA事务来处理。 由binlog通知InnoDB引擎来执行prepare,commit或者rollback。事务提交的整个过程如下:
内部XA异常恢复
MySQL外部XA问题
看起来很完美,然而在MySQL5.7.7以前,MySQL对外部XA的支持是有限的,主要存在两个问题:
问题一:准备阶段的事务被回滚
已经prepare的事务在连接断开后事务会被回滚(不符合2PC协议规范)
问题二:数据不一致
MySQL主备库的同步是通过binlog复制完成的。prepare状态的事务在节点宕机后重启,引擎层通过recover机制可以恢复该事务:
上述问题在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进行数据复制回放的大体流程,不同的复制方式执行细节有些不一样)
主要原理
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协议保证集群节点数据强一致性,似乎做到了真正的一致性,但也是以牺牲一定的性能为代价。
本文来自网易实践者社区,经作者陈志良授权发布。