探索TiDB Lightning的原始碼來解決發現的bug

balahoho發表於2022-03-11

背景

上一篇《記一次簡單的Oracle離線資料遷移至TiDB過程》說到在使用Lightning匯入csv檔案到TiDB的時候發現了一個bug,是這樣一個過程。

Oracle源庫中表名都是大寫,經過前文所述的方法匯入到TiDB後表名也是保持全大寫,資料同步過程非常順利。
第二天我把整套操作流程教給一位新手朋友,他就挑了一張表用來做實驗,結果死活都不行。各種分析和重試都沒有效果,就在快要懵逼的時候想到了這個大小寫問題,把csv拉出來一看是個全小寫的檔名,我嘗試著把表名改成大寫再匯入一次,這次終於成功了。
原來,是這位小夥子用sqluldr2匯出表資料的時候把檔名寫死了,而且是個小寫。。。

這裡提一下TiDB表名大小寫敏感相關的引數lower-case-table-names,這個引數只能被設定成2,也就是儲存表名的時候區分大小寫,對比的時候統一轉為小寫。因此,TiDB中的表名建議使用全小寫來命名。

這個特性基本和MySQL是一致的,只是MySQL支援更多的場景,具體可以參考https://dev.mysql.com/doc/refman/5.7/en/identifier-case-sensitivity.html

那麼,說好的TiDB表名不區分大小寫呢,怎麼用了Lightning就失效了?

Bug重現

上面說的還是有點抽象,我們通過如下的步驟重現一下。

這裡我準備的TiDB測試版本是v5.2.2,和前面發現bug的版本一致,Lightning也使用配套的版本。我拿最新的master分支也能復現這個問題。

先建立一張測試表,表名全部用大寫:

use test;

create table LIGHTNING_BUG (f1 varchar(50),f2 varchar(50),f3 varchar(50));

再準備一個待匯入的csv檔案,檔名是test.lightning_bug.csv

111|aaa|%%%
222|bbb|###

Lightning的完整配置檔案:

[lightning]
level = "info"
file = "tidb-lightning.log"
index-concurrency = 2
table-concurrency = 5
io-concurrency = 5

[tikv-importer]
backend = "local"
sorted-kv-dir = "/tmp/tidb/lightning_dir"

[mydumper]
data-source-dir = "/tmp/tidb/data"
no-schema = true
filter = ['*.*']

[mydumper.csv]
# 欄位分隔符,支援一個或多個字元,預設值為 ','。
separator = '|'
# 引用定界符,設定為空表示字串未加引號。
delimiter = ''
# 行尾定界字元,支援一個或多個字元。設定為空(預設值)表示 "\n"(換行)和 "\r\n" (回車+換行),均表示行尾。
terminator = ""
# CSV 檔案是否包含表頭。
# 如果 header = true,將跳過首行。
header = false
# CSV 檔案是否包含 NULL。
# 如果 not-null = true,CSV 所有列都不能解析為 NULL。
not-null = false
# 如果 not-null = false(即 CSV 可以包含 NULL),
# 為以下值的欄位將會被解析為 NULL。
null = '\N'
# 是否對欄位內“\“進行轉義
backslash-escape = true
# 如果有行以分隔符結尾,刪除尾部分隔符。
trim-last-separator = false

[tidb]
host = "x.x.x.x"
port = 4000
user = "root"
password = ""
status-port = 10080
pd-addr = "x.x.x.x:2379"

[checkpoint]
enable = false

[post-restore]
checksum = false
analyze = false

執行如下命令開始執行匯入任務:

./tidb-lightning --config tidb-lightning.toml --check-requirements=false

報錯資訊:

日誌裡面全部是Info,除了沒有正常輸出tidb lightning exit以外,看不到任何報錯,一幅歲月靜好的樣子:

我認為這裡的主要問題是,panic非常不友好,而且提示資訊不夠明確,雖然說了是空指標異常不過沒什麼參考價值,當時還被segmentation violation誤導了好久,一直懷疑是資料格式有問題。

我意識到這個bug應該不難,於是自己拉了一份TiDB原始碼開始定位問題。

Lightning的處理流程

Lightning的入口檔案是br/cmd/tidb-lightning/main.go,而它的核心實現都放在br/pkg/lightning目錄下。

我根據報錯的堆疊資訊倒推整個Lightning的匯入流程,首先定位到restore.go檔案第1311行,我看到如下程式碼:

根據直覺,猜測tableInfo是一個nil值,以至於在取tableInfo.Name的時候報出空指標異常。如果是這樣的話,證明是表名不存在導致,但我記得表不存在的時候它的報錯資訊是這樣:

所以說在此之前的某個地方,它一定是把大寫表名和小寫表名匹配上的,我們繼續往上翻。

在報錯的這個地方,需要重點關注兩個被對比的map物件rc.dbMetasrc.dbInfos,報錯的原因是dbMetas裡的表在dbInfos裡面找不到,那我們就分別看看這兩個物件是幹嘛用的。

通過查詢這行程式碼所在的方法restoreTables呼叫關係,發現了Lightning的主要匯入流程:

func (rc *Controller) Run(ctx context.Context) error {
	opts := []func(context.Context) error{
		rc.setGlobalVariables,
		rc.restoreSchema,
		rc.preCheckRequirements,
		rc.restoreTables,
		rc.fullCompact,
		rc.switchToNormalMode,
		rc.cleanCheckpoints,
	}
    ....
 	for i, process := range opts {
		err = process(ctx)
		....
	}
    ....
}

這裡的主要流程就是restoreSchemarestoreTables,我們一會再來細看,先繼續往上翻。

再上一層是lightning.go檔案的run方法,在這兒我們找到了那個dbMetas是怎麼來的:

func (l *Lightning) run(taskCtx context.Context, taskCfg *config.Config, g glue.Glue) (err error) {
	...
	dbMetas := mdl.GetDatabases()
	web.BroadcastInitProgress(dbMetas)

	var procedure *restore.Controller
	procedure, err = restore.NewRestoreController(ctx, dbMetas, taskCfg, s, g)
	if err != nil {
		log.L().Error("restore failed", log.ShortError(err))
		return errors.Trace(err)
	}
	defer procedure.Close()

	err = procedure.Run(ctx)
	return errors.Trace(err)
}

通過一路追蹤進去,發現dbMetas就是通過解析要匯入的檔名來獲得資料庫名稱和表名稱的,也就是說它存放著要被匯入的Schema資訊,這也是為什麼csv檔案要按照{dbname}.{tablename}.csv來命名的原因

Tips:其實這個格式是可以通過[mydumper.files]自定義的,上面這種是預設格式。

再往上的話就是RunOnce方法,這是main函式的呼叫入口,它傳入了一個空的上下文物件,以及配置檔案資訊:

/// br > pkg > lightning > lightning.go
func (l *Lightning) RunOnce(taskCtx context.Context, taskCfg *config.Config, glue glue.Glue) error {
	if err := taskCfg.Adjust(taskCtx); err != nil {
		return err
	}

	taskCfg.TaskID = time.Now().UnixNano()
	...
	return l.run(taskCtx, taskCfg, glue)
}

/// br > cmd > tidb-lightning > main.go
func main() {
    globalCfg := config.Must(config.LoadGlobalConfig(os.Args[1:], nil))
	....
	err = func() error {
		if globalCfg.App.ServerMode {
			return app.RunServer()
		}
		cfg := config.NewConfig()
		if err := cfg.LoadFromGlobal(globalCfg); err != nil {
			return err
		}
		return app.RunOnce(context.Background(), cfg, nil)
	}()
	....
}

整個過程還是比較清晰的,核心處理邏輯都放在Restore Controller裡面。

按照前面的分析,似乎只要在報錯的地方判斷一下 nil就行了,但判斷之後我該做如何處理呢?感覺只是治標不治本,還需要進一步分析下。

對Bug的思考

深度分析之前再看一個現象,我把最開始的匯入命令去掉--check-requirements=false引數,看到如下提示:

貌似lightning本身是能識別到大小寫的差異呀(看到這裡我一度認為修復方法是提示表不存在),再結合之前提到的table schema not found報錯,我覺得事情有點詭異。

深扒原始碼發現,Lightning是能夠對上下游Schema做非常細緻的檢查,這部分邏輯被封裝在SchemaIsValid方法中,只有在--check-requirements=true的時候才會啟用,這裡的檢查包括庫表名稱、欄位數量、資料檔案、csv表頭等等。那table schema not found又是怎麼回事?

前面提到dbMetas是通過解析檔名獲取,我們再看看dbInfos是如何獲取的。回到之前提到的restoreSchema方法,我看到如下程式碼:

	getTableFunc := rc.backend.FetchRemoteTableModels
	....
	err := worker.makeJobs(rc.dbMetas, getTableFunc)
	....
	dbInfos, err := LoadSchemaInfo(ctx, rc.dbMetas, getTableFunc)
	if err != nil {
		return errors.Trace(err)
	}
	rc.dbInfos = dbInfos
	....

從這裡可以看到,獲取目標庫的表清單是通過各自Backend提供的遠端方式讀取的,對於local模式而言,實際就是呼叫TiDB的狀態埠去獲取(現在知道配置檔案中10080的作用了吧):

curl http://{tidb-server}:10080/schema/test

