HikariCP 源码里的设计模式,比连接池本身更值得学
有次做性能调优,把 Druid 换成 HikariCP,QPS 涨了 15%。运维同事问我是不是改了什么大逻辑,我说就换了个连接池。他不信。
结果我自己也不信,于是翻了一圈源码。翻完就一个感觉:这哪是连接池,这是一本设计模式教科书。HikariCP 的代码量不到 Druid 的十分之一,但设计模式的运用密度高得离谱。这篇文章拆解一下 HikariCP 里我发现的 4 种设计模式,每种都直接关联到它的性能优势。
代理模式:Connection 不是你拿到的那个 Connection
你从 HikariCP 拿到的 Connection,从来不是原生的 JDBC Connection。
HikariCP 生成了两个代理类:ProxyConnection 和 HikariProxyConnection。前者是手写的,负责接管 close() 方法的语义------你调用 connection.close(),它不会真的关闭物理连接,而是把连接还回池里。后者是 Javassist 动态生成的,代理了 Statement、PreparedStatement、CallableStatement 的所有方法,目的是拦截这些方法的调用并记录统计信息。
java
// ProxyConnection.java 核心逻辑
public final void close() throws SQLException {
// 不是真的 close,而是归还到连接池
this.delegate = null; // 切断对原生连接的引用
this.poolEntry.recycle(); // 归还
}
这个设计有几个好处:
一是 close() 的语义被悄悄替换了。业务代码里到处都是 try-with-resources,写完就 close,从来不用关心连接是怎么回收的。这正是代理模式的核心价值------在不改变接口的前提下,改变行为。
二是代理层是一个天然的拦截点。HikariCP 可以在这里记录 SQL 执行时间、连接的借用时长,而这些统计代码跟业务逻辑完全不耦合。你写你的 CRUD,它统计它的指标,两边谁也不认识谁。
三是 Javassist 生成的代理比 JDK 动态代理快。HikariCP 的作者 Brett Wooldridge 在 GitHub 上专门解释过:JDK 动态代理每次调用都要走反射,而 Javassist 生成的字节码跟手写代码几乎一样,调用的开销可以忽略不计。这一点的设计取舍非常务实------代理模式有很多种实现方式,但选哪个直接影响性能。
单例模式:连接池本身当然是单例
这个看起来没什么好说的,但 HikariCP 的单例做得比大部分人想的细致。
大部分人的单例就是 private static final HikariDataSource dataSource = new HikariDataSource(),但这其实不是 HikariCP 内部在用的方式。HikariCP 内部通过 HikariConfig 的不可变性来保证配置级别的单例语义------HikariConfig 一旦传给 HikariDataSource 的构造器,就会被深拷贝一份,后续修改 HikariConfig 不会影响已经创建的数据源。
java
public HikariDataSource(HikariConfig configuration) {
// 校验配置
configuration.validate();
// 深拷贝,切断引用
this.config = new HikariConfig(configuration);
// 这里才真正初始化连接池
this.pool = new HikariPool(this);
}
这么做的意义在于:你不会因为 "不小心改了一下配置对象" 而把生产环境的连接池搞崩。HikariCP 用拷贝构造的方式强制了不可变性,这比写文档提醒「请不要在运行时修改配置」靠谱得多。
另外,HikariPool 这个核心类也不是你想建就能建的,它只由 HikariDataSource 创建,外部完全不可见。这个设计跟单例模式里「控制实例的创建入口」是一样的思路,只不过限制的不是实例数量,而是创建路径。
工厂模式:连接不是 new 出来的
HikariDataSource.getConnection() 的调用链相当长,但每一步的职责都很清楚:
scss
HikariDataSource.getConnection()
→ HikariPool.getConnection()
→ ConcurrentBag.borrow()
→ 从空闲队列取或创建新连接
→ PoolEntry.createProxyConnection()
→ ProxyConnection 构造
PoolEntry 是整个链路里最核心的工厂角色。它封装了一个物理连接的所有状态------创建时间、最后使用时间、当前是否被借用------然后通过 createProxyConnection() 方法产出 ProxyConnection。
java
Connection createProxyConnection() {
return new ProxyConnection(this, connection, ...);
}
这看上去就是一个 new,没什么设计模式可言,但关键是创建的条件判断。ConcurrentBag.borrow() 的逻辑是这样的:
- 先从当前线程的本地队列取(ThreadLocal 缓存)
- 取不到就从全局空闲队列取
- 再取不到就检查是否还能创建新连接(没到
maximumPoolSize) - 如果都不能,就等着别人归还(
wait在synchronized块上)
这个"先取后造"的逻辑让连接创建变成了一种懒加载策略------不用提前建一堆连接等着,用了再说。这也是 HikariCP 比 Druid 启动更快的原因之一。
状态模式:连接的一生
HikariCP 里每个连接有 5 种状态,定义在 PoolEntry 的常量里:
NOT_IN_USE → IN_USE → RESERVED → REMOVED
↓
MARKED_FOR_REMOVAL
这些状态的转换不是用 if-else 堆出来的,而是通过 PoolEntry 的 CAS 操作驱动的:
java
// 借用连接
boolean evict = poolEntry.compareAndSet(STATE.NOT_IN_USE, STATE.IN_USE);
// 归还连接
poolEntry.setState(STATE.NOT_IN_USE);
// 标记驱逐
poolEntry.compareAndSet(STATE.IN_USE, STATE.MARKED_FOR_REMOVAL);
每个状态对应一套行为规则:
- NOT_IN_USE:连接空闲,可以被借用
- IN_USE:连接正在被业务线程使用
- RESERVED:连接被保留了,用于连接泄漏检测
- MARKED_FOR_REMOVAL:连接被标记为要移除,等待当前使用结束后清理
- REMOVED:连接已从池中移除
为什么不用 if-else?因为连接的状态变化是并发的------有线程在借连接,有线程在还连接,有线程在扫超时连接做驱逐。用 CAS 保证状态的原子切换,比加锁快得多,也比 if-else 看着清晰。
总结几句
翻了 HikariCP 源码后我最大的感受是:设计模式不是用来装饰代码的花边,它是解决具体性能问题的工具。代理模式替它省了反射开销,工厂模式替它省了启动时间,状态模式替它省了锁竞争。每个模式都在直接服务于 HikariCP 的核心竞争力------快。
Brett Wooldridge 写这个连接池的时候,肯定不会先在脑子里过一遍 GoF 23 种模式再开始写。他只是写了正确的东西,而正确的东西碰巧符合这些模式的结构。
这就回到了设计模式学习的本质:不是背模式、套模式,而是理解了模式之后,你写出来的代码天然就带着模式的味道。
我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」,23 个设计模式用漫画加答题的方式讲,HikariCP 这种源码里的设计模式我也会陆续加进去。如果你觉得这种"从源码学模式"的思路有意思,搜一下「爪爪代码冒险记」或者等我后面的文章。