系列目录
- MySQL 索引与慢查询:B+ 树如何减少扫描
- 声明式事务之下:InnoDB 的 MVCC 与锁
- 接入层:Nginx 反向代理与 OpenResty 的边界
- Web 容器与 Netty:线程模型之下的 IO 模型
- Redis(上):缓存用法与单线程模型的限制
- Redis(下):超出缓存用途的用法:锁、队列与排行榜
- 数据库访问层:连接池与 MyBatis 的显式 SQL(本篇)
压测后的连接池调整
(数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上)记录了云盘转 Java 时连接池与声明式事务的接入。那篇的内容包括 @Transactional 的代理边界、连接被事务占用的时长、共享连接池下慢接口拖累快接口。当时连接池大小用的是默认值和经验值,没有按压测推导。在 IM 融合前的压测(压测与容量评估:IM 融合前的流量需求)中,模拟峰值流量时活动连接数很快达到上限,快接口开始等待连接,RT 上升。
当时先调大了 maxActive(DBCP 1.x 参数)。调整后连接池不再耗尽,但数据库整体 RT 上升,之前 2ms 量级的查询变成 5ms 量级。连接数翻倍并未使吞吐等比例提高,查询反而变慢。因此需要确定连接池大小的设定方法。
之前已经总结过连接池与事务的使用层,包括连接的借还与自调用绕过代理;本文总结下连接数的推导方法和 ORM 的 SQL 生成方式,不重复前文引用的内容。
连接池大小:Little’s law 与数据库并发度约束
连接池需要确定两项行为:借不到连接时如何处理,以及池中保留多少连接。等待超时(HikariCP 的 connectionTimeout)决定前者:超时会抛异常,让上游以受控失败的形式感知,等待连接的线程不会无限累积。本文重点讨论连接数。
HikariCP 2.x 的相关参数:
maximumPoolSize:池中连接数上限。达到上限后新的借用请求进入等待。minimumIdle:最小空闲连接数。HikariCP 文档建议与maximumPoolSize相等,避免连接随负载伸缩时反复建连的开销。connectionTimeout:获取连接的等待超时,默认 30s。超时抛SQLException。它只限制等待连接的时间,不限制 SQL 执行时间或事务持续时间。idleTimeout/maxLifetime:空闲连接存活时长 / 连接最大存活时长。maxLifetime应比数据库或网络设备的最长空闲断连时间短几秒(如 MySQL 的wait_timeout),避免借到一条刚被数据库关掉的连接。- 连接校验:HikariCP 默认用 JDBC 4 的
Connection.isValid()做借出校验,不需要配connectionTestQuery。只有驱动不支持isValid()的老驱动(JDBC 3 时代)才需要显式配置一条测试 SQL,否则借出校验会被跳过。
连接数可以先用 Little’s law 推导:L = λ × W。L 是系统里同时在处理的请求数(需要的并发连接数),λ 是应用需要支撑的每秒查询数,W 是单条查询在数据库里的平均耗时(秒)。比如要支撑每秒 1000 次查询、单查询平均 5ms(0.005s),需要的并发连接数 ≈ 1000 × 0.005 = 5。
这个公式只提供需求侧的估算,并有三个局限。第一,它假设稳态,忽略排队和锁竞争;查询在连接上等待行锁时会占用这条连接,W 增大,L 也随之增大。第二,它给出所需连接数的下界;超过需求侧的连接数不会继续提高吞吐,因为请求到达速率没有增加。第三,它没有考虑数据库能够同时服务的并发查询数。数据库的有效并发度受 CPU 核数和磁盘并行度约束:8 核的 MySQL 实例能真正并行执行的查询在十几的量级,再多就会增加 CPU 上的上下文切换。HikariCP 的设计文档指出,较小的连接池通过更快的复用获得的吞吐高于以更多排队等待为代价的大连接池,因为数据库的并发服务能力有限。
连接数上限受数据库的有效并发度约束。公式算出的需求侧 L 小于数据库有效并发度时,连接池可按 L 配置并留出余量;L 较大时,将池子配置在数据库有效并发度附近即可。更多连接会增加数据库内部的上下文切换和锁竞争。2016 年调大 maxActive 后 RT 上升,原因是连接数超过了数据库能有效服务的并发度。
HikariCP 的连接复用与 Druid 的监控能力
连接池较小时,连接借还的开销会直接影响请求处理速度。HikariCP 的设计目标是尽量降低一次连接借还的代价。它的几个实现取舍:
- FastList 代替 ArrayList。 连接代理上记录的 Statement 用 FastList 管理:get 去掉了 ArrayList 的越界检查,remove 按从尾到头的顺序扫描(Statement 通常后开的先关,从尾部找命中率最高)。借还连接路径上 Statement 的登记与注销是高频操作,这点开销在大并发下被放大。
- ConcurrentBag。 自定义的无锁连接借还集合。借连接时先查当前线程的 ThreadLocal 列表,再扫共享列表,都没有就进 handoff 队列等有线程还连接;还连接时有等待者就直接交接,没有等待者就放回当前线程的 ThreadLocal 列表。全程用 CAS 改状态,避免了传统连接池在借还路径上的
synchronized或ReentrantLock。 - 借出期间的连接属性重置。 ProxyConnection 用一个位字段记录借出期间被改过的连接属性(autoCommit、隔离级别、catalog 等),归还时只重置改过的项。
close()不向外抛出关闭残留 Statement 时的委托层异常;关闭失败时会把连接标记为损坏并驱逐出池。 - 字节码精简。 连接代理类用 Javassist 生成,方法体尽量短,减少 JIT 内联失败和栈帧开销。
这些实现减少连接借还路径的开销,避免连接池本身成为吞吐瓶颈。小连接池在高并发场景中依赖快速借还来复用连接。
Druid 将监控集成到连接池中:StatFilter 拦截每条 SQL,采集执行次数、平均耗时、最慢 SQL;Druid Monitor 提供内置的 web 控制台查看这些指标。国内团队使用 Druid 往往是为了同时获得连接池监控和 SQL 监控,可以省去单独接入监控系统的工作。HikariCP 定位为纯连接池,监控需要通过外部组件(Dropwizard Metrics、Micrometer)提供。在没有统一监控平台的项目中,Druid 自带控制台可以减少接入成本;我认为这是它在 2015-2017 年国内 Java 圈普及度高于 HikariCP 的主要原因。
ORM:SQL 的生成方式
ORM 的主要区别在于 SQL 的生成方式。JPA/Hibernate 自动生成 SQL:开发者定义实体与关联(@Entity、@OneToMany、@ManyToOne),框架根据映射配置和方法调用拼 SQL。MyBatis 的 SQL 写在 mapper XML 或注解里,框架负责参数绑定和结果映射,不生成 SQL。
Hibernate 的入口是 Session 和一级缓存。按主键查同一个对象时,在一个 Session 内查两次,第二次直接返回缓存里的对象,不发 SQL。拿到一个 User 后,调用 user.getOrders() 可以获得订单列表,开发者无需编写 JOIN。查询行为依赖映射配置,调用代码中不直接体现:fetch = FetchType.EAGER 的关联会在每次查主实体时一并查出,fetch = FetchType.LAZY 的关联在第一次被访问时才发 SQL。
LAZY 关联有一个常见的性能问题,即 N+1 问题。查询列表中的 N 条主实体后,每条实体的 LAZY 关联在后续访问时各发一条 SQL,原本一次查询变成 N+1 次。调用仍然是 user.getOrders(),代码中不直接体现额外的 SQL。Hibernate 提供 JOIN FETCH、@Fetch(FetchMode.SUBSELECT)、EntityGraph 等方式,将 N+1 查询减少为一次或几次查询;开发者需要在写查询时预先确定会访问哪些关联。
MyBatis 把 SQL 留给开发者。一条查询对应 mapper XML 里的一段 SQL:
<select id="findFileByUid" resultType="FileInfo">
SELECT id, uid, name, size, created_at
FROM file
WHERE uid = #{uid}
<if test="status != null">AND status = #{status}</if>
ORDER BY id DESC
</select>动态 SQL 用 <if>、<foreach>、<choose> 标签拼条件,SQL 结构在 XML 中可见。SqlSession 是 MyBatis 的执行入口,一次 SqlSession 对应一次数据库会话,mapper 接口方法最终映射到 XML 里的某段 SQL。MyBatis 不自动加载对象关联;JOIN 和嵌套结果需要分别编写 JOIN 与配置 resultMap。MyBatis 也有 N+1 风险:collection 标签配置嵌套查询时,外层每查一条,内层各查一次。这要求在 XML 中显式声明嵌套查询,和 JPA 在访问 LAZY 关联对象时隐式触发 SQL 的机制不同。
为什么选 MyBatis
云盘的数据库访问选 MyBatis,主要原因是 SQL 需要人工检查和优化。文件元数据查询里有分页、多条件过滤、递归目录,这些查询的慢查询治理在 MySQL 索引与慢查询:B+ 树如何减少扫描讲过;治理的前提是每条 SQL 都是写出来的,能在慢查询日志中对应到具体的 mapper 方法。使用 JPA 时,方法名派生查询或 @Query 之外的自动生成 SQL,需要先从慢查询日志定位生成 SQL 的方法,再检查映射配置;优化时修改的是映射配置。
我没有在 JPA 项目中做过生产开发,云盘使用的也是 MyBatis。因此,上面对 JPA N+1 的讨论基于 Hibernate 文档和公开资料,范围限于机制说明。MyBatis 文档描述了 collection 嵌套查询的 N+1 风险;云盘的 resultMap 都用联合查询一次取出,没有使用会触发 N+1 的嵌套查询写法。
连接池监控与 ORM 选择检查项
连接池监控必须暴露的指标:
- 活跃连接数(active)。 当前被借出在用的连接。长期接近
maximumPoolSize说明池子配小了或下游慢。 - 空闲连接数(idle)。 当前可借的连接。
- 等待线程数(pending / threads awaiting connection)。 在等连接的请求数。大于 0 且持续,说明池子成了瓶颈。
- 等待耗时(acquire time / wait time)。 借一条连接的平均耗时。在活跃连接数达到上限前,等待耗时已经可能上升。
- 连接创建数 / 销毁数。 频繁创建销毁说明
minimumIdle与maximumPoolSize差距大或maxLifetime配得过短。
ORM 的选择标准:
- 团队里是否有人对每条 SQL 负责。能检查慢查询、能改 SQL 的团队,可以通过 MyBatis 的显式 SQL 直接将慢查询日志对应到 mapper 方法。使用框架生成 SQL 且无人复核的项目,JPA 的开发效率更高,但慢查询治理时需要从方法名定位生成的 SQL。
- 查询复杂度。多表 JOIN、递归、报表类查询可以直接用显式 SQL 指定查询逻辑;标准 CRUD 为主、关联简单的领域模型,JPA 的样板代码更少。
- N+1 检查。JPA 项目应把 LAZY 关联的访问路径列入 review 检查项,关键查询用 JOIN FETCH 或 DTO 投影明确需要加载的关联。MyBatis 项目应把
collection嵌套查询列入同样的检查项,优先用联合查询。
参考资料
- HikariCP 官方文档(About Pool Sizing、Configuration 章节,2.x 版本)
- MyBatis 3 官方文档(mapper XML、动态 SQL、resultMap 章节,3.4.x 版本)
- Hibernate 官方文档(Session、一级缓存、FetchType 章节,5.x 版本)
- Druid GitHub Wiki(StatFilter、Druid Monitor)
- 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上(2016 系列第 3 篇,连接池与事务使用层,与本篇分工)
- 压测与容量评估:IM 融合前的流量需求(2016 系列第 10 篇,压测方法)
- MySQL 索引与慢查询:B+ 树如何减少扫描(慢查询治理,本篇选 MyBatis 的理由呼应)
