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-j,Maven 坐标大致是这样:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.3.0</version> </dependency>老版本用的是mysql:mysql-connector-java,8.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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=trueserverTimezone不写经常报时区错误,useSSL=false在本地开发省去证书麻烦,allowPublicKeyRetrieval=true是配合新版认证插件用的。生产环境 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" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"; try (Connection conn = DriverManager.getConnection(url, user, pwd); Statement stmt = conn.createStatement()) { stmt.execute(ddl); }这里用了 try-with-resources,Connection和Statement都会自动关闭,比手动在 finally 里关优雅得多,也不容易漏。IF NOT EXISTS是个好习惯,重复执行不会报错,适合放在程序启动时的初始化逻辑里。
2.2 字符集和存储引擎,别等出乱码才后悔
建表时最容易忽略的是字符集。utf8mb4和utf8的区别,很多人栽过跟头:MySQL 里的utf8其实是“阉割版”,最多存 3 字节,存不了 emoji 和一些生僻字,而utf8mb4才是真正的完整 UTF-8。现在新项目一律用utf8mb4,别犹豫。
存储引擎方面,InnoDB支持事务、行级锁、外键,是绝对的主流选择。MyISAM虽然读性能在某些场景下略好,但不支持事务,早就该淘汰了。建表时显式写上ENGINE=InnoDB,避免依赖数据库默认配置。
字段类型的选择也有门道。主键用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 '1'='1,拼出来的 SQL 就变成了WHERE username = '' OR '1'='1',条件永远成立,整张表的数据全被查出来。第二颗是特殊字符处理:如果名字里带单引号,比如O'Brien,拼出来的 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 '1'='1,数据库也只会把它当成一个普通的字符串去匹配 username,不会把它解析成 SQL 逻辑。这就是预编译的威力——代码和数据从传输层面就被分开了。
除了安全,PreparedStatement 还有性能优势。同一条 SQL 模板如果被反复执行(比如批量插入),数据库可以复用执行计划,省去重复解析的开销。批量操作时配合addBatch和executeBatch,性能提升非常明显。
3.3 参数设置的那些细节
setXxx系列方法看着简单,坑却不少。索引从 1 开始而不是 0,这个新手经常搞错。类型要匹配,setInt对应 INT,setString对应 VARCHAR,setTimestamp对应 DATETIME。如果类型对不上,驱动可能会做隐式转换,也可能直接报错。
对于可能为 null 的字段,别用setInt传 null(会 NPE),要用setNull:
if (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 有三个方法,很多人用了很久也没分清它们的边界:
| 方法 | 适用语句 | 返回值 | 典型场景 |
|---|---|---|---|
executeQuery | SELECT | ResultSet | 查询数据 |
executeUpdate | INSERT/UPDATE/DELETE/DDL | int(受影响行数) | 增删改、建表 |
execute | 任意 | boolean(是否有结果集) | 动态 SQL、存储过程 |
executeQuery只能用于查询,返回结果集。executeUpdate用于更新类操作,返回受影响的行数——注意 DDL 返回的是 0,因为建表不涉及“行”。execute最通用,返回一个布尔值表示“这次执行有没有产生结果集”,如果有,再用getResultSet()拿;如果没有,用getUpdateCount()拿受影响行数。
4.2 受影响行数到底意味着什么
executeUpdate返回的 int 值,是这次操作实际影响的行数。这个数字很有用,但含义要搞清楚:
- INSERT 返回 1(插入一行),批量插入返回插入的总行数。
- UPDATE 返回实际被修改的行数。注意,如果更新的值和原值一样,MySQL 默认返回 0(因为没变化),除非连接串加了
useAffectedRows=true让它返回匹配行数。 - 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 批量操作的返回值与性能
批量插入用addBatch+executeBatch,返回值是一个 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 时区、数据库时区、连接串时区三者不一致。解决办法是在连接串里明确指定serverTimezone=Asia/Shanghai,并确保数据库服务器和 JVM 的时区设置一致。用LocalDateTime而不是Date也能规避一部分问题,因为LocalDateTime不带时区信息,不会被自动转换。
6.2 连接泄漏的排查思路
程序跑一段时间后报“too many connections”,基本可以断定是连接泄漏。排查方法:在获取连接和关闭连接的地方打日志,对比数量;或者用连接池的监控功能,看活跃连接数是否只增不减。最常见的泄漏点是异常路径——正常流程关了,但抛异常时跳过了关闭代码。try-with-resources 能从语法层面杜绝这个问题,所以我现在写 JDBC 一律用它,绝不手写 finally 关闭。
6.3 大结果集导致的内存溢出
一次性SELECT *查出几十万行,全塞进内存,OOM 就来了。解决办法有两个:一是用setFetchSize控制每次从数据库拉取的行数,让驱动分批取;二是分页查询,用LIMIT和OFFSET一批批处理。setFetchSize对 MySQL 需要配合useCursorFetch=true才生效,这个细节很多人不知道。
ps.setFetchSize(1000);对于导出类业务,分页 + 流式处理是更稳妥的方案,避免长时间占用数据库游标。
6.4 SQL 写错却报“语法错误”的迷惑现场
有时候 SQL 在客户端工具里跑得好好的,放到 JDBC 里就报语法错误。常见原因有几个:字符串里的换行符没处理、占位符数量和setXxx次数对不上、SQL 末尾多了分号(某些驱动不接受)。排查时把最终执行的 SQL 打印出来(注意 PreparedStatement 打印的是带?的模板,不是填充后的),逐字对比,问题往往就藏在一个看不见的空格或引号里。
7. 把零散知识串成一套可复用的模板
7.1 一个通用的查询工具方法
把重复的样板代码抽出来,是提升效率的关键。下面这个泛型查询方法,把连接获取、参数设置、结果映射、资源释放都封装好了:
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... params) { List<T> 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 什么时候该上框架,什么时候坚持 JDBC
JDBC 适合的场景:小工具、批处理脚本、对 SQL 有精细控制需求、维护老系统、学习数据库原理。它的优势是透明、可控、无额外依赖。
该上框架的场景:业务表多、关联复杂、需要大量 CRUD、团队协作要求统一规范。MyBatis 保留了 SQL 的可控性,JPA/Hibernate 则进一步屏蔽了 SQL。选择哪个没有绝对答案,取决于团队习惯和项目特点。
我的建议是:先把 JDBC 吃透,再学框架。因为框架再高级,底层跑的还是 JDBC,出了问题最终还是要回到 JDBC 层面去排查。理解了连接、事务、预编译、结果集这些底层机制,用任何框架都能心里有底。
最后分享一个我自己的习惯:每次写 JDBC 代码,先问自己三个问题——SQL 里有没有拼接用户输入(防注入)?资源有没有保证关闭(防泄漏)?事务边界划得对不对(防数据不一致)?这三个问题答清楚了,代码基本就不会出大问题。剩下的,就是多写多练,把那些细节变成肌肉记忆。