JDBC这个名字很多人入门Java时都见过但真正把它用明白的人并不多。我见过太多同学学完SSM框架、MyBatis之后回过头来连一个最简单的JDBC查询都写不利索更不用说出问题了连怎么排查都摸不着头脑。其实JDBC才是Java操作数据库的真正地基框架做得再花哨底层也逃不过Connection、PreparedStatement、ResultSet这三件套。这篇文章不讲虚的从JDBC到底解决什么问题讲起到驱动选型、核心接口拆解、手写工具类全流程再到连接泄漏、时区报错这类真实踩坑现场最后聊聊连接池。无论是刚准备学数据库编程的新手还是用了很久框架想补地基的开发者这篇文章都值得你花二十分钟读完。1. JDBC到底在解决什么问题先说个场景。你要写一个Java程序往数据库里存数据数据库厂商那么多有MySQL、有Oracle、有PostgreSQL每家数据库的通信协议都不一样。如果每次换数据库都要把代码重写一遍这活儿就没法干了。JDBC就是Sun为了统一这件事搞出来的一套标准全称Java Database Connectivity它定义了一套通用的接口规范数据库厂商按照这套规范提供自己的驱动实现。你的Java代码只面向JDBC接口编程底层连的是哪个数据库交给驱动去处理。这个设计思路跟日常生活中的插座标准很像。你家的电器只需要一个标准插头至于发电厂是火电、水电还是风电你不用关心电网帮你转换好了。JDBC就是那个标准插头驱动就是电厂端的适配器。JDBC能做什么核心就四件事建立连接、发送SQL、处理结果、管理事务。听着简单但这里面细节多得很比如事务隔离级别、批处理优化、结果集游标、元数据获取每一项展开都是一堆坑。而市面上绝大多数ORM框架像MyBatis、Hibernate底层全部是封装了JDBC你写的XML映射、注解SQL最终都会转换成JDBC调用。所以理解了JDBC你才算真正看懂了这些框架的底层逻辑出了问题也才能从根上排查而不是两眼一抹黑。2. 环境准备与驱动选型2.1 先搞定基础环境动手之前先把环境备齐。JDK建议8以上虽然JDBC是老技术但新版本JDK在驱动加载和连接管理上还是有不少改进。数据库我这边以MySQL 8.0为例来讲这也是目前最主流的选择。开发工具随便IDEA或者Eclipse都行IDE本身不影响JDBC学习。项目结构上建议不要一上来就建Maven项目先建一个普通的Java工程手动把驱动JAR包放进classpath。这么做不是倒退而是让你亲眼看到驱动是怎么被加载的省得一上来就被Maven把依赖细节全藏起来出了问题反而不知道去哪找驱动。MySQL 8.0的驱动包可以从Maven中央仓库下载也可以直接在项目里引入坐标dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency如果你用的是Maven版本号建议锁死别用带SNAPSHOT的版本也别直接不写版本号让IDE自动解析最新的有时候依赖传递会把不兼容的版本带进来很难排查。2.2 驱动选型的几个关键点驱动选型这事坑不少。以前用MySQL 5.x的时候驱动类是com.mysql.jdbc.Driver连接串是jdbc:mysql://localhost:3306/xxx。到了MySQL 8.0驱动类变成了com.mysql.cj.jdbc.Driver连接串不变但如果你还拿老的驱动类去连新版本数据库直接给你抛异常。还有一个容易踩的坑是时区。MySQL 8.0的驱动默认要求连接串里带上时区参数否则会报一个Server returns invalid timezone的错误。这个问题我在第五部分专门讲先记住连接串里要加serverTimezoneAsia/Shanghai就行。PostgreSQL、Oracle这些数据库也有自己的一套驱动和连接串规则但学的时候先精通一种其他数据库的驱动不过是换汤不换药核心机制完全一样。我见过一些开发者简历上写着熟悉多种数据库结果切换数据库之后只知道改连接串不知道驱动加载、SQL方言、事务行为这些也要跟着变这就不算真懂JDBC。3. 核心接口拆解与实操要点3.1 Connection不只是连上那么简单Connection是JDBC所有操作的发起点也是初学者最容易忽视的一环。很多人觉得它就是DriverManager.getConnection(url, user, password)一行代码的事拿到Connection就赶紧去执行SQL用完也不关。这个习惯后面是要还的。一个Connection对象背后实际上对应着数据库端的一个会话资源。连接建立要经过网络握手、身份认证、权限校验这个过程少说几十毫秒业务量一大频繁创建连接的代价就很夸张。更麻烦的是如果连接没被正确关闭数据库端的会话会一直挂着连接数被耗尽的时候整个应用就卡死了。后面讲连接泄漏排查的时候我会详细说。关于获取连接其实有两个方式。最传统的是DriverManagerClass.forName(com.mysql.cj.jdbc.Driver); Connection conn DriverManager.getConnection( jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/Shanghai, root, password);这里有个细节Class.forName那行现在其实可以不写了因为JDBC 4.0之后引入了SPI机制驱动JAR包里的META-INF/services目录会自动注册驱动。但我还是建议写上一方面是兼容老环境的习惯另一方面是出了问题能明确看到驱动加载这一步骤。另一种获取连接的方式是DataSource这是生产环境的标准做法。DataSource背后可以接连接池提供连接复用、超时控制、统计监控这些能力。这个留到第六部分详细展开。3.2 Statement和PreparedStatement为什么必须用后者拿到Connection之后接下来是创建Statement对象。新手阶段大家都会先写Statement因为它最直观Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(SELECT * FROM user WHERE id id);这种方式最大的问题是SQL注入。你直接把用户输入拼进SQL字符串里如果id是用户传进来的别人传一个1 OR 11你的查询就变成了全表查询。更狠的还能拼接DELETE、DROP语句后果不堪设想。这不是危言耸听现实中因为这个导致的数据泄露事件太多了。PreparedStatement就是来解这个问题的。它用占位符?代替拼接值SQL结构提前发送给数据库预编译参数值单独绑定PreparedStatement ps conn.prepareStatement( SELECT * FROM user WHERE id ?); ps.setInt(1, id); ResultSet rs ps.executeQuery();参数值在绑定过程中被转义处理注入的恶意内容只会被当成普通字符串根本影响不了SQL结构。而且因为SQL提前预编译同一SQL重复执行时数据库端可以复用执行计划性能比Statement好。实测下来批量插入场景下PreparedStatement比Statement能快好几倍。顺带说一句现在很多框架会自动帮我们处理预编译比如MyBatis里写#{}就是走PreparedStatement预编译而${}是直接字符串拼接——这就是为什么大家都叮嘱你用#{}不用${}。知道了底层原理你就不再是背口诀而是真的理解了。3.3 ResultSet游标遍历和结果集元数据查询结果返回后数据放在ResultSet里。ResultSet天然带一个游标的概念初始位置在第一行数据之前next()方法让游标向后移动一行返回false就表示没有更多数据了。这个设计和很多语言的游标模型一样理解了游标就理解了整个遍历过程while (rs.next()) { int id rs.getInt(id); String name rs.getString(name); }这里有个性能点很多人不知道默认情况下JDBC会一次性把查询结果全部拉到客户端内存里数据量小没问题但要是查了几十万行还这么干内存直接爆掉。解决办法是设置游标类型为TYPE_FORWARD_ONLY并给Statement设置fetchSize让服务端分批次返回数据。像这样PreparedStatement ps conn.prepareStatement( SELECT * FROM big_table, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); ps.setFetchSize(1000);实测下来处理百万级数据导出时这个配置能把内存占用从崩溃级别降到平稳运行是处理大数据量查询的必备招式。除了取数据ResultSet还能拿到结果集的元数据列名、列类型、列数都行ResultSetMetaData meta rs.getMetaData(); int columnCount meta.getColumnCount(); for (int i 1; i columnCount; i) { System.out.println(meta.getColumnName(i) - meta.getColumnTypeName(i)); }这个能力一般用在写通用查询工具、动态生成实体映射的时候。很多代码生成器的底层就是靠这个实现的写个工具类扫描表结构自动生成JavaBean核心逻辑就是ResultSetMetaData。4. 手写JDBC工具类全流程4.1 从零实现一个通用DBUtil前面讲的都是接口概念现在进入实战。我们来手写一个工具类把数据库连接、关闭资源这些重复代码收拢起来。第一次写的时候我建议照下面这个思路来虽然现在有各种框架轮子但自己写一遍才能感知到那些框架替我们省了什么事。先定义配置文件db.properties把连接信息放进去避免硬编码jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.password123456然后用静态块加载驱动写一个获取连接的方法public class DBUtil { private static String driver; private static String url; private static String username; private static String password; static { try { InputStream is DBUtil.class.getClassLoader() .getResourceAsStream(db.properties); Properties props new Properties(); props.load(is); driver props.getProperty(jdbc.driver); url props.getProperty(jdbc.url); username props.getProperty(jdbc.username); password props.getProperty(jdbc.password); Class.forName(driver); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (stmt ! null) stmt.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }注意关闭资源的顺序ResultSet - Statement - Connection从里到外。顺序反了或者漏关了轻则内存泄漏重则连接耗尽。4.2 增删改查完整代码演示工具类有了写一个通用的查询方法。这里用到了反射和泛型算是JDBC和Java基础知识的综合运用public static T ListT query(String sql, RowMapperT mapper, Object... params) { ListT list new ArrayList(); Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); ps conn.prepareStatement(sql); for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } rs ps.executeQuery(); while (rs.next()) { list.add(mapper.mapRow(rs)); } } catch (SQLException e) { throw new RuntimeException(查询失败, e); } finally { DBUtil.close(conn, ps, rs); } return list; }这里把PreparedStatement的参数绑定抽成了循环不管SQL里有几个占位符都能处理。查询结果通过RowMapper接口转成对象这是一种思路后面框架里的ResultHandler、RowCallbackHandler都是这个思想。写插入、更新、删除操作时executeUpdate方法返回的是受影响的行数可以用来判断操作是否成功public static int update(String sql, Object... params) { Connection conn null; PreparedStatement ps null; try { conn DBUtil.getConnection(); ps conn.prepareStatement(sql); for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(更新失败, e); } finally { DBUtil.close(conn, ps, null); } }调用方式就很清爽了// 插入 DBUtil.update(INSERT INTO user(name, age) VALUES(?, ?), 张三, 25); // 查询 ListUser users DBUtil.query(SELECT * FROM user WHERE age ?, rs - new User(rs.getInt(id), rs.getString(name), rs.getInt(age)), 18);看到没有了这个封装业务代码里基本看不到JDBC的样板代码了。你可能会说这不就是JdbcTemplate的原理吗对Spring的JdbcTemplate核心思路就是这样只不过它把连接管理、异常转换、LOB处理这些坑都帮我们填了。所以别急着学框架自己先写一遍后面看框架源码就顺了。4.3 事务控制与批处理的实际写法很多新手以为事务就是BEGIN和COMMIT但在JDBC里事务默认是自动提交的也就是说每执行一条SQL就立刻提交。要开启手动事务必须先关闭自动提交Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 // 执行多条SQL updateWithConn(conn, UPDATE account SET balance balance - 100 WHERE id ?, 1); updateWithConn(conn, UPDATE account SET balance balance 100 WHERE id ?, 2); conn.commit(); // 全部成功提交 } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new RuntimeException(转账失败, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException ignored) {} DBUtil.close(conn, null, null); } }这里有个关键点事务必须绑定在同一个Connection上。你要是每条SQL都去getConnection拿新连接那每个连接都是独立事务commit和rollback根本管不到对方。这也是为什么前面封装DBUtil的时候update方法里不能把连接也封装进去——一旦封装进去事务方法就拿不到同一个连接了。所以生产代码里事务管理一定是通过ThreadLocal或者框架的事务同步管理器来保证同一个连接。Spring的Transactional注解本质上就是在方法前后帮你做了setAutoCommit(false)、commit、rollback这套动作。事务这里还有个细节隔离级别。默认的MySQL隔离级别是REPEATABLE_READ一般够用。如果你的业务遇到幻读、不可重复读的问题可能需要调隔离级别但代价是并发性能下降。步骤是conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED)注意要在事务开始前设置才生效。批处理则是批量插入的性能神器。逐条插入一万条数据可能要好几秒但用Batch批量提交基本能压到百毫秒级Connection conn DBUtil.getConnection(); conn.setAutoCommit(false); PreparedStatement ps conn.prepareStatement( INSERT INTO log(content, create_time) VALUES(?, ?)); for (int i 0; i 10000; i) { ps.setString(1, 日志内容 i); ps.setTimestamp(2, new Timestamp(System.currentTimeMillis())); ps.addBatch(); // 每500条执行一次避免一次积压太多 if (i % 500 0) { ps.executeBatch(); } } ps.executeBatch(); conn.commit();为什么快因为批处理把多次网络往返合并成了一次批量发送数据库端也能批量执行。这里的500是一个经验值太小体现不出批量优势太大又可能让数据库端一次处理不过来根据实际情况调整。5. 常见问题与排查技巧实录5.1 驱动加载失败与ClassNotFoundException刚入门时最常遇到的就是java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。这个报错说明JVM在classpath里找不到驱动类排查方向很明确看驱动JAR有没有真的在classpath里。Maven项目就检查pom依赖有没有引入、版本是否有效普通项目就检查lib目录和构建配置。还有一个小概率情况是JAR包损坏或者下载不完整重新导入一次就行。实话说这类问题90%都是新手没把JAR放到对的位置不是驱动本身的问题。5.2 时区报错和连接串参数详解MySQL 8.0的典型报错长这样The server time zone value CST is unrecognized or represents more than one time zone。意思是服务器时区无法识别。解决办法就是连接串里加上serverTimezone参数jdbc:mysql://localhost:3306/test_db?serverTimezoneAsia/Shanghai这个坑太常见了因为MySQL 8.0驱动变得更严格不再默认使用服务器时区。除了serverTimezone连接串里还有几个高频参数值得记住useSSLfalse本地开发建议关掉SSL避免握手开销生产环境按需开characterEncodingutf8保证中文不乱码跟数据库表的字符集要匹配rewriteBatchedStatementstrue批处理优化参数开启后批量插入性能提升明显allowPublicKeyRetrievaltrueMySQL 8.0用户密码认证时可能用得到建议把这几个参数组合起来直接用jdbc:mysql://localhost:3306/test_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8rewriteBatchedStatementstrue5.3 连接泄漏与连接耗尽问题这是生产环境最容易踩的大坑。现象是系统运行一段时间后突然所有请求都卡住日志里报Too many connections或者Connection is not available。原因大多是代码里获取了Connection但没关闭数据库端连接数被占满。排查思路分三步走先看数据库端的连接数状态MySQL可以用SHOW PROCESSLIST查看当前连接数一数哪些连接处于Sleep状态占比很高的话基本就是应用端泄漏了。再查代码里所有getConnection的调用看有没有漏掉close。特别要注意的是如果中途抛异常了后面的close会不会被跳过。所以正确姿势必须是try-catch-finally或者用try-with-resourcesJava 7之后推荐直接用try-with-resourcestry (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ResultSet rs ps.executeQuery(); } catch (SQLException e) { log.error(执行失败, e); }try-with-resources会在代码块结束时自动调用close异常路径也能正确关闭比手写finally稳妥得多。最后如果用了连接池检查连接池参数。最大连接数设得太小或空闲回收时间设得太长都可能加剧问题。这块我在下一部分讲连接池的时候一起展开。5.4 SQL语法错误的排查思路还有一个高频报错是SQLSyntaxErrorException。新手遇到这个建议先别急着怀疑代码把完整的SQL打印出来看一眼。用PreparedStatement的情况下别用toString直接打印因为占位符不会替换成实际值最好在catch里用e.getMessage()看数据库返回的详细错误位置和错误码。常见的坑包括SQL关键字写错、表名和列名跟数据库不一致注意大小写和反引号、字符串值忘了加引号、占位符数量跟setter数量对不上。还有一种隐蔽情况是数据库保留字段没有转义比如表里有个字段叫desc这在SQL里是保留字必须用反引号包起来。6. 从JDBC到连接池生产环境必走的一步6.1 为什么一定要引入连接池直接用DriverManager获取连接的写法学习阶段没问题但到生产环境就完全不行了。原因前面提过创建连接太贵了。每次请求都新建连接、用完后销毁数据库端要不断创建和销毁会话应用端要承受网络握手和认证的开销高并发下简直是一场灾难。连接池的解决思路很朴素提前创建一批连接放着用的时候借一个用完了还回去没有就等多出来的请求。这跟图书馆借书一个道理书都在库房里借了就拿走还了就放回去不会每次借书都重新印刷一本。一个合格的连接池还要管这些事连接数上下限控制、空闲连接回收、连接有效性检测、获取连接超时、连接泄漏检测。这些细节如果自己实现工作量不小而且很难做到健壮。所以生产环境直接用成熟的开源连接池组件才是理性的选择。6.2 连接池的关键参数怎么设连接池用起来不难难的是参数调优。我见过不少项目连接池参数随便填上线后系统一出问题就全赖在数据库头上其实根子在连接池配置上。几个核心参数解释一下initialSize启动时创建的初始连接数。别设太大应用启动阶段用不了那么多浪费资源。一般5到10就够了。maxActive/maximumPoolSize最大连接数。这个要结合数据库的max_connections来设应用的总连接数一定不能超过数据库上限否则直接把数据库打挂。minIdle/minimumIdle最小空闲连接数。低于这个数时会补充新连接。设太大流量低谷期会有一堆空闲连接占资源。maxWait/connectionTimeout获取连接的超时时间。设太短偶发高并发下拿到不到连接就报错设太长请求会堆积。一般建议3到5秒。validationQuery连接有效性检测SQL。每次借出连接时执行一下防止拿到已断开的死连接。不过现在有些组件不强制配这个了而是通过更高效的机制检测。给一个参考配置思路数据库连接上限是200应用部署两个实例每个实例maxActive就设80留出余量给运维和后台工具。这80不是越高越好因为连接多了每个连接背后还有一个数据库会话数据库端压力也会上去。6.3 连接池之外的框架演进连接池解决了连接的复用问题但JDBC的样板代码还在。于是后面的框架就开始一代代进化先有Spring的JdbcTemplate把资源管理和异常转换封装掉然后MyBatis这类半ORM框架把SQL和Java对象的映射也做了再到Hibernate这类全ORM框架连SQL都不用手写了。但不管是哪一代底层都没有跳出JDBC的脉络。这里想给读者一个建议学框架之前一定先把JDBC本身搞透。我在实际带项目的时候深有体会的是只懂框架的人遇到一个问题会非常被动——MyBatis里报一个BindingException他只能去搜MyBatis绑定异常搜半天也未必对症。而懂JDBC的人能顺着参数绑定、Mapper代理、SQL解析这条链路去定位很快就能找到是XML的namespace写错了还是参数类型对不上。这就是地基的作用。再比如线上突然出现慢SQL框架使用者只知道在日志里看SQL而懂JDBC的人会直接想到执行计划、预编译、fetchSize、批处理这些优化手段能从根上解决问题。7. 一些实操心得和补充建议写到这里核心内容基本都覆盖了。最后再分享几个我在实际使用中的体会。第一连接串参数一定要从第一天就写规范。很多开发者本地跑通就不管了等部署到服务器环境时区、SSL、编码各种问题连环炸。与其到时候抓瞎不如一开始就把连接串写全写规范。第二手动封装工具类这种练习值得做但别沉迷。自己写一遍是为了理解原理真到项目里直接用连接池组件加JdbcTemplate效率和健壮性都更好。那种话说得对自己造过轮子才懂得珍惜别人的轮子。第三SQL日志一定要留。每一个数据库操作的正确打开方式都是先看SQL再找代码。你连执行的是什么SQL都不知道后面的排查都是瞎掰。第四事务的粒度宁小勿大。事务能保证一致性但也会锁资源。一次事务里别塞太多无关操作否则锁的持有时间过长并发掉得厉害。我见过最夸张的情况一个事务里做了接口调用加文件上传一个请求跑好几秒直接把数据库连接池占满。JDBC这门技术看起来是老生常谈但每次复习都有新收获。尤其当你从框架再回到JDBC层面去看问题很多原本觉得神秘的东西都会变得清晰起来。希望这篇文章能帮你把这层窗户纸捅破。