Giới thiệu về vấn đề
Trong bài viết trước, tôi đã chia sẻ về quá trình chuyển đổi dữ liệu từ Oracle sang TiDB. Khi sử dụng Lightning để import file CSV vào TiDB, tôi phát hiện một lỗi liên quan đến việc xử lý tên bảng. Cụ thể, các bảng từ Oracle có tên viết hoa toàn bộ, sau khi import theo phương pháp đã mô tả thì tên bảng vẫn giữ nguyên dạng viết hoa và quá trình đồng bộ diễn ra thuận lợi.
Tuy nhiên, khi hướng dẫn một đồng nghiệp mới làm quen với công cụ này, anh ấy gặp phải vấn đề nghiêm trọng. Dù đã thử nhiều cách phân tích và chạy lại nhiều lần mà vẫn không thành công. Sau khi kiểm tra kỹ, tôi nhận ra vấn đề nằm ở việc khác biệt chữ hoa chữ thường trong tên file - file CSV có tên viết thường hoàn toàn. Sau khi đổi tên file thành chữ hoa, quá trình import đã thành công.
Nguyên nhân là do anh đồng nghiệp sử dụng sqluldr2 để export dữ liệu bảng nhưng đã đặt tên file cố định là chữ thường.
Lưu ý: TiDB có tham số
lower-case-table-namesliên quan đến việc phân biệt chữ hoa chữ thường trong tên bảng. Tham số này chỉ có thể đặt giá trị 2, nghĩa là lưu trữ tên bảng có phân biệt chữ hoa chữ thường nhưng khi so sánh sẽ chuyển đổi thành chữ thường. Do đó, khuyến nghị sử dụng tên bảng viết thường hoàn toàn trong TiDB.Tính năng này tương tự MySQL, tuy nhiên MySQL hỗ trợ nhiều trường hợp hơn. Tham khảo thêm tại https://dev.mysql.com/doc/refman/5.7/en/identifier-case-sensitivity.html
Vậy điều gì đã xảy ra với cơ chế không phân biệt chữ hoa chữ thường của TiDB khi sử dụng Lightning?
Tái hiện lỗi
Để hiểu rõ hơn, chúng ta sẽ tái hiện lỗi theo các bước sau:
Phiên bản TiDB test được sử dụng là v5.2.2, cùng phiên bản với khi phát hiện lỗi, Lightning cũng sử dụng phiên bản tương ứng. Vấn đề này cũng có thể tái hiện trên branch master mới nhất.
Tạo bảng test với tên viết hoa toàn bộ:
use test;
CREATE TABLE LIGHTNING_BUG (f1 VARCHAR(50), f2 VARCHAR(50), f3 VARCHAR(50));
Chuẩn bị file CSV để import với tên là test.lightning_bug.csv:
111|aaa|%%%
222|bbb|###
Cấu hình đầy đủ cho 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 = ''
terminator = ""
header = false
not-null = false
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
Chạy lệnh import:
./tidb-lightning --config tidb-lightning.toml --check-requirements=false
Kết quả báo lỗi:
Trong log chỉ toàn là thông tin Info, ngoài việc không xuất hiện thông báo tidb lightning exit bình thường, không có bất kỳ error nào được hiển thị rõ ràng.
Vấn đề chính ở đây là panic không thân thiện với người dùng và thông báo lỗi không rõ ràng. Dù đề cập đến null pointer exception nhưng không có giá trị tham chiếu, lúc đầu còn bị segmentation violation làm hiểu nhầm là vấn đề về định dạng dữ liệu.
Nhận thấy đây là một bug không quá phức tạp, tôi quyết định tải mã nguồn TiDB về để phân tích.
Luồng xử lý của Lightning
File đầu vào của Lightning là br/cmd/tidb-lightning/main.go, còn các implementation chính nằm trong thư mục br/pkg/lightning.
Dựa vào thông tin stack trace từ lỗi, tôi đã truy ngược lại toàn bộ luồng import của Lightning, bắt đầu từ file restore.go dòng 1311, với đoạn code như sau:
Theo phân tích, có thể tableInfo là giá trị nil, dẫn đến lỗi null pointer khi truy cập tableInfo.Name. Nếu đúng như vậy, điều này chứng tỏ là do tên bảng không tồn tại. Tuy nhiên, khi bảng không tồn tại, thông báo lỗi thường là:
Điều này có nghĩa là ở đâu đó trước đó, hệ thống đã khớp được tên bảng viết hoa với viết thường. Tiếp tục phân tích sâu hơn.
Tại vị trí lỗi, cần chú ý đến hai đối tượng map được so sánh là rc.dbMetas và rc.dbInfos. Lỗi xảy ra do bảng trong dbMetas không tìm thấy trong dbInfos. Để hiểu rõ, chúng ta cần xem xét hai đối tượng này được sử dụng như thế nào.
Bằng cách tìm hiểu quan hệ giữa các method gọi đến method restoreTables nơi xảy ra lỗi, tôi đã xác định được luồng import chính của 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)
}
return err
}
Luồng chính bao gồm restoreSchema và restoreTables, chúng ta sẽ phân tích chi tiết hơn.
Ở layer cao hơn, trong method run của file lightning.go, chúng ta tìm thấy nguồn gốc của dbMetas:
func (l *Lightning) run(taskCtx context.Context, taskCfg *config.Config, g glue.Glue) 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)
}
Qua quá trình truy vết, phát hiện dbMetas được lấy bằng cách phân tích tên file cần import, bao gồm tên database và tên bảng. Đây là lý do tại sao file CSV cần đặt theo định dạng {dbname}.{tablename}.csv.
Lưu ý: Thực tế định dạng này có thể tùy chỉnh qua [mydumper.files], đây là định dạng mặc định.
Tiếp tục往上追溯 là method RunOnce, đây là entry point được gọi từ hàm main, nhận vào một context rỗng và thông tin cấu hình:
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)
}
Toàn bộ quá trình tương đối rõ ràng, logic xử lý chính nằm trong Restore Controller.
Theo phân tích trước đó, có vẻ như chỉ cần kiểm tra giá trị nil tại vị trí lỗi là đủ. Tuy nhiên, sau đó cần xử lý như thế nào? Đây chỉ là giải pháp tạm thời, cần phân tích sâu hơn.
Phân tích sâu về lỗi
Trước khi đi sâu vào phân tích, hãy xem xét một hiện tượng khác. Khi bỏ tham số --check-requirements=false khỏi lệnh import ban đầu, nhận được thông báo sau:
Có vẻ như bản thân Lightning có thể nhận ra sự khác biệt về chữ hoa chữ thường. Kết hợp với thông báo lỗi table schema not found đã đề cập, vấn đề trở nên khó hiểu.
Sau khi nghiên cứu kỹ mã nguồn, phát hiện Lightning có khả năng kiểm tra rất chi tiết Schema giữa upstream và downstream. Logic này được封装 trong method SchemaIsValid, chỉ được kích hoạt khi --check-requirements=true. Việc kiểm tra bao gồm tên database, tên bảng, số lượng field, file dữ liệu, header CSV... Vậy thông báo table schema not found xuất phát từ đâu?
Đề cập ở trên, dbMetas được lấy bằng cách phân tích tên file. Giờ hãy xem dbInfos được lấy như thế nào. Quay lại method restoreSchema, thấy đoạn code sau:
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
Từ đây thấy rằng, việc lấy danh sách bảng từ database đích được thực hiện thông qua Backend tương ứng. Với mode local, thực tế là gọi qua status port của TiDB (giờ đã hiểu tác dụng của port 10080 trong cấu hình):
curl http://{tidb-server}:10080/schema/test
Method makeJobs là implementation chính để tạo Schema, bao gồm 3 phần: restore database, restore cấu trúc bảng, restore view. Xem đoạn code sau:
for _, dbMeta := range dbMetas {
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 {
continue
} else if tblMeta.SchemaFile.FileMeta.Path == "" {
return errors.Errorf("table `%s`.`%s` schema not found", dbMeta.Name, tblMeta.Name)
}
}
}
Điều gây confuse ở đây là khi kiểm tra bảng có tồn tại hay không, code sử dụng toàn bộ chữ thường để so sánh, không nhất quán với method SchemaIsValid ở trước.
Tiếp theo, xem xét method LoadSchemaInfo, đây là nơi tạo ra dbInfos. Đối tượng này chứa thông tin Schema thực tế của database đích. Đoạn code quan trọng:
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
}
tableInfo := &checkpoints.TidbTableInfo{
ID: tblInfo.ID,
DB: schema.Name,
Name: tableName,
Core: tblInfo,
}
dbInfo.Tables[tableName] = tableInfo
}
result[schema.Name] = dbInfo
}
return result, nil
}
Đến đây vấn đề đã rõ ràng hơn. Trong suốt quá trình đầu đều sử dụng so sánh chữ thường, nhưng đến khi lấy tableName thì dường như đã quên mất điều này?
Kiểm tra tblInfo.Name.String() trả về gì:
type CIStr struct {
O string `json:"O"`
L string `json:"L"`
}
func (cis CIStr) String() string {
return cis.O
}
Như vậy, SchemaIsValid bị ảnh hưởng bởi LoadSchemaInfo, tạo ra ảo giác có khả năng phân biệt chữ hoa chữ thường.
Hướng khắc phục của tôi
Quá trình phân tích trên cũng cho thấy sự thay đổi trong cách tiếp cận của tôi, có hai phương án:
Phương án thứ nhất, kiểm tra giá trị nil tại vị trí lỗi và thông báo cấu trúc bảng không tồn tại. Tuy nhiên, khi gặp thông báo này thì nên tiếp tục import hay dừng toàn bộ task cần cân nhắc kỹ. Nếu còn các vấn đề tương tự thì cũng cần xử lý theo cách này.
Phương án thứ hai, chuyển toàn bộ logic sang sử dụng chữ thường để so sánh từ gốc. Cách này có hai ưu điểm: tránh các bug mới liên quan đến chữ hoa chữ thường, và phù hợp với đặc điểm tên bảng trong TiDB là không phân biệt chữ hoa chữ thường.
Tiếp theo, tôi sẽ submit PR theo phương án thứ hai để khắc phục vấn đề này.
Ngoài ra, với trường hợp ngược lại là tên bảng trong database viết thường nhưng tên file viết hoa, tôi đã test và thấy có vấn đề tương tự.
Kết luận
Khi đặt tên cho các đối tượng Schema trong TiDB, nên tạo thói quen tốt bằng cách sử dụng chữ thường nhất quán để tránh những rắc rối không đáng có.
Khi sử dụng Lightning, không nên tắt dễ dàng tham số check-requirements vì nó sẽ giúp phát hiện sớm nhiều rủi ro, điều này rất quan trọng.
Từ kinh nghiệm sử dụng các công cụ TiDB, nhiều thông báo lỗi không được thân thiện lắm, điều này khiến người dùng phải đi nhiều đường vòng. Hy vọng đội ngũ phát triển có thể chú ý tối ưu phần này.
Và cuối cùng, khi gặp lỗi đừng hoảng loạn, việc đọc và phân tích mã nguồn cũng khá thú vị.