SQL

第18章 意大利面条式查询

实体不应不必要地增殖。-- 奥卡姆

你的老板正和他的老板打电话,然后他示意你过去。他用手遮住了话筒,小声对你说:“执行委员会在开预算会议,我们可能要被裁员,除非我能有数据告诉副总裁我们的部门一直都很忙。我需要知道我们同时在开发多少个项目,多少个程序员在修补Bug,每个程序员平均修补多少个Bug,以及多少修复了的Bug是由用户报告的。现在就要!”

你飞奔至座位,打开SQL工具,开始写查询语句。你想要立刻得到所有的数据,于是你写了一个很复杂的查询脚本,希望能尽可能减少重复的工作量,能更快地得到数据。

Spaghetti-Query/intro/report.sql
SELECT COUNT(bp.product_id) AS how_many_products, 
       COUNT(dev.account_id) AS how_many_developers,
       COUNT(b.bug_id)/COUNT(dev.account_id) AS avg_bugs_per_developer,
       COUNT(cust.account_id) AS ow_many_customers
 FROM Bugs b JOIN BugsProducts bp ON (b.bug_id = bp.bug_id)
             JOIN Accounts dev ON (b.assigned_to = dev.account_id)
             JOIN Accounts cust ON (b.reported_by = cust.account_id)
 WHERE cust.email NOT LIKE '%@example.com' GROUP BY bp.product_id;

数据出来了,但看上去是错的。我们怎么会有几十个产品?怎么可能平均每个程序员正好修复1.0个Bug?你的老板要的不是客户数量,他要得是客户报告的Bug数量。这些数据怎么会是这样呢?这个查询比你所想的要更加复杂。

你的老板挂掉了电话。“无所谓了,”他叹息道,“太晚了。我们收拾下桌子吧。”

18.1 目标:减少SQL查询数量

SQL开发人员经常会被同一个问题困扰:“我要怎么用一个查询来完成这件事情?”这个问题基本上在任务中都会被提及。受过培训的程序员认为,一个SQL查询是困难的、复杂的和耗资源的,那么两个SQL查询就是糟糕度乘以二。用多于两个的SQL查询来解决问题根本不在考虑范围内。

程序员不能减少他们任务的复杂度,但他们想要简单化其解决方案。他们使用“优雅”或者“高效”这些形容词来描述他们的目标,并且认为使用一条SQL查询就能完成目标。

18.2 反模式:使用一步操作解决复杂问题

SQL是一门极具表现力的语言——你可以在单个SQL查询或者单条语句中完成很多事情。但这并不意味着必须强制只使用一行代码,或者认为使用一行代码就搞定每个任务是个好主意。你在使用其他语言的时候也有这样的习惯吗?应该没有吧。

18.2.1 副作用

通过一个查询来获得所有结果的常见后果就是得到了一个笛卡儿积。当查询中的两张表之间没有条件限制其关系时,就会发生这样的情况。没有对应的限制而直接使用两张表进行联结查询,就会得到第一张表中的每一行和第二张表中的每一行的一个组合。每一个这样的组合就会成为结果集中的一行,最终你就得到一个行数多很多的结果集。

我们来看个例子。假设我们想要查询Bugs数据库,计算一个给定的产品有多少Bug被修复了,多少Bug正处于打开状态。很多程序员可能会写出如下的这条语句:

Spaghetti-Query/anti/cartesian.sql
SELECT p.product_id, COUNT(f.bug_id) AS count_fixed, COUNT(o.bug_id) AS count_open
 FROM BugsProducts p LEFT OUTER JOIN Bugs f ON (p.bug_id = f.bug_id AND f.status = 'FIXED')
                     LEFT OUTER JOIN Bugs o ON (p.bug_id = o.bug_id AND o.status = 'OPEN')
WHERE p.product_id = 1 GROUP BY p.product_id;

你碰巧知道,事实上对于给定的这个产品,有12个Bug被修复了,有7个Bug是打开的。因此,结果看上去很耐人寻味:

product_id count_fixed count_open
1 84 84

是什么导致结果和预期相差十万八千里?没那么巧,正好是84 = 12×7。这个例子将Products表和两个不同的Bugs表的子集联合起来,结果却是那两个子集的笛卡儿积。12个FIXED状态的Bug中的每一个和一个OPEN状态的Bug凑成了一对。

