接手一个老项目打开代码库第一眼最容易让人崩溃的永远是数据访问这一层。几百个类、上千行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); ListUser 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 RowMapperT { T mapRow(ResultSet rs, int rowNum) throws SQLException; }再写一个泛型基类public abstract class BaseDaoT { protected ListT queryForList(String sql, RowMapperT 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(); ListT 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, RowMapperT rowMapper, Object... params) throws SQLException { ListT 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 BaseDaoUser 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 ListUser 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 ThreadLocalConnection 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数据访问世界的最牢固的地基。
阅读完成 · 觉得有帮助?