Java性能调优18——数据库最佳实践(上)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

本章主要探讨由 Java 驱动的数据库应用的性能(而不会讨论数据库优化相关内容,如果需要的话可能需要新开一个系列)。访问数据库的应用程序经常会受制于一些与 Java 不直接相关的性能问题,譬如使用的数据库有 I/O 瓶颈,或者由于关键索引缺失,导致 SQL 查询需要做全表扫描。要解决这些问题,不必调优 JVM,也不需要修改应用代码。处理数据库相关的性能问题需要我们(从其他的源头)了解如何为数据库应用编程及调优。

但这并不是说,使用数据库的应用的性能与 JVM 管理的对象及使用的 Java 技术没有多少相关性。恰恰相反,为了获得更优的性能,我们要确保数据库和应用都得到最好的调优,运行于最佳的代码路径之上。

JDBC

本章从 Java 持久化 API(Java Persistence API,JPA)的角度讨论数据库的性能——JPA 采用的版本是 2.0。然而,JPA 的底层调用也是 JDBC,很多程序员在编写程序时还是喜欢在应用代码中直接调用 JDBC 接口——因此,这也是我们调查 JDBC 性能问题时重要的一方面。即使应用程序已经使用了 JPA(或者其他的数据库框架),理解哪些方面会影响 JDBC 的性能对我们最大限度地提升框架性能的目标也是相当有益的。

JDBC驱动程序

JDBC 驱动程序是影响数据库应用程序性能的诸多因素中最重要的一个。几乎每种数据库都提供了自己的 JDBC 驱动程序,大多数流行的数据库往往还有第三方厂商的 JDBC 驱动程序可用。通常情况下,使用第三方驱动程序的原因都是它们能提供更好的性能。

我无法对所有数据库驱动程序的性能表现作裁决,但是我们可以在这里探讨评估不同数据库驱动程序时应该考虑的因素。

  • 1.工作于何处进行

    JDBC 驱动程序的实现有两种选择,要么在 Java 应用端(即数据库的客户端)完成更多的工作;要么将工作推迟到数据库服务器端进行。阐释这个问题最合适的例子是 Oracle 数据库的“瘦驱动程序”和“胖驱动程序”。瘦驱动程序的实现对 Java 应用程序自身的影响极小,它依赖数据库服务器进行更多的处理工作。胖驱动程序的实现与此相反:它将数据库的工作迁移到 Java 客户端来完成,代价是客户端会消耗更多的处理能力和更多的内存。大多数的数据库都提供了类似的方案。

    关于哪种模式能提供更好的性能,有诸多的争论。实际上,这两种模式都不能提供以不变应万变的优势——即到底采用哪种驱动程序能获得最好的性能往往取决于具体的运行环境。譬如,应用程序运行的服务器比较弱,是个双核的机器,它连接的是一个性能强大、调优良好的数据库服务器,那么在这种情况下,很可能应用还没施加多大的压力到数据库,应用服务器自身的 CPU 就已经达到了 100%。这种情况下,使用“瘦客户端”类型的驱动程序往往能获得更好的性能。与此相反,在一个大型企业中,100 多个部门都需要访问人力资源数据库时,尽量保留数据库服务器的资源,部署“胖客户端”型的驱动程序才能获得更好的性能。

    这就是我们谈论 JDBC 驱动程序的性能时要保持谨慎怀疑态度的原因:经常会出现这样的情况,即某个驱动程序在特定的环境下表现的性能优于其他供应商,但在你自己的环境中,采用同样的配置效果却不尽人意。尽量在你自己的环境中进行测试,务必确保你的测试环境与你将来部署的生产环境保持一致。

  • 2.JDBC驱动程序的类型

    JDBC 驱动程序可以划分为四种类型(1-4)。目前使用最广泛的是 2 型(使用本地代码)和 4 型(纯 Java 代码)。

    1 型驱动程序在 ODBC 和 JDBC 之间搭建了一座通信的桥梁。如果应用程序需要访问 ODBC 的数据库,只能使用这种类型的驱动程序。1 型驱动程序通常性能都不好;尽量避免使用 ODBC。

    3 型和 4 型的驱动程序都采用纯 Java 语言编写,但是 3 型的驱动程序通常是为特定的架构而设计的,这些架构中,中间件的某些部分(有时也可能是应用服务器,不过绝大多时候都不是)会对中间结果进行翻译。在这种架构中,JDBC 客户端程序(通常是独立的程序,不过最常见的还是应用服务器)向中间件发送 JDBC 协议报文,中间件解析这些请求,将其转译为数据库协议,并转发到数据库服务器(对数据库返回的数据采用逆向的转译)。

    这种架构有时是必不可少的:中间件部署在网络的非军事区(Demilitarized Zone,DMZ),为访问数据库的连接提供额外的安全保护。从性能的角度看,这种部署方式既有其优势也有其弊端。中间件可以对数据库中的记录进行缓存,而这能减轻数据库服务器的压力(在某种程度上让它跑得更快),能够更快地返回客户端请求的数据(降低了请求的延迟)。不过,如果关闭了中间件的缓存,数据库访问的性能就会受到影响,因为每次数据库操作都要经历两次网络往返。理想的情况下,这些因素可以忽略不计(或者由于缓存,数据的请求响应得更快)。

    然而在实际生产环境中,这种架构并没有广泛地部署(一如往常,情况总在不断地变化。譬如,甲骨文公司为它的分布式远程连接池(Distributed Remote Connection Pool,DRCP)提供了 JDBC 驱动程序,这是一种 3 型驱动程序,不过它和 4 型 JDBC 驱动程序一样也采用了 JAR 文件作为发布的格式,但是,对于终端用户而言,JDBC 驱动程序到底是 3 型还是 4 型并不是那么重要)。通常情况下,将应用服务器部署到中间层(如果有必要,就部署到 DMZ 之内)是更容易的方案。这样,应用服务器既能完成正常的数据库操作,又不必为客户端提供任何 JDBC 的接口:由于提供了诸如 servlet 接口、Web Service 接口等等这样的服务,架构的灵活性增强了,客户端不再需要了解任何数据库内部的细节。

    这之后剩下的是 2 型和 4 型驱动程序,但是跟其他的驱动程序比起来,这二者中的任何一个都不具备与生俱来的性能优势。2 型驱动程序会受 JNI 引入的开销影响,但是设计良好的 2 型驱动程序能避免这样的问题。不要将驱动程序的类型(2 型或 4 型)和前文介绍过的驱动程序的特征——它是“胖驱动程序”还是“瘦驱动程序”混为一谈。虽然 2 型驱动程序往往具有胖驱动程序的特征,但 4 型驱动程序通常表现出瘦驱动程序的特点,但是这并非严格意义上的要求。最后,选择 2 型驱动程序还是 4 型驱动程序,取决于你的环境以及具体的驱动程序。并没有一种先验的方法能判断哪种方式一定能获得更好的性能。

