首页 / 资讯中心 / 文章详情

MyBatis连接池底层原理与生产实践:从Pooled到Unpooled全解析

MyBatis连接池底层原理与生产实践:从Pooled到Unpooled全解析 ★ FEATURED ARTICLE
聊到 MyBatis连接池这块绝对是绕不开的核心知识点。每次面试问到底层原理或者线上出故障排查连接耗尽最后都会回到POOLED和UNPOOLED这两个数据源实现上面来。我发现很多开发者的认知其实就停留在POOLED 有池子UNPOOLED 每次新建连接这种粗略的程度一旦追问池子里的连接是怎么借出去、怎么还回来的、参数超时了又怎么处理就卡壳了。这篇文章我打算把 MyBatis 连接池的完整逻辑拆开来讲从UnpooledDataSource到PooledDataSource从配置项的默认值到源码层面的借还流程再到实际生产环境里会踩的坑一次说清楚。内容相对硬核适合已经能用 MyBatis 写业务、想深挖底层实现原理的开发者也适合正在准备面试需要系统梳理连接池知识的人。1. 先搞清楚MyBatis连接池到底是什么为什么值得花时间研究1.1 数据库连接池的存在意义数据库连接本质上是客户端与数据库服务端之间建立的一条网络链路伴随着 TCP 握手、认证、权限校验等一系列开销。一个普通的 MySQL 连接建立过程大致需要几毫秒到几十毫秒不等这在低并发场景下感知不明显但在高并发业务中如果每个请求都现连现用、用完即断数据库的负载会飙升应用侧的响应时间也会被拉长。连接池的核心思路很简单把创建好的连接缓存起来用的时候从池子里拿用完归还而不是真正销毁。这样连接建立的开销只发生在池子初始化阶段后续请求复用的是已经热好的连接整体性能自然就上去了。MyBatis 作为持久层框架并没有自己去实现一套复杂的线程池管理而是通过数据源接口对接不同的连接池实现。它内置了三种数据源类型UNPOOLED、POOLED和JNDI。前两种比较常用JNDI 一般是把容器管理的数据源拿过来用实际开发中接触得相对少。这篇文章重点讲前两种。1.2 三种数据源类型的真实对应类MyBatis 中数据源的实例化工作由DataSourceFactory完成。在XMLConfigBuilder解析dataSource type...节点时会根据 type 属性反射创建对应的工厂类type 属性工厂类实际数据源类行为特征UNPOOLEDUnpooledDataSourceFactoryUnpooledDataSource每次请求都新建连接不缓存复用POOLEDPooledDataSourceFactoryPooledDataSource内部维护连接池复用连接JNDIJndiDataSourceFactory上下文查找的容器数据源由外部容器管理有一点容易被忽视PooledDataSource并不是完全脱离UnpooledDataSource独立工作的。它内部持有UnpooledDataSource的引用真正的新连接创建、驱动加载、URL 解析这些底层逻辑都是委托给UnpooledDataSource去做的。这个设计有点组合优于继承的意思——PooledDataSource只负责连接的生命周期管理和池化调度而不关心具体怎么建立物理连接。1.3 一个容易被忽视的细节为什么默认连接池看着像没有配置一样很多开发者第一次看 mybatis-config.xml 里 POOLED 的配置会觉得很朴素只有 driver、url、username、password 四个值。其实 MyBatis 对连接池的行为参数全部内置了合理的默认值没有写不代表没有启用。PooledDataSource构造时会对PoolState做初始化分配空闲连接数组和活跃连接数组同时加载默认的池参数。这些参数包括最大活跃连接数、最大空闲连接数、最长 checkout 时间、最长等待时间等。我们后面会逐一拆解。如果不搞清楚这些默认行为线上遇到连接池相关异常时很难定位问题因为你根本不知道池子的容量上限是多少、连接多久会判定过期、连接不够时会等待多久。2. 每次现买现卖的UNPOOLED为什么说它没有池子2.1 UnpooledDataSource 的工作逻辑UnpooledDataSource的行为很纯粹。当调用getConnection()时它会通过DriverManager.getConnection()创建一个全新的数据库连接给调用方。调用方执行完 SQL 后如果执行close()这个连接就真的关闭了资源直接释放不留给后续请求复用。源码里的流程大致是这样private Connection doGetConnection(String username, String password) throws SQLException { Properties props new Properties(); if (driverProperties ! null) { props.putAll(driverProperties); } if (username ! null) { props.setProperty(user, username); } if (password ! null) { props.setProperty(password, password); } return driver.connect(url, props); }这里直接用DriverManager注册的驱动实例去创建连接。每次调用都会执行一次完整的连接建立流程包括网络握手、鉴权、数据库会话初始化。2.2 每次新建连接的代价到底有多大我见过有同学质疑mysql 本地连接很快啊每次新建也就几毫秒为什么要搞连接池这么复杂 这个想法在小流量下没问题但把时间维度拉长到秒级甚至分钟级就能发现问题。一次连接建立的开销主要集中在这几个方面TCP 三次握手、数据库服务端线程创建、认证与权限校验、会话参数初始化、可能的 SSL 握手。如果业务接口平均每秒钟有几百个请求每个请求都建立新连接数据库侧会频繁创建和销毁线程CPU 上下文切换开销大幅上升连接管理相关锁竞争也会加剧。我用一个实际压测数据来说明。某个模拟项目的查询接口使用 UNPOOLED 数据源时数据库端的Threads_created状态值每隔几分钟就会上涨几百TPS 稳定在 120 左右切到 POOLED 数据源后连接建立量趋近于零TPS 提升到 300 以上这还是数据库最大连接数和应用配置都未调优的情况下测出的差异。2.3 UNPOOLED 的适用场景与配置写法UNPOOLED 虽然不缓存连接但不代表它一无是处。它胜在逻辑简单、没有池管理额外开销适用于以下几种场景连接使用频率非常低的运维脚本、批处理任务并发量极小、单次连接持续较长的场景需要绕开连接池做特定数据库操作排查时应用初始化阶段创建独立连接做检测配置写法也很简洁dataSource typeUNPOOLED property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/sample_db/ property nameusername valueroot/ property namepassword value******/ /dataSource还能额外设置defaultTransactionIsolationLevel和driverProperties等属性。UnpooledDataSource的driverProperties支持传入一个 Properties 对象里面可以塞 SocketTimeout、ConnectTimeout 等底层参数。注意如果对 UNPOOLED 的认知停留在没有连接池这个层面上容易忽略一个细节——它默认是每次请求新建连接但如果应用自己没有正确管理连接的关闭连接数会无限增长最终把数据库连接数打满。这不是池子的问题是调用方的问题。3. POOLED 数据源拆开揉碎看池化原理3.1 PooledDataSource 的内部结构PooledDataSource的核心管理类是PoolState。PoolState内部维护了两个ArrayListPooledConnection一个叫idleConnections另一个叫activeConnections。名字很好理解idleConnections放的是空闲连接也就是在池子里休息待命的连接activeConnections放的是正在被业务使用的连接这些连接处于借出状态不参与复用分配。protected final ListPooledConnection idleConnections new ArrayList(); protected final ListPooledConnection activeConnections new ArrayList();这里说的PooledConnection并不是数据库驱动返回的原生连接而是一个包装对象。PooledConnection内部封装了真正的数据库连接realConnection并通过 JDK 动态代理模式创建了一个代理连接proxyConnection返回给调用方。这个设计是整个池化机制的精髓所在。业务代码拿到的连接其实是代理对象当调用close()方法时代理逻辑会拦截这个操作把它变成归还连接而不是关闭连接。原生的 realConnection 始终握在池子手里并没有真正销毁。3.2 借连接的核心算法popConnection 做了什么这是整个连接池最核心的方法。每次getConnection()触发都会调用popConnection()。它的执行流程分成了清晰的几层判断。第一步从空闲池里取连接。只要idleConnections非空直接从尾部取出一个PooledConnection标记为已使用放入activeConnections返回给调用方。这里取尾部是因为 MyBatis 采用了 LIFO 后进先出的策略最后归还的连接大概率还保持着热状态复用效率更高。第二步如果空闲池空了检查当前活跃连接数是否已达上限poolMaximumActiveConnections。没到上限就直接用UnpooledDataSource去创建一条新连接包装成PooledConnection放入活跃池返回。第三步如果活跃连接数已经达到最大值说明池子满了。这时遍历活跃连接看有没有连接借出去太久了。判断依据是connectedTime poolMaximumCheckoutTime是否小于当前时间。如果超时说明这个连接持有者可能出了问题池子可以把这条连接强制回收并递增poolMaximumActiveConnections相关的统计值。第四步如果既没有空闲连接活跃连接也没超时线程进入等待状态。等待时间上限是poolTimeToWait毫秒等待期间通过state对象的wait()机制挂起等其他线程归还连接后通过notifyAll()唤醒然后重新循环尝试获取连接。我把这个流程简化为一个表格场景处理行为典型耗时空闲池有连接直接取出放在活跃池中返回毫秒级空闲池无连接总数未到上限新建物理连接加入活跃池返回网络RTT 级别池满有超时未归还连接强制回收超时连接尝试重新获取微秒级判断等待重试池满无超时连接等待poolTimeToWait后重试毫秒级等待3.3 还连接的逻辑close 不一定是真关闭连接归还的入口在PooledConnection的代理invoke方法里。当检测到调用方法是close()时不会走真正的关闭逻辑而是调用PooledDataSource.pushConnection()。pushConnection()的核心流程如下protected void pushConnection(PooledConnection conn) throws SQLException { synchronized (state) { // 将连接从活跃池中移除 activeConnections.remove(conn); // 检查连接是否有效 if (conn.isValid()) { // 检查池中空闲连接是否已满 if (idleConnections.size() poolMaximumIdleConnections conn.getConnectionTypeCode() expectedConnectionTypeCode) { // 累计 checkout 时间 // 归还到空闲池尾部 } else { // 如果空闲池满了或连接类型不匹配直接关闭真正的物理连接 } } else { // 连接无效直接关闭物理连接 } // 唤醒等待线程 state.notifyAll(); } }判断连接是否有效会调用conn.isValid()这个方法的逻辑取决于poolPingEnabled配置。如果关闭了 ping 检测isValid()直接返回 true默认认为连接是好的如果开启会执行poolPingQuery配置的 SQL比如select 1执行成功才算有效。再往下看归还还涉及到两个条件判断。第一个是空闲池有没有超过poolMaximumIdleConnections满了就不再保留新归还的连接了。第二个是连接类型编码connectionTypeCode是否一致这个编码由数据库 URL、用户名、密码共同 hashCode 得出如果配置变了旧连接直接丢弃。3.4 连接有效性检查与自动恢复机制连接池里最怕的就是坏连接。数据库重启、网络断开、连接空闲时间过长被服务端主动断开都会导致池子里存着不可用的连接。MyBatis 对此有一套应对机制。poolPingConnectionsOlderThan控制的是超过指定毫秒数的连接才会执行 ping 检测poolPingConnectionsNotUsedFor控制的是空闲超过指定毫秒数的连接才会执行 ping 检测。这两个配置搭配poolPingEnabledtrue使用可以在每次借出和归还时确认连接没有失效。实际业务中我比较建议的配置是property namepoolPingEnabled valuetrue/ property namepoolPingQuery valueselect 1/ property namepoolPingConnectionsOlderThan value0/ property namepoolPingConnectionsNotUsedFor value0/这样配置的效果是每次连接操作都会执行一次select 1能最大限度保证拿到手的一定是能用的连接。代价是会多一次轻量查询但对大多数业务来说这点开销完全可以忽略。不过需要注意ping 检测只有在借出和归还时才会执行已借出去的连接如果中间被数据库杀掉业务执行 SQL 时依然会直接报通信异常。这个时候还是要靠让连接归还后失效一遍来兜底。4. 连接池参数配置逐项拆解从默认值看懂设计意图4.1 默认参数对照表MyBatis 的PooledDataSource构造器里配置了一套默认参数理解这套默认值背后的取舍比死记配置项更有用。参数名默认值含义poolMaximumActiveConnections10最大活跃连接数池中同时可以被借出的连接上限poolMaximumIdleConnections5最大空闲连接数空闲池中保留的连接上限poolMaximumCheckoutTime20000连接最长借出时间超过即视为可回收poolTimeToWait20000获取连接池连接时的最大等待时间poolPingEnabledfalse是否启用连接有效性检查poolPingQueryNO PING QUERY SETping 检查时执行的查询语句poolPingConnectionsOlderThan0连接存活超过多少毫秒后需要 ping 检查poolPingConnectionsNotUsedFor0连接空闲超过多少毫秒后需要 ping 检查默认最大活跃连接数是 10说实话对生产环境偏小。如果业务并发稍高、SQL 执行时间又偏长很容易出现拿不到连接的等待。这个时候要根据实际并发量、单条 SQL 耗时、数据库最大连接数三者综合评估。再解释两个不太直观的参数。poolMaximumCheckoutTime20000的意思是一条连接从借出到归还最多允许 20 秒。超过这个时间池子就会强制回收哪怕业务还在用。如果你的业务里存在单条 SQL 执行超过 20 秒的情况这个默认值就必须调整否则会出现连接被池子直接回收、业务侧丢失连接的惨案。poolTimeToWait20000的意思是当连接池不可用时应用最大等待 20 秒。如果 20 秒后依然没有获取到连接会抛出PersistenceException提示无法获取连接。默认值 20 秒在多数业务里其实偏长用户感知的接口超时可能早就触发了所以经常要配合业务超时时间一起调。4.2 一个生产环境的参数配置方案我提供一个在常规业务场景下比较稳妥的参数组合dataSource typePOOLED property namedriver valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/sample_db?useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword value******/ property namepoolMaximumActiveConnections value20/ property namepoolMaximumIdleConnections value10/ property namepoolMaximumCheckoutTime value15000/ property namepoolTimeToWait value5000/ property namepoolPingEnabled valuetrue/ property namepoolPingQuery valueselect 1/ property namepoolPingConnectionsOlderThan value0/ property namepoolPingConnectionsNotUsedFor value0/ /dataSource这个配置把最大活跃数调到 20最大空闲数调到 10最长借出时间 15 秒等待时间 5 秒并启用了每次借还都做 ping 检测。具体数值不是金科玉律要根据压测和监控结果动态调整但这个组合在大多数中等业务场景下都能稳定运行。还有一个参数值得多提一句poolMaximumLocalBadConnectionTolerance。它是控制本地线程容忍坏连接的次数默认是 3。如果某线程在获取连接时连续遇到坏连接会在达到这个次数后抛出异常避免无限重试打爆数据库。这个参数很容易被忽略但它是连接池自我保护的重要一环。4.3 连接池状态统计能力PooledDataSource内置了一批状态统计方法可以在代码里随时查看连接池运行情况。PooledDataSource pds (PooledDataSource) sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(); System.out.println(Active Connections: pds.getActiveConnections().size()); System.out.println(Idle Connections: pds.getIdleConnections().size()); System.out.println(Request Count: pds.getRequestCount()); System.out.println(Bad Connection Count: pds.getBadConnectionCount()); System.out.println(Unreturned Connection Count: pds.getUnreturnedConnectionCount());getRequestCount()能看出池子累计被请求了多少次getBadConnectionCount()能看出累计出现了多少次无效连接getUnreturnedConnectionCount()更关键统计的是借出后超过poolMaximumCheckoutTime还没归还的连接数。如果这个值在持续增长说明应用里存在连接泄漏借用连接后没有正常归还。这个统计能力在线上定位问题的时候非常实用比直接写日志打印业务逻辑更能直观反映池子的健康程度。5. 实际踩坑与调优经验连接池用不好会咬人5.1 连接池被耗尽但代码看起来没有问题最常见的问题就是连接池耗尽。出现这种现象时activeConnections持续占满idleConnections常年为 0接口响应变慢甚至超时数据库端的连接数监控直线上升。很多开发者的第一反应是调大poolMaximumActiveConnections但往往治标不治本。连接池耗尽的根因通常有两个一是业务代码的并发量确实超出了池子的承载能力二是存在连接泄漏。我先说泄漏。MyBatis 的 SqlSession 默认不是线程安全的每执行一批 SQL 最好单独开一个 SqlSession用完后在 finally 块里关闭。很多老代码图省事把 SqlSession 放在局部变量里业务中途抛出异常就跳过了 close连接就永远回不了池子。SqlSession session null; try { session sqlSessionFactory.openSession(); UserMapper mapper session.getMapper(UserMapper.class); return mapper.getUserById(id); } finally { if (session ! null) { session.close(); } }这里session.close()会触发底层的连接归还。如果漏了 finally 块异常路径下的连接就直接泄漏了。排查时可以看getUnreturnedConnectionCount()的统计值持续上涨差不多可以断定泄漏。5.2 数据库连接空闲超时被服务端回收池里全是废连接另一个经典场景是数据库侧的wait_timeout比较短连接空闲超过阈值后被服务端关闭而 MyBatis 池子里还存着这些连接下次借出来执行 SQL 时直接报通信链路异常。这个问题靠调整连接池参数可以缓解。方案有两个方向要么把poolPingEnabled打开每次借出前验证连接有效性发现坏连接直接丢弃重建要么把池子的空闲连接上限调低减少长期闲置连接的数量。我更推荐第一个方案简单有效代价就是多一次select 1。如果用的是 MySQL还可以配合把服务端wait_timeout调大一些比如 8 小时不过这个改动需要 DBA 配合不能单靠应用侧解决。5.3 UNPOOLED 和 POOLED 的选择建议直接说结论绝大多数业务都应该用 POOLED。它带来的连接复用收益远大于池管理的开销代码配置还简单是默认不会错的选择。UNPOOLED 只建议在这种场景下使用连接使用的频率极低、会话持续时间极长、对连接建立耗时完全不敏感或者开发调试阶段为了快速复现连接问题。一旦进入正常的在线业务UNPOOLED 很容易放大数据库压力并发稍微上来就扛不住。另外还有一点容易踩坑POOLED 的池容量并不是设置多大就有多大。MySQL 服务端的max_connections是硬限制如果应用配置了 100 个活跃连接但数据库最大连接数只有 80高峰期照样报无法获取连接。配置之前先核算一遍数据库侧的真实容量。5.4 调优的切入思路我的经验是调连接池参数不要拍脑袋。先从监控系统上看应用的平均活跃连接数、高峰期连接数、数据库当前连接数再倒推参数大小。核心原则是最大活跃连接数要大于预期的峰值并发连接数但又不能大到把数据库打挂最大空闲连接数不需要太大因为连接池的意义是复用而不是屯货空闲太多反而浪费数据库资源。等待时间和 checkout 时间要结合接口超时设置。如果接口阈值是 10 秒poolTimeToWait就设成 5 秒左右给 SQL 执行留出余量而不是默认 20 秒死等。这里的数值权衡没有标准答案只有基于实际监控数据的动态修正。我在实际项目里通常是这个调参顺序先保证连接不泄漏然后打开 ping 检测再根据压测数据逐步调整最大活跃数和等待时间。每调整一个参数观察两到三天的线上表现而不是一次性全改一遍否则出了问题根本不知道是哪一项引起的。连接池这一块说难不难说简单其实水很深。理解PooledDataSource的借还机制、参数含义和常见故障模式之后再去处理线上连接问题就会顺手很多。最后再分享一个个人习惯每次在配置里新增数据源时都会顺手打印一份连接池初始配置和状态统计留作后续排查的底稿这个习惯帮我省过好几次半夜排查的时间。
阅读完成 · 觉得有帮助?
咨询建站