在 MySQL5.7 引入基于 Logical clock 的并行复制方案前, MySQL 使用基于 Schema 的并行复制, 使不同 db 下的 DML 操作可以在备库并发回放. 在优化后, 可以做到不同表 table 下并发. 但是如果业务在 Master 端高并发写入一个库 (或者优化后的表), 那么 slave 端就会出现较大的延迟. 基于 schema 的并行复制, Slave 作为只读实例提供读取功能时候可以保证同 schema 下事务的因果序 (Causal Consistency, 本文讨论 Consistency 的时候均假设 Slave 端为只读), 而无法保证不同 schema 间的. 例如当业务关注事务执行先后顺序时候, 在 Master 端 db1 写入 T1, 收到 T1 返回后, 才在 db2 执行 T2. 但在 Slave 端可能先读取到 T2 的数据, 才读取到 T1 的数据.
MySQL 5.7 的 LOGICAL CLOCK 并行复制, 解除了 schema 的限制, 使得在主库对一个 db 或一张表并发执行的事务到 slave 端也可以并行执行. Logical Clock 并行复制的实现, 最初是 Commit-Parent-Based 方式, 同一个 commit parent 的事务可以并发执行. 但这种方式会存在可以保证没有冲突的事务不可以并发, 事务一定要等到前一个 commit parent group 的事务全部回放完才能执行. 后面优化为 Lock-Based 方式, 做到只要事务和当前执行事务的 Lock Interval 都存在重叠, 即保证了 Master 端没有锁冲突, 就可以在 Slave 端并发执行. LOGICAL CLOCK 可以保证非并发执行事务, 即当一个事务 T1 执行完后另一个事务 T2 再开始执行场景下的 Causal Consistency.
LOGICAL_CLOCK Commit-Parent-Based 模式
由于在 MySQL 中写入是基于锁的并发控制, 所以所有在 Master 端同时处于 prepare 阶段且未提交的事务就不会存在锁冲突, 在 Slave 端执行时都可以并行执行. 因此可以在所有的事务进入 prepare 阶段的时候标记上一个 logical timestamp(实现中使用上一个提交事务的 sequence_number), 在 Slave 端同样 timestamp 的事务就可以并发执行.
Master 端
在 SQL 层实现一个全局的 logical clock: commit_clock.
当事务进入 prepare 阶段的时候, 从 commit_clock 获取 timestamp 并存储在事务中.
在 transaction 在引擎层提交之前, 推高 commit_clock. 这里如果在引擎层提交之后, 即释放锁后操作 commit_clock, 就可能出现冲突的事务拥有相同的 commit-parent, 所以一定要在引擎层提交前操作.
Slave 端
如果事务拥有相同的 commit-parent 就可以并行执行, 不同 commit-parent 的事务, 需要等前面的事务执行完毕才可以执行.
LOGICAL_CLOCK Lock-Based 模式原理及实现分析
Commit-Parent-Based 模式, 用事务 commit 的点将 clock 分隔成了多个 intervals. 在同一个 time interval 中进入 prepare 状态的事务可以被并发. 例如下面这个例子 (引自 WL#7165):
Trx1 ------------P----------C-------------------------------->
|
Trx2 ----------------P------+---C---------------------------->
| |
Trx3 -------------------P---+---+-----C---------------------->
| | |
Trx4 -----------------------+-P-+-----+----C----------------->
| | | |
Trx5 -----------------------+---+-P---+----+---C------------->
| | | | |
Trx6 -----------------------+---+---P-+----+---+---C---------->
| | | | | |
Trx7 -----------------------+---+-----+----+---+-P-+--C------->
| | | | | | |
每一个水平线代表一个事务. 时间从左到右. P 表示 prepare 阶段读取 commit-parent 的时间点. C 表示事务提交前增加全局 counter 的时间点. 垂直线表示每个提交划分出的 time interval.
从上图可以看到因为 Trx5 和 Trx6 的 commit-parent 都是 Trx2 提交点, 所以可以并行执行. 但是 Commit-Parent-Based 模式下 Trx4 和 Trx5 不可以并行执行, 因为 Trx4 的 commit-parent 是 Trx1 的提交点. Trx6 和 Trx7 也不可以并行执行, Trx7 的 commit-parent 是 Trx5 的提交点. 但 Trx4 和 Trx5 有一段时间同时持有各自的所有锁, Trx6 和 Trx7 也是, 即它们之间并不存在冲突, 是可以并发执行的.
针对上面的情况, 为了进一步增加复制性能, MySQL 将 LOGICAL_CLOCK 优化为 Lock-Based 模式, 使同时 hold 住各自所有锁的事务可以在 slave 端并发执行.
Master 端
添加全局的事务计数器产生事务 timestamp 和记录当前最大事务 timestamp 的 clock.
class MYSQL_BIN_LOG: public TC_LOG
{
...
public:
/* Committed transactions timestamp */
Logical_clock max_committed_transaction;
/* "Prepared" transactions timestamp */
Logical_clock transaction_counter;
...
}
对每个事务定义其 lock interval, 并记录到 binlog 中.
在每个 transaction 中添加下面两个 member.
class Transaction_ctx
{
...
int64 last_committed;
int64 sequence_number;
...
}
其中 last_committed 表示事务 lock interval 的起始点, 是所有锁都获得时候的 max-commited-timestamp. 由于在一个事务执行过程中, 数据库无法知道当前的锁是否为最后一个, 在实际实现的时候, 对每次 DML 操作都更新一次 last_committed.
static int binlog_prepare(handlerton *hton, THD *thd, bool all)
{
...
if (!all)//DML 操作
{
Logical_clock& clock= mysql_bin_log.max_committed_transaction;
thd->get_transaction()->
store_commit_parent(clock.get_timestamp());// 更新 transaction 中的 last_committed
sql_print_information("stmt prepare");
}
...
}
class Transaction_ctx
{
...
void store_commit_parent(int64 last_arg)
{
last_committed= last_arg;
}
...
}
sequence_number 为 lock interval 的结束点. 从理论上在最后更新 last_committed 后, 引擎层 commit 前的一个时刻即可, 满足这一条件的情况下时间点越靠后越能获得更大 lock interval, 后面在 Slave 执行也就能获得更大并发度. 由于我们需要把该信息记录到 binlog 中, 所以实现中在 flush binlog cache 到 binlog 文件中的时候记录. 而且当前的 MySQL5.7 已经 disable 掉了设置 GTID_MODE 为 OFF 的功能, 会强制记录 GTID_EVENT. 这样事务的 last_committed 和 sequence_number 记录在事务开头的 Gtid_log_event 中.
int
binlog_cache_data::flush(THD *thd, my_off_t *bytes_written, bool *wrote_xid)
{
...
if (flags.finalized)
{
trn_ctx->sequence_number= mysql_bin_log.transaction_counter.step();// 获取 sequence_number
if (!error)
if ((error= mysql_bin_log.write_gtid(thd, this, &writer)))// 记录 Gtid_log_event
...
}
bool MYSQL_BIN_LOG::write_gtid(THD *thd, binlog_cache_data *cache_data,
Binlog_event_writer *writer)
{
...
Transaction_ctx *trn_ctx= thd->get_transaction();
Logical_clock& clock= mysql_bin_log.max_committed_transaction;
DBUG_ASSERT(trn_ctx->sequence_number > clock.get_offset());
int64 relative_sequence_number= trn_ctx->sequence_number - clock.get_offset();
int64 relative_last_committed=
trn_ctx->last_committed <= clock.get_offset() ?
SEQ_UNINIT : trn_ctx->last_committed - clock.get_offset();
...
Gtid_log_event gtid_event(thd, cache_data->is_trx_cache(),
relative_last_committed, relative_sequence_number,//Gtid_log_event 中记录 relative_last_committed 和 relative_sequence_number
cache_data->may_have_sbr_stmts());
...
}
同时可以看到记录在 Gtid_log_event(即 binlog file 中) 的 sequence_number 和 last_committed 使用的是相对当前 binlog 文件的 clock 的值. 即每个 binlog file 中事务的 last_commited 起始值为 0,sequence_number 为 1. 由于 binlog 切换后, 需要等待上一个文件的事务执行完, 所以这里记录相对值并不会引起冲突事务并发执行. 这样做一个明显的好处是由于 server 在每次启动的时候都会生成新的 binlog 文件, max_committed_transaction 和 transaction_counter 不需要持久化.
更新 max_committed_transaction.
max_committed_transaction 的更新一定要在引擎层 commit(即锁释放) 之前, 如果之后更新, 释放的锁被其他事务获取到并且获取到 last_committed 小于该事务的 sequence_number, 就会导致有锁冲突的事务 lock interval 却发生重叠.
void
MYSQL_BIN_LOG::process_commit_stage_queue(THD *thd, THD *first)
{
...
if (head->get_transaction()->sequence_number != SEQ_UNINIT)
update_max_committed(head);
...
if (head->get_transaction()->m_flags.commit_low)
{
if (ha_commit_low(head, all, false))
head->commit_error= THD::CE_COMMIT_ERROR;
...
}
Slave 端
当事务的 lock interval 存在重叠, 即代表他们的锁没有冲突, 可以并发执行. 下图中 L 代表 lock interval 的开始, C 代表 lock interval 的结束.
- 可并发执行:
Trx1 -----L---------C------------>
Trx2 ----------L---------C------->
- 不可并发执行:
Trx1 -----L----C----------------->
Trx2 ---------------L----C------->
slave 端在并行回放时候, worker 的分发逻辑在函数 Slave_worker Log_event::get_slave_worker(Relay_log_info rli) 中, MySQL5.7 中添加了 schedule_next_event 函数来决定是否分配下一个 event 到 worker 线程. 对于 DATABASE 并行回放该函数实现为空.
bool schedule_next_event(Log_event* ev, Relay_log_info* rli)
{
...
error= rli->current_mts_submode->schedule_next_event(rli, ev);
...
}
int
Mts_submode_database::schedule_next_event(Relay_log_info *rli, Log_event *ev)
{
/*nothing to do here*/
return 0;
}
Mts_submode_logical_clock 的相关实现如下.
在 Mts_submode_logical_clock 中存储了回放事务中已经提交事务 timestamp(sequence_number) 的 low-water-mark lwm.low-water-mark 表示该事务已经提交, 同时该事务之前的事务都已经提交.
class Mts_submode_logical_clock: public Mts_submode
{
...
/* "instant" value of committed transactions low-water-mark */
longlong last_lwm_timestamp;
...
longlong last_committed;
longlong sequence_number;
在 Mts_submode_logical_clock 的 schedule_next_event 函数实现中会检查当前事务是否和正在执行的事务冲突, 如果当前事务的 last_committed 比 last_lwm_timestamp 大, 同时该事务前面还有其他事务执行, coordinator 就会等待, 直到确认没有冲突事务或者前面的事务已经执行完, 才返回. 这里 last_committed 等于 last_lwm_timestamp 的时候, 实际这两个值拥有事务的 lock interval 是没有重叠的, 也可能有冲突. 在前面 lock-interval 介绍中, 这种情况是前面一个事务执行结束, 后面一个事务获取到 last_committed 为前面一个的 sequence_number 的情况, 他们的 lock interval 没有重叠. 但由于 last_lwm_timestamp 更新表示事务已经提交, 所以等于的时候, 该事务也可以执行.
int
Mts_submode_logical_clock::schedule_next_event(Relay_log_info* rli,
Log_event *ev)
{
...
switch (ev->get_type_code())
{
case binary_log::GTID_LOG_EVENT:
case binary_log::ANONYMOUS_GTID_LOG_EVENT:
// TODO: control continuity
ptr_group->sequence_number= sequence_number=
static_cast<Gtid_log_event*>(ev)->sequence_number;
ptr_group->last_committed= last_committed=
static_cast<Gtid_log_event*>(ev)->last_committed;
break;
default:
sequence_number= last_committed= SEQ_UNINIT;
break;
}
...
if (!is_new_group)
{
longlong lwm_estimate= estimate_lwm_timestamp();
if (!clock_leq(last_committed, lwm_estimate) && // 如果 last_committed > lwm_estimate
rli->gaq->assigned_group_index != rli->gaq->entry) // 当前事务前面还有执行的事务
{
...
if (wait_for_last_committed_trx(rli, last_committed, lwm_estimate))
...
}
...
}
}
@return true when a "<=" b,
false otherwise
*/
static bool clock_leq(longlong a, longlong b)
{
if (a == SEQ_UNINIT)
return true;
else if (b == SEQ_UNINIT)
return false;
else
return a <= b;
}
bool Mts_submode_logical_clock::
wait_for_last_committed_trx(Relay_log_info* rli,
longlong last_committed_arg,
longlong lwm_estimate_arg)
{
...
my_atomic_store64(&min_waited_timestamp, last_committed_arg);// 设置 min_waited_timestamp
...
if ((!rli->info_thd->killed && !is_error) &&
!clock_leq(last_committed_arg, get_lwm_timestamp(rli, true)))// 真实获取 lwm 并检查当前是否有冲突事务
{
// 循环等待直到没有冲突事务
do
{
mysql_cond_wait(&rli->logical_clock_cond, &rli->mts_gaq_LOCK);
}
while ((!rli->info_thd->killed && !is_error) &&
!clock_leq(last_committed_arg, estimate_lwm_timestamp()));
...
}
}
上面循环等待的时候, 会等待 logical_clock_cond 条件然后做检查. 该条件的唤醒逻辑是: 当回放事务结束, 如果存在等待的事务, 即检查 min_waited_timestamp 和当前 curr_lwm(lwm 同时会被更新), 如果 min_waited_timestamp 小于等于 curr_lwm, 则唤醒等待的 coordinator 线程.
void Slave_worker::slave_worker_ends_group(Log_event* ev, int error)
{
...
if (mts_submode->min_waited_timestamp != SEQ_UNINIT)
{
longlong curr_lwm= mts_submode->get_lwm_timestamp(c_rli, true);// 获取并更新当前 lwm.
if (mts_submode->clock_leq(mts_submode->min_waited_timestamp, curr_lwm))
{
/*
There's a transaction that depends on the current.
*/
mysql_cond_signal(&c_rli->logical_clock_cond);// 唤醒等待的 coordinator 线程
}
}
...
}
LOGICAL_CLOCK Consistency 的分析
无论是 Commit-Parent-Based 还是 Lock-Based,Master 端一个事务 T1 和其 commit 后才开始的事务 T2 在 Slave 端都不会被并发回放, T2 一定会等 T1 执行结束才开始回放. 因此 LOGICAL_CLOCK 并发方式在 Slave 端只读时候的上述场景中能够保证 Causal Consistency. 但如果事务 T2 只是等待事务 T1 执行 commit 成功后再执行 commit 操作, 那么事务 T1 和 T2 在 Slave 端的执行顺序就无法得到保证, 用户在 Slave 端读取可能先读到 T2 再读到 T1 的提交. 这种场景就无法满足 Causal Consistency.
slave_preserve_commit_order 的简要介绍
我们在前面的介绍中了解到, 当 slave_parallel_type 为 DATABASE 和 LOGICAL_CLOCK 的时候, 在 Slave 端的读取操作都存在场景无法满足 Causal Consistency, 都可能存在 Slave 端并行回放时候事务顺序发生变化. 复制进行中时业务方可能会在某一时刻观察到 Slave 的 GTID_EXECUTED 有空洞. 那如果业务需要完整的保证 Causal Consistency 呢, 除了使用单线程复制, 是否可以在并发回放的情况下满足这一需求?
MySQL 提供了 slave_preserve_commit_order, 使 LOGICAL_CLOCK 的并发执行时候满足 Causal Consistency, 实际获得 Sequential Consistency. 这里 Sequential Consistency 除了满足之前分析的客户端事务 T1,T2 先后执行操作的场景外, 还满足即使 T1T2 均并发执行的时候, 第三个客户端在主库观察到 T1 先于 T2 发生, 在备库也会观察到 T1 先于 T2 发生, 即在备库获得和主库完全一致的执行顺序.
slave_preserve_commit_order 实现的关键是添加了 Commit_order_manager 类, 开启该参数会在获取 worker 时候向 Commit_order_manager 注册事务.
Slave_worker *
Mts_submode_logical_clock::get_least_occupied_worker(Relay_log_info *rli,
Slave_worker_array *ws,
Log_event * ev)
{
...
if (rli->get_commit_order_manager() != NULL && worker != NULL)
rli->get_commit_order_manager()->register_trx(worker);
...
}
void Commit_order_manager::register_trx(Slave_worker *worker)
{
...
queue_push(worker->id);
...
}
在事务进入 FLUSH_STAGE 前, 会等待前面的事务都进入 FLUSH_STAGE.
int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit)
{
...
if (has_commit_order_manager(thd))
{
Slave_worker *worker= dynamic_cast<Slave_worker *>(thd->rli_slave);
Commit_order_manager *mngr= worker->get_commit_order_manager();
if (mngr->wait_for_its_turn(worker, all)) // 等待前面的事务都进入 FLUSH\_STAGE
{
thd->commit_error= THD::CE_COMMIT_ERROR;
DBUG_RETURN(thd->commit_error);
}
if (change_stage(thd, Stage_manager::FLUSH_STAGE, thd, NULL, &LOCK_log))
DBUG_RETURN(finish_commit(thd));
}
...
}
bool Commit_order_manager::wait_for_its_turn(Slave_worker *worker,
bool all)
{
...
mysql_cond_t *cond= &m_workers[worker->id].cond;
...
while (queue_front() != worker->id)
{
...
mysql_cond_wait(cond, &m_mutex);// 等待 condition
}
...
}
当该事务进入 FLUSH_STAGE 后, 会通知下一个事务的 worker 可以进入 FLUSH_STAGE.
bool
Stage_manager::enroll_for(StageID stage, THD *thd, mysql_mutex_t *stage_mutex)
{
bool leader= m_queue[stage].append(thd);
if (stage == FLUSH_STAGE && has_commit_order_manager(thd))
{
Slave_worker *worker= dynamic_cast<Slave_worker *>(thd->rli_slave);
Commit_order_manager *mngr= worker->get_commit_order_manager();
mngr->unregister_trx(worker);
}
...
}
void Commit_order_manager::unregister_trx(Slave_worker *worker)
{
...
queue_pop();// 退出队列
if (!queue_empty())
mysql_cond_signal(&m_workers[queue_front()].cond);// 唤醒下一个
...
}
在保证 binlog flush 的顺序后, 通过 binlog_order_commit 即可获取同样的提交顺序.
浅谈 LOGICAL_CLOCK 依然存在的不足
LOGICAL_CLOCK 为了准确性和实现的需要, 其 lock interval 实际实现获得的区间比理论值窄, 会导致原本一些可以并发执行的事务在 Slave 中没有并发执行. 当使用级联复制的时候, 这会后面层级的 Slave 并发度会越来越小.
实际很多业务中, 虽然事务没有 Lock Interval 重叠, 但这些事务操作的往往是不同的数据行, 也不会有锁冲突, 是可以并发执行, 但 LOGICAL_CLOCK 的实现无法使这部分事务得到并发回放.
虽然有上述不足, LOGICAL_CLOCK 的复制方式在有多客户端写入同样 database 的场景中相比 DATABASE 能够获得很大的复制性能提升, 实际场景中很多业务的写入也都是在一个 database 下.
来源: https://yq.aliyun.com/articles/422834