JDBC驱动小结

  1. 花时间评估挑选出最适合你的应用程序的 JDBC 驱动程序。

  2. 最合适的驱动程序往往依特定的部署环境而有所不同。对同样的应用程序,在一个部署环境中可能要使用 JDBC 驱动程序,在另一个部署环境中则要采用不同的 JDBC 驱动程序,才能有更好的性能。

  3. 如果可以选择,尽量避免使用 ODBC 和 JDBC 1 型的驱动程序。

预处理语句和语句池

大多数情况下,代码中若要进行 JDBC 调用,推荐使用 PreparedStatement,尽量避免直接使用 Statement。这二者的区别在于预处理语句让数据库有机会重用已经执行过的 SQL 信息。而这能够帮助节省之后运行的预处理语句的开销,提升执行效率。

“重用”这个词用在这儿是相当贴切的:首次使用预处理语句时数据库会耗费更多的执行时间,因为它需要设置和保存相应的信息。如果这个语句仅使用一次,这些额外的工作就被浪费了,这种情况下,使用常规的语句(Statement)可能是更好的选择。

对只进行少量数据库调用的批处理类型(batch-oriented)的程序,选择 Statement 接口能让程序更迅速地完成工作。但是批处理类型的程序也可能发起成百上千的 JDBC 调用,不过调用的对象都是几乎一样的几个 SQL 语句;后者就是本章要讨论的对象,它使用批处理程序加载一个含有 10 000 条股票记录的数据库。需要处理大量 JDBC 调用的批处理程序,包括生命周期内要服务大量请求的应用服务器,使用 PreparedStatement 接口都能取得较好的效果(JPA 这样的数据库框架会自动采用这样的接口)。

