前言
MySQL数据库被诟病的一个地方就是子查询。很多开发人员和DBA认为MySQL数据库只是拥有该项功能,但性能很差,是一项很不实用的功能。在实际开发中开发人员很少使用子查询,因为在应用程序中使用子查询后,SQL语句的查询性能变得非常糟糕。本章介绍子查询的使用方法,同时介绍为什么有些子查询的效率令人难以接受,并对子查询的优化给出一些方法和建议。最后,介绍生产环境中子查询的一些应用,如分区、行号计算、缺失范围、连续范围等。
4.1 子查询概述
4.1.1 子查询的优点和限制
子查询是指在一个SELECT语句中嵌套另一个SELECT语句。MySQL数据库从4.1版本开始支持子查询,并且支持所有SQL标准的子查询,也扩展了一些其独有的子查询标准。
下面是一个子查询:
SELECT * FROM t1 WHRER column1=(SELECT column1 FROM t2);
在这个示例中,SELECT*FROM t1是外部查询(outer query),SELECT column1 FROM t2是子查询。一般来说,称子查询嵌套(nested)于外部查询中。实际上也可以将两个或两个以上的子查询进行嵌套。需要注意的是,子查询必须包含括号。
通常来讲,使用子查询的好处如下:
子查询允许结构化的查询,这样就可以把一个查询语句的每个部分隔开。
子查询提供了另一种方法来执行有些需要复杂的JOIN和UNION来实现的操作。
在许多人看来,子查询可读性较高。而实际上,这也是子查询的由来。
一个子查询会返回一个标量(单一值)、一个行、一个列或一个表(一行或多行及一列或多列),这些子查询被称为标量、列、行和表子查询。可返回一个特定种类结果的子查询经常只用于特定的语境中,在后面各节中有说明。子查询可以包括普通SELECT可以包括的任何关键词或子句,如DISTINCT、GROUP BY、ORDER BY、LIMIT、JOIN、UNION等。
子查询的限制是其外部语句必须是以下语句之一:SELECT、INSERT、UPDATE、DELETE、SET或DO。还有一个限制是,目前用户不能既在一个子查询中修改一个表,又在同一个表中进行选择,虽然这样的操作可用于普通的DELETE、INSERT、REPLACE和UPDATE语句中,但是对子查询不可以同时进行这样的操作。
4.1.2 使用子查询进行比较
最常见的一种子查询使用方式如下:
comparison_operator可以是以下操作符之一:=、>、<、>=、<=、<>。
例如:
以下是一个常见的子查询比较的例子,其中不能使用JOIN来完成此类比较。表t1中有些值与表t2中的最大值相同,该子查询可以查找出所有这些行数。
下面是另一个例子,该例子也不可能使用JOIN来得到结果,因为该例子涉及对其中一个表进行总计。表t1中的有些行含有的值会在给定的列中出现两次,该例子可以查找出所有这些行。
4.1.3 使用ANY、IN和SOME进行子查询
使用ANY、IN和SOME进行子查询的语法如下:
ANY关键词必须与一个比较操作符一起使用。ANY关键词的意思是“对于子查询返回的列中的任一数值,如果比较结果为TRUE,则返回TRUE”。例如:
假设表t1中有一行包含(10)。如果表t2包含(21,14,7),则表达式为TRUE,因为t2中有一个值为7,该值小于10。如果表t2包含(20,10),或者表t2为空表,则表达式为FALSE。如果表t2包含(NULL, NULL, NULL),则表达式为UNKNOWN。
词语IN是“=ANY”的别名。因此,这两个语句是一样的:
词语SOME是ANY的别名。因此,这两个语句是一样的:
使用词语SOME的机会很少,但是这个例子显示了为什么SOME是有意义的。对于大多数人来说,英语短语“a is not equal to any b”的意思是“没有一个b与a相等”,但是在SQL语法中不是这个意思,其意思是“有部分b与a不相等”。使用“<>SOME”有助于确认用户是否理解该查询的真正含义。
4.1.4 使用ALL进行子查询
使用ALL进行子查询的语法:
词语ALL必须与比较操作符一起使用。ALL的意思是“对于子查询返回的列中的所有值,如果比较结果为TRUE,则返回TRUE”。例如:
假设表t1中有一行包含(10)。如果表t2包含(-5,0,+5),则表达式为TRUE,因为10比t2中的所有三个值都大。如果表t2包含(12,6,NULL,-100),则表达式为FALSE,因为表t2中有一个值12大于10。如果表t2包含(0,NULL,1),则表达式为UNKNOWN。最后,如果表t2为空表,则结果为TRUE。因此,当表t2为空表时,以下语句为TRUE:
但是,当表t2为空表时,以下语句为NULL:
另外,当表t2为空表时,以下语句为NULL:
通常,包含NULL值的表和空表为“边缘情况”。当编写子查询代码时,都要考虑是否需要把这两种可能性计算在内。NOT IN是“<>ALL”的别名。因此,以下两个语句是相同的:
4.2 独立子查询
子查询可以按两种方式进行分类。若按照期望值的数量,可以将子查询分为标量子查询和多值子查询;若按查询对外部查询的依赖可分为独立子查询(self-contained subquery)和相关子查询(correlated subquery)。标量子查询和多值子查询可以是独立子查询,也可以是相关子查询。这一节先来介绍独立子查询。
独立子查询是不依赖外部查询而运行的子查询。与相关子查询相比,独立子查询更便于SQL语句的调试。
标量子查询可以出现在查询中希望产生标量值的任何地方,而多值子查询可以出现在查询中希望产生多值集合的任何地方。只要标量子查询返回的是单个值或NULL值,就说明该子查询是有效的。如果标量子查询返回多个值,则MySQL数据库将抛出错误。如以下两句标量子查询都是正确的。
因为子查询产生的派生表t返回两个值(a和b),而外部查询期望的是一个标量值,因此在运行时,MySQL数据库抛出异常,提示该子查询返回多行数据。
目前已经介绍了独立子查询的基本使用方法,接着将继续讨论更加复杂的独立子查询问题。这里将从一个属于关系分区的问题开始。关系分区问题有很多细微的差别和实际的应用,从逻辑上看,关系分区就是一个集合划分出另外一个集合,并产生一个结果集。
例如,从tpcc数据库中返回每个美国员工至少处理过一个订单的所有客户。这里需要从所有美国员工的集合中来划分订单的集合,并得到期望匹配的客户集合。这个操作并不简单,因为需要对每一个客户检查多行以判断是否有匹配。如果事先知道所有美国员工的employeeid列表,那么可以通过类似下列的语句来解决问题:
然而,一般来说,事先无法得知所有美国员工的employeeid列表,但是可能有很多很多的美国员工,这时SQL语句变得十分冗长。因此解决这个问题可以通过子查询来代替固定值:
另一个与独立子查询相关的问题是返回在每月最后实际订单日期发生的订单。在这里,需要注意的是,每月最后实际订单的日期可能并不是每月的最后一天。例如,周末可能没有订单产生。因此,每月最后订单日期需要通过子查询来判断。下面是该问题的SQL查询语句,生成的结果如表4-1所示。
子查询通过把订单按月分组并返回每个分组中最大的日期结果,这就是每个月最后订单生成的日期。而后外部查询通过子查询获得的日期来取得所有的订单信息。
这个问题看似得到了解决,实际上并没有这么简单。因为在这个数据量并不大的数据库中(实际orders表的大小只有336KB)执行上述SQL语句竟然需要6.08秒,这个时间是不能接受的。如果我们用EXPLAIN来对语句进行分析,就可以找到问题的所在。EXPLAIN查询的结果如图4-1所示。
怎么是DEPENDENT SUBQUERY?难道上述SQL的子查询变成了相关子查询?但是从逻辑上来说,优化器只需要先从子查询中得到每个月订单的最大日期就行了。这完全是一个独立子查询,不需要和外部查询有任何交互,为什么变成相关子查询呢?
这个是MySQL优化器对IN子查询优化时存在的一个问题,MySQL优化器对于IN语句的优化是“LAZY”的。对于IN子句,如果不是显式的列表定义,如IN('a','b','c'),那么IN子句都会被转换为EXISTS的相关子查询。如下面这条独立子查询:
如果子查询和外部查询分别返回M和N行,那么该子查询被扫描为O(N+M*N)而不是O(M+N)。因此,对于上述问题的SQL语句,MySQL数据库的优化器将其对应地转换为如下的相关子查询:
用户通过EXPLAIN EXTENDED命令可以更为明确地得到优化器的执行方式,如:
同样会得到图4-1的结果,但是命令行会显示有一个警告(WARNING),如果接着运行SHOW WARNINGS语句,将得到如下的结果:
通过斜体部分的语句可以看到优化器将IN语句转化为EXISTS语句。同时,这解释了为什么前面的SQL执行了那么长的时间。另外,有意思的是,翻阅官方的MySQL手册会发现,在子查询章节中有相关子查询的介绍,却没有独立子查询的介绍。这是因为在大多数情况下,MySQL数据库都将独立子查询转换为相关子查询。
InnoSQL数据库支持在慢查询日志中记录InnoDB的逻辑IO和物理IO的次数(物理IO就是实际读取磁盘的次数),故开启IO记录,可在慢查询日志中观察到类似如下的内容:
通过InnoSQL的慢查询日志可以看到逻辑IO有91 584次,物理IO只有19次。
不过对于上述语句,还是有一定的方法可以优化的。注意到慢的原因是独立子查询被转换成相关子查询,而这个相关子查询需要进行多次的分组操作。可以采取另一个方法,再嵌套一层子查询,避免多次的分组操作,语句如下:
这次该SQL语句的执行时间缩短到了0.0976秒。虽然从查询分析器(如图4-2所示)看,优化器还是将其转换为相关子查询,但是减少了外部查询与子查询的匹配次数。
MariaDB 5. 3提供了对独立子查询的优化,尤其是对IN语句的优化。可以通过下列语句在MariaDB中显式地打开对SEMIJOIN的优化(MariaDB 5.3.3默认已经打开此优化):
从图4-3中可以看到,这次显示的是SUBQUERY而非之前的DEPENDENT SUBQUERY,即这次子查询完全是独立的子查询。若再执行EXPLAIN EXTENDED命令,可以看到完全不同的执行方法:
可以看到,开启对于SEMIJOIN的优化后,优化器不再将IN子句转换为EXISTS语句,而是先将独立子查询产生的结果生成一张物化视图,之后再对外部查询的表进行JOIN操作。
4.3 相关子查询
相关子查询(Dependent Subquery或Correlated Subquery)是指引用了外部查询列的子查询,即子查询会对外部查询的每行进行一次计算。但是在优化器内部,这是一个动态的过程,随情况的变化会有所不同,通过不止一种优化方式来处理相关子查询。
下面通过一个示例来慢慢展现相关子查询的用法。例如,要查询每个员工最大订单日期的订单,因为一个员工可能有多个订单具有相同的订单日期,所以可能会为每个员工返回多行数据。
通过上一小节的介绍,用户可能会尝试这样的解决方案:使用一个子查询,该子查询与前面那个返回某月份内最后订单日期的订单的子查询类似。
实际上,这个解决方案是错误的。虽然结果集会包含正确的订单,但是也可能包含不是该用户的最大订单日期的订单。因此在这个例子中,子查询必须关联外部查询,将内部查询的employeeid与外部的employeeid进行匹配:
该查询得到的正确结果如表4-3所示。
该查询在笔者的笔记本电脑上运行,需要1.18秒,对于这样数据量的表来说,显然太慢了。通过InnoSQL数据库的慢查询日志来观察实际的IO次数,情况如下
可以看到,虽然物理IO只有23次,但是逻辑IO大于18万次。对于有经验的DBA,应该知道可以通过添加一个唯一索引来加快处理速度,具体语句如下:
不错,显示只需要0.468秒,差不多节省了一半的时间。能不能更快一点呢?先来分析该SQL语句的执行计划,如图4-4所示。
从图4-4中的执行计划可以看到,优化器的确使用了新建的唯一索引,因此速度得到了一定的提升。然而问题仍然是相关子查询需要与外部子查询的列进行多次比较。通过InnoSQL数据库的慢查询日志来观察实际的IO次数,具体情况如下:
可以看到,添加唯一索引后逻辑读取从18万次减少为12 000次,执行效率得到了大幅度的提升。然而,对于相关子查询,有时可以通过派生表来进行重写,以避免子查询与外部子查询的多次比较操作。故对于上述SQL语句,我们可以重写为:
在重写的SQL语句中,我们使用子查询作为派生表B,然后再将表A和表B进行联接。这样做的好处是显而易见的,从执行计划中可以看到没有了相关子查询的过程。另外A表也能使用OrderDate的索引,因此这句SQL语句的执行时间缩短为0.064秒。表4-4显示了运行该语句后的结果。
与表4-3的结果是一样的,唯一的不同是结果按照employeeid进行了排序。因为派生表是通过索引idx_empid_od_rd_oid得到的,然后再进行关联操作。
最后,通过InnoSQL数据库的慢查询日志来观察数据库实际的IO,具体情况如下:
可以看到这次逻辑IO只有284次,远小于之前介绍的两种SQL语句。
这里再次提醒开发人员,对子查询的编写需要非常小心,尽可能地使用EXPLAIN来确认子查询的执行计划,并确认是否可以对其进行进一步优化。在测试机上执行一句SQL需要1秒的时间看似很短,但这通常是数据量较小的缘故;如果在大数据量的生产环境中,这可能会带来灾难性的后果。
继续相关子查询问题的介绍,在表4-4中可以看到employeeid为2的员工有多个订单。如果只想为每个员工返回一行数据,可能还需要引入一个条件,即最大订单ID的那行数据。因此这个问题的解决方案变为:
嵌套查询在逻辑上与非嵌套查询完全相同,不同的是非嵌套查询用了0.64秒,而嵌套查询用了18秒,时间差不多是非嵌套查询的28倍!导致这个问题的原因还是当前相关子查询的性能问题,因为在嵌套查询中需要进行两次相关子查询。SQL的执行计划如表4-6所示。
4.4 EXISTS谓词
4.4.1 EXISTS
EXISTS是一个非常强大的谓词,它允许数据库高效地检查指定查询是否产生某些行。通常EXISTS的输入是一个子查询,并关联到外部查询,但这不是必须的。根据子查询是否返回行,该谓词返回TRUE或FALSE。与其他谓词和逻辑表达式不同的是,无论输入子查询是否返回行,EXISTS都不会返回UNKNOWN。如果子查询的过滤器为某行返回UNKNOWN,则表示该行不返回,因此,这个UNKNOWN被认为是FALSE。
在之前的小节中已经演示了EXISTS的用法。这里再通过一个例子来演示了EXISTS的用法及其内部的一些特性。下面的查询返回来自西班牙(Spain)且发生过订单的消费者,生成的结果如表4-6所示。
外部查询返回来自西班牙的消费者,EXISTS谓词在表orders中匹配在外部查询中遇有相同customerid的行数据。
注意 尽管通常不建议在SQL语句中使用*,因为可能会引起一些问题的产生,但是在EXIST子查询中*可以放心地使用。EXISTS只关心行是否存在,而不会去取各列的值。
图4-7显示了上述SQL语句的执行计划。
通过查询计划可以看到,SQL优化器首先根据WHERE条件先将country列为Spain的行数据取出,对于每个匹配的customerid,该执行计划对orders表上customerid索引进行一次查询,以检查orders表中是否有customerid的订单。子查询中的索引是必需的,因为它允许直接访问orders表的customerid行。
有些DBA有过一些其他数据库的使用经验,在其他数据库中可能存在这样“幽默”的优化定理,就是将IN语句改写为EXISTS,这样SQL查询的效率更高。据我所知,的确曾有过这种说法,这可能是因为当时优化器还不是很稳定和足够优秀。目前在绝大多数的情况下,IN和EXISTS都具有相同的执行计划。但是要注意的是,NOT IN和NOT EXISTS具有非常不同的执行计划。这个问题会在后面的小节中进行解释。
将上述的这句SQL重写为IN子查询:
这个IN查询的计划和图4-7所示的EXISTS计划一样。
4.4.2 NOT EXISTS
EXISTS与IN的一个小区别体现在对三值逻辑的判断上。EXISTS总是返回TRUE或FALSE,而对于IN,除了TRUE、FALSE值外,还有可能对NULL值返回UNKNOWN。但是在过滤器中,UNKNOWN的处理方式与FALSE相同,因此使用IN与使用EXISTS一样,SQL优化器会选择相同的执行计划。
但是输入列表中包含NULL值时,NOT EXISTS和NOT IN之间的差异就表现得非常明显了。输入列表中包含NULL值时,IN总是返回TRUE和UNKNOWN,因此NOT IN总是返回NOT TRUE和NOT UNKNOWN,即FALSE和UNKNOWN。我们来看下面的例子:
IN和NOT IN的返回值都是显而易见的。NULL IN('a','b',NULL)返回的是NULL,因为对NULL值进行比较返回的是UNKNOWN状态。最后,'c'NOT IN('a','b’,NULL)的结果可能出乎一些人的意料,其返回的是NULL。因为之前已经说对于包含NULL值的NOT IN来说,其总是返回FALSE和UNKNOWN,而对于NOT EXISTS,其总是返回TRUE和FALSE。这就是NOT EXISTS和NOT IN的最大区别。
例如,我们要返回来自西班牙且没有订单的客户信息,下面是使用NOT EXISTS谓词的解决方案,生成的结果如表4-7所示。
虽然NOT IN和NOT EXISTS产生相同的结果,但是执行计划却发生了一些细微的改变。在NOT IN中,相关子查询中的type列变为index_subquery,而在NOT EXISTS中,type列和EXISTS查询一样,都是ref。
对于NOT EXIST和NOT IN,虽然执行计划不同,但是返回的结果是相同的。这是因为orders表中不存在customerid为NULL的行。若人为地插入以下数据,再来比较NOT EXISTS和NOT IN之间的区别:
再次运行NOT EXISTS和NOT IN查询,就会发现NOT EXISTS依旧返回之前的结果,但是NOT IN查询返回空集合,这是因为orders表中存在customerid为NULL的行。所以NOT IN的查询返回的是FALSE和UNKNOWN,而不是TRUE,从而导致我们找不到需要的数据。因此对于使用NOT IN的子查询,可以在子查询中先过滤掉NULL值,如:
4.5 派生表
目前为止已经介绍了标量子查询和多值子查询,这一节将介绍派生表。派生表又被称为表子查询,与其他表一样出现在FROM的子句中,但是是从子查询派生出的虚拟表中产生的。派生表的使用形式一般如下:
目前派生表在使用上有以下使用规则:
列的名称必须是唯一的。在某些情况下不支持LIMIT。在派生表中,列的名称必须是唯一的,而在一般SQL语句中并没有这样的强制规定。
例如:
另外,对于某些SQL语句,派生表也不支持LIMIT子句,例如:
注意,派生表是完全的虚拟表,并没有也不可能被物理地具体化,因此优化器不清楚派生表的信息,这对于涉及查看派生表的EXPLAIN执行计划来说,速度可能非常慢,例如对于下面的执行计划:
这个执行计划在笔者的笔记本电脑上运行需要9.96秒,而直接对表salaries进行EXPLAIN操作瞬间就能得到结果。
前面小节已经演示了将子查询重写为派生表来提高效率,这对MySQL数据库来说可能是非常有效的一种调优手段,不过这可能要求开发人员或DBA对子查询本身非常熟悉。另外,对于大数据量的表,查看派生表的执行计划可能并不是十分友好。
4.6 子查询可以解决的经典问题
4.6.1 行号
行号是指按顺序为查询结果集的行分配的连续整数。MySQL数据库在行号方面的支持并不是十分友好,没有像其他数据库一样提供类似的ROW_NUMBER解决方案。因此得到行号是一个十分有技巧的问题。先根据如下代码创建sales表并填充该表:
从执行计划中可以发现,每条记录都需要在相关子查询中进行一次查找。因为empid列上已经有索引了,查找起来速度很快,所以对于表中有N条记录的行号统计来说,对于第1行数据需要扫描一行数据,对于第2行需要扫描两行数据,对于第3行需要扫描三行数据,……,对于第N行需要扫描N行数据,一共需要N*(N-1)/2行数据。如果empid上没有索引,那么每次都需要扫描N行数据,一共需要扫描N2行数据。但是不管是否有索引,行号统计子查询的解决方案的扫描成本为O(N2)。
在MySQL数据库中,最快得到行号的解决方案是采用CROSS JOIN,这会在第5章介绍,其时间复杂度为O(N)。
要对多个列排序后进行行号统计,这里需要按照qty和empid的顺序生成行号。针对这种情况我们可以采用下面的SQL语句,最后得到如表4-10所示的结果。
以上讨论的行号问题都是行数据不存在重复的情况,若存在多个相同行记录,上述方法都会失效,因为统计出来的结果可能相同,不能作为行号,所以用子查询来解决这个问题就显得更加困难了。例如,先根据下列代码创建表T并填充数据。
该SQL语句并不是非常好理解的,特别是对于数字辅助表的使用。但是我们可以从里到外,一层一层地来分析该语句的构成。先看最内部的子查询:
上述语句先根据a列进行分组,并统计数量及小于该分区数据的数量,最后得到的结果如表4-11所示。
接着使用数字辅助表来对上述产生的结果集进行复制,每行复制的记录数由count字段决定。记录X需要复制3次,记录Y需要复制2次,记录Z需要复制1次。与数字辅助表联接的语句如下,执行这段语句后得到的结果如表4-12所示。
仔细观察表4-12可以发现,smaller列和a列的数字相加就是最后需要统计的行号。最终的结果如表4-13所示。
4.6.2 分区
分区是通过在集合中进行分组操作,再对集合添加分区列来实现的。在子查询的解决方案中,通过在子查询内部添加相关性,并匹配内部表和外部表的分区列来实现分区。例如,对于表dept_manager,按dept_no分区,并按照emp_no升序进行分区统计,相应的SQL语句如下,得到的最终结果如表4-14所示。
对于分区问题,假设表中有m个分区,每个分区有n行数据,则扫描的总行数将是m*n*(n-1)/2,而m*n=N为表中的所有数据。因此,当n的值较小,即每个分区中含有的数据较少时,扫描的成本为O(N),而当n的值较大时,该子查询解决方案的扫描成本为O(N2)。
因此,对于表中数据较少的情况,这种方法非常快。如表dept_manager中只有24行数据,运行上述子查询的方案瞬间就能完成。同样的问题,对于含有30万行记录的表dept_emp而言,上述子查询的解决方案的运行效率是灾难性的,在笔者的笔记本电脑上运行了8个小时也没有得到结果。
但是就这个问题的本身而言,只需要一次扫描就能得到结果,因此对于该问题还可以采用除子查询外的其他解决方案,如基于游标的方案和基于临时表的解决方案。
基于游标的解决方案十分简洁明了。创建一个前向只读的游标,按分区列进行排序,然后再按面向过程的编程方式进行解题,每次将一行数据输入到一张临时表中,即将查询的最终结果存放于该临时表,具体的语句如下:
这里有几个值得注意的地方,首先使用CREATE TEMPORARY TABLE来创建临时表,这样可以在多个线程中并发运行该存储过程而不会有错误产生。其次,这里将产生的临时表指定为InnoDB引擎,对于插入的数据,必须要使用START TRANSACTION,使所有的插入操作放在同一个事务中完成,这样可以极大地提高插入的性能。最后,这里使用了InnoDB引擎,也可以使用MyISAM和Memory引擎,有兴趣的读者可以体验一下不同引擎的运行效率。在笔者的笔记本电脑上,使用InnoDB引擎作为临时表,运行该存储过程需要44.959秒;使用Memory引擎作为临时表需要的运行时间为24.586秒;而MyISAM需要81.261秒。可见基于游标的解决方案要远快于基于子查询集合的解决方案。
最后,介绍基于临时表的解决方案。该方案技巧性最高,执行效率也是最高的。基于临时表的解决方案可以在临时表中创建一个自增长列,按照分区的要求将数据插入临时表。这也是行号问题的另一个解决方案,即通过含有自增长列的临时表来得到行号,这个方法的性能一般也好于基于子查询的集合的解决方案。
而当前问题的特殊之处在于,并不只是要求取得行号,还要根据dept_no和emp_no进行分区后再进行行号的统计,因此需要再一次借助嵌套查询来解决这个问题,基于临时表的解决方案如下所示:
就dept_manager这张表来说,基于临时表的解决方案并没有带来查询效率的显著提升。但是如果将表dept_manager换成拥有30万行记录的表dept_emp,则两种解决方案的结果全然不同,采用基于子查询的集合的解决方案,在笔者笔记本电脑上运行8个小时也未得到结果。而采用基于临时表的解决方案,需要36.141秒,大部分的时间消耗在向临时表插入数据上,最后的嵌套子查询只需8秒就能完成。
在基于临时表的解决方案中获取数据时,采用嵌套查询来得到结果,将行号减去每个分区下最小的行号,在此基础上得到最后的结果。但是,如果采用如下相关子查询的方式来得到结果,那就和之前基于子查询的集合的解决方案没有什么不同了,同样在笔者的笔记本电脑上运行8个小时也得不到最终的结果。
4.6.3 最小缺失值问题
最小缺失值是另一个可用子查询解决的问题,一般应用EXISTS谓词。为了说明该问题,首先,创建并填充表x,过程如下:
注意,a列必须是一个正整数,所以这里的类型为INT UNSIGNED。最小缺失值的问题是,假设列a从1开始,对于当前表中的数据3、4、6、7,查询应返回1。如果当前表的数据为1、2、3、4、6、7、9,则查询应该返回5。最小缺失值的问题可以通过如下的表达式来解决:
如果表中不存在列a的值为1的情况,则结果返回1,否则返回子查询的结果,该子查询返回最小缺失值。下面是通过子查询得到的最小缺失值的过程。
NOT EXISTS谓词用来判断列a的值是否存在一个连续的值,使得A.a+1=B.a。如果存在,表示是连续值;如果不存在,则表示是缺失值。MIN函数用来返回最小的缺失值,如果要求返回最大缺失值,则用MAX函数。整个解决方案的SQL语句如下:
如果对表x执行上述语句,会得到值1。若按如下方式向列a插入值1和2之后,重新运行上述查询,则会得到结果5。
若要对最小缺失值进行补缺操作,则可以通过INSERT……SELECT语句来实现,如:
运行完上述语句再执行下面的语句查询表x,最终会得到如表4-15所示的结果。注意操作生成了a为5的列,它就是我们补缺的值。
4.6.4 缺失范围和连续范围
在生产环境中连续范围和缺失范围也是常见的问题。首先来创建并填充表g,过程如下:
这里演示的连续范围和缺失范围是整型类型。在生产环境中遇到的类型可能是整型,也有可能是时间类型,但是两者并没有大的区别,稍做修改就可以将该问题转换为时间类型。
对于缺失范围的问题,可以通过下列步骤来解决:
1)找出间断点之前的值,然后对该值加1,即为start_range;
2)通过间断点找出下一个值,对该值减1,即为end_range。
对于间断点之前的值,可以进行下面的子查询:
注意,最后的值106对查询来说是无用的,因为它是表中最大的值,所以需要过滤掉。而start_range的值为间断点加1,因此最后start_range的值可以通过如下SQL语句得到:
最后通过子查询为每个间断点返回表g中下一个已有的值并减1,即得到间断点的end_range,最终的SQL语句如下所示:
这只是该问题的解决方法之一,更为简洁直观的方法是,将表g中的数据进行移位匹配,如果是连续的值,那么其差值应该为1,如果不是连续的值,那么差值就应该大于1。对于表g,进行移位匹配后应该得到如表4-18所示的内容。
可以看到next-cur的值为1表示连续的值,不连续的值为(3,100)、(101,103),而我们要求的不连续范围为(4,99)、(102,102),也就是(cur+1,next-1)就是我们要求的缺失范围。要得到表4-20所示的内容,可以执行下述SQL语句:
而要得到最终的结果,只需在对当前值加1,下一个值减1即可。该解决方案的另一个好处是无需处理最大值,因此它的next值为NULL。该解决方案的SQL语句如下所示;
对于连续范围的问题,4.6.1节已经给出了解决方案的SQL语句。采用行号来进行分组是最快的一种做法。当然,这个问题也可以通过子查询来完成,不过其效率远没有采用行号分组的方法来得快。
如果采用子查询的方案,我们要手动创建(或得到)一个列,并对这个列进行分组。这个列应该是每个连续分组的最大值,对于{1,2,3}来说,这个最大值就应该为3。计算一组连续值中最大值所依据的原理是:返回大于或等于当前值且后面一个值为间断的最小值。下面是该子查询的SQL语句,生成的输出如表4-19所示。
剩下的工作就简单了,在上一步查询中执行如下语句对max列进行分组,得到分组中的最小值和最大值,这就是我们要求的连续范围。
上述查询给出了连续范围问题的解决方案,但是其性能是值得商榷的。这里的扫描成本变为了O(N2)。对于表中数据量非常大的情况,其性能又会变得十分糟糕。因此解决连续范围问题的最优方案是采用行号方法。因为已介绍过实现思路,这里仅给出SQL语句:
18:58:40