带着问题读 TiDB 源码:Power BI Desktop 以 MySQL 驱动连接 TiDB 报错

张翔 产品技术解读 2021-12-01

常有人说,阅读源码是每个优秀开发工程师的必经之路,但是在面对像类似 TiDB 这样复杂的系统时,源码阅读是一个非常庞大的工程。而对一些 TiDB User 来说,从自己日常遇到的问题出发,反过来阅读源码就是一个不错的切入点,因此我们策划了《带着问题读源码》系列文章。

本文为该系列的第二篇,从一个 Power BI Desktop 在 TiDB 上表现异常的问题为例,介绍从问题的发现、定位,到通过开源社区提 issue、写 PR 解决问题的流程,从代码实现的角度来做 trouble shooting,希望能够帮助大家更好地了解 TiDB 源码。

首先我们重现一下失败的场景(TiDB 5.1.1 on MacOS),建一个简单的只有一个字段的表:

CREATETABLEtest(nameVARCHAR(1)PRIMARY KEY);

MySQL 上可以 TiDB 上就不可以,报错

DataSource.Error: An error happened while reading data from the provider: 'Failed to enable constraints. One or more rows contain values violating non-null, unique, or foreign-key constraints.' Details: DataSourceKind=MySql DataSourcePath=localhost:4000;test

看 general log TiDB 上最后一条跑的 SQL 是:

selectCOLUMN_NAME, ORDINAL_POSITION, IS_NULLABLE, DATA_TYPE,casewhenNUMERIC_PRECISIONisnullthennullwhenDATA_TYPEin('FLOAT','DOUBLE')then2else10endASNUMERIC_PRECISION_RADIX, NUMERIC_PRECISION, NUMERIC_SCALE, CHARACTER_MAXIMUM_LENGTH, COLUMN_DEFAULT, COLUMN_COMMENTASDESCRIPTION, COLUMN_TYPEfromINFORMATION_SCHEMA.COLUMNSwheretable_schema ='test'andtable_name='test';

我们用 tiup 启动一个 TiDB 集群,使用 tiup client 执行该命令,tiup client 也会报错:

error: mysql: sql: Scan error on column index 4, name "NUMERIC_PRECISION_RADIX": converting NULL to int64 is unsupported

那我们的注意力就集中在解决这条语句的问题,我们先看 tiup client 上报的这个错意味着什么。tiup client 使用的是 golangxo/usql库,但是在xo/usql库中,我们并不能找到对应的报错信息,grep converting 关键字返回极有限且无关的内容。我们再看xo/usql的 mysql driver,其中又引用到了go-sql-driver/mysql,下载它的代码并 grep converting,只返回了 changelog 中的一条信息,大概率报错的地方也不在这个库中。浏览一下go-sql-driver/mysql中的代码,发现它依赖于database/sql,那我们看看database/sql的内容。database/sql是 golang 的标准库,所以我们需要下载 golang 的源码。在 golang 的 database 目录中 grep converting,很快就找到了与报错信息相符的内容:

go/src/database/sql/convert.go

case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: if src == nil { return fmt.Errorf("converting NULL to %s is unsupported", dv.Kind()) } s :=asString(src) i64, err := strconv.ParseInt(s,10, dv.Type().Bits()) if err != nil { err =strconvErr(err) return fmt.Errorf("converting driver.Value type %T (%q) to a %s: %v", src, s, dv.Kind(), err) } dv.SetInt(i64) return nil

我们再追踪这个片段,看这里的类型是如何来的,最终我们会回到 go-sql-driver/mysql 中:

mysql/fields.go

case fieldTypeLongLong:ifmf.flags&flagNotNULL !=0{ifmf.flags&flagUnsigned !=0{returnscanTypeUint64 }returnscanTypeInt64 }returnscanTypeNullInt

这部分的代码是在解析语句返回体中的column definition,转换成 golang 中的类型。我们可以使用mysql --host 127.0.0.1 --port 4000 -u root --column-type-info连上后查看有问题的 SQL 返回的 column metadata:

MySQL

Field5: `NUMERIC_PRECISION_RADIX`Catalog:`def`Database:' 'Table:' 'Org_table:' 'Type:龙龙的Collation:binary(63)Length:3Max_length:0Decimals:0Flags:BINARYNUM

TiDB

Field5: `NUMERIC_PRECISION_RADIX`Catalog:`def`Database:' 'Table:' 'Org_table:' 'Type:龙龙的Collation:binary(63)Length:2Max_length:0Decimals:0Flags:NOT_NULLBINARYNUM

可以很明显的看到,tiup client 报错信息中的NUMERIC_PRECISION_RADIX字段的 column definition 在 TiDB 上有明显的问题,该字段在 TiDB 的返回体中被标记为了 NOT_NULL,很明显这是不合理的,因为该字段显然可以是NULL,MySQL 的返回值也体现了这一点。所以xo/usql在处理返回体的时候报错了。到了这里,我们已经发现了 client 端为什么会报错,下面我们就需要去寻找 TiDB 为什么会返回一个错误的 column definition。