你可以想象,笛卡儿积和图18-1所画的一样。每条线链接了一个已修复的Bug和一个打开的Bug,然后成为了临时结果集中的一行(在分组语句执行之前)。我们可以注释掉GROUP BY子句和那些聚合函数来查看这个查询的结果。

Spaghetti-Query/anti/cartesian-no-group.sql
SELECT p.product_id, f.bug_id AS fixed, o.bug_id AS open FROM BugsProducts p
 JOIN Bugs f ON (p.bug_id = f.bug_id AND f.status = 'FIXED')
 JOIN Bugs o ON (p.bug_id = o.bug_id AND o.status = 'OPEN')
WHERE p.product_id = 1;

这个查询唯一描述的关系是BugsProducts表和每个Bugs子集之间的关系。没有条件来约束每个FIXED状态的Bug和每个OPEN状态的Bug是否能进行配对,而默认情况下它们是可以配对的。最终的结果是得到一个12乘以7的结果集。

当你尝试着执行一个类似的双重任务的查询时,很容易就得到一个意料之外的笛卡儿积。如果你尝试在一个查询中完成更多不相关的工作,最终的结果可能是在此基础上再多乘出一个笛卡儿积。

18.2.2 那好像还不够??

除了你会得到错误结果之外,这些查询也非常难写、难以修改和难以调试。数据库查询请求的日益增加应该是预料之中的事。经理们想要更复杂的报告以及在用户界面上添加更多的字段。如果你的设计很复杂,并且是一个单一查询,要扩展它们就会很费时费力。不论对你还是对项目来说,时间花在这些事情上面不值得。

此外,还有运行时开销。一条精心设计的复杂SQL查询,相比于那些直接简单的查询来说,不得不使用很多的JOIN、关联子查询和其他让SQL引擎难以优化和快速执行的操作符。程序员直觉地认为越少的SQL执行次数性能越好,如果SQL查询的复杂度都相同时的确如此。但另一方面,一个怪兽般的SQL查询的开销可能成指数级别增长,而使用多个简单的查询却有更好的效果。

18.3 如何识别反模式

如果你听见项目组成员说了下面这些话,可能就是使用了“意大利面条式查询”这个反模式。

  • “为什么我的求和、计数返回的结果异常地大?”一个意料之外的两个联结查询的数据集生成的笛卡儿积。
  • “我一整天都在和这个变态的查询语句做斗争!”SQL并不是那么难的,真的!如果你和单条SQL查询纠结了很长时间,应该重新考虑你的实现方式。
  • “我们不能在数据库报表中再加入任何新东西了,因为对查询语句的修改要花很长时间。”写这个查询语句的人要永远为他所写的代码负责,即使他们已经加入到别的项目中去了。那个写这段代码的人可能就是你,因此别把查询写得如此复杂以至于别人无法维护!
  • “试试看再加一个DISTINCT进去?”要修正由于笛卡儿积所带来的数据集暴涨,程序员通常使用DISTINCT这个关键字作为一个查询修正或者一个聚合函数来减少重复。这个方法能够隐藏掉那个难看的查询的踪迹,但导致RDBMS做了更多的工作来生成排序、去重的临时表。

另一个表明一个查询可能是意大利面条式查询的证据是它的执行时间很长。低劣的性能可能是其他问题的一个征兆,但你在调查时应该考虑到可能在某个SQL语句中做了太多事情。

18.4 合理使用反模式

需要将一个复杂的查询任务放在一个SQL查询中完成的最常见原因,是你正在使用一个编程框架或者一个可视化组件库直接和数据源相连,然后在程序中直接展示数据。简单的商务智能和报表工具都属于这一分类中,但大多数高级的BI软件可以从多个数据源合并数据。

组件或者报表工具通常假设单个SQL查询仅用来完成一个简单的任务,但它鼓励你去设计更庞大的查询来生成报告中的所有数据。如果你使用某个这样的报表程序,就可能被迫去写一个更复杂的SQL查询,而没有机会写代码操作结果集。

如果报表需求太复杂而不能用单个SQL查询完成,更好的方案可能是生成多个报表。如果你的老板不喜欢这样的解决方案,要提醒他报表的复杂度和生成报表所花的时间是成正比的。