对预处理语句进行池化(pooled),才能体现出预处理语句的性能优势——即实际生产中,重用 PreparedStatement 对象才能更显著地提升性能。为了更好地进行“池化”,我们需要注意两件事,即 JDBC 连接池以及 JDBC 驱动程序的配置(语句池根据数据库供应商的不同,也常常被称作语句缓存,Statement Caching)。这些配置选项适用于任何使用 JDBC 的应用程序,无论它们是直接使用 JDBC 驱动程序,还是以 JPA 的方式间接调用 JDBC。

  • 1.设置语句池(statement pool)

    预处理语句池基于每个连接进行工作。如果程序的一个线程从池内取得一个 JDBC 连接,并以此为基础创建一个预处理语句,那么所有跟这个语句相关的内容都仅在该连接的生命周期内有效。第二个线程会使用新的连接创建自己的预处理语句连接池实例。最终,每个连接对象都会创建自己的预处理语句池,服务于应用程序(假设它们在应用程序的生命周期中会一直被使用)。

    这就是为什么独立的 JDBC 应用应该使用连接池的原因之一(JPA 会为 Java SE 的应用程序创建一个连接池,并且这个过程是透明的,Java EE 环境中,程序可以使用应用服务器的连接池)。这也意味着连接池的大小是非常重要的(无论是使用 JDBC 还是使用 JPA 的程序)。在程序的早期运行中,这一点尤其重要:如果连接没有使用预处理语句,第一个请求的处理就会比较慢。选择合适的连接池大小也非常重要,因为连接池要缓存预处理语句,而这些预处理语句的缓存会占用堆空间(并且通常是大量的堆空间)。这种情况下,对象重用当然是件好事,但是你还需要考察重用的对象要占用多少堆空间,以避免对 GC 时间产生负面的影响。

  • 2.语句池的管理

    使用预处理语句池时第二件要特别注意的事是哪些代码会实际地创建和管理池。预处理语句池在 JDBC 3.0 中首次引入,它提供了一个方法(即 ConnectionPoolDataSource 类的 setMaxStatements() 方法)用于开启和禁用语句池。如果传递给 setMaxStatements() 方法的参数是 0,语句池就被禁用。这个接口并未明确定义语句池在什么地方创建——是在 JDBC 驱动程序层面还是在其他的层面上,譬如应用服务器上。这个单一的接口无法适配所有的 JDBC 驱动程序,有些情况还需要额外的配置才能满足需要。

    因此,编写直接进行 JDBC 调用的 Java SE 应用时,你有两个选择:要么通过配置 JDBC 驱动程序来创建和管理语句池,要么在应用程序代码中创建和管理语句池。Java EE 的应用有两种可能性(略微不同):由 JDBC 驱动程序来创建和管理语句池,或者由应用服务器来创建和管理语句池。

    这里最麻烦的问题是这个领域没有统一的标准。有的 JDBC 驱动程序根本没有提供语句池的机制,它们的开发者希望能简化驱动程序的结构,由应用服务器来完成语句池的管理。有的应用服务器并未提供语句池管理的功能;它们希望由 JDBC 驱动程序来进行这方面的工作,不愿意覆盖复杂的语句池管理。这两种观点都有一定的道理(不过,如果 JDBC 驱动程序不提供语句池管理机制,这副重担就落在了应用程序开发者的肩上)。最终,你要厘清不同方案的利弊,确保语句池在某个地方创建。

    由于没有公认的标准,你可能会碰到 JDBC 驱动程序和应用服务器同时支持预处理语句池的情况。这时,确保只有一方在管理预处理语句池是非常重要的。从性能的角度出发,更好的选择取决于驱动程序和应用服务器的组合。通用的原则是,我们可以期望 JDBC 驱动程序在语句池的管理上性能更优。这是因为驱动程序(通常情况下)是针对特定的数据库开发的,会针对这种数据库进行相应的优化,而更通用的应用服务器代码很难做到这一点。

    想要了解如何开启某个 JDBC 驱动程序的语句池(或者缓存池)功能,可以查询驱动程序的文档。很多时候,你只需要将驱动程序的 maxStatements 属性设定为期望的值(即语句连接池的大小)就完成了配置的工作。另一些驱动程序可能还需要进行额外的设置(譬如,Oracle 的 JDBC 驱动程序就需要设置某些属性,并依此决定使用隐式的语句池还是显示的语句池)。

预处理语句和语句池小结

  1. Java 应用程序通常都会重复地运行同样的 SQL 语句。这些情况下,重用预处理语句池能极大地提升程序的性能。

  2. 预处理语句必须依单个连接进行池化。大多数的 JDBC 驱动程序和 Java EE 框架默认都提供了这一功能。

  3. 预处理语句会消耗大量的堆空间。我们需要仔细调优语句池的大小,避免由于对大量大型对象池化而引起 GC 方面的问题。

JDBC连接池

创建数据库连接是非常耗时的操作,因此,JDBC 连接是 Java 程序中应该尽量重用的典型对象。

在 Java EE 的环境中,所有的 JDBC 连接都源自应用服务器的连接池。在使用 JPA 的 Java SE 环境中,大多数的 JPA 提供商都默认使用一个连接池,你可以通过配置文件 persistence.xml 对连接池进行自定义。单机版的 Java SE 环境里,连接是由应用程序来控制的。对于最后一种情况,你可以从连接池的第三方库中选择其一进行构建,这些库从很多渠道都能找到。不过,对于单独的应用程序,很多时候,最简单的方式是为每个线程创建一个连接,将它保存在线程的本地变量中。

一如往常,平衡池化对象的内存占用以及由于池化触发的额外 GC 数量是非常重要的。由于预处理语句缓存的开销,这一点变得尤其要紧。实际的连接对象可能不是非常大,但是语句缓存(它们建立在每个连接的基础之上)可以变得非常大。