makeJobs方法是建立Schema的核心實現,主要包括恢復資料庫、恢復表結構、恢復檢視3部分。看如下一部分程式碼;

	// 2. restore tables, execute statements concurrency
	for _, dbMeta := range dbMetas {
		// we can ignore error here, and let check failed later if schema not match
		tables, _ := getTables(worker.ctx, dbMeta.Name)
		tableMap := make(map[string]struct{})
		for _, t := range tables {
			tableMap[t.Name.L] = struct{}{}
		}
		for _, tblMeta := range dbMeta.Tables {
			if _, ok := tableMap[strings.ToLower(tblMeta.Name)]; ok {
				// we already has this table in TiDB.
				// we should skip ddl job and let SchemaValid check.
				continue
			} else if tblMeta.SchemaFile.FileMeta.Path == "" {
				return errors.Errorf("table `%s`.`%s` schema not found", dbMeta.Name, tblMeta.Name)
			}
            ...
        }
        ...

這裡很讓人迷惑,它檢查表是否存在的時候是用全小寫去判斷的,和前面的SchemaIsValid方法不一致,我又認為修復方法應該是轉為全小寫判斷了。。。

我們再來看LoadSchemaInfo方法,從程式碼來看它就是產生dbInfos的地方,而這個物件存放的是目標庫的實際Schema資訊,下面這段程式碼是重頭戲:

func LoadSchemaInfo(
	ctx context.Context,
	schemas []*mydump.MDDatabaseMeta,
	getTables func(context.Context, string) ([]*model.TableInfo, error),
) (map[string]*checkpoints.TidbDBInfo, error) {
	result := make(map[string]*checkpoints.TidbDBInfo, len(schemas))
	for _, schema := range schemas {
		tables, err := getTables(ctx, schema.Name)
		if err != nil {
			return nil, err
		}

		tableMap := make(map[string]*model.TableInfo, len(tables))
		for _, tbl := range tables {
			tableMap[tbl.Name.L] = tbl
		}

		dbInfo := &checkpoints.TidbDBInfo{
			Name:   schema.Name,
			Tables: make(map[string]*checkpoints.TidbTableInfo),
		}

		for _, tbl := range schema.Tables {
			tblInfo, ok := tableMap[strings.ToLower(tbl.Name)]
			if !ok {
				return nil, errors.Errorf("table '%s' schema not found", tbl.Name)
			}
			tableName := tblInfo.Name.String()
			if tblInfo.State != model.StatePublic {
				err := errors.Errorf("table [%s.%s] state is not public", schema.Name, tableName)
				metric.RecordTableCount(metric.TableStatePending, err)
				return nil, err
			}
			metric.RecordTableCount(metric.TableStatePending, err)
			if err != nil {
				return nil, errors.Trace(err)
			}
			tableInfo := &checkpoints.TidbTableInfo{
				ID:   tblInfo.ID,
				DB:   schema.Name,
				Name: tableName,
				Core: tblInfo,
			}
			dbInfo.Tables[tableName] = tableInfo
		}

		result[schema.Name] = dbInfo
	}
	return result, nil
}

看到這裡好像真相大白了,前半部分都一直用小寫匹配,到取tableName的時候貌似忘了這個事???

最後看看tblInfo.Name.String()返回的是啥:

// CIStr is case insensitive string.
type CIStr struct {
	O string `json:"O"` // Original string.
	L string `json:"L"` // Lower case string.
}

// String implements fmt.Stringer interface.
func (cis CIStr) String() string {
	return cis.O
}

這樣來看,SchemaIsValid其實是受到了LoadSchemaInfo的影響,給人一種能夠區分大小寫的假象。

我的修復思路

上面的分析過程也提到了我的修復思路的變化,彙總有以下兩種辦法:

第一種,在報錯的地方做nil值判斷提示表結構不存在,但是碰到這個提示後是繼續匯入還是整個任務退出需要深度考慮一下,如果還有類似的問題是不是也這樣去修復。

第二種,整個邏輯全部轉為全小寫去判斷,從根源上解決問題,這樣的話我覺得有兩個好處,一個是避免大小寫引發新的bug,二是TiDB的表名本身就是不區分大小寫。

接下來,我會按第二種方式提交PR嘗試修復這個問題。

不過,針對這個bug我又想起了另一種情況,就是資料庫表名是小寫檔名是大寫,我測試了會有相同的問題。

總結

在TiDB中給Schema物件命名的時候養成好習慣,統一使用小寫,避免引起不必要的麻煩。

在使用Lightning的時候,不要輕易關閉check-requirements,它會幫你提前預判很多風險,這點還是很重要的。

從一些TiDB工具的使用經驗上來看,它們的很多異常提示並不是很友好,這樣會讓使用者多走彎路,希望官方能關注下這塊的優化。

還有就是,碰到報錯不要慌(實際上在客戶現場的時候慌的一批?),啃一啃原始碼也挺有意思的。

相關文章