
1. 从一段“能用就行”的JDBC代码说起1.1 一个典型的早期写法你大概率也写过先别急着往下看我们先还原一个场景一个刚学完Java基础的小伙接到任务写一个用户查询接口。他很快写出了这样一段代码public class UserDao { public ListUser findAll() { ListUser list new ArrayList(); try { Class.forName(com.mysql.cj.jdbc.Driver); Connection conn DriverManager.getConnection( jdbc:mysql://localhost:3306/test, root, 123456); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(select * from user); while (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setName(rs.getString(name)); list.add(user); } rs.close(); stmt.close(); conn.close(); } catch (Exception e) { e.printStackTrace(); } return list; } }单看这一段好像没什么大毛病该有的步骤都有加载驱动、获取连接、执行SQL、遍历结果集、关闭资源。这也是绝大多数Java学习资料里的标准教学代码。但问题恰恰出在“标准”上。你把这套写法复制到项目里的第二个、第三个方法会发现每写一个查询就要重复一遍连接创建、资源关闭、异常处理的代码。等一个类里塞了七八个方法整个文件就变成了一个大型复读机现场。1.2 这段代码的六个问题面试官一眼就能看穿我平时会拿着这种代码去问一些来面试的候选人大部分人都能说出“代码重复”“没有关闭资源”这类浅层问题但真正能按面向对象的思路把问题拆清楚的不多。这里我按重要性从高到低列一下连接管理和业务逻辑完全纠缠在一起。这个方法是查用户如果再加一个查询订单的方法又是那几行连接代码。连接应该是一个可以被复用的资源而不是每个方法里自己“new”出来的临时工。资源关闭全靠自觉。一旦查询中途抛异常后面的conn.close()根本执行不到连接直接泄漏。靠catch里e.printStackTrace()然后什么都不管是项目上线后连接池被打爆的罪魁祸首。SQL语句和Java代码死死绑在一起。查询条件一变你得改代码重新编译部署。面向对象讲究封装和变化隔离SQL字符串散落在方法里是最原始的做法。ResultSet处理是硬编码。rs.getInt(id)、rs.getString(name)列名和实体类字段之间靠手动赋值。一旦数据库列名重构这里就跟着崩。没有事务和批处理的概念。每个方法默认走一次自动提交资金转账这种需要跨多条SQL保持原子性的操作这种写法完全撑不住。Statement容易被SQL注入。这是老生常谈但越基础的代码越容易犯。规范的做法应该是PreparedStatement参数占位。1.3 面向对象介入的第一个切入点表记录与对象的映射标题写的是“Java面向对象—JDBC”那面向对象到底在JDBC里落点在哪我的理解最核心的就一句话数据库里的每一行记录应该映射成Java里的一个对象。这张映射关系是面向对象思想在JDBC中的第一次真正落地。你的User类字段名和类型要和表的列对应起来ResultSet遍历时要把关系型数据“翻译”成对象属性。这个翻译过程叫 ORMObject Relational Mapping你以后接触的 MyBatis、Hibernate 框架核心原理就是帮你做这件事。但靠框架之前你自己手写过一遍这个翻译过程你才会真正理解什么叫做“数据和对象之间的转换层”。这一步踏扎实了后面看任何ORM源码都会有底。2. 连接管理别死记硬背把配置和连接做成对象2.1 Class.forName 到底干了什么很多教材把Class.forName(com.mysql.cj.jdbc.Driver)讲成固定写法学生就真的背下来了。但面试被问一句“为什么需要这行”就卡壳。这个方法的本质是把驱动类加载进JVM触发类里的静态代码块执行让驱动把自己注册到DriverManager里。在 JDBC 4.0 之前DriverManager不知道世界上有哪些驱动必须显式加载。JDBC 4.0 之后驱动包通过 SPI 机制在META-INF/services/java.sql.Driver文件里声明了驱动类所以新版驱动即使不写Class.forName也能连上。那是不是就不用写了我的建议是老项目里保留着写法新项目也可以写一行没坏处。一方面兼容老驱动另一方面代码语义更明确别人一眼就知道这里用的是哪个驱动。但别在循环里写驱动只需注册一次。2.2 useSSL、sslmode 和时区参数很多人真的被绕晕过看搜索词里有一堆人搜“mysql jdbc usessl 与 sslmode 使用”说明这个坑几乎人人踩过。我解释清楚后面你就不用再搜了。MySQL 的 JDBC 驱动从 5.x 到 8.x 版本SSL 相关的参数改过一次命名useSSL是旧版参数只接收true/false。本地开发连 MySQL 5.7、不带 SSL 证书时如果不写useSSLfalse控制台会刷出大量 SSL 警告。于是很多人图省事直接加?useSSLfalse。sslMode是 Connector/J 8.0.13 之后推荐的参数取值有DISABLED、PREFERRED、REQUIRED、VERIFY_CA、VERIFY_IDENTITY。如果驱动版本较新useSSLfalse在功能上等效于sslModeDISABLED但驱动会提示你使用新参数。我给的通用配置建议是本地开发和测试环境sslModeDISABLED不要用useSSLfalse。生产环境如果你的 MySQL 没开启 SSL就保持DISABLED如果开启了 SSL至少用REQUIRED要求更严格再用VERIFY_CA。还有一个高频的连带问题是时区。MySQL 8.x 驱动默认要求服务器时区不配置会报serverTimezone相关错误。本地一般加jdbc:mysql://localhost:3306/test?sslModeDISABLEDserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8allowPublicKeyRetrievaltrue是因为 MySQL 8 使用 caching_sha2_password 认证时如果连接没走 SSL驱动默认不允许从服务器获取公钥来加密密码传输会报Public Key Retrieval is not allowed。这个参数在本地开发很常见生产环境建议优先配置 SSL 而不是打开这个开关。2.3 自己封装一个连接管理工具与其每个方法里写DriverManager.getConnection不如把连接管理收敛到一个类里。最朴素的版本长这样public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/test?sslModeDISABLEDserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这只是第一步。再往下把配置放进db.properties用Properties加载这样切换环境不用改代码再配合try-with-resources规范释放资源。不过说句实在话你手写一个工具类去复习原理没问题但真实项目别这么做。真正的面向对象解法是引入连接池把“获取连接”这个动作变成一个池化对象的行为而不是每次物理创建。2.4 连接池本质上就是对象复用思想为什么要用连接池因为创建一条物理连接的代价很高包括 TCP 握手、认证、服务端分配资源等。高频请求下如果每条 SQL 都新建连接性能会大打折扣。以 HikariCP 为例配置其实很简单HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/test); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(20); config.setMinimumIdle(5); config.setConnectionTimeout(30000); HikariDataSource dataSource new HikariDataSource(config);之后想拿连接调用dataSource.getConnection()即可。从面向对象的角度看DataSource是一个接口HikariDataSource是它的实现调用方不关心连接是物理新建还是从池里借的这就实现了“面向接口编程而不是面向实现编程”。用连接池还有个好处连接生命周期被池子统一管理借用、归还、回收都有明确规则业务代码只需要关心事务和SQL不用操心底层的创建销毁。3. DAO模式JDBC和业务代码之间的“中间层”3.1 一个接口多种实现这才是面向对象的味道聊到“面向对象 JDBC”最经典的设计就是 DAO 模式。DAO 全称 Data Access Object作用是把数据库访问细节封装在一个独立的层里让上层业务代码不直接面对 JDBC。定义一个接口public interface UserDao { User findById(Integer id); ListUser findAll(); int insert(User user); int update(User user); int deleteById(Integer id); }然后写一个 JDBC 实现类public class UserDaoImpl implements UserDao { Override public User findById(Integer id) { String sql select * from 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()) { return mapRow(rs); } } } catch (SQLException e) { throw new RuntimeException(e); } return null; } }为什么一定要加个接口我举一个很实在的例子你写单测的时候不想真连数据库就可以 Mock 一个UserDao让业务代码跑通将来要把 MySQL 换成 PostgreSQL只要新增一个UserDaoPostgresImpl业务层不用大改。这就是面向对象里的“依赖倒置”——上层依赖抽象不依赖具体实现。3.2 ResultSet 处理别在每个方法里重复造轮子手写 DAO 最头疼的就是ResultSet转对象。如果每个方法里都来一遍user.setId(rs.getInt(id))代码会非常冗余。我倾向于在 DAO 里抽一个mapRow方法集中做映射private User mapRow(ResultSet rs) throws SQLException { User user new User(); user.setId(rs.getInt(id)); user.setName(rs.getString(name)); user.setEmail(rs.getString(email)); user.setCreateTime(rs.getTimestamp(create_time).toLocalDateTime()); return user; }如果一张表有十几二十个字段手动 get/set 确实繁琐。这时候两个思路查询 SQL 里用列别名让结果集列名直接对齐实体字段比如select create_time as createTime from user。写一个通用的BeanProcessor通过反射把 ResultSet 列值填到实体对应属性里。Apache Commons DbUtils 里就有现成的BeanProcessor。我建议新手先手动写mapRow写多了你才能理解 MyBatis 的ResultMap为什么长那样底层都是同一件事。3.3 批处理别把一千条插入写成一千次网络往返另一个典型坑是批量插入。有人写循环for (User user : userList) { ps.setString(1, user.getName()); ps.executeUpdate(); }数据量小倒是无所谓一千条可能耗时几十秒性能核心瓶颈在网络往返。正确姿势是addBatchexecuteBatchString sql insert into user(name, email) values(?, ?); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (User user : userList) { ps.setString(1, user.getName()); ps.setString(2, user.getEmail()); ps.addBatch(); } ps.executeBatch(); }MySQL 还有一个参数rewriteBatchedStatementstrue要拼在 JDBC URL 里jdbc:mysql://localhost:3306/test?rewriteBatchedStatementstrue这个参数开启后驱动会把多条批量 insert 重写成一条多 values 的 insert 语句性能还能再高一截。值得注意批量操作要控制 batch 大小一般每 500~1000 条执行一次executeBatch避免一次性堆积太多。3.4 事务边界到底应该放在哪一层我见过不少新手把事务写在 DAO 方法内部比如insertUser方法开头conn.setAutoCommit(false)最后conn.commit()。表面看没问题但业务层一次操作可能调用多个 DAO 方法比如创建订单时要更新库存、插入订单记录、写日志。如果事务边界被拆到每个 DAO 方法里三个方法就是三个独立事务第二个失败第一个已经提交了根本没法整体回滚。正确做法是事务边界放在 Service 层DAO 方法只关心单条 SQL 的执行。public class OrderService { public void createOrder(Order order, Integer productId) { try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); try { orderDao.insert(conn, order); stockDao.decrease(conn, productId); logDao.insert(conn, create order); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } } catch (SQLException e) { throw new RuntimeException(e); } } }上面这种写法里DAO 方法需要接收Connection参数这样多个 DAO 方法才能共享同一个连接和事务。用 Spring 的同学会觉得很熟悉——Spring 的Transactional本质上就是把这个过程统一封装了。4. 绕过几个高频低级错误4.1 驱动包与版本不匹配的排查链路那个No suitable driver报错能劝退一堆新人。报这个错的原因无非下面几种按出现的概率排序驱动包没引入到 classpath。Maven 项目检查pom.xml里有没有mysql-connector-j依赖普通项目检查是不是把 jar 放进了WEB-INF/lib。驱动类写错了。Connector/J 5.x 的驱动类是com.mysql.jdbc.Driver8.x 变成了com.mysql.cj.jdbc.Driver。如果你 jar 是 8.x 却写了 5.x 的类名同样找不到。根本没加载驱动也没走 SPI 自动注册。这种情况理论上只出现在非常老的环境。我的排查姿势先用java -jar或 IDE 的依赖视图确认 jar 存在再看驱动类是否匹配最后才怀疑代码逻辑。别一上来就改代码六成是依赖问题。4.2 PreparedStatement 占位符到底能不能覆盖所有参数PreparedStatement 的参数占位符?只能替代值不能替代表名、列名这些结构信息。// 可以 select * from user where name ? // 不可以 select * from ? where name ?如果你真的需要根据条件动态拼接表名或列名只能在确保白名单的前提下拼接字符串。但这不是让你把用户输入直接拼进 SQL——那属于送人头。经典注入场景是登录框// 危险写法用字符串拼接用户输入 String sql select * from user where name username and password password ;输入 or 11SQL 就变成了恒真条件直接绕过认证。这就是为什么我一直强调凡是值的位置必须用?这是 JDBC 面向对象之外的另一个安全底线。4.3 连接泄漏finally 到底在保护什么有些代码写成了这样Connection conn null; try { conn dataSource.getConnection(); // 执行SQL } catch (SQLException e) { e.printStackTrace(); } finally { conn.close(); }conn.close()这行对物理连接来说是真关闭对连接池来说是把连接归还池子。如果你的close()因为conn为 null 或者被放在异常路径之外而没执行连接就一直“借出”状态池子很快被打满。我用 Java 7 的 try-with-resources 规范写法更多try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果 } }这样无论正常还是异常资源都会按逆序自动关闭先 rs再 ps最后 conn。这个顺序本身也符合 JDBC 的资源依赖层次。4.4 流式查询读取大数据量时内存爆掉怎么办查几千条没什么感觉但如果一次性查询百万行select * from big_table会把所有结果加载到内存直接 OutOfMemoryError。MySQL 的 JDBC 驱动有两种流式读取方式执行查询前设置stmt.setFetchSize(Integer.MIN_VALUE)让驱动一边读一边返回而不是一次性拉完。这是 MySQL 5.x 的传统做法。在 URL 上配置useCursorFetchtruedefaultFetchSize1000按每次 1000 条分批读取。用流式查询有个注意事项结果集打开期间连接不能再执行其他查询否则会报并发操作相关错误。也就是说流式查询必须占用整个连接直到读完用完立刻关闭这跟前面强调的连接池归还时机是关联的。4.5 常见报错速查表报错信息常见原因处理建议No suitable driver驱动包缺失或驱动类错误检查 classpath 和驱动类全限定名Connection refusedMySQL 没启动或端口不对检查服务状态和 3306 端口确认 IP 地址是否可达Access denied for user用户名或密码错误或用户不允许该主机登录核对账号检查 MySQL 用户 host 权限Unknown database连接的库不存在确认库名先建库再看 URLPublic Key Retrieval is not allowed使用 caching_sha2_password 认证且未走 SSL配置 SSL 或临时加 allowPublicKeyRetrievaltrueCommunications link failure网络中断、连接超时、服务端被 kill检查网络查看数据库超时配置和慢查询日志Software caused connection abort连接被服务端断开检查 wait_timeout 与连接池空闲回收配置这张表是浓缩版经验真遇到问题先对号入座能省下半小时扯头发的时间。5. 面向对象与JDBC的实践边界说点大实话5.1 手写JDBC和用ORM不是二选一的关系很多新人纠结既然有 MyBatis、JPA我为什么还要手写 JDBC我的看法是框架解决的是重复劳动但框架掩盖了底层理解。你早晚会遇到框架解决不了的问题比如特殊的批处理调优、极端性能场景的 SQL 执行计划、连接保活策略这些追根溯源都回到 JDBC 本身。反过来如果项目里全是简单 CRUD手写 JDBC 就是给自己找麻烦。用 MyBatis 的BaseMapper或者 JPA 的Repository开发效率确实高。我见过的一些中型项目纯手写 JDBC DAO 没有问题但前提是你已经把连接池、事务、批处理这些细节打磨到位。技术选型是权衡不是信仰。5.2 分层的命名与组织比用什么框架更重要不管用什么方案包结构一定要清晰com.example.project ├── entity // 数据库表对应的实体类 ├── dao // 数据访问接口 ├── dao.impl // DAO 接口实现 ├── service // 业务逻辑 ├── service.impl ├── util // 工具类如 DbUtil、ResultSet 转换 └── config // 数据源、连接池等配置类实体类命名用User、OrderDAO 命名用UserDao实现类用UserDaoImpl或UserDaoJdbcImpl业务类用UserService。这套命名约定几乎全行业通用照着写别人接手你会感谢你。5.3 我给新人的JDBC学习路线如果你刚开始学可以按下面这个顺序死磕先手写 JDBC 的完整流程加载驱动、建立连接、执行 SQL、遍历结果、关闭资源。至少把每一步背后的原因弄清楚。把连接配置抽到db.properties写一个DbUtil理解配置与代码分离的意义。手写一个表的 DAO 接口和实现类分别完成增删改查再改成PreparedStatement参数化版本。引入连接池对比引入前后的性能和代码变化。给 Service 加事务人为制造一个异常观察回滚是否生效。最后再学 MyBatis 或 JPA你会觉得那些注解和 XML 配置突然变得顺理成章。我自己带新人的时候特别强调一点能写出“能跑”的代码只是起点能讲清楚“为什么这么写”才算过关。面试问的八股文比如“Statement 和 PreparedStatement 的区别”“JDBC 的流程”本质上都在考察你对这层的理解深度。把上面这条路走一遍那些问题基本不用背自然就能答上来。JDBC 这门技术已经陪 Java 走过了二十多年它不够炫酷但它是理解 Java 数据访问体系的基石。你在抽 DAO、封装连接、设计事务边界上做出的每一个面向对象决策都会在后续学习 Spring、MyBatis 时换来成倍的回报。