写点什么

如何从头到脚彻底解决一个 MySQL Bug?华为云数据库高级专家带你看

  • 2022 年 3 月 11 日
  • 本文字数:4120 字

    阅读完需:约 14 分钟


说明:本文中的 MySQL,如果不做特殊说明,指的是开源社区版 MySQL。

华为云数据库新版本在发布之前,会面临一系列严苛的测试规则,除了要求通过 MySQL 的所有测试用例之外,还需要通过由华为百万级更丰富、更贴近用户业务场景的测试用例构筑的测试防护网,以此充分验证新版本是否满足用户经典场景的稳定性。

 

正是在这样严苛的验证过程中,我们发现了 MySQL 的一个潜在 Bug。

Bug 描述

测试环境:

基于相同的测试用例、数据集,分别测试 MySQL 8.0.22, MySQL 8.0.26,与华为云 GaussDB(for MySQL)的返回结果。

 测试语句:

select    subq_0.c2 as c0from   (select          ref_6.C_STATE asc0,         case whenref_6.C_PHONE is not NULL then ref_5.C_ID else ref_5.C_ID end           asc1,         floor(          ref_3.c_id)as c2      from        sqltester.t0_hash_partition_p1_view as ref_0             right join sqltester.t4 as ref_1             on (EXISTS (                 select                       ref_1.c_middle as c0                   from                      sqltester.t1 as ref_2                   where ((false)                        and ((true)                          or (true)))                      or (false)                   ))            innerjoin sqltester.t0_range_key_subpartition_sub_view as ref_3            on(EXISTS (               select                     ref_0.c_credit as c0,                    ref_1.c_street_1 as c1,                    ref_4.c_credit_lim as c2,                    ref_3.c_credit as c3                 from                    sqltester.t0_hash_partition_p1 as ref_4                 where true                 ))          left joinsqltester.t10 as ref_5            innerjoin sqltester.t11 as ref_6            on(true)          on (((pi() isnot NULL))              and (false))      where (((ref_5.C_D_ID isnot NULL)             or(ref_3.c_middle is not NULL))       )) as subq_0where (EXISTS (          select               subq_0.c0 as c0,              pi() as c1,              ref_11.c_street_1 as c2,              ref_11.c_discount as c3,              pi() as c4           from              sqltester.t0_partition_sub_view_mixed_001 as ref_11))group by 1order by 1;
复制代码

返回结果:

如下图所示,MySQL 8.0.22、MySQL8.0.26 与华为云 GaussDB(for MySQL)的返回结果不一致,也就是说产生了 Bug,如下图红色部分。


Bug 分析

首先确定哪一个执行结果是正确的。当前这个语句执行的 execution plan 是 Hash Join,而 MySQL8.0 里面引入了 Hash Join,由此推论开源版本可能存在问题。接下来我们从 MySQL 成熟版本以及非 MySQL 数据库两个方面来进行验证。

验证过程:

  • 使用相对成熟的版本 MySQL 5.6 进行验证,返回结果与 GaussDB(for MySQL)相同,但与 MySQL 8.0 不同。

  • 使用 PostgreSQL 进行验证,执行结果与 MySQL 5.6、GaussDB(for MySQL)相同,但与 MySQL 8.0 及更高版本不同。

由此可以确定:MySQL 8.0 以及更高版本存在问题。

那么,是什么原因引起了这一 Bug 呢?

1.  首先精简查询,以方便后面分析。经过多次验证,将查询简化如下:

SELECT count(*)FROM  (SELECT 1   FROM sqltester.t4 AS ref_1   INNER JOIN sqltester.t4 AS ref_3 ON (EXISTS                                         (SELECT 1                                           FROMsqltester.t4 AS ref_4                                           WHERE TRUE ))   LEFT JOIN sqltester.t10 AS ref_5 ON (FALSE)   WHERE (((ref_5.C_D_ID IS NOT NULL)           OR (ref_3.c_middle IS NOT NULL))))AS subq_0 执行计划如下:-> Aggregate: count(0) (cost=2.75 rows=0)   -> Filter: ((ref_5.C_D_ID is not null) or(ref_3.c_middle is null)) (cost=2.75 rows=0)       -> Inner hash join(no condition) (cost=2.75 rows=0)          -> Index scan on ref_3 using ndx_c_middle (cost=0.13 rows=50)          -> Hash              -> Inner hash join (no condition) (cost=1.50 rows=0)                  -> Index scan on ref_1 using ndx_c_id (cost=6.25 rows=50)                  -> Hash                      -> Left hash join (no condition) (cost=0.25 rows=0)                          -> Limit: 1 row(s) (cost=312.50 rows=1)                             ->Index scan on ref_4 using ndx_c_id (cost=312.50 rows=50)                          -> Hash                              -> Zero rows (Impossible filter) (cost=0.00..0.00 rows=0)
复制代码

从上面的执行计划可以看出,ref_5 被优化器进行了优化,转换成了 Zero rows,而且 ref_5 是 Left Hash Join 的内表。作为 Left Join 的内表,如果内表没有匹配条件的记录(这里已经是 Impossible 条件了,也就是说连接条件始终是 False),则需要内表生成 NULL 行来和外表进行外表连接。

 

2.  在 MySQL 8.0.22 版本上执行问题查询,语句和执行结果如下:

