简介:面向Java后端初学者与Spring Boot入门开发者的JDBC连接MySQL实战资料包,围绕在Spring Boot项目中通过JDBC访问数据库这一常见需求,提供可运行的示例源码、SQL脚本与配套环境文件,适合课程实验、毕业设计原型及小型项目起步时对照学习。压缩包共136个文件,约637.42MB,包含3个java源码、5个class编译文件、3个sql脚本,以及大量dll运行库、exe与msi安装程序、xml和properties配置等,兼顾代码示例与依赖环境准备。已有601人学习下载,说明其具备一定的参考与复用价值。读者可据此梳理数据源配置、实体类与控制器调用、建表语句和依赖加载流程,并借助现成目录快速定位源码、脚本与安装文件,减少搭建环境和排查连接问题的时间。
1. 一套能直接跑的 SpringBoot JDBC 连接 MySQL 方案,解决的是哪一段
很多人接手 SpringBoot 项目,业务代码写得挺齐,一到连 MySQL 就卡在Communications link failure或者Public Key Retrieval is not allowed,翻半天文档也说不清是驱动、连接串还是权限的问题。这套带源码和安装包的 JDBC 连接方案,解决的就是从装库、配依赖到跑通增删改查这一整段:驱动坐标怎么选、连接串每个参数什么含义、连接池怎么配、事务和批量写入怎么写、报错怎么定位。它不适合已经天天写 ORM 框架、想找新轮子的人,适合刚接手 SpringBoot 项目、要把老 JDBC 代码搬进新工程、或者第一次独立搭数据访问层的开发者。下面按我拆包的顺序讲。
2. 环境对齐:JDK、驱动坐标、MySQL 三处版本怎么咬合
2.1 版本错配是启动失败的第一大来源
Spring Boot 2.x 和 3.x 在 JDBC 这一层的差别,不在JdbcTemplate的 API,而在两个地方:JDK 基线和 MySQL 驱动的 Maven 坐标。2.7.x 系列以 JDK 8 为基线,驱动坐标是mysql:mysql-connector-java;3.x 系列要求 JDK 17,坐标换成了com.mysql:mysql-connector-j。这两处不统一,Maven 照样能编译通过,运行期直接抛ClassNotFoundException: com.mysql.cj.jdbc.Driver或者No suitable driver found,报错信息还指向连接串,很容易误判。
我一般的做法是先在pom.xml里把spring-boot-starter-parent的版本钉死,再看本地 JDK 的java -version是否满足,最后才去写连接串。顺序反了就是白折腾。
| Spring Boot | JDK 基线 | 驱动坐标 | 驱动类名 |
|---|---|---|---|
| 2.7.x | 8 / 11 | mysql:mysql-connector-java | com.mysql.cj.jdbc.Driver |
| 3.x | 17 | com.mysql:mysql-connector-j | com.mysql.cj.jdbc.Driver |
表里驱动版本统一写 8.0.x,是因为 MySQL 5.7 和 8.0 都能被 8.0 系列驱动连上,而 5.1.x 老驱动连 MySQL 8 会碰到认证插件不兼容。源码包里带的是 8.0.x 驱动,如果你的库是 5.7,不用降驱动,改连接串里的时区和 SSL 参数就行。
2.2 装库、建库、建账号:三行 SQL 定生死
安装包按默认选项装完,root密码设一个自己记得住的,然后打开命令行执行。别用图形工具点,第一次建库建号用命令行最不容易漏参数。
# 登录(-p 后不要直接跟密码,回车后再输入,避免进历史命令) mysql -u root -p # 建库:字符集必须是 utf8mb4,排序规则 8.0 用 0900_ai_ci CREATE DATABASE demo_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; # 建业务账号,别让应用直接用 root CREATE USER 'app_user'@'%' IDENTIFIED BY 'App@123456'; # 只给这一个库的权限,够用即可 GRANT SELECT, INSERT, UPDATE, DELETE ON demo_db.* TO 'app_user'@'%'; FLUSH PRIVILEGES;这几行的逻辑很直白:utf8mb4是为了存得下 Emoji 和生僻字,utf8在三字节上会截断;排序规则utf8mb4_0900_ai_ci只在 MySQL 8.0 存在,5.7 上要用utf8mb4_general_ci,否则建库语句直接报错。'app_user'@'%'里的%表示允许从任意主机连,本地开发图省事可以这么写,真要上生产就得收窄成具体网段。GRANT里我故意没给DROP,是因为见过测试环境一条误执行的DROP TABLE把演示数据清空的场面,权限收窄比事后恢复便宜得多。
建完库立刻验证一次:mysql -u app_user -p -h 127.0.0.1 -P 3306 demo_db。这一步能连上,后面 Java 里连不上就一定是应用侧的问题,排查范围直接砍一半。
2.3 pom.xml 依赖:只引必要的两个
源码包的pom.xml里 JDBC 相关的依赖只有两块:spring-boot-starter-jdbc和 MySQL 驱动。不需要spring-boot-starter-data-jpa,也不需要额外引连接池——starter-jdbc里已经带了 HikariCP。
<dependencies> <!-- 提供 JdbcTemplate、DataSource 自动装配、事务管理器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- 驱动:Boot 2.x 用下面这个坐标;Boot 3.x 换成 com.mysql:mysql-connector-j --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>scope写runtime是有讲究的:业务代码里不该出现任何com.mysql.*的 import,驱动只在运行期通过 SPI 加载。一旦你在代码里Class.forName("com.mysql.cj.jdbc.Driver"),就等于把驱动实现焊死在业务里,将来换库或升驱动都得改代码。常见做法是交给 Spring Boot 的自动装配,它在检测到 classpath 上有驱动、且配置文件里有spring.datasource.url时,自动建好DataSource和JdbcTemplate。
3. 四种连库写法对比:从 DriverManager 到 JdbcTemplate 的取舍与代码
3.1 DriverManager 和 DataSource 到底差在哪
DriverManager.getConnection()每次调用都会新建一条物理连接,用完close()就真断掉。写个定时任务循环一千次,就是一千次 TCP 握手加 MySQL 认证握手,本地测不出来,上了有网络延迟的环境就是灾难。DataSource是连接池的抽象,getConnection()是从池里借一条,close()是还回去,物理连接复用。
这也是为什么源码包里那条“原生 JDBC 演示”只作为对照存在,真正对外的是走DataSource的JdbcTemplate。选型上没有悬念:只要有并发,就必须用池化数据源。唯一适合DriverManager的场景是一次性数据迁移脚本,跑完就退出的那种。
3.2 连接串参数逐项拆开看
application.yml里那一行长 URL,是坑最密集的地方。逐项拆:
| 参数 | 建议值 | 作用与不写会怎样 |
|---|---|---|
serverTimezone | Asia/Shanghai | 不写时,驱动会拿服务端时区,遇到CST这类有歧义的缩写直接抛The server time zone value is unrecognized |
characterEncoding | utf8 | 配合useUnicode=true,保证中文不乱码 |
useSSL | false | 本地无证书必须关,否则启动要握证书,日志里刷一堆警告 |
allowPublicKeyRetrieval | true | MySQL 8 默认caching_sha2_password,不开这个本地连不上 |
rewriteBatchedStatements | true | 批量插入时把多条 INSERT 合成一条多值语句,性能差一个量级 |
connectTimeout/socketTimeout | 3000/60000 | 不设会一直挂着,故障时线程池被拖死 |
zeroDateTimeBehavior | convertToNull | 遇到0000-00-00不设会抛Zero date value prohibited |
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true&connectTimeout=3000&socketTimeout=60000 username: app_user password: App@123456 driver-class-name: com.mysql.cj.jdbc.Driver注意&在 YAML 里不需要转义,但如果你把这段放进.properties,就得原样写,别加反斜杠。另外driver-class-name在 Boot 2.x 之后其实可以省略,自动装配能推断出来;写上的好处是启动日志里能一眼确认驱动是否被正确加载。
3.3 JdbcTemplate 的增删改查:把主键和映射写对
JdbcTemplate的核心就三类方法:update管写,queryForObject管单条,query管列表。真正容易写错的是自增主键回填和字段映射。
@Repository public class OrderRepository { private final JdbcTemplate jdbcTemplate; // 构造器注入,Spring 会自动把容器里的 JdbcTemplate 传进来 public OrderRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } /** 插入并回填自增主键,keyHolder 必须配合 RETURN_GENERATED_KEYS */ public Long insert(Order order) { String sql = "INSERT INTO t_order(order_no, amount, status) VALUES (?, ?, ?)"; KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(conn -> { PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setString(1, order.getOrderNo()); ps.setBigDecimal(2, order.getAmount()); ps.setInt(3, order.getStatus()); return ps; }, keyHolder); Number key = keyHolder.getKey(); return key == null ? null : key.longValue(); } /** 显式 RowMapper,按列名取值,不依赖 SELECT 的字段顺序 */ public Order findByNo(String orderNo) { String sql = "SELECT id, order_no, amount, status, created_at FROM t_order WHERE order_no = ?"; return jdbcTemplate.queryForObject(sql, ROW_MAPPER, orderNo); } /** 更新,返回受影响行数,业务层据此判断是否真的改到了 */ public int updateStatus(Long id, int status) { return jdbcTemplate.update("UPDATE t_order SET status = ? WHERE id = ?", status, id); } private static final RowMapper<Order> ROW_MAPPER = (rs, rowNum) -> { Order o = new Order(); o.setId(rs.getLong("id")); o.setOrderNo(rs.getString("order_no")); o.setAmount(rs.getBigDecimal("amount")); o.setStatus(rs.getInt("status")); Timestamp ts = rs.getTimestamp("created_at"); o.setCreatedAt(ts == null ? null : ts.toLocalDateTime()); return o; }; }逻辑上说清楚三件事。第一,update(PreparedStatementCreator, KeyHolder)这个重载才是回填主键的正确姿势,很多示例用SimpleJdbcInsert,功能没问题但多引一个类,不如这个直接。第二,RowMapper按列名取值,rs.getLong("id")里的字符串必须和 SELECT 里的别名一致,写成SELECT id AS order_id就得改成getLong("order_id"),这一点和按索引取值不同。第三,金额字段用BigDecimal接,用double接会丢精度,这是财务类字段的老坑。
参数说明上再补一句:queryForObject在查不到数据时抛EmptyResultDataAccessException,不是返回 null。业务层要么 try-catch,要么改用query(...)拿 List 再判断isEmpty()。我一般后者,代码干净。
3.4 事务边界:@Transactional 失灵的三种典型情况
@Transactional挂在方法上就生效,这是错觉,它依赖 Spring 的 AOP 代理。三种情况必失效:方法不是public;同类内部this.xxx()自调用,不走代理;异常被自己 catch 掉没往外抛。默认情况下只有RuntimeException和Error触发回滚,抛Exception得显式写@Transactional(rollbackFor = Exception.class)。
@Service public class OrderService { private final OrderRepository repository; public OrderService(OrderRepository repository) { this.repository = repository; } // rollbackFor 必须显式写,否则受检异常不回滚 @Transactional(rollbackFor = Exception.class) public void createAndPay(Order order) throws Exception { Long id = repository.insert(order); repository.updateStatus(id, 1); if (order.getAmount().signum() < 0) { throw new Exception("金额非法"); // 触发回滚,两条 SQL 一起撤销 } } }还有个细节:事务方法里如果调了远程接口,长事务会一直占着连接,池子很快见底。我的习惯是把远程调用挪到事务外,事务里只包数据库操作。
4. 排查与避坑:五条连不上库的真实现场和处理顺序
4.1 报错信息到根因的对照表
先给一张对照表,绝大多数启动失败都能在里面找到定位方向。看报错不要看第一行,要看Caused by最下面那一层,那才是根因。
| 报错关键字 | 大概率根因 | 先查什么 |
|---|---|---|
Communications link failure | 服务没起、端口不通、防火墙 | telnet 127.0.0.1 3306通不通 |
Access denied for user | 账号密码错、Host 不匹配 | 账号是不是'user'@'%',密码有没有特殊字符 |
Public Key Retrieval is not allowed | MySQL 8 认证插件 | URL 有没有allowPublicKeyRetrieval=true |
Unknown database | 库名拼错、库没建 | SHOW DATABASES;对一遍 |
The server time zone value ... is unrecognized | 时区参数缺失 | 加serverTimezone=Asia/Shanghai |
No suitable driver found | 驱动坐标错、JDK 版本不匹配 | 看mvn dependency:tree里有没有驱动 |
4.2 五条踩坑记录,每条都是真金白银
现象一:本地 IDEA 里跑得好好的,打成 jar 扔到服务器上启动就Communications link failure。原因:配置文件里写的是localhost,容器或服务器上localhost解析到的地址和 MySQL 实际监听的不是一回事,加上 MySQL 默认只监听127.0.0.1。解决:URL 改成明确的127.0.0.1:3306或实际内网地址,同时确认my.cnf里bind-address的值,两边对齐再重启。
现象二:批量插入一万条数据,耗时接近 40 秒,慢得离谱。原因:URL 里没加rewriteBatchedStatements=true,驱动把batchUpdate拆成一条条发出去,等于一万次网络往返。解决:加上这个参数,注意它只对INSERT/UPDATE的预编译语句生效,且 SQL 里不能有;结尾,否则重写会失败。
现象三:应用跑了一夜,第二天所有请求超时,日志刷HikariPool - Connection is not available, request timed out。原因:连接池maxLifetime默认 30 分钟,而 MySQL 服务端wait_timeout是 8 小时,本来不该冲突;但中间挂了代理或防火墙会静默掐掉空闲连接,池子拿到的是死连接。解决:把maxLifetime调到比中间设备空闲超时更短(比如 10 分钟),并开启connection-test-query或用 JDBC4 的isValid探活。
现象四:中文写进去变成问号。原因:建库时用了utf8,或者连接串漏了characterEncoding=utf8,也可能是表级字符集没跟着库走。解决:库、表、连接串三处统一utf8mb4,SHOW CREATE TABLE逐张确认,历史表用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4补。
现象五:@Transactional加了但异常后数据没回滚。原因:方法内部try-catch把异常吞了,或者同类内部调用绕过了代理。解决:异常往上抛,或者手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();自调用改成注入自身代理或拆到另一个 Bean。
4.3 日志和诊断开关怎么开
排查连库问题,把这两行加到application.yml,日志里能看到池子的创建、借出、归还全过程:
logging: level: org.springframework.jdbc.core: DEBUG # 打印实际执行的 SQL 和参数 com.zaxxer.hikari: DEBUG # 打印连接池状态 com.mysql.cj.jdbc: DEBUG # 驱动层握手细节JdbcTemplate的 DEBUG 日志会把参数值原样打出来,开发期很好用,上线前必须调回 INFO,否则等于把手机号、金额这些字段全写进日志文件。还要注意,日志级别调太高会明显拖慢批量操作,压测时关掉。
5. 连接池与批量写入:HikariCP 参数和 rewriteBatchedStatements 的边界
5.1 HikariCP 的关键参数怎么定
HikariCP 是starter-jdbc的默认池,默认值对演示够用,对生产往往不合适。最常被误解的是maximumPoolSize:调大不等于变快。数据库的连接数是有限资源,单实例 MySQL 常见配置也就几百,应用侧每个实例开 20 个连接已经不算少。合理值大致是「数据库最大连接数 ÷ 应用实例数,再留 20% 余量」。
spring: datasource: hikari: pool-name: demo-pool maximum-pool-size: 20 # 上限,超过就排队 minimum-idle: 5 # 常驻空闲连接,避免突发时现建 connection-timeout: 3000 # 借连接等待上限,超时抛异常而不是无限等 idle-timeout: 600000 # 空闲 10 分钟回收 max-lifetime: 600000 # 必须小于 MySQL wait_timeout connection-test-query: SELECT 1 # 老驱动探活用;JDBC4 驱动可省connection-timeout我一般设 3 秒而不是默认的 30 秒。业务请求等 30 秒才报错,用户体验和线程占用都是双输,早点失败早点降级。max-lifetime设为 10 分钟,是给中间网络设备留出安全边界,同时也别忘了 MySQL 侧wait_timeout的对照。
5.2 批量写入:batchUpdate 的正确打开方式
单条update循环一万次,和batchUpdate一次发一万条,差距在rewriteBatchedStatements=true的前提下能到十倍以上。前提是这个参数真的生效了,判断办法就是看驱动 DEBUG 日志里是否出现合并后的多值 INSERT。
public int[] batchInsert(List<Order> orders) { String sql = "INSERT INTO t_order(order_no, amount, status) VALUES (?, ?, ?)"; return jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { Order o = orders.get(i); ps.setString(1, o.getOrderNo()); ps.setBigDecimal(2, o.getAmount()); ps.setInt(3, o.getStatus()); } @Override public int getBatchSize() { return orders.size(); } }); }batchUpdate返回的int[]是每行受影响行数,注意 MySQL 在开启重写后这个数组可能被合并成一个元素,别拿它跟入参条数强行对应。另外批量不要一次塞十万条,常见做法是每 1000 条切一批,事务也按批提交,既控内存也控锁持有时间。
5.3 大结果集:别把整张表读进内存
导出类需求最容易翻车。query一次性把结果集映射成对象列表,几十万行直接 OOM。要用RowCallbackHandler流式处理:
public void exportAll(Consumer<Order> consumer) { String sql = "SELECT id, order_no, amount, status FROM t_order"; jdbcTemplate.query(sql, rs -> { // 不返回 List,逐行回调 Order o = new Order(); o.setId(rs.getLong("id")); o.setOrderNo(rs.getString("order_no")); o.setAmount(rs.getBigDecimal("amount")); o.setStatus(rs.getInt("status")); consumer.accept(o); }); }回调式查询本身不会构建全量列表,但 MySQL 驱动默认仍会把整个结果集拉到客户端。要真正边读边处理,得在连接串加useCursorFetch=true并在 Statement 上设fetchSize,或者用setFetchSize(Integer.MIN_VALUE)走流式模式。流式期间这条连接不能做别的操作,所以别放在事务里长时间持有,导出任务最好单独配一个数据源。
6. 换成自己的库:三步验证法与一个自检接口
拿到源码包先别动业务代码,我一般按三步验证。第一步,把application.yml里的url、username、password换成自己的,启动后盯这行日志:HikariPool-1 - Start completed.,出现说明池子建起来了,没出现就是连接串或权限的问题,这时候翻第 4 章那张对照表最快。第二步,确认能读:写个临时接口调一次SELECT 1,顺便验证JdbcTemplate是否被正确注入。第三步,确认能写且能回滚:手动抛一次异常,看数据是否真的没落库。
源码包里带了个自检接口,思路很简单,用CommandLineRunner在启动时跑一次轻量探测:
@Component public class DbHealthCheck implements CommandLineRunner { private final JdbcTemplate jdbcTemplate; public DbHealthCheck(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void run(String... args) { Integer one = jdbcTemplate.queryForObject("SELECT 1", Integer.class); String version = jdbcTemplate.queryForObject("SELECT VERSION()", String.class); System.out.println("db ok=" + one + ", server=" + version); } }这段代码只做两件事:验证连接可用、确认连到的是哪个服务端版本。别小看第二项,本地 MySQL 5.7 和测试环境 8.0 的时区、认证插件行为都不一样,先把版本打出来能省掉一大半「本地好好的」类问题。验证通过后再把这段删掉或加开关,别让它跟着上生产。
从那以后我每次接入一套新库,都强制走这三步再动业务代码,顺序反过来的次数多了,吃过亏就记住了。希望帮到你。
本文还有配套的精品资源,点击获取