
JDBC 这套东西刚接触数据库编程的人多半会觉得它又臭又长加载驱动、拿连接、开 Statement、拼 SQL、处理结果集、一层层关资源写一次忘一次。但真到了项目里尤其是需要自己手写数据访问层、做小工具、写批处理脚本、或者维护一些老系统的时候JDBC 反而是最直接、最可控的那条路。它不像 ORM 框架那样把细节藏起来SQL 是你写的连接是你管的事务边界是你划的出了问题你能一路追到底。这篇就围绕 JDBC 连接 MySQL、建表、增删改查、PreparedStatement 防注入以及 SQL 语句返回值类型这几个核心点把我这些年踩过的坑和总结出来的经验一次性讲清楚。适合刚学 JDBC 的新手打基础也适合写了几年 CRUD 但没系统梳理过的朋友查漏补缺。1. 先把 JDBC 这套流程的骨架搭明白1.1 JDBC 到底在替我们干什么很多人一上来就背“加载驱动、获取连接、创建语句、执行、关闭”但没搞明白每一步存在的意义结果换个数据库、换个驱动版本就懵了。JDBC 本质上是 Java 官方定义的一套接口规范它规定了“Java 程序怎么跟数据库对话”的标准动作而各个数据库厂商MySQL、PostgreSQL、Oracle 等负责提供这套接口的具体实现也就是驱动 jar 包。打个比方JDBC 就像电源插座的国家标准规定了插孔的形状和电压MySQL 驱动就是某个电器厂商按这个标准做出来的插头。你写代码时面向的是 JDBC 接口Connection、Statement、ResultSet运行时真正干活的是驱动里的实现类。这就是为什么代码里几乎看不到 MySQL 特有的类全靠java.sql包下的接口在撑场面。理解这一点很关键它意味着你写的 JDBC 代码在理论上可以换数据库只要换驱动和连接串。当然实际中 SQL 方言会有差异但至少接口层面是统一的。1.2 一次完整的数据库操作要经过哪几道手把流程拆开看一次标准的 JDBC 查询大致经历这么几个阶段加载并注册驱动让 JVM 知道用哪个驱动实现。现代驱动通过 SPI 机制可以自动注册但显式加载更稳妥。建立连接通过DriverManager.getConnection拿到Connection对象这一步背后是 TCP 握手、身份认证、会话初始化。创建语句对象Statement或PreparedStatement承载你要执行的 SQL。执行 SQL查询用executeQuery更新用executeUpdate通用用execute。处理结果查询返回ResultSet需要逐行遍历取值。释放资源ResultSet、Statement、Connection按后开先关的顺序关闭。这六步里新手最容易出问题的是第 6 步——资源不关连接池很快被耗尽程序跑一会儿就卡死。我见过太多“本地测试没问题一上压力就崩”的案例根子都在连接没释放。1.3 驱动依赖怎么选别再用老掉牙的写法现在 MySQL 驱动的主流是mysql-connector-jMaven 坐标大致是这样dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.3.0/version /dependency老版本用的是mysql:mysql-connector-java8.0 之后官方换了 groupId很多人复制旧教程的依赖发现拉不下来就是这个问题。版本上建议用 8.x它对时区、SSL、字符集的处理都比 5.x 合理而且默认驱动类名从com.mysql.jdbc.Driver变成了com.mysql.cj.jdbc.Driver。提示如果你还在用Class.forName(com.mysql.jdbc.Driver)在 8.x 驱动下会报类找不到。要么改成com.mysql.cj.jdbc.Driver要么干脆省略这行靠 SPI 自动注册。连接串也有讲究一个我常用的模板jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueserverTimezone不写经常报时区错误useSSLfalse在本地开发省去证书麻烦allowPublicKeyRetrievaltrue是配合新版认证插件用的。生产环境 SSL 该开还得开这里只是本地调试的简化写法。2. 建表这件事代码里做还是脚本里做2.1 用 JDBC 执行 DDL 的正确姿势建表语句属于 DDL数据定义语言它和增删改查的 DML 在执行方式上略有不同。用 JDBC 执行建表走的是Statement.execute或executeUpdate注意 DDL 一般不返回受影响行数executeUpdate会返回 0。String ddl CREATE TABLE IF NOT EXISTS user_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, age INT DEFAULT 0, email VARCHAR(128), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; try (Connection conn DriverManager.getConnection(url, user, pwd); Statement stmt conn.createStatement()) { stmt.execute(ddl); }这里用了 try-with-resourcesConnection和Statement都会自动关闭比手动在 finally 里关优雅得多也不容易漏。IF NOT EXISTS是个好习惯重复执行不会报错适合放在程序启动时的初始化逻辑里。2.2 字符集和存储引擎别等出乱码才后悔建表时最容易忽略的是字符集。utf8mb4和utf8的区别很多人栽过跟头MySQL 里的utf8其实是“阉割版”最多存 3 字节存不了 emoji 和一些生僻字而utf8mb4才是真正的完整 UTF-8。现在新项目一律用utf8mb4别犹豫。存储引擎方面InnoDB支持事务、行级锁、外键是绝对的主流选择。MyISAM虽然读性能在某些场景下略好但不支持事务早就该淘汰了。建表时显式写上ENGINEInnoDB避免依赖数据库默认配置。字段类型的选择也有门道。主键用BIGINT而不是INT因为INT上限约 21 亿用户量大的系统很容易撞顶迁移起来非常痛苦。金额字段用DECIMAL而不是FLOAT/DOUBLE浮点数有精度丢失问题算钱会出岔子。时间字段用DATETIME还是TIMESTAMP前者范围大到 9999 年不受时区影响后者范围小到 2038 年且随时区变化一般业务用DATETIME更省心。2.3 建表逻辑放在哪一层更合理我个人的经验是建表语句不要散落在业务代码里。虽然 JDBC 能执行 DDL但把建表混在业务逻辑中会导致职责混乱而且生产环境通常不允许应用随意改表结构。比较稳妥的做法是用独立的 SQL 脚本管理表结构配合 Flyway、Liquibase 这类数据库版本管理工具或者至少用一个schema.sql文件在初始化时执行。JDBC 执行 DDL 更适合的场景是单元测试——测试前建临时表测试后删掉保证测试环境干净。这种“用完即弃”的场景JDBC 的灵活性就体现出来了。3. 增删改查Statement 与 PreparedStatement 的分水岭3.1 用 Statement 拼 SQL 的隐患先看一段很多人入门时都写过的代码String name 张三; String sql SELECT * FROM user_info WHERE username name ; Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql);这段代码能跑但它埋了两颗雷。第一颗是SQL 注入如果name的值是 OR 11拼出来的 SQL 就变成了WHERE username OR 11条件永远成立整张表的数据全被查出来。第二颗是特殊字符处理如果名字里带单引号比如OBrien拼出来的 SQL 语法直接错乱。SQL 注入不是危言耸听它是 Web 安全里最经典的漏洞之一。只要你的 SQL 里有任何来自用户输入的拼接就有风险。而防御它的最有效手段就是下面要讲的 PreparedStatement。3.2 PreparedStatement 为什么能防注入PreparedStatement 的核心在于SQL 预编译。它的工作方式是先把带占位符?的 SQL 模板发给数据库数据库解析、编译、生成执行计划然后再把参数值单独传过去填充。关键在于参数值永远被当作“数据”处理而不是“SQL 代码的一部分”。String sql SELECT * FROM user_info WHERE username ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, name); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果 } } }哪怕name传的是 OR 11数据库也只会把它当成一个普通的字符串去匹配 username不会把它解析成 SQL 逻辑。这就是预编译的威力——代码和数据从传输层面就被分开了。除了安全PreparedStatement 还有性能优势。同一条 SQL 模板如果被反复执行比如批量插入数据库可以复用执行计划省去重复解析的开销。批量操作时配合addBatch和executeBatch性能提升非常明显。3.3 参数设置的那些细节setXxx系列方法看着简单坑却不少。索引从 1 开始而不是 0这个新手经常搞错。类型要匹配setInt对应 INTsetString对应 VARCHARsetTimestamp对应 DATETIME。如果类型对不上驱动可能会做隐式转换也可能直接报错。对于可能为 null 的字段别用setInt传 null会 NPE要用setNullif (age null) { ps.setNull(2, Types.INTEGER); } else { ps.setInt(2, age); }日期时间类型推荐用setObject配合LocalDateTime这是 JDBC 4.2 之后的新 API比老的java.sql.Timestamp更清爽ps.setObject(3, LocalDateTime.now());3.4 查询结果集怎么读才不出错ResultSet的遍历靠next()方法它把游标往下移一行返回是否有数据。取值时按列名或列索引都行我一般推荐列名可读性好改表结构时也不容易错位。while (rs.next()) { long id rs.getLong(id); String username rs.getString(username); int age rs.getInt(age); LocalDateTime createTime rs.getObject(create_time, LocalDateTime.class); }这里有个细节getInt遇到 NULL 会返回 0而不是 null你没法区分“值是 0”和“值是 NULL”。要区分就得用rs.getObject(age)然后判断是否为 null或者用rs.wasNull()检查上一次读取是否为 NULL。这个坑在统计类业务里特别容易出问题比如求平均年龄时把 NULL 当成 0 算进去结果就偏了。4. SQL 语句返回值类型execute 家族的三兄弟4.1 executeQuery、executeUpdate、execute 各管什么JDBC 里执行 SQL 有三个方法很多人用了很久也没分清它们的边界方法适用语句返回值典型场景executeQuerySELECTResultSet查询数据executeUpdateINSERT/UPDATE/DELETE/DDLint受影响行数增删改、建表execute任意boolean是否有结果集动态 SQL、存储过程executeQuery只能用于查询返回结果集。executeUpdate用于更新类操作返回受影响的行数——注意 DDL 返回的是 0因为建表不涉及“行”。execute最通用返回一个布尔值表示“这次执行有没有产生结果集”如果有再用getResultSet()拿如果没有用getUpdateCount()拿受影响行数。4.2 受影响行数到底意味着什么executeUpdate返回的 int 值是这次操作实际影响的行数。这个数字很有用但含义要搞清楚INSERT 返回 1插入一行批量插入返回插入的总行数。UPDATE 返回实际被修改的行数。注意如果更新的值和原值一样MySQL 默认返回 0因为没变化除非连接串加了useAffectedRowstrue让它返回匹配行数。DELETE 返回删除的行数。这个差异坑过不少人明明执行了 UPDATE返回 0以为是失败了其实是值没变。判断更新是否成功不能只看返回值还要结合业务逻辑。比如“把状态从 A 改成 B”如果本来就是 B返回 0 是正常的。4.3 主键回填插入后怎么拿到自增 ID插入一条记录后经常需要拿到它的自增主键比如插入订单后要拿订单号。用Statement.RETURN_GENERATED_KEYS可以做到String sql INSERT INTO user_info (username, age) VALUES (?, ?); try (PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, 李四); ps.setInt(2, 25); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { if (keys.next()) { long newId keys.getLong(1); } } }这里getGeneratedKeys返回的结果集里就是新生成的主键。批量插入时它可能返回多个主键需要循环读取。这个功能在需要建立关联记录时特别有用省去了再查一次的麻烦。4.4 批量操作的返回值与性能批量插入用addBatchexecuteBatch返回值是一个 int 数组每个元素对应一条语句的受影响行数String sql INSERT INTO user_info (username, age) VALUES (?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { for (User u : userList) { ps.setString(1, u.getUsername()); ps.setInt(2, u.getAge()); ps.addBatch(); } int[] results ps.executeBatch(); }批量操作一定要配合事务否则每条语句自动提交性能优势荡然无存。把conn.setAutoCommit(false)打开批量执行完再commit几千条插入能从几十秒降到几百毫秒。这个优化在数据导入场景里效果立竿见影。5. 连接管理与资源释放最容易被忽视的重灾区5.1 为什么必须用连接池每次DriverManager.getConnection都意味着一次完整的 TCP 连接建立、认证握手开销很大。高并发下如果每次都新建连接数据库很快会被连接数打满。连接池如 HikariCP、Druid预先建立一批连接放在池子里用的时候借用完还避免了频繁创建销毁。HikariCP 是目前性能和稳定性都很出色的选择配置也简单HikariConfig config new HikariConfig(); config.setJdbcUrl(url); config.setUsername(user); config.setPassword(pwd); config.setMaximumPoolSize(10); config.setConnectionTimeout(30000); HikariDataSource ds new HikariDataSource(config);maximumPoolSize不是越大越好一般按CPU核数 * 2 磁盘数估算盲目调大反而会因为线程切换和锁竞争拖慢性能。这个参数需要结合压测来定没有万能值。5.2 try-with-resources 的正确嵌套顺序资源释放的顺序必须是后开的先关ResultSet→Statement→Connection。用 try-with-resources 时嵌套顺序自然满足这个要求try (Connection conn ds.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, name); try (ResultSet rs ps.executeQuery()) { // 处理结果 } }注意ResultSet单独放在内层 try 里因为它是最后开的应该最先关。如果全写在一个 try 的括号里关闭顺序是反的从右到左虽然大多数驱动能容忍但严格来说不符合规范。5.3 事务边界怎么划JDBC 默认是自动提交模式每条 SQL 执行完立即生效。需要事务时关掉自动提交try (Connection conn ds.getConnection()) { conn.setAutoCommit(false); try { // 多条 SQL 操作 conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); } }事务范围要尽量小只包住必须原子性的操作。把耗时的网络调用、文件读写放进事务里会长时间占用连接和锁拖垮并发能力。另外setAutoCommit(true)放在 finally 里恢复是因为连接池借出的连接会被复用不恢复状态会污染下一个使用者。6. 那些年我踩过的 JDBC 坑6.1 时区问题导致的八小时偏差存进去的时间比实际少了 8 小时或者多了 8 小时这是 JDBC 连接 MySQL 最经典的坑。根因是 JVM 时区、数据库时区、连接串时区三者不一致。解决办法是在连接串里明确指定serverTimezoneAsia/Shanghai并确保数据库服务器和 JVM 的时区设置一致。用LocalDateTime而不是Date也能规避一部分问题因为LocalDateTime不带时区信息不会被自动转换。6.2 连接泄漏的排查思路程序跑一段时间后报“too many connections”基本可以断定是连接泄漏。排查方法在获取连接和关闭连接的地方打日志对比数量或者用连接池的监控功能看活跃连接数是否只增不减。最常见的泄漏点是异常路径——正常流程关了但抛异常时跳过了关闭代码。try-with-resources 能从语法层面杜绝这个问题所以我现在写 JDBC 一律用它绝不手写 finally 关闭。6.3 大结果集导致的内存溢出一次性SELECT *查出几十万行全塞进内存OOM 就来了。解决办法有两个一是用setFetchSize控制每次从数据库拉取的行数让驱动分批取二是分页查询用LIMIT和OFFSET一批批处理。setFetchSize对 MySQL 需要配合useCursorFetchtrue才生效这个细节很多人不知道。ps.setFetchSize(1000);对于导出类业务分页 流式处理是更稳妥的方案避免长时间占用数据库游标。6.4 SQL 写错却报“语法错误”的迷惑现场有时候 SQL 在客户端工具里跑得好好的放到 JDBC 里就报语法错误。常见原因有几个字符串里的换行符没处理、占位符数量和setXxx次数对不上、SQL 末尾多了分号某些驱动不接受。排查时把最终执行的 SQL 打印出来注意 PreparedStatement 打印的是带?的模板不是填充后的逐字对比问题往往就藏在一个看不见的空格或引号里。7. 把零散知识串成一套可复用的模板7.1 一个通用的查询工具方法把重复的样板代码抽出来是提升效率的关键。下面这个泛型查询方法把连接获取、参数设置、结果映射、资源释放都封装好了public T ListT query(String sql, RowMapperT mapper, Object... params) { ListT list new ArrayList(); try (Connection conn ds.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()) { while (rs.next()) { list.add(mapper.map(rs)); } } } catch (SQLException e) { throw new RuntimeException(查询失败: sql, e); } return list; }RowMapper是个函数式接口负责把一行结果映射成对象。这样业务代码只需要关心 SQL 和映射逻辑样板代码全部收敛到一处。这个思路其实就是轻量级的 JdbcTemplate理解了它再去看 Spring 的 JdbcTemplate 就一目了然。7.2 更新操作的统一封装更新类操作同理返回受影响行数public int update(String sql, Object... params) { try (Connection conn ds.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(更新失败: sql, e); } }setObject能自动处理大部分常见类型省去了一堆instanceof判断。对于特殊类型如LocalDateTime新版驱动也能正确识别。7.3 什么时候该上框架什么时候坚持 JDBCJDBC 适合的场景小工具、批处理脚本、对 SQL 有精细控制需求、维护老系统、学习数据库原理。它的优势是透明、可控、无额外依赖。该上框架的场景业务表多、关联复杂、需要大量 CRUD、团队协作要求统一规范。MyBatis 保留了 SQL 的可控性JPA/Hibernate 则进一步屏蔽了 SQL。选择哪个没有绝对答案取决于团队习惯和项目特点。我的建议是先把 JDBC 吃透再学框架。因为框架再高级底层跑的还是 JDBC出了问题最终还是要回到 JDBC 层面去排查。理解了连接、事务、预编译、结果集这些底层机制用任何框架都能心里有底。最后分享一个我自己的习惯每次写 JDBC 代码先问自己三个问题——SQL 里有没有拼接用户输入防注入资源有没有保证关闭防泄漏事务边界划得对不对防数据不一致这三个问题答清楚了代码基本就不会出大问题。剩下的就是多写多练把那些细节变成肌肉记忆。