有时候,你想要在一个SQL查询中得到一个复杂的结果,是因为需要所有的这些结果能排序后再组合在一起。在SQL查询中指定一个排序是很简单的。理论上来说让数据库来执行这个操作比你自己写代码实现多个请求结果的排序要高效得多。

18.5 解决方案:分而治之

奥卡姆1在本章开头的名句也称为简约律。

1 William of Ockham,十四世纪英国著名思想家,逻辑学家。著有“奥卡姆剃刀”理论。——编者注

简约律

当你有两个相互竞争的理论能得出同样的结论,那么简单的那个更好。

对于SQL来说,那意味着你在两个能得到同样结果的查询之中做选择时,选择更简单的那个。我们在修正本章的反模式查询时,应时刻谨记这个定律。

18.5.1 一步一个脚印

如果你看不出,在意外产生了笛卡儿积的两张表间存在逻辑关联关系,那有一个很简单的解释:根本就没有这种关系。要避免生成这个笛卡儿积,你不得不将这个意大利面条式查询拆分成几个小而简单的查询。在之前描述的那个简单例子中,我们只需要两个查询:

Spaghetti-Query/soln/split-query.sql
SELECT p.product_id, COUNT(f.bug_id) AS count_fixed FROM BugsProducts p
 LEFT OUTER JOIN Bugs f ON (p.bug_id = f.bug_id AND f.status = 'FIXED')
WHERE p.product_id = 1 GROUP BY p.product_id;  SELECT p.product_id, COUNT(o.bug_id) AS count_open FROM BugsProducts p
 LEFT OUTER JOIN Bugs o ON (p.bug_id = o.bug_id AND o.status = 'OPEN')
WHERE p.product_id = 1 GROUP BY p.product_id;

这两个查询的结果分别是12和7,正如我们所希望的。

你可能会觉得将一个查询拆分成多个查询是一个“不雅”的解决方案,并且略感憋屈和遗憾,但你很快就会意识到,在开发、维护和性能方面,这么做能带来一些积极的影响。

  • 这个查询不会产生早先例子中出现的那种意外的笛卡儿积,因此很简单地就能确认这个查询给出的结果是精确的。
  • 当有新的需求增加到报表中时,添加另一个简单的查询,比将更多的计算整合到一个已经很复杂的查询中去要简单很多。
  • SQL引擎能更容易和可靠地对简单的查询进行优化和执行。即使整个工作看上去像是被分割出来的查询弄得有点重复,但可能执行得更快。
  • 在代码审查或者团队间的培训交流时,解释几个易懂的查询要比解释一个庞大复杂的查询容易得多。

18.5.2 寻找UNION标记

你可以将几个查询的结果进行UNION操作,从而最终得到一个结果集。当你确实想要提交单个查询并且得到单个结果集时,这么做很有帮助,比如在需要保存查询结果时。

Spaghetti-Query/soln/union.sql
(SELECT p.product_id, f.status, COUNT(f.bug_id) AS bug_count
  FROM BugsProducts p  LEFT OUTER JOIN Bugs f ON (p.bug_id = f.bug_id AND f.status = 'FIXED')
WHERE p.product_id = 1  GROUP BY p.product_id, f.status)
  UNION ALL
  (SELECT p.product_id, o.status, COUNT(o.bug_id) AS bug_count
    FROM BugsProducts p
    LEFT OUTER JOIN Bugs o ON (p.bug_id = o.bug_id AND o.status = 'OPEN')
    WHERE p.product_id = 1
    GROUP BY p.product_id, o.status)
    ORDER BY bug_count;

这个查询的结果是每个子查询结果联合后所得的。在本例中有两行,每行对应一个子查询。请记住要额外地增加一列来区分不同子查询的结果,本例中是status这一列。

仅在两个子查询的列属性是相互兼容的情况下才能使用UNION。你不能在查询的中间改变列的数值、名字或者数据类型,因此需要确保所有行的所有列都是相同的。如果你发现定义了一个列的别名,类似于bugcount_or_customerid_or_null,很有可能就是你对不兼容的两个结果集使用了UNION。

18.5.3 解决老板的问题

怎么解决这个统计项目信息的紧急任务?你的老板说:“我需要知道我们同时在开发多少个项目,多少个程序员在修补Bug,每个程序员平均修补多少个Bug,以及多少修复了的Bug是由用户报告的。”