这种情况下,数据库也需要寻求适当的平衡。每个数据库的连接都需要数据库分配资源(此外,应用程序还要消耗一定的内存)。随着连接不断地添加到数据库,数据库需要的资源越来越多:它会为每个 JDBC 驱动程序使用的预处理语句分配更多的内存。如果应用服务器开启了过多的连接,数据库的性能会受到负面的影响。

对于连接池而言,首要的原则是应用的每个线程都持有一个连接。对应用服务器而言,则是初始时将线程池和连接池的大小设置为同一值。对单一的应用程序,则是依据应用程序创建的线程数调整连接池的大小。典型的情况下,这种设置能取得最好的性能:所有的程序线程都不需要等待数据库连接的释放,数据库亦有足够的资源处理来自应用程序的负荷。

然而,如果数据库成为瓶颈,采用这条规则可能会适得其反。向一个太弱的数据库施加过多的连接是对前一原则的另一种阐释,即向一个已经非常繁忙的系统增大负荷只会降低它的性能。这种情况下,使用连接池限制施加到数据库的负荷可以改善程序的性能。这时应用程序线程需要等待空闲连接,不过如果数据库没有被压垮的话,总的系统吞吐量会提升。

JDBC连接池小结

  1. 数据库连接对象初始化的代价是昂贵的。所以在 Java 语言中,它们通常都会采用池技术进行管理——要么是通过 JDBC 驱动程序自身管理,要么在 Java EE 和 JPA 框架中进行管理。

  2. 跟其他的对象池一样,对连接池的调优也是非常重要的,我们需要确保连接池不会对垃圾收集造成负面的影响。为了达到这个目标,调优连接池,避免对数据库自身的性能产生负面影响也是非常有必要的。

事务

应用程序拥有合理的需求,这些需求最终可以指导事务如何被正确地处理。要求“重复读”(repeatable-read)语义的事务会比只要求“提交读”(read-committed)语义的事务慢,但是了解这些对于无法接受“非重复读”的应用程序而言没有太多的实际参考意义。因此,虽然这一节讨论的主要是如何替应用程序构造使用最小隔离集的语义,但也不要一叶障目地试图用高性能掩盖程序的确切需求。

数据库的事务有两类性能代价。首先,数据库事务的设置和提交都会耗费时间。这个过程包括确保对数据库的修改都已经完全保存到磁盘,检查数据库事务日志的一致性,等等。其次,数据库事务进行期间,通常事务都要对部分数据加锁(不一定总是行锁,不过这里我们会以行锁作为例子)。如果两个事务在同一个数据库行锁上发生竞争,应用的可扩展性就会受影响。从 Java 语言的角度看,这种行为跟线程同步优化章节中讨论的竞争锁及非竞争锁很相似。

为了优化性能,我们需要考虑这些问题:如何调整程序,优化事务的实现,让事务自身更加高效,以及在事务中如何持有锁,才能让应用程序的整体性能有更好的扩展性。

JDBC事务的控制

本节对事务的介绍包括两种形式,同时涵盖使用 JDBC 和 JPA 的应用,不过 JPA 管理事务的方式不大一样(本章后续内容会详细探讨这些细节)。对于使用 JDBC 应用,事务的开始和结束都基于如何使用 Connection 对象。

在基础的 JDBC 使用中,连接提供了自动提交(autocommit)模式(通过 setAutoCommit() 方法设置)。如果开启自动提交模式(对大多数的 JDBC 驱动程序,这个选项是默认开启的),则 JDBC 程序中的每个语句自身都是一个事务。这种情况下,程序不需要额外提交事务(事实上,如果调用了 commit() 方法,性能反而可能会恶化)。

如果自动提交被关闭,那么事务默认于连接对象第一次调用时开始(譬如,调用 executeQuery() 方法时)。事务持续运行直到 commit() 方法(或者 rollback() 方法)被调用。新的事务在下一次数据库调用连接操作时开始。

事务的提交操作是非常昂贵的,因此我们的目标之一是尽可能在一次事务中完成更多的工作。不幸的是,这个目标与我们的另一个目标几乎是南辕北辙:因为事务需要持有锁,所以它们应该在尽可能短的时间内完成。很明显,我们需要在二者之间进行权衡,如何进行平衡取决于应用程序以及它的锁需求。下一节探讨事务隔离及锁时会对此作进一步讨论,这里首先看看事务处理自身有哪些优化选项。

下面这段代码用于向数据库中插入数据供股票应用使用。为使每天的数据有效,一行数据会被插入到 STOCKPRICE 表,五行数据插入到 STOCKOPTIONPRICE 表。为了完成这个目标,一个基本的循环如下所示:

