技术分享 | gh-ost 原理剖析

深入解析gh-ost工具的执行流程,涵盖数据库检查、数据迁移、binlog应用及原子性切换,确保在线DDL变更的安全与高效。

作者简介:
杨奇龙,网名“北在南方”,7年DBA老兵,目前任职于杭州有赞科技DBA,主要负责数据库架构设计和运维平台开发工作,擅长数据库性能调优、故障诊断。

一、简介

上一篇文章(gh-ost 在线 ddl 变更工具​)介绍 gh-ost 参数和具体的使用方法、核心特性(可动态调整暂停)、动态修改参数等等。本文分几部分从源码方面解释 gh-ost 的执行过程,数据迁移,切换细节设计。

二、原理

2.1 执行过程

本例基于在主库上执行 DDL 记录的核心过程。核心代码在 github.com/github/gh-ost/go/logic/migrator.go 的 Migrate()

func (this *Migrator) Migrate() //Migrate executes the complete migration logic. This is the major gh-ost function.
1.检查数据库实例的基础信息
a 测试db是否可连通,
b 权限验证 
  show grants for current_user()
c 获取binlog相关信息,包括row格式和修改binlog格式后的重启replicate
  select @@global.log_bin, @@global.binlog_format
  select @@global.binlog_row_image
d 原表存储引擎是否是innodb,检查表相关的外键,是否有触发器,行数预估等操作,需要注意的是行数预估有两种方式  一个是通过explain 读执行计划 另外一个是select count(*) from table ,遇到几百G的大表,后者一定非常慢。	
explain select /* gh-ost */ * from `test`.`b` where 1=1
2.模拟 slave,获取当前的位点信息,创建 binlog streamer 监听 binlog
2019-09-08T22:01:20.944172+08:00	17760 Query	show /* gh-ost readCurrentBinlogCoordinates */ master status
2019-09-08T22:01:20.947238+08:00	17762 Connect	root@127.0.0.1 on  using TCP/IP
2019-09-08T22:01:20.947349+08:00	17762 Query	SHOW GLOBAL VARIABLES LIKE 'BINLOG_CHECKSUM'
2019-09-08T22:01:20.947909+08:00	17762 Query	SET @master_binlog_checksum='NONE'
2019-09-08T22:01:20.948065+08:00	17762 Binlog Dump	Log: 'mysql-bin.000005'  Pos: 795282
3.创建 日志记录表 xx_ghc 和影子表 xx_gho 并且执行 alter 语句将影子表 变更为目标表结构。如下日志记录了该过程,gh-ost 会将核心步骤记录到 _b_ghc 中。
2019-09-08T22:01:20.954866+08:00	17760 Query	create /* gh-ost */ table `test`.`_b_ghc` (
			id bigint auto_increment,
			last_update timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			hint varchar(64) charset ascii not null,
			value varchar(4096) charset ascii not null,
			primary key(id),
			unique key hint_uidx(hint)
		) auto_increment=256
2019-09-08T22:01:20.957550+08:00	17760 Query	create /* gh-ost */ table `test`.`_b_gho` like `test`.`b`
2019-09-08T22:01:20.960110+08:00	17760 Query	alter /* gh-ost */ table `test`.`_b_gho` engine=innodb
2019-09-08T22:01:20.966740+08:00	17760 Query 
   insert /* gh-ost */ into `test`.`_b_ghc`(id, hint, value)values (NULLIF(2, 0), 'state', 'GhostTableMigrated') on duplicate key update last_update=NOW(),value=VALUES(value)
4.insert into xx_gho select * from xx 拷贝数据

获取当前的最大主键和最小主键,然后根据命令行传参 chunk 获取数据 insert 到影子表里面

获取最小主键 select `id` from `test`.`b` order by `id` asc limit 1;
获取最大主键 soelect `id` from `test`.`b` order by `id` desc limit 1;
获取第一个 chunk:
select  /* gh-ost `test`.`b` iteration:0 */ `id` from `test`.`b` where ((`id` > _binary'1') or ((`id` = _binary'1'))) and ((`id` < _binary'21') or ((`id` = _binary'21'))) order by `id` asc limit 1 offset 999;

