资讯详情

Spring Boot JDBC 数据访问与连接池实战

📅 2026/11/2 11:24:40 | 华诺云谱 👁 阅读
Spring Boot JDBC 数据访问与连接池实战
简介面向Java后端初学者与需要快速落地数据库连接的开发者这份Spring Boot整合JDBC访问MySQL的成套资料把环境准备、依赖配置、实体与DAO编写、连接测试等环节串成一条完整链路适合课程实验、毕业设计或小型项目起步时对照实践。压缩包共136个文件、约637MB除java源码、class编译文件、properties配置与sql脚本外还附带dll、exe、msi等Windows运行库和安装包以及少量php、html示例页方便在缺少驱动或运行环境时直接补齐减少搭建阶段的琐碎配置。整体以可运行工程为核心资料分散在源码、脚本与运行库几类文件中检索时按目录归类即可。已有601人学习下载可作为从建库建表到接口调用的参考样本源码中的控制器、实体类与配置文件相互对应配合SQL脚本能较快还原可运行工程也便于排查驱动缺失、连接串错误等常见问题。1. 从一次「查不出是谁发的慢 SQL」说起表结构只有五六张、SQL 全是手写的却非要套一层 ORM最后连一条慢查询是谁发的都定位不到——这是我在评审课程设计时见得最多的一类返工。这套方案反着走Spring Boot 只负责装配与生命周期MySQL 访问全部交给 JDBC用JdbcTemplate收口参数绑定用 HikariCP 管连接一条 SQL 从 Controller 到数据库全程可见。它适合三类人要交数据库课程设计、需要一套能直接跑起来的最小可运行骨架的学生刚学完 JDBC 想看看它在工程里怎么被组织起来的初学者以及想把 ORM 换掉做一次性能对照的开发者。整套代码按 controller–service–dao 三层组织换掉表名就能改成自己的业务。2. 技术选型与总体架构三个依赖、四层结构与一条调用链先把边界划清楚。这套方案不引入 JPA不引入 MyBatis也不引 MyBatis-Plus原因不是它们不好而是这个场景里它们会把「SQL 什么时候执行、执行了几次、绑定的是什么值」这三件事藏起来。课程设计要答辩业务系统要排查恰恰都需要看见这三件事。JDBC 把控制权还给开发者代价是要自己写 RowMapper、自己管事务边界这个代价在五六张表的规模里完全可控。2.1 依赖清单三个 jar 决定整套方案的边界一份pom.xml里真正必需的只有三块Web 起步依赖负责把请求接进来JDBC 起步依赖负责把数据源和JdbcTemplate装起来MySQL 驱动负责把 JDBC 协议翻译成 MySQL 的通信协议。其余像 Lombok、Validation 都是可选项加不加不影响主链路能不能跑通。dependencies !-- Web 层提供内嵌 Tomcat 与 RestController -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JDBC 起步依赖自带 HikariCP 连接池与 JdbcTemplate无需再单独引 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency !-- MySQL 驱动8.x 之后坐标由 mysql:mysql-connector-java 改为 com.mysql:mysql-connector-j -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这段依赖里有两个容易翻车的细节值得单独说。第一spring-boot-starter-jdbc已经传递引入了 HikariCP再手动引一次HikariCP会出现版本冲突启动时可能报NoSuchMethodError。第二MySQL 驱动的坐标在 8.x 之后换过网上很多老教程还写着mysql:mysql-connector-java照抄下来在较新的 Spring Boot 里会拿不到驱动表现为启动时报Failed to determine a suitable driver class。这里还刻意没有写version因为版本由spring-boot-starter-parent统一管理手写版本号反而容易和父 POM 管理的版本打架——这条血泪经验在换 Spring Boot 大版本时尤其明显。2.2 分层结构与包组织四层职责与一次请求的完整链路分层不是为了好看是为了让「谁负责拼 SQL」这件事有唯一答案。我一般按下面这个结构铺包每个包的职责在代码评审时可以直接对照包路径职责关键类不该做的事controller接收 HTTP 参数、返回统一响应体StudentController不写 SQL、不开事务service业务校验、事务边界、跨 DAO 编排StudentService不出现JdbcTemplatedao拼 SQL、绑定参数、映射结果集StudentDao不做业务判断、不吞异常entity与表结构一一对应的数据载体Student不放业务方法config数据源、事务管理器等显式装配DataSourceConfig不写查询逻辑一次GET /api/students/S2024001的完整链路是这样的Controller 收到路径参数后转给 ServiceService 做参数合法性校验必要时开启事务DAO 拿到参数用?占位符拼出SELECT ... WHERE student_no ?JdbcTemplate通过DataSource从连接池借一条连接调用PreparedStatement完成参数绑定MySQL 驱动把这条语句发到服务端返回ResultSetRowMapper逐行把列值塞进实体方法结束后连接自动归还池中而不是被物理关闭。这个链路里我最想强调的一点是DAO 的方法签名永远不返回ResultSet也不返回Connection。一旦让ResultSet泄漏到 Service 层连接归还的时机就变得不可控而连接池最怕的就是「借了不还」。JdbcTemplate的价值就在于它把try-finally里的close()全包了你只要不手动去拿DataSource.getConnection()就不会出问题。2.3 连接池为什么默认是 HikariCP五个参数怎么读Spring Boot 在检测到类路径上有 HikariCP 时会自动装配HikariDataSource不需要写任何Bean。要改参数直接在spring.datasource.hikari.*下面写就行。五个参数里真正需要动脑子的只有两个maximum-pool-size决定池子上限。常见的错误直觉是「数据库能扛多少就配多少」实际上连接数超过数据库的活跃工作线程数之后多出来的连接只会在服务端排队反而拉高 RT。一个够用的估算方式是目标 QPS × 单条 SQL 平均耗时秒÷ 0.8。比如目标 300 QPS、平均 8 毫秒算下来约 3 条并发连接配 10 已经留了足够余量。课程设计这种规模10 到 20 之间随便选。max-lifetime必须小于 MySQL 服务端的wait_timeout。MySQL 默认 28800 秒也就是 8 小时会主动掐掉空闲太久的连接如果池子里的连接活得比它久就会出现「拿到的连接其实已经断了」的情况报错信息通常是Communicating with the server或者No operations allowed after connection closed。HikariCP 默认max-lifetime是 30 分钟本身是安全的但如果你为了「减少重连」把它调到 10 小时就正好踩在这个坑上。connection-timeout是借连接的等待上限默认 30 秒。这个值在排查连接泄漏时特别关键——调小到 3 秒能让泄漏问题在压测阶段立刻暴露而不是等到线上用户投诉。minimum-idle和idle-timeout影响的是冷启动后的第一批请求延迟非高并发场景保持默认即可。2.4 自动装配的前提DataSource 是怎么被注入进来的很多人写完了能跑但说不清JdbcTemplate是从哪来的。机制是这样的DataSourceAutoConfiguration在容器里没有自定义DataSourceBean、且类路径存在数据库驱动时读取spring.datasource.*构造一个HikariDataSource紧接着JdbcTemplateAutoConfiguration检测到容器里有DataSource就再注册一个JdbcTemplate和一个NamedParameterJdbcTemplate。理解了这条链两个结论就顺理成章第一单数据源场景下一个Bean都不用写写多了反而可能覆盖掉自动装配第二只有在多数据源、或者要接连接池监控的时候才需要自己写Configuration加ConfigurationProperties(spring.datasource.hikari)去手工构造。后者属于进阶场景课程设计阶段不建议一上来就搞多数据源先把单库跑通。3. 数据源配置从 JDBC URL 到启动自检的完整闭环配置写错是 JDBC 方案里最高频的失败原因而且报错信息往往指向性很差——时区问题报的是驱动内部异常字符集问题表现为一堆问号连接池问题要等 30 秒才报。这一章的做法是把配置拆成「URL 参数」「池参数」「敏感信息」「启动自检」四块每块都能单独验证。3.1 JDBC URL 拆解五个必填参数与不写会怎样spring: datasource: # 127.0.0.1 而不是 localhost避免部分环境优先解析成 IPv6 导致连接被拒 url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: demo password: ${DB_PASSWORD:demo123} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 # 池子上限按 QPS 估算后留余量 minimum-idle: 5 # 常驻空闲连接数减少冷启动抖动 connection-timeout: 3000 # 借连接最多等 3 秒便于尽早暴露泄漏 idle-timeout: 600000 # 空闲 10 分钟后回收 max-lifetime: 1740000 # 29 分钟必须小于 MySQL wait_timeout connection-test-query: SELECT 1URL 后面的五个参数每一个都对应一类具体的故障不是可有可无的装饰参数作用不写时的典型表现useUnicodetrue启用 Unicode 编码传输中文写入后变成???characterEncodingutf8指定客户端字符集同上且 emoji 类字符直接插入失败serverTimezoneAsia/Shanghai告诉驱动服务端时区报The server time zone value ... is unrecognizeduseSSLfalse本地开发关闭 TLS启动慢日志里刷一堆 SSL 警告allowPublicKeyRetrievaltrue允许 8.0 默认加密插件取公钥报Public Key Retrieval is not allowedcharacterEncoding里写utf8还是utf8mb4有讲究。MySQL 的utf8实际是utf8mb3最多三个字节存不下 emoji 和部分生僻字建库建表时如果用的是utf8mb4连接参数也建议统一写成utf8mb4免得出现「表能存、连接传不过去」的错位。提示useSSLfalse只适合本机或内网开发环境。真要往外网暴露这条必须去掉并配上证书否则等于把账号密码明文扔在网络上。3.2 连接池参数与服务端超时的对齐连接池和 MySQL 服务端是两套独立的超时体系任何一边的假设错了都会在「空闲一段时间后的第一次请求」上炸出来。除了前面说的max-lifetime要小于wait_timeout还有一对参数值得对齐connection-timeout要小于上游的接口超时。如果你的 Controller 层设置了 5 秒超时而池子借连接要等 30 秒那请求早就被上层掐断了日志里只剩一个孤零零的Connection is not available看不出真正原因。验证方式很直接把maximum-pool-size临时改成 1然后用两个线程并发压同一接口第二个请求必然在connection-timeout之后失败。这个练习能让你亲眼看到「池子耗尽」长什么样比看十篇博客都管用。3.3 密码外置与环境切换别把生产密码提交进仓库${DB_PASSWORD:demo123}这种写法表示优先读环境变量DB_PASSWORD没有则用冒号后的默认值。本地开发图省事可以直接写死但只要代码要提交到公开仓库就必须走外置。做法有两种按项目复杂度选轻量的做法是用 Spring 的 profile 拆文件application-dev.yml和application-prod.yml各写各的启动时用--spring.profiles.activeprod切换敏感项仍然用环境变量占位。重一点的做法是接配置中心课程设计用不上。还有一个常被忽略的点spring.datasource.username和password如果同时出现在配置文件和启动命令里优先级是命令行高于配置文件。答辩演示时如果要临时连到另一台机器直接加--spring.datasource.url...覆盖即可不用改代码。3.4 启动自检用 ApplicationRunner 打一条真实 SQL配置对不对不要靠「启动没报错」来判断——Tomcat 起来了但数据源没通的情况太常见了因为 HikariCP 默认是懒初始化池子要到第一次借连接时才真正建立物理连接。稳妥的做法是加一个启动探针在应用启动完成时主动打一条 SQLComponent public class DataSourceProbe implements ApplicationRunner { private static final Logger log LoggerFactory.getLogger(DataSourceProbe.class); private final JdbcTemplate jdbcTemplate; public DataSourceProbe(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Override public void run(ApplicationArguments args) { // 服务端版本确认驱动与服务端能正常握手 String version jdbcTemplate.queryForObject(SELECT VERSION(), String.class); // 当前连接的库名确认 URL 里的 database 拼对了 String schema jdbcTemplate.queryForObject(SELECT DATABASE(), String.class); // 连接池运行时快照确认池参数真的生效 HikariDataSource ds jdbcTemplate.getDataSource() instanceof HikariDataSource ? (HikariDataSource) jdbcTemplate.getDataSource() : null; log.info(数据源就绪server{}, schema{}, poolName{}, maxPool{}, version, schema, ds null ? unknown : ds.getPoolName(), ds null ? -1 : ds.getMaximumPoolSize()); } }这段代码在ApplicationRunner里执行意味着它在容器完全初始化之后、对外提供服务之前运行失败会直接让启动中断属于「快速失败」的做法。三个查询各有分工VERSION()验证协议层握手DATABASE()验证 URL 里的库名拼写getMaximumPoolSize()验证 YAML 里的池参数是否被正确绑定——最后这条经常能揪出「配置写在了错误的层级下」这类问题比如把hikari写成和url平级配置会被静默忽略。4. 数据访问层实现建表、CRUD、批量与事务的六段代码前面两章解决「连得上」和「配得对」这一章解决「用得稳」。DAO 层的代码量不大但每一段都有明确的写法约定照着写能避开大部分运行时问题。4.1 建表脚本与实体字段类型对齐是第一步CREATE TABLE t_student ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, student_no VARCHAR(20) NOT NULL COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, score DECIMAL(5,2) DEFAULT NULL COMMENT 成绩, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 学生表;实体字段类型要和列类型对得上BIGINT UNSIGNED用LongDECIMAL用BigDecimal别用double成绩算出来会出现89.99999999这种DATETIME用LocalDateTime。student_no上建唯一索引不只是为了查询快更重要的是让「重复插入」在数据库层被拦住而不是靠 Service 里的if (exists)判断——后者在并发下必然失效。4.2 写入update 的返回值就是影响行数Repository public class StudentDao { private final JdbcTemplate jdbcTemplate; public StudentDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } /** 返回受影响行数1 表示成功0 表示条件未命中 */ public int insert(Student s) { String sql INSERT INTO t_student(student_no, name, score) VALUES (?, ?, ?); return jdbcTemplate.update(sql, s.getStudentNo(), s.getName(), s.getScore()); } public int updateScore(String studentNo, BigDecimal score) { String sql UPDATE t_student SET score ? WHERE student_no ?; return jdbcTemplate.update(sql, score, studentNo); } public int deleteByNo(String studentNo) { return jdbcTemplate.update(DELETE FROM t_student WHERE student_no ?, studentNo); } }JdbcTemplate.update的返回值是int就是这条语句实际影响的行数。这个值必须被 Service 层用起来updateScore返回 0 意味着学号不存在此时应该抛业务异常让事务回滚而不是默默返回成功让前端以为改好了。我见过不少课程设计把返回值直接丢掉结果「修改不存在的记录」这种用例在答辩现场一测就露馅。参数绑定用的是可变参数顺序必须和 SQL 里的?严格一致。BigDecimal和LocalDateTime驱动都支持直接传不需要手动转字符串。4.3 查询三种 API 的适用边界private static final RowMapperStudent ROW_MAPPER (rs, rowNum) - { Student s new Student(); s.setId(rs.getLong(id)); s.setStudentNo(rs.getString(student_no)); // 列名用下划线和表结构一致 s.setName(rs.getString(name)); s.setScore(rs.getBigDecimal(score)); Timestamp ts rs.getTimestamp(create_time); s.setCreateTime(ts null ? null : ts.toLocalDateTime()); // 判空防止 NPE return s; }; /** 查单条查不到会抛 EmptyResultDataAccessException调用方必须接住 */ public Student findByNo(String studentNo) { String sql SELECT id, student_no, name, score, create_time FROM t_student WHERE student_no ?; return jdbcTemplate.queryForObject(sql, ROW_MAPPER, studentNo); } /** 查列表无结果返回空集合而不是 null调用方可以直接 for */ public ListStudent findByScoreGreaterThan(BigDecimal min) { String sql SELECT id, student_no, name, score, create_time FROM t_student WHERE score ? ORDER BY score DESC; return jdbcTemplate.query(sql, ROW_MAPPER, min); } /** 查单个标量count、sum 这类聚合走这里 */ public int countAll() { Integer n jdbcTemplate.queryForObject(SELECT COUNT(*) FROM t_student, Integer.class); return n null ? 0 : n; }三个 API 的分工要记牢queryForObject用于「确定有且仅有一条」的场景多一条少一条都会抛异常query用于列表查不到返回空List而不是nullqueryForObject(sql, Integer.class)用于聚合。最常见的翻车是把queryForObject用在「可能查不到」的地方然后被EmptyResultDataAccessException打断。处理方式有两种捕获异常返回null或者干脆改成query(...)拿到List再判空。我倾向前者写在 DAO 内部对外暴露OptionalStudent或直接返回null让上层不用关心 Spring 的异常类型。RowMapper写成static final常量避免每次查询都新建匿名类。列名用字符串字面量而非列索引rs.getLong(1)这种写法在 SQL 增删字段后会静默错位。4.4 批量插入与主键回填/** 批量插入返回实际执行的批次数组长度等于批大小 */ public int batchInsert(ListStudent list) { if (list null || list.isEmpty()) { return 0; } String sql INSERT INTO t_student(student_no, name, score) VALUES (?, ?, ?); int[] rows jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { Override public void setValues(PreparedStatement ps, int i) throws SQLException { Student s list.get(i); ps.setString(1, s.getStudentNo()); ps.setString(2, s.getName()); ps.setBigDecimal(3, s.getScore()); } Override public int getBatchSize() { return list.size(); } }); return rows.length; } /** 单条插入并回填自增主键 */ public Long insertAndReturnId(Student s) { String sql INSERT INTO t_student(student_no, name, score) VALUES (?, ?, ?); KeyHolder keyHolder new GeneratedKeyHolder(); jdbcTemplate.update(conn - { PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setString(1, s.getStudentNo()); ps.setString(2, s.getName()); ps.setBigDecimal(3, s.getScore()); return ps; }, keyHolder); Number key keyHolder.getKey(); return key null ? null : key.longValue(); }批量插入的性能收益来自两点预编译语句复用、多次往返合并成一次网络交互。三千条记录逐条插可能要几秒用batchUpdate通常在百毫秒级。但要注意批量不是越大越好一次塞一万条容易超过max_allowed_packet实际每批 500 到 1000 条比较稳。主键回填这里有个坑keyHolder.getKey()在「批量插入」或「自增列不止一个」的场景会抛InvalidDataAccessApiUsageException因为返回的是多行多列此时必须换成keyHolder.getKeyList()取列表。4.5 事务注解生效的四个前提Service public class ScoreImportService { private final StudentDao studentDao; private final ScoreLogDao scoreLogDao; public ScoreImportService(StudentDao studentDao, ScoreLogDao scoreLogDao) { this.studentDao studentDao; this.scoreLogDao scoreLogDao; } /** rollbackFor 显式指定 Exception否则受检异常不会触发回滚 */ Transactional(rollbackFor Exception.class) public int importScores(ListStudent list) { int ok 0; for (Student s : list) { studentDao.insert(s); // 第一条 DML scoreLogDao.insert(s.getStudentNo(), IMPORT); // 第二条 DML ok; } return ok; } }Transactional想生效四个条件缺一不可类必须被 Spring 管理Service或Component、方法必须是public、调用必须从代理对象进入同类内部this.method()调用不经过代理注解形同虚设、异常类型必须命中回滚规则。默认只回滚RuntimeException和Error所以业务里抛受检异常时一定要写rollbackFor Exception.class这是最省后悔药的一行配置。另外事务范围要贴着「必须一起成功」的边界划。上面这个方法把「插学生」和「写日志」放在一个事务里是合理的因为它们要么都成、要么都不成。但如果循环里还夹着调用外部接口就不该塞进同一个事务——外部调用可能几十秒长时间持有连接会把池子拖干。4.6 分页与 SQL 注入两条硬规则/** 物理分页LIMIT 在前 OFFSET 在后参数顺序不能反 */ public ListStudent page(int pageNo, int pageSize) { String sql SELECT id, student_no, name, score FROM t_student ORDER BY id DESC LIMIT ? OFFSET ?; return jdbcTemplate.query(sql, ROW_MAPPER, pageSize, (pageNo - 1) * pageSize); }LIMIT ? OFFSET ?的参数是先 limit 后 offset写反了不会报错只会返回错误的数据属于典型的静默 bug。另外ORDER BY后面如果要动态拼接列名占位符是不生效的JDBC 只允许绑定值不允许绑定标识符这种情况必须用白名单枚举校验列名绝不能把前端传来的字符串直接拼进 SQL。SQL 注入的防线只有一条所有「值」走?占位符所有「标识符」走白名单。SELECT * FROM t WHERE name name 这种写法哪怕加了一层replace(, )也不安全绕过方式太多了。5. 避坑与排查六个最容易在演示现场翻车的点这一章按「现象 → 原因 → 解决」组织都是我在评审和联调里反复见到的。每一条都能在十分钟内验证建议在提交前逐条过一遍。5.1 启动报 Failed to determine a suitable driver class现象应用启动直接失败堆栈里反复提示找不到合适的驱动spring.datasource.url明明写了。原因通常是驱动坐标引错——8.x 之后正确坐标是com.mysql:mysql-connector-j老的mysql:mysql-connector-java在部分 Spring Boot 版本里已被移除管理另一种情况是scope写成了provided打包后驱动不在BOOT-INF/lib里。解决确认pom.xml坐标正确、scope是runtime再执行mvn dependency:tree | grep mysql看驱动是否真的在依赖树里。5.2 报 The server time zone value is unrecognized现象连接能建立但驱动在初始化时抛时区异常提示服务端时区无法识别。原因是 MySQL 服务端返回的时区名比如CST在 Java 的时区表里有歧义驱动无法确定该按哪个偏移量解析。解决在 URL 上显式加serverTimezoneAsia/Shanghai。如果服务端本身就是 UTC就写UTC但要注意这样存进去的时间会比北京时间少八小时读出来要自己做转换——统一用Asia/Shanghai最省事。5.3 Connection is not available, request timed out after 30000ms现象跑一段时间后接口大面积超时日志里全是借连接超时。原因有三类一是连接泄漏手动getConnection()后没在finally里close()或者用了JdbcTemplate又额外拿了裸连接二是慢 SQL 长时间占用连接SHOW PROCESSLIST能看到大量Sleep之外的长事务三是池子确实太小。排查顺序建议从泄漏入手把maximum-pool-size调到 2、connection-timeout调到 3 秒压一下就知道了。解决泄漏点补上try-with-resources慢 SQL 加索引或者分批处理确实不够再扩池别一上来就无脑加连接数。5.4 空闲一夜后第一条请求必然失败现象白天用得好好的第二天早上第一次访问报连接已关闭重试一次又正常。原因是 MySQL 的wait_timeout把长时间空闲的连接单方面断了而池子还以为它是活的。解决把max-lifetime设成比wait_timeout小的值比如 29 分钟对 8 小时让池子主动淘汰连接而不是等服务端来断。同时开着connection-test-query: SELECT 1借出前做一次探活。5.5 中文变成问号或插入时报 Incorrect string value现象英文正常中文存进去变???或者插入带 emoji 的昵称时直接报错。原因是链路上有一环不是utf8mb4可能是建库时用了utf8可能是连接 URL 没写characterEncoding也可能是客户端工具建表时继承的默认字符集不对。解决SHOW CREATE TABLE t_student确认表字符集是utf8mb4SHOW VARIABLES LIKE character_set_%确认服务端配置URL 里补上useUnicodetruecharacterEncodingutf8mb4。三处都对齐才算彻底。5.6 事务明明加了注解却没回滚现象方法抛了异常数据库里前面的插入还在后面的没执行数据处于半截状态。原因是异常类型不在默认回滚范围内受检异常或者方法被同类内部调用绕过了代理或者异常被catch之后没重新抛。解决注解上写rollbackFor Exception.class确认调用是从外部进入的catch块里如果要吞异常必须显式调TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。这三条里第一条能解决八成问题。注意排查连接类问题时SHOW PROCESSLIST和SHOW ENGINE INNODB STATUS比翻应用日志快得多。前者能看出连接是被谁占着、在跑什么 SQL后者能看出有没有锁等待。6. 验收与答辩用四张证据证明这套 JDBC 方案真的能跑代码写完不等于方案成立。答辩现场最尴尬的不是被问到不会的而是被要求「现场演示一下事务回滚」时手忙脚乱。我一般会提前准备四份证据构成一条完整的自证链。第一份是启动日志。把 3.4 节那个DataSourceProbe打出来的那行数据源就绪server8.x, schemademo_db, poolNameHikariPool-1, maxPool10截图存好。这一行同时证明了驱动握手成功、库名正确、池参数生效比说十句「配置没问题」都管用。第二份是连接复用的现场演示。开一个终端不停刷接口另一个终端执行SHOW PROCESSLIST让评委看到连接数稳定在池子上限以内、Time列在增长但Command大部分是Sleep。这一个动作就能解释清楚「连接池到底在干什么」也是区分「会用」和「懂原理」的分水岭。第三份是事务回滚的对照实验。准备一个会抛异常的导入接口先跑一遍正常的看数据入库再把异常开关打开跑一遍然后SELECT COUNT(*)证明条数没变。这一步建议提前写成一个可在页面上点一下的按钮别现场敲 SQL。第四份是验收清单表格直接放进文档里验收项判定标准验证方式连接可用启动日志出现数据源就绪行看日志参数绑定传入 OR 11 --不返回全表手工构造入参事务原子性异常后两次写入均不存在对照COUNT(*)批量性能1000 条插入耗时 1 秒日志打时间戳连接不泄漏压测 5 分钟后池内连接数不持续上涨SHOW PROCESSLIST字符集中英文、emoji 均能正确存取写入后回读比对答辩追问里出现频率最高的三个问题答案其实都在前面的章节里为什么不用 MyBatis——因为这个项目要让 SQL 的执行时机和绑定值全程可见ORM 会把这层藏起来排查成本反而更高连接池配多大——按 QPS × 平均耗时估算后留余量不是越大越好超过数据库工作线程数只会排队SQL 注入怎么防——值走?占位符标识符走白名单两条规则覆盖全部场景。回答时给出「怎么算的」比给出「配了多少」更能得分。我自己的习惯是每次交付前把这套清单从头跑一遍哪怕代码一行没改。因为配置问题从来不是「改完就永久好了」换台机器、换个 MySQL 小版本、换个字符集任何一个变量动了都可能让某一条失效。从那以后我每次交方案都强制走一遍这四份证据没跑完不敢说「能演示」。希望这份清单能帮你少走一遍我踩过的弯路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