好久没聊 JDBC 了。最近在帮一些刚入行的朋友看代码,发现一个挺普遍的现象:大家看教程、背八股文的时候,DriverManager、Connection、Statement、ResultSet 这些概念都能说得头头是道,但真让他独立写一个数据访问层,写出来的代码要么是"能跑但没法维护",要么是"跑着跑着就出各种诡异问题"。今天这篇,我就以"JDBC 高级编程与 DAO 模式实战"为主线,把从基础 API 到完整 DAO 层的落地过程完整串一遍,重点讲那些教程里不会细说、但实际项目中天天要面对的东西。
这篇文章适合谁看?两类人。一类是刚学完 Java SE、准备往 Web 方向转的初学者,你需要知道 JDBC 不只是六个步骤,而是整个数据访问体系的基石;另一类是写过一些 CRUD、但都是靠 MyBatis 或 Spring Data JPA 封装好的同学,这类朋友往往对底层缺乏感知,一旦遇到批量插入性能问题、连接池满了、事务莫名其妙的失效,就容易抓瞎。看完这篇文章,你能独立手写一套可维护、可扩展的 DAO 层,并且对为什么这样写、不那样写有清晰认知。
1. 从"会写 JDBC"到"会写项目",中间到底缺了什么
1.1 六个步骤背后的真实复杂度
很多人入门 JDBC 时,接触到的就是"六步走":加载驱动、获取连接、创建 Statement、执行 SQL、处理结果集、释放资源。这个概念本身没错,但它把 JDBC 的复杂度严重简化了。
拿"获取连接"来说,教程里通常教你用 DriverManager.getConnection(url, user, password)。这句代码在一个 Demo 里跑没问题,但放到真实项目里,每次请求都走这句话意味着什么?每来一个用户请求,JVM 就要向数据库发起一次 TCP 握手、完成身份认证、分配会话资源,用完了还得释放。学过计算机网络的朋友都知道,TCP 三次握手本身就有开销,加上 MySQL 服务端的连接认证和资源分配,整个过程少说要几十毫秒。你的业务 SQL 可能只跑了 5 毫秒,连接却花了 30 毫秒,性能直接打了对折。
再往深处想:如果项目有 100 个并发请求同时进来,数据库会收到 100 个连接请求。MySQL 默认的最大连接数是 151,虽然可以调大,但数据库服务器的内存和线程资源是有上限的。如果连接获取逻辑没有做任何限流或复用,你的应用在流量高峰期就是把数据库往死里压。这也是为什么真实项目里几乎不会直接使用 DriverManager.getConnection,而是通过数据库连接池(如 HikariCP、Druid)来管理连接。
我见过不少初学者在本地写 JDBC 工具类,用 static 块加载驱动、每次调用都新建连接,跑单线程测试没问题,一放到 Servlet 容器里被多线程调用就频繁报 Too many connections。这个问题的根源不是说 JDBC 不行,而是连接管理策略不对。
1.2 "能跑"的代码和"能上线"的代码,差距在细节
还有一个很大的差距在于异常处理和资源释放。很多入门教程为了简化,在 finally 块里写关闭资源的代码时,往往只是简单地调用 conn.close(),然后用 e.printStackTrace() 处理异常。这在学习阶段没什么问题,但一旦上了生产,这种写法会引发几类非常头疼的问题:
第一,异常信息被吞掉。e.printStackTrace() 只把信息打到控制台,日志系统里捞不到。生产环境出了问题,你连排查的入口都找不到。
第二,资源泄漏。如果 rs.close() 或 stmt.close() 在 finally 里再次遇到异常,后续的连接可能没有正确归还到连接池,积少成多,最终把连接池拖垮。
第三,SQL 注入。用 Statement 直接拼接字符串,这是入门阶段最常见的写法。SELECT * FROM user WHERE name = '" + name + "' 这种代码在练习项目里没有被攻击的后果,但在真实业务中就是致命漏洞。
这些细节层面的东西,恰恰就是 JDBC 高级编程要解决的核心问题。DAO 模式本身不复杂,它不过是一层封装,但封装得好不好,取决于你对 JDBC 底层细节的理解有多深。理解了这些问题,再看 DAO 模式的落地,就会非常顺畅。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么 DAO 模式值得手写一遍,即使你以后会用 MyBatis
2.1 DAO 模式解决的核心痛点:业务逻辑和数据访问的耦合
先谈谈架构层面的东西。DAO,全称 Data Access Object,中文常翻译为"数据访问对象"。它的核心思想特别简单:把对数据库的所有操作封装到一个独立的层里,业务逻辑层不关心 SQL 怎么写、结果集怎么解析,只面向 DAO 接口编程。
为什么要做这一层隔离?举个我在实际项目中遇到的例子。早期公司有一个模块,业务代码里直接写 JDBC。后来数据库要从 MySQL 迁移到 PostgreSQL,本来以为只是改改连接配置,结果发现业务代码里大量使用了 MySQL 特有的 SQL 写法,比如 LIMIT ? 分页、NOW() 函数、反引号包裹表名。因为 SQL 散落在各个业务方法里,迁移的时候只能一个一个找出来改,改完还得重新回归测试,工作量非常大。
如果当时用了 DAO 模式,情况就完全不同。业务层只依赖 DAO 接口,SQL 的差异被封装在 DAO 实现类内部。换数据库的时候,只需要为新的数据库重新写一套 DAO 实现,业务层代码完全不用动。这就是分层架构的价值——把变化的因素隔离在一个可控的范围内。
2.2 接口与实现分离,对单元测试的隐形帮助
除了可维护性,DAO 模式还顺带解决了一个测试痛点。假设你的业务逻辑是"用户注册时需要校验用户名唯一、写入用户表、同时记录一条日志"。如果业务代码直接操作数据库,单元测试就必须依赖一个真实的数据库环境。但凡 CI 环境没有配好数据库,测试就全挂了。
引入 DAO 接口之后,你可以在测试代码里用 Mockito 之类的框架 mock 一个 DAO 接口的实现,指定"当调用 findByUsername('admin') 时返回 null,当调用 insert(user) 时返回 1",这样就能把业务逻辑的测试跟真实数据库彻底解耦。这是只面向接口编程带来的额外好处,很多刚接触 DAO 的人可能一时体会不到,但做过中大型项目之后就会明白这有多重要。
2.3 手写 DAO,但不要把 JDBC 封装过度
这里我想提一个很多新手容易走偏的点:一上来就想着封装一个特别牛的工具类,什么 BaseDao、DbUtil、ReflectUtil 一套组合拳打下来,结果代码写了一堆,别人看不懂,自己过两周也忘了。
我的建议是,手写 DAO 的时候,封装要适度。第一版可以老老实实把 DAO 接口、实现类写完整,每个方法里都写标准的 JDBC 代码。跑通之后,再观察哪些逻辑是可以抽取的,比如"释放资源"、"设置参数"、"处理结果集"这些重复代码,然后渐进式地做一层 BaseDAO 的抽象。不要一开始就上泛型和反射,那样很容易被细节绕晕。
实践顺序很重要:先能用,再谈复用;先写清楚,再谈优雅。DAO 模式的最终形态不是一开始就设计出来的,而是在迭代中自然演化的。
3. DAO 层的标准落地:从实体类到实现类的完整代码走读
3.1 实体类、DAO 接口、DAO 实现:各自职责的精确划分
先给一个最常见也最好用的工程结构,大家可以直接照着裁剪:
code复制com.example.demo
├── entity
│ └── User.java
├── dao
│ ├── UserDao.java
│ └── impl
│ └── UserDaoImpl.java
├── util
│ └── DBUtil.java
└── test
└── UserDaoTest.java
我把这个结构展开讲一讲。
实体类(entity):对应数据库表结构。数据库里每张表对应一个 Java Bean,字段类型和表字段一一对应。比如 t_user 表有 id、username、password、email、create_time 几个字段,User 类里就定义对应的属性。这个类只承载数据,不写任何业务逻辑。
DAO 接口(dao):定义数据访问的契约。比如"按照用户名查用户"、"插入一个新用户"、"更新用户邮箱"、"删除用户"、"分页查询用户列表"。接口只声明方法签名,不写实现。
DAO 实现类(dao.impl):真正写 JDBC 代码的地方。实现类里做的事情,在我这套标准里永远是四件事:拿连接、写 SQL 并设置参数、执行并处理结果集、释放资源。
下面用一个完整的 UserDao 接口和实现来看代码:
java复制public interface UserDao {
User findById(Integer id);
User findByUsername(String username);
int insert(User user);
int updateEmail(Integer id, String email);
int deleteById(Integer id);
List<User> findPage(int offset, int size);
}
3.2 查询方法里,ResultSet 映射是技术的精细活
先看实现类里的查询方法。这里头有两个精细活:PreparedStatement 的参数设置和 ResultSet 到实体对象的映射。
java复制public class UserDaoImpl implements UserDao {
@Override
public User findById(Integer id) {
String sql = "SELECT id, username, password, email, create_time FROM t_user WHERE id = ?";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
User user = new User();
user.setId(rs.getInt("id"));
user.setUsername(rs.getString("username"));
user.setPassword(rs.getString("password"));
user.setEmail(rs.getString("email"));
user.setCreateTime(rs.getTimestamp("create_time"));
return user;
}
}
} catch (SQLException e) {
// 生产环境请使用日志框架记录,例如 log.error("查询用户失败, id={}", id, e);
throw new RuntimeException("查询用户失败", e);
}
return null;
}
}
代码里我用的是 try-with-resources 语法,这是 Java 7 引入的标准写法,它的好处是资源释放由 JVM 自动完成,即使执行过程中抛异常,连接、语句、结果集也会被正确关闭。这比自己手写 finally 再判断非空关闭要优雅可靠得多。
ResultSet 的映射这里要特别强调一下命名规范。如果数据库字段用的是下划线风格(create_time),实体类属性用的是驼峰风格(createTime),那么映射的取值代码就是 rs.getTimestamp("create_time")、user.setCreateTime(...) 这样手动对应的。想在代码里做自动化映射,就需要借助反射或 BeanUtils 工具,但那属于框架层面的事情。手写 DAO 的时候,老老实实一个个字段 set 是最可控、最不容易出错的。
3.3 新增操作里的主键回填:一个不起眼但很实用的小细节
再看插入方法。很多新手写 insert 时只关心执行成功没成功,忽略了主键回填这个细节。其实"插入一条记录后立刻拿到数据库生成的自增主键",在业务里特别常用,比如注册用户后需要把用户 id 拿去生成 JWT token、记录操作日志。
java复制@Override
public int insert(User user) {
String sql = "INSERT INTO t_user (username, password, email, create_time) VALUES (?, ?, ?, NOW())";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
ps.setString(3, user.getEmail());
int rows = ps.executeUpdate();
if (rows > 0) {
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
user.setId(keys.getInt(1));
}
}
}
return rows;
} catch (SQLException e) {
throw new RuntimeException("插入用户失败", e);
}
}
注意 prepareStatement(sql, Statement.RETURN_GENERATED_KEYS) 这个重载方法。如果不传这个参数,执行完 insert 之后调用 getGeneratedKeys() 是拿不到主键的。这也是很多人在实际开发中遇到"插入后没有返回主键"问题的原因——你根本没有告诉 JDBC 你需要返回生成键。
3.4 更新和删除:参数与条件的匹配关系
更新和删除方法相对简单,但有一个高频坑点:参数的顺序。PreparedStatement 的参数下标从 1 开始,必须严格匹配 SQL 中问号出现的顺序。
java复制@Override
public int updateEmail(Integer id, String email) {
String sql = "UPDATE t_user SET email = ? WHERE id = ?";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, email);
ps.setInt(2, id);
return ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("更新用户邮箱失败", e);
}
}
这段代码如果写反了,把 id 当字符串设置、把 email 当整数设置,编译不会报错,但运行时数据库会报类型转换异常,或者更隐蔽地,由于 MySQL 的隐式类型转换,查询结果不符合预期。遇到这种问题,不要怀疑数据库,先检查参数设置顺序。
4. 抛开重复代码:手写一个泛型 BaseDAO 的思路
4.1 泛型 + 反射,抽出公共逻辑
如果你完成了上面 UserDaoImpl 的全套代码,你会明显感觉到每个方法里都有大量结构相同的代码。比如获取连接、释放资源、设置参数这些步骤。这时候就可以考虑做一个 BaseDAO<T> 泛型基类,把"T 的类型信息"和"数据库表名"通过反射拿到,然后用泛型方法处理通用逻辑。
先定义一个简单的 BaseDAO:
java复制public abstract class BaseDAO<T> {
private Class<T> clazz;
@SuppressWarnings("unchecked")
public BaseDAO() {
// 通过反射获取子类泛型参数的实际类型
ParameterizedType type = (ParameterizedType) this.getClass().getGenericSuperclass();
clazz = (Class<T>) type.getActualTypeArguments()[0];
}
protected T getBean(ResultSet rs) throws Exception {
T obj = clazz.getDeclaredConstructor().newInstance();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
// 简单约定:字段名与列名相同,或者通过注解指定列名
String columnName = field.getName();
Object value = rs.getObject(columnName);
field.set(obj, value);
}
return obj;
}
// 通用查询单条记录
protected T queryOne(String sql, Object... params) {
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
ps.setObject(i + 1, params[i]);
}
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return getBean(rs);
}
}
} catch (Exception e) {
throw new RuntimeException("查询失败", e);
}
return null;
}
// 通用增删改
protected int executeUpdate(String sql, Object... params) {
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
ps.setObject(i + 1, params[i]);
}
return ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("更新失败", e);
}
}
}
这里用到了两个 Java 进阶语法点:泛型反射(getGenericSuperclass())和可变参数(Object... params)。泛型反射的作用是让子类在继承 BaseDAO 时,把具体的实体类型信息传给基类;可变参数则让不同的 SQL 可以传不同数量、不同类型的参数。
4.2 BaseDAO 的实际用法与边界
有了上面的 BaseDAO,再写具体的 UserDaoImpl 就清爽很多:
java复制public class UserDaoImpl extends BaseDAO<User> implements UserDao {
@Override
public User findById(Integer id) {
return queryOne("SELECT id, username, password, email, create_time FROM t_user WHERE id = ?", id);
}
@Override
public int updateEmail(Integer id, String email) {
return executeUpdate("UPDATE t_user SET email = ? WHERE id = ?", email, id);
}
@Override
public User findByUsername(String username) {
return queryOne("SELECT id, username, password, email, create_time FROM t_user WHERE username = ?", username);
}
}
但是我必须强调:这个 BaseDAO 是一个教学演示级的实现,不是生产级的万能方案。它的局限在哪?反射对字段名和列名做了严格约定,如果表结构变化或者字段命名不规范,rs.getObject(columnName) 可能拿不到值;rs.getObject 对日期类型、枚举类型、Decimal 精度等特殊类型,映射规则不一定符合业务预期;如果一个实体类里有多对多关系、嵌套对象,这种简单的反射映射也搞不定。
所以在实际团队开发中,泛型 BaseDAO 更适合做"简单单表 CRUD"的收口,复杂查询还是写专门的 DAO 方法。这样既减少重复代码,又不会因为过度抽象导致代码难读。这也是我常说的:设计模式是服务业务的,不要让业务去迁就设计模式。
5. 高级编程不可回避的两座山:批处理和事务
5.1 批量插入的性能差异,一个参数值极大的优化
现在来说说 JDBC 高级编程里躲不开的两个主题。第一个是批处理。
假设业务上需要一次性导入 1 万条用户数据,最简单的写法是用 for 循环逐条执行 insert。我们来分析一下这会发生什么:1 万次 executeUpdate,意味着 1 万次 SQL 语句从 Java 层传到 MySQL 服务端,每次还要等待数据库返回结果。这个过程中网络往返(RTT)开销是巨大的。哪怕 SQL 本身只执行 0.5 毫秒,加上网络传输和协议解析,单条可能就要 2~3 毫秒,1 万条就是 20~30 秒。这还只是单线程的情况。
更优的做法是用 addBatch() + executeBatch():
java复制public void batchInsert(List<User> userList) {
String sql = "INSERT INTO t_user (username, password, email, create_time) VALUES (?, ?, ?, NOW())";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
for (User user : userList) {
ps.setString(1, user.getUsername());
ps.setString(2, user.getPassword());
ps.setString(3, user.getEmail());
ps.addBatch();
// 每 500 条执行一次,避免 PreparedStatement 缓存过多对象
if (userList.size() % 500 == 0) {
ps.executeBatch();
}
}
ps.executeBatch();
} catch (SQLException e) {
throw new RuntimeException("批量插入失败", e);
}
}
这里有两个细节需要特别注意:
第一,MySQL 的 JDBC 驱动默认不会真正执行批处理。需要用连接参数显式开启:
code复制jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true
rewriteBatchedStatements=true 这个参数会让驱动把多条 insert 语句重写成一条多 VALUES 的语句一次性发给数据库,性能提升可能达到十倍以上。没加这个参数,你的 addBatch 其实是一条一条发的,批处理的优势完全发挥不出来。
第二,分批执行。把所有数据一次性执行 executeBatch() 会导致一条 SQL 里有几万条 values,MySQL 可能因为 max_allowed_packet 参数限制直接报错。建议每 500 条或 1000 条执行一次,既控制单次提交的数据量,又避免批量太大占用过多内存。
5.2 事务边界必须放在 Service 层,而不是 DAO 层
第二个主题是事务。
事务的四个特性 ACID 大家应该都背过,但在 JDBC 编程中,事务控制的关键是"边界放在哪里"。很多初学者会想,既然 DAO 里拿到了 Connection,那就在 DAO 方法里开启事务、提交、回滚好了。这个理解是错的。
原因很简单:一个业务操作往往涉及多个 DAO 方法。比如"用户注册"这个业务,需要检查用户名唯一(findByUsername)、插入用户表(insert)、写注册日志(insertLog)。如果每个 DAO 方法都自己开事务,那么前两步成功、第三步失败时,第一步和第二步的数据已经提交了,无法回滚。
正确的事务边界应该放在 Service(业务逻辑)层。Service 层拿到一个唯一的 Connection,用 setAutoCommit(false) 关闭自动提交,然后依次调用多个 DAO 方法,最后统一 commit(),任何一个环节抛异常就 rollback()。
但这里马上就引出一个问题:DAO 实现类里通过 DBUtil.getConnection() 每次拿到的连接是同一个吗?如果是连接池,默认情况下每次调用 getConnection 都可能拿到不同的连接。这就意味着你在 Service 层开启事务的 Connection,和 DAO 方法里用的 Connection 不是同一个,事务自然无法控制多个 DAO 方法。
解决思路有两种:
- 第一种是用
ThreadLocal持有当前线程的 Connection,保证同一个线程内共享同一个连接。这是很多轻量级框架的思路。 - 第二种是在 DAO 方法中支持外部传入 Connection,即
dao.insert(conn, user)这种重载方法。Service 层负责创建连接和管理事务,DAO 只负责执行 SQL。
第二种方法更直白,我刚工作那会儿是这么写的。到后来用 Spring,声明式事务 @Transactional 帮我们把这层自动管理了。但理解原理之后,你看 Spring 事务的传播行为、回滚机制都会更有底气。
5.3 事务失效的经典翻车案例
再说一个我在 Code Review 时经常遇到的翻车案。
有些人知道 Service 层要开启事务,于是写了类似这样的代码:
java复制public void register(User user) {
Connection conn = DBUtil.getConnection();
try {
conn.setAutoCommit(false);
userDao.insert(user);
logDao.insertLog(...);
conn.commit();
} catch (Exception e) {
conn.rollback();
} finally {
conn.setAutoCommit(true);
conn.close();
}
}
这段代码看起来好像没问题,但你仔细想想:DAO 实现类里的方法用的是 DBUtil.getConnection() 自己拿的连接,跟 Service 层的 conn 是同一个吗?
如果是手动管理连接的 JDBC 工具类,用同一个 DataSource 也行,但通常 getConnection 每次都返回新连接(没有连接池的话)。这就会导致:userDao.insert(user) 里的连接是独立的,它默认自动提交,直接写库成功。即使后面 logDao.insertLog 失败,rollback 也只能回滚 Service 层这个连接未提交的事务,而用户数据早就提交进去了。
本质上这个问题的根源就是事务跨了多个连接,回滚无从谈起。正确做法是:共享同一个连接,或者把连接管理交给统一的事务管理器。这也是为什么 Spring 的 DataSourceTransactionManager 会通过 ThreadLocal 把 connection 绑定到当前线程。
手写 DAO 做事务,建议优先用"传连接"的方式,简单直接,不容易出错。不需要盲目模仿框架,先把原理吃透。
6. 连接池、分页查询和 SQL 注入:绕不开的三个实战话题
6.1 连接池不是"可有可无"的优化,而是刚需
前面提到了连接池,这里把它展开。连接池的核心价值是复用数据库连接,避免每次操作都重新建立网络连接。以 HikariCP 为例,使用起来非常简洁:
java复制HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/demo");
config.setUsername("root");
config.setPassword("password");
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
HikariDataSource dataSource = new HikariDataSource(config);
然后在工具类中不再用 DriverManager.getConnection,而是从 dataSource.getConnection() 获取。因为 HikariCP 内部会做一些连接有效性检测和回收,所以这里的 connection 用完 close() 不是物理关闭,而是归还连接池。
这里有几个核心参数值得记一下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
maximumPoolSize |
10~20 | 最大连接数,不是越大越好,取决于数据库并发能力 |
minimumIdle |
5~10 | 空闲时保持的最小连接数 |
connectionTimeout |
30000 | 获取连接的最大等待时间,超过则抛异常 |
maxLifetime |
1800000 | 连接最大存活时间,应小于数据库 wait_timeout |
validationTimeout |
3000 | 连接有效性检测的超时时间 |
新手刚接触连接池时,容易犯一个错:把 maximumPoolSize 调得特别大,以为连接越多越快。实际上,连接数超过数据库 CPU 核数之后,再增加连接反而会因为上下文切换和锁竞争导致性能下降。连接数公式大致可以参考 CPU核心数 * 2 + 磁盘并发数,但最靠谱的方式还是在测试环境中用压测工具(如 JMeter)实测。
6.2 分页查询:看起来简单,其实藏着两类写法差异
分页查询在 DAO 层非常常见,但这里我要讲一下 MySQL 和 PostgreSQL 的差异,因为很多人在换库之后会踩坑。
MySQL 的分页 SQL 是 LIMIT ?, ?,第一问是偏移量,第二问是行数:
java复制public List<User> findPage(int pageNum, int pageSize) {
int offset = (pageNum - 1) * pageSize;
String sql = "SELECT id, username, email, create_time FROM t_user ORDER BY id LIMIT ?, ?";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, offset);
ps.setInt(2, pageSize);
try (ResultSet rs = ps.executeQuery()) {
List<User> list = new ArrayList<>();
while (rs.next()) {
User user = new User();
user.setId(rs.getInt("id"));
// 省略其他字段映射
list.add(user);
}
return list;
}
} catch (SQLException e) {
throw new RuntimeException("分页查询失败", e);
}
}
PostgreSQL 的分页用的是 LIMIT ? OFFSET ?,参数顺序和 MySQL 完全相反。这种细节差异如果没经过 DAO 层隔离,散落在业务代码里,迁移成本会非常高。这也是我前面强调 DAO 模式价值的另一个原因——数据库特性被封装在实现层,迁移时只改实现类即可。
分页查询里还有一个性能注意点:当偏移量特别大时(比如翻到第 10000 页),LIMIT 99000, 10 会让 MySQL 扫描并丢弃前 99000 行,性能极差。这时候更好的方案是"基于游标的分页",即 WHERE id > ? ORDER BY id LIMIT ?。但那个写法在业务上需要保证排序字段是唯一有序的,这里不展开,有兴趣可以自己试一下。
6.3 PreparedStatement 为什么能防 SQL 注入
最后聊一个面试高频点:为什么 PreparedStatement 能防 SQL 注入,而 Statement 不能?
SQL 注入的本质是用户输入的数据被拼接到了 SQL 语句中,被当作 SQL 代码执行。比如用户输入的用户名是 ' OR '1'='1,如果代码是:
java复制String sql = "SELECT * FROM t_user WHERE username = '" + name + "'";
拼出来就变成:
sql复制SELECT * FROM t_user WHERE username = '' OR '1'='1'
这个条件恒成立,于是数据库返回所有用户数据。
而 PreparedStatement 的处理方式是:SQL 语句的骨架在预编译阶段就固定了(PREPARE ... FROM),参数部分问号占位,真正执行的时候参数通过二进制的文本协议传给服务端,服务端不再把参数内容当作 SQL 语法解析,只当作纯数据来处理。这就是为什么 ? 占位符里的任何输入都只是"值",永远不会变成 SQL 关键字或条件片段。
所以,从安全角度讲,所有涉及用户输入的 SQL,一律使用 PreparedStatement。这条原则在手写 DAO 时是铁律,没有例外。
7. 实测验证与常见异常速查表:收藏这一份就够了
7.1 一套完整的 DAO 测试代码,跑通才算真的掌握
代码写完了,一定要实测。我建议你写一个简单的测试类,老老实实连一次数据库,把每个方法都调一遍:
java复制public class UserDaoTest {
public static void main(String[] args) {
UserDao userDao = new UserDaoImpl();
// 1. 插入并验证主键回填
User u = new User();
u.setUsername("admin");
u.setPassword("123456");
u.setEmail("admin@example.com");
int rows = userDao.insert(u);
System.out.println("插入结果: " + rows + ", 回填ID: " + u.getId());
// 2. 查询
User dbUser = userDao.findById(u.getId());
System.out.println("查询结果: " + dbUser.getUsername() + ", " + dbUser.getEmail());
// 3. 更新
userDao.updateEmail(u.getId(), "new-email@example.com");
User updated = userDao.findById(u.getId());
System.out.println("更新后邮箱: " + updated.getEmail());
// 4. 删除
int deleteRows = userDao.deleteById(u.getId());
System.out.println("删除结果: " + deleteRows);
}
}
跑通之后,再试着改一改参数、制造一些异常场景(比如查一个不存在的 id),观察异常信息是否清晰。一个合格的 DAO 实现,应该在异常时向上抛出可读性强的异常信息,而不是让一堆 SQLException 堆栈直接裸奔到控制台。
7.2 常见异常的排查思路
我整理了一份高频异常速查表,都是实际开发中非常常见的问题,可以直接对照排查:
| 异常场景 | 常见原因 | 解决方案 |
|---|---|---|
ClassNotFoundException: com.mysql.cj.jdbc.Driver |
缺少 MySQL 驱动 jar 包 | 在 pom.xml 或 lib 中引入 mysql-connector-j |
Access denied for user |
用户名或密码错误 | 确认数据库账号,注意密码中特殊字符是否被资源文件转义 |
Unknown database |
数据库名称不匹配 | 检查连接 URL 中的库名 |
Too many connections |
连接未释放或连接池太小 | 检查是否用了连接池,确认连接会被正确归还 |
Connection is closed |
在事务提交前把连接关了 | 检查代码执行顺序,确认关闭操作发生在提交之后 |
Data too long for column |
插入的数据超过字段长度限制 | 检查数据库字段类型和业务数据长度 |
Incorrect string value |
字符集不一致 | URL 加 characterEncoding=utf8,建表指定 utf8mb4 |
Parameter index out of range |
PreparedStatement 参数下标越界 | 检查 SQL 中问号和 setXxx 是否一一对应 |
Cannot create PoolableConnectionFactory |
连接池初始化失败 | 检查数据库是否可连通、账号权限是否正确 |
7.3 最后的经验谈:手写 DAO 对我的意义
回头再看这篇的标题,JDBC 高级编程和 DAO 模式并不是两个孤立的知识点。高级编程强调的是对 JDBC 底层机制的深入理解——连接管理、批处理、事务边界、预处理语句、异常处理,这些东西决定了你写出来的代码能不能扛住真实项目的压力。而 DAO 模式则是一种工程组织方式,它让复杂的数据访问逻辑变得有序、可维护、可测试。
我到现在依然觉得,每一个 Java 后端开发者都应该至少手写一遍 DAO 层。不是因为 MyBatis/Spring Data JPA 不好用,而是因为只有亲手经历过连接池配置的坑、批处理性能的差异、事务回滚失灵的困惑,你才能真正理解那些 ORM 框架帮你做了什么,以及在什么情况下框架本身的默认行为并不能满足你的需求。
如果你正准备照着本文的示例敲一遍,我的建议是:先不要复制代码,自己动手写。从一个最简单的 UserDaoImpl 开始,每一个方法都手动编写 PreparedStatement、ResultSet 映射、try-with-resources 和异常转换。写完之后再对照我上面给的代码,看看差异在哪里。这样一轮下来,你对 JDBC 的理解会比看十遍教程都扎实。
希望这篇文章对你有实在的帮助。如果实操中遇到问题,欢迎把报错信息发出来一起讨论。
