如果你干过几年Java后端,一定会发现一个现象:不管外面吹得再花哨的框架,Hibernate、MyBatis、Spring Data JPA,最后落到数据库那一层,干活的还是JDBC。它就像是Java和关系型数据库之间那座绕不开的桥。JDBC开发实战这件事,往小了说是写几个Dao方法,往大了说,连接管理、事务边界、批处理性能、连接池参数调优,全是靠对JDBC的理解撑起来的。这篇内容就是把我这些年用JDBC写业务、踩坑、优化、排查问题的经验和盘托出,从最基础的API到底是什么,到生产环境连接池怎么配,一条线讲清楚。适合刚接触JDBC的初学者建立整体认知,也适合写了一段时间但总遇到怪问题的同学对照排查,看完你会发现,很多难缠的线上问题,根子其实都在JDBC这一层。
1. 为什么绕不开JDBC:先搞清楚它解决什么问题
1.1 JDBC到底是什么
JDBC全称Java Database Connectivity,翻译过来就是Java数据库连接。我第一次接触这个概念的时候,想不通一个问题:明明各个数据库厂商都有自己的协议,MySQL有MySQL的交互方式,Oracle有Oracle的通信格式,那我写代码的时候,难道还要针对每家的区别做适配吗?JDBC解决的就是这个痛点,它在Java程序和各种数据库驱动之间定义了一套统一的接口规范。
我习惯拿插头来打比方。你在国内买的电器,插头规格是统一的,因为你家的插座就是按国家标准来的。JDBC就是这个“国家标准”,Sun公司定义好一套接口,比如Connection表示连接、Statement表示执行SQL的语句对象、ResultSet表示查询结果集。数据库厂商想要让Java程序能连上自家的数据库,就得提供一套实现了这些接口的驱动包,插到JDBC这套标准插座上就能用。
所以你在写代码的时候,面向的永远是java.sql和javax.sql这两个包下的接口,而不是某个数据库的私有实现。今天用MySQL,只需要引入MySQL的驱动jar包,明天想换PostgreSQL,换掉驱动包、改一下连接URL,业务代码几乎不用动。这就是JDBC最大的价值:屏蔽数据库差异,让Java程序可以以一种统一的方式操作各种关系型数据库。
1.2 ORM框架底层还是JDBC
很多初学者会有个疑问:我直接用MyBatis或者Spring Data JPA不就行了,为什么还要学JDBC?这个想法我能理解,但现实会给你上一课。不管是MyBatis的执行器,还是Hibernate的Session,它们底层都是封装了JDBC的调用。你在XML里写的那条select语句,最终会被框架翻译成PreparedStatement,然后通过JDBC的API发到数据库去执行。
我记得有次排查一个线上慢查询问题,MyBatis打印出来的SQL看着很正常,但就是不走索引。我扒开框架的日志,把PreparedStatement真正执行的那条SQL捞出来,才发现框架在拼接参数的时候,因为传入的类型和字段类型不一致,导致了隐式类型转换,索引直接失效。那一刻我就意识到,如果不理解JDBC这一层参数是怎么绑定的,遇到这类问题你连排查方向都找不到。
另外,像Spring的JdbcTemplate,口号就是“简化JDBC操作”,那它简化的是操作,底层依然是JDBC那套东西。你学会了JDBC,再去看这些框架的源码和设计思路,会觉得豁然开朗。相反,如果你只会用框架,出了问题就像隔着一层雾,执行计划不对、连接池打满、事务没提交,这些现象背后全是JDBC层面的机制在起作用。
1.3 JDBC的架构分层
JDBC整体分为两层。一层是JDBC API,就是Java标准库提供的接口,面向开发者,你写代码只用关心这个。另一层是JDBC Driver API,这是给数据库厂商用的,驱动jar包里的核心类实现Driver接口,负责处理与具体数据库的通信协议。
当你的程序调用DriverManager.getConnection()的时候,实际上做的是:DriverManager会遍历已经加载的Driver实现,逐个询问“你能处理这个URL吗”,对应的驱动认领这个连接请求,然后通过内部协议和数据库建立真正的网络连接。这个过程理解起来很简单,但牵扯出一个关键点:驱动类必须被正确加载,否则DriverManager根本不知道该找谁来建立连接。
在JDBC 4.0之前,你需要手动写Class.forName("com.mysql.jdbc.Driver")来注册驱动。JDBC 4.0之后,驱动jar包里多了一个META-INF/services/java.sql.Driver配置文件,里面写了驱动类的全限定名,DriverManager通过SPI机制自动加载,Class.forName这行就变成可选的了。不过为了兼容老项目,很多连接池和框架在初始化的时候依然会显式加载一次,也不算什么坏习惯。
2. 核心API与连接管理:把地基打牢
2.1 从DriverManager到Connection
Connection是JDBC里最重要的对象,没有之一。它就是你和数据库之间那条活着的网络连接,后面所有的Statement、事务、结果集,全是从它身上派生出来的。获取Connection的标准方式就是通过DriverManager:
Connection conn = DriverManager.getConnection( "jdbc:mysql://localhost:3306/shop_db?useSSL=false&serverTimezone=Asia/Shanghai", "root", "password" );这个URL的格式值得多说几句。jdbc:mysql://是协议头,告诉DriverManager找MySQL的驱动;localhost:3306是数据库的IP和端口;shop_db是库名;后面那一串参数非常关键,useSSL=false是因为本地开发一般不开SSL加密,serverTimezone=Asia/Shanghai是因为新版MySQL驱动要求显式指定时区,不然解析日期时间类型的时候会报错或者差几个小时。
配置连接的常见参数我都整理过一份,实测中要关注的几个:characterEncoding=UTF-8解决中文乱码,connectTimeout和socketTimeout控制建连和读超时时间,rewriteBatchedStatements=true是批量插入性能优化的关键开关,后面我讲批处理的时候会单独展开。
2.2 Statement家族三兄弟
Statement家族有三个成员,新手最容易混淆:Statement是基础款,直接拼接SQL字符串执行;PreparedStatement是预编译款,用占位符?代替参数,预编译后可以重复使用;CallableStatement是专门用来调用存储过程的。业务代码里,Statement基本不应该出现,PreparedStatement才是那个真正的主角。
为什么PreparedStatement能防SQL注入?因为它在发送SQL给数据库的时候,结构已经固定了,参数是通过占位符单独传过去的。数据库会把SQL语句和参数分开处理,参数永远被当作数据,而不是SQL代码的一部分。你自己想想拼接字符串的场景:用户输入一个xxx' OR '1'='1,拼进SQL里就直接改变了查询条件,这就是注入的根源。
PreparedStatement还有几个优势容易被忽视。一是预编译,数据库把SQL解析好存起来,重复执行同一结构的SQL时直接复用执行计划,性能更好。二是类型安全,setString、setInt这些方法会做类型检查,类型传错会在客户端就报出来,而不是到了数据库才报错。三是可读性,SQL模板和参数分离,代码看起来干净得多。
PreparedStatement ps = conn.prepareStatement( "INSERT INTO t_user (username, age) VALUES (?, ?)" ); ps.setString(1, "张三"); ps.setInt(2, 28); ps.executeUpdate();2.3 ResultSet游标机制
执行完查询以后,返回的ResultSet对象是很多人容易搞错的地方。它并不是一次把整张表读进内存,而是一个游标式的访问接口。ResultSet内部维护着一个指向当前数据行的指针,游标初始位置在第一行的前面,你调一次next(),它才往后走一行,返回boolean告诉你还有没有下一行。
try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM t_user WHERE age > ?")) { ps.setInt(1, 18); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { long id = rs.getLong("id"); String username = rs.getString("username"); int age = rs.getInt("age"); } } }理解游标机制有个实际意义:大查询的时候,如果你用rs.next()一格格取数据,那连接和结果集会长时间被占用,如果数据量很大,内存也会被撑爆。生产环境处理大数据量导出时,需要设置fetchSize让驱动分批拉取数据,而不是一次性把几百万行都塞进客户端内存。这个参数在MySQL驱动上的表现有个特殊性,它必须配合Statement方式执行才能生效,这是使用中容易掉进去的坑。
3. 手写一个完整CRUD工程:从建表到服务层
3.1 工程结构和准备
学习JDBC最好的方式,就是放下框架,纯手写一个能跑的CRUD工程。我自己带人的时候,永远让新人先干这件事,写完再碰框架。项目结构不需要复杂,Maven工程,JDK 8以上,引入一个MySQL驱动依赖即可:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>这里注意一个版本坑:新版的MySQL Connector/J,驱动类名已经变成com.mysql.cj.jdbc.Driver,老的那个com.mysql.jdbc.Driver在MySQL 8.0的驱动里被移除了。很多老教程还在用旧类名,照抄就会踩到ClassNotFoundException。
建一张基础的用户表,演示够用就好:
CREATE TABLE `t_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `email` VARCHAR(100), `age` INT, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;工程结构上,我一般建议拆成entity、dao两层就够了。entity是数据载体,字段和表一一对应;dao负责所有JDBC操作,对外暴露的方法和业务无关,只体现数据访问。后面要加service再加,别一开始就套一堆没用的层次。
3.2 DAO层代码怎么写才规范
DAO层最核心的一点,是把连接的获取、资源的释放这些样板代码收敛好。我见过不少新手,在每个方法里写一遍try-catch,最后资源忘关,连接泄漏,线上直接打满。我在实际项目里倾向于用一个工具类管理连接:
public class JdbcUtil { private static final String URL = "jdbc:mysql://localhost:3306/shop_db?characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }JDK 7之后有了try-with-resources语法,资源释放这件事才算真正变简单了。Connection、Statement、ResultSet都实现了AutoCloseable接口,写在try后面的括号里,代码块结束会自动关闭,顺序正好是结果集、语句、连接,不用自己手动在finally里写一堆if (rs != null)的判断。但有个细节要知道:try-with-resources要求所有资源都声明在同一个try里,关闭顺序是逆序的,这个顺序经过设计,刚好不会出问题。
3.3 完整的增删改查示例
接下来是核心的CRUD方法。先说新增,新增操作需要拿到数据库生成的自增主键,这个需求很常见,比如新建订单后立刻用订单号去插入关联的子表。实现方式是prepareStatement的第二个参数填Statement.RETURN_GENERATED_KEYS,执行完插入后,从getGeneratedKeys里取主键:
public Long insert(User user) { String sql = "INSERT INTO t_user (username, email, age) VALUES (?, ?, ?)"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getEmail()); ps.setInt(3, user.getAge()); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { return rs.getLong(1); } } return null; } catch (SQLException e) { throw new RuntimeException("插入用户失败", e); } }删除和修改结构上很类似,唯一的区别是SQL不同。这里记住一个实用原则:更新的SQL里动态拼接条件的时候,是最容易出SQL注入的地方,宁可用多条固定的PreparedStatement分支,也别用字符串拼接去处理动态条件,等框架帮你解决动态SQL问题。
查询部分,按ID查单条是入门必写,查询列表就要注意排序和分页的写法。分页这个点比较微妙,MySQL用LIMIT实现分页逻辑上是正确的,但真要深度分页,即偏移量特别大的时候,比如LIMIT 100000, 10,数据库要扫描前面十万行才能丢掉,性能极差。更优的方案是记录上一页最后一条ID,用WHERE id > ? LIMIT 10,走索引直接定位,这是实战经验,面试也经常问。
public List<User> listPage(long lastId, int limit) { String sql = "SELECT id, username, email, age FROM t_user WHERE id > ? ORDER BY id ASC LIMIT ?"; List<User> list = new ArrayList<>(); try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, lastId); ps.setInt(2, limit); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); user.setAge(rs.getInt("age")); list.add(user); } } } catch (SQLException e) { throw new RuntimeException("分页查询用户失败", e); } return list; }3.4 为什么我坚持先手写再上框架
这里分享一点带新人的个人体会。直接上手MyBatis,确实一周不到就能把单表的增删改查写得飞起,但是一旦碰到批量插入性能上不去、连接池参数不会调、多数据源事务管不住这些问题,就会陷入原地打转的状态。而当你手写过一遍JDBC,你会清楚地看到一条更新语句从Java代码走到MySQL里经历了什么,事务在哪里开启、哪里提交,连接是什么时候占住、什么时候归还的。这些感知,框架给不了你,只有手写完一个完整工程才能建立起来。
4. 事务与批处理:写代码容易,写对代码难
4.1 事务边界到底划在哪里
JDBC里连接对象默认是自动提交模式,每执行一条SQL,数据库就帮你提交一次。这在单条SQL的场景下没毛病,但一旦多条SQL需要作为一个整体成功或者整体失败,就必须手动关掉自动提交,自己控制事务边界。
经典的转账场景是最好的例子。从A账户扣款和给B账户加钱,两条UPDATE必须同时成功或者同时失败,中间任何一步出错,钱都不能少。正确写法是这样的:
Connection conn = JdbcUtil.getConnection(); try { conn.setAutoCommit(false); // 扣款SQL // 加钱SQL conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }有一个细节判断事务是否生效,特别实用:当你调了setAutoCommit(false)后,执行SQL但是一直没有commit,然后你到数据库客户端里看这条数据,是看不到变更的。我拿这个现象给很多朋友解释过“事务未提交数据不可见”的概念。反过来,如果代码里没有commit就想在另一个会话看到更新,那基本等于没开事务或者提交时机不对,排查方向往这里想准没错。
4.2 事务隔离级别,不是越高越好
Connection的setTransactionIsolation可以设置事务隔离级别,日常接触最多的就是读已提交和可重复读两种。MySQL默认是可重复读,Oracle默认是读已提交。可重复读保证同一个事务里两次查询结果一致,但实现代价是间隙锁、范围锁,并发写入冲突的概率更高。
选隔离级别的一个经验是:不要一刀切追求最高级别。可串行化级别确实完全避免了并发问题,但性能代价大到日常业务根本承受不起。大多数业务系统,读已提交就够用了。如果你发现线上死锁特别多,很多人第一反应是加锁,实际上先看一眼隔离级别是不是默认的可重复读,改成读已提交往往就能立竿见影地缓解。
4.3 批量插入性能优化实战
批量插入是JDBC实战里一个绕不开的性能考点。最笨的写法是一条一条循环插入,一万条数据可能要好几分钟,因为每条SQL都要走一次网络往返、一次SQL解析。批量操作的正确姿势是PreparedStatement的addBatch和executeBatch:
String sql = "INSERT INTO t_user (username, email, age) VALUES (?, ?, ?)"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (int i = 0; i < 10000; i++) { ps.setString(1, "user_" + i); ps.setString(2, i + "@test.com"); ps.setInt(3, 20 + (i % 20)); ps.addBatch(); if (i % 500 == 0) { ps.executeBatch(); } } ps.executeBatch(); conn.commit(); }这里有一个MySQL特有的开关,驱动URL上要加rewriteBatchedStatements=true,如果不加这个参数,MySQL驱动会把你的批量操作拆成一条条单条SQL发给服务器,性能提升非常有限。加了之后,驱动会把多条SQL重写成一条多VALUES的INSERT语句,插入一万条的时间能从几分钟降到几秒。
关于批的大小,我实测下来的经验是500条一批比较均衡。太小,比如10条一批,网络往返依然频繁,体现不出批的优势;太大,比如5000条一批,MySQL单条SQL的数据量过大会导致内存压力大,有时候还会触发参数限制。500是一个安全值,不过机器配置不同,自己在测试环境跑分对比一下最靠谱。
5. 连接池:生产环境的必需品而非奢侈品
5.1 为什么不能每次手动建连接
还是那句话,为什么生产环境没人直接写DriverManager.getConnection?因为建立一个数据库连接,底层要完成TCP三次握手、数据库认证、驱动初始化这一整套流程,消耗的时间可能是几毫秒到几十毫秒。业务请求一多,每个请求都现建连接、用完关闭,数据库的资源很快就会被不断建连和断连的操作拖垮。
连接池的思路就是预先创建一批连接放在池子里,业务线程来取现成的,用完不是真正关闭,而是归还到池子,下一个线程接着用。连接是昂贵的资源,池化是最基础的复用手段。这个思路不止适用于数据库连接,线程、HTTP客户端的Keep-Alive都是同一个道理。
5.2 主流连接池怎么选
市面上的连接池组件不少,我自己用过和调研过的有这么几个:HikariCP、Druid、dbcp、c3p0。
| 连接池 | 性能 | 功能丰富度 | 社区活跃度 | 备注 |
|---|---|---|---|---|
| HikariCP | 极快 | 精简 | 高 | Spring Boot默认,轻量可靠 |
| Druid | 快 | 很丰富 | 中 | 阿里出品,自带监控和SQL防火墙 |
| dbcp | 一般 | 一般 | 低 | Apache老牌,新项目基本不选 |
| c3p0 | 偏慢 | 一般 | 低 | 老框架时代产物,维护已停滞 |
新项目不用纠结,直接用HikariCP就好。Spring Boot 2.0之后默认就是它,性能测试常年第一,代码量小,配置简单。如果团队有强烈的监控诉求,比如想看慢SQL、活跃连接数、执行次数,Druid的监控面板确实方便,可以看情况选择。
5.3 HikariCP参数配置背后的逻辑
连接池的配置我觉得比选型更难。配置不当,连接池非但帮不上忙,还可能成为故障源。一个我常用的HikariCP配置示例:
spring: datasource: hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 connection-test-query: SELECT 1各个参数的设计思路,我一个个说。maximum-pool-size不是越大越好。连接池大小其实和数据库服务器的CPU核数强相关,计算公式业界有成熟结论:核心数×2加磁盘IO等待的额外连接数。MySQL单机一般建议10到20,你开200个连接,大部分资源消耗在线程切换和锁竞争上,高并发下数据库IOPS反而下降。
max-lifetime这个参数,你一定要把它设置成小于数据库侧的wait_timeout。MySQL默认wait_timeout是8小时,连接空闲超过8小时会被服务端断开。如果不设max-lifetime,池里的连接可能已经失效了,客户端还不知道,下次取出来执行SQL,会抛连接已断开的异常。HikariCP推荐max-lifetime比wait_timeout短几十秒,让池主动回收老连接,这个细节是排查“连接隔一段时间就断一次”问题最经典的解法。
connection-test-query在HikariCP里其实可以省略,它内部会通过JDBC的isValid或T4通信协议来验证连接可用性。但如果你的数据库代理层或者防火墙对连接健康检查有特殊要求,手动配置连接测试SQL更稳妥。
6. 常见问题与排查技巧实录
6.1 驱动加载失败和找不到合适的驱动
“No suitable driver found for jdbc:mysql://...”这个报错我见过太多次。程序跑起来就报这个错,第一反应检查URL是不是写对了,比如是不是把jdbc:mysql错写成了mysql://。第二检查驱动jar包有没有进到运行时的classpath。第三检查版本兼容,MySQL 8.x的驱动要求JDK 8以上,老项目用JDK 7会直接加载失败。
还有一种隐蔽的可能,用了某些远程开发环境或者容器编排工具,打包的时候把驱动jar排除了。我帮人排查过一次,本地跑得好好的,发到服务器上就报这个错,最后发现是构建配置里exclude了所有*.jar,驱动包被过滤掉了。这类问题,重点怀疑构建产物里有没有驱动,数一下jar包的体积都能看出来。
6.2 中文乱码问题
中文乱码的根因是字符集不一致。客户端发送SQL用的字符集、数据库表存储的字符集、程序读取时用的字符集,三个环节任何一个不对就出乱码。最简单的做法,连接URL上加characterEncoding=UTF-8,建表时显式指定utf8mb4,程序里统一用UTF-8编码,三处对齐基本不会再乱。
值得注意的是,utf8和utf8mb4不是一个东西。MySQL的utf8是阉割版,只支持三个字节的字符,像emoji这种四字节字符存不进去。从MySQL 5.5.3开始有了utf8mb4,才是完整版的UTF-8。新表直接用utf8mb4,不用犹豫。
6.3 连接泄漏:最让人头疼的故障
连接泄漏的直接表现是系统慢、数据库连接数打满、后来无法建立新连接。最常见的病根是,代码里取的Connection没关。就算你写了close,如果写在出错分支后面,异常一抛就跳过了关闭逻辑,照样泄漏。这类问题排查的时候,先看数据库侧:
SHOW PROCESSLIST;如果看到大量Sleep状态的连接,而且host的IP和端口一直不释放,基本可以确认客户端没正常归还连接。代码层面,检查有没有用try-with-resources。如果你在用一个老项目,一堆手动写finally关闭的代码,仔细检查finally里有没有包一层try-catch,close方法本身也可能抛异常导致后面的资源没关掉。连接池的自愈能力有限,泄漏是慢慢钝刀割肉的,等到扛不住的时候,业务往往已经受了很长时间的影响。
6.4 事务不生效的几种隐蔽情况
事务不生效的排查,说几个我实际碰到过的场景。一是连接对象没用同一个。事务是绑定在Connection上的,你在两个方法里分别拿连接,各自执行一条SQL,那这两条SQL根本不在一个事务里,commit和rollback都是各管各的。业务上遇到这种情况,要把连接从方法参数一路传下去,或者用框架的事务管理器统一管理。
二是setAutoCommit(true)的位置不对。有些人在finally里把autoCommit恢复成true,这个动作本身是对的,但它有一个隐式提交行为。如果你在commit之后、close之前执行了setAutoCommit(true),等于又发了一次提交指令,虽然这时候没数据可提,不至于出错。真正危险的是,你把这个恢复动作放在了commit之前,那后面的SQL就全被自动提交了,数据没等整体完成就写进去了。
三是Spring的声明式事务失效。如果你调用了类内部的另一个方法,而这个方法上有@Transactional注解,要明白Spring的事务代理是基于代理对象的,类内部直接用this调用不会经过代理,事务自然不生效。这个机制虽然不属于JDBC API层面的问题,但原因本质上是绕过了代理对象,对JDBC事务的理解越深,越容易想通这个链路。
6.5 关于ResultSet和游标的细节
ResultSet有一个特性,它是依赖Statement和Connection存活的。Connection关了,ResultSet的数据就没法继续读了。所以操作数据库时,不要让一个ResultSet的生命周期超出它所属的连接。比如你在方法里开了连接查了数据,把ResultSet暴露给外部业务代码慢慢遍历,连接一归还或者关闭,读取就会出现异常。
还有一次我遇到过明明查询有数据,但程序查出来的结果却是空的这种诡异问题。排查到最后,发现代码里在同一个连接上先执行了一个更新操作,再把同一个PreparedStatement拿来执行查询,结果返回了更新的影响的记录行数而不是查询结果。这是JDBC的共享状态问题,statement被复用了,执行结果被覆盖。遇到这种问题,优先怀疑有没有方法复用了同一个statement对象。
6.6 时区导致时间字段偏差八小时
这个问题也很有代表性。数据库存的是UTC时间,Java读出来变成了本地时间,或者反过来差八个小时,看似不大,碰上报表统计、定时任务就是致命缺陷。解决办法是在连接URL上明确serverTimezone=Asia/Shanghai,并处理好JVM的默认时区,保持两边一致。
MySQL驱动8.x版本还有个变化,连接属性useTimezone改成了使用实时时区,有些框架配置了旧参数的,升级驱动以后时间处理会出问题。升级驱动之后多写几个测试用例覆盖日期时间的读写,能省去线上踩坑的麻烦。
结尾:我的几点实践体会
这些年JDBC相关的坑,大部分都不是因为JDBC多复杂,而是因为平时只用框架,对底层缺少体感,真正出了事就无从下手。我个人一个习惯性的做法是:不管项目里用什么持久层框架,闲下来时还是会单独开一个干净的工程,用原生JDBC写点东西,写写批处理、测测连接超时参数、试试不同隔离级别下的锁行为。这跟骑自行车一样,平时用不着,但真到关键时候,说上手就能上手,比什么都管用。
给还在学JDBC的朋友一个建议:先把这一条链路——驱动加载、连接建立、SQL执行、结果集读取、连接归还——完整地走一遍,再谈ORM框架的优劣。框架是拿来提效的,不是拿来遮住基础知识的。后续如果想要进阶,可以去看看Spring的JdbcTemplate源码,看看它如何把样板代码收敛、如何做到Callback模式复用,然后再看MyBatis的Executor实现,你会发现底子打的好的话,读这些源码就是顺水推舟的事。