SELECT count(*)FROM  (SELECT 1   FROM sqltester.t4 AS ref_1   INNER JOIN sqltester.t4 AS ref_3 ON (EXISTS                                          (SELECT 1                                           FROM sqltester.t4 AS ref_4                                           WHERE TRUE ))   LEFT JOIN sqltester.t10 AS ref_5 ON (FALSE)   WHERE (((ref_5.C_D_ID IS NOT NULL) or(ref_3.c_middle IS NOT NULL))))AS subq_0;++| count(*) |++|     2500 |++1 row in set (0.00 sec)
复制代码

3.  对问题查询进行修改:去掉 Where 条件里面的另外一个条件(ref_3.c_middleis NULL)

现在 Where 条件只包含了(ref_5.C_D_IDIS NOT NULL)一个条件,要求当前查询过滤掉所有 ref_5 没有匹配的连接记录。

 

则 SQL 语句和执行结果如下:

SELECT count(*)FROM  (SELECT 1   FROM sqltester.t4 AS ref_1   INNER JOIN sqltester.t4 AS ref_3 ON (EXISTS                                          (SELECT 1                                           FROM sqltester.t4 AS ref_4                                           WHERE TRUE ))   LEFT JOIN sqltester.t10 AS ref_5 ON (FALSE)   WHERE (((ref_5.C_D_ID IS NOT NULL))))assubq_0;++| count(*) |++|     2500 |++1 row in set (0.01 sec)
复制代码

对比修改前后的语句和执行结果可以看出:执行结果与条件(ref_3.c_middle is NULL)没有关系,只与(ref_5.C_D_ID IS NOT NULL)这个条件有关。正常情况下对 ref_5 表来说,因为是 Impossible 条件,所以 ref_5 被优化成了 Zero rows。那么如果只剩(ref_5.C_D_ID IS NOT NULL)这个条件,正常的结果应该是空集(count 返回 0)。但现在开源版本的结果集却不是,这再次说明了开源版本出现了问题。

 

对于 Left Join 来说,如果 Join 条件不匹配,内表需要设置为 NULL 行来连接外表。而这里执行计划使用的是 Zero rows,也就是说 MySQL 8.0 使用的是 ZeroRowsIterator 来执行的。执行器需要调用 ZeroRowsIterator::SetNullRowFlag 来设置 Nullflag。

 

4.  通过 gdb 来查看设置是否正确:

Breakpoint 1, ZeroRowsIterator::SetNullRowFlag(this=0x7f92a413d510, is_null_row=false)   at /mywork/mysql-sql/sql/basic_row_iterators.h:398398          assert(m_child_iterator != nullptr);(gdb) n399          m_child_iterator->SetNullRowFlag(is_null_row);(gdb) sstd::unique_ptr<RowIterator,Destroy_only<RowIterator> >::operator-> (this=0x7f92a413d520)   at/opt/simon/taurus/mysql-root/src/tools/gcc-9.3.0/include/c++/9.3.0/bits/unique_ptr.h:355355               returnget();(gdb) finRun till exit from #0 std::unique_ptr<RowIterator,Destroy_only<RowIterator> >::operator-> (   this=0x7f92a413d520)   at/opt/simon/taurus/mysql-root/src/tools/gcc-9.3.0/include/c++/9.3.0/bits/unique_ptr.h:355ZeroRowsIterator::SetNullRowFlag (this=0x7f92a413d510,is_null_row=false)  at/home/simon/mywork/mysql-sql/sql/basic_row_iterators.h:399399          m_child_iterator->SetNullRowFlag(is_null_row);Value returned is $1 = (RowIterator *) 0x7f92a413d4d0(gdb) sTableRowIterator::SetNullRowFlag (this=0x7f92a413d4d0,is_null_row=false)   at/home/simon/mywork/mysql-sql/sql/records.cc:229229          if(is_null_row) {(gdb) n232          m_table->reset_null_row();(gdb)234        }
复制代码

从上面的 gdb 来看,断点处利用 ZeroRowsIterator::SetNullRowFlag 将表的 Nullflag 设置为了 False。后面的 gdb 信息也证明了这一点。

 

可以确定,导致此 Bug 的原因是:ZeroRowsIterator::SetNullRowFlag 设置为 False 这里是不正确的。因为如果把 ZeroRowsIterator::SetNullRowFlag 设置为 False,那就会导致内表为 ZeroRows 的 Left Join 生成内表非 NULL 的结果集。

如何解决

既然上面的 Bug 分析已经非常清楚了,那么修复起来也就比较简单了。只需要将 ZeroRowsIterator::SetNullRowFlag 始终设置为 True 就可以了。因为 ZeroRowIterator 只能产生两种结果,一种是空集,另一种就是作为外连接的内表产生 NULL 行。

对 MySQL-8.0.26 进行修复后,执行结果如下:



从返回的结果可以看出查询结果正确,也就是说问题得到了修复。

 

为了保障华为云 GaussDB 产品的可靠性,每一款产品发布前都要通过多轮严苛的测试用例。在发现问题后,华为云数据库团队以缜密的思路去逐步确定问题、分析问题,并第一时间修复 Bug,解决问题,以确保客户的数据安全和业务结果的准确性。


华为云数据库团队荟聚了业内 50%以上的数据库内核专家,以专业技术实时保障客户业务安全,助力企业业务安全上云!

发布于: 刚刚阅读数: 2
用户头像

提供华为云数据库最新鲜热门技术干货 2021.06.24 加入

汇聚华为云数据库社区热门技术干货,跟踪最新数据库新闻动态,提供问题交流平台,互动共同成长! 传送门:https://developer.huaweicloud.com/techfield/db.html

评论

发布
暂无评论
如何从头到脚彻底解决一个MySQL Bug?华为云数据库高级专家带你看_bug_华为云数据库小助手_InfoQ写作平台