分类: Linux
INSERT 处理过程概述
首先让我们快速地看看插入一行时的处理步骤。这些步骤中的每一步都有优化的潜力,对此我们在后面会一一讨论。
在客户机准备 语句。对于动态 SQL,在语句执行前就要做这一步,此处的性能是很重要的;对于静态 SQL,这一步的性能实际上关系不大,因为语句的准备是事先完成的。在客户机,将要插入的行的各个 列值组装起来,发送到 DB2 服务器。 DB2 服务器确定将这一行插入到哪一页中。DB2 在用于该页的缓冲池中预留一个位置。假如 DB2 选定的是个已有的页,那么就需要读磁盘;假如使用一个新页,则要在表空间(假如是SMS,也就是系统管理存储的表空间)中为该页物理地分配空间。插入了新行的每一页最后都要从缓冲池写入到磁盘。在目标页中对该行进行格式化,并获得该行上的一个X(exclusive,独占的) 行锁。将反映该 insert 的一条记录写入到日志缓冲区中。
最后提交包含该 insert 的事务,假如这时日志缓冲区中的记录还没有被写入日志文档的话,则将这些记录写到日志文档中。此外,还可能发生很多类型的附加处理,这取决于数据库配置,例如,索引或触发器的存在。这种额外的处理对于性能来说也是意义重大的,我们在后面会讨论到。
INSERT 的替代方案
在周详讨论 insert 的优化之前,让我们先考虑一下 insert 的两种替代方案:load 和 import。import 实用程式实际上是 SQL INSERT 的一个前端,但他的某些功能对于您来说也是有用的。load 也有一些有用的额外功能,但是我们使用 load 而不使用 insert 的主要原因是能够提高性能。
load 直接格式化数据页,而避免了由于插入导致的对每一行进行处理的大部分开销(例如,日志记录在这里实际上是消除了)。而且,load 能够更好地利用多处理器机器上的并行性。在 V8 load 中有两个新功能,他们对于 load 成为 insert 的替代方案有着特别的功效,这两个功能是:从游标装载和从调用层接口(CLI)应用程式装载。
从游标装载
这种方法可用于应用程式的程式代码(通过 db2Load API),或用于 DB2 脚本。下面是后一种情况的一个例子:
declare staffcursor cursor forselect * from staff;
load from staffcursor of cursor insert into myschema.new_staff;
这两行能够用下面一行替代:
insert into myschema.new_staff select * from staff
同等效的 INSERT ... SELECT 语句相比,从游标装载几乎能够提高 20% 的性能。
从 CLI 装载
这种方法显然只限于调用层接口(CLI)应用程式,但是他很快。这种技巧很类似于数组插入,DB2 附带了这样的示例,使用 load 时的速度是使用经过完全优化的数组插入时的两倍,几乎要比未经优化的数组插入快 10 倍。
任何INSERT能够改进的地方
让我们看看插入处理的一些必要步骤,连同我们能够用来优化这些步骤的技巧。
1. 语句准备
作为一条 SQL 语句,INSERT 语句在执行之前必须由 DB2 进行编译。这一步骤能够自动发生(例如在 CLP 中,或在一次 CLI SQLExecDirect 调用中),也能够显式地进行(例如,通过一条 SQL Prepare、CLI SQLPrepare 或 JDBC prepareStatement 语句)。该编译过程牵涉到授权检查、优化,连同将语句转化为可执行格式时所需的其他一些活动。在编译语句时,语句的访问计划被存储在包缓存中。
假如重复地执行相同的 INSERT 语句,则该语句的访问计划(通常)会进入到包缓存中,这样就免除了编译的开销。然而,假如 insert 语句对于每一行有不同的值,那么每一条语句都将被看成是惟一的,必须单独地进行编译。因此,将像下面这样的重复语句:
insert into mytable values (1, 'abc')
insert into mytable values (2, 'def')
等等,换成带有参数标记的语句,一次准备,重复执行,这样做是十分可取的:
insert into mytable values (?, ?)
使用参数标记能够让一系列的 insert 的运行速度提高数倍。(在静态 SQL 程式中使用主机变量也能够获得类似的好处。)
2. 发送列值到服务器
能够归为这一类的优化技巧有好几种。最重要的一种技巧是在每条 insert 语句中包括多行,这样就能够避免对于每一行都进行客户机-服务器通信,同时也减少了 DB2 开销。可用于多行插入的技巧有:
在 VALUES 子句中包含多行的内容。例如,下面的语句将插入三行:INSERT INTO mytable VALUES (1, 'abc'), (2, 'def'), (3, 'ghi') 在 CLI 中使用数组插入(array insert)。这需要准备一条带参数标记的 INSERT 语句,定义一个用于存储要插入的值的数组,将该数组绑定到参数标记,连同对于每个数组中的一组内容执行一次 insert。而且,示例程式 sqllib/samples/cli/tbload.c 提供了数组插入的基本框架(但是执行的是 CLI LOAD)。从不使用数组改为使用包含 100 行的数组,能够将时间缩短大约 2.5 倍。所以应该尽可能地使用包含至少 100 行的数组。
在 JDBC 中使用批处理操作。这跟 CLI 中的数组插入相同,基于相同的概念,但是实现细节有所不同。当通过 prepareStatement 方法准备了 insert 语句之后,剩下的步骤是针对每一列调用适当的 setXXXX 方法(例如,setString 或 setInt),然后是 addBatch。对于要插入的每一行,都要重复这些步骤,然后调用 executeBatch 来执行插入。要查看这方面的例子,请参阅“参考资料”一节中的 JDBC Tutorial。
使用 load 将数据快速地装入到一个 staging 表中,然后使用 INSERT ... SELECT 填充主表。(通过这种方法节省下来的代价源于 load 的速度很快,再加上 INSERT ... SELECT 是在 DB2 内(在服务器上)传输数据的,从而消除了通信上的代价。一般情况下我们不会使用这种方法,除非在 INSERT ... SELECT 中还要另外做 load 无法完成的处理。
假如不可能在一条 insert 语句中传递多行,那么最好是将多条 insert 语句组成一组,将他们一起从客户机传递到服务器。(但是,这意味着每条 insert 都包含不同的值,都需要准备,因而其性能实际上要比使用参数标记情况下的性能更差一些。)将多条语句组合成一条语句能够通过 Compound SQL 来实现。在 SQL 中,复合语句是通过 BEGIN ATOMIC 或 BEGIN COMPOUND 语句创建的。在 CLI 中,复合语句能够通过 SQLExecDirect 和 SQLExecute 调用来建立。对于 DB2 V8 FixPak 4,另一种生成复合语句的方法是在(对一条预处理语句)发出多个 SQLExecute 调用之前配置语句属性 SQL_ATTR_CHAINING_BEGIN,并在调用之后配置语句属性 SQL_ATTR_CHAINING_END。
下面是关于该话题的其他一些建议:
假如可能的话,让客户机和要存取的数据库使用相同的代码页,以避免在服务器上的转换代价。数据库的代码页能够通过运行“get db cfg for ”来确定。
在某些情况下,CLI 会自动执行数据类型转换,但是这样同时也会带来看不见的(小小的)性能损耗。因此,尽量使插入值直接处于和相应列对应的格式。
将应用程式中和插入相关的配置开销最小化。例如,当在 CLI 中使用数组插入时,对于整个一组插入,应该尽量确保对于每一列只执行一次 SQLBindParameter,而不是对每一组数组内容都执行一次。对于个体来说,这些调用的代价并不高,但是这些代价是累积的。
3. 找到存储行的地方
DB2 使用三种算法中的一种来确定将行插入到哪里。(假如使用了多维群集(Multi-dimensional Clustering,MDC),则另当别论,我们在这里不予讨论。)
缺省模式是,DB2 搜索散布在表的各页上的自由空间控制记录(Free Space Control Records,FSCR),以找到有足够自由空间存放新行的页。显然,假如每页上的自由空间都比较少的话,就要浪费很多的搜索时间。为了应付这一点, DB2 提供了 DB2MAXFSCRSEARCH 注册表变量,以便允许将搜索范围限制为少于缺省的 5 页。
当表是通过 ALTER TABLE 以 APPEND 模式放置时,就要使用第二种算法。这样就完全避免了 FSCR 搜索,因为只需简单地将行直接放到表的末尾。
当表有群集索引(clustering index)时,就要用到最后一种算法。在这种情况下,DB2 试图将每一行插入到有相似键值的一页中。假如那一页没有空间了,DB2 就会尝试附近的页,假如附近的页也没有空间,DB2 就进行 FSCR 搜索。
假如只考虑插入时间的优化,那么使用 APPEND 模式对于批量插入是最快的一种方法,但是这种方法的效果远不如我们这里讨论的很多其他方法那么成效显著。第二好的方法应该是采用缺省算法,但是,假如在最好环境中,更改 DB2MAXFSCRSEARCH 的值影响很小,而在一个 I/O 约束较少的环境中,这种更改所造成的影响就比较可观了。
假如有群集索引,则对 insert 的性能会有很大的负面影响,这一点也不惊奇,因为使用群集索引的目的就是通过在插入时做额外的工作来提高查询(即 select)性能的。假如的确需要群集索引,那么能够通过确保有足够的自由空间来使其对插入的影响降至最小:使用 ALTER TABLE 增加 PCTFREE,然后使用 REORG 预留自由空间。但是,假如允许太多自由空间的存在,则可能导致查询时需要读取额外的页,这反而大大违反了使用群集索引的本意。另一种选择是,在批量插入之前先删除群集索引,而后再重新创建群集索引,也许这是最优的方法(创建群集索引的开销跟创建常规索引的开销差不多,都不是很大,只是在插入时有额外的开销)。
4. 缓冲池、I/O 和页清除
每一条 insert 在执行时,都是先将新行存储在一个页中,并最终将那个页写到磁盘上。一旦像前面讨论的那样指定了页,那么在将行添加到该页之前,该页必须已在缓冲池中。对于批量插入,大部分页都是最新指派给表的,因此让我们关注一下对新页的处理。
假如表在系统管理存储的(System Managed Storage,SMS)表空间中,当需要新页时,缺省情况下是从文档系统中分别为每一页分配空间。但是,假如对数据库运行了 db2empfa 命令,那么每个 SMS 表空间就会为新页一次性分配一个区段。我们建议运行 db2empfa 命令,并使用 32 页的区段。
对于数据库管理的存储(Database Managed Storage,DMS)表空间,空间是在创建表空间时就预先分配的,但是页的区段则是在插入处理过程中指派给表的。和 SMS 相比,DMS 对空间的预分配能够提高大约 20% 的性能 -- 使用 DMS 时,更改区段大小并没有明显的效果。
假如表上有索引,则对于每个插入的行,都要添加一个条目到每条索引。这需要在缓冲池中存在适当的索引页。晚些时候我们将讨论索引的维护,但是现在只需记住,插入时对缓冲池和 I/O 的考虑也类似地适用于索引页,对于数据页也是相同。
随着插入的进行,越来越多的页中将填入被插入的行,但是,DB2 不需要在 insert 或 Commit 后将任何新插入的或更新后的数据或索引写入到磁盘。(这是由于 DB2 的 writeahead 日志记录算法。但是有一个例外,这将在关于日志记录的小节中论述到。)然而,这些页需要在某一时刻写到磁盘上,这个时刻可能会在数据库关闭时才会轮到。
一般来说,对于批量插入,您会希望积极地进行 异步页清除(asynchronous page cleaning),这样在缓冲池中就总有可用于新页的空余位置。页清除率,或说总缺页率,可能导致计时上的很大不同,使得性能比较容易产生误解。例如,假如使用 100,000 页的缓冲池,并且不存在页清除,则批量插入在结束前不会有任何新的或更改过的(“脏的”)页写到磁盘上,但是随后的操作(例如选择,甚至乎关闭数据库)都将被大大推迟,因为这时有至多 100,000 个在插入时产生的脏页要写到磁盘上。另一方面,假如在同一情况下进行了积极的页清除,则批量插入过程可能要花更长的时间,但是此后缓冲池中的脏页要少一些,从而使得随后的任务执行起来性能更佳。至于那些结果中到底哪个要更好些,我们并不是总能分得清,但是通常来说,将任何脏页都存储在缓冲池中是不可能的,所以为了取得最好性能,采取有效的页清除是有必要的。
为了尽可能好地进行页清除:将 CHNGPGS_THRESH 数据库配置参数的值从缺省的 60 减少到 5 这么低。这个参数决定缓冲池中脏页的?值百分比,当脏页达到这个百分比时,就会启动页清除。
尝试启用注册表变量 DB2_USE_ALTERNATE_PAGE_CLEANING(在 DB2 V8 FixPak 4 中最新提供)。通过将这个变量配置成 ON,能够为页清除提供一种比缺省方法(基于 CHNGPGS_THRESH 和 LSN 间隙触发器)更积极的方法。我没有评测过其效果。请参阅 FixPak 4 Release Notes 以了解这方面的信息。确保 NUM_IOCLEANERS 数据库配置参数的值至少等于数据库中物理存储设备的数量。
至于 I/O 本身,当需要建立索引时,能够通过使用尽可能大的缓冲池来将 I/O 活动减至最少。假如不存在索引,则使用较大的缓冲池帮助不大,而只是推迟了 I/O。也就是说,他允许任何新页暂时安放在缓冲池中,但是最终仍需要将这些页写到磁盘上。
当发生将页写到磁盘的 I/O 时,通过一些常规的 I/O 调优步骤能够加快这一过程,例如:
将表空间分布在多个容器(这些容器映射到不同磁盘)。
尽可能使用最快的硬件和存储管理配置,这包括磁盘和通道速度、写缓存连同并行写等因素。
避免 RAID5(除非是和像 Shark 这样有效的存储设备一起使用)。
5. 锁
缺省情况下,每一个插入的行之上都有一个 X 锁,这个锁是在该行创建时就开始有的,一直到 insert 被提交。有两个跟 insert 和锁相关的性能问题:
为获得和释放锁而产生的 CPU 开销。
可能由于锁冲突而导致的并发问题。
对于经过良好优化的批量插入,由获得每一行之上的一个 X 锁连同后来释放该锁引起的 CPU 开销是比较可观的。对于每个新行之上的锁,惟一能够替代的是表锁(DB2 中没有页锁)。当使用表锁时,耗时减少了 3%。有 3 种情况能够导致表锁的使用,在讨论表锁的缺点之前,我们先用一点时间看看这 3 种情况:
运行 ALTER TABLE LOCKSIZE TABLE。这将导致 DB2 为随后使用该表的任何 SQL 语句使用一个表锁,直到 locksize 参数改回到 ROW。
运行 LOCK TABLE IN EXCLUSIVE MODE。这将导致表上立即上了一个 X 锁。注意,在下一次提交(或回滚)的时候,这个表将被释放,因此,假如您要运行一个测试,测试中每 N 行提交一次,那么就需要在每次提交之后重复执行 LOCK TABLE。
使用缺省锁,但是让 LOCKLIST 和 MAXLOCKS 数据库配置参数的值比较小。当获得少量的行锁时,行锁就会自动地逐渐升级为表锁。
当然,任何这些的缺点就在于并发的影响:假如表上有一个 X 锁,那么其他应用程式除非使用了隔离级别 UR(未提交的读),否则都不能访问该表。假如知道独占访问不会导致问题,那么就应该尽量使用表锁。但是,即使您坚持使用行锁,也应记住,在批量插入期间,表中可能存在数千个有 X 锁的新行,所以就可能和其他使用该表的应用程式产生冲突。通过一些方法能够将这些冲突减至最少:
确保锁的升级不会无故发生。您可能需要加大 LOCKLIST 和/或 MAXLOCKS 的值,以允许插入应用程式有足够的锁。对于其他的应用程式,使用隔离级别 UR。
对于 V8 FixPak 4,或许也能够通过 DB2_EVALUNCOMMITTED 注册表变量来减少锁冲突:假如将该变量配置为 YES,那么在很多情况下,只能获得那些符合某个谓词的行上的锁,而并不是获得被检查的任何行上的锁。
发出一个 COMMIT 命令以释放锁,因此假如更频繁地提交的话就足以减轻锁冲突的负担。
注意
在 V7 中,存在涉及 insert 和键锁的并发问题,但是在 V8 中,由于提供了 type-2 索引,这些问题实际上已不见了。假如要迁移到 V8 中来,那么应该确保使用带 CONVERT 关键字的 REORG INDEXES 命令,以便将索引从 type-1 转换为 type-2。
在 V7 中,插入过程中可能使用 W 或 NW 锁,但是在 V8 中只有在使用了 type-1 索引或隔离级别为 RR 的情况下才会出现这两种锁。因此,应尽可能避免这两种情况。
一条 insert 所据有的锁(通常是个 X 锁)通常不会受隔离级别的影响。例如,使用隔离级别 UR 不会阻止从插入的行上获得锁。然而,假如使用了 INSERT ... SELECT,则隔离级别将影响从 SELECT 获得的锁。
6. 日志记录
缺省情况下,每条 insert 都会被记录下来,以用于恢复。日志记录首先被写到内存中的日志缓冲池,然后再写到日志文档,通常是在日志缓冲池已满或发生了一次提交时写到日志文档的。对批量插入的日志记录的优化实际上就是最小化日志记录写的次数,连同使写的速度尽可能快。
这里首先考虑的是日志缓冲池的大小,这由数据库配置参数 LOGBUFSZ 来控制。该参数缺省值为 8 页或 32 K,这和大多数批量插入所需的理想日志缓冲池大小相比要小些。举个例子,对于一个批量插入,假设对于每一行的日志内容有 200 字节,则在插入了 160 行之后,日志缓冲池就将被填满。假如要插入 1000 行,因为日志缓冲池将被填满几次,再加上提交,所以大概有 6 次日志写。假如将 LOGBUFSZ 的值增加到 64 页(256K)或更大,缓冲池就不会被填满,这样的话对于该批量插入就只有一次日志写(在提交时)。通过使用更大的 LOGBUFSZ 能够获得大约 13% 的性能提升。较大日志缓冲池的不利之处是,紧急事故恢复所花的时间可能要稍微长一点。
减少日志写的另一种可能性是对新行要插入到的那个表使用“ALTER TABLE ACTIVATE NOT LOGGED INITIALLY”(NLI)。假如这样做了,那么在该工作单元内不会记录任何 insert 操作,但是这里存在两个和 NLI 有关的重要问题:
假如有一条语句失败,那么这个表将被标记为不可访问的,并且需要被删除掉。这和其他恢复问题(请参阅 SQL Reference 关于 Create Table 的讨论)一起使得 NLI 在很多情况下不能成为可行的方法。
在工作单元最后进行的提交,必须等到在此工作单元内涉及的任何脏页都被写到磁盘之后才能完成。这意味着这种提交要占用大量的时间。假如没有积极地进行页清除,那么在使用 NLI 的情况下,Insert 加上提交所耗费的总时间要更长一些。将 NLI 和积极的页清除一起使用的时候,能够大大减少耗时。假如使用 NLI,就要瞪大眼睛盯紧提交操作所耗费的时间。
至于提高日志写的速度,有下面一些可能性:
将日志和新行所要插入到的表分别放在不同的磁盘上。
在操作系统层将日志分放到多个磁盘。
考虑为日志使用原始设备(raw device),但是要注意,这样管理起来要更困难些。
避免使用 RAID 5,因为他不适合于写密集型(write-intensive)活动。
7. 提交
提交迫使将日志记录写到磁盘上,以确保提交的插入肯定会存在于数据库中,并且释放新行上的锁。这些都是有价值的活动,但是因为 Commit 总是要牵涉到同步 I/O(对于日志),而 insert 则不会,所以 Commit 的开销很容易高于 insert 的开销。因此,在进行批量插入时,每一行都提交一次的做法对于性能来说是很糟糕的,所以应确保不使用自动提交(对于 CLI 和 CLP 来说缺省情况正是如此)。建议大约每 1000 行提交一次:当每 1000 行而不是一两行提交一次时,性能能够提高大概 10 倍。但是,一次提交多于 1000 行只能节省少量的时间,但是一旦出现失败,恢复起来所花的时间要更多。
对上述方法的一种修正:假如 MINCOMMIT 数据库配置参数的值大于 1 (缺省值),则 DB2 就不必对每次 commit 都进行一次同步 I/O,而是等待,并试图和一组事件一起共享日志 I/O。对于某些环境来讲,这样做是有好处,但是对于批量插入常常没有作用,甚至有负作用,因此,假如要执行的关键任务是批量插入,就应该让 MINCOMMIT 的值保持为 1。
能够选择性地进行改进的地方
对于一次 insert,有几种类型的处理将自动发生。假如您的主要目标只是减少插入时间,那么最简单的方法是避免任何这些处理的开销,但是假如从总体上考虑的话,这样做未必值得。让我们依次进行讨论。
索引维护
对于插入的每一行,必须添加一个条目到表上的每个索引中(包括任何主键索引)。这一过程主要有两方面的代价:
遍历每个索引树,在树的每一层搜索一个页,以确定新条目必须存储在哪里(索引条目总是按键顺序存储的),这一过程所引起的 CPU 开销;
将任何搜索到的页读入缓冲池,并最终将每个更新后的页写到磁盘上的 I/O 开销。
更坏的场景是,在索引维护期间有大量的随机 I/O。假设要插入 10,000 行,在索引的缓冲池中有 5000 页,并且要插入的各行的键值随机分布在整个键范围内。那么,有 10,000 个这么多的叶子页(可能更有些非叶子页)需要进入缓冲池,以便对他们进行搜索和/或更新,对于一个给定的叶子页,他预先已在缓冲池中的概率只有 10%。对于每次的 insert,需要读磁盘的概率如此之高,使得这种场景往往性能很差。
对于逐行插入,将新行添加到已有的索引中比起创建一个新索引来代价要高得多。假如是插入到一个空表,应该总是在进行了列插入之后创建索引。(注意,假如使用了 load,则应该预先创建索引。)假如要插入到一个已填充过的表,那么在列插入之前删除索引,并在列插入之后重新创建索引,这种方法可能是最快的,但是只有在要插入相当多的行 -- 大概大于表的 10-20% 的时候,才能这么说。假如为索引表空间使用较大的缓冲池,并且尽可能地将不同 insert 排序,以便键值是排好序的,而不是随机的,就能够帮助加快索引维护。