通过 TiDB Dev Guide 我们可以知道 TiDB 中一条 DQL 语句的大体执行过程,我们从入口的server/conn.go#clientConn.Run往下看去,一路经过server/conn.go#clientConn.dispatchserver/conn.go#clientConn.handleQueryserver/conn.go#clientConn.handleStmtserver/driver_tidb.go#TiDBContext.ExecuteStmtsession/session.go#session.ExecuteStmt遗嘱执行人/ compiler.go #编译器.Compileplanner/optimize.go#Optimizeplanner/optimize.go#optimizeplanner/core/planbuilder.go#PlanBuilder.Buildplanner/core/logical_plan_builder.go#PlanBuilder.buildSelect,在buildSelect中,我们可以看到 TiDB planner 对查询语句进行的一系列处理,然后我们就可以走到planner/core/expression_rewriter.go#PlanBuilder.rewriteWithPreprocessplanner/core/expression_rewriter.go#PlanBuilder.rewriteExprNode,在rewriteExprNode中,会把有问题的字段NUMERIC_PRECISION_RADIX进行解析,最终这条CASE表达式的解析会在expression/builtin_control.go#caseWhenFunctionClass.getFunction中,我们终于走到了计算 CASE 表达式返回的 column definition 的地方(这依赖于遍历 compiler 解析出的 AST):

for i :=1; i < l; i +=2{fieldTps= append(fieldTps, args[i].GetType())decimal= mathutil.Max(decimal, args[i].GetType().Decimal)ifargs[i].GetType().Flen== -1{flen= -1}elseifflen != -1{flen= mathutil.Max(flen, args[i].GetType().Flen) }isBinaryStr= isBinaryStr || types.IsBinaryStr(args[i].GetType())isBinaryFlag= isBinaryFlag || !types.IsNonBinaryStr(args[i].GetType()) }ifl%2==1{fieldTps= append(fieldTps, args[l-1].GetType())decimal= mathutil.Max(decimal, args[l-1].GetType().Decimal)ifargs[l-1].GetType().Flen== -1{flen= -1}elseifflen != -1{flen= mathutil.Max(flen, args[l-1].GetType().Flen) }isBinaryStr= isBinaryStr || types.IsBinaryStr(args[l-1].GetType())isBinaryFlag= isBinaryFlag || !types.IsNonBinaryStr(args[l-1].GetType()) } fieldTp := types.AggFieldType(fieldTps) // Here we turn off NotNullFlag. Becauseifall when-clauses arefalse, // the result of case-when expr is NULL. types.SetTypeFlag(&fieldTp.Flag, mysql.NotNullFlag,false) tp := fieldTp.EvalType()iftp== types.ETInt {decimal=0} fieldTp.Decimal, fieldTp.Flen= decimal, fleniffieldTp.EvalType().IsStringKind() && !isBinaryStr { fieldTp.Charset, fieldTp.Collate= DeriveCollationFromExprs(ctx, args...)iffieldTp.Charset== charset.CharsetBin && fieldTp.Collate== charset.CollationBin { // When args are JsonandNumerical type(eg. Int), the fieldTp is String. // Both their charset/collation is binary, but the String need a default charset/collation. fieldTp.Charset, fieldTp.Collate= charset.GetDefaultCharsetAndCollate() } }else{ fieldTp.Charset, fieldTp.Collate= charset.CharsetBin, charset.CollationBin }ifisBinaryFlag { fieldTp.Flag |= mysql.BinaryFlag } // Set retType to BINARY(0)ifall arguments are of type NULL.iffieldTp.Tp== mysql.TypeNull { fieldTp.Flen, fieldTp.Decimal=0, types.UnspecifiedLength types.SetBinChsClnFlag(fieldTp) }

查看如上计算 column definition flag 的代码我们可以发现,无论CASE表达式的情况是怎么样的,NOT_NULL标记位都一定会被设置成false,所以问题不出现在这里!这个时候我们只能沿着上面的代码路径往回看,看看上面生成的 column definition 在后续有没有被修改。终于在server/conn.go#clientConn.handleStmt中,发现它调用了server/conn.go#clientConn.writeResultSet,然后又陆续调用了server/conn.go#clientConn.writeChunksserver/conn.go#clientConn.writeColumnInfoserver/column.go#ColumnInfo.Dumpserver/column.go#dumpFlag,在 dumpFlag 中,之前生成的column definition flag被修改了:

funcdumpFlag(tpbyte, flaguint16)uint16{switchtp {casemysql.TypeSet:returnflag |uint16(mysql.SetFlag)casemysql.TypeEnum:returnflag |uint16(mysql.EnumFlag)default:ifmysql.HasBinaryFlag(uint(flag)) {returnflag |uint16(mysql.NotNullFlag) }returnflag } }

终于,我们找到了 TiDB 返回错误的 column definition 的原因!其实这个 bug 在 TiDB 最新版5.2.0中已经被修复了:*: fix some problems related to notNullFlag by wjhuang2016 · Pull Request #27697 · pingcap/tidb

