资讯详情

资讯详情

JDBC全攻略:从连接管理到连接池,掌握数据库访问底层

做Java开发这些年我见过不少同事从入门就直接用各种ORM框架整表操作、自动生成SQL用得不亦乐乎。可一旦碰到老系统维护、数据迁移、报表跑批或者需要精细控制SQL执行路径的时候才发现自己连最基本的JDBC都快写不出来了。JDBCJava Database Connectivity是Java访问关系型数据库最底层的标准接口你平时用的框架最终都是通过它跟数据库会话。这篇全攻略我不打算写成语法手册而是把多年实际项目中真正影响过我的JDBC知识点从驱动加载、连接管理、PreparedStatement选型、ResultSet处理、事务与批处理到连接池和各类高频坑位一次性整理清楚。适合想补底层功课的初级工程师也适合被困在“只会上框架”舒适区的同学——看完你至少能独立写出一套能上生产环境的JDBC访问层。1. 先搞清楚JDBC的定位它到底替我们挡掉了什么1.1 JDBC是标准接口不是具体实现JDBC本质上是一套接口规范定义在java.sql和javax.sql两个包下。真正干活的数据库驱动是各家数据库厂商按这套规范写的实现类。所以你在代码里面对的是统一的Connection、Statement、ResultSet接口不用关心后面对接的是MySQL还是PostgreSQL——只要驱动Jar换一换连接串改一改业务代码几乎不用动。这种设计可以类比成家用插座标准JDBC就是插座规格厂商是生产适配器的你只需要认准规格去插就行。这也是为什么JDBC从1997年诞生到现在Java生态经历了那么多轮更新它依然是数据库访问的底座。1.2 核心对象的工作流一次典型的JDBC访问流程分五步通过DriverManager拿到Connection基于Connection创建Statement或PreparedStatement执行SQL拿到ResultSet查询场景遍历ResultSet读取数据释放资源按“先ResultSet、再Statement、最后Connection”的顺序关闭这里有个容易被忽略的机制DriverManager会遍历所有已注册的Driver逐个询问“你能处理这个URL吗”直到某个Driver返回true。JDBC 4.0之后驱动类可以通过SPI机制自动加载所以很多新人看不到Class.forName(com.mysql.cj.jdbc.Driver)这行代码以为驱动不需要注册了——其实是JVM在背后用ServiceLoader帮你做了这件事。1.3 为什么现在学JDBC仍然不亏ORM框架的本质是在JDBC之上做了一层对象映射和SQL生成。如果底层机制不懂出了问题只能瞎猜。比如你熟悉的框架里SqlSession不过是对Connection的包装一级缓存的生命周期绑定在会话上背后其实就是JDBC事务边界的问题。把JDBC吃透之后框架里的很多“神奇行为”都会变成常识。另外在生成报表、批量导入、复杂查询这些场景手写JDBC往往是最可控的方案——你清楚每一条SQL什么时候发送、连接什么时候归还、事务边界在哪排查问题根本不用靠日志猜。2. 第一个可运行示例驱动、连接串与try-with-resources2.1 依赖与驱动类版本对不上会有什么表现以MySQL为例Maven里引入连接驱动dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency注意MySQL 8的驱动类名是com.mysql.cj.jdbc.Driver老版本是com.mysql.jdbc.Driver。如果你的代码里写的是老类名而项目里用的是新版驱动Jar启动时直接抛ClassNotFoundException。这类问题看起来像环境问题实际就是版本迁移没做干净。另外一个常见现象是“类能加载但连接报错”——多半是连接串参数和驱动版本不匹配。升级驱动前一定先看兼容说明后文踩坑章节还会再讲。2.2 连接串参数逐个拆解标准连接串格式长这样jdbc:mysql://127.0.0.1:3306/testdb?useSSLfalseserverTimezoneGMT%2B8characterEncodingutf8参数作用建议127.0.0.1:3306数据库地址和端口MySQL默认端口3306testdb目标库名与连接账号权限对应useSSLfalse是否启用SSL本地开发关掉避免证书协商日志serverTimezoneGMT%2B8服务器时区必须显式设置否则报时区错误characterEncodingutf8客户端字符集配合表结构的utf8mb4避免中文乱码这个URL最好放在配置文件或配置中心不要写死在代码里。原因后面讲连接池时再说你先记住这个习惯。2.3 第一段完整查询代码下面是一段带参数查询的完整代码按生产习惯来写String url jdbc:mysql://127.0.0.1:3306/testdb?useSSLfalseserverTimezoneGMT%2B8characterEncodingutf8; String user app_user; String password password; String sql SELECT id, name, created_at FROM user WHERE id ?; try (Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, 1001); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { long id rs.getLong(id); String name rs.getString(name); Timestamp createdAt rs.getTimestamp(created_at); System.out.printf(id%d, name%s, createdAt%s%n, id, name, createdAt); } } } catch (SQLException e) { // 记录日志不要吞异常 throw new RuntimeException(查询用户失败, e); }这里用了两层try-with-resources外层管Connection和PreparedStatement内层管ResultSet关闭顺序由JVM保证避免了手动finally容易遗漏的问题。需要特别强调DriverManager.getConnection每次都会新建一个物理连接。本地Demo没问题生产环境必须交给连接池这是第6节的重点——但前提是你先把这段最朴素的代码跑通。3. PreparedStatement为什么是默认选择执行计划与注入防御3.1 字符串拼接SQL为什么危险先看一个反面教材String name userInput; // 假设来自前端 String sql SELECT * FROM user WHERE name name ;如果前端传的是 OR 11拼接出来就变成SELECT * FROM user WHERE name OR 11整表数据全被拉出来。如果再恶意拼上分号和多段SQL后果更严重。有同学觉得参数校验做得好就没事但校验只能挡一部分治本的办法是参数化查询——让驱动把输入当数据而不是当SQL骨架。3.2 预编译的底层原理一次解析、多次执行PreparedStatement会把SQL骨架先发给数据库数据库完成解析、校验、生成执行计划后缓存起来之后每次执行只需要传参数数据库直接用缓存好的计划执行。所以对“同一条SQL执行很多次、参数不同”的场景比如批量导入PreparedStatement明显比Statement快。Statement每执行一次SQL都要经历完整的解析流程等于每次重新下订单。而PreparedStatement是“先下订单、锁定菜品再反复上菜”。不过有个MySQL细节得说清楚MySQL内核层面是否缓存执行计划取决于服务端配置和驱动参数比如useServerPrepStmts。但即便没有服务端预编译Java驱动层面的参数类型绑定、转义处理和安全效果仍然是实实在在的。我的经验是快慢是次要的安全才是直接决定生死的。3.3 参数化防不住的场景结构部分必须白名单占位符只能绑定字面量值SQL的“骨架”部分没法参数化比如ORDER BY的字段名排序方向ASC / DESC表名、列名正确做法是白名单校验字段名必须出现在允许列表里排序方向只允许asc或desc。另外模糊查询要写成name LIKE ?参数用% keyword %传进去不要把百分号拼进SQL里。4. ResultSet取数与行映射从getInt到通用映射器的思路4.1 ResultSet的默认行为与游标移动ResultSet默认是TYPE_FORWARD_ONLY只能从前往后单向移动next()返回boolean表示是否还有下一行。只有少数驱动、特定参数下支持双向滚动TYPE_SCROLL_INSENSITIVE但生产上绝大多数场景用不到反而会加大内存负担。所以建议一开始就养成“单次遍历”的心智模型查询结果是一次性的数据流需要二次处理就在遍历过程中完成不要把整个结果集塞进内存再慢慢玩。4.2 常见数据类型与NULL处理getXxx有两种取法getXxx(int columnIndex)按列索引getXxx(String columnLabel)按列标签。按索引效率略高一点点按标签可读性更好。我推荐按标签取值因为SELECT返回列的顺序很容易因为重构变化按索引取值容易错位且完全看不出来。最容易被坑的是SQL NULL的隐式转换getInt()返回0getString()返回nullgetObject()返回null。如果业务里“0”和“NULL”语义不同比如“未设置”和“数值0”你必须先用rs.getObject(field)判断是否为null或者用rs.wasNull()检查上次读取的列是否为NULL。4.3 手写一个通用行映射器手写映射通常两种一种是按列硬编码到DTO写起来啰嗦但可控另一种是用反射按列名和属性名对映适合列名规范的表。下面这个思路是很多轻量级DAO框架的雏形FunctionalInterface interface RowMapperT { T mapRow(ResultSet rs) throws SQLException; } private T ListT query(String sql, RowMapperT mapper, Object... params) { ListT result new ArrayList(); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { result.add(mapper.mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException(e); } return result; }调用方式ListUser users query(SELECT id, name FROM user WHERE status ?, rs - new User(rs.getLong(id), rs.getString(name)), 1);这段代码你吃透了再去看框架里的RowMapper接口、结果处理器会觉得非常亲切。它解释了一个核心问题ORM底层不过是在做“ResultSet到对象”的搬运。5. 事务、批量提交和自增主键数据库写入的三个高频细节5.1 事务的边界到底划在哪默认情况下connection.getAutoCommit()为true每条SQL执行完自动提交。想要事务控制就得关闭自动提交conn.setAutoCommit(false); try { // 多条DML conn.commit(); } catch (Exception e) { conn.rollback(); throw e; }事务的边界就是“从setAutoCommit(false)到commit/rollback”之间的所有SQL。注意事务绑定在Connection上必须在同一个连接对象上完成。这也是为什么事务里不能用连接池随手拿到的两个不同连接——第二个连接根本不参与前一个连接的事务。隔离级别有四个它们和并发问题的关系如下隔离级别脏读不可重复读幻读READ_UNCOMMITTED可能可能可能READ_COMMITTED避免可能可能REPEATABLE_READ避免避免可能SERIALIZABLE避免避免避免MySQL默认REPEATABLE_READ部分数据库默认READ_COMMITTED。并发要求高的系统有时会把MySQL事务级别调成READ_COMMITTED来减少间隙锁导致的死锁。但这个决策一定要结合实际业务和压测不要照抄网上的结论。5.2 批量插入的提速姿势与clearBatch的必要性批量插入用addBatch攒一批、executeBatch一次发出去网络往返从N次降为1次try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(INSERT INTO log(content, created_at) VALUES (?, ?))) { int batchCount 0; for (LogItem item : items) { ps.setString(1, item.getContent()); ps.setTimestamp(2, new Timestamp(item.getCreatedAt())); ps.addBatch(); if (batchCount % 1000 0) { ps.executeBatch(); ps.clearBatch(); } } ps.executeBatch(); }两个关键点攒到一定量就执行一次比如每1000条一批防止单批参数占用过多内存executeBatch之后必须clearBatch否则下一轮还会带上旧参数。这是很多批量导出程序隐藏的bug我排查过不止一次executeBatch返回的int[]里每个元素表示对应语句的影响行数-2表示驱动无法统计。这个数组只能当参考不要依赖它的绝对准确性。5.3 拿到自增主键的正确方式插入后想拿到数据库生成的ID要这样做try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(INSERT INTO user(name) VALUES (?), Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, 示例用户); ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { long newId rs.getLong(1); } } }注意getGeneratedKeys必须在executeUpdate之后、连接关闭之前调用。如果prepareStatement时没传RETURN_GENERATED_KEYS某些驱动下会返回空结果。批量插入想拿每个自增ID不同数据库行为差异很大。MySQL驱动默认只返回一部分生成的键具体表现和连接串参数、驱动版本强相关这块一定要在目标库上实测不要想当然。6. 连接池配置从手动建连到生产级参数调优6.1 手动建连的开销到底有多大建立一次物理连接包括TCP握手、认证、权限校验、会话初始化动辄几十到几百毫秒。高并发下每次请求都新建连接数据库很快会被打满连接数。我以前做过一次压测对比同一个查询接口从DriverManager手动建连改成连接池后吞吐量差了大半个数量级。这还只是查询写入场景差异更明显。更隐蔽的问题是手动建连的代码很容易泄漏连接——异常分支里一旦忘记关闭连接就会被慢慢耗光应用看起来没报错其实已经“假死”了。6.2 连接池的核心参数与调优思路拿常见的HikariCP连接池举例一段标准配置大致是maximumPoolSize: 20 minimumIdle: 5 connectionTimeout: 30000 idleTimeout: 600000 maxLifetime: 1800000我不打算逐条背参数手册只说几个真正影响业务的点maximumPoolSize不是越大越好。池大小最佳值大致可以用“核心处理线程数×(1等待系数)”估算。常规IO密集的数据库操作20个连接通常已经能撑住上百并发connectionTimeout是“获取连接的等待超时”。线上频繁出现“Connection is not available”要么连接不够要么用完了没归还maxLifetime必须小于数据库端的wait_timeout否则池里的连接会被数据库主动断开客户端还以为是好连接拿出去执行SQL直接报连接失效minimumIdle和maximumPoolSize设为相同值等价于固定连接数能避免冷启动但代价是空闲连接不回收6.3 集成连接池后的标准代码形态一旦引入连接池代码里不再直接DriverManager.getConnection而是dataSource.getConnection()。连接池的getConnection拿到的其实是池化连接调用close()不会真正关闭物理连接而是“归还”到池里。所以前面讲的try-with-resources照样可以写但语义已经从“关闭”变成了“归还”。一个常见误区是多个DAO方法各自getConnection以为它们共享同一个事务。实际上它们是不同物理连接事务不可能跨连接生效。要么在一个方法里完成整条事务链要么用ThreadLocal把连接绑定到当前线程——这是自行封装DAO时最容易出乱子的地方。7. 高频踩坑定位手册连接泄漏、时区、游标与动态SQL7.1 连接泄漏明明没报错数据库却耗尽连接最常见的泄漏不是没写close而是写了但没在finally里。比如查询方法里提前return了或者异常分支抛SQLExceptionclose永远没执行。现在Java 7以后的try-with-resources能自动关闭尽量把资源声明全放到try括号里。连接池场景下泄漏的典型表现是“连接池耗尽、应用假死”日志里全是Connection is not available。排查方式看连接池的active/idle/pending指标或者用线程栈工具分析找出“持有连接但没有归还”的线程。能在日志里打印连接池指标的应用在真实故障里能省下大量排查时间。7.2 时区与日期类型为什么时间老是差8小时“为什么时间保存到数据库后差了8小时”是永恒热题。本质是驱动、服务器、数据库三者的时区不一致。JDBC连接串里的serverTimezone参数如果没配MySQL 8驱动要么抛异常要么隐式用服务器默认时区。统一的解决方案连接串固定时区比如serverTimezoneGMT%2B8应用层涉及日期时间时统一用Timestamp或LocalDateTime处理不要在代码里到处用Date做字符串转换跨时区场景会非常痛苦。7.3 大结果集游标溢出与fetchSize的真实作用查询几百万行时一次性把所有结果加载到内存轻则GC频繁重则OOM。MySQL驱动的处理方式是连接串加useCursorFetchtrue同时设置statement.setFetchSize(1000)驱动才能以流式方式不断从服务端拉取。注意fetchSize只是“建议值”如果驱动不支持服务器端游标它会被直接忽略数据还是会一次性全部拉回。另外流式遍历期间连接被占用不能在同一个连接上执行其他SQL——这个限制在写并发读写的代码时要特别小心。还有一个容易被忽略的问题大结果集遍历完一定要在finally里关闭ResultSet后再归还连接。否则游标没释放数据库端会话一直挂着最终触发“游标溢出”或临时文件暴涨。7.4 动态SQL里头“插不进去”的部分怎么处理前文提过排序字段、表名这类结构不能参数化但还有一些看着能参数化、实际很坑的场景LIKE带通配符LIKE ?参数传% keyword %没问题把百分号拼进SQL里等于放弃了参数化IN列表WHERE id IN (?)只有一个问号传不了数组。要么按数量动态生成问号占位符要么用批量值拼接并做好长度限制。注意IN列表太长时要分段或换用临时表很多数据库对IN元素数量有限制数值精度setObject传BigDecimal给DECIMAL列时scale不一致可能触发乱进位最好先setScale再传入这些场景没有一劳永逸的参数化方案核心思路就一句话凡是SQL结构部分一律用白名单或硬编码凡是数据部分一律走占位符。7.5 驱动版本与数据库版本匹配MySQL 5.7的项目用老驱动一般没问题但MySQL 8默认认证插件是caching_sha2_password老驱动连不上直接报认证协议错误。反过来驱动升级到com.mysql.cj.jdbc.Driver后连接串参数也要跟着确认。这种问题排查起来费劲程度不亚于SQL报错。所以升级驱动或数据库前先查驱动的兼容矩阵再在测试环境把“驱动版本数据库版本连接串参数”三件套验证一遍能省一大半折腾的时间。最后说一句实在话。我这些年见过太多“只玩框架、不碰JDBC”的开发者遇到SQL慢、连接池耗尽、事务失效这类问题就无从下手。JDBC确实原始但它给了你完全掌控一次数据库会话的能力。把这套全攻略里的代码和坑位过一遍再回去读框架源码你会发现原先那些“玄学”其实都很直白。如果接下来想进阶我建议顺着三个方向继续挖一是看自己用的框架底层是怎么复用连接的二是把PreparedStatement的服务端预编译效果和执行计划实际对比一次感受“一次解析、多次执行”到底差在哪三是尝试写一个精简的ORM雏形。这三件事做完你会回来感谢JDBC的——它才是让你真正理解数据库访问的那把钥匙。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →