介绍

在使用 Java 语言进行与数据库相关的应用开发中,通常使用 JDBC(Java Database Connectivity)来进行数据库交互。其中有一个关键概念是 Connection(连接),它在 Java 中是一个类,代表了一个通信通道。应用程序通过该通道访问数据库中的数据。

对于简单的数据库应用,由于访问频率不高,可以在需要访问时新建一个连接,用完即关闭。这种做法不会带来明显的性能开销。但对于复杂的数据库应用,情况则完全不同。频繁地建立和关闭连接会极大降低系统性能,因为连接的管理成为了系统性能的瓶颈。

本文提出了一种合理、有效的连接管理策略,旨在避免对连接的随意和无规则使用。该策略的核心思想是:连接复用。通过建立数据库连接池以及一套连接使用管理策略,使得数据库连接可以得到高效、安全的复用,避免了频繁建立和关闭连接的开销。此外,通过对 JDBC 原始连接进行封装,方便了应用程序对连接的使用(特别是事务处理),提高了开发效率。封装层的存在也隔离了应用业务逻辑与具体数据库访问逻辑,使应用本身的复用成为可能。

问题产生

我参与的项目是开发一个网管系统,不可避免地需要与数据库打交道。项目初期,由于数据库访问不频繁,我们采用了“需要时建立,用完即关闭”的简单策略。这很符合 XP(eXtreme Programming,极限编程)的口号:"Do the Simplest Thing that Could Possibly Work"(做可能工作的最简单的事情)。确实,初期工作运行良好。

随着项目进展,数据库访问变得频繁,问题随之暴露。原先简单地获取和关闭数据库连接的方法严重影响了系统性能。这种影响主要是由数据库资源管理器进程频繁创建和销毁连接对象引起的。

此时,有必要对数据库访问方法进行重构(Refactoring),以改进并提高系统性能。

解决方案

可以看出,问题的根源在于对连接资源的低效管理。对于共享资源,有一个著名的设计模式:资源池(Resource Pool)。该模式正是为了解决资源频繁分配、释放所造成的问题。将此模式应用到数据库连接管理领域,即建立一个数据库连接池,提供一套高效的连接分配和使用策略,最终目标是实现连接的高效、安全复用。

3.1 建立连接池

第一步是建立一个静态连接池。所谓“静态”,是指池中的连接在系统初始化时分配好,并且不能随意关闭。Java 提供了许多容器类方便构建连接池,如 VectorStack 等。在系统初始化时,根据配置创建连接并放置在连接池中,后续所使用的连接均从该池中获取。这样可以避免连接随意建立、关闭造成的开销(当然,无法避免 Java Garbage Collection 带来的开销)。

3.2 分配、释放策略

有了连接池,我们就可以提供一套自定义的分配、释放策略。

当客户请求数据库连接时,首先查看连接池中是否有空闲连接(即目前没有分配出去的连接)。如果存在空闲连接,则将其分配给客户并作相应处理(主要策略是标记该连接为已分配)。若连接池中没有空闲连接,则在已经分配出去的连接中寻找一个合适的连接给客户(选择策略详见关键议题),此时该连接在多个客户间复用。

当客户释放数据库连接时,根据该连接是否被复用进行不同处理。如果连接没有使用者,则将其放回连接池中,而不是直接关闭。

正是这套策略保证了数据库连接的有效复用。

3.3 配置策略

数据库连接池中到底要放置多少个连接?连接耗尽后该如何处理?这是一个配置策略问题。一般的策略是:开始时根据具体应用需求,给出一个初始连接数目以及连接池可以扩张到的最大连接数目。本方案即按照这种策略实现。

关键议题

本节将对上述解决方案中的关键细节进行详述,正是这些关键策略保证了数据库连接复用的高效和安全。

4.1 引用计数

3.2 节中的分配、释放策略对于有效复用连接非常重要。我们采用了一个著名的设计模式:Reference Counting(引用计数)。该模式在资源复用方面应用广泛,我们将该方法运用到连接的分配释放上。

每一个数据库连接保留一个引用计数,用来记录该连接的使用者个数。具体实现上,我们采用了两极连接池空闲池使用池。空闲池中存放目前还没有分配出去的连接;一旦一个连接被分配出去,就会放入到使用池中,并且增加引用计数。

