gh-ost使用问题记录

因为 pt-osc 对数据库性能影响较大,且容易造成死锁问题,目前我们在线更改表结构都使用 gh-ost 工具进行修改,这里记录一下使用 gh-ost 过程中的问题,以作记录;首先先复习一下gh-ost的基本实现,gh-ost的基本实现原理如下图所示:

根据源码,核心步骤如下:

1. initiateStreaming: 初始化 binlog events streaming
2. initiateApplier: 初始化 applier
3. addDMLEventsListener: 添加对指定表的binlog event过滤
4. ReadMigrationRangeValues: 获取对应表唯一索引的 min & max 值
5. executeWriteFuncs: 通过applier向ghost表写入数据, binlog event 相比 copy rows具有更高优先级
6. iterateChunks: 根据 min & max的值, 批量插入数据到 ghost 表
7. cutOver: rename & drop新旧表

问题一:gh-ost导致最新一次写操作丢失

原因分析:

在 initiateStreaming 的过程中通过 show master status 获取主节点当前的 binlog name & pos & Executed_Gtid_Set,然后通过 binlog name & pos 和当前的数据库节点建立复制通道,而后在 ReadMigrationRangeValues 的过程中通过 select min(unique_key) 和 select max(unique_key) 快照读的方式获取原表数据的范围。

问题就出在这里,根据事务的提交流程,如果sync_binlog != 1,那么 binlog name & pos 是在binlog flush阶段之后进行更新;如果 sync_binlog = 1,那么 binlog name & pos 是在 binlog sync 阶段之后进行更新,这时事务还没有在 Innodb 中完成 commit。因此,最新的一次事务对于 select min() & max() 这样的快照读是不可见的,最终造成了写操作的丢失。

如何修复:

这里有两种解决办法:1. 虽然 binlog name & pos 的信息是在 Innodb memory commit 之前进行更新,但是show master status 的 Executed_Gtid_Set 是在 Innodb memory commit 完成之后进行更新的,因此 gh-ost 可以使用 Executed_Gtid_Set 来与数据库节点建立复制通道来解决这个问题。

2. 在 ReadMigrationRangeValues 的过程中使用 select min() & max() lock in share mode 当前读来解决这个问题;

 

问题二:添加唯一索引时有可能导致数据丢失:

在使用 gh-ost 做 “add column field1 int not null, add unique index uniq_idx_field1(field1)” 或 “add column field1 int not null default 0, add unique index uniq_idx_field1(field1)” 这样的操作时,会导致整张表只剩下一条数据;

在执行  “add unique index uniq_idx_field1(field1)” 这样的操作时,如果表中的 field1字段存在重复数据,会导致第 2~n 条重复数据丢失。

相比之下,pt-osc 工具提供的 --check-unique-key-change 参数可以在出现以上情况时进行 warning 退出,有补救的可能。

 

问题三:高并发写入时gh-ost无法结束:

 

如截图所示,Applied一直在增大,而 Copy保持不变。这是因为在通过 Applier 向 ghost 表中写数据时,binlog events apply 相比rows copy 具有更高的优先级;同时,由于 gh-ost 是通过监听 mysql binlog的方式获取增量写操作,对源MySQL节点的侵入较小;因此,在MySQL实例高并发写入时,gh-ost会忙于 apply binlog events而无法结束。

 

扩展一:gh-ost的cutover过程:

 

 

 如上图所示,根据gh-ost  -cut-over参数的不同会选择不同的 cur-over 算法,默认是github的 atomic算法,也可以选择 facebook的 OSC算法。

atomic算法// atomicCutOver

 这里为什么需要 magic_old表呢?

是为了防止lockSessionId被意外关闭后,可以阻塞rename操作。lockSessionId被意外关闭后,original table可以被写入,会造成数据不一致。至于为何要对 magic_old表加锁,我个人认为是防止magic_old表被意外删除。

 

源码实现如下:

