JDBC03,这个编号是我整理一套JDBC实战教程时随手起的文件夹名,对应整个学习路径里的第三阶段。前两篇把“怎么连数据库”“怎么执行查询”讲完了,这一篇该聊真实业务里躲不开的内容:事务控制、批量写入、DAO分层,以及那些连官方文档都未必写全的异常排查经验。
常有同学问我,项目里都上MyBatis、Spring Data JPA了,为什么还要花时间啃JDBC。我的回答很直接:框架解决的是80%的常规操作,剩下20%的调优和排错,几乎都要靠JDBC层面那点底层认知。不信你去看线上报“Connection is not available”“Lock wait timeout exceeded”的时候,最后被翻来覆去查的,一定是连接释放、事务边界和SQL参数这些问题。
这篇文章适合两类人:一类是把JDBC入门教程看完、想进阶到真实业务开发的初学者;另一类是平时写框架写惯了、对原生JDBC细节越来越模糊的开发者。文中所有代码都以可实践的方式给出,你可以当成一个作业清单去对照项目里检查。
1. JDBC03要练的核心能力:从“能跑通查询”到“能写业务代码”
1.1 从只会查表到能写业务代码,差的是什么
很多初学者的JDBC学习路径是这样的:配好驱动,写一个静态SQL查询,把结果集打印到控制台,然后觉得JDBC就这么回事。这个过程本身没问题,但它离真实业务开发还有一段距离。真实业务系统里,一个请求往往要同时对多张表做修改,数据要保障一致性,日志和监控要跟上,代码还要能被别人接手维护。
我此前带过一个小型订单系统的开发,系统里有个演示订单流程的模拟项目X。最初的版本把所有数据访问代码都堆在一个工具类里,查询串、条件拼接、连接关闭全写在一起。上线后遇到两次事故:一次是库存扣减成功、订单没写进去;另一次是某个报表查询把几十万行数据直接加载进内存,导致服务重启。说到底,不是SQL写错,而是JDBC的资源管理、事务边界和分层封装没有做好。
1.2 从JDBC01到JDBC03,能力是叠加的
我习惯把JDBC学习路径分成三个阶段,列一张对照表来看很清楚:
| 阶段 | 核心能力 | 典型场景 |
|---|---|---|
| JDBC01 | 加载驱动、建立连接、执行静态查询 | 能跑通第一个数据库Demo |
| JDBC02 | PreparedStatement、参数绑定、结果集映射 | 能写出带条件的查询 |
| JDBC03 | 事务、批量、DAO、连接池、异常排查 | 能支撑真实业务模块 |
JDBC03不是在学新语法,而是在把前两步积累的操作规范化和体系化。事务、批量、DAO看起来是三个知识点,实际上它们是同一个工程问题的三个切面。
1.3 为什么把这三个主题串起来讲
如果只讲事务,你可能会背下来setAutoCommit、commit、rollback这几个API,但一遇到批量插入失败,不知道要不要回滚。如果只讲批量,你可能会写addBatch、executeBatch,但不知道它为什么要和事务配合。如果只讲DAO,你可能会写出一堆接口,但没意识到每个方法里的连接释放才是生死线。
把它们串在一起,是因为真实场景里它们永远相互依赖。拿批量初始化订单数据举例:整个过程需要在一个事务里完成,任何一个批次失败都要整体回滚;这批数据写入后被订单查询模块读走,查询模块又要通过DAO来封装访问。没有哪一步能脱离其他环节独立存在。
2. 连接管理别靠感觉,这几个细节决定线上稳不稳定
2.1 把资源释放写进肌肉记忆
我在团队里见过最经典的翻车现场:有一段老代码每次手动获取连接,执行完查询就扔在那里不关。一开始数据量小,没什么感觉;后来并发一上来,数据库直接报“Too many connections”,连运维工具都登不进去。排查后确认,就是连接没释放。
JDBC里的Connection、Statement、ResultSet都是外部资源。Java的垃圾回收只回收内存对象,不会自动帮你归还数据库连接。常规写法有两种:在finally里关闭,或者用Java 7之后的try-with-resources。我现在几乎只用try-with-resources,因为它代码更短,而且关闭顺序自动按声明逆序执行:
String sql = "select id, name, email from t_user where age > ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, 18); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { User u = new User(); u.setId(rs.getLong("id")); u.setName(rs.getString("name")); u.setEmail(rs.getString("email")); } } }注意几个点:try括号里申请的资源按声明顺序的逆序关闭,也就是ps先关,conn后关,符合“先建后关”的原则;ResultSet在最内层,所以单独再包一层try;不要把getConnection放在try块外面还自己写finally,那个finally里的close会干扰主流程的异常抛出,排查问题时容易把真实错误吞掉。
2.2 早就不需要手动加载驱动类了
很多老资料还在让你写:
Class.forName("com.mysql.cj.jdbc.Driver");实际上JDBC 4.0开始,驱动包只要在classpath里,DriverManager会自动通过SPI机制加载驱动,这行完全可以省略。但如果遇到“No suitable driver found”,先检查三件事:驱动jar到底在不在;JDBC URL是否写成了localhost而不是IP;是不是把MySQL的驱动类名写成了别的数据库的类名。
这些属于“连不上”类问题,后面我会专门整理成一张排查表,验证连接比背一百句理论都管用。
2.3 PreparedStatement不是花架子,它是防注入的地基
有同学问,为什么不直接写Statement,非要每次写PreparedStatement?拿一个登录场景举例。如果我把用户输入直接拼进SQL:
String inputName = "zhangsan' OR '1'='1"; Statement stmt = conn.createStatement(); stmt.executeQuery("select * from t_user where name='" + inputName + "'");这条SQL执行时,where条件就变成了name='zhangsan' OR '1'='1',整张表都能被查出来。更危险的输入可以直接在结尾拼接破坏性语句。这正是SQL注入的原理。
换成PreparedStatement之后:
PreparedStatement ps = conn.prepareStatement("select * from t_user where name=?"); ps.setString(1, inputName);数据库在服务端先把这条SQL解析成固定的执行计划,用户输入只是作为参数值绑定进去,不会改变SQL结构。这就是预编译的意义,也是很多ORM框架生成动态SQL时依然优先使用占位符的原因。
使用PreparedStatement有几个细节容易踩:参数索引从1开始,不是从0开始;不同数据类型要选对set方法,比如金额用setBigDecimal而不是setDouble,否则精度会出问题;别手欠把参数又转成字符串拼进去,那等于把预编译的保护扔掉了。
3. 用转账案例吃透事务控制:commit、rollback、隔离级别
3.1 一个真实的“钱消失”事故
项目群里有人反馈,某次转账,扣款成功了,收款方余额却纹丝不动。查日志发现,是执行完扣款SQL之后,连接断了。由于没有开启事务,默认的autocommit是true,每一条SQL执行完就立刻提交。扣款那条提交了,加款那条没提交,账自然就对不上。
事务要解决的正是这个问题:把多条SQL放进同一个原子操作,要么全部成功,要么全部回滚。
以账户转账为例,代码骨架如下:
Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 String sqlA = "update t_account set balance = balance - ? where user_id = ?"; try (PreparedStatement ps = conn.prepareStatement(sqlA)) { ps.setBigDecimal(1, amount); ps.setLong(2, fromUserId); ps.executeUpdate(); } String sqlB = "update t_account set balance = balance + ? where user_id = ?"; try (PreparedStatement ps = conn.prepareStatement(sqlB)) { ps.setBigDecimal(1, amount); ps.setLong(2, toUserId); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { if (conn != null) { conn.rollback(); } throw e; } finally { if (conn != null) { conn.close(); } }这段代码里,Connection没有放进try-with-resources,是因为它要在多个语句之间保持同一事务状态,而且catch里还要执行rollback。我用finally来关闭连接,保证异常路径上连接也不会泄漏。
3.2 三个容易“假提交”的坑
第一个坑是连接中途关闭再重开。事务状态绑定在连接上,你中途把连接close了,即使后面新建连接继续执行SQL,也不会和之前的操作属于同一个事务。所以事务内的多条SQL必须共享同一个连接。
第二个坑是把远程调用、文件上传、复杂计算塞进事务里。曾有人在事务里调用外部服务接口,依赖方响应一慢,数据库相关行的行锁一直不释放,后续操作排队等待,最后连接池被耗尽。记住一个原则:事务范围越小越好,只包含必要的数据库写操作。
第三个坑是事务里查询也走了新连接。有时候为了省事,业务代码里调用了某个DAO方法,而那个方法内部自己新建了连接,新连接默认自动提交,它看不看得到未提交数据取决于隔离级别,反正会和事务预期不一致,容易整出“明明没提交,别的地方却读到了”的混乱现象。
3.3 事务隔离级别:别只会背四个名词
事务隔离级别解决脏读、不可重复读、幻读三类问题。JDBC里可以这样设置:
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);常见级别对照:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 |
| READ_COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE_READ | 不会 | 不会 | 可能(InnoDB下可能解决) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
不是所有业务都需要最高隔离级别。SERIALIZABLE并发性能极低,一般只用在特定计算场景。绝大多数互联网业务会用READ_COMMITTED或REPEATABLE_READ配合行锁来保证一致性。但要注意,隔离级别只是理论约束,真正落地时还要配合索引和锁,否则行锁退化成表锁,照样会出性能问题。
4. 批量插入10000条数据,从十几秒优化到秒级
4.1 为什么单条插入那么慢
一个系统初始化数据时,需要往订单明细表灌几万条记录。最初有人写了循环单条insert,每条都走独立网络包、独立解析、独立提交,总共跑了十几秒,还会因为网络往返频繁拖慢整体性能。
改用JDBC批处理以后,同样的数据量基本在两秒内完成。核心思路是:把多条SQL攒在内存里,一次性发到数据库,减少网络往返和SQL解析次数。
基础写法:
try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); String sql = "insert into t_order_item(order_id, product_id, num, price) values(?, ?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { int count = 0; for (OrderItem item : items) { ps.setLong(1, item.getOrderId()); ps.setLong(2, item.getProductId()); ps.setInt(3, item.getNum()); ps.setBigDecimal(4, item.getPrice()); ps.addBatch(); count++; if (count % 500 == 0) { ps.executeBatch(); } } ps.executeBatch(); conn.commit(); } }4.2 executeBatch的几个常见误区
一是addBatch只是把参数加入批次,不会执行。很多人写完addBatch就以为数据入库了,结果怎么查都是空,一看忘了最后executeBatch。
二是MySQL驱动默认对batch的优化不彻底,建议在JDBC URL上追加rewriteBatchedStatements=true。加了之后驱动会把多条insert重写成一条多values的语句,执行效率提升极其明显。
三是分批大小要合适。我习惯500到1000条一批。太大容易把数据库端的临时缓冲区塞满,太小又没起到减少往返的作用。如果你用的是别的数据库,可能在驱动参数或方言上略有差异,但按批次提交的思路一致。
四是批量操作必须和事务配合。setAutoCommit(false)后,executeBatch不会立即提交,最后由commit统一落库。如果开着自动提交,每个批次等于单独事务,性能优势大打折扣,数据一致性也得不到保障。
五是batch中某条数据失败时,不同数据库行为不一样。有些会中断整个batch,有些会标记该条失败。生产环境建议先校验数据,再把batch包在事务里,失败就整体回滚,避免出现半截数据。
4.3 一组实测对比数据
我拿同一个数据集做过简单对比,固定数据量下整理出来的参考结果如下:
| 写入方式 | 10000条耗时 |
|---|---|
| 循环单条插入,自动提交 | 13.2秒 |
| 循环预处理,自动提交 | 8.9秒 |
| 批处理,自动提交 | 3.4秒 |
| 批处理 + 关闭自动提交 + rewriteBatchedStatements | 0.8秒 |
不同机器配置差距很大,但数量级就是如此。不要小看批处理,数据量一上来,优化效果非常直观。
5. 手把手落地DAO分层与分页查询
5.1 DAO存在的理由:把数据库细节关进小黑屋
业务代码里最怕出现什么?一堆ResultSet rs = stmt.executeQuery(...)直接写在Controller里。这样写,业务逻辑和SQL逻辑揉成一团,改一张表结构可能要动几十个地方。
我的习惯是给每张核心表做一个DAO类。DAO层只负责数据库访问,对外提供方法,不暴露任何JDBC细节。业务层只依赖DAO接口。
一个最简单的用户DAO:
public interface UserDao { User findById(long id); List<User> findByPage(int offset, int limit); long countAll(); } public class UserDaoImpl implements UserDao { @Override public User findById(long id) { String sql = "select id, name, email from t_user where id = ?"; try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } } } catch (SQLException e) { throw new DataAccessException("查询用户失败, id=" + id, e); } return null; } private User mapRow(ResultSet rs) throws SQLException { User u = new User(); u.setId(rs.getLong("id")); u.setName(rs.getString("name")); u.setEmail(rs.getString("email")); return u; } }DAO类里有个隐藏价值:它可以把SQLException封装成自定义的运行时异常。上层业务不用每个方法都写throws SQLException,也便于统一记录日志和打印上下文参数。
5.2 分页查询不要一次性把全表塞进内存
有次项目里的报表接口,直接把整张订单表select出来再在内存里过滤,数据量一上来就OutOfMemoryError。正确的分页思路是先统计总数,再有限查询数据。
SQL大致长这样:
select id, name, email from t_user order by id limit ? offset ?limit是显示条数,offset是偏移量,计算公式是(pageNum - 1) * pageSize。
在DAO方法里:
public List<User> findByPage(int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; String sql = "select id, name, email from t_user order by id limit ? offset ?"; try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, pageSize); ps.setInt(2, offset); try (ResultSet rs = ps.executeQuery()) { List<User> list = new ArrayList<>(); while (rs.next()) { list.add(mapRow(rs)); } return list; } } }再写一个分页结果类:
public class PageResult<T> { private List<T> list; private long total; private int pageNum; private int pageSize; public PageResult(List<T> list, long total, int pageNum, int pageSize) { this.list = list; this.total = total; this.pageNum = pageNum; this.pageSize = pageSize; } // 省略 getter/setter }调用时先count,再查列表,最后拼成PageResult返回给前端。虽然代码多一些,但这就是第一版手写分页,后面接上手写分页插件或MyBatis的分页拦截器时,原理是一样的。
5.3 为什么小项目也要用连接池
有人觉得写个工具类每次DriverManager.getConnection就够了,没必要用池子。但数据库连接的创建和销毁很费时,包含TCP握手、认证、会话初始化;高并发下这种开销极其可观。所以生产项目基本都用连接池管理连接。
常见连接池有Druid、HikariCP、dbcp等。我一般参考以下配置起步:
| 配置项 | 建议初始值 | 说明 |
|---|---|---|
| initialSize | 5 | 启动时预热连接数 |
| maxActive | 50 | 最大活跃连接数,别太大 |
| minIdle | 5 | 最小空闲连接数 |
| maxWait | 3000 | 请求连接的最大等待时间(毫秒) |
| testOnBorrow | true | 借出前校验连接是否有效 |
重点是maxActive不要拍脑袋填个几千,再好的数据库也扛不住大量无效连接。连接数上不去,优先排查SQL和事务,不要先加连接数。
6. 高频JDBC异常排查速查与三个踩坑实录
6.1 把这些异常对照着查,基本能省半天
| 异常或现象 | 典型原因 | 排查方向 |
|---|---|---|
| ClassNotFoundException | 驱动类在classpath缺失 | 检查依赖,确认驱动包存在 |
| No suitable driver found | URL写错、驱动未加载 | 检查jdbc url前缀、classpath |
| Connection timed out / Communications link failure | 网络、端口、数据库空闲回收 | ping服务、检查数据库wait_timeout |
| Access denied for user | 用户名密码错误或权限不足 | 核对账号授权 |
| Lock wait timeout exceeded | 并发事务锁等待超时 | 查长事务,缩短锁持有时间 |
| OutOfMemoryError: Java heap space | 全表查询塞进内存 | 加分页,限制返回条数 |
| Statement cancelled due to timeout | 查询超时被数据库或驱动取消 | 优化SQL,加索引 |
有个常见场景:数据库设置wait_timeout较短,空闲连接被服务端回收,但连接池里存的还是旧连接。这时低峰期之后第一次请求,经常报Communications link failure。解决办法是配置连接池的校验或空闲回收策略。
6.2 我在真实项目里踩过的三个坑
第一个坑是连接池配了超大maxActive。系统并发一高,连接池疯狂创建连接,最后数据库连接数满,只能重启数据库。后来把maxActive压到合理值,再打开慢SQL日志,才发现真正的问题是几张表缺索引导致的全表扫描。
第二个坑是事务范围失控。有个定时任务在事务里逐条更新大量数据,执行到一半还要调用外部服务做规则校验,外部服务响应一慢,整个事务的锁就一直挂着。最后不是优化SQL,而是把外部调用挪到事务外面,事务里只保留必要的数据库写操作。
第三个坑是分页参数顺序写反。limit ? offset ?这个顺序老是和offset、limit混淆,导致第一页正常,第二页开始数据错乱。这类问题不报异常,只出错误数据,比异常还难排查。后来我在DAO层把所有分页参数收敛为一个PageQuery对象,彻底杜绝了传参顺序错误。
6.3 白屏、超时、卡死的排查顺序
遇到页面卡在白屏或接口超时,我的习惯是按下面顺序查:
- 先看应用日志异常栈,重点看
Caused by后面那一截,那才是底层真实原因; - 再看连接池状态,如果大量线程在等待连接,多半是连接没归还或事务没释放;
- 如果日志出现
Lock wait timeout exceeded,就去数据库查当前的锁等待和慢SQL,优先杀掉长时间未提交的事务; - 如果所有SQL都正常,但业务还是卡,很可能在事务里调用了外部接口,检查调用链路上的耗时。
这个顺序能覆盖绝大多数JDBC相关故障。不要一上来就重启应用,那只会把现场破坏掉。
最后再分享一个个人习惯,也算给这篇文章收个尾。每写完一个DAO方法,我都会顺便自查三件事:Connection有没有被try-with-resources管理住;事务边界是不是刚好包住所有应该一致的SQL;参数顺序和类型是不是和SQL占位符一一对应。这三处决定了代码能不能从“本地能跑”变成“线上能扛”。JDBC03这套内容学完之后,你不需要记住每个API的长相,但一定要把资源释放和事务边界两条线刻在脑子里。框架再好用,也只是在这两条线之上做封装,底层的账咱自己得算清楚。