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