暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

TiDB | csv文档导入,到底需要注意表名大小写问题吗?

740





TiDB

神州数码云基地

在 TiDB 上的尝试、调研与分享




本期内容 

 Lightning源码解决Bug 



前不久在使用Lightning导入csv文件到TiDB的时候发现了一个bug,是这样一个过程:


Oracle源库中表名都是大写,导入到TiDB后表名也是保持全大写,数据同步过程非常顺利。


第二天,一位新手朋友挑了一张表用来做实验,结果各种分析和重试都没有效果,后来把csv拉出来一看是个全小写的文件名,我尝试着把表名改成大写再导入一次,这次终于成功了。


原来,是这位小伙子用sqluldr2导出表数据的时候把文件名写死了,而且是个小写。。。


那么,说好的TiDB表名不区分大小写呢,怎么用了Lightning就失效了?本篇我们来看看如何通过Lightning源码来解决这个Bug!




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的表名本身就是不区分大小写。






                          写在最后  



                          • 在TiDB中给Schema对象命名的时候养成好习惯,统一使用小写,避免引起不必要的麻烦。


                          • 在使用Lightning的时候,不要轻易关闭check-requirements,它会帮你提前预判很多风险,这点还是很重要的。


                          • 从一些TiDB工具的使用经验上来看,它们的很多异常提示并不是很友好,这样会让用户多走弯路,希望官方能关注下这块的优化。





                          本期内容就到这里啦

                          有更好的办法或疑问请

                          ⬇欢迎加入社群一起讨论哦⬇

                          本期作者 

                          高级后端开发工程师 何傲 



                          更多精彩内容 





                          了解云基地,就现在!


                          IT技术哪家

                          神州数码最在行

                          行业新星后起之秀

                          历史虽不长,但实 力 强




                          文章转载自神州数码云基地,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                          评论