今天聊一个不太常见的 Java 面试题:为什么数据库连接池不采用 IO 多路复用?
这个问题挺有意思。IO 多路复用一向被视作性能利器,但日常操作数据库时,大家还是习惯用 c3p0、Tomcat Connection Pool 这类连接池,哪怕整个应用已经以 Netty 为核心。为什么?
先纠正一个常见误解。IO 多路复用听上去像是"多个数据可以共享一个 IO(socket 连接)",其实并非如此。IO 多路复用不是指多个服务共享一个连接,而仅仅是指多个连接的管理可以在同一进程内完成。在网络服务中,它的作用是:一次性把多个连接的事件通知业务代码处理。至于这些事件怎么处理——业务代码自己循环、丢进队列,还是交给线程池,那由业务代码决定。
对于访问数据库的程序来说,不管用多路复用还是连接池,都要维护一组网络连接,支持并发查询。
可为什么并发查询一定要多个连接才能完成?因为 DB 通常把连接作为 Session 管理的基本单元。在一个连接中,SQL 语句的执行必须是串行、同步的。原因在于,每个 Session 都要维护一组状态来支撑查询,比如事务隔离级别、当前 Session 的变量等。只有单 Session 内串行执行,才能保证查询正确性——试想一组 SQL 在不断增减变量,结果乱序执行会怎样?维护这些状态需要消耗内存,同时也会占用 CPU 和磁盘 IO。因此,限制对 DB 的连接数,本质上就是限制对 DB 资源的消耗。
所以对 DB 来说,关键就是控制连接数目。这一点,无论连接池还是 NIO 连接管理都做得到。
问题又绕回来了:既然都能做到,为什么 DB 连接不能放进 IO 多路复用里一并执行?为什么大家普遍用连接池?
答案是:可以用 IO 多路复用,但用 JDBC 不行。JDBC 是一个出现了近 20 年的标准,它的设计核心是 BIO(因为 199X 年那个年代还没有别的 IO 可用):调用者通过 JDBC 执行比如 query 这样的 API 时,整个调用线程会被卡住,直到执行完成。像 MySQL Connector/J 这样的 driver,完整实现了这套语义。
当然,如果把 DB Client 协议的连接处理和解析稍微改一下:
- 将 IO 模式调整为 Non-Blocking,这样就可以挂到 IO 多路复用的内核上(select、epoll、kqueue……)
- 在 Non-Blocking 实现的基础上,完成数据库协议的编码和解析
就可以用 IO 多路复用来访问 DB。实际上,很多其他语言/框架早就这么干了。比如 Node.js,见 https://github.com/sidorares/node-mysql2 ;或者 Vert.X 的 db 客户端 https://github.com/mauricio/postgresql-async (别被名字误导,它其实同时支持 MySQL 和 Postgres)。只是数据库官方似乎都没做这种支持——他们只支持 JDBC、ODBC 等标准协议。
那为什么基于 IO 多路复用的实现不能成为默认的、官方的方案,反而显得偏门?
从数据库开发者的角度看,这类用户占比实在太小,不值得花大力气去做。只要把协议写清楚(见 https://dev.mysql.com/doc/internals/en/client-server-protocol.html ),社区有兴趣的人自然会去实现。
另一个原因是体系支持。简单讲,如果没有一个大的 Reactive 运行环境,IO 多路复用的使用会非常受限。
IO 多路复用之所以成立,前提是整个程序要有一个 IO 多路复用的驱动代码——就是 select 那句调用,一个 blocking 的 API,等待事件来临。整个程序必须以这个驱动代码为核心展开。这对代码结构的影响是根本性的,没法靠简单的接口抽象来抹平。
Java Web 容器之所以能用 NIO,是因为 NIO 可以被封装在容器内部。Web 容器对外暴露的仍然是传统多线程形式的 Java EE 接口。
可如果 DB 和 Web 容器同时用 NIO,事情就复杂了:DB 连接库必须与容器有一个约定,明确DB 的连接管理如何接入 Web 容器的 NIO 驱动代码。但在 Java 这个大环境里,不同人、不同容器写的代码各不相同;还有人不使用任何常见容器,自己用 NIO 封装一层。这样一来,代码层面就形成不了约定,多个独立组件也就很难共享 NIO 驱动代码。
上面这个用法假设整个程序共享一个 NIO 驱动代码。那 Web 和 DB 各用各的,行不行?也可以,但为了保证两个 NIO 驱动代码互不 block,最好拆成两个线程。这样又会打破一般 Web 服务"一个请求一个线程"的常规做法,程序变得更复杂——业务代码和 DB 查询之间不得不做跨线程数据交换。
相比之下,连接池的实现就独立得多,也简单得多。外部只要配好 DB URL、用户名密码和连接池容量参数,它就能自行管理连接。
而 Node.js 和 Vert.X 完全是另一个世界。它们本质就是 Reactive 的,NIO 的驱动方式是运行时的基础——所有要在这个基础上开发的代码,都必须遵循同样的 NIO + 异步开发规范,使用同一个 NIO 驱动。这样 DB 与 NIO 的协作自然不成问题。
最后还要看到,有大量场景是需要 BIO 的 DB 查询支持的。批处理、数据分析代码就是典型。这类程序写成 NIO 反而得不偿失——代码不容易理解,也没有任何效率优势。类似 Node.js 这样的运行时在这些场景下,反而要借助 async 或等价的语法,让代码看起来是同步的,才好写。
总结一下。DB 访问普遍采用连接池,是生态造成的。历史上的 BIO + 连接池方案经过多年演进,已经解决了主要问题。在 Java 的大环境下,这条路非常靠谱、成熟。而基于 IO 多路复用的方式,虽然性能上可能有优势,但对整个程序的代码结构要求过高、过于复杂。当然,如果有特定需要,采用 IO 多路复用 管理 DB 连接,也是完全可行的。这类技术选型上的取舍,在 云栈社区 也常被开发者们拿出来反复讨论。