func (this *Migrator) atomicCutOver() (err error) {
     // 设置 cutover 标记, 限流函数会使用此标记
	atomic.StoreInt64(&this.migrationContext.InCutOverCriticalSectionFlag, 1)
	defer atomic.StoreInt64(&this.migrationContext.InCutOverCriticalSectionFlag, 0)
     // 通知释放锁的通道
	okToUnlockTable := make(chan bool, 4)
     // 最后删除 magic old table
	defer func() {
		okToUnlockTable <- true
		this.applier.DropAtomicCutOverSentryTableIfExists()
	}()
     // 
	atomic.StoreInt64(&this.migrationContext.AllEventsUpToLockProcessedInjectedFlag, 0)
       // 存储会话ID
	lockOriginalSessionIdChan := make(chan int64, 2)
        // 表锁定后通知通道
	tableLocked := make(chan error, 2)
        // 表锁释放后通知通道
	tableUnlocked := make(chan error, 2)
	go func() {
          /*
          * 在 cutover 阶段对表加锁, 共分为以下几步:
                 * 1. 打开一个新的会话,将会话ID存入 lockOriginalSessionIdChan 中
                   2. 调用 get_lock(key, timeout) 对应用进行加锁。需要一个字符串key和加锁超时等待时间,sessionA 调用 get_lock 对key加锁, 
              sessionB也调用 get_lock 对相同的key加锁会等待,直到超时或 sessionA 断开。
                   3. 通过 set session lock_wait_timeout 设置表切换锁超时时间为CutOverLockTimeoutSeconds * 2, 默认为 6s,CutOverLockTimeoutSeconds可以人为指定。
                   4. 创建 cutover 过程中的 magic old表
                   5. 通过 lock tables write 对原始表和 magic old 表加 write锁
                   6. 向 tableLocked 通道中写入nil, 通知其他协程对原表和 magic old表加锁完成,可以开始后续工作
                   7. 当前协程阻塞,通过 <- okToUnlockTable 等待 unlocks table 信号
                   8. 删除 magic old 表, 删除之后,原始表仍然处于 lock 状态
                   9. 通过 unlock tables 对表进行解锁
                   10. 解锁成功后,向 tableUnlocked 通道写入 nil, 通知其他协程
          */
		if err := this.applier.AtomicCutOverMagicLock(lockOriginalSessionIdChan, tableLocked, okToUnlockTable, tableUnlocked); err != nil {
			log.Errore(err)
		}
	}()
        // 这里阻塞等待原始表和 magic old 表加锁完成
	if err := <-tableLocked; err != nil {
		return log.Errore(err)
	}
        // 获取加锁的 sessionId
	lockOriginalSessionId := <-lockOriginalSessionIdChan
	log.Infof("Session locking original & magic tables is %+v", lockOriginalSessionId)
	// At this point we know the original table is locked.
	// We know any newly incoming DML on original table is blocked.
        // 这里,原始表已被锁定,原始表不会产生新的 binlog event。我们需要等待 apply 原始表的 binlog events 到最新位置,等待
        // 时间默认为 CutOverLockTimeoutSeconds(3s, 可以人为指定)
	if err := this.waitForEventsUpToLock(); err != nil {
		return log.Errore(err)
	}

	// Step 2
	// We now attempt an atomic RENAME on original & ghost tables, and expect it to block.
	this.migrationContext.RenameTablesStartTime = time.Now()
        // rename 操作是否被阻塞标志
	var tableRenameKnownToHaveFailed int64
        // 存储 rename 会话ID
	renameSessionIdChan := make(chan int64, 2)
        // rename 成功或失败后消息通知通道
	tablesRenamed := make(chan error, 2)
        // 异步的进行 rename 操作
	go func() {
                /**
           * rename 表操作
                    1. 打开新的 session,并将 sessionId 存入 renameSessionIdChan 中
                    2. 通过 set session lock_wait_timeout:= CutOverLockTimeoutSeconds 来设置 rename 操作超时时间
                    3. 通过 rename originalTable to magic_oldTable, ghost_table to originalTable 来进行rename操作,因为在之前对 originalTable
                       和 magic_oldTable 加有锁,正常情况下会阻塞
                 4. 而后向 tablesRenamed 通道写入信息,如果rename成功写入 nil,rename失败则写入 err
          */
		if err := this.applier.AtomicCutoverRename(renameSessionIdChan, tablesRenamed); err != nil {
			// Abort! Release the lock
                        // 终止,释放锁
			atomic.StoreInt64(&tableRenameKnownToHaveFailed, 1)
			okToUnlockTable <- true
		}
	}()
        // 这里获取 rename sessionId
	renameSessionId := <-renameSessionIdChan
	log.Infof("Session renaming tables is %+v", renameSessionId)
        
	waitForRename := func() error {
                
		if atomic.LoadInt64(&tableRenameKnownToHaveFailed) == 1 {
			// We return `nil` here so as to avoid the `retry`. The RENAME has failed,
			// it won't show up in PROCESSLIST, no point in waiting
                        // 当 rename 操作失败时, 返回 nil避免重试。
			return nil
		}
		return this.applier.ExpectProcess(renameSessionId, "metadata lock", "rename")
	}
	// Wait for the RENAME to appear in PROCESSLIST
        // 等待 rename 操作出现在 processlist 中
	if err := this.retryOperation(waitForRename, true); err != nil {
		// Abort! Release the lock
                // 终止! 释放锁
		okToUnlockTable <- true
		return err
	}
        // tableRenameKnownToHaveFailed = 0 表示 rename 操作被阻塞,符合预期
	if atomic.LoadInt64(&tableRenameKnownToHaveFailed) == 0 {
		log.Infof("Found atomic RENAME to be blocking, as expected. Double checking the lock is still in place (though I don't strictly have to)")
	}
        // 探测对 original table 和 magic old table 加锁的线程还继续存在, 如果不存在则无法保证数据一致性
	if err := this.applier.ExpectUsedLock(lockOriginalSessionId); err != nil {
		// Abort operation. Just make sure to drop the magic table.
		return log.Errore(err)
	}
	log.Infof("Connection holding lock on original table still exists")

	// Now that we've found the RENAME blocking, AND the locking connection still alive,
	// we know it is safe to proceed to release the lock
        // 这里 rename 操作被阻塞,并且加锁的连接仍然有效,继续释放锁是能保证数据安全的,这里通知释放锁
	okToUnlockTable <- true
	// BAM! magic table dropped, original table lock is released
	// -> RENAME released -> queries on original are unblocked.
        // 阻塞等待锁被释放
	if err := <-tableUnlocked; err != nil {
		return log.Errore(err)
	}
        // 阻塞等待 rename 操作完成 (锁被释放后rename操作会优先其他dml操作执行)
	if err := <-tablesRenamed; err != nil {
		return log.Errore(err)
	}
	this.migrationContext.RenameTablesEndTime = time.Now()

	// ooh nice! We're actually truly and thankfully done
        // 整个切换完成
	lockAndRenameDuration := this.migrationContext.RenameTablesEndTime.Sub(this.migrationContext.LockTablesStartTime)
	log.Infof("Lock & rename duration: %s. During this time, queries on %s were blocked", lockAndRenameDuration, sql.EscapeName(this.migrationContext.OriginalTableName))
	return nil
}

  facebook OSC算法// cutOverTwoStep

      cutOverTwoStep很巧妙地利用MySQL不同 session 下 alter table x rename to x1; 和 rename table x1 to x; 不同的锁机制进行 cutover,值得深入研究。

 

 源码如下:

