除了前面两章中我们接触到的简单的数据库操作,iBATIS也可以完成更为复杂的任务。在本章中,我们会了解新的技术,减少我们的编码量;以及改善性能、降低资源消耗(footprint)的几种方法。
6.1 使用iBATIS操作XML
译者注:iBATIS的Java版本可以操作基于XML的数据。但意义并不是很大,在以后的版本中该特性可能会被移除。iBATIS.NET则未提供该功能。
6.2 使用映射语句关联对象
iBATIS框架也提供了多种方法用以关联复杂的对象,比如订单(order)和它的订单项(order item)(还有它们的相关产品、顾客等等)。每种方法都有其优点和缺点,正所谓“尺有所短,寸有所长”,每一种方案都不是完美的。应根据需要来选择适合的方案。
注意:为简短起见,在本章的余下的例子中,我们将省略那些对于演示来说不必要的数据。例如,当我们获取了一个顾客(customer)对象,我们不会获取它的所有字段,而是仅仅获取它的主键和外键。
6.2.1 复杂的集合属性
在第4章中,我们学习了如何使用SELECT语句从数据库获取数据。在那些例子中,我们获取的结果仅仅是单个对象,即使是连接多表也是如此。事实上,如果您有多个复杂对象,也可以使用iBATIS加载它们。
如果我们的应用程序模型与数据模型比较类似,那么这个功能会很有用。可以考虑根据对象的关系(关联)来定义数据模型,然后使用iBATIS将它们一起加载。例如,如果在数据库中,Account记录对应着相关的Order记录,而Order又对应着相关的OrderItem记录,可以为这些记录建立关系,当我们请求一条Account记录时,可以一并获取所有的Order和OrderItem记录。下面的代码清单显示了如何定义我们的SQL映射:
先来看看结果映射(result map,即上面的ResultAccountInfoMap,ResultOrderInfoMap和ResultOrderItemMap),前两个Map都用到了select特性。这个特性告诉iBATIS,属性的值将由另一个映射语句来设置,语句的名称就是select特性的值。例如,我们执行getAccountInfoList语句时,ResultAccountInfoMap结果映射有一个子元素:
它的作用是告诉iBATIS,account对象的orderList属性的值由Ch6.getOrderInfoList语句来设置,同时把accountId列的值传给Ch6.getOrderInfoList作为参数。类似地,在设置order对象的orderItemList对象时,也会执行getOrderItemList语句。
这个功能给我们带来便利的同时,也带来了两个问题。首先,创建包含大量对象的列表可能会消耗大量的内存。其次,这种方法会导致数据库的I/O问题,其原因是所谓的“N+1 Select”现象,这个现象将在后面讨论。对于每个问题,iBATIS框架都提供了解决方案,但是注意,没有哪一种能同时解决这两个问题。
数据库I/O
数据库I/O是数据库使用状况的一项指标,也是数据库性能的主要瓶颈之一。在读取或写入数据库时,数据必须要经历从磁盘到内存或者从内存到磁盘的转换,这个过程是比较耗时的。在程序中使用缓存可以减少对数据库的访问,但这种方法使用时要谨慎,否则也会引发问题。要了解iBATIS中的缓存机制,可以参看第10章的内容。
在使用关联数据时,可能会遭遇数据库I/O问题。考虑一下这个场景:有1000个Account,每一个关联了1000个Order,而每个Order则包含25个OrderItem。如果尝试将所有这些数据加载到内存,执行的SQL语句要超过1000000行(1条用来查询Account,1000条用于Order,1000000条用于OrderItem),而创建的对象大约为2500万——如果你真敢这么做,等你的系统管理员收拾你吧。
分析N+1查询问题
N+1查询问题是由于试图加载多个父记录(比如Account)的子记录(Order)而引起的。因此,在查询父记录时,只需要1条语句,假设返回N条记录,那么就需要再执行N条语句来查询子记录,引发所谓的“N+1查询”。
这些问题的解决方案
延迟加载(Lazy load,在6.2.2中详细讲述)可以解决一部分内存问题,它将加载过程打散为一些更小的过程。但是,它并没有解决数据库I/O问题,在最坏的情况下,它对数据库的访问次数与非延迟加载的版本是一样的,因为加载数据时它的方法还是N+1查询(这个我们将在6.2.3中解决)。另一方面,当我们解决了N+1查询问题,减少了对数据库的访问,但我们的查询结果却包含着2500万行记录!
要决定是否使用复杂属性,我们需要理解数据库以及应用程序对数据库的使用方式。如果您使用了本节中的技术,那可以省不少事儿,但如果误用了它,也会有大麻烦。在接下来的两节中,我们会分析如何根据目标选择合适的策略。
让我们从这个问题开始:像上面例子那样将Account关联到Order并将Order关联到OrderItem是否合适?实际上,不是——order-to-orderitem关系是固定的,但是account-to-order关系则是不必要的。
我们是如是推理的:没有所属的Order,OrderItem是没有意义的,而Account则是有意义的。一般情况下,没有OrderItem,Order没什么大用,相对的,不属于任何Order的OrderItem是没有意义的。另一方面,一个Account则可以认为是一个完整的对象。
但在我们的例子中,这种关系可以良好地描述相关的技术,因此我们会在一段时间内一直使用它。
6.2.2 延迟加载(Lazy loading)
首先来看看延迟加载。如果不是对所有数据都马上用到,那么延迟加载是有用的。例如,我们的程序首先在一个网页显示所有Account,然后销售代理(我们的客户)可以点击一个Account来查看该Account的Order列表,然后可以再点击一个Order来查看其所有的OrderItem信息。在这种情况下,每次都仅查询一个列表。这是对延迟加载的合理使用。
译注:在iBATIS的Java版本中,使用延迟加载前还需要进行配置SqlMapConfig.xml以打开该功能。在.NET版本中不需要配置等价的sqlMap.config。
使用了延迟加载后,我们就可以更合理地进行对象创建和对数据库的访问。(还是使用上面的例子)如果一个用户关注到OrderItem层次的数据,我们需要进行三次查询(一次是为Account,一次是Order,还有一次是OrderItem),应用程序则要创建2025个对象(1000个Account, 1000个Order,25个OrderItem)。效果明显!而我们要做的仅仅是修改XML配置文件的一个特性(attribute)而已,无需改动代码。
在一项不太严谨的测试中,我们发现,对于同样的对象关联关系(如上面的Account- Order- ORderItem),在加载第一个列表数据时(Account列表),没有使用延迟加载的版本花费的时间是使用了延迟加载的版本的三倍。但是,在加载所有数据时,延迟加载的版本的时间却多了20%。很明显,我们要根据数据加载的数量和时机来确定是否采用延迟加载。此时,经验是最重要的。
而有时您并不希望推迟数据的加载,而是希望在第一次请求的时候加载所有的数据。在这种情况下,您可以使用下节中的技术,它仅需要一次查询即可。下节的方法避免了“N+1查询”。
6.2.3 避免“N+1查询”问题
我们来考虑如何避免“N+1查询”问题,这里可以使用连接语句(Join)。
这里用到的技术同前面类似。简单的说,使用Result Map来定义对象间的关系,将顶层的Result Map关联到映射语句。下面的例子的Data Map文件结构与前面大体一致,但是只需要执行一条SQL语句。
这里面有三个Result Map,一是关于Account的,二是关于Order的,三是OrderItem的。
关于Account的Result Map有两个作用:
Order的Result Map作用与之类似。
我们的不太科学的测试表明,在加载少量数据时,该方法将原先方法的性能提高为7:1。我们猜想,对于例子中使用的2500万条数据,两种方法仍然不错。
译注:在iBATIS.NET DataMapper 1.6.1中,添加了groupBy特性,它将进一步改善性能。详细内容请参看相关文档,本文使用的是DataMapper 1.5.1。
需要注意的是,尽管性能得到改善,内存的消耗仍然与没有使用延迟加载的版本相同。所有的记录一起放入内存,因此尽管它稍微快了一点,但内存的消耗仍是问题。
译注:在加载复杂属性时可能出现两方面的问题,一是对数据库的访问,二是创建对象时对内存的消耗。我们可以采用延迟加载或Join的方法来解决这些问题,但是两者都不是万灵药。延迟加载的原理时推迟对复杂属性的加载,以减少对数据库的访问和对象的创建,但它的前提是复杂属性不会马上用到,否则的话,延迟就失去意义。Join的原理是通过一条SQL语句加载所有数据,这样可以大幅度减少对数据库的访问量,它的前提是对象的数量不会太多。该如何选择呢?下面的表格给出了简单的原则:
延迟加载 |
Join |
如果要加载大量的数据,它们不会马上用到,延迟加载会比较合适。 | 数据量较小或者数据马上就会用到,Join方法比较合适。 |
译注:另外,我觉得还有一条很重要的原则,那就是永远只加载必需的数据。以上面的例子来说,我们不太可能会同时显示1000个Account给用户看,这时就不要同时加载1000个Account的数据了,可以通过分页只显示50条数据,在此基础上再应用延迟加载或Join效果会很不错。