news 2026/10/16 8:25:27

JDBC连接MySQL实战:增删改查、防注入与返回值类型详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JDBC连接MySQL实战:增删改查、防注入与返回值类型详解

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 查询大致经历这么几个阶段:

  1. 加载并注册驱动:让 JVM 知道用哪个驱动实现。现代驱动通过 SPI 机制可以自动注册,但显式加载更稳妥。
  2. 建立连接:通过DriverManager.getConnection拿到Connection对象,这一步背后是 TCP 握手、身份认证、会话初始化。
  3. 创建语句对象:Statement或PreparedStatement,承载你要执行的 SQL。
  4. 执行 SQL:查询用executeQuery,更新用executeUpdate,通用用execute。
  5. 处理结果:查询返回ResultSet,需要逐行遍历取值。
  6. 释放资源: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=true

serverTimezone不写经常报时区错误,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 有三个方法,很多人用了很久也没分清它们的边界:

方法适用语句返回值典型场景
executeQuerySELECTResultSet查询数据
executeUpdateINSERT/UPDATE/DELETE/DDLint(受影响行数)增删改、建表
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 里有没有拼接用户输入(防注入)?资源有没有保证关闭(防泄漏)?事务边界划得对不对(防数据不一致)?这三个问题答清楚了,代码基本就不会出大问题。剩下的,就是多写多练,把那些细节变成肌肉记忆。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 8:25:27

Django模板引擎深度解析:从渲染架构到高级标签与过滤器实战

Django 模板引擎确实是很多开发者低估的一块。用了好几年的人&#xff0c;可能还停留在{{ obj }}和{% if %}的阶段&#xff0c;觉得它只是简单的字符串替换。但实际上&#xff0c;模板引擎是 Django 渲染层中可扩展性最强、设计最精巧的部分之一。这篇文章会从渲染层架构的角度…

作者头像 李华
网站建设 2026/10/16 8:24:35

DeepSeek Harness 更新详解:插件、工作树与思考翻译

先说结论&#xff1a;这次 DeepSeek Harness 的更新&#xff0c;不是那种只改改文档、修修边角的"例行公事"&#xff0c;它把 Codex 和 Claude Code 两套成熟编码智能体正式收进了官方插件列表&#xff0c;同时补上了 Git 工作树和思考翻译两个相当实用的能力。如果你…

作者头像 李华
网站建设 2026/10/16 8:24:34

AI编程工具实战指南:四类工具、方法论与工作流深度解析

过去这两年&#xff0c;我身边的开发团队几乎没有人能绕开 AI 编程工具这个话题。从最开始大家拿它当“自动补全的高级版”&#xff0c;到后来逐渐分化出不同的使用流派——有人重度依赖&#xff0c;有人嗤之以鼻&#xff0c;更多的人是“用着但不知道怎么用好”。这篇文章我想…

作者头像 李华
网站建设 2026/10/16 8:24:23

GitHub九月热门项目盘点:热度算法与实战避坑指南

每个月月初&#xff0c;我都会抽出完整的一晚&#xff0c;把 GitHub 上过去 30 天涨势最猛的项目翻一遍&#xff0c;自己拉 Star 增量、Fork 增量&#xff0c;再看 Issue 响应速度和合并 PR 的数量。说实话&#xff0c;GitHub 官方那张 Trending 榜单给的是“结果”&#xff0c…

作者头像 李华
网站建设 2026/10/16 8:23:47

Spring Boot公交查询系统毕设实战:数据库设计到BFS换乘算法

每年做毕业设计&#xff0c;找我聊选题的同学特别多。大多数人上来就说要做"XX管理系统"&#xff0c;无非是用户管理、订单管理、增删改查&#xff0c;页面堆一堆&#xff0c;等答辩的时候老师问一句"你系统的难点是什么"&#xff0c;整个人就僵住了。但如…

作者头像 李华
网站建设 2026/10/16 8:22:22

数值分析上机编程全攻略:题型拆解、算法选型与Python实现

数值分析上机编程题&#xff0c;大概是每个工科学生都绕不过去的坎。期末要交作业、考试前突击练习、保研复试上机考核&#xff0c;翻来覆去就是那几个经典题目&#xff1a;插值、数值积分、非线性方程求根、线性方程组迭代解、常微分方程数值解。题目看着简单&#xff0c;但真…

作者头像 李华