Connection c = DriverManager.getConnection(URL, user, pw);
PreparedStatement ps = c.prepareStatement(insertStockSQL);
PreparedStatement ps2 = c.prepareStatement(insertOptionSQL)) {
Date curDate = new Date(startDate.getTime());
while (!curDate.after(endDate)) {
    StockPrice sp = createRandomStock(symbol, curDate);
    if (sp != null) {
        ps.clearParameters();
        ps.setBigDecimal(1, sp.getClosingPrice());
        // 其余字段使用类似的集调用
        ps.executeUpdate();
        for (int j = 0; j < 5; j++) {
            ps2.clearParameters();
            ps2.setBigDecimal(1, ...);
            // 其余字段使用类似的集调用
            ps2.executeUpdate();
        }
    } //其余的curDate是周末,故被略过
    curDate.setTime(curDate.getTime() + msPerDay);
}

如果开始和结束的日期表示的是 2013 年,那么这个循环会插入 261 行记录到 STOCKPRICE 表(通过第一次调用的 executeUpdate() 方法),并插入 1305 行记录到 STOCKOPTIONPRICE 表(通过内部的 for 循环)。若使用默认的自动提交模式,就意味着会有 1566 个相互独立的事务,而这将是相当昂贵的。

如果关闭自动提交模式,在循环结束时显式地调用提交操作能够获得更好的性能:

Connection c = DriverManager.getConnection(URL, user, pw);
c.setAutoCommit(false);
    ...
while (!curDate.after(endDate)) {
...
}
c.commit();

从逻辑的观点来看,这可能也更合理:数据库中要么保存一整年的数据,要么就没有任何数据。

如果这个循环被用于多个股票的计算,就会又面临一个选择,即是一次性提交所有的数据,还是一次提交一年的数据:

Connection c = DriverManager.getConnection(URL, user, pw);
c.setAutoCommit(false);
for (int i = 0; i < numStocks; i++) {
    curDate = startDate;
    while (!curdate.after(endDate)) {
        ...
    }
    //c.commit(); // 一次提交一年的数据
}
c.commit(); // 一次性提交所有的数据

一次性提交所有的数据能获得最高的性能,这就是注释排除其他选项的原因。不过,这个例子中,还有一种合理的情况,在其场景下应用的语义可能要求单独提交每一年的数据。有时候,其他的需求可能会与获取最佳性能的尝试发生冲突。

每次 executeUpdate() 方法在上述代码中运行时,数据库就会进行一次远程方法调用,执行一些工作。此外,更新表时,数据会被加锁(至少要确保另一个事务不能使用同样的符号和日期向表内插入记录)。这个例子中,通过批量插入可以进一步优化事务的处理。插入批量化时,JDBC 驱动程序会保持这些数据,直到批量插入完成;之后所有的语句会被发送到一个远程 JDBC 调用中。

下面展示了批量处理是如何实现的:

for (int i = 0; i < numStocks; i++) {
    while (!curdate.after(endDate)) {
        ...
        ps.addBatch();  // 替换executeUpdate()调用
        for (int j = 0; j < 5; j++) {
            ...
            ps2.addBatch();  // 替换executeUpdate()调用
        }
    }
}
ps.executeBatch();
ps2.executeBatch();
c.commit();

这段代码同样还能用于依据每支股票进行批量执行(在 while 循环之后)。有些 JDBC 驱动程序对它们能够批量执行的语句数做了限制(批量处理的确会消耗应用程序的内存),所以,即使可以在整个操作完成之后再进行提交,批量处理还是需要频繁地执行。

这些优化措施对性能的提升是非常明显的。下表展示了将 128 支股票一年的数据插入数据库所花费的时间(总计 200 448 次插入操作)。

编程模式消耗时间(秒)DB访问次数DB提交次数
启用自动提交,不使用批处理2220.53200448200448
每支股票提交一次174.44200448128
所有数据一次性提交169.342004481
每支股票的每次提交都用一次批处理完成19.32128128
每支股票都用一次批处理完成,1次提交17.331281
所有的股票用一次批处理完成,1次提交11.5511

注意,这张表中有一个有趣的事实,不过并不明显:第一行和第二行的差别在于自动提交被关闭了,代码在每个 while 循环的结尾显式地调用 commit() 方法完成提交。第一行和第四行的差别是语句被批处理(batched)了——不过自动提交还是开启的。一次批处理调用被当成一个事务,这也是为什么数据库调用和提交是一一对应关系的原因。这个例子告诉我们,通过批处理实现的性能提升要明显高于显式地进行事务控制。

事务隔离和锁

影响事务性能的第二个因素与数据库的扩展性相关,因为事务中的数据会被锁定。锁机制能保证数据的完整性,用数据库的术语来说,它让一个事务与其他的事务相互隔离。JDBC 和 JPA 都支持四种主要的数据库事务隔离模式,不过在具体的实现方式上各有不同。

虽然使用正确的隔离模式(Isolation Mode)进行程序设计实际上并不是一个纯 Java 的问题,我们还是会简短地介绍下隔离模式。要了解更多相关信息,请参考数据库编程相关的书籍。

基本的事务隔离模式如下所列(依照开销最大到最小的顺序)。

TRANSACTION_SERIALIZABLE

这是最昂贵的事务模式;它要求在事务进行期间,事务涉及的所有数据都被锁定。通过主键访问数据以及通过 WHERE 子句访问数据都属于这种情况:使用 WHERE 子句时,表被锁定,避免事务进行期间有新的满足 WHERE 语句的记录被加入。序列化事务每次查询时看到的数据都是一样的。

TRANSACTION_REPEATABLE_READ

这种模式下要求事务进行期间,所有访问的数据都被锁定。不过,其他的事务可以随时在表中插入新的行。这种模式下可能会发生“幻读”(phantom read),即事务再次执行带有 WHERE 子句的查询语句时,第二次可能会得到不同的数据。

TRANSACTION_READ_COMMITTED

使用这种模式时,事务运行期间只有正在写入的行会被锁定。这种模式可能会发生“不可重复读”(nonrepeatable read),即在事务进行中,一个时间点读到的数据到另一个时间点再次读取时,就变得完全不同了。

TRANSACTION_READ_UNCOMMITTED

这是代价最低的事务模式。事务运行期间不会施加任何锁,因此一个事务可以同时读取另一个事务写入(但尚未提交)的数据。这就是著名的“脏读”(dirty read);由于首次的事务可能会回滚(意味着写入操作实际并未发生),因此可能会导致一系列的问题,因为一旦发生这种情况,第二次的事务就是对非法数据进行操作。

数据库都按照自己默认的事务隔离模式进行工作:MySQL 默认使用 TRANSACTION_REPEATABLE_READ;Oracle 和 DB2 默认使用 TRANSACTION_READ_COMMITTED;诸如此类。除此之外,还有很多与具体数据库相关的事务隔离模式变种。DB2 称他们默认的事务模式为 CS(意为游标稳定性,cursor stability),对其他的三种 JDBC 模式亦采用不同的命名。Oracle 数据库不支持 TRANSACTION_READ_UNCOMMITTED 和 TRANSACTION_REPEATABLE_READ 事务类型。

JDBC 语句执行时,使用数据库默认的隔离模式。另一方面,调用 JDBC 连接的 setTransaction() 方法也可以控制数据库支持需要的事务隔离级(如果数据库无法支持指定的级别,JDBC 驱动程序会抛出一个异常,或者自动将隔离级升级到它支持的下一个更严格的隔离级)。

  • TRANSACTION_NONE 和自动提交

    JDBC 规范定义了第五种事务模式,就是本节讨论的 TRANSACTION_NONE。理论上,这种事务模式不能通过 setTransactionIsolation() 方法设置,因为如果事务已经存在,它的隔离级就不能被设置为 none。不过有的 JDBC 驱动程序(最著名的是 DB2)的确允许进行这样的调用(实际上,它甚至允许将默认隔离级设置为 TRANSACTION_NONE)。另一些 JDBC 驱动程序允许在初始化驱动程序时通过属性设置隔离级为 none。

    严格来说,使用 TRANSACTION_NONE 语义的连接执行语句时是无法向数据库提交数据的:它只能是只读的查询操作。如果有数据写入,必须添加某种锁;否则,如果一个用户使用 TRANSACTION_NONE 语义向一个表写入一个很长的字符串,另一个用户可能仅会看到写入表中的这个字符串的一部分。可能有些数据库会以这种模式运行,不过数量会非常少;最起码,向一张表写入数据的操作应该是原子操作。因此,生产环境中的写操作至少应该使用 TRANSACTION_READ_UNCOMMITTED 语义。

    使用 TRANSACTION_NONE 语义的查询是无法提交的,但是如果 JDBC 驱动程序使用 TRANSACTION_NONE 隔离级同时又开启了自动提交,有可能允许写操作。这意味着数据库将每个查询语句当成一个独立的事务。即使如此,由于数据库(很可能)不允许其他的事务看到部分写入的数据,所以实际使用的隔离级是 TRANSACTION_READ_UNCOMMITTED。

对于简单的 JDBC 程序,这就已经足够了。更常见的情况是——尤其是使用 JPA 的时候——程序希望在一个事务内混合使用不同的隔离级。对一个查询我的员工信息并最终据此进行薪资调整的应用程序而言,它需要访问我的员工记录,这部分数据必须被保护:这些数据应该采用 TRANSACTION_REPEATABLE_READ 隔离级。不过同一个事务还可能需要访问其他表中的数据,譬如保存我的办公室编号的表。对于这部分数据,没有必要在事务中进行锁定,因此访问这些行时当然可以采用 TRANSACTION_READ_COMMITTED 隔离级(甚至是更低的隔离级)。

JPA 让你可以依据每个实体设定锁的级别(当然实体——通常至少——是数据库中的一行)。由于合理设置这些锁的级别是相当困难的,因此相对于直接使用 JDBC 语句设置锁,利用 JPA 让这部分工作变得容易多了。此外,JPA 中采用悲观锁或者乐观锁语义,可以达到与 JDBC 应用中使用不同锁级别同样的效果(如果你对这些语义还不是很熟悉,可通过下面这个例子来熟悉这些概念)。

在 JDBC 层次上,基本的方式是将连接的隔离级设置为 TRANSACTION_READ_UNCOMMITTED,之后按照需要显式地对事务中的数据上锁:

Connection c = DriverManager.getConnection(); // 或……从本地池中获取
c.setAutoCommit(false);
c.setTransactionIsolation(TRANSACTION_READ_UNCOMMITTED);
PreparedStatement ps1 = c.prepareStatement(
    "SELECT * FROM employee WHERE e_id = ? FOR UPDATE");
……处理来自ps1的信息……
PreparedStatement ps2 = c.prepareStatement(
   "SELECT * FROM office WHERE office_id = ?");
……处理来自ps2的信息……
c.commit();

ps1 语句显式地在员工数据表上设置了一把锁:在这次事务进行过程中,其他事务都无法访问这行数据。实现这个目的的 SQL 格式并不是标准的。你需要根据你的数据库提供商的文档,查找获得所需的锁级别的方法,但通常的格式是包含 FOR UPDATE 子句。这种类型的锁被称为悲观锁(pessimistic locking)。它有效地阻止了其他的事务,使其不得访问保护的数据。

锁的性能通常可以通过使用**乐观锁(optimistic locking)**的方式改善——这与 java.util.concurrent.atomic 解决无竞争原子操作(uncontended atomic operation)的方式异曲同工。如果数据访问不存在竞争,采用乐观锁能显著提升程序的性能。然而,如果数据访问存在竞争,程序设计就变得更加复杂。

对于数据库而言,乐观并发是通过版本列实现的。从一行中选择数据时,选择项必须包含期望的数据及版本列。譬如,为了获取关于我的信息,可以使用下面的 SQL 语句:

SELECT first_name, last_name, version FROM employee WHERE e_id = 5058;

这个查询会返回我的名字(Scott 和 Oaks)以及当前的版本号(譬如 1012)。事务完成时,会更新当前的版本列:

UPDATE employee SET version = 1013 WHERE e_id = 5058 AND version = 1012;

如果访问的行要求重复读取或者序列化语义,那么即使事务中只是对数据做读操作也需要更新版本号——这些隔离级要求对事务中的只读数据进行锁定。对于读 - 提交语义,只有在该行中的其他数据发生更新时才需要更新版本号。

这种模式下,如果两个事务同时使用我的员工记录,则每个都会读取版本号为 1012 的数据。第一个事务会更新数据的版本号到 1013 并继续运行。此后,第二个事务就无法更新员工记录了——因为表中不再有版本号为 1012 的记录,因此 SQL 更新的语句会失败。该事务会接到一个异常并回滚。

这个例子展示了数据库的乐观锁与 Java 的原子原语之间的主要差异:在数据库编程中,事务收到异常时,是不会进行(也无法进行)透明的重试的。如果你直接对 JDBC 进行编程,commit() 方法会收到一个 SQLException;在 JPA 中,事务提交时,你的应用程序会收到一个 OptimisticLockException。

根据你看问题的角度,这可能是件好事,也可能是件坏事。讨论原子工具集的性能时(它们不会透明地进行重试操作),我观察到冲突频繁的情况下,如果有大量的重试操作,程序的性能会受到极大的影响,因为重试会占用大量的 CPU 资源。对数据库应用来说,这种情况会更加严重,因为事务中的代码运行比简单地增加内存某个区间的值要复杂得多。在数据库应用中,重试失败的乐观事务对性能的影响要大得多,它可能会导致永无止境的重试死循环。除此之外,很多时候我们无法判断哪些操作能自动地进行重试。

因此,不提供自动重试机制也许是件好事(很多时候是唯一可行的方案),但是另一方面,这并不是说应用程序现在不负责处理异常。应用程序可以选择重试事务(可能是一次或者两次),可以选择提示用户请求不同的输入,或者它可以简单地通知用户操作失败。这个问题也没有普遍适用的答案。

如果两个数据源之间极少有机会发生碰撞,则使用乐观锁工作是最好的。假设有一个联合支票账户出现了这样的情况:丈夫和妻子在城市的不同地方,几乎在同一个时刻从这个联合账户中提取现金。这会为我们触发一个乐观锁异常。即使这样的情况发生,要求我们中的一个重试也并不是太繁重的任务,现在发生乐观锁异常的机会就几乎是零了(或者我希望如此:我们不需要解决以多高的频率从 ATM 机上提取现金的问题)。与刚才的场景在某种程度上完全相反的是样本股票的应用。真实世界中,这些数据更新得如此频繁,使用乐观锁只能适得其反。实际上,由于变化的数量庞大,股票应用更频繁使用的是无锁机制,不过实际的交易更新还是会使用某种类型的锁。

事务小结

  1. 事务会从两个方面影响应用程序的性能:事务提交是非常昂贵的,与事务相关的锁机制会限制数据库的扩展性。

  2. 这两个方面的效果是相互制约的:为了提交事务而等待太长时间会增大事务相关锁的持有时间。尤其是对使用严格语义的事务来说,平衡的方式是使用更多更频繁的提交来取代长时间地持有锁。

  3. JDBC 中为了细粒度地控制事务,可以默认使用 TRANSACTION_READ_UNCOMMITTED 隔离级,然后显式地按需锁定数据。

结果集的处理

典型的数据库应用会对一个区间的数据进行操作。譬如股票应用要对某支股票的历史价格进行处理。通过一条 SELECT 语句可以将历史记录载入:

SELECT * FROM stockprice WHERE symbol = 'TPKS' AND
        pricedate >= '2013-01-01' AND pricedate <= '2013-12-31';

这条语句会返回 261 条数据记录。如果还需要对应股票的股价,可以采用类似的查询得到五倍数量的记录。获取样本数据库中所有数据(128 支股票一年的数据)的 SQL 语句会返回 200 448 条数据记录。

SELECT * FROM stockprice s, stockoptionprice o WHERE
        o.symbol = s.symbol AND s.pricedate >= '2013-01-01'
        AND s.pricedate <= '2013-12-31';

为了使用这些数据,代码需要遍历结果集:

PreparedStatement ps = c.prepareStatement(...);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
    ……读取当前的行……
}

