TiDB 源碼閱讀系列文章(二十四)TiDB Binlog 源碼解析

作者:姚維

TiDB Binlog Overview

這篇文章不是講 TiDB Binlog 組件的源碼,而是講 TiDB 在執行 DML/DDL 語句過程中,如何將 Binlog 數據 發送給 TiDB Binlog 集羣的 Pump 組件。目前 TiDB 在 DML 上的 Binlog 用的類似 Row-based 的格式。具體 Binlog 具體的架構細節可以參考這篇 文章

這裏只描述 TiDB 中的代碼實現。

DML Binlog

TiDB 採用 protobuf 來編碼 binlog,具體的格式可以見 binlog.proto。這裏討論 TiDB 寫 Binlog 的機制,以及 Binlog 對 TiDB 寫入的影響。

TiDB 會在 DML 語句提交,以及 DDL 語句完成的時候,向 pump 輸出 Binlog。

Statement 執行階段

DML 語句包括 Insert/Replace、Update、Delete,這裏挑 Insert 語句來闡述,其他的語句行爲都類似。首先在 Insert 語句執行完插入(未提交)之前,會把自己新增的數據記錄在 binlog.TableMutation 結構體中。

// TableMutation 存儲表中數據的變化
message TableMutation {
        // 表的 id,唯一標識一個表
        optional int64 table_id      = 1 [(gogoproto.nullable) = false]; 
        
        // 保存插入的每行數據
        repeated bytes inserted_rows = 2;
        
        // 保存修改前和修改後的每行的數據
        repeated bytes updated_rows  = 3;
        
        // 已廢棄
        repeated int64 deleted_ids   = 4;
        
        // 已廢棄
        repeated bytes deleted_pks   = 5;
         
        // 刪除行的數據
        repeated bytes deleted_rows  = 6;
        
        // 記錄數據變更的順序
        repeated MutationType sequence = 7;
}

這個結構體保存於跟每個 Session 鏈接相關的事務上下文結構體中 TxnState.mutations。 一張表對應一個 TableMutation 對象,TableMutation 裏面保存了這個事務對這張表的所有變更數據。Insert 會把當前語句插入的行,根據 RowID + Row-value 的格式編碼之後,追加到 TableMutation.InsertedRows 中:

func (t *Table) addInsertBinlog(ctx context.Context, h int64, row []types.Datum, colIDs []int64) error {
    mutation := t.getMutation(ctx)
    pk, err := codec.EncodeValue(ctx.GetSessionVars().StmtCtx, nil, types.NewIntDatum(h))
    if err != nil {
        return errors.Trace(err)
    }
    value, err := tablecodec.EncodeRow(ctx.GetSessionVars().StmtCtx, row, colIDs, nil, nil)
    if err != nil {
        return errors.Trace(err)
    }
    bin := append(pk, value...)
    mutation.InsertedRows = append(mutation.InsertedRows, bin)
    mutation.Sequence = append(mutation.Sequence, binlog.MutationType_Insert)
    return nil
}

等到所有的語句都執行完之後,在 TxnState.mutations 中就保存了當前事務對所有表的變更數據。

Commit 階段

對於 DML 而言,TiDB 的事務採用 2-phase-commit 算法,一次事務提交會分爲 Prewrite 階段,以及 Commit 階段。這裏分兩個階段來看看 TiDB 具體的行爲。

Prewrite Binlog

session.doCommit 函數中,TiDB 會構造 binlog.PrewriteValue

message PrewriteValue {
    optional int64         schema_version = 1 [(gogoproto.nullable) = false];
    repeated TableMutation mutations      = 2 [(gogoproto.nullable) = false];
}

這個 PrewriteValue 中包含了跟這次變動相關的所有行數據,TiDB 會填充一個類型爲 binlog.BinlogType_Prewrite 的 Binlog:

info := &binloginfo.BinlogInfo{
    Data: &binlog.Binlog{
        Tp:            binlog.BinlogType_Prewrite,
        PrewriteValue: prewriteData,
    },
    Client: s.sessionVars.BinlogClient.(binlog.PumpClient),
}

TiDB 這裏用一個事務的 Option kv.BinlogInfo 來把 BinlogInfo 綁定到當前要提交的 transaction 對象中:

s.txn.SetOption(kv.BinlogInfo, info)

twoPhaseCommitter.execute 中,在把數據 prewrite 到 TiKV 的同時,會調用 twoPhaseCommitter.prewriteBinlog,這裏會把關聯的 binloginfo.BinlogInfo 取出來,把 Binlog 的 binlog.PrewriteValue 輸出到 Pump。

binlogChan := c.prewriteBinlog()
err := c.prewriteKeys(NewBackoffer(prewriteMaxBackoff, ctx), c.keys)
if binlogChan != nil {
    binlogErr := <-binlogChan // 等待 write prewrite binlog 完成
    if binlogErr != nil {
        return errors.Trace(binlogErr)
    }
}

這裏值得注意的是,在 prewrite 階段,是需要等待 write prewrite binlog 完成之後,才能繼續做接下去的提交的,這裏是爲了保證 TiDB 成功提交的事務,Pump 至少一定能收到 Prewrite Binlog。

Commit Binlog

twoPhaseCommitter.execute 事務提交結束之後,事務可能提交成功,也可能提交失敗。TiDB 需要把這個狀態告知 Pump:

err = committer.execute(ctx)
if err != nil {
    committer.writeFinishBinlog(binlog.BinlogType_Rollback, 0)
    return errors.Trace(err)
}
committer.writeFinishBinlog(binlog.BinlogType_Commit, int64(committer.commitTS))

如果發生了 error,那麼輸出的 Binlog 類型就爲 binlog.BinlogType_Rollback,如果成功提交,那麼輸出的 Binlog 類型就爲 binlog.BinlogType_Commit