做好的解决方案就是拆分所有的这些工作。

  • 多少产品:
    Spaghetti-Query/soln/count-products.sql
    SELECT COUNT(*) AS how_many_products FROM Products;
    
  • 多少开发人员在参与修补Bug:
    Spaghetti-Query/soln/count-developers.sql
    SELECT COUNT(DISTINCT assigned_to) AS how_many_developers FROM Bugs WHERE status = 'FIXED';
    
  • 平均每个程序员修复了多少Bug:
    Spaghetti-Query/soln/bugs-per-developer.sql
    SELECT AVG(bugs_per_developer) AS average_bugs_per_developer
     FROM (SELECT dev.account_id, COUNT(*) AS bugs_per_developer
           FROM Bugs b JOIN Accounts dev
             ON (b.assigned_to = dev.account_id)
           WHERE b.status = 'FIXED'
           GROUP BY dev.account_id) t;
    
  • 多少修复了的Bug是由客户报告的:
    Spaghetti-Query/soln/bugs-by-customers.sql
    SELECT COUNT(*) AS how_many_customer_bugs FROM Bugs b JOIN Accounts cust ON (b.reported_by = cust.account_id) WHERE b.status = 'FIXED' AND cust.email NOT LIKE '%@example.com';
    

其中的几个查询其本身就已经足够复杂了,要再将它们合并到单个输出结果集中,简直就是噩梦!

18.5.4 使用SQL自动生成SQL

当你拆分一个复杂的SQL查询时,得到的结果可能是很多相似的查询,可能仅仅在数据类型上面有所不同。编写所有的这些查询是很乏味的,因此,最好能够有个程序自动生成这些代码。

代码生成是一种输出一段新的可以编译或者执行的代码的写代码技术。如果手写这些代码很费力,代码生成技术就是非常有价值的。一个代码生成器可以帮你去除重复的工作。

多表更新

在做咨询时,我被叫去为另一个部门的经理解决一个紧急的SQL问题。

我走进了经理办公室,看到他穷途末路的苦恼样子。我们简单地互相问候了一下,他就开始向我解释他所面临的困境:“我希望你能快速地解决这个问题。我们的库存系统已经下线一整天了。”他并不是SQL的业余开发者,但他告诉我他已经在一个用来同时更新大量记录的SQL语句上花了好几个小时了。

他的问题是他没办法在UPDATE语句上对所有的值使用固定的SQL表达式。事实上,每一行上需要更新的值都是不一样的。他的数据库跟踪一个计算机中心的库存信息以及每台电脑的使用情况。他想要添加一个last_used列记录每台电脑的最后一次使用日期。

他太专注于使用单个SQL语句来解决这个复杂的问题了,这是另一个“意大利面条式查询”的例子!他这几个小时想要写出这个完美的UPDATE的时间,都可以手动更新掉所有的记录了。

Spaghetti-Query/soln/generate-update.sql
SELECT CONCAT('UPDATE Inventory '' SET last_used = ''', MAX(u.usage_date), '''',
      ' WHERE inventory_id = ', u.inventory_id,';') AS update_statement
    FROM ComputerUsage u
    GROUP BY u.inventory_id;

和他想要写出一个SQL语句来解决这个复杂问题不同,我写了一个脚本来生成一系列更简单且符合需求的SQL语句:

这个查询的输出是一系列的UPDATE语句,由分号分割,可以直接作为SQL脚本执行:

Update_statement
UPDATE Inventory SET last_used = '2002-04-19'
WHERE inventory_id = 1234;
UPDATE Inventory SET last_used = '2002-03-12'
WHERE inventory_id = 2345;
UPDATE Inventory SET last_used = '2002-04-30'
WHERE inventory_id = 3456;
UPDATE Inventory SET last_used = '2002-04-04'
WHERE inventory_id = 4567;

通过这种方法,我在几分钟内就解决了这个经理花了几小时在那里纠结的问题。

执行多次SQL查询或者多条SQL语句可能并不是解决问题最高效的办法,但你应该在效率和解决问题之间找到平衡点。

尽管SQL支持用一行代码解决复杂的问题,但也别做不切实际的事情。

下一节:连我自己都不知道自己要说什么,怎么告诉你我在想什么? -- E. M.福斯特