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

GaussDB 数据库 发表于 2022/03/11 11:55:43 2022/03/11
【摘要】 一个Bug从诞生到终结——处理Bug的全流程

任务.jpg

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

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

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

Bug描述

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

 测试语句:
select  
  subq_0.c2 as c0
from 
  (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_0
where (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 1
order by 1;
返回结果:
如下图所示,MySQL 8.0.22、MySQL8.0.26与华为云GaussDB(for MySQL)的返回结果不一致,也就是说产生了Bug,如下图红色部分。

1.png

2.jpg

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:398
398          assert(m_child_iterator != nullptr);
(gdb) n
399          m_child_iterator->SetNullRowFlag(is_null_row);
(gdb) s
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:355
355               returnget();
(gdb) fin
Run 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:355
ZeroRowsIterator::SetNullRowFlag (this=0x7f92a413d510,is_null_row=false)
  at/home/simon/mywork/mysql-sql/sql/basic_row_iterators.h:399
399          m_child_iterator->SetNullRowFlag(is_null_row);
Value returned is $1 = (RowIterator *) 0x7f92a413d4d0
(gdb) s
TableRowIterator::SetNullRowFlag (this=0x7f92a413d4d0,is_null_row=false)
   at/home/simon/mywork/mysql-sql/sql/records.cc:229
229          if(is_null_row) {
(gdb) n
232          m_table->reset_null_row();
(gdb)
234        }
从上面的gdb来看,断点处利用ZeroRowsIterator::SetNullRowFlag将表的Nullflag设置为了False。后面的gdb信息也证明了这一点。
 
可以确定,导致此Bug的原因是:ZeroRowsIterator::SetNullRowFlag设置为False这里是不正确的。因为如果把ZeroRowsIterator::SetNullRowFlag设置为False,那就会导致内表为ZeroRowsLeft Join生成内表非NULL的结果集。

如何解决

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

3.png

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

华为云数据库团队荟聚了业内50%以上的数据库内核专家,以专业技术实时保障客户业务安全,助力企业业务安全上云!
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区),文章链接,文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件至:cloudbbs@huaweicloud.com进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容。
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。