最后,在上述阅读代码的过程中,我们其实最好能够看到被 TiDB 解析后的 AST 是什么样子的,这样在最后遍历 AST 的过程中,才不至于摸瞎。TiDB dev guide 中有parser 章节讲解如何调试 parser,parser/quickstart.md at master · pingcap/parser中也有样例输出生成的 AST,但是简单地输出基本没有任何作用,我们可以使用davecgh/go-spew直接输出 parser 生成的 node,这样就能获得一个可被人理解的 tree:

packagemainimport("fmt""github.com/pingcap/parser""github.com/pingcap/parser/ast"_"github.com/pingcap/parser/test_driver""github.com/davecgh/go-spew/spew")funcparse(sqlstring)(*ast.StmtNode, error){ p := parser.New() stmtNodes, _, err := p.Parse(sql,"","")iferr !=nil{returnnil, err }return&stmtNodes[0],nil}funcmain(){ spew.Config.Indent =" "astNode, err := parse("SELECT a, b FROM t")iferr !=nil{ fmt.Printf("parse error: %v\n", err.Error())return} fmt.Printf(“% s \ n”, spew.Sdump(*astNode)) }
(*ast.SelectStmt)(0x140001dac30)({ dmlNode: (ast.dmlNode) { stmtNode: (ast.stmtNode) { node: (ast.node) { text: (string) (len=18)"SELECT a, b FROM t"} } }, resultSetNode: (ast.resultSetNode) { resultFields: ([]*ast.ResultField)  }, SelectStmtOpts: (*ast.SelectStmtOpts)(0x14000115bc0)({ Distinct: (bool)false, SQLBigResult: (bool)false, SQLBufferResult: (bool)false, SQLCache: (bool)true, SQLSmallResult: (bool)false, CalcFoundRows: (bool)false, StraightJoin: (bool)false, Priority: (mysql.PriorityEnum)0, TableHints: ([]*ast.TableOptimizerHint)  }), Distinct: (bool)false, From: (*ast.TableRefsClause)(0x140001223c0)({ node: (ast.node) { text: (string)""}, TableRefs: (*ast.Join)(0x14000254100)({ node: (ast.node) { text: (string)""}, resultSetNode: (ast.resultSetNode) { resultFields: ([]*ast.ResultField)  }, Left: (*ast.TableSource)(0x14000156480)({ node: (ast.node) { text: (string)""}, Source: (*ast.TableName)(0x1400013a370)({ node: (ast.node) { text: (string)""}, resultSetNode: (ast.resultSetNode) { resultFields: ([]*ast.ResultField)  }, Schema: (model.CIStr) , Name: (model.CIStr) t, DBInfo: (*model.DBInfo)(), TableInfo: (*model.TableInfo)(), IndexHints: ([]*ast.IndexHint) , PartitionNames: ([]model.CIStr) { } }), AsName: (model.CIStr) }), Right: (ast.ResultSetNode) , Tp: (ast.JoinType)0, On: (*ast.OnCondition)(), Using: ([]*ast.ColumnName) , NaturalJoin: (bool)false, StraightJoin: (bool)false}) }), Where: (ast.ExprNode) , Fields: (*ast.FieldList)(0x14000115bf0)({ node: (ast.node) { text: (string)""}, Fields: ([]*ast.SelectField) (len=2cap=2) { (*ast.SelectField)(0x140001367e0)({ node: (ast.node) { text: (string) (len=1)"a"}, Offset: (int)7, WildCard: (*ast.WildCardField)(), Expr: (*ast.ColumnNameExpr)(0x14000254000)({ exprNode: (ast.exprNode) { node: (ast.node) { text: (string)""}, Type: (types.FieldType) unspecified, flag: (uint64)8}, Name: (*ast.ColumnName)(0x1400017dc70)(a), Refer: (*ast.ResultField)() }), AsName: (model.CIStr) , Auxiliary: (bool)false}), (*ast.SelectField)(0x14000136840)({ node: (ast.node) { text: (string) (len=1)"b"}, Offset: (int)10, WildCard: (*ast.WildCardField)(), Expr: (*ast.ColumnNameExpr)(0x14000254080)({ exprNode: (ast.exprNode) { node: (ast.node) { text: (string)""}, Type: (types.FieldType) unspecified, flag: (uint64)8}, Name: (*ast.ColumnName)(0x1400017dce0)(b), Refer: (*ast.ResultField)() }), AsName: (model.CIStr) , Auxiliary: (bool)false}) } }), GroupBy: (*ast.GroupByClause)(), Having: (*ast.HavingClause)(), WindowSpecs: ([]ast.WindowSpec) , OrderBy: (*ast.OrderByClause)(), Limit: (*ast.Limit)(), LockTp: (ast.SelectLockType) none, TableHints: ([]*ast.TableOptimizerHint) , IsAfterUnionDistinct: (bool)false, IsInBraces: (bool)false, QueryBlockOffset: (int)0, SelectIntoOpt: (*ast.SelectIntoOption)() })

点击查看更多带着问题读 TiDB 源码系列文章

目录