循环插入到目标表:
insert /* gh-ost `test`.`b` */ ignore into `test`.`_b_gho` (`id`, `sid`, `name`, `score`, `x`) (select `id`, `sid`, `name`, `score`, `x` from `test`.`b` force index (`PRIMARY`)  where (((`id` > _binary'1') or ((`id` = _binary'1'))) and ((`id` < _binary'21') or ((`id` = _binary'21')))) lock in share mode;

循环到最大的id,之后依赖binlog 增量同步     

需要注意的是

rowcopy 过程中是对原表加上 lock in share mode,防止数据在 copy 的过程中被修改。这点对后续理解整体的数据迁移非常重要。因为 gh-ost 在 copy 的过程中不会修改这部分数据记录。对于解析 binlog 获得的 INSERT , UPDATE, DELETE 事件我们只需要分析 copy 数据之前 log before copy 和 copy 数据之后 log after copy。整体的数据迁移会在后面做详细分析。

5.增量应用 binlog 迁移数据

核心代码在 gh-ost/go/sql/builder.go 中,这里主要做 DML 转换的解释,当然还有其他函数做辅助工作,比如数据库 ,表名校验 以及语法完整性校验。

解析到delete语句 对应转换为delete语句

func BuildDMLDeleteQuery(databaseName, tableName string, tableColumns, uniqueKeyColumns *ColumnList, args []interface{}) (result string, uniqueKeyArgs []interface{}, err error) {
   ....省略代码...
	result = fmt.Sprintf(`
			delete /* gh-ost %s.%s */
				from
					%s.%s
				where
					%s
		`, databaseName, tableName,
		databaseName, tableName,
		equalsComparison,
	)
	return result, uniqueKeyArgs, nil
}

解析到 insert 语句 对应转换为 replace into 语句

func BuildDMLInsertQuery(databaseName, tableName string, tableColumns, sharedColumns, mappedSharedColumns *ColumnList, args []interface{}) (result string, sharedArgs []interface{}, err error) {
   ....省略代码...
	result = fmt.Sprintf(`
			replace /* gh-ost %s.%s */ into
				%s.%s
					(%s)
				values
					(%s)
		`, databaseName, tableName,
		databaseName, tableName,
		strings.Join(mappedSharedColumnNames, ", "),
		strings.Join(preparedValues, ", "),
	)
	return result, sharedArgs, nil
}

解析到 update 语句 对应转换为语句

func BuildDMLUpdateQuery(databaseName, tableName string, tableColumns, sharedColumns, mappedSharedColumns, uniqueKeyColumns *ColumnList, valueArgs, whereArgs []interface{}) (result string, sharedArgs, uniqueKeyArgs []interface{}, err error) {
   ....省略代码...
	result = fmt.Sprintf(`
 			update /* gh-ost %s.%s */
 					%s.%s
				set
					%s
				where
 					%s
 		`, databaseName, tableName,
		databaseName, tableName,
		setClause,
		equalsComparison,
	)
	return result, sharedArgs, uniqueKeyArgs, nil
}

数据迁移的数据一致性分析
gh-ost 做 DDL 变更期间对原表和影子表的操作有三种:对原表的 row copy (我们用 A 操作代替),业务对原表的 DML 操作(B),对影子表的 apply binlog©。而且 binlog 是基于 DML 操作产生的,因此对影子表的 apply binlog 一定在 对原表的 DML 之后,共有如下几种顺序:

通过上面的几种组合操作的分析,我们可以看到 数据最终是一致的。尤其是当copy 结束之后,只剩下apply binlog,情况更简单。

6.copy 完数据之后进行原始表和影子表 cut-over 切换

gh-ost 的切换是原子性切换,基本是通过两个会话的操作来完成 。作者写了三篇文章解释cut-over操作的思路和切换算法。详细的思路请移步到下面的链接。

http://code.openark.org/blog/mysql/solving-the-non-atomic-table-swap-take-iii-making-it-atomic
http://code.openark.org/blog/mysql/solving-the-non-atomic-table-swap-take-ii
http://code.openark.org/blog/mysql/solving-the-facebook-osc-non-atomic-table-swap-problem

这里将第三篇文章描述核心切换逻辑摘录出来。其原理是基于 MySQL 内部机制:被 lock table 阻塞之后,执行rename的优先级高于 DML,也即先执行 rename table ,然后执行 DML 。假设 gh-ost 操作的会话是 c10 和 c20 ,其他业务的 DML 请求的会话是 c1-c9, c11-c19, c21-c29。

1 会话 c1..c9: 对b表正常执行DML操作。
2 会话 c10 : 创建_b_del 防止提前rename 表,导致数据丢失。
      create /* gh-ost */ table `test`.`_b_del` (
			id int auto_increment primary key
		) engine=InnoDB comment='ghost-cut-over-sentry'
		
3 会话 c10 执行LOCK TABLES b WRITE, `_b_del` WRITE。
4 会话c11-c19 新进来的dml或者select请求,但是会因为表b上有锁而等待。
5 会话c20:设置锁等待时间并执行rename
	set session lock_wait_timeout:=1
	rename /* gh-ost */ table `test`.`b` to `test`.`_b_20190908220120_del`, `test`.`_b_gho` to `test`.`b`
  c20 的操作因为c10锁表而等待。
  
6 c21-c29 对于表 b 新进来的请求因为lock table和rename table 而等待。
7 会话c10 通过sql 检查会话c20 在执行rename操作并且在等待mdl锁。
select id
			from information_schema.processlist
			where
				id != connection_id()
				and 17765 in (0, id)
				and state like concat('%', 'metadata lock', '%')
				and info  like concat('%', 'rename', '%')

8 c10 基于步骤7 执行drop table `_b_del` ,删除命令执行完,b表依然不能写。所有的dml请求都被阻塞。

9 c10 执行UNLOCK TABLES; 此时c20的rename命令第一个被执行。而其他会话c1-c9,c11-c19,c21-c29的请求可以操作新的表b。

划重点(敲黑板)

1 创建 _b_del 表是为了防止 cut-over 提前执行,导致数据丢失。
2 同一个会话先执行 write lock 之后还是可以 drop 表的。
3 无论 rename table 和 DML 操作谁先执行,被阻塞后 rename table 总是优先于 DML 被执行。
大家可以一边自己执行 gh-ost ,一边开启 general log 查看具体的操作过程。

2019-09-08T22:01:24.086734	17765	create /* gh-ost */ table `test`.`_b_20190908220120_del` (
			id int auto_increment primary key
		) engine=InnoDB comment='ghost-cut-over-sentry'
2019-09-08T22:01:24.091869	17760 Query	lock /* gh-ost */ tables `test`.`b` write, `test`.`_b_20190908220120_del` write
2019-09-08T22:01:24.188687	17765	START TRANSACTION
2019-09-08T22:01:24.188817	17765  	select connection_id()
2019-09-08T22:01:24.188931	17765  	set session lock_wait_timeout:=1
2019-09-08T22:01:24.189046	17765  	rename /* gh-ost */ table `test`.`b` to `test`.`_b_20190908220120_del`, `test`.`_b_gho` to `test`.`b`
2019-09-08T22:01:24.192293+08:00	17766 Connect	root@127.0.0.1 on test using TCP/IP
2019-09-08T22:01:24.192409	17766  	SELECT @@max_allowed_packet
2019-09-08T22:01:24.192487	17766  	SET autocommit=true
2019-09-08T22:01:24.192578	17766  	SET NAMES utf8mb4
2019-09-08T22:01:24.192693	17766  	select id
			from information_schema.processlist
			where
				id != connection_id()
				and 17765 in (0, id)
				and state like concat('%', 'metadata lock', '%')
				and info  like concat('%', 'rename', '%')
2019-09-08T22:01:24.193050	17766 Query	select is_used_lock('gh-ost.17760.lock')
2019-09-08T22:01:24.193194	17760 Query	drop /* gh-ost */ table if exists `test`.`_b_20190908220120_del`
2019-09-08T22:01:24.194858	17760 Query	unlock tables
2019-09-08T22:01:24.194965	17760 Query	ROLLBACK
2019-09-08T22:01:24.197563	17765 Query	ROLLBACK
2019-09-08T22:01:24.197594	17766 Query	show /* gh-ost */ table status from `test` like '_b_20190908220120_del'
2019-09-08T22:01:24.198082	17766 Quit
2019-09-08T22:01:24.298382	17760 Query	drop /* gh-ost */ table if exists `test`.`_b_ghc`

如果 cut-over 过程的各个环节执行失败会发生什么?

其实除了安全,什么都不会发生。

如果c10的create `_b_del` 失败,gh-ost 程序退出。
如果c10的加锁语句失败,gh-ost 程序退出,因为表还未被锁定,dml请求可以正常进行。
如果c10在c20执行rename之前出现异常
 A. c10持有的锁被释放,查询c1-c9,c11-c19的请求可以立即在b执行。
 B. 因为`_b_del`表存在,c20的rename table b to  `_b_del`会失败。
 C. 整个操作都失败了,但没有什么可怕的事情发生,有些查询被阻止了一段时间,我们需要重试。
如果c10在c20执行rename被阻塞时失败退出,与上述类似,锁释放,则c20执行rename操作因为——b_old表存在而失败,所有请求恢复正常。
如果c20异常失败,gh-ost会捕获不到rename,会话c10继续运行,释放lock,所有请求恢复正常。
如果c10和c20都失败了,没问题:lock被清除,rename锁被清除。 c1-c9,c11-c19,c21-c29可以在b上正常执行。

整个过程对应用程序的影响
应用程序连接保证被阻止,直到交换 ghost 表或直到操作失败。在前者中,他们继续在新表上进行操作。在后者中,他们继续在原表上进行操作。
对复制的影响
slave 因为 binlog 文件中不会复制 lock 语句,只能应用 rename 语句进行原子操作,对复制无损。

7.处理收尾工作

最后一部分操作其实和具体参数有一定关系。最重要必不可少的是

关闭 binlogsyncer 连接
至于中间表 ,其实和参数有关 --initially-drop-ghost-table --initially-drop-old-table

###小结
纵观 gh-ost 的执行过程,查看源码算法设计, 尤其是 cut-over 设计思路之精妙,原子操作,任何异常都不会对业务有严重影响。欢迎已经使用过的朋友分享各自遇到的问题,也欢迎还未使用过该工具的朋友大胆尝试。

###参考文章
https://www.cnblogs.com/mysql-dba/p/9901589.html

`gh-ost`(GitHub Online Schema Change)是 GitHub 开发的一款 **在线无锁结构变更工具**,用于在 MySQL 上安全地执行 `ALTER TABLE` 操作而无需长时间锁定表。它通过模拟主从复制的方式,在不阻塞读写的情况下完成大表的 DDL 变更。 --- ## ✅ gh-ost 的核心原理 ### 🎯 目标 在不影响线上业务的前提下,对大表进行结构修改(如添加字段、索引、修改列类型等),避免使用原生 `ALTER TABLE` 导致的: - 表级锁 - 复制延迟 - 主库性能下降 - 服务中断 --- ### 🔧 工作流程图解(简化版) ``` 原始表: your_table ↓ gh-ost 创建: ghost_table (新结构) ↓ 启用 binlog 同步 → 捕获 your_table 的增删改 → 应用到 ghost_table ↓ 数据迁移完成后,原子性重命名交换表名 ↓ 最终: your_table = 新结构 ``` --- ### 🔁 核心步骤详解 #### 1. **创建 Ghost 表(_your_table_gho)** ```sql CREATE TABLE `_your_table_gho` LIKE `your_table`; -- 然后执行 ALTER 修改结构 ALTER TABLE `_your_table_gho` ADD COLUMN new_column VARCHAR(100); ``` > 命名规则:`_<original_table_name>_gho` #### 2. **插入初始数据(Copy Phase)** 使用 `SELECT ... LIMIT` 分块读取原表数据,并插入到 `_gho` 表中。 - 使用 `WHERE id BETWEEN ? AND ?` 分片 - 不会一次性全表扫描锁住 - 支持限速、暂停、断点续传 #### 3. **同步增量变更(Apply Binlog Events)** 启动一个 binlog reader,连接 MySQL 主库,监听该表的所有 `INSERT/UPDATE/DELETE` 操作,并将这些变更实时应用到 `_gho` 表。 - 利用 `REPLICATION SLAVE` 权限读取 binlog - 内部维护一个“开始位点”和“当前位点”,确保一致性 - 所有写操作都通过 ROW 格式解析并重放 #### 4. **等待追平(Catching Up)** 当所有历史数据拷贝完毕后,gh-ost 会持续追赶最新的 binlog,直到延迟接近 0。 #### 5. **原子切换(Cut-over / Switchover)** 使用 **双 RENAME** 实现最小停机时间切换: ```sql RENAME TABLE `your_table` TO `your_table_del`, `_your_table_gho` TO `your_table`; ``` 这个操作非常快(毫秒级),几乎无感知。 > ⚠️ 默认使用“互斥锁”保证只有一个 gh-ost 实例能执行切换。 #### 6. **清理旧表(可选)** 切换成功后,原表被重命名为 `_your_table_del`,你可以手动删除或让 gh-ost 自动删除。 --- ## 📁 gh-ost 生成的日志与临时表 | 文件/表 | 名称 | 作用 | |--------|------|------| | **Ghost 表** | `_your_table_gho` | 新结构的目标表,最终将成为主表 | | **日志表** | `_your_table_ghc` | 存储迁移过程中的状态、心跳、进度信息 | | **旧表备份** | `_your_table_del` | 原始表重命名后的名字,可用于回滚 | --- ### 📘 ghc 日志表详解(_xxx_ghc) `_ghc` 表是 gh-ost 的 **控制通道 + 状态存储表**,结构如下: ```sql CREATE TABLE `_your_table_ghc` ( `id` bigint NOT NULL AUTO_INCREMENT, `migration_uuid` varchar(40) DEFAULT NULL, `database_name` varchar(64) NOT NULL, `table_name` varchar(64) NOT NULL, `command` text, -- 当前命令 `value` text, -- 状态值(JSON 格式) `type` varchar(16) DEFAULT NULL, -- 类型:status, heartbeat, etc. `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `migration_uuid_table_name` (`migration_uuid`,`table_name`) ); ``` #### 主要用途: - 存储迁移任务的状态(如已复制行数、速度、延迟) - 心跳检测(判断主从是否存活) - 支持远程控制(通过 SQL 发送 `pause`, `resume`, `kill` 指令) - 断点续传(崩溃后恢复上次位置) > 💡 你可以查询 `_ghc` 表来监控进度: > ```sql > SELECT value FROM _your_table_ghc WHERE type = 'status'; > ``` --- ### 🗑️ del 表(_your_table_del) 这是原表在切换时被重命名的结果: ```sql RENAME TABLE `your_table` TO `your_table_del`; ``` #### 是否可以删除? ✅ 可以,但建议保留一段时间(比如 24 小时),以便出现问题时快速回滚。 #### 如何回滚? ```sql RENAME TABLE `your_table` TO `_your_table_new_backup`, `your_table_del` TO `your_table`; ``` 然后重启应用即可回到旧结构。 --- ## ✅ gh-ost 常用命令示例 ```bash gh-ost \ --host=127.0.0.1 \ --user=inceptionrw \ --password=123456 \ --database="test" \ --table="your_table" \ --alter="ADD COLUMN description TEXT" \ --chunk-size=1000 \ --max-load='Threads_running=25' \ --critical-load='Threads_running=100' \ --timeout=0 \ --verbose \ --execute ``` 参数说明: - `--alter`: 要执行的 DDL - `--chunk-size`: 每次 copy 的行数 - `--max-load`: 最大负载阈值(超过则降速) - `--critical-load`: 超过则停止迁移 - `--execute`: 不加此参数只会做 dry-run(预演) --- ## ✅ 优势 vs 缺陷 | 优点 | 缺陷 | |------|------| | ✔️ 无需触发器(相比 pt-osc 更高效)<br>✔️ 支持断点续传<br>✔️ 实时监控与动态控制(pause/resume)<br>✔️ 对主库压力小,支持限速 | ❌ 需要 `REPLICATION CLIENT`, `REPLICATION SLAVE`, `SUPER`<br>❌ 跨机房延迟高时可能影响切换<br>❌ 不支持外键自动处理(需先禁用)<br>❌ JSON 列修改仍有限制 | --- ## ✅ 安全建议 1. **测试环境先行验证** 2. **避开业务高峰期** 3. **开启 `--postpone-cut-over-flag-file`**:允许人工确认切换时机 4. **设置合理的 `--max-load` 和限速参数** 5. **保留 `_del` 表至少一天** ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值