4

如何从头到脚彻底解决一个MySQL Bug

 3 years ago
source link: https://juejin.cn/post/7073667235809607717
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

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

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

3.jpg

Bug描述

测试环境:

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

测试语句:

select  
   subq_0.c2 as c0
 from 
   (select  
         ref_6.C_STATE as c0, 
         case when ref_6.C_PHONE is not NULL then ref_5.C_ID else ref_5.C_ID end
            as c1, 
         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)
                     ))
             inner join 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 join sqltester.t10 as ref_5
             inner join sqltester.t11 as ref_6
             on (true)
           on (((pi() is not NULL)) 
               and (false))
       where (((ref_5.C_D_ID is not 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、MySQL 8.0.26与华为云GaussDB(for MySQL)的返回结果不一致,也就是说产生了Bug,如下图红色部分。

2.jpg

3.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

                                           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

 

执行计划如下:

-> 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_middle is NULL)

现在Where条件只包含了(ref_5.C_D_ID IS 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来设置Null flag。

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                return get();

(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将表的Null flag设置为了False。后面的gdb信息也证明了这一点。

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

如何解决

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

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

4.png

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

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

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

华为云开年采购季盛大开幕!点击链接,快来0门槛抽奖!

activity.huaweicloud.com/dbs_Promoti…

点击关注,第一时间了解华为云新鲜技术~


Recommend

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK