虾皮面试原题:探讨为何在Java开发中数据库连接池通常未采用IO多路复用的原因与影响

今天我们将讨论一个不太常见的Java面试问题:为何数据库连接池通常不使用IO多路复用?

这个问题非常有意义。IO多路复用被广泛认为是提升性能的有效手段。然而,在与数据库的交互中,我们常常还是依赖于c3p0tomcat connection pool等技术,即使整个应用程序已经以Netty为基础。究竟是什么原因导致了这种现象呢?

首先,我们需要澄清一个常见的误解。虽然IO多路复用的概念似乎暗示了多个数据可以共享同一个IO(socket连接),但实际上并非如此。IO多路复用的本质在于它允许在同一进程中管理多个连接,而不是让多个服务共享一个连接。在网络服务中,IO多路复用的作用是一次性通知业务代码处理多个连接的事件。至于如何处理这些事件,是业务代码决定的,可能是通过循环处理、丢入队列,或由线程池来处理。

图片

对于需要使用数据库的程序而言,无论是采用多路复用还是连接池,核心都是要维护一组网络连接,以支持并发查询。

那么,为什么并发查询就必须依赖多个连接呢?因为数据库通常使用连接作为Session管理的基本单元。在一个连接中,SQL语句的执行必须是串行且同步的。这是因为数据库需要为每个Session维护一组状态,以支持查询,例如事务隔离级别和当前Session的变量等。只有在单个Session内进行串行执行,才能确保查询的正确性(想象一下,如果一组SQL在不断增减变量后乱序执行,会发生怎样的后果)。维护这些状态需要消耗内存,同时也会消耗CPU和磁盘IO。因此,限制数据库的连接数实际上就是在控制对数据库资源的消耗。

因此,对于数据库而言,关键在于限制连接的数量。这一需求无论是通过数据库连接池还是NIO的连接管理都能得到满足。

图片

问题又回到了原点:为什么数据库连接不能放入IO多路复用中一并处理,而大家却纷纷选择连接池呢?

答案是,尽管可以使用IO多路复用,但使用JDBC是无法实现的。JDBC作为一种已有近20年的标准,其设计核心是BIO(因为在199X年时,尚未出现其他IO选项):在通过JDBC执行如query这样的API时,调用线程会在执行完成之前被阻塞。而像Mysql Connector/J这样的驱动完全遵循了这套语义。

当然,如果对数据库客户端的协议进行一些简单修改:

  1. 将IO模式调整为Non-Blocking,这样就可以集成到IO多路复用的内核(如select、epoll、kqueue等)中。
  2. 在Non-Blocking实现的基础上,实施数据库协议的编码与解析。

这样就能够实现通过IO多路复用访问数据库。事实上,许多其他语言或框架都是这样做的。例如,Node.js使用https://github.com/sidorares/node-mysql2实现,或者Vert.X的数据库客户端https://github.com/mauricio/postgresql-async(尽管名字可能会让人困惑,实际上它同时支持MySQL和PostgreSQL)。不过,数据库官方似乎没有提供这种支持——他们只支持诸如JDBC、ODBC等标准协议。

那么,为什么基于IO多路复用的实现不能成为主流和官方标准,而仅仅是小众选择呢?

对于数据库开发者而言,这种用法在用户中的应用比例极小,因此不值得花费大量精力去维护。只需将协议书写清晰(例如https://dev.mysql.com/doc/internals/en/client-server-protocol.html),有兴趣的社区成员自然而然会去实现。

另一个原因是体系结构的支持。简而言之,如果没有一个大型的Reactive运行环境,IO多路复用的使用就会受到极大的限制。

IO多路复用之所以能够存在,是因为整个程序需要一个IO多路复用的驱动代码——也就是那句select调用——等待事件到来,一个阻塞的API。整个程序必须围绕这一驱动代码构建,这对代码结构有重大影响,无法用简单的接口抽象来替代。

Java Web容器之所以能够使用NIO,是因为NIO可以被封装在容器内部。Web容器对外仍然暴露出传统的多线程形式的Java EE接口。

如果数据库和Web容器同时使用NIO,则DB连接库必须与容器之间有约定,以描述数据库连接管理如何接入Web容器的NIO驱动代码。在Java这个大环境中,由于不同开发者和不同容器的代码风格各异,或者根本不使用常见的容器,而是使用NIO自行封装,这就很难形成良好的代码共识,多个独立组件也无法有效共享NIO的驱动代码。

假设整个程序应该共享一个NIO驱动代码,那么Web和数据库能否各自使用呢?答案是可以,但为了确保这两个NIO驱动代码之间不会相互阻塞,最好把它们放在不同的线程中。这样一来,就会打破常规Web服务一个请求处理一个线程的做法,增加了程序的复杂性——业务代码与数据库查询之间必须进行跨线程的数据交换。

与此相对,连接池的实现相对独立且简单。外部只需配置数据库URL、用户名密码以及连接池容量参数,就能实现自我管理连接。

Node.js和Vert.X则完全不同,因为它们本质上就是Reactive框架。它们的NIO驱动方式是其运行时的基础——所有需要在此基础上开发的代码都必须遵循相同的NIO+异步开发规范,使用同一个NIO驱动。因此,数据库与NIO的协作不会出现问题。

最后,有大量场景需要BIO的数据库查询支持。例如,批处理数据分析代码往往如此。这种场景下,以NIO去实现将会得不偿失——代码变得难以理解,而且并没有带来效率上的优势。类似于Node.js这样的运行时环境,在此场景下反而需要利用async或等效语法使代码看起来像是同步的,从而更容易编写。

总之,数据库访问通常采用连接池的现象是生态环境使然。历史上,BIO加连接池的做法经过多年的发展,已经有效解决了主要问题。在Java的大环境下,这一方案是非常可靠且成熟的。尽管基于IO多路复用的方式在性能上有所优势,但对整个程序的代码结构要求过高且复杂。不过,如果有特定需要,使用IO多路复用管理数据库连接是完全可行的。