接手一个老项目,打开代码库第一眼最容易让人崩溃的,永远是数据访问这一层。几百个类、上千行SQL,有的直接用字符串拼,有的用预编译,有的返回List,有的返回Map,还有个别方法把ResultSet直接甩给上层业务代码,让Servlet层自己去遍历游标。这不是编出来的极端案例,见过类似的代码越多,越觉得很多人不是不会写数据库访问,而是从入行开始就没人告诉他一套统一的约束应该长什么样。今天要聊的JDBC数据库访问与DAO设计模式,本质就是解决这个问题的。它能让你把数据库的存取细节封装在固定的一层里,业务代码不用再关心那些Connection、ResultSet、SQLException,也能让一个团队写出来的数据访问代码长成一个样子,可读、可测、可替换。
1. 为什么数据库访问层总成为项目的黑洞
1.1 一段真实到有点尴尬的失控代码
先把最容易踩的坑摆出来。很多老项目里都能看到这类代码,看起来只是几句普通的数据库操作,实际上处处是雷:
Class.forName("com.example.jdbc.Driver"); Connection conn = DriverManager.getConnection( "jdbc:你的厂商标识://127.0.0.1:你的端口/demo", "你的用户名", "你的密码"); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery( "SELECT * FROM t_user WHERE name = '" + name + "'"); while (rs.next()) { // 手工取值、手工拼对象 } conn.close();这段代码第一次跑通的时候很爽,可它隐藏了至少四个问题:第一,用户输入直接拼进SQL,等于把语句结构暴露给了外部输入,一旦有人传入带有攻击性质的片段,整个表的数据都可能被拖走;第二,try-catch里如果只打了异常栈却没有在finally中关闭连接,查询一报错连接就永久泄漏;第三,Statement每次执行都要让数据库重新解析一遍SQL,在高频访问下白白浪费数据库的解析时间;第四,ResultSet被直接暴露给上层以后,业务代码不得不了解JDBC的游标机制,改字段、换数据结构时牵一发而动全身。
1.2 失控的根因不是能力,而是缺少边界
我复盘过很多次这类问题,结论是:写坏代码的人不一定水平差,而是JDBC这套API给了太大的自由。JDBC本身是一个能力非常完整的接口标准,但它把决定权完全交给了调用者。连接要不要复用?字段怎么映射?异常怎么处理?事务边界划在哪里?这些在API层面没有强制约束,也没有统一的默认规范,于是十个人就能写出十种数据访问风格。
业务少的时候风格差异还看不出来,等表多了、方法多了、团队大了,坏味道会随着业务量一起放大。一个查询从Service层拼SQL拼到DAO层,人手一份ResultSet遍历模板,改一个公共字段要在几十个类里搜索替换。这时候你真正缺的不是某个新的数据库框架,而是一层约定的边界。DAO设计模式就是这条边界:它把“怎么存取数据”这件事的所有细节关在一个笼子里,业务层只和笼子外面那张稳定接口打交道。
2. JDBC是接口标准,不是某个具体实现
2.1 驱动JAR才是真正干活的人
很多人把JDBC当成一个具体的库,其实它是Java平台上访问数据库的一组接口标准,定义在java.sql包里。真正实现这些接口的,是数据库厂商提供的驱动JAR。你换一种数据库,只要换掉驱动JAR和连接URL,业务代码里用到的Connection、PreparedStatement、ResultSet这些类名统统不用变。这就是接口标准的威力:上层面对统一抽象,底层负责各自方言。
驱动加载通常通过Class.forName("驱动类全限定名")完成,在新一点的JDBC版本里,驱动JAR通过ServiceLoader机制自动注册,这句有时可以省略。但为了兼容老环境,我还是习惯在连接工具类的静态代码块里显式加载一次,花不了几个字符,能省掉不少诡异的环境问题。
2.2 Connection、Statement、ResultSet各自扮演什么角色
用一个电话类比来理解这三个核心接口会非常直观:
Connection就是一条拨通的电话线路,代表与数据库的一次会话,线路要在通话结束后挂断。Statement是你在电话里说出去的一句话。PreparedStatement更特殊,它像一张预先写好底稿、只留出空档往里面填内容的台词,好处是数据库先解析框架,后面每次只要填参数就行。ResultSet是电话那头一句一句返回的答复。它内部维护着一个游标,初始位置在第一行之前,调用next()才会向下移动,移动到哪一行,那一行的数据才可读,读完之后游标不会自动回到开头。
这三个接口加一个DriverManager,就是JDBC最小可用的全部骨架。理解了它们,你后面看任何持久层框架的源码都不会觉得陌生,因为无论外面包了多少层薄纱,核心仍然是这套东西。
2.3 一次完整查询的底层生命周期
把一次最普通的查询拆开看,它的完整路径是这样的:
// 1. 获取连接,这一步可能来自DriverManager,也可能来自连接池 Connection conn = DBUtil.getConnection(); // 2. 预编译SQL,数据库端先解析结构,?是参数占位符 PreparedStatement ps = conn.prepareStatement( "SELECT id, name, age FROM t_user WHERE name = ?"); // 3. 绑定参数,参数只作为数据传给数据库,不参与SQL结构拼接 ps.setString(1, userName); // 4. 执行查询,返回结果集游标 ResultSet rs = ps.executeQuery(); // 5. 游标逐行移动,每一行通过RowMapper转换成业务对象 while (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); // 这里每换一张表,都要重新写一遍模板 } // 6. 释放资源,顺序是ResultSet -> Statement -> Connection rs.close(); ps.close(); conn.close();你会发现第2步和第3步的组合已经优于Statement拼字符串:SQL结构在数据库端被锁定,参数永远只是参数,这是防注入的根本原因。但更刺眼的还是第5步——每张表、每个查询都要重复写“游标移动、按列名取值、set进对象”的模板代码,而且要小心翼翼保证第6步在异常路径上也能执行到。如果不幸漏了一行close,连接就会被一直占着,数据库的连接数很快被吃光。这也是为什么原生JDBC“能用但不好用”,痛点非常具体。
3. 原生JDBC为什么“能用但不好用”:裂缝在哪
3.1 连接获取是一次昂贵的“重会话”
每次调用DriverManager.getConnection都会让数据库重新完成一次网络握手、身份认证、会话资源分配。想象一下你每说一句话都要重新拨一次电话、等对方接听、寒暄两句才进入正题,这显然不可持续。在高并发场景下,频繁创建连接会让数据库端线程调度压力暴增,更严重的是,如果应用层没有及时关闭连接,连接数会一直涨,直到数据库拒绝新的会话。
解决连接复用问题的手段是连接池,但连接池不是JDBC之外的东西,它只是实现了DataSource接口,对外仍然返回Connection对象。换连接池的时候,上层取连接的写法甚至不用变,DriverManager.getConnection换成dataSource.getConnection而已。DAO层如果从一开始就面对DataSource而不是DriverManager,后面做连接池更换几乎零成本。
3.2 样板代码累积起来就是技术债
说句实话,JDBC里最让人头疼的不是API难懂,而是重复。每个方法都要经历“拿连接、建语句、设参数、执行、映射结果、关资源”这条流水线,而且每个环节还都可能出错。有人试过用try-with-resources简化释放逻辑,但一旦连接来自连接池,close()的语义变成了归还连接而不是真正关闭,很多人在这里会愣一下。
更隐蔽的是异常处理。很多项目的DAO方法里只有空catch,日志里什么都看不到,数据库报错就像被黑洞吞掉。等线上出问题,你连是哪条SQL失败都查不出来。这些样板代码带来的不是单点故障,而是集体性的可维护性恶化。
3.3 SQL和业务逻辑纠缠不清的连锁反应
原生JDBC没有强制数据访问层的位置,于是SQL经常直接出现在Servlet、Service甚至工具类里。最直接的问题是,SQL里的字段名写错、表名写错,编译期完全察觉,只有运行到那一行才知道。数据库改个字段名,你要在整个项目里搜索字符串;一个查询被复用了三次,值被改了一处,另外两处还跑在老逻辑上。
我把这些痛点整理成一张表,方便你对号入座:
| 原生JDBC的痛点 | 典型后果 |
|---|---|
| 连接频繁创建销毁 | 高并发下连接耗尽、性能抖动 |
| SQL散落在业务代码 | 改表结构要全局搜索,漏改一处就出错 |
| 手写结果集映射 | 大量重复模板代码,空值、列缺失难发现 |
| 异常处理随意 | 关键错误被吞掉,线上排查无从下手 |
| 事务边界混乱 | 跨多表操作部分成功、部分失败,数据不一致 |
4. DAO设计模式的核心思想:把存取细节关进笼子
4.1 单一职责与依赖倒置:DAO到底在保护谁
DAO的全称是Data Access Object,数据访问对象。它的核心思想其实就一句话:把数据存取的全部细节关在这一层,其他层不允许碰数据库API。
这样做有两个层面的保护。物理层面,连接获取、SQL执行、结果集映射、资源释放全都在DAO实现类内部完成,业务层看不到任何Connection和ResultSet。逻辑层面,业务层只依赖DAO接口,不依赖具体实现。依赖倒置的好处立刻显现:测试的时候可以替换成内存桩实现,数据库迁移时只改DAO实现类,上层业务一行不动。
4.2 一个标准DAO族的组成与分工
一个规范的DAO族通常由四部分组成:
- 实体类(也叫PO或DO):一张表对应一个Java类,字段与表字段保持映射关系。
- DAO接口:定义这张表所有对外可用的操作,方法签名里不出现SQL和JDBC类型。
- DAO实现类:真正写SQL、操作JDBC的地方,也是全文唯一允许出现SQL片段的位置。
- 连接工具类或数据源:给所有实现类提供统一的连接获取和资源释放入口。
比如用户表的接口可以长这样:
public interface UserDao { User findById(Long id); User findByName(String name); List<User> findByAgeRange(int minAge, int maxAge); Long insert(User user); int update(User user); int delete(Long id); }实现类里要做什么就很明确了:把findById翻译成一条SELECT,把insert翻译成一条INSERT,同时保证用完的资源被正常释放。业务层看到的是“查用户”“新增用户”这样业务化的语义,而不是“打开连接、拼SQL、遍历游标”这样的技术步骤。
可能有人会问:小项目是不是可以不写接口,直接用一个类搞定?我的经验是,如果有测试需求,或者预期这张表的存储方式未来可能变化,接口带来的收益就很大。哪怕目前只有一种实现,“接口 + 实现类”这个结构本身也在提醒你:这条边界是存在的,不能越过它去访问数据库。
5. 手把手搭一套可复用的DAO基础设施
5.1 先用DBUtil把连接的获取和释放统一起来
DAO层最底下的地基是一个连接工具类。它负责两件事:提供连接,以及安全地释放资源。下面是一个简化版的样子:
public final class DBUtil { private static DataSource dataSource; private DBUtil() { } // 实际项目中,这里通过配置文件初始化数据源, // 比如读取配置后创建连接池实例,这里用占位示意 public static void init(DataSource ds) { dataSource = ds; } public static Connection getConnection() throws SQLException { if (dataSource == null) { throw new IllegalStateException("数据源尚未初始化"); } return dataSource.getConnection(); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) { } } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) { } } if (conn != null) { try { conn.close(); } catch (SQLException ignored) { } } } }注意几点。第一,关闭顺序一定是ResultSet先、Statement其次、Connection最后,虽然现代JDBC驱动在关闭Statement时通常会连带关闭ResultSet,但显式关闭能让日志和监控更清楚。第二,当连接来自连接池时,close()不是真正断开,而是把连接归还给连接池复用。第三,异常即使想忽略,也建议打一条trace级日志,完全不理会让连接泄漏问题无法追踪。
5.2 泛型BaseDao:把查询模板和结果映射分开
有了连接工具类,下一步是消灭重复的模板代码。核心思路是用一个泛型基类把“执行查询、遍历游标、关闭资源”这些公共流程抽出来,把“每行怎么转成对象”留给调用方通过回调传入。这个回调就是常说的RowMapper。
先定义一个函数式接口:
@FunctionalInterface public interface RowMapper<T> { T mapRow(ResultSet rs, int rowNum) throws SQLException; }再写一个泛型基类:
public abstract class BaseDao<T> { protected List<T> queryForList(String sql, RowMapper<T> rowMapper, Object... params) throws SQLException { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql); setParameters(ps, params); rs = ps.executeQuery(); List<T> result = new ArrayList<>(); int rowNum = 0; while (rs.next()) { result.add(rowMapper.mapRow(rs, rowNum++)); } return result; } finally { DBUtil.close(rs, ps, conn); } } protected T queryForObject(String sql, RowMapper<T> rowMapper, Object... params) throws SQLException { List<T> list = queryForList(sql, rowMapper, params); if (list == null || list.isEmpty()) { return null; } return list.get(0); } protected int update(String sql, Object... params) throws SQLException { Connection conn = null; PreparedStatement ps = null; try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql); setParameters(ps, params); return ps.executeUpdate(); } finally { DBUtil.close(null, ps, conn); } } protected Long insertReturnKey(String sql, Object... params) throws SQLException { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); setParameters(ps, params); ps.executeUpdate(); rs = ps.getGeneratedKeys(); if (rs.next()) { return rs.getLong(1); } return null; } finally { DBUtil.close(rs, ps, conn); } } private void setParameters(PreparedStatement ps, Object... params) throws SQLException { if (params == null) { return; } for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } } }这个基类一出来,80%的样板代码就消失了。queryForList负责搞定“连接、预编译、绑定参数、遍历游标、关闭资源”,调用方只需要关心两件事:SQL里占位符的顺序,以及RowMapper里一行数据怎么变成对象。insertReturnKey特别注意一下,它通过Statement.RETURN_GENERATED_KEYS让数据库返回自增主键,避免插入之后还要再查一次。
5.3 落地一个真实的UserDao
有了BaseDao之后,写UserDao的实现类会变得非常轻快:
public class UserDaoImpl extends BaseDao<User> implements UserDao { @Override public User findById(Long id) { String sql = "SELECT id, name, age, create_time FROM t_user WHERE id = ?"; return queryForObject(sql, (rs, rowNum) -> toUser(rs), id); } @Override public User findByName(String name) { String sql = "SELECT id, name, age, create_time FROM t_user WHERE name = ?"; return queryForObject(sql, (rs, rowNum) -> toUser(rs), name); } @Override public List<User> findByAgeRange(int minAge, int maxAge) { String sql = "SELECT id, name, age, create_time FROM t_user WHERE age BETWEEN ? AND ?"; return queryForList(sql, (rs, rowNum) -> toUser(rs), minAge, maxAge); } @Override public Long insert(User user) { String sql = "INSERT INTO t_user(name, age, create_time) VALUES (?, ?, ?)"; return insertReturnKey(sql, user.getName(), user.getAge(), user.getCreateTime()); } @Override public int update(User user) { String sql = "UPDATE t_user SET name = ?, age = ? WHERE id = ?"; return update(sql, user.getName(), user.getAge(), user.getId()); } @Override public int delete(Long id) { String sql = "DELETE FROM t_user WHERE id = ?"; return update(sql, id); } private User toUser(ResultSet rs) throws SQLException { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); user.setAge(rs.getInt("age")); user.setCreateTime(rs.getTimestamp("create_time")); return user; } }这段代码读起来顺眼多了。每个方法一眼就能看出在做什么,SQL只有这一层有,参数按顺序填,资源统一由基类释放,RowMapper把数据库行转换成业务对象。业务层调用时只需要拿到UserDao接口,不用知道背后有BaseDao、有DBUtil这些东西。
6. 规模化路上的三座山:连接池、事务边界、批量与分页
6.1 连接池只是换了连接来源,DAO接口纹丝不动
数据库连接池的标准做法是通过DataSource对外提供连接。你只需要在项目启动时初始化一次连接池实例,再把它交给DBUtil.init。连接池的核心参数有这些:
| 参数名称 | 含义 | 备注 |
|---|---|---|
| 初始连接数 | 启动时就创建好的连接数量 | 启动较慢但第一波请求更稳 |
| 最大活动连接数 | 同时最多能分配的连接数量 | 设置太小会排队,太大浪费资源 |
| 最小空闲连接数 | 长期保持的空闲连接数量 | 与最大活动数配合控制回收 |
| 最大等待时间 | 请求连接排队超过此时长则报错 | 单位毫秒,避免请求无限等待 |
| 连接校验查询 | 取出连接前检查是否可用 | 常见写法是执行一条轻量查询 |
一旦连接池接入,DBUtil.getConnection()内部返回的就是池里分配的连接,close()变成归还连接。DAO接口本身完全不动,ServiceImpl也不动,这就是前面强调封装的价值——底层存储来源怎么换,上层用起来没有任何感知。
6.2 事务边界归Service层,连接传递用ThreadLocal
连接池解决的是连接复用,可它管不了事务。一个业务方法往往要调用多个DAO方法,比如转账要“扣A账户”和“加B账户”,这两个操作必须在同一个数据库连接上完成、要么一起成功、要么一起回滚。如果每个DAO方法都自己拿连接、自己提交,就会出现A扣款成功但B加款失败的尴尬局面。
业界比较通用的方案是:事务边界划在Service层,DAO层不做提交和回滚,事务所需的连接通过ThreadLocal绑定在当前线程上。伪代码如下:
public class ConnectionHolder { private static final ThreadLocal<Connection> HOLDER = new ThreadLocal<>(); public static void bind(Connection conn) { HOLDER.set(conn); } public static Connection get() { return HOLDER.get(); } public static void unbind() { HOLDER.remove(); } }Service层的典型写法是:
public void transfer(Long fromId, Long toId, BigDecimal amount) { Connection conn = DBUtil.getConnection(); boolean oldAutoCommit; try { oldAutoCommit = conn.getAutoCommit(); conn.setAutoCommit(false); ConnectionHolder.bind(conn); accountDao.decrease(fromId, amount); accountDao.increase(toId, amount); conn.commit(); } catch (Exception e) { conn.rollback(); throw new RuntimeException("转账失败", e); } finally { conn.setAutoCommit(true); ConnectionHolder.unbind(); conn.close(); } }对应的DAO实现里,取连接时先看ConnectionHolder有没有绑定,有就用绑定的,没有再新取:
Connection conn = ConnectionHolder.get() != null ? ConnectionHolder.get() : DBUtil.getConnection();要点在于,DAO层的update方法绝不自己commit或rollback,提交时机完全由Service层决定。这样事务的粒度才够大,跨多表操作才能保持一致性。
6.3 批量写入与分页查询的注意点
批量插入如果要一条条执行,数据库往返次数多到让人心疼。JDBC提供了一套批量接口,把命令攒到一定数量再一次发送:
String sql = "INSERT INTO t_order(user_id, amount) VALUES (?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (Order order : orderList) { ps.setLong(1, order.getUserId()); ps.setBigDecimal(2, order.getAmount()); ps.addBatch(); if (batchCount % 500 == 0) { ps.executeBatch(); } } ps.executeBatch(); }分页查询则是另一个容易埋坑的地方。最粗暴的写法是SELECT * FROM t_user然后把所有数据load进内存再在Java里分页,数据量一大内存就被打爆。正确做法是让数据库只返回当前页需要的那几行,但不同数据库分页方言不同,正好可以由DAO实现类把这种差异消化掉,对外部接口完全透明。
7. 实战中我踩过的深坑与排查要点
7.1 ResultSet是一条会过期的游标,不是结果集合
新手最容易犯的错误,是把ResultSet当成普通集合返回给上层:
// 错误示范:DAO方法返回ResultSet public ResultSet queryByName(String name) { Statement stmt = conn.createStatement(); return stmt.executeQuery("SELECT * FROM t_user WHERE name = '" + name + "'"); }危险在于,ResultSet默认绑定在Statement上,Statement关闭后游标就失效了。等你把ResultSet交到Service层,连接可能已经关闭,要么报“ResultSet closed”,要么拿到一堆看不出时机的怪异异常。正确做法就是在DAO方法内部完成映射,把数据转换成实体对象或DTO列表,然后返回普通Java集合。一句话:游标不过DAO的边界。
7.2 预编译与参数绑定的真正价值:参数是数据,不是语句结构
SQL注入的本质,是把外部输入参数当成了SQL语句的一部分去解析。很多书本说“用了PreparedStatement就能防注入”,但没有把为什么讲透。关键在两点:一是prepareStatement时数据库已经完成了SQL结构解析,结构被锁定;二是setXxx绑定参数时,参数永远以数据的形式传给数据库,不会重新参与结构拼接。
所以我在DAO层立了一条死规矩:所有SQL一律用?占位符加预编译,任何情况下不允许用字符串拼接参数。哪怕参数看起来非常“安全”,比如一个整数ID,也坚持走占位符。习惯一旦养成,就不会在某个紧急修复时手滑拼出一个漏洞。
7.3 空值、时间类型与自增主键的三个细节
结果集映射里最容易翻车的是空值。getInt("age")在数据库值为NULL时返回0,你以为用户年龄是0,其实人家年龄压根没填。如果业务里需要区分“没填”和“填了0”,就要用包装类型:
// 最好用Integer接收,null和0能区分开 Integer age = rs.getObject("age", Integer.class);时间类型也是个经典坑。数据库的DATETIME、TIMESTAMP映射到Java通常是java.sql.Timestamp,而业务里常用java.util.Date。取值时直接rs.getObject("create_time", Timestamp.class),再把Timestamp转成Date,千万别把Timestamp和Date混着用,Date持有的毫秒数对不上会出各种诡异偏差。
自增主键回填我上面已经写了insertReturnKey,这里补充一句判断逻辑:主键列必须是数据库自增列,且prepareStatement时传了RETURN_GENERATED_KEYS,这样getGeneratedKeys()才有值。有些JDBC驱动对“返回主键”的支持不太一样,实测时如果拿不到,检查一下表和驱动两边的配置。
7.4 排查问题时的习惯:把最终SQL和参数打出来
一个让我少熬好几次夜的调试习惯,是在DAO基类里把SQL主体和参数数组一起打成日志:
if (log.isDebugEnabled()) { log.debug("SQL: {}, params: {}", sql, Arrays.toString(params)); }这样线上搜索日志时,直接按方法名或表名就能定位到某条请求实际执行的SQL是什么、参数是什么,不需要一行行看代码猜。很多人排查慢查询时跑到数据库那边抓SQL,却没有应用侧参数,两边对不上,白白折腾半天。先在DAO层把上下文信息打印全,比什么工具都好使。
8. 吃过亏之后,我给自己定的几条DAO规矩
这几条规矩是我在几个项目里踩坑踩出来的,不复杂,但每一条都对应过真实事故。
- 所有SQL只出现在DAO实现类里,业务层不出现任何SQL片段。
- 每张表对应一个DAO,一个方法只做一件事。不要搞一个万能方法接收一堆开关参数。
- 连接、Statement、ResultSet统一走工具类关闭,不允许在DAO实现类里散装管理资源。
- 参数绑定一律使用
?占位符加预编译,永远不做字符串拼接。 - 事务入口放在Service层,DAO层不主动提交、不主动回滚。
- 结果集必须在DAO方法内部转换成实体或DTO,绝不返回游标对象。
- 关键SQL和执行参数要打日志,线上敢查、查得到,比事后猜强一百倍。
如果你现在的项目正处于“SQL满天飞、改表结构要满项目搜索”的阶段,我的建议是先别急着换重型框架。把DAO这一层梳理清楚,划定边界、统一模板、规范事务和连接管理,成本最低,收益却立竿见影。等以后再接触那些主流的持久层框架,你会发现自己早就理解了它们想解决的核心问题。JDBC这套底层接口没有过时,DAO这种设计思想也没有过时,它们仍然是理解Java数据访问世界的最牢固的地基。