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

JDBC进阶实战:事务、批量写入、DAO分层与异常排查全解析

JDBC进阶实战:事务、批量写入、DAO分层与异常排查全解析 ★ FEATURED ARTICLE
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加载驱动、建立连接、执行静态查询能跑通第一个数据库DemoJDBC02PreparedStatement、参数绑定、结果集映射能写出带条件的查询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拿一个登录场景举例。如果我把用户输入直接拼进SQLString inputName zhangsan OR 11; Statement stmt conn.createStatement(); stmt.executeQuery(select * from t_user where name inputName );这条SQL执行时where条件就变成了namezhangsan OR 11整张表都能被查出来。更危险的输入可以直接在结尾拼接破坏性语句。这正是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上追加rewriteBatchedStatementstrue。加了之后驱动会把多条insert重写成一条多values的语句执行效率提升极其明显。三是分批大小要合适。我习惯500到1000条一批。太大容易把数据库端的临时缓冲区塞满太小又没起到减少往返的作用。如果你用的是别的数据库可能在驱动参数或方言上略有差异但按批次提交的思路一致。四是批量操作必须和事务配合。setAutoCommit(false)后executeBatch不会立即提交最后由commit统一落库。如果开着自动提交每个批次等于单独事务性能优势大打折扣数据一致性也得不到保障。五是batch中某条数据失败时不同数据库行为不一样。有些会中断整个batch有些会标记该条失败。生产环境建议先校验数据再把batch包在事务里失败就整体回滚避免出现半截数据。4.3 一组实测对比数据我拿同一个数据集做过简单对比固定数据量下整理出来的参考结果如下写入方式10000条耗时循环单条插入自动提交13.2秒循环预处理自动提交8.9秒批处理自动提交3.4秒批处理 关闭自动提交 rewriteBatchedStatements0.8秒不同机器配置差距很大但数量级就是如此。不要小看批处理数据量一上来优化效果非常直观。5. 手把手落地DAO分层与分页查询5.1 DAO存在的理由把数据库细节关进小黑屋业务代码里最怕出现什么一堆ResultSet rs stmt.executeQuery(...)直接写在Controller里。这样写业务逻辑和SQL逻辑揉成一团改一张表结构可能要动几十个地方。我的习惯是给每张核心表做一个DAO类。DAO层只负责数据库访问对外提供方法不暴露任何JDBC细节。业务层只依赖DAO接口。一个最简单的用户DAOpublic interface UserDao { User findById(long id); ListUser 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 ListUser 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()) { ListUser list new ArrayList(); while (rs.next()) { list.add(mapRow(rs)); } return list; } } }再写一个分页结果类public class PageResultT { private ListT list; private long total; private int pageNum; private int pageSize; public PageResult(ListT 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等。我一般参考以下配置起步配置项建议初始值说明initialSize5启动时预热连接数maxActive50最大活跃连接数别太大minIdle5最小空闲连接数maxWait3000请求连接的最大等待时间毫秒testOnBorrowtrue借出前校验连接是否有效重点是maxActive不要拍脑袋填个几千再好的数据库也扛不住大量无效连接。连接数上不去优先排查SQL和事务不要先加连接数。6. 高频JDBC异常排查速查与三个踩坑实录6.1 把这些异常对照着查基本能省半天异常或现象典型原因排查方向ClassNotFoundException驱动类在classpath缺失检查依赖确认驱动包存在No suitable driver foundURL写错、驱动未加载检查jdbc url前缀、classpathConnection timed out / Communications link failure网络、端口、数据库空闲回收ping服务、检查数据库wait_timeoutAccess 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的长相但一定要把资源释放和事务边界两条线刻在脑子里。框架再好用也只是在这两条线之上做封装底层的账咱自己得算清楚。
阅读完成 · 觉得有帮助?
咨询建站