1 复现问题
1. 部署一主三从,并开启半同步的 mysql 8.0 集群
2. 在主库执行以下设置,并显示以下状态
mysql> show global status like "Rpl_semi_sync_master_clients";
+------------------------------+-------+
| Variable_name | Value |
+------------------------------+-------+
| Rpl_semi_sync_master_clients | 3 |
+------------------------------+-------+
1 row in set (0.01 sec)
mysql> set global rpl_semi_sync_master_timeout = 100000000;
Query OK, 0 rows affected (0.00 sec)
mysql> set global rpl_semi_sync_master_wait_for_slave_count=4;
Query OK, 0 rows affected (0.00 sec)
mysql> set global binlog_order_commits = 0;
Query OK, 0 rows affected (0.00 sec)
mysql> show global variables like "%semi%";
+-------------------------------------------+------------+
| Variable_name | Value |
+-------------------------------------------+------------+
| rpl_semi_sync_master_enabled | ON |
| rpl_semi_sync_master_timeout | 100000000 |
| rpl_semi_sync_master_trace_level | 32 |
| rpl_semi_sync_master_wait_for_slave_count | 4 |
| rpl_semi_sync_master_wait_no_slave | ON |
| rpl_semi_sync_master_wait_point | AFTER_SYNC |
| rpl_semi_sync_slave_enabled | ON |
| rpl_semi_sync_slave_trace_level | 32 |
+-------------------------------------------+------------+
8 rows in set (0.00 sec)
3. 开三个 cli,连接 master,命名 cli-1、cli-2、cli-3
4. 每个从库,开一个 cli 连接,命名 sla-1、sla-2、sla-3,并且各自执行 stop slave
mysql> stop slave;
Query OK, 0 rows affected, 1 warning (0.12 sec)
5. 在 master cli-1 上执行插入,由于半同步设置等待 ack,将会卡主
mysql> insert into t values(1, "a");
6. 在 slave sla-3 上执行,start slave
mysql> start slave;
Query OK, 0 rows affected, 1 warning (0.22 sec)
7. 在 slave sla-1 上执行, start slave
mysql> start slave;
Query OK, 0 rows affected, 1 warning (0.24 sec)
8. 在 slave sla-2 上执行, start slave
mysql> start slave;
Query OK, 0 rows affected, 1 warning (0.19 sec)
9. 在 slave sla-1 上执行, stop slave
mysql> stop slave;
Query OK, 0 rows affected, 1 warning (0.10 sec)
10. 在 master cli-2 上执行插入,由于半同步设置等待 ack,将会卡主
mysql> insert into t values(2, "b");
11. 在 slave sla-2 上执行, stop slave
mysql> stop slave;
Query OK, 0 rows affected, 1 warning (0.09 sec)
12. 在 master cli-3 上执行插入,由于半同步设置等待 ack,将会卡主
mysql> insert into t values(3, "c");
13. 在 master cli 上设置 rpl_semi_sync_master_wait_for_slave_count
mysql> set global rpl_semi_sync_master_wait_for_slave_count=3;
Query OK, 0 rows affected (0.01 sec)
14. 第5步骤中的 master cli-1 和第10步骤中的 master cli-2 上的查询都返回了
(5 master cli-1)
mysql> insert into t values(1, "a");
Query OK, 1 row affected (1 min 18.08 sec)
(10 master cli-2)
mysql> insert into t values(2, "b");
Query OK, 1 row affected (45.08 sec)
15. 主从节点中的数据
slave 1 节点(slave sla-1) :
mysql> select * from t;
+------+------+
| id | name |
+------+------+
| 1 | a |
+------+------+
1 row in set (0.00 sec)
slave 2 节点(slave sla-2) :
mysql> select * from t;
+------+------+
| id | name |
+------+------+
| 1 | a |
| 2 | b |
+------+------+
2 rows in set (0.00 sec)
slave 3 节点(slave sla-3) :
mysql> select * from t;
+------+------+
| id | name |
+------+------+
| 1 | a |
| 2 | b |
| 3 | c |
+------+------+
3 rows in set (0.00 sec)
也就是当前的数据(1, "a"),复制到三个节点,(2, "b") 当前只复制到两个从节点。
16. 结论
由于在 master 上,设置的 rpl_semi_sync_master_wait_for_slave_count 为 3;
但是数据(2, "b")目前只复制到两个节点,当前应该阻塞在等待 ack,不应该返回,与期望不符。
2 原因分析
出现上述问题是因为半同步插件的一个小 bug 导致的,如果不是特意的构建场景,问题还是比较难复现的,所以这也是导致这个 bug 一直没有修复的原因吧。
/**
Find the minimum ack which is smaller than given position. When more than
one slots are minimum acks, it returns the one has smallest index.
@param[in] log_file_name binlog file name
@param[in] log_file_pos binlog file position
@return NULL if no ack is smaller than given position, otherwise
return its pointer.
*/
AckInfo *minAck(const char *log_file_name, my_off_t log_file_pos) {
unsigned int i;
AckInfo *ackinfo = nullptr;
for (i = 0; i < m_size; i++) {
if (m_ack_array[i].less_than(log_file_name, log_file_pos))
ackinfo = m_ack_array + i;
}
return ackinfo;
}
从代码注释上看,这里是找 “the minimum ack”,但是实际返回值,却是返回了最后一个小于(log_file_name, log_file_pos)的 ack。
2.1 半同步 ack 数量
rpl_semi_sync_master_wait_for_slave_count 代表半同步中,主库完成事务提交前,需要收到 binlog 的 ack 数量。
一个 slave 接收到事务完整的 binlog 后,向 master 发送一个 ack。
比如在 master 库上,设置:
rpl_semi_sync_master_wait_for_slave_count = 4
在 master 库上执行一条 sql:
mysql> insert into t (id) values(1);
那么 master 需要收到 4 个 slave 发送的关于这条事务的 binlog ack 才能完成整个事务提交。
2.2 ack 的保存
master 半同步插件,是将 slave 的 ack 保存在一个数组里:
array = ack[rpl_semi_sync_master_wait_for_slave_count - 1]
数组大小为 rpl_semi_sync_master_wait_for_slave_count - 1。如果数组数已满,又来了一个 ack,那么收到的 ack 数量满足 rpl_semi_sync_master_wait_for_slave_count 要求了。
假设我们 rpl_semi_sync_master_wait_for_slave_count = 4,那么 slave 依序发送 4 个 ack 过来的流程:
1. 初始状态 array = { null, null, null }
2. 收到 slave 1 的 ack-1 放进 array = { null, null, ack-1 }
3. 收到 slave 2 的 ack-2 放进 array = { null, ack-2, ack-1 }
4. 收到 slave 3 的 ack-3 放进 array = { ack-3, ack-2, ack-1 }
5. 收到 slave 4 的 ack-4,array 满了,目前的 ack 为 { ack-3, ack-2, ack-1 } + ack-4
2.3 找最小的 ack
这里就涉及到 bug 函数 minAck。
在上面收到的 4 个 ack 中,找到最小的那个,就是目前所能确认的满足的事务的 binlog ack。
比如由于从库复制速度的原因,四个 ack 分别为:
{ ack-3(log-1, 100), ack-2(log-1, 300), ack-1(log-1, 200)} + ack-4(log-1, 400)
正确的收到 4 个 ack 位置应该是 ack-3 返回的 (log-1, 100),这个才是最小的。
函数中,返回了 ack - 1(log-1, 200),这个就返回了一个错误的 min ack。
产生的影响就是 master 错误的认为 ack-1( log-1, 200) 前的事务 binlog 都完整的复制到 4 个从节点,但是实际上并没有。
2.4 ack array resize
在上面的内容已经解释了问题的原因,这部分补充内容有助于理解上面的复现 bug 场景。
当调整 rpl_semi_sync_master_wait_for_slave_count 时,代表需要的 ack 数有变化,上面介绍了 array 大小的计算方式,当 rpl_semi_sync_master_wait_for_slave_count 从 4 变为 3 时:
1. old_array 大小为 4-1 = 3
2. new_array 大小为 3-1 = 2
3. 把 old_array 的 ack 复制到 new_array 中
4. 如果 old_array ack 数量有 3 个,那么在复制到 new_array 的时候,就构成了 {ack-1, ack-2} + ack-3 满足要求的情况
也就是说在 array resize 中,触发了 minAck 的 bug 场景。
另外就是,array 插入 ack,是从右向左插入的,也就是先插入序号大的位置,再插入序号小的位置。
3 构建复现 bug 场景
复现步骤 3-8 是为了让不同的 slave 的 ack 占据目标位置,最终为 { ack2, ack-1, ack-3}。
步骤 9, 是为了 slave 1 只复制到数据 (1, "a")。
步骤 10-11,是为了 slave 2 只复制到数据 (2, "b")。
步骤 12,是为了 slave 能复制到更远的位置(3, "c")。
步骤 13,是触发 array resize,插入的过程为 {ack-1, ack-2} + ack-3。
根据 minAck 函数,其返回的最小值为 ack-2,也就是数据(2, "b") 的位置,这时候 master 把它作为可以完全提交的事务点,成功返回了,但是此时(2, "b")只复制到两个 slave。
以上步骤,bug 成功复现。
4 关闭 binlog_order_commits 的原因
无论是否关闭 binlog_order_commits,问题 bug 都是存在的,但是要从测试上直观看到问题,关闭它是关键。
binlog_order_commits 变量控制着 binlog 的 COMMIT_STAGE 阶段,如果开启,将以分组的形式进入 COMMIT_STAGE 等待 ack,并且组之间严格串行。
事务 T1(1, "a"), T2(2, "b"), T3(3, "c"),将会分为两组:{T1},{T2, T3}.
T1组进入等待 ack 结束后,虽然当前确认的 ack 被错误的设置到 T2,但是{T2,T3}将以 T3(3, "c")作为等待点,需要等待 T3 被返回才能推进,导致 T2也阻塞,问题就复现不出来。
关闭后:
binlog_order_commits = 0;
各个事务单独进入等待 ack 阶段,等待各自的事务 binlog 点,当 bug 触发后,T2 立马返回,直观上就看到了异常。