这里就有一个问题,即这 200 448 条记录保存在什么地方。如果整个结果集的数据都在执行 executeQuery() 调用时返回,应用程序就会在它的堆内保存大量的活跃数据,而这可能会导致 GC 或者其他的问题。与此相反,如果只返回调用 next() 方法时的一行数据,在处理结果集时,应用和数据库之间就会有大量的往返流量。

与之前一样,这个问题也没有所谓的正确答案;在有的情况下,在数据库中保持大多数的数据,需要时进行提取是更高效的方法;而在另一些场景里,查询时一次性地将所有的数据返回可能会更高效。通过 PreparedStatement 对象的 setFetchSize() 方法可以控制这些行为,它能通知 JDBC 驱动程序一次返回多少行数据。

这个参数的默认值随 JDBC 驱动程序的不同而异;譬如对 Oracle 的 JDBC 驱动程序,该默认值是 10。在上面展示的循环语句中调用 executeQuery() 方法,Oracle 数据库会返回 10 行数据,返回的数据会由 JDBC 驱动程序在内部缓存。头 10 次 next() 方法的调用都直接从缓存行中读取,返回其中的一条记录。第 11 次调用该方法时,JDBC 驱动程序会向数据库发出请求,取得另 10 行数据,如此周而复始。

  • 设置提取缓冲区大小的其他方法

    前面我们曾推荐使用(预处理)语句对象的 setFetchSize() 方法,但是该方法依赖于 ResultSet 接口。在任何情况下,设置的提取缓存区的大小都只是建议值。JDBC 驱动程序可以自由决定是要忽略该设置,还是将其圆整为其他的值,亦或是设置为它期望的任何值。没有任何一个方法能一劳永逸地保证设置一定的工作,但是在查询之前设置该参数能获得更多的机会采用你期望的缓冲区大小。

    如果连接是通过向 DriverManager 的 getConnection() 方法传递参数的方式创建,那么有些 JDBC 驱动程序也允许你设置默认的提取缓冲区大小。你可能需要查看你供应商的文档,判断到底哪种方法对你而言更容易。

虽然各 JDBC 驱动程序的默认提取缓冲区大小各有不同,不过它们的典型特征是都相当小。这种设置在大多数情况下是合理的,尤其是,这种设置不大可能会引起应用程序的内存问题。不过如果 next() 方法的性能时不时地非常慢(或者结果集的首次查询方法性能很差),你可能就需要考虑增大提取缓冲区的大小。

结果处理集小结

  1. 需要查询处理大量数据的应用程序应该考虑增大数据提取缓冲区的大小。

  2. 我们总是面临着一个取舍,即在应用程序中载入大量的数据(直接导致垃圾收集器面临巨大的压力),还是频繁地进行数据库调用,每次获取数据集的一部分。

更新时间:2020-04-06 18:10:29

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优18数据库最佳实践上
最后更新:2020-04-06 18:10:29

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×