看到标题里那个"2",你应该知道这是个延续篇。上一篇我们把手搭在JDBC的门把手上,学会了加载驱动、建立连接、执行一条SQL、把ResultSet里的数据一行行捞出来,最后像个好学生一样把资源一个个close掉。那是入门,是"知其然"。这一篇要做的,是把你从"能跑通Demo"推到"能写出扛得住生产环境折腾的数据库访问代码"。
说白了,真正的工程问题从来不是"怎么查一条数据",而是这些:连接到底该什么时候建、放在哪里;一条SQL用拼接还是预编译,差在哪儿;批量插一万条为什么慢得离谱;事务的边界画在哪里才不会被同事骂;表结构一变,代码里几百个字段映射要不要跟着改;线上突然报"连接不可用""锁等待超时",怎么三分钟内定位。这篇全部会聊到。
适合谁看呢?已经会用JDBC做增删改查、但没系统捋过连接池、事务、批处理、元数据映射这些内容的开发者,是最典型的读者。另一个隐藏受众是那些用MyBatis、Hibernate用了一两年、偶尔想看明白框架到底替自己做了什么的人——不管你用哪个ORM,底层跑的还是JDBC,这一层吃透了,再回去看框架调优和问题排查,你会发现视野完全不一样。
1. 从"能跑"到"扛得住",中间隔着的几道坎
1.1 先对齐一下基础,省得后面看不懂
我默认你已经有这些底子:知道java.sql.Connection、Statement、ResultSet是干嘛的,写过类似"查用户列表""插入一条订单"这种最基本的代码,也知道Class.forName注册驱动是历史遗留做法,现在用DataSource配合驱动SPI机制自动加载。如果这些还不太熟,建议先把上一篇的基础部分消化掉再往下读。
这一篇所有示例都基于一个假设:你已经有一个可用的DataSource。不管是手写的最小实现,还是Spring Boot里配好的HikariCP,代码层面我们只跟DataSource.getConnection()打交道。这也是从入门到实战的第一步转变——从"每次自己new驱动、拼URL"变成"从池子里借连接"。
1.2 真实项目里,基础CRUD之外的五个高频场景
我把日常工作里最常遇到的问题归纳成五类,这一篇的主线就是围绕它们展开的。
第一类是连接管理。很多人知道要连数据库,但不知道连接是个多昂贵的资源。一次TCP握手、数据库端认证、会话初始化,一套流程走下来几十毫秒打底。如果每个请求都现场造一个连接,高并发下数据库直接被你的连接风暴打挂。
第二类是SQL执行方式。用Statement拼字符串写起来是快,但SQL注入和性能隐患都是这么来的。PreparedStatement不只是安全,它背后还牵扯到数据库的预编译和执行计划缓存。
第三类是事务。银行转账那种"扣款和加款必须同时成功或同时失败"的需求谁都会说,可放在代码里,setAutoCommit(false)、commit()、rollback()三个方法的位置稍微放错,结果就是线上数据对不上账。
第四类是结果集处理。一张表二十个字段,你手写二十行rs.getString(),那六十张表呢?这就是为什么需要反射映射和元数据机制。
第五类是性能与排查。慢查询、连接泄漏、锁等待超时,这些报错信息我闭着眼都能背出来几个。处理它们的思路,其实就藏在前面几类问题的原理里。
2. 连接管理:为什么说连接池是数据库访问的命门
2.1 一次连接产生的代价,比你想象的大
很多人以为Connection就是一个网络通道,用完了扔掉换一个不就行了。大错特错。一次完整的连接建立,数据库端要做的工作包括:TCP三次握手、认证与权限校验、分配会话内存、初始化事务上下文、加载用户变量。这一整套下来,本地数据库可能只要几毫秒,但生产环境跨网络、有防火墙策略、数据库节点负载高的时候,一个连接的建立时间能到几百毫秒甚至秒级。
你拿这个时间去算一笔账:假设一个Tomcat线程池里有200个线程,每个请求都新建连接,数据库一秒钟就算只能扛1000次握手,你的接口吞吐量上限就已经被焊死了,CPU和数据库负载还都烧在无意义的连接建立上。打个比方,这就好比一家餐厅每天给每位客人单开一个厨房,做完一道菜就把厨房炸了重建。连接池做的事情很简单:厨房留几个常备的,客人来了直接用。
2.2 手写一个最小连接池,先弄懂原理
虽然生产环境我强烈建议直接上成熟连接池,但手写一个最小版本能让你对连接池的理解彻底落地。核心思路无非三条:池子里预先放一批连接;取的时候从池子里拿,没有就等待或新建;还回来的时候放回池子复用。
public class MiniPool { private final BlockingQueue<Connection> idle = new ArrayBlockingQueue<>(10); private final AtomicInteger total = new AtomicInteger(0); private final String url; private final String user; private final String password; public MiniPool(String url, String user, String password) { this.url = url; this.user = user; this.password = password; // 预热3个连接 for (int i = 0; i < 3; i++) { idle.offer(createConnection()); total.incrementAndGet(); } } public Connection getConnection() throws SQLException { Connection conn = idle.poll(); if (conn != null) { return conn; } // 没闲置连接时,只要还没到上限就新建 while (true) { int now = total.get(); if (now >= 10) { // 池子满了,阻塞等待归还 try { return idle.poll(3, TimeUnit.SECONDS); } catch (InterruptedException e) { throw new SQLException("获取连接超时", e); } } if (total.compareAndSet(now, now + 1)) { return createConnection(); } } } public void returnConnection(Connection conn) { if (conn == null) return; if (!idle.offer(conn)) { // 池子满了,只能关闭 closeQuietly(conn); total.decrementAndGet(); } } }写完之后你马上会发现几个问题。第一,连接如果被数据库服务端掐断,比如MySQL的wait_timeout到了8小时,池子里存的其实是死连接,你没有校验机制。第二,连接被业务代码拿出去后忘了还,池子很快被借空,而且你很难追踪是谁借走的。第三,没有空闲淘汰、没有统计数据、没有健康检查。
这些坑,成熟连接池全都替你想好了。所以我的建议非常明确:手写池子只用来理解原理,生产环境千万别自己造轮子,你踩过的每一个连接泄漏坑,社区早就替你把补丁打好了。
2.3 生产选型与核心参数,照着配就行
目前Java生态里最主流的是HikariCP和Druid。HikariCP胜在极致性能,Spring Boot 2.x之后默认就是它,代码极简、字节码层面做过优化,适合大多数互联网场景。Druid的强项是监控和管理:SQL执行统计、慢查询日志、连接泄漏检测、防火墙配置,这些面板能力比HikariCP丰富得多,很多公司内部平台就是基于Druid的监控数据做的。
核心参数就四个,理解清楚比背文档重要。
maximumPoolSize是池子最大连接数。很多人以为越大越好,其实是错的。连接数越多,数据库端需要维护的会话就越多,上下文切换越频繁。常规经验值是CPU核心数*2 + 有效磁盘数,加上一些业务缓冲。如果你一个接口的DB操作平均耗时20ms,单连接每秒能处理50个请求,那么50个并发请求其实只需要一个连接就够了,连接数远没有你想象的那么需要多。
connectionTimeout是借连接的最大等待时间,默认30秒,我建议生产环境设成3秒左右。接口不应该容忍一个线程傻等30秒拿不到连接,早点失败、早点降级,比拖死整个线程池强。
maxLifetime是连接的最大存活时间,HikariCP默认30分钟。这个参数有个强制性约束:必须小于数据库侧的wait_timeout。否则会出现一个经典故障——池子里的连接其实已经被数据库闲置回收,但池子不知道,下一次借出去用的时候直接报"Connection is closed"或"Communications link failure"。这也是线上最高频的JDBC故障之一。
minimumIdle是池子保持的最小空闲数。流量不均匀的场景建议设小一点,避免闲时也占着数据库连接数;流量平稳的可以直接不设,默认等于maximumPoolSize。
3. PreparedStatement与批处理:安全和性能要一起抓
3.1 预编译到底预编译了什么
先说安全。用Statement拼SQL是这个样子:"SELECT * FROM t_user WHERE name = '" + name + "'"。如果name传进来的是' OR '1'='1,拼出来的SQL就变成了WHERE name = '' OR '1'='1',整张表都被捞出来了。这就是教科书级的SQL注入。
PreparedStatement的占位符机制之所以能防注入,是因为它把参数和SQL结构彻底分离。数据库收到的是SELECT * FROM t_user WHERE name = ?这个模板,参数通过二进制协议单独传过去,哪怕参数里塞满恶意字符,也只能被当作字符串字面量处理,永远不可能变成SQL结构的一部分。
再说性能。数据库拿到带占位符的SQL模板后,会做词法解析、语法校验、生成执行计划,然后把这些结果缓存起来。下次再收到一模一样的模板,直接复用执行计划,只把参数替换进去。这就是"预编译"三个字的真实含义。反观Statement每次拼接出不一样的SQL文本,数据库每次都要重新解析一遍,热点SQL的执行计划缓存完全派不上用场。
值得注意的是,预编译的缓存命中有个前提:SQL文本必须逐字符一致。所以代码里绝不能写成动态拼WHERE条件的做法,比如"条件成立就多拼一段AND xxx = ?,不成立就不拼",这样会导致缓存碎片化。正确做法是固定拼接所有可能的条件,对不需要的条件用1=1这种恒真占位,或者直接用动态SQL构建工具,保证生成的SQL模式是稳定的。
3.2 批量写入的正确姿势
批量插入是另一个性能重灾区。很多人插入一万条数据是这么干的:外面一个for循环,循环里每次executeUpdate()一次。这样做的结果是,每一条记录都完整走一遍"网络发送SQL -> 数据库解析 -> 执行 -> 返回结果",一万条就是一万次网络往返。就算每次5毫秒,也要50秒,这还没算事务提交开销。
正确做法是用addBatch()。
public void batchInsert(List<User> users) { long start = System.currentTimeMillis(); String sql = "INSERT INTO t_user(name, age, created_at) VALUES (?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); int batchSize = 500; for (int i = 0; i < users.size(); i++) { User u = users.get(i); ps.setString(1, u.getName()); ps.setInt(2, u.getAge()); ps.setTimestamp(3, new Timestamp(u.getCreatedAt().getTime())); ps.addBatch(); if ((i + 1) % batchSize == 0) { ps.executeBatch(); ps.clearBatch(); } } // 收尾剩余不足一批的数据 ps.executeBatch(); conn.commit(); } catch (Exception e) { throw new RuntimeException("批量插入失败", e); } }这段代码有几个关键决策需要解释。batchSize取500到1000是比较稳妥的范围,太小了减少网络往返的效果不明显,太大了PreparedStatement的batch缓冲区会累积大量参数数据,内存和驱动端压力都会上来。setAutoCommit(false)是必须的,否则JDBC驱动会在每次executeBatch()时自动提交,批处理性能直接腰斩。
另外如果你用的是MySQL,连接URL上务必加上rewriteBatchedStatements=true这个参数。它的作用是让驱动把多条INSERT INTO t_user VALUES (...),(...),(...)重写成一条多值语句发送给数据库,减少SQL文本解析次数,性能提升是数量级的。我第一次加上这个参数的时候,同样的10万条数据,耗时从十几秒降到了两秒多,属实夸张。
3.3 日期时间类型:JDBC里最容易踩的坑
日期时间处理是新手翻车高发区。核心规则是:往数据库写java.util.Date之前,先转成java.sql.Timestamp;读的时候,用getTimestamp()拿到的java.sql.Timestamp是java.util.Date的子类,可以直接用。
ps.setTimestamp(3, new Timestamp(user.getCreatedAt().getTime())); Timestamp ts = rs.getTimestamp(3); if (ts != null) { user.setCreatedAt(new Date(ts.getTime())); }如果你的驱动和数据库支持JDBC 4.2标准,还能直接用LocalDate、LocalDateTime配合setObject()和getObject(),代码会清爽很多。但要注意,这个能力依赖驱动版本,老版本的MySQL驱动并不支持,升级驱动前先在测试环境验一把。
还有个隐藏比较深的坑:时区。连接URL里的serverTimezone参数如果配错了,比如北京时间写成UTC,你存进去的2024-06-01 12:00:00在数据库里可能就变成了2024-06-01 04:00:00。MySQL 8.0以上驱动默认要求显式指定时区,我的习惯是统一配serverTimezone=Asia/Shanghai并且useLegacyDatetimeCode=false,让驱动和JVM时区保持一致性。
4. 事务边界:隔离级别与Savepoint的实务用法
4.1 事务三件套与经典翻车现场
setAutoCommit(false)、commit()、rollback(),就这三个方法,但线上事务事故有一大半是它们用错了位置。最常见的问题有两个。
第一个是把commit()放在循环里面。每成功处理一条数据就提交一次,性能差不说,一旦中途失败,前面已经提交的数据回不去了,业务上就出现"部分成功"。
第二个是catch块里只打印日志不回滚。代码抛了异常,连接还停留在未提交状态,资源释放时连接池的归还逻辑如果没做回滚清理,下一个人从池子里拿到这条连接,直接复用你未提交的事务状态,数据混乱得一塌糊涂。
标准写法是try-catch-finally三件套配合,而且注意一个细节:同一个连接的所有操作都必须在同一个事务上下文里,也就是说你在Service层开启了事务,就不能在DAO层内部重新getConnection(),否则拿到的是另一个连接,事务根本管不住它。这个问题的工程解法是后面要讲的DAO层设计。
4.2 隔离级别的选择题
JDBC里隔离级别是Connection上的四个常量:TRANSACTION_READ_UNCOMMITTED、TRANSACTION_READ_COMMITTED、TRANSACTION_REPEATABLE_READ、TRANSACTION_SERIALIZABLE,对应数据库理论里的四个级别。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 |
| READ_COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE_READ | 不可能 | 不可能 | 可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
先解释名词。脏读是读到了别人还没提交的数据,对方一回滚,你读到的就是幻觉。不可重复读是在同一事务里执行同样查询,两次结果不一样,因为其他事务在中间提交了修改。幻读是两次查询返回的行数不一样,别人插入了新行。MySQL的InnoDB在REPEATABLE_READ下用间隙锁解决了大部分幻读问题,所以它的行为比理论表里写的更乐观。
工程上怎么选?绝大多数业务系统选READ_COMMITTED就够了,Oracle和SQL Server的默认级别就是它,语义清晰、并发性能好。MySQL默认是REPEATABLE_READ,如果你的项目没有特殊依赖,不建议为了"更安全"随便调成SERIALIZABLE——那是用全表锁换正确性,并发一上来基本没法玩。SERIALIZABLE只适合极少数极强一致性的场景,比如库存精确扣减且并发量极低的内部系统。
设置的代码很简单:conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);。有个细节是,连接池里的连接是复用的,所以这个设置在本次使用完、归还连接之前,最好重置回默认值,否则泄漏给下一个使用者。
4.3 Savepoint:事务里的"局部回滚"
有时候事务内会分好几个步骤,你不希望一个步骤失败就全部推翻重来。比如下单流程:先扣积分,再创建订单,再冻结库存。库存冻结失败了,前面的扣积分和创建订单其实都成功了,全部回滚反而把正确结果也丢了。
Savepoint就是干这个的。
public void createOrder(Connection conn, OrderDo order) throws SQLException { Savepoint sp = null; try { conn.setAutoCommit(false); deductPoints(conn, order.getUserId(), 100); sp = conn.setSavepoint("beforeOrder"); insertOrder(conn, order); freezeStock(conn, order.getSkuId(), order.getQty()); conn.commit(); } catch (Exception e) { if (sp != null) { // 只回滚到保存点,保留扣积分 conn.rollback(sp); conn.commit(); } else { conn.rollback(); } throw e; } finally { conn.setAutoCommit(true); } }注意两个坑。第一,conn.rollback(sp)之后事务并没有结束,你还要主动commit()一次,把保存点之前已执行的操作正式提交。第二,Savepoint在事务提交或整体回滚后会自动失效,所以它只能在当前事务内使用,不能跨事务传递。
4.4 事务的生命周期越短越好
这是我在实际项目里反复强调的观点:一个事务从开启到提交,中间尽量不要夹杂慢操作。最常见的问题是,有人在事务里调外部接口——调一个支付回调HTTP,等三秒钟,数据库连接就攥在你手里三秒钟。高并发下,连接池被这种"占着茅坑不拉屎"的事务耗尽,整个系统全线超时。
我的实践原则是:事务里只放必须保证原子性的数据库操作,查询、远程调用、消息发送、文件处理全部放到事务外。如果业务上确实需要"本地数据更新成功后才发消息",那就先提交事务,再用可靠消息机制保证后续动作,比如本地消息表加定时投递。分布式事务是最后的手段,别一上来就上重型方案,本地事务的边界画对了,90%的问题都能解决。
5. ResultSet与元数据:动态映射背后的原理
5.1 ResultSet三个容易忽略的概念
标准的查询代码大家都知道:rs.next()循环、rs.getXxx()取值。但ResultSet还有几个概念值得认真理解,尤其是走向工程化的路上绕不开。
第一个是ResultSet.TYPE_FORWARD_ONLY和TYPE_SCROLL_INSENSITIVE。前者只能从前向后单向遍历,是绝大多数场景的默认值,也是性能最优的。后者支持rs.previous()、rs.absolute(5)这种随机跳转,但代价是驱动可能要把整个结果集缓存在内存里。没有特殊交互需求,别用滚动类型。
第二个是CONCUR_READ_ONLY和CONCUR_UPDATABLE。后者允许你用rs.updateString()、rs.updateRow()直接在ResultSet上改数据并同步到数据库,听起来很炫,但这种模式对结果集和游标状态有严格限制,实际工程里几乎没人用它,大家都用PreparedStatement执行UPDATE。看到CONCUR_UPDATABLE可以直接绕道。
第三个是游标与内存。默认情况下,MySQL驱动会把所有查询结果拉到JVM内存里,而不是服务端分页返回。大结果集查询一出来,JVM内存直接告急。这个问题的解法是分页,或者把连接URL里的useCursorFetch=true配好并用setFetchSize()控制,但游标模式会占用数据库端会话资源,不是万能的。最实用的还是"查询条件+分页"。
5.2 用反射把一行数据库记录变成Java对象
手写几十张表的字段映射是纯体力活,还极容易在改表结构后漏改代码。工程化的思路是用反射写一个通用行映射器:读ResultSetMetaData拿到列名,用列名去找实体类里同名的字段,把数据库值转换后反射设置进去。
public <T> List<T> mapRows(ResultSet rs, Class<T> clazz) throws Exception { ResultSetMetaData meta = rs.getMetaData(); int columnCount = meta.getColumnCount(); List<String> columnNames = new ArrayList<>(); for (int i = 1; i <= columnCount; i++) { columnNames.add(meta.getColumnLabel(i)); } List<T> result = new ArrayList<>(); while (rs.next()) { T obj = clazz.getDeclaredConstructor().newInstance(); for (int i = 0; i < columnCount; i++) { Object value = rs.getObject(i + 1); if (value == null) { continue; } Field field = findDeclaredField(clazz, columnNames.get(i)); if (field == null) { continue; } field.setAccessible(true); field.set(obj, convertValue(field.getType(), value)); } result.add(obj); } return result; }convertValue里做的事情就是类型适配:数据库返回的可能是BigDecimal、Timestamp、Blob,要分别转成实体类字段声明的类型。这里最容易踩的坑是getObject()返回类型和驱动、数据库都有关系,比如MySQL的TINYINT(1)可能被映射成Boolean,也可能被映射成Integer,不同驱动版本还不一样。所以convertValue里不仅要按目标类型转,还要加日志或兜底判断,否则线上会出现诡异的类型转换异常。
另一个实用技巧是拿下划线转驼峰。数据库列名一般是created_at,Java字段是createdAt,映射器里写一个简单的snakeToCamel()转换,能省掉实体类上一大堆@Column注解。
5.3 元数据:给数据库做"体检"的入口
ResultSetMetaData让你看到查询结果的列信息,DatabaseMetaData则让你看到整个数据库的元信息:有哪些表、每张表的字段、主键、索引、存储过程。这个能力的价值在写工具类的时候才能体现,比如自动生成简单报表的通用查询组件、数据库结构对比工具、ORM框架的映射生成器。
DatabaseMetaData dbMeta = conn.getMetaData(); ResultSet tables = dbMeta.getTables(conn.getCatalog(), null, "%", new String[]{"TABLE"}); while (tables.next()) { String tableName = tables.getString("TABLE_NAME"); System.out.println("表: " + tableName); } ResultSet columns = dbMeta.getColumns(conn.getCatalog(), null, "t_user", "%"); while (columns.next()) { System.out.println(columns.getString("COLUMN_NAME") + " - " + columns.getString("TYPE_NAME")); }这套API在不同数据库厂商的实现上有差异,比如getCatalogs()和getSchemas()的用法在Oracle和MySQL之间就不一致。我的建议是,工具类里封装好一层适配层,别在业务代码里直接散落DatabaseMetaData调用。
6. DAO层设计与资源释放的工程规范
6.1 Service里不该出现裸SQL
先讲一个我见过很多次的坏味道:Service方法里直接dataSource.getConnection(),然后一行行拼SQL,业务逻辑和数据库访问搅在一起。这种代码的典型问题是,换一个数据库方言要改业务代码,加一个缓存要动业务代码,SQL调优还要在业务代码里找。职责边界完全失控。
DAO模式的核心是隔离:Service层只面向接口编程,不知道SQL长什么样,不知道底层是MySQL还是PostgreSQL,不知道连接从哪来。DAO接口定义业务需要的数据库操作,DAO实现类里才出现JDBC代码。
public interface UserDao { User findById(Long id); List<User> findByStatus(Integer status); int insert(User user); int updateBalance(Long id, BigDecimal delta); }public class UserDaoImpl implements UserDao { private final DataSource dataSource; public UserDaoImpl(DataSource dataSource) { this.dataSource = dataSource; } @Override public User findById(Long id) { String sql = "SELECT id, name, balance, created_at FROM t_user WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { User user = new User(); if (rs.next()) { user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setBalance(rs.getBigDecimal("balance")); user.setCreatedAt(rs.getTimestamp("created_at")); return user; } return null; } } catch (SQLException e) { throw new DataAccessException("查询用户失败, id=" + id, e); } } }注意这里每个方法内部通过dataSource.getConnection()拿连接、用完即还,这个"连接即用即还"的模式就是事务边界划在DAO层之外的原因。如果Service层需要多个DAO操作共享一个事务,那就由Service层统一获取连接、设置自动提交关闭,并把连接传给DAO方法。我见过Spring的@Transactional用习惯了就忘了这回事,等切回原生JDBC的时候被事务割裂问题打得措手不及。
6.2 资源关闭的次序和try-with-resources
JDBC里需要关闭的资源有三个:ResultSet、Statement、Connection。关闭顺序有讲究,正确的次序是后开的先关:先关ResultSet,再关Statement,最后关Connection。实际当中,关闭Statement的时候,大多数驱动会连带关闭它创建的ResultSet;关闭Connection的时候,又会关闭相关的Statement和ResultSet。
现代JDBC代码我强烈建议用try-with-resources语法,它保证资源在退出代码块时自动关闭,顺序正好是声明逆序。
try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { // 处理结果 } }有个细节:如果连接是从连接池借的,close()方法并不会真正关闭底层连接,而是把连接归还给池子。这也是为什么连接池方案的资源泄漏风险比DriverManager直连更高——你调用了close()但连接其实还活着,一旦漏了归还,池子里的可用连接就少一个,直到耗尽。排查连接泄漏的实用手段是给连接池打开泄漏检测,比如HikariCP的leakDetectionThreshold=10000,超过10秒没归还的连接会打印告警日志和借用栈快照,这个配置强烈建议在测试环境打开。
6.3 异常翻译:别把SQLException直接抛给业务层
SQLException是个受检异常,而且它的信息是给DBA看的,不是给业务代码看的。让Service层处理SQLException,会导致Service被迫了解JDBC的底层失败模型,耦合度瞬间上来。工程上的惯例是做一个异常翻译层:DAO层捕获SQLException,转成业务语义明确的运行时异常,比如DataAccessException,里面保留sqlState和errorCode两个关键属性,方便排查。
public class DataAccessException extends RuntimeException { private final String sqlState; private final int errorCode; public DataAccessException(String message, SQLException cause) { super(message + " [SQLState=" + cause.getSQLState() + ", ErrorCode=" + cause.getErrorCode() + "]", cause); this.sqlState = cause.getSQLState(); this.errorCode = cause.getErrorCode(); } }翻译异常的时候千万别做一件事:只记日志不抛出,然后把null返回给调用方。这个"吞异常"的写法会让事务管理彻底失效——方法正常返回了,事务框架不知道出错了,该回滚的没有回滚,数据就静默地错了。要么抛出运行时异常,要么返回一个明确的结果对象,二选一,不要留灰色地带。
7. 高频故障排查与性能优化速查
7.1 常见异常对照表
做JDBC时间长了,有些报错你一看就知道怎么回事。我整理了一份高频故障速查表,都是我实际遇到过的类型。
| 异常信息 | 常见原因 | 处理动作 |
|---|---|---|
| Access denied for user | 账号密码错误,或host白名单没加 | 核对账号、授权host范围 |
| No suitable driver found | 驱动jar没引入,或URL前缀不匹配 | 检查驱动依赖、URL协议头 |
| Communications link failure | 网络不通、数据库重启、连接被防火墙断开 | ping通网络,检查连接池maxLifetime |
| Connection is closed | 连接已被服务端回收,池中连接是僵死连接 | 调大连接池校验、缩短maxLifetime |
| Unknown column 'xxx' in 'field list' | SQL列名和表结构不一致,或表结构刚改过 | 对比实体字段和数据库表结构 |
| Lock wait timeout exceeded | 事务持锁时间过长,或死锁 | 查InnoDB锁状态、优化事务长度、检查索引 |
| Data too long for column | 写入的数据超过列定义长度 | 检查字段长度、参数截断逻辑 |
7.2 分页与大结果集的两个实用策略
LIMIT分页是大部分人的第一反应,但数据量一旦上去,越往深翻页越慢。LIMIT 1000000, 20的意思是要先扫描出前1000020行再扔掉前1000000行,前面的扫描全浪费了。
一个实用优化是条件分页,也叫游标分页:翻页时带上上一页最后一条记录的排序字段值。
SELECT id, name, created_at FROM t_user WHERE created_at < ? -- 上一页最后一条的created_at ORDER BY created_at DESC LIMIT 20这样每一页的查询都能直接利用created_at索引定位起点,不会全表扫描。注意排序字段必须唯一,否则翻页边界会重复或漏数据,实际中我用过created_at加id的联合排序来保证唯一性。
另一个大结果集策略是流式读取,适合做数据导出这种全量扫描场景。MySQL下把连接URL加上useCursorFetch=true,PreparedStatement设置setFetchSize(1000),驱动就会从"一次性拉全量"变成"分批从服务端取"。使用前提是查询期间连接必须保持打开,而且不能边读边关,导出任务要把整个读流程放在一个方法里同步完成。
7.3 锁等待与事务超时的定位思路
"Lock wait timeout exceeded"这类问题看起来吓人,定位思路其实很固定。先确认是哪个事务在持锁。MySQL里执行SHOW ENGINE INNODB STATUS,或者查information_schema.INNODB_TRX,能看到当前未提交事务及其持续时间。持锁时间长的那个事务就是元凶。
然后再问一个问题:为什么持锁时间长?答案通常是两个。一是事务里混了慢查询或者外部调用,二是更新操作没有走索引,行锁退化成表锁,锁的粒度变大了。我曾经定位过一个案例,"锁等待超时"的触发条件是某条UPDATE语句的WHERE条件列没有索引,每执行一次都要扫全表,把整个表的上千行全锁住,并发一上来必然互等。给WHERE列加上索引之后,行锁只锁住目标行,问题直接消失。所以排查锁问题的第一步永远不是调超时参数,而是看SQL的索引和事务长度。
8. 从JDBC走向ORM:什么时候该换工具
8.1 ORM框架到底替你做了什么
你花了这么大篇幅学JDBC,其实正是理解ORM最好的底座。MyBatis、Hibernate、Spring Data JPA,不管宣传语多花哨,底层全部是JDBC。框架做的事情无非三件:帮你管理连接,帮你把参数绑定进PreparedStatement,帮你把ResultSet映射成对象。这三件事你刚亲手做过一遍,所以现在看框架你能看明白它每一步在干嘛,也能理解为什么某些写法性能差。
比如MyBatis,它其实是一个半自动的SQL映射框架,SQL还是你写的,框架帮你管参数绑定和结果映射。Hibernate和Spring Data JPA更激进,SQL由框架生成。前者适合SQL复杂的项目,后者适合CRUD为主、追求开发效率的项目。
8.2 中间路线:JdbcTemplate与SQL映射器
如果你不想引入完整ORM,又觉得原生JDBC样板代码太多,Spring的JdbcTemplate是性价比极高的选择。它把"获取连接、创建Statement、遍历ResultSet、关闭资源"全部收进框架,你只写SQL和告诉它怎么映射行。
public User findById(Long id) { return jdbcTemplate.queryForObject( "SELECT id, name, balance, created_at FROM t_user WHERE id = ?", (rs, rowNum) -> { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); return user; }, id); }NamedParameterJdbcTemplate还支持:name这种命名参数,SQL可读性比?占位符好很多,适合条件多的查询。如果你的团队不想为几十条SQL引入完整ORM,JdbcTemplate就是很好的平衡点。它的定位是"JDBC的体验增强",而不是另一套抽象。
8.3 我的选择原则
我的判断标准很朴素。项目以标准CRUD为主、对象模型复杂、团队追求开发速度,优先Spring Data JPA这类全自动框架。项目有大量复杂SQL、报表统计、多表关联,或者对SQL的可控性有执念,选MyBatis这种半自动框架。项目体量小、SQL数量有限、或者处于性能优化攻坚阶段,直接用JdbcTemplate甚至原生JDBC,把每一行SQL都攥在自己手里。
无论选哪个,我的建议都是:不要放弃JDBC这层认知。ORM发生的问题,比如N+1查询、会话缓存不一致、批量插入慢,根源都在JDBC的机制上。我见过太多人调MyBatis批量插入性能调不动,一查发现是连接URL没配rewriteBatchedStatements,这种问题没有JDBC底子,连排查方向都不知道在哪。
最后说点我自己的体会。做了这么久的数据库访问层开发,我越来越觉得JDBC是最值得花时间吃透的基础设施之一。它不性感,没有炫酷的新特性,但它就在每一行数据读写的脚下。把连接、事务、批处理、映射这几件事想明白了,你写出来的代码会自然地更稳——连接不泄漏,事务不越界,SQL不拼接,异常不吞掉。这些习惯不是靠框架约束出来的,是你自己理解了原理之后长出来的。
再分享一个小建议:学这东西,光看文章不够,一定要装一个本地数据库,造个十万行的表,亲手试一遍批量插入、事务回滚、连接池参数调优。实测过"加一个参数快十倍"和"漏一个finally导致连接耗尽",比读十篇博客都管用。等这些手感都有了,你再看那些ORM框架的文档,会发现很多"为什么这么设计"的问题,答案其实都藏在JDBC这一层里。