【译】索引进阶(三):聚集索引
【译注:此文为翻译,由于本人水平所限,疏漏在所难免,欢迎探讨指正】
原文链接:传送门。
前两篇文章对索引进行了一个总体上的概述,并特意介绍了非聚集索引。它包含了关于SQL SERVER索引的一些关键性的概念,当一个请求到达你的数据库时,不管它是SELECT语句,还是INSERT,UPDATE或者DELETE语句,SQL SERVER仅仅具有三种可能的方式来访问此语句所涉及的表数据:
- 仅访问非聚集索引,而避免访问表数据,只有当非聚集索引包含此查询所请求的所有数据时,这种方式才是可能的。
- 用检索键来访问索引,然后用所选择的书签来访问各个数据行。
- 忽略索引,直接查询表数据来获取所请求的数据。
这篇文章以关注于以上列表中的第三个选择开始:整表检索。这反过来会引导我们讨论聚集索引的概念,这个主题我们曾在第二章有所涉及,但并没有涵盖此概念。
我们在此章节中使用到的主要的数据库表是AdventureWorks 库的SalesOrderDetail 表。包含了121,317行数据,它足够大,可以用来演示在一个表上建立聚集索引的益处。而且它还有两个外键,因而也足够复杂,可以用以演示你必须对聚集索引做的一些设计决策。
演示数据库
虽然我们在第一章已经讨论过示例数据库,这次又要重复了,贯穿整个进阶系列,我们将使用例子来演示基本概念,这些例子基于微软的AdventureWorks 示例数据库。我们关注于销售订单,如下五张表既提供了事务性数据,又提供了非事务性数据:Customer, SalesPerson, Product, SalesOrderHeader, and SalesOrderDetail。为了保证关注的焦点,我们使用了列的子集。因为AdventureWorks 数据库具有良好的组织结构,销售人员信息被分解于三张表中:SalesPerson, Employee及Contact。
在本文中,我们用了两个术语来表述一个订单中的单个行(它们可相互替换):“line item”和 “order detail”,前者是更通用的商业术语,而后者出现在数据库AdventureWorks 的表名称中。
完整的表集合以及它们间的关系如下:
聚集索引
开始时我们先问一些问题:如果不使用非聚集索引,那么找到一个表数据需要做多少工作?检索表以找到请求的数据是否意味着在未排序表中扫描每一行数据?SQL SERVER能否永久的对表中的数据进行排序以便根据检索键快速的访问到数据,如同它们根据检索键快速的访问非聚集索引条目那样?答案取决于你是否指示SQL SERVER在表上创建了聚集索引。
不同于非聚集索引,它是一个独立的对象并占据自己的存储空间,聚集索引和表其实是一回事。通过创建一个聚集索引,你指示SQL SERVER将表的数据行按照索引键序列进行排序,并且在将来的数据更新时维护这个序列,后续章节将研究为了实现此而产生的内部数据结构,但是到现在为止,可以把聚集索引看作是一个排序表,给定一行数据的索引键值,SQL SERVER能快速的访问到那个数据,并能够从那行数据进行有序的处理。
为了演示的目的我们创建SalesOrderDetail表的两个拷贝:一个没有索引,另一个具有一个聚集索引。关于索引键列,我们和AdventureWorks数据库设计者做了同样的选择: SalesOrderID / SalesOrderDetailID。如下代码创建了SalesOrderDetail 表的 拷贝,任何时候我们想以一个“干净的状态”开始,便可以执行此脚本。
IF EXISTS (SELECT * FROM sys.tables  WHERE OBJECT_ID = OBJECT_ID('dbo.SalesOrderDetail_index')) DROP TABLE dbo.SalesOrderDetail_index; GO IF EXISTS (SELECT * FROM sys.tables  WHERE OBJECT_ID = OBJECT_ID('dbo.SalesOrderDetail_noindex')) DROP TABLE dbo.SalesOrderDetail_noindex; GO SELECT * INTO dbo.SalesOrderDetail_index FROM Sales.SalesOrderDetail; SELECT * INTO dbo.SalesOrderDetail_noindex FROM Sales.SalesOrderDetail; GO CREATE CLUSTERED INDEX IX_SalesOrderDetail ON dbo.SalesOrderDetail_index (SalesOrderID, SalesOrderDetailID) GO
假设SalesOrderDetail表在创建聚集索引之前看起来是这个样子:
SalesOrderID SalesOrderDetailID ProductID OrderQty UnitPrice
69389 102201 864 3 38.10
56658 59519 711 1 34.99
59044 70000 956 2 1430.442
48299 22652 853 4 44.994
50218 31427 854 8 44.994
53713 50716 711 1 34.99
50299 32777 739 1 744.2727
45321 6303 775 6 2024.994
72644 115325 873 1 2.29
48306 22705 824 4 141.615
69134 101554 876 1 120.00
48361 23556 760 3 469.794
53605 50098 888 1 602.346
48317 22901 722 1 183.9382
66430 93291 872 1 8.99
65281 90265 889 2 602.346
52248 43812 871 1 9.99
47978 20189 794 2 1308.9375
创建了上述的聚集索引之后,结果表会看起来如这个样子:
SalesOrderID SalesOrderDetailID ProductID OrderQty UnitPrice
43668 106 722 3 178.58
43668 107 708 1 20.19
43668 108 733 3 356.90
43668 109 763 3 419.46
43669 110 747 1 714.70
43670 111 710 1 5.70
43670 112 709 2 5.70
43670 113 773 2 2,039.99
43670 114 776 1 2,024.99
43671 115 753 1 2,146.96
43671 116 714 2 28.84
43671 117 756 1 874.79
43671 118 768 2 419.46
43671 119 732 2 356.90
43671 120 763 2 419.46
43671 121 755 2 874.79
43671 122 764 2 419.46
43671 123 716 1 28.84
43671 124 711 1 20.19
43671 125 708 1 20.19
43672 126 709 6 5.70
43672 127 776 2 2,024.99
43672 128 774 1 2,039.99
43673 129 754 1 874.79
43673 130 715 3 28.84
43673 131 729 1 183.94
当你看见如上所示的示例数据时,你会注意到每一个SalesOrderDetailID值都是唯一的,不要困惑,SalesOrderDetailID 不是表的主键,SalesOrderID / SalesOrderDetailID组合键才是表的主键,并且是聚集索引的索引键。
理解聚集索引基础
聚集索引键可以由任何你选择的列组成,它不是必须基于主键的。在我们的例子里,最重要的是键的最左边的列是一个外键:SalesOrderID 。因此,一个订单的所有条目在SalesOrderDetail表中是连续出现的。
记住关于聚集索引的这些额外的信息点:
- 因为聚集索引条目本身就是数据行,因而在聚集索引条目中不包含书签,当SQL SERVER已经在一行中时,便不再需要一条信息来告诉它在哪儿定位数据行。
- 聚集索引总是涵盖了查询,既然对于聚集索引来说,索引和表是一回事,那么表中的每一行便都在索引中。
- 在一个表上创建聚集索引不会影响你在那张表上创建非聚集索引。
选择聚集索引键列
每张表最多只有一个聚集索引。表中的行仅仅能按一种顺序排列,你需要决定什么顺序对于每张表是最合适的,并且,如果可能的话,在表充满数据之前创建好聚集索引。当做这个决定的时候,记住序列不仅仅意味着排序,还意味着分组,比如按照订单对于销售条目进行分组。这就是为什么AdventureWorks数据库的设计者选择SalesOrderDetailID,SalesOrderID作为SalesOrderDetail 表的顺序,它是销售条目的自然顺序。
举个例子,如果一个用户请求订单中的一个子条目,他们常常会请求那个订单的所有条目,看一下传统的销售订单形式,它告诉了我们打印版本的订单通常包含了所有的条目明细。根据销售订单聚集条目明细是最自然的销售订单业务。或许偶尔会有从仓库发起的请求,会想要根据产品来查看条目而不是根据订单,但是大部分的请求,比如从销售人员发来的请求,从消费者发来的请求,以及打印发票,计算订单总价的请求,往往需要得到一个给定订单的所有条目。
然而,单单根据用户需求不能决定什么会是索引的最佳选择。后续进阶文章会覆盖索引的内部原理,因为某些索引的内部方面也会影响你对索引列的选择。
堆
如果一个表没有聚集索引,那么这个表便是堆,每一个表要么是一个堆,要么是一个聚集索引,因此,虽然我们常常说一个索引可以归类为以下两种类型之一:聚集索引和非聚集索引,同样重要的是,我们要注意到的是每张表也会被归类为以下两种类型之一:聚集索引或者是堆。开发者总是说一张表有或者没有聚集索引,但说一张表是或者不是聚集索引更有意义。
对于SQL SERVER来说,只有一种方法来检索堆来查找数据行(包括使用了非聚集索引的表),那就是从表的第一行开始,然后往前进行,直到所有的数据行都被读取到。没有排序,便没有检索键及任何方式来快速定位到指定行。
比较聚集索引和堆
为了评估聚集索引和堆的性能,列表1创建了SalesOrderDetail表的两个拷贝,其中一个是堆版本,另一个表,我们创建和源表相同的聚集索引SalesOrderID, SalesOrderDetailID),两张表都不包含非聚集索引。
我们将在各个版本的表上分别执行相同的三个查询语句,一个返回单独的一行数据,一个返回一个订单的所有行,一个返回一个商品的所有行。我们将SQL和每次执行的结果列在下表中。
第一个查询返回单独的一行,执行的详细信息如下:
SQL |
|
堆 |
(1行被影响) 表SalesOrderDetail_noindex扫描计数1次,逻辑读1495次 |
聚集索引 |
(1行被影响) 表SalesOrderDetail_noindex:扫描计数1次,逻辑读3次 |
聚集索引影响 |
IO:从1495次逻辑读减少至3次逻辑读。 |
结论 |
不要惊奇,表扫描121,317来找一行数据并不高效 |
我们的第二个查询返回一个订单的所有行,执行细节如下:
SQL |
|
堆 |
(11行被影响) 表SalesOrderDetail_noindex扫描计数1次,逻辑读1495次 |
聚集索引 |
(11行被影响) 表SalesOrderDetail_noindex:扫描计数1次,逻辑读3次 |
聚集索引影响 |
IO:从1495次逻辑读减少至3次逻辑读。 |
结论 |
和前一个查询一样的统计信息,堆仍然需要表扫描,然而聚集索引将请求的11行数据分组得足够近因此返回11行所需要的IO数与返回1行所需的IO数一样,后续章节会详细解释为什么不需要额外的读来返回另外10行数据。 |
第三个查询返回一个商品的所有行,执行结果如下:
SQL |
|
堆 |
(228行被影响) 表SalesOrderDetail_noindex扫描计数1次,逻辑读1495次 |
聚集索引 |
(11行被影响) 表SalesOrderDetail_noindex:扫描计数1次,逻辑读1513次 |
聚集索引影响 |
IO:比聚集索引稍微多点, |
结论 |
ProductID列没有非聚集索引来帮助我们找到一个商品的所有行,两个版本的表都要进行表扫描,因为聚集所以的开销,聚集索引是稍微大的表,因而扫描它需要更多的 逻辑读 |
结论
聚集索引是一个排序表,它的顺序是在创建索引时由你指定的,并通过SQL Server维护。给定键值后,表的任意行可以快速访问。在键顺序索引中,表的任意列在给定值范围后也是可以快速访问的。
每个表中仅有一个聚集索引。那些列应该成为索引列的决定是你为任意表做的最重要的索引决策。
在第四章节我们将重点从逻辑阐述转换到实际运用,介绍页和区。