这样做有一个很大的好处:使得我们可以高效地使用连接。一旦空闲池中的连接被全部分配出去,我们就可以根据相应策略从使用池中挑选出一个正在使用的连接用来复用,而不是随意拿出一个连接。策略可以根据需要选择,我们采用的策略比较简单:复用引用计数最小的连接。Java 的面向对象特性使得我们可以灵活选择不同的策略(提供一个不同策略共用的抽象接口,各个具体策略都实现这个接口,这样策略的处理逻辑就和实现逻辑分离)。

4.2 事务处理

前面谈到的都是关于使用数据库连接进行普通的数据库访问。对于事务处理,情况变得比较复杂。因为事务本身要求原子性保证,此时要求对于数据库的操作符合 "All-or-Nothing" 原则,即要么全部完成,要么什么都不做。如果简单采用上述的连接复用策略,就会发生问题,因为无法控制属于同一个事务的多个数据库操作方法的动作。这些操作可能是在多个连接上进行的,并且这些连接可能被其他非事务方法复用。

Connection 本身提供了对于事务的支持,可以通过设置 ConnectionAutoCommit 属性为 false,显式调用 commit 或者 rollback 方法来实现。但是要安全、高效地进行 Connection 复用,就必须提供相应的事务支持机制。

我们采用的方法是:采用显式的事务支持方法,每一个事务独占一个连接。这种方法可以大大降低事务处理的复杂性(如果事务不独占一条连接,那么要保证事务的原子性并且又不妨碍复用该连接的其他和该事务无关的操作,基本上不可能,除非 Connection 类是你开发的),并且又不会妨碍连接的复用,因为隶属于该事务的所有数据库操作都是通过这一个连接完成的,并且事务方法又复用了其他一些数据库方法。

在我们的连接管理服务中,提供了显式的事务开始、结束(commit 或者 rollback)声明,以及一个事务注册表,用于登记事务发起者和事务使用的连接的对应关系。通过该表,使用事务的部分和我们的连接管理部分就隔离开,因为该表是在运行时根据实际的调用情况动态生成的。事务使用的连接在该事务运行中不能被复用。

当使用者需要使用事务方法时,首先调用连接管理服务提供的 beginTrans 方法。该方法主要处理流程如下(伪码描述):

public void beginTrans() {
    // ...
    conn = getIdleConnectionFromPool();
    userId = getUserId();
    registerTrans(userId, conn);
    // ...
}

在我们的实现中,用户标识是通过使用者所在的线程来标识的。后面的所有对于数据库的访问都是通过查找该注册表,使用已经分配的连接来完成的。当事务结束时,从注册表中删除相应表项。

对于嵌套的事务如何处理呢?我们采用的方法仍为引用计数,不过这里的引用计数是指“嵌套层次”,具体的细节不再赘述。

4.3 封装

从上面的论述可以看出,普通的数据库方法和事务方法对于连接的使用(分配、释放)是不同的。为了便于使用,对外提供一致的操作接口,我们对连接进行了封装:即普通连接事务连接。在此,我们利用了 Java 中强大的面向对象特性:多态。普通连接和事务连接均实现了一个 DbConnection 接口,对于接口中定义的方法,分别根据自己的特点作了不同的实现,这样在对于连接的处理上就非常一致了。

4.4 并发问题

为了使我们的连接管理服务有更大的通用性,必须要考虑到多线程环境,即并发问题。在一个多线程的环境下,我们必须要保证连接管理自身数据的一致性和连接内部数据的一致性。还好 Java 提供对这方面的很好的支持(synchronized 关键字),这样我们就很容易使连接管理成为线程安全的。

结论

本文给出了一个基本的连接管理框架,在其中使用了一些广泛使用的设计模式(资源池、引用计数等),使得高效、安全的复用数据库连接成为可能。当然,还有一些问题没有考虑到,比如:没有实现对不同种类的数据库的联合管理;没有提供定时检测机制查询连接的状态等。另外,在连接管理的使用包装上比起一些商用系统还显粗糙,但是底层的机理是一致的。通过本文,相信对于这些商用产品中的相关功能会有更好的理解。

说明:本文所述方案基于早期 Java 开发实践(如使用 VectorStack 及手动同步机制)。在现代 Java 开发中,建议直接使用成熟的第三方连接池组件(如 HikariCP、Druid 等),它们提供了更完善的性能优化、监控及维护功能。本文内容主要用于理解连接池底层原理。