func (c *twoPhaseCommitter) writeFinishBinlog(tp binlog.BinlogType, commitTS int64) {
    if !c.shouldWriteBinlog() {
        return
    }
    binInfo := c.txn.us.GetOption(kv.BinlogInfo).(*binloginfo.BinlogInfo)
    binInfo.Data.Tp = tp
    binInfo.Data.CommitTs = commitTS
    go func() {
        err := binInfo.WriteBinlog(c.store.clusterID)
        if err != nil {
            log.Errorf("failed to write binlog: %v", err)
        }
    }()
}

值得注意的是,這裏 WriteBinlog 是單獨啓動 goroutine 異步完成的,也就是 Commit 階段,是不再需要等待寫 binlog 完成的。這裏可以節省一點 commit 的等待時間,這裏不需要等待是因爲 Pump 即使接收不到這個 Commit Binlog,在超過 timeout 時間後,Pump 會自行根據 Prewrite Binlog 到 TiKV 中確認當條事務的提交狀態。

DDL Binlog

一個 DDL 有如下幾個狀態:

const (
    JobStateNone            JobState = 0
    JobStateRunning         JobState = 1
    JobStateRollingback      JobState = 2
    JobStateRollbackDone     JobState = 3
    JobStateDone             JobState = 4
    JobStateSynced             JobState = 6
    JobStateCancelling         JobState = 7
)

這些狀態代表了一個 DDL 任務所處的狀態:

  1. JobStateNone,代表 DDL 任務還在處理隊列,TiDB 還沒有開始做這個 DDL。
  2. JobStateRunning,當 DDL Owner 開始處理這個任務的時候,會把狀態設置爲 JobStateRunning,之後 DDL 會開始變更,TiDB 的 Schema 可能會涉及多個狀態的變更,這中間不會改變 DDL job 的狀態,只會變更 Schema 的狀態。
  3. JobStateDone, 當 TiDB 完成自己所有的 Schema 狀態變更之後,會把 Job 的狀態改爲 Done。
  4. JobStateSynced,當 TiDB 每做一次 schema 狀態變更,就會需要跟集羣中的其他 TiDB 做一次同步,但是當 Job 狀態爲 JobStateDone 之後,在 TiDB 等到所有的 TiDB 節點同步之後,會將狀態修改爲 JobStateSynced
  5. JobStateCancelling,TiDB 提供語法 ADMIN CANCEL DDL JOBS job_ids 用於取消某個正在執行或者還未執行的 DDL 任務,當成功執行這個命令之後,DDL 任務的狀態會變爲 JobStateCancelling
  6. JobStateRollingback,當 DDL Owner 發現 Job 的狀態變爲 JobStateCancelling 之後,它會將 job 的狀態改變爲 JobStateRollingback,以示已經開始處理 cancel 請求。
  7. JobStateRollbackDone,在做 cancel 的過程,也會涉及 Schema 狀態的變更,也需要經歷 Schema 的同步,等到狀態回滾已經做完了,TiDB 會將 Job 的狀態設置爲 JobStateRollbackDone

對於 Binlog 而言,DDL 的 Binlog 輸出機制,跟 DML 語句也是類似的,只有開始處理事務提交階段,纔會開始寫 Binlog 出去。那麼對於 DDL 來說,跟 DML 不一樣,DML 有事務的概念,對於 DDL 來說,SQL 的事務是不影響 DDL 語句的。但是 DDL 裏面,上面提到的 Job 的狀態變更,是作爲一個事務來提交的(保證狀態一致性)。所以在每個狀態變更,都會有一個事務與之對應,但是上面提到的中間狀態,DDL 並不會往外寫 Binlog,只有 JobStateRollbackDone 以及 JobStateDone 這兩種狀態,TiDB 會認爲 DDL 語句已經完成,會對外發送 Binlog,發送之前,會把 Job 的狀態從 JobStateDone 修改爲 JobStateSynced,這次修改,也涉及一次事務提交。這塊邏輯的代碼如下:

worker.handleDDLJobQueue():

if job.IsDone() || job.IsRollbackDone() {
        binloginfo.SetDDLBinlog(d.binlogCli, txn, job.ID, job.Query)
        if !job.IsRollbackDone() {
            job.State = model.JobStateSynced
        }
        err = w.finishDDLJob(t, job)
        return errors.Trace(err)
}

type Binlog struct {
    DdlQuery []byte
    DdlJobId         int64
}

DdlQuery 會設置爲原始的 DDL 語句,DdlJobId 會設置爲 DDL 的任務 ID。

對於最後一次 Job 狀態的提交,會有兩條 Binlog 與之對應,這裏有幾種情況:

  1. 如果事務提交成功,類型分別爲 binlog.BinlogType_Prewritebinlog.BinlogType_Commit
  2. 如果事務提交失敗,類型分別爲 binlog.BinlogType_Prewritebinlog.BinlogType_Rollback

所以,Pumps 收到的 DDL Binlog,如果類型爲 binlog.BinlogType_Rollback 應該只認爲如下狀態是合法的:

  1. JobStateDone (因爲修改爲 JobStateSynced 還未成功)
  2. JobStateRollbackDone

如果類型爲 binlog.BinlogType_Commit,應該只認爲如下狀態是合法的:

  1. JobStateSynced
  2. JobStateRollbackDone

當 TiDB 在提交最後一個 Job 狀態的時候,如果事務提交失敗了,那麼 TiDB Owner 會嘗試繼續修改這個 Job,直到成功。也就是對於同一個 DdlJobId,後續還可能會有多次 Binlog,直到出現 binlog.BinlogType_Commit

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章