/*
 * cutOverTwoStep() 将阻塞原始表,等待原始表上的binlog events全部应用到 ghost 表,然后进行非原子的表rename操作,original->old, then new->original;
 * 在rename过程中,原始表不存在,查询操作将失败。
*/
func (this *Migrator) cutOverTwoStep() (err error) {
	atomic.StoreInt64(&this.migrationContext.InCutOverCriticalSectionFlag, 1)
	defer atomic.StoreInt64(&this.migrationContext.InCutOverCriticalSectionFlag, 0)
	atomic.StoreInt64(&this.migrationContext.AllEventsUpToLockProcessedInjectedFlag, 0)
     // 首先通过 lock tables write; 对原始表加锁
	if err := this.retryOperation(this.applier.LockOriginalTable); err != nil {
		return err
	} 
        // 等待原始表上的 binlog events 全部应用到 ghost 表
	if err := this.retryOperation(this.waitForEventsUpToLock); err != nil {
		return err
	}
     // 1. 这里使用和 LockOriginalTable 操作相同的session来执行 alter original_table rename magic_old_table; 来重
        // 命名原表,该操作不会被 LockOriginalTable 操作加的锁阻塞,相同的 session 执行 rename table original to magic 将阻塞。
        // 2. 将 ghost 表重命名为 original 表; 这里采用在另一个 session 上执行 rename table ghost to original;来进行,因此在
        // 原 session 上执行将被锁阻塞。
	if err := this.retryOperation(this.applier.SwapTablesQuickAndBumpy); err != nil {
		return err
	}
        // 对原表解锁
	if err := this.retryOperation(this.applier.UnlockTables); err != nil {
		return err
	}

	lockAndRenameDuration := this.migrationContext.RenameTablesEndTime.Sub(this.migrationContext.LockTablesStartTime)
	renameDuration := this.migrationContext.RenameTablesEndTime.Sub(this.migrationContext.RenameTablesStartTime)
	log.Debugf("Lock & rename duration: %s (rename only: %s). During this time, queries on %s were locked or failing", lockAndRenameDuration, renameDuration, sql.EscapeName(this.migrationContext.OriginalTableName))
	return nil
}

  

 

posted @ 2022-03-29 18:29  卷毛狒狒  阅读(374)  评论(0编辑  收藏  举报