ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JDBC实战:Maven配置、PreparedStatement与事务处理

2026/9/29 17:09:20 拓冰建站 浏览量
JDBC实战:Maven配置、PreparedStatement与事务处理 拿到这个标题的时候我正好在帮一个刚转Java的朋友排查IDEA里连SQL Server报错的问题。他看到报错第一反应是百度download from maven failed折腾半天没弄明白其实问题根本不在下载而在依赖坐标和驱动版本对不上。这让我觉得JDBC这个老生常谈的东西真到实战里还是有一堆坑值得好好梳理一遍的。JDBC01这个标题比较简略结合配套的第1关jdbc插入用户数据以及一堆热搜词我大概能拼出你现在的处境刚接触JDBC想从增删改查入手但被环境配置、依赖下载、版本兼容这些事绊住了脚。这篇文章我就按一条完整的上手路径来写——从环境准备到增删改查再到事务和常见异常排查把搜索引擎里那些零散答案整合成一份可以直接照着做的攻略。1. JDBC为什么绕不开它才是Java连数据库的地基不管你现在用的是MyBatis、Hibernate还是Spring Data JPA这些框架底层最终都是通过JDBC和数据库打交道的。JDBCJava Database Connectivity是Java平台提供的一套统一访问关系型数据库的接口规范定义在java.sql和javax.sql两个包里。通俗点讲它就像是一个万能插座只要数据库厂商提供了对应的驱动驱动就是插头适配器你的Java代码就能用同一套API去操作MySQL、Oracle、SQL Server、PostgreSQL等数据库。很多新手会有个误区觉得现在写SQL都用ORM框架了JDBC用不上。但实际上框架只是帮你封装了JDBC的样板代码——连接管理、结果集映射、异常处理——这些操作的底层本质还是JDBC那套流程加载驱动、获取连接、创建Statement、执行SQL、处理ResultSet、释放资源。理解了这个流程你再看框架的源码就不会觉得神秘出了问题排查起来也有方向。这篇文章适合三类人一是刚接触Java数据库编程的学生课程任务里写着第1关jdbc插入用户数据二是工作中被迫从框架切回原生JDBC比如写数据同步工具、维护遗留系统的开发三是遇到各种连接器异常、版本不兼容问题想搞清楚底层原因的中间件维护者。我按一条完整的上手路径来写环境准备、增删改查、事务处理、异常排查都会覆盖到。2. 环境准备与依赖把Maven下载失败和版本兼容一次讲透2.1 download from maven failed的根因排查链路IDEA里使用SQL Server或其他数据库驱动时经常遇到让人一头雾水的下载失败问题。很多人以为自动下载就是网络不好重试好几次依然无济于事。实际上IDEA提示的download from maven failed分为好几层原因排查思路得按顺序来。第一步检查依赖坐标本身是否正确。SQL Server驱动的Maven坐标是com.microsoft.sqlserver:mssql-jdbcMySQL驱动是com.mysql:mysql-connector-j注意是老坐标mysql:mysql-connector-java的演进版本新项目建议直接使用com.mysql:mysql-connector-jPostgreSQL是org.postgresql:postgresql。很多下载失败纯粹是groupId写错、artifactId拼写错或者版本号不存在。比如表驱动时把mysql-connector-j写成mysql-connector-java在较新版本仓库里确实还兼容但版本坐标如果写成mysql-connector-j-8.0.33可能会因仓库同步延迟导致404。第二步排查Maven本地仓库和镜像配置。默认的Maven中央仓库在国外国内访问时快时慢IDEA内置的Maven如果没配置阿里云镜像大概率会超时。建议在~/.m2/settings.xml里配置镜像mirror idaliyunmaven/id mirrorOf*/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror配置完成后重启IDEA并让Maven重新加载大多数下载失败都能解决。还有些时候本地仓库路径包含中文或空格也会导致下载文件损坏、校验失败这种情况在Windows上比较常见。第三步确认IDEA使用的外部Maven和本地仓库是否一致。很多人装的IDEA自带Maven和命令行里用的Maven版本不同本地仓库路径也不一样容易造成明明命令行能下载IDEA却找不到依赖的现象。在Settings - Build Tools - Maven里统一Maven home directory、User settings file和Local repository能减少很多莫名其妙的问题。2.2 驱动版本与数据库、Java版本的对应关系依赖下载成功后另一个高频问题是驱动版本和数据库版本、Java版本不匹配。这里给出几个常用的对应关系方便查阅MySQL Connector/J 8.x支持MySQL 5.6以上包括MySQL 8.x要求Java 8及以上。连接MySQL 5.x时建议加一个参数用旧版认证插件否则可能会报Unable to load authentication plugin caching_sha2_password。mssql-jdbc 10.x/11.x/12.x支持SQL Server 2008以上版本Java 8及以上均可使用。较新的12.x版本已经要求Java 11以上部分版本支持Java 8选版本的时候看自己项目的JDK版本。PostgreSQL JDBC 42.x支持PostgreSQL 8.2及以上要求Java 8以上。一个非常容易踩坑的点JDBC驱动版本和数据库版本不完全绑死但驱动包内的某些特性只在特定数据库版本下可用。比如MySQL Connector/J 8.x默认使用caching_sha2_password认证如果你的MySQL用户还是mysql_native_password连接时会报错。解决办法有两个把驱动降到5.x不推荐或者在连接URL加allowPublicKeyRetrievaltrueuseSSLfalse参数并确保用户认证插件兼容。版本选择的原则我个人的经验是驱动小版本选比数据库大版本略新的稳定版不要一味追求最新。比如用的MySQL 5.7选mysql-connector-j 8.0.33没问题选8.3.0也兼容但如果选9.x可能存在数据库服务端协议的兼容风险。Java版本方面驱动官方文档都有明确的JDK要求老项目用JDK 8就老老实实选支持JDK 8的驱动版本不要逞强。3. JDBC增删改查的标准骨架连接、执行、释放的完整闭环3.1 获取连接连接URL的构成原理JDBC的第一步是获取Connection对象连接URL是有固定结构的。以MySQL为例标准格式是jdbc:mysql://主机地址:端口号/数据库名?参数1值1参数2值2主机地址可以是localhost、IP地址或者云数据库的公网地址端口号MySQL默认3306SQL Server默认1433PostgreSQL默认5432。数据库名必须写正确否则连接虽然建立成功但执行SQL时会报no database selected。URL后面的参数非常关键很多奇奇怪怪的报错都是参数缺失导致的。MySQL 8.x连接时常见的参数组合jdbc:mysql://localhost:3306/user_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue其中serverTimezone在驱动8.x版本是必须的不设置的话数据库服务器和JVM时区不一致读写DATETIME类型会出现时间偏移甚至直接报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。characterEncodingutf8可以避免中文乱码useSSLfalse是开发环境的常规配置生产环境需要SSL的话再考虑开启加密。SQL Server的连接URL格式有所不同要注意分号分隔而不是jdbc:sqlserver://localhost:1433;databaseNameuser_db;encrypttrue;trustServerCertificatetrueencrypt和trustServerCertificate是微软驱动10.x以后的新参数默认强制加密连接。如果数据库没配置SSL证书本地连接就需要加trustServerCertificatetrue否则会报证书验证失败。同时它不再支持characterEncoding参数字符集由数据库排序规则决定。3.2 增删改查的标准写法获取连接之后增删改查的执行方式只有两步创建Statement执行SQL。以第1关的插入用户数据为例最基本的写法是Class.forName(com.mysql.cj.jdbc.Driver); String url jdbc:mysql://localhost:3306/user_db?useSSLfalseserverTimezoneAsia/Shanghai; String user root; String password 123456; try (Connection conn DriverManager.getConnection(url, user, password); Statement stmt conn.createStatement()) { String sql INSERT INTO user (name, age, email) VALUES (张三, 25, zhangsanexample.com); int rows stmt.executeUpdate(sql); System.out.println(影响行数 rows); } catch (SQLException e) { e.printStackTrace(); }Class.forName(com.mysql.cj.jdbc.Driver)在JDBC 4.0之后的驱动中可以省略因为驱动通过META-INF/services/java.sql.Driver自动注册。但很多老教程还在写这一行写了也不算错只是多余。我建议新手还是保留原因有二一是强制自己记住驱动的类名排查ClassNotFound异常时心里有数二是如果你的项目里同时有多个数据库驱动显式加载某个特定驱动可以避免驱动误选。执行SQL时executeUpdate()用于INSERT、UPDATE、DELETE这类影响行数的操作返回结果是受影响的行数executeQuery()用于SELECT查询返回ResultSet结果集。如果你不确定SQL是查询还是更新可以用execute()方法然后根据返回值判断——返回true表示有结果集用getResultSet()获取返回false表示无结果集用getUpdateCount()获取影响行数。查询的标准写法String sql SELECT id, name, age, email FROM user WHERE age ?; try (Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, 20); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { int id rs.getInt(id); String name rs.getString(name); int age rs.getInt(age); String email rs.getString(email); System.out.printf(id%d, name%s, age%d, email%s%n, id, name, age, email); } } } catch (SQLException e) { e.printStackTrace(); }ResultSet的游标机制要理解初始位置在第一条记录之前next()每调用一次游标向后移动一行返回true代表还有数据。游标只能向前移动默认类型如果想要可滚动结果集需要创建Statement时额外指定参数实际业务中很少用。3.3 资源释放顺序先ResultSet再Statement再ConnectionJDBC资源的释放顺序是有讲究的先关闭ResultSet再关闭Statement最后关闭Connection。如果用Java 7以上的try-with-resources语法要注意声明顺序先声明的后关闭——比如先声明Connection后声明PreparedStatement关闭时顺序正好是反的。原因也好理解Statement依赖Connection才能执行ResultSet依赖Statement才能获取数据所以释放时要反过来层层收口。如果不使用try-with-resources需要在finally块里手动释放。同时注意关闭Statement会自动关闭它的ResultSet关闭Connection会自动关闭它创建的所有Statement。所以最小化的写法其实只需要关闭Connection但为了代码清晰和及时释放数据库游标资源显式关闭每一层是更好的习惯。一个容易踩的坑ResultSet被关闭后如果还想再遍历数据会报Operation not allowed after ResultSet closed。有些场景下你需要缓存数据正确做法是先遍历ResultSet并存入List再关闭资源。4. 插入用户数据的第一课PreparedStatement与SQL注入防线4.1 字符串拼接SQL为什么是引火烧身第1关是jdbc插入用户数据很多初学者第一反应就是用字符串拼接构造SQL。先看一个典型的错误写法String name 张三; int age 25; String email zhangsanexample.com; String sql INSERT INTO user (name, age, email) VALUES ( name , age , email );如果name是用户输入拼接进去会发生什么假设用户输入的姓名是张); DROP TABLE user; --拼出来的SQL就变成了INSERT INTO user (name, age, email) VALUES (张); DROP TABLE user; --, 25, zhangsanexample.com)这条SQL执行完user表没了。这就是SQL注入的经典原理——用户输入被当成SQL代码执行了。我在实际项目里见过不止一次因拼接SQL导致的数据泄露或数据删除事故轻则线上混乱重则赔偿道歉。所以第一个原则任何涉及用户输入的数据操作一律使用PreparedStatement。这不是什么高级安全策略这是底线。JDBC里的PreparedStatement在设计上就是为这个服务的它允许你用占位符?表示未知参数再通过setXxx方法把参数值传给数据库引擎。这样参数值只被当作值处理永远不会被解释成SQL语法。4.2 预编译机制与PreparedStatement正确写法PreparedStatement的核心机制是预编译。数据库驱动收到带有?占位符的SQL后会先把SQL模板发送到数据库服务端做编译语法解析、优化、生成执行计划之后每次执行时只发送参数值数据库直接用已有执行计划来绑定参数。带来的好处有两个一是安全——参数值不可能被当作SQL指令处理二是性能——同一SQL反复执行时减少了解析和编译的开销。正确的插入写法String sql INSERT INTO user (name, age, email, create_time) VALUES (?, ?, ?, ?); try (Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, 张三); ps.setInt(2, 25); ps.setString(3, zhangsanexample.com); ps.setTimestamp(4, Timestamp.valueOf(LocalDateTime.now())); int rows ps.executeUpdate(); if (rows 0) { System.out.println(插入成功); } } catch (SQLException e) { e.printStackTrace(); }参数索引从1开始按?出现的顺序绑定。类型匹配要严格对应VARCHAR用setStringINT用setIntBIGINT用setLongDATETIME/TIMESTAMP用setTimestampDECIMAL用setBigDecimal。语法上驱动支持setObject让驱动根据目标列类型自动推断但推断不总是正确。比如Java的java.util.Date传入setObject驱动可能无法确定该映射为DATE还是TIMESTAMP导致类型转换异常。规规矩矩用setXxx能减少一半的类型问题。4.3 批量插入别一条一条executeUpdate插入用户数据的场景往往不只是插入一条而是批量插入几千上万条。如果用循环逐条executeUpdate每次都要走一次完整的网络往返性能会很差。JDBC的addBatch和executeBatch就是为这个设计的String sql INSERT INTO user (name, age, email) VALUES (?, ?, ?); try (Connection conn DriverManager.getConnection(url, user, password); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i 1000; i) { ps.setString(1, user_ i); ps.setInt(2, 20 i % 30); ps.setString(3, user i example.com); ps.addBatch(); if (i % 500 0) { ps.executeBatch(); // 每500条提交一次防止批处理缓冲过大 ps.clearBatch(); } } ps.executeBatch(); // 最后一次清算 }批量插入的时间能比逐条插入快一个数量级。核心原因就是减少了客户端和数据库之间的通信次数把多次网络往返合并成少数几次。批量操作还有一个注意点如果某一条数据违反约束导致批量执行中断整个批次的异常处理需要明确策略。你可以设置conn.setAutoCommit(false)批量执行出错后统一rollback()回滚保证数据一致性也可以继续执行剩余的条数牺牲一致性换取部分写入。业务上通常选择整体回滚除非你能手工处理中间状态。5. JDBC事务让插入操作具备回滚能力5.1 事务边界与setAutoCommit的核心逻辑JDBC默认情况下autocommit为true意味着每条SQL执行完自动提交。但实际业务中一个操作往往涉及多条SQL比如转账操作A账户扣款、B账户加款任何一条失败都必须让另一条不生效否则账就不平了。这就要手动控制事务核心就是setAutoCommit(false)。事务的标准流程Connection conn null; try { conn DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); // 关闭自动提交开启事务 try (PreparedStatement ps1 conn.prepareStatement(UPDATE account SET balance balance - ? WHERE id ?)) { ps1.setBigDecimal(1, new BigDecimal(100)); ps1.setInt(2, 1); ps1.executeUpdate(); } try (PreparedStatement ps2 conn.prepareStatement(UPDATE account SET balance balance ? WHERE id ?)) { ps2.setBigDecimal(1, new BigDecimal(100)); ps2.setInt(2, 2); ps2.executeUpdate(); } conn.commit(); // 所有SQL都成功提交事务 } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); // 任何一条SQL失败回滚到事务开始状态 } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); } finally { if (conn ! null) { try { conn.setAutoCommit(true); // 恢复默认状态方便连接归还到连接池后能被复用 conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }事务的边界就是setAutoCommit(false)到commit()/rollback()之间的所有SQL。数据库会把它们看作一个原子单位要么全部生效要么全部不生效。这里有一个新手经常搞不明白的点rollback()回滚到哪回滚到事务开始的时候也就是setAutoCommit(false)那一条之后。如果事务里已经执行了10条SQL前9条都没问题第10条报错那前9条的结果也会被撤销。因为这个事务并没有commit()它的所有修改对外都不可见隔离级别足够高时一旦回滚全部丢弃。5.2 Savepoint部分回滚的思路事务里有时不需要全部回滚比如一个批量导入操作处理到一半遇到脏数据想回滚到某个检查点继续后面的逻辑。这时可以用Savepointconn.setAutoCommit(false); Savepoint sp conn.setSavepoint(before_batch); try { // 批量执行SQL } catch (SQLException e) { conn.rollback(sp); // 回滚到保存点之前已commit的部分不会回滚 // 跳过脏数据继续后续处理 } conn.commit();Savepoint适合长事务里的局部回滚但也要注意如果连接池不支持Savepoint或事务太长导致锁竞争激烈实际项目的收益会大打折扣。我更建议把大事务拆成多个小事务保持事务短小精悍。事务越长持有的数据库锁越多死锁和超时的概率越大系统吞吐量越低。5.3 连接池环境下的事务陷阱到了生产环境基本不会用DriverManager.getConnection直接建立连接而是用连接池HikariCP、Druid、C3P0。连接池复用连接带来一个隐蔽的坑代码里如果不小心没setAutoCommit(true)就把连接归还连接池下次拿到同一条连接时还是autocommitfalse状态SQL不会自动提交业务上表现为数据丢失。我自己就踩过这种坑。有一次写数据同步任务用的Druid连接池任务跑完统计显示处理5000条数据库却一条没进。排查了半天发现是某个异常路径提前returnfinally里只做了close()——连接池的close()只是归还连接并不会重置状态。后面我在所有事务代码的finally块里统一加一行conn.setAutoCommit(true)问题彻底解决。还有一点事务千万不能跨越连接获取。比如事务开始时获取连接A中间有事把连接还给连接池后面又拿连接B继续执行事务SQL——这样事务在连接B上是不会续接的数据库层面完全是两个会话。必须保证一个事务内的所有SQL都使用同一条连接。6. 高频异常速查连接器版本冲突与生产事故复盘6.1 Flink JDBC连接器异常的排查链路热词里出现了flink的jdbc连接器异常做实时计算的朋友对这个应该不陌生。Flink的JDBC连接器一般指flink-connector-jdbc用于流式读写数据库。常见的异常集中在几类驱动冲突、连接空闲超时、类型映射错误、Exactly-Once语义的ID冲突。最常见的驱动冲突是这样的Flink任务里同时引入flink-connector-jdbc和mysql-connector-j两个包都带META-INF/services/java.sql.Driver类加载时可能加载到错误的驱动版本。Flink的类加载模型隔离性不好依赖冲突会导致奇怪的ClassNotFoundException或NoSuchMethodError肉眼很难看出原因。排查步骤建议按顺序来用mvn dependency:tree查看依赖树确认flink-connector-jdbc自带的驱动版本和你引用的版本是否冲突。如果冲突把连接的驱动依赖改成provided作用域让Flink使用胖包里的版本。报错Communications link failure或者Connection reset多半是连接空闲超时。数据库服务端有wait_timeout参数默认8小时但Flink的连接池可能把空闲连接保留更久导致连接已被服务端断开客户端不知道的经典场景。解决的思路是让连接池定期校验和回收空闲连接比如HikariCP的connection-test-query、max-lifetime配置。数据类型映射问题重点检查数据库字段类型是否被JDBC驱动识别为预期的Java类型。比如TINYINT(1)在MySQL驱动里映射为Boolean如果你预期读取数字类型就会解析出true/false。Flink JDBC连接器做Exactly-Once时通过两阶段事务提交保证写入一致性但这要求数据库连接持有XID事务。MySQL并不天然支持分布式事务Flink实际上是把事务提交到MySQL的XA协议里。如果看到XAER_RMERR或者XAException说明MySQL的XA支持和驱动版本之间有兼容问题通常只需要升级驱动或调整setAutoCommit配置。6.2 Elasticsearch JDBC驱动的版本墙另一个高频报错this version of the jdbc driver is only compatible with elasticsearch version ...。这个感觉是Elasticsearch SQL JDBC客户端的老大难问题。Elasticsearch JDBC驱动org.elasticsearch.plugin:x-pack-sql-jdbc有严格的主版本对应关系驱动小版本必须和Elasticsearch版本精确匹配。7.15的驱动连不了7.16的Elasticsearch8.x的驱动连不了7.x的服务端。不像MySQL驱动那种低版本兼容高版本的思路ES的JDBC驱动版本墙非常硬。解决办法是明确你的ES版本后选完全一致或实现完全一致且较早的驱动版本。比如ES集群是7.16.2可以用org.elasticsearch.plugin:x-pack-sql-jdbc:7.16.2。如果仓库没有该小版本找最接近的补丁版本替换例如7.16.0但不要太跨大版本。另外注意Maven坐标的classifier字段。ES JDBC驱动默认没有打fat包只包含驱动本身的class运行时报java.lang.NoClassDefFoundError的话需要改为带classifier: http的依赖坐标或者把ES客户端相关的依赖一并引入。6.3 通用排查思路从堆栈第一行开始逆向最后分享一个通用的异常排查思路。很多人一看到堆栈就慌直接从最后一行Exception开始读。其实复杂异常的正确姿势是先读堆栈最上面的Caused by那才是根源。Java的SQL异常往往层层包装最外层是业务代码里的SQLException内层才真正写明失败的数据库原因。比如SQLException: Connection is not available, request timed out after 30000ms Caused by: SQLTransientConnectionException: HikariPool-1 - Connection is not available最外层只提示连接超时内层的SQLTransientConnectionException指向连接池没有空闲连接。再往下的Caused by可能是Connection refused或者Access denied。顺着Caused by链条追到最底层才能定位是网络问题、认证失败还是连接池配置太小。还有一个小技巧把?loggercom.mysql.cj.jdbc.Driver或者?logLevelDEBUG加到JDBC URL末尾驱动会输出连接建立过程的详细日志。SQL Server驱动则是?loggerLevelTRACE。生产环境不要开本地排查很有效。7. 我的几点实操体会关于JDBC我自己从照着教程能跑通到敢在生产环境碰直连数据库大概花了半年中间踩过的坑比写出来的多得多。分享几个实用的个人经验。第一建立统一的连接参数模板很值得。不管你用MySQL、SQL Server还是PostgreSQL把连接URL、驱动版本、超时参数、连接池配置沉淀成团队内的标准文档能省掉大量你连不上数据库的排查时间。我一般会在新项目里直接把HikariCP等连接池配上再写一个简单的DataSource工厂类全局复用。第二在写增删改查之前先建好测试表和数据。JDBC的学习和调试需要反复执行脚本表结构不稳定会让排查复杂化。我习惯用一套固定的建表脚本字段覆盖常见类型整数、字符串、时间戳、小数、布尔这样建好之后增删改查的类型映射问题很快就能暴露出来。第三一定要会看驱动源码。遇到不理解的报错直接打开依赖里的驱动源码搜索错误信息比自己猜要快得多。JDBC驱动源码整体来说写得清晰注释也算友好算是Java生态里很适合读源码入门的依赖库。最后JDBC只是起点但扎实的JDBC基础会让后续理解连接池、数据库事务、ORM框架变得非常轻松。如果你正在完成插入用户数据之类的实验任务先把PreparedStatement和事务这关过了后面的路会顺畅很多。