资讯动态

JDBC底层原理与实战:构建数据库交互心智模型

发布时间:2026/10/17 2:17:23 来源:尧图企业网站定制
1. 为什么今天还要学 JDBC——一个被低估的“底层锚点”很多人看到“JDBC 基础操作”这几个字第一反应是“这不就是十年前的老古董吗现在谁还手写Connection、StatementSpring Boot 一行Autowired JdbcTemplate就搞定了。”我完全理解这种想法。去年在某次技术分享会上一位刚转行半年的开发者举手问“老师我们项目里连pom.xml都没手动改过MyBatis 的 XML 文件都是 IDEA 自动生成的学 JDBC 还有意义吗”台下一片附和。我当时没直接回答而是打开 IDE现场写了三行代码Connection conn DriverManager.getConnection(jdbc:h2:mem:test, sa, ); PreparedStatement ps conn.prepareStatement(SELECT * FROM user WHERE id ?); ps.setLong(1, 123L); ResultSet rs ps.executeQuery();然后停住问“如果这三行中任意一行抛出SQLException错误信息是No suitable driver found for jdbc:h2:mem:test你第一反应是查 Spring Boot 的application.yml吗还是去翻 HikariCP 的连接池配置或者干脆重启服务”全场安静了五秒。——问题就在这里当框架把“连接数据库”这件事封装成一个黑盒时你失去的不是代码量而是对故障边界的感知能力。JDBC 不是过时的技术它是 Java 生态里唯一一个被 JVM 规范明确定义、由 JDK 原生提供、所有关系型数据库驱动必须实现的标准化契约层。它像 TCP/IP 协议栈里的 IP 层——你日常用 HTTP对应 Spring Data JPA但一旦 DNS 解析失败或 TLS 握手超时最终要回溯到 IP 和 TCP 的行为逻辑里找根因。同理当你的应用出现“连接池耗尽但活跃连接数为 0”“事务未提交却自动回滚”“批量插入性能断崖式下跌”这类问题时所有主流 ORM 框架的源码最终都会落到java.sql.*接口的实现上。更现实的一点是我在过去三年参与的 7 个生产环境数据库事故复盘中有 5 起的根因都卡在 JDBC 层的隐式行为上。比如某次凌晨三点的告警现象是 MySQL 连接数飙升至 1000而业务 QPS 并无明显增长。排查发现是某位同事在 MyBatis 的Select注解里写了fetchSize Integer.MIN_VALUE本意是启用流式读取却不知 MySQL Connector/J 对该值的处理逻辑是强制关闭连接复用每次查询都新建物理连接。这个行为在 JDBC 规范里没有定义是驱动厂商的实现细节——而你只有亲手写过ResultSet.setFetchSize()才可能在 Code Review 时一眼识别出这个危险信号。所以“JDBC 基础操作”的真实价值从来不是教你如何增删改查而是帮你建立一套数据库交互的底层心智模型连接怎么建立、语句如何编译、参数怎样绑定、结果如何传输、事务如何传播、异常如何分类。这套模型不会因为你换用 Hibernate 或 jOOQ 而失效反而会成为你评估任何新框架可靠性的标尺。提示本文所有代码均基于 JDK 17 JDBC 4.3 规范编写不依赖任何第三方框架。所有实操步骤均可在纯 JDK 环境下验证无需 Spring、MyBatis 或其他中间件。这是刻意为之的设计——只有剥离所有封装你才能看清 JDBC 本身长什么样子。2. 从零构建一个可运行的 JDBC 环境——那些被忽略的“默认陷阱”很多教程一上来就贴Class.forName(com.mysql.cj.jdbc.Driver)然后DriverManager.getConnection(...)仿佛只要 URL 正确就能跑通。但我在带新人做第一个 JDBC Demo 时发现超过 60% 的人卡在第一步连驱动类都加载失败。原因五花八门JDK 版本不匹配、驱动 JAR 未加入 classpath、URL 格式不符合规范……这些看似低级的问题恰恰暴露了对 JDBC 初始化机制的误解。2.1 驱动注册机制的演进从显式加载到服务发现在 JDBC 4.0JDK 6之前你必须手动调用Class.forName()来触发驱动类的静态初始化块从而向DriverManager注册自身。例如 MySQL 5.x 的经典写法// JDBC 3.x 时代已淘汰仅作历史对照 Class.forName(com.mysql.jdbc.Driver); // 触发 static {} 块 Connection conn DriverManager.getConnection( jdbc:mysql://localhost:3306/test?userrootpassword123456 );这个写法的问题在于它强耦合了驱动实现类名一旦升级到 MySQL 8.x类名变成com.mysql.cj.jdbc.Driver所有代码都要改。更严重的是Class.forName()只是“尝试加载”如果类路径不对会抛ClassNotFoundException但这个异常往往被吞掉或误判为数据库连接失败。JDBC 4.0 引入了Service Provider InterfaceSPI机制彻底改变了这一流程。现在你只需确保驱动 JAR 包在 classpath 中JVM 会在启动时自动扫描META-INF/services/java.sql.Driver文件并实例化其中声明的驱动类。这意味着✅ 你完全不需要写Class.forName()✅ 驱动注册发生在DriverManager.getConnection()调用前的静态初始化阶段✅ 如果驱动未找到getConnection()会直接抛SQLException错误信息明确为 “No suitable driver”。但这里有个关键细节SPI 机制依赖于 ClassLoader 的双亲委派模型。如果你的应用打包成 fat-jar如 Spring Boot 的 executable jar且驱动 JAR 被嵌套在BOOT-INF/lib/下那么默认的AppClassLoader是无法扫描到META-INF/services/文件的。此时你需要显式指定DriverManager.deregisterDriver()或使用Thread.currentThread().setContextClassLoader(...)。不过对于初学者最稳妥的做法是将驱动 JAR 放在独立目录通过-cp参数显式引入。2.2 数据库 URL 的结构解析不只是协议和地址JDBC URL 的格式是jdbc:subprotocol:subname但subname部分远比//host:port/database复杂。以 MySQL Connector/J 8.0 为例一个生产环境常用的 URL 可能长这样jdbc:mysql://192.168.1.100:3306/myapp?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruerewriteBatchedStatementstruecachePrepStmtstrueprepStmtCacheSize250prepStmtCacheSqlLimit2048初看眼花缭乱但其实每一项都对应一个具体的 JDBC 行为控制点。我们拆解几个最关键的参数参数默认值作用不设的后果useSSLtrueMySQL 8.0控制是否启用 TLS 加密若数据库未配置 SSL连接直接失败报错SSLExceptionserverTimezone由系统时区推导统一客户端与服务器时间解释逻辑时间字段存取错乱如2023-01-01 00:00:00存成2022-12-31 16:00:00rewriteBatchedStatementsfalse将addBatch()的多条 INSERT 合并为一条INSERT INTO ... VALUES (...),(...)批量插入性能下降 5~10 倍单条语句网络往返开销cachePrepStmtsfalse缓存PreparedStatement的解析结果频繁执行相同 SQL 时每次都要重新解析语法树CPU 占用升高注意这些参数不是 JDBC 规范定义的而是各数据库驱动厂商的扩展。PostgreSQL 的?currentSchemapublic、Oracle 的?oracle.jdbc.autoCommitSpecCompliantfalse都属于同类机制。因此当你切换数据库时URL 参数必须重审——不存在“通用 JDBC URL”。2.3 连接获取的三种方式DriverManager、DataSource 与连接池新手常以为DriverManager.getConnection()是唯一入口其实它只是最原始的方式。JDBC 规范定义了更健壮的javax.sql.DataSource接口这才是企业级应用的标准实践。DriverManager.getConnection()适合单元测试、脚本工具等轻量场景。它的本质是每次调用都新建一个物理连接不支持连接复用、无超时控制、无密码加密。BasicDataSourceApache Commons DBCP早期连接池实现已停止维护仅作历史参考。HikariCP/DruidDataSource现代高性能连接池它们实现了DataSource接口但内部管理着连接池、心跳检测、泄漏预警等复杂逻辑。关键区别在于DataSource的getConnection()方法返回的Connection对象不是物理连接而是一个代理对象Proxy。当你调用conn.close()时实际执行的是“归还连接到池中”而非真正关闭 socket。这就是为什么你在使用 Spring 的Transactional时即使方法内多次调用 DAO底层也只用一个物理连接——因为DataSource保证了同一事务内的Connection复用。实操建议初学阶段务必先用DriverManager写透所有 CRUD再迁移到HikariCP。否则你会陷入“为什么conn.close()没效果”“为什么事务不生效”这类认知混淆。就像学开车得先摸清离合、油门、档位的物理反馈再上自动挡。3. PreparedStatement 的深度实践超越“防 SQL 注入”的常识提到PreparedStatement90% 的教程只会强调它能防止 SQL 注入。这没错但过于片面。PreparedStatement的核心价值在于它把“SQL 语句模板”和“执行时参数”做了语义分离从而触发数据库端的预编译优化。这个机制直接影响着你的应用性能上限。3.1 预编译的本质数据库端的“函数缓存”当你执行conn.prepareStatement(SELECT * FROM user WHERE id ?)时JDBC 驱动做的第一件事是向数据库发送一条PREPARE命令MySQL 协议或等效的预编译请求PostgreSQL 的Parse消息。数据库收到后会对 SQL 进行词法分析、语法分析、语义检查如表是否存在、字段名是否合法生成执行计划Execution Plan包括索引选择、连接顺序、聚合策略等将该计划缓存起来关联一个唯一的statement_id返回给客户端一个“预编译句柄”。后续调用ps.setLong(1, 123L); ps.executeQuery()时驱动只需发送EXECUTE命令 参数值数据库直接复用已缓存的执行计划跳过耗时的解析和优化阶段。这个过程类似于 Java 的 JIT 编译第一次执行某个方法时JVM 解释执行当该方法被频繁调用JIT 会将其编译为本地机器码并缓存。PreparedStatement就是数据库的“JIT 编译器”。3.2 何时预编译失效——三个常见陷阱但预编译不是万能的。以下情况会导致数据库放弃缓存退化为普通语句执行动态拼接 SQL 字符串// ❌ 错误字符串拼接破坏了 SQL 模板的稳定性 String sql SELECT * FROM user WHERE status status ; Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql);即使status值相同每次拼出的 SQL 字符串也是新对象数据库无法命中缓存。参数位置不固定// ❌ 错误WHERE 条件数量动态变化 String sql SELECT * FROM user WHERE 11; if (id ! null) sql AND id ?; if (name ! null) sql AND name LIKE ?; PreparedStatement ps conn.prepareStatement(sql); // 每次 SQL 字符串都不同使用非标准参数类型MySQL 中?占位符只能代表标量值数字、字符串、日期不能代表表名、列名、ORDER BY 子句。试图这样做// ❌ 运行时报错You have an error in your SQL syntax PreparedStatement ps conn.prepareStatement(SELECT * FROM ? WHERE id ?); ps.setString(1, user); // 表名不能参数化实测数据在 MySQL 8.0 上对同一张百万级用户表执行SELECT * FROM user WHERE id ?使用PreparedStatement的平均响应时间为 12ms而用Statement拼接字符串平均为 28ms。差距主要来自每次执行都要重复解析 SQL 和生成执行计划。3.3 批量操作的正确姿势addBatch() 与 rewriteBatchedStatementsPreparedStatement.addBatch()是提升批量写入性能的关键 API但它的行为高度依赖数据库驱动的实现。以 MySQL 为例其默认行为是将每条addBatch()的 SQL 发送为独立的INSERT语句即ps conn.prepareStatement(INSERT INTO user(name, age) VALUES (?, ?)); ps.setString(1, Alice); ps.setInt(2, 25); ps.addBatch(); ps.setString(1, Bob); ps.setInt(2, 30); ps.addBatch(); ps.executeBatch(); // 实际发送两条独立 INSERT 语句这虽然比循环executeUpdate()稍好减少了 JDBC 层的开销但网络层面仍是两次往返。而开启rewriteBatchedStatementstrue后驱动会将批处理重写为单条语句INSERT INTO user(name, age) VALUES (Alice, 25), (Bob, 30)性能提升可达 3~5 倍。但注意此优化仅对 INSERT 有效UPDATE/DELETE 不支持且要求所有批次的 SQL 结构完全一致字段数、类型、顺序。PostgreSQL 的处理方式不同它原生支持executeBatch()的二进制协议批量执行无需 URL 参数但要求驱动版本 ≥ 42.2.0。4. ResultSet 的内存与流式读取别让大数据集拖垮你的服务ResultSet是 JDBC 中最容易被忽视的性能黑洞。新手常写出这样的代码ResultSet rs ps.executeQuery(SELECT * FROM huge_table WHERE status active); while (rs.next()) { process(rs.getString(name), rs.getInt(age)); // 处理每一行 }表面看逻辑清晰但背后藏着两个致命风险4.1 默认的“全量拉取”模式内存爆炸的元凶JDBC 规范规定ResultSet的默认获取方式是TYPE_FORWARD_ONLYCONCUR_READ_ONLY但未规定数据何时从数据库传输到客户端。各驱动厂商的实现差异巨大MySQL Connector/J默认将整个结果集一次性加载到 JVM 堆内存中。如果你查询一张千万行的表ResultSet对象会占用数百 MB 内存极易触发 Full GC 甚至OutOfMemoryError。PostgreSQL JDBC Driver默认采用“流式读取”Streaming Result即只在rs.next()时按需从 socket 读取下一行数据内存占用恒定。这个差异导致了一个经典问题同样的代码在 PostgreSQL 上跑得好好的一迁到 MySQL 就 OOM。解决方案是显式设置ResultSet的获取方向和大小// 显式启用 MySQL 的流式读取必须在 executeQuery 前设置 ps conn.prepareStatement(SELECT * FROM huge_table WHERE status active); ((com.mysql.cj.jdbc.JdbcPreparedStatement) ps) .setFetchSize(Integer.MIN_VALUE); // 关键MIN_VALUE 触发流式 ResultSet rs ps.executeQuery(); // 或者更通用的写法兼容多数驱动 ps.setFetchSize(100); // 每次从网络读取 100 行缓存到本地setFetchSize(int rows)的含义是JDBC 驱动每次从数据库网络缓冲区中批量读取多少行数据到客户端内存。设为 100意味着rs.next()调用 100 次以内都不需要发起新的网络请求第 101 次时驱动自动发起下一批拉取。这是一个典型的“空间换时间”策略。4.2 类型转换的隐式开销getString() 为什么比 getLong() 慢ResultSet提供了getString()、getInt()、getLong()、getDate()等一系列 getter 方法。很多人认为这只是语义区别实则不然。以 MySQL 为例当你调用rs.getString(id)时驱动会将数据库返回的BIGINT字节流先解析为long再调用String.valueOf(long)转为字符串而rs.getLong(id)直接返回解析后的long值省去了字符串创建和 GC 开销。在高频循环中这种差异会被放大。实测对 10 万行数据调用getString(id)耗时约 180ms改用getLong(id)耗时降至 45ms。差距来自字符串对象的创建、字符数组拷贝、以及后续的垃圾回收压力。经验技巧永远使用与数据库字段类型最匹配的 getter 方法。TINYINT用getByte()DECIMAL用getBigDecimal()TIMESTAMP用getTimestamp()。避免无谓的类型转换。4.3 游标定位与并发修改next() 之外的生存法则ResultSet不仅是数据容器还是一个数据库游标Cursor的客户端映射。它的行为受ResultSet.TYPE_XXX和ResultSet.CONCUR_XXX两个属性控制类型组合是否可滚动是否可更新典型用途TYPE_FORWARD_ONLYCONCUR_READ_ONLY❌ 只能next()❌ 只读默认适合简单查询TYPE_SCROLL_INSENSITIVECONCUR_READ_ONLY✅first(),last(),absolute(n)❌需要随机访问结果集如分页TYPE_SCROLL_SENSITIVECONCUR_UPDATABLE✅✅updateString(),insertRow()极少用性能差易引发锁冲突特别注意SCROLL_SENSITIVE它要求驱动实时监听数据库变更通常通过轮询或数据库通知机制实现开销极大。生产环境应避免使用。一个实用技巧分页查询不要依赖absolute()而要用 SQL 本身的LIMIT/OFFSET或ROWNUM。因为absolute(10000)会让驱动从头遍历 10000 行效率极低。正确的做法是-- MySQL SELECT * FROM user ORDER BY id LIMIT 20 OFFSET 10000 -- PostgreSQL SELECT * FROM user ORDER BY id LIMIT 20 OFFSET 10000 -- Oracle12c SELECT * FROM user ORDER BY id OFFSET 10000 ROWS FETCH NEXT 20 ROWS ONLY5. 事务管理的底层逻辑从 autoCommit 到 savepoint 的完整链路JDBC 的事务控制是Connection对象最核心的能力之一。但很多开发者只停留在conn.setAutoCommit(false)和conn.commit()的层面忽略了事务隔离级别、保存点、跨连接事务等深层机制。5.1 autoCommit 的两种状态不是“开”就是“关”Connection的autoCommit属性决定了每条 SQL 语句是否自动提交。默认为true即每执行一条INSERT/UPDATE/DELETE数据库立即持久化。设为false后必须显式调用commit()或rollback()。但这里有个关键细节autoCommit的状态变更会隐式触发一次commit()。也就是说conn.setAutoCommit(true); // 此时若存在未提交的事务会立即提交 conn.setAutoCommit(false); // 同样若当前处于 autoCommittrue 模式此调用会先 commit 再关闭这个行为常被忽略导致“明明没调用 commit数据却写入了”的困惑。根源在于setAutoCommit(false)不是“开始事务”而是“结束上一个事务并开启新事务边界”。真正的事务起点是第一条 DML 语句执行的那一刻。5.2 隔离级别的选择READ_COMMITTED 为何是默认值JDBC 定义了四种事务隔离级别级别脏读不可重复读幻读数据库默认TRANSACTION_READ_UNCOMMITTED✅✅✅极少用TRANSACTION_READ_COMMITTED❌✅✅MySQL InnoDB、PostgreSQL 默认TRANSACTION_REPEATABLE_READ❌❌✅MySQL InnoDB 默认InnoDB 用 MVCC 实现实际避免幻读TRANSACTION_SERIALIZABLE❌❌❌最高隔离性能最差为什么READ_COMMITTED是主流默认因为它在数据一致性与并发性能之间取得了最佳平衡。REPEATABLE_READ虽然避免了不可重复读但会增加间隙锁Gap Lock范围导致更多锁冲突SERIALIZABLE则通过强制串行化执行几乎废掉了并发能力。在 JDBC 中设置隔离级别conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);但要注意并非所有数据库都支持全部级别。例如 SQL Server 的READ_UNCOMMITTED等价于NOLOCK提示而 Oracle 只支持READ_COMMITTED和SERIALIZABLE。5.3 Savepoint事务中的“后悔药”当一个大事务包含多个逻辑步骤时你可能希望某一步失败后只回滚到该步骤之前而不是整个事务。Savepoint就是为此设计的conn.setAutoCommit(false); // 步骤1创建用户 ps1 conn.prepareStatement(INSERT INTO user(name) VALUES (?)); ps1.setString(1, Charlie); ps1.executeUpdate(); // 设置保存点 Savepoint sp1 conn.setSavepoint(user_created); // 步骤2为用户分配角色 ps2 conn.prepareStatement(INSERT INTO user_role(user_id, role_id) VALUES (?, ?)); ps2.setLong(1, 1001L); ps2.setLong(2, 201L); ps2.executeUpdate(); // 步骤3发送欢迎邮件假设此处调用外部服务失败 try { sendWelcomeEmail(Charlie); } catch (Exception e) { // 只回滚到保存点保留用户创建丢弃角色分配 conn.rollback(sp1); } conn.commit();Savepoint的本质是数据库在事务日志中标记的一个位置。回滚到保存点时数据库会撤销该点之后的所有更改但保持该点之前的修改有效。这比rollback()更精细是实现复杂业务流程原子性的关键工具。注意Savepoint是连接级别的不能跨Connection对象共享。且某些数据库如 SQLite不支持保存点。6. 异常处理的黄金法则SQLException 的分类与应对策略JDBC 的异常体系以SQLException为核心但它不是简单的“连接失败”或“SQL 错误”。SQLException是一个链式异常Chained Exception其getNextException()方法可以返回关联的其他异常形成异常链。正确处理它是写出健壮 JDBC 代码的前提。6.1 SQLState 与 ErrorCode比 getMessage() 更可靠的诊断依据SQLException提供了两个关键属性getSQLState()遵循 SQL 标准的 5 位字符串代码如08001表示连接拒绝23505表示唯一约束冲突getErrorCode()数据库厂商自定义的整数错误码如 MySQL 的1062表示重复键PostgreSQL 的23505同样表示唯一约束。相比getMessage()可能包含敏感信息或本地化文本SQLState是跨数据库可移植的诊断依据。例如处理唯一约束冲突try { ps.executeUpdate(); } catch (SQLException e) { String sqlState e.getSQLState(); // 标准 SQLState23xxx 系列为完整性约束违规 if (23505.equals(sqlState) || 23000.equals(sqlState)) { // 处理重复数据逻辑如更新而非插入 handleDuplicateKey(); } else { throw e; // 其他异常向上抛 } }6.2 常见异常场景与修复方案异常现象典型 SQLState根本原因推荐处理No suitable driver found08001驱动 JAR 未加载或类名错误检查 classpath确认驱动版本与 JDK 兼容性Connection refused08001数据库服务未启动或网络不通检查数据库进程、防火墙、DNS 解析Lock wait timeout exceeded40001MySQL行锁等待超时优化事务粒度减少长事务添加重试逻辑Deadlock found40001MySQL死锁被数据库自动检测并回滚捕获异常重试事务需幂等设计Data truncation01004字符串截断、数值溢出在PreparedStatement中校验参数长度/范围或调整数据库字段精度经验技巧永远不要用e.getMessage().contains(timeout)这种字符串匹配来判断超时。SQLState是标准化的而错误消息会随数据库版本、语言环境变化。6.3 资源释放的 finally 陷阱try-with-resources 的强制胜利JDBC 资源Connection、Statement、ResultSet必须显式关闭否则会导致连接泄漏、内存溢出。传统写法Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DriverManager.getConnection(url); ps conn.prepareStatement(sql); rs ps.executeQuery(); // 处理结果 } finally { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); // 但 conn.close() 可能抛异常导致 rs/ps 未关闭 }这个finally块存在严重缺陷conn.close()抛异常时rs.close()和ps.close()可能被跳过。Java 7 引入的try-with-resources彻底解决了这个问题try (Connection conn DriverManager.getConnection(url); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理数据 } // 所有资源在此自动关闭按声明逆序且异常被抑制suppressed }try-with-resources要求资源类实现AutoCloseable接口Connection、Statement、ResultSet均已实现。它保证无论 try 块正常结束还是抛异常所有资源的close()方法都会被调用。即使close()本身抛异常也会作为“被抑制异常”附加到主异常上可通过e.getSuppressed()获取。这是现代 Java JDBC 编程的强制规范没有例外。7. 从基础到进阶一个完整的用户管理 Demo 实战现在我们把前面所有知识点整合成一个可运行的、生产级的用户管理模块。它不依赖任何框架只用原生 JDBC但包含了连接池、事务、批量操作、异常处理等全部核心要素。7.1 项目结构与依赖创建一个 Maven 项目pom.xml关键依赖dependencies !-- HikariCP 连接池 -- dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId version5.0.1/version /dependency !-- H2 内存数据库用于演示零配置 -- dependency groupIdcom.h2database/groupId artifactIdh2/artifactId version2.2.224/version /dependency /dependenciesH2 是一个纯 Java 实现的嵌入式数据库启动快、无依赖、支持标准 SQL非常适合 JDBC 教学和单元测试。7.2 数据源配置与连接池初始化public class JdbcConfig { private static final HikariConfig config new HikariConfig(); private static final HikariDataSource dataSource; static { // H2 内存数据库 URL自动建表 config.setJdbcUrl(jdbc:h2:mem:testdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE); config.setUsername(sa); config.setPassword(); // 连接池参数 config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); // 30秒 config.setIdleTimeout(600000); // 10分钟 config.setMaxLifetime(1800000); // 30分钟 // 验证连接有效性 config.setConnectionTestQuery(SELECT 1); dataSource new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void closeDataSource() { if (dataSource ! null !dataSource.isClosed()) { dataSource.close(); } } }注意DB_CLOSE_DELAY-1表示 JVM 退出时不关闭数据库方便多线程测试DB_CLOSE_ON_EXITFALSE防止 JVM shutdown hook 关闭连接。7.3 用户实体与 DAO 接口public class User { private Long id; private String name; private Integer age; private LocalDateTime createdAt; // getters setters... } public interface UserDao { void createTable(); // 创建表结构 Long insert(User user); // 插入单个用户 ListUser batchInsert(ListUser users); // 批量插入 User findById(Long id); // 根据ID查询 int updateAge(Long id, Integer newAge); // 更新年龄 void deleteById(Long id); // 删除 }7.4 核心实现融合所有最佳实践public class UserDaoImpl implements UserDao { Override public void createTable() { String sql CREATE TABLE IF NOT EXISTS user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, age INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ; try (Connection conn JdbcConfig.getConnection(); Statement stmt conn.createStatement()) { stmt.execute(sql); } catch (SQLException e) { throw new RuntimeException(Failed to create table, e); } } Override public Long insert(User user) { String sql INSERT INTO user(name, age) VALUES (?, ?); try (Connection conn JdbcConfig.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); int affected ps.executeUpdate(); if (affected 0) { throw new SQLException(Creating user failed, no rows affected.); } // 获取自增主键 try (ResultSet generatedKeys ps.getGeneratedKeys()) { if (generatedKeys.next()) { return generatedKeys.getLong(1); } else { throw new SQLException(Creating user failed, no ID obtained.); } } } catch (SQLException e) { handleSqlException(e); return null; } } Override public ListUser batchInsert(ListUser users) { if (users.isEmpty()) return Collections.emptyList(); String sql INSERT INTO user(name, age) VALUES (?, ?); try (Connection conn JdbcConfig.getConnection(); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { conn.setAutoCommit(false); // 开启事务 for (User user : users) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.addBatch(); } int[] results ps.executeBatch(); conn.commit(); // 批量提交 // 获取所有生成的主键 ListUser insertedUsers new ArrayList(); try (ResultSet generatedKeys ps.getGeneratedKeys()) { for (int i 0; i

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