GitHub's Online Schema Transmogrifier/Translator/Transformer/Transfigurator
gh-ost
is a ==triggerless== ==online== schema migration solution for MySQL. It is testable and provides pausability, dynamic control/reconfiguration, auditing, and many operational perks.
gh-ost
produces a light workload on the master throughout the migration, decoupled from the existing workload on the migrated table.
It has been designed based on years of experience with existing solutions, and changes the paradigm of table migrations.
gh-ost 在主库上创建一张与原始表定义相同的 gho 表,在未同步数据前先把 gho 表 alter 改好表定义,然后慢慢地把数据从原始表拷到 gho 表,同时 gh-ost 充当从库,从另一个从库不断地把进行中的原始表上的数据操作(所有应用在原始表上的插入、删除、更新操作)也以 binlog 增量变更的方式异步拉取应用过来。当 gh-ost 把所有数据都拷贝完毕,两边数据同步了之后,它就用这张 gho 表来替代原始表。
- 验证权限,replica
- 验证 binlog 格式为 row
- ==确定 binlog apply 的起始位点并开始 dump binlog==
- 创建 changelog 表(记录任务元信息)和 ghost 表
- ==reads min/max values that will be used for rowcopy(根据主键或唯一键确定)==
注意:
- 这里必须先记录 binlog 位点,然后再开始计算 rowcopy 的范围。因为在操作的过程中,原表是有写入的,如果先计算了 rowcopy 的范围,再开始 binlog dump,中间的数据会丢失;先 binlog dump 再计算 row copy 范围,有部分数据会即在 binlog 中有记录,也在 rowcopy 中有记录,但是这种情况可以处理。
数据迁移分二个部分:RowCopy 和 BinlogApply,RowCopy 和 BinlogApply 是同时进行的,但 BinlogApply 优先级高于 RowCopy。
在迁移过程中,数据变量有:A(RowCopy),B(对原表的数据操作 [insert/update/delete]),C(BinlogApply)。C 操作肯定在 B 操作之后,因为只有对原表的数据记录进行操作 B 才会触发 C 操作。
所以,数据迁移模型:ABC、BCA、BAC
上图中表示对于特定一行数据的处理顺序:
- INSERT
- row copy → DML → binlog apply:数据被 binlog 覆盖,最终是最新数据
- DML → row copy → binlog apply:binlog apply 为空操作,最终为最新数据
- DML → binlog apply → row copy:row copy 数据 ignore,最终是最新数据
- UPDATE
- row copy → DML → binlog apply:数据被 binlog 覆盖,最终是最新数据
- DML → row copy → binlog apply:binlog apply 为空操作,最终为最新数据
- DML → binlog apply → row copy:row copy 数据 ignore,最终是最新数据
- DELETE
- row copy → dml → binlog apply:老数据先 insert,binlog apply 把老数据删除
- dml → row copy → binlog apply:row copy 和 binlog apply 都是空操作
- dml → binlog apply → row copy:binlog apply 和 row copy 都是空操作
apply-binlog 和 row-copy 同时进行可能带来的问题:打破了原本有序的数据,导致聚簇索引利用率不高,做完 DDL 后空间增大(特别是对于大字段)
// go/binlog/gomysql_reader.go读取binlog,然后go/logic/applier.go将binlog转化写入ghost表中
// go/logic/migrator.go:: executeWriteFuncs 、 iterateChunks 和 consumeRowCopyComplete
executeWriteFuncs:
for {
select { ### BinlogApply 与 RowCopy 同时可操作,BinlogApply 优先处理
case eventStruct := <-this.applyEventsQueue: ### BinlogApply 的队列长度为 100,Event 事件由 streamer 提供
this.onApplyEventStruct(eventStruct)
default:
select {
case copyRowsFunc := <-this.copyRowsQueue: ### RowCopy的队列长度为1
copyRowsFunc()
default:
time.Sleep(time.Second) ### 既没有BinlogApply,又没有RowCopy,可能是超负载
}
}
}
iterateChunks:
terminateRowIteration := func(err error) error {
this.rowCopyComplete <- true ### 标记RowCopy结束的信道
}
for {
copyRowsFunc := func() error {
if atomic.LoadInt64(&this.rowCopyCompleteFlag) == 1{ ### 表示RowCopy结束
return nil
}
hasFurtherRange, err := this.applier.CalculateNextIterationRangeEndValues() ###探测是否有数据需要迁移
if !hasFurtherRange {
return terminateRowIteration(nil)
}
applyCopyRowsFunc := func() error {
_, rowsAffected, _, err := this.applier.ApplyIterationInsertQuery() ### insert ignore into ghost from (select * from originalTbl)
}
return this.retryOperation(applyCopyRowsFunc) ### 执行applyCopyRowsFunc()函数
}
this.copyRowsQueue <- copyRowsFunc
}
consumeRowCopyComplete:
<-this.rowCopyComplete ### 等待RowCopy结束
atomic.StoreInt64(&this.rowCopyCompleteFlag, 1) ### 标记RowCopy结束
在 row copy 结束后就可以开始 cutover 流程了,此时 binlog apply 仍然继续进行。
这里不能直接 rename,因为原表还有写入,一旦直接 rename,数据会不一致。而且 rename 命令会试图获取 MDL 写锁,而当系统中有长事物时,rename 会阻塞在 MDL 锁获取,其他操作也不能继续进行。所以 rename 之前要先 lock 原表,不让写入,直到 binlog 全部追上主库,原表和 gh-ost 表数据完全一致。
在 pt-osc 中,pt-osc 采用同步模式,在 copyrow 阶段完成之后,直接通过这条原子性的语句完成 rename,语句如下:
RENAME TABLE tbl TO tbl_old, tbl_new TO tbl;
在 fb-osc 中,fb-osc 采用异步模式,完成 rename 阶段,语句如下:
LOCK TABLES tbl WRITE;
ALTER TABLE tbl RENAME TO tbl_old;
ALTER TABLE tbl_new RENAME TO tbl;
UNLOCK TABLES;
- 在 pt-osc 中,rename 操作一般是耗时比较短,但如果表结构变更过程中,有大查询进来,那么在 rename 操作的时候,会触发 MDL 锁的等待,如果在高峰期,这就是个严重的问题。
- 在 fb-osc 中,在 tbl 被更改为 tbl_old 之后,在 tbl_new 被更改为 tbl 之前,会存在一段较短时间没有 tbl,可能对应用带来错误,或许并不能捕捉没有表的错误信息。
gh-ost 也是异步模式,利用 Mysql 一个特性,就是==在所有被 blocked 的请求中,rename 请求是永远最优先的==。一条连接对原表加锁,另一条连接进行 rename 操作,此时会被 blocked 掉,当 unlock 后,rename 请求会优先被处理,其他的请求会应用到新表上。
其中作者写三篇文章对 cut-over 阶段进行分析,比较有趣,详情参考:gh-ost atomic cutover specification
The solution we offer is now based on two connections only (as opposed to three, in the optimistic approach). "Our" connections will be C10, C20. The "normal" app connections are C1..C9, C11..C19, C21..C29.
- Connections C1..C9 operate on tbl with normal DML: INSERT, UPDATE, DELETE
- Connection C10:
CREATE TABLE tbl_old (id int primary key) COMMENT='magic-be-here'
- Connection C10:
LOCK TABLES tbl WRITE, tbl_old WRITE
- Connections C11..C19, newly incoming, issue queries on
tbl
but are blocked due to theLOCK
- Connection C20:
RENAME TABLE tbl TO tbl_old, ghost TO tbl
(需要等待原表的 DML 操作 binlog 全部同步完成)- This is blocked due to the
LOCK
, but gets prioritized on top connections C11..C19 and on top C1..C9 or any other connection that attempts DML on tbl
- This is blocked due to the
- Connections C21..C29, newly incoming, issue queries on
tbl
but are blocked due to theLOCK
and due to theRENAME
, waiting in queue - Connection C10: checks that C20's
RENAME
is applied (looks for the blockedRENAME
inshow processlist
) - Connection C10:
DROP TABLE tbl_old
. Nothing happens yet;tbl
is still locked. All other connections still blocked. - Connection C10:
UNLOCK TABLES
BAM! The RENAME
is first to execute, ghost table is swapped in place of tbl
, then C1..C9, C11..C19, C21..C29 all get to operate on the new and shiny tbl
异常情况下的正确性:
- T5 rename 之前,如果 C10 连接断开:rename 出错,因为 tbl_old 表存在
- T7 - T8 - T9 之间 C20 连接断开:无影响,rename 不会进行
其他异常情况参见 gh-ost 的 cutover 流程 http://code.openark.org/blog/mysql/solving-the-non-atomic-table-swap-take-iii-making-it-atomic
// go/logic/migrator.go :: atomicCutOver
atomicCutOver: ### 连接 C3
go AtomicCutOverMagicLock ### 对应锁原表那条连接
<- tableLocked ### 锁住原表后才进行 rename 操作,做到同步
go AtomicCutOverRename ### 对应rename操作那条连接
ExpectProcess(renameSessionId, "metadata lock", "rename") ### 由于 rename 操作会被阻塞住,不会立即返回结果,所以通过此函数检查 rename 真正地被阻塞住
ExpectUsedLock(lockOriginalSessionId)
okToUnlockTable <- true ### 表示可以进行解锁
// go/logic/applier.go :: AtomicCutOverMagicLock 和 AtomicCutOverRename
AtomicCutOverMagicLock: ### 连接C1
set session lock_wait_timeout := CutOverLockTimeoutSeconds * 2 ### 防止 lock tables 等待太久,CutOverLockTimeoutSeconds 可配置
create table _'originalTbl'_del ### 防止rename过早
lock tables originalTbl write, _'originalTbl'_ write
tableLocked <- nil ### 表示可以进行 rename 操作
<- okToUnlockTable ### 等待解锁信号
drop table _'originalTbl'_del
unlock tables
AtomicCutOverRename: ### 连接 C2
set session lock_wait_timeout := CutOverLockTimeoutSeconds ### 防止 rename 被阻塞死,如果 rename 操作超出设置 lock
将 BinlogApply 和 RowCopy 放在一个协程内,BinlogApply 优先于 RowCopy。
- 如果正在数据迁移过程中,检测到需要节流,则完成当前批次数据迁移后再节流
- 如果没有数据迁移,检测到需要节流,立即节流
- 节流是通过休眠当前协程来完成,即即使满足数据迁移条件,也要等到不再需要节流,才能进行数据迁移
- 手动设置 throttle:echo throttle | nc -U /tmp/gh-ost.test.sample_data_0.sock
- 创建标示文件来节流:--throttle-flag-file
- 设置 Mysql 的状态阈值:--max-load
- 设置一个限流 SQL:--throttle-query
gh-ost
内置了心跳机制,从而对主从复制延迟时间进行监控,当前从库的主从复制延迟时间或由--throttle-control-replicas 指定的从库中最大复制延迟时间大于设定的延迟阈值:--max-lag-millis
// go/logic/migrator.go :: executeWriteFuncs
// 节流操作、binlog应用以及行复制是同步的
executeWriteFuncs:
for {
throttle() // 节流操作
select {
case:
BinlogApply // binlog应用
default:
select {
case:
RowCopy // 行复制
default:
}
}
}
// go/logic/throttler.go :: throttle
throttle:
for {
if ! IsThrottled() { // 判断是否可以节流
return
}
time.Sleep(250 * time.Millisecond) // 通过休眠当前协程来节流
}