搞 Java 的同学,几乎没人绕得开 JDBC 这个名字。不管你现在用的是 Spring Boot + MyBatis,还是更偏底层的自研框架,只要连的是关系型数据库,JDBC 就是那条绕不过去的必经之路。这篇文章我不打算念 API 文档,而是把我自己从“只会调框架”到“能徒手写好一个数据访问层”的过程拆开讲清楚:原生 JDBC 该怎么写、Spring Boot 集成后连接池和事务是怎么回事、再往上怎么平滑过渡到 MyBatis 这类 ORM 框架,最后把工作中真正踩过的连接泄露、时区报错、慢 SQL 这些坑也一并整理出来。
适合谁看?如果你是刚入门 Java、准备面试或者正在做毕设的同学,这篇文章能帮你把 JDBC 和 Spring Boot 之间的那层窗户纸捅破;如果你已经写了一阵子业务代码,但遇到数据库连接异常就发怵,也能在里面找到排查思路。核心就一句话:知道底层怎么运作,上层框架用起来才不慌。
1. 为什么要从 JDBC 原生 API 开始,而不是直接上框架
1.1 JDBC 到底是什么,它帮你干了哪些事
JDBC 的全称是 Java Database Connectivity,翻译过来就是 Java 数据库连接。你可以把它理解成一套“插座标准”:Sun 公司定义好接口,MySQL、PostgreSQL、Oracle 这些数据库厂商各自提供对应的“插头”,也就是驱动(Driver)。有了这套标准,你的 Java 代码写一次,换个数据库厂商只要换驱动和连接串就行,业务代码基本不用动。
它真正帮你做的事,笼统说有三件:建立连接、执行 SQL、处理结果。打开一个 JDBC 连接,本质上是在客户端和数据库服务器之间建立一条网络通道,驱动负责把这条通道上的字节流转换成数据库能识别的协议。执行 SQL 时,驱动把你要执行的语句发给数据库,数据库解析、优化、执行,然后把结果集(ResultSet)返回给 Java 端。整个过程如果让你用 Socket 自己写,要处理协议、加密、分帧,基本不可能,所以 JDBC 这层封装才这么重要。
好多人一上来就接触 MyBatis,觉得 JDBC 是个旧东西,没必要学。这个观点我很不认同。MyBatis 内部的核心依然是 JDBC,它只不过帮你把 Connection 的获取和释放、Statement 的创建、ResultSet 到对象的映射这些样板代码隐藏了。你要是连 JDBC 最基本的执行流程都不清楚,将来排查一条 SQL 很奇怪的问题时,会连日志都看不懂。
1.2 直接上 Spring Boot + MyBatis 前,最好先手写一遍原生 JDBC
我在带新人时有个习惯:入职第一周,不让他碰 MyBatis,先让他用原生 JDBC 写一个完整的增删改查。有人可能觉得这是浪费时间,但实际效果很好。为什么?因为手写一遍之后,很多概念会瞬间变得具体。
比如“连接池”这个词。你第一次用原生 JDBC,每次请求都 DriverManager.getConnection(),一旦并发上来,数据库连接数瞬间被打满,你才开始明白连接为什么要复用。再比如“事务”这件事,你用 MyBatis 时只要在 Service 方法上加 @Transactional,一切看起来很简单,但其实底层是 Spring 帮你拿到了同一个 Connection,关闭了自动提交。你不理解这一层,遇到事务失效的坑时往往不知道往哪找,其实大概率就是数据源配错了、方法被 private 修饰了,或者异常被 try-catch 吃掉了。
手写 JDBC 也能让你在面试时更有底气。Java 面试题里常考的“PreparedStatement 和 Statement 区别”“JDBC 操作数据库步骤”“如何防止 SQL 注入”,本质上都是 JDBC 的底层知识。你要是只会背八股文,面试官随便追问一个细节就露馅;但你真的动手写过,答出来的是自己的体会。
1.3 前置条件:驱动、连接串、依赖选择
开始写代码之前,先把环境准备好。我用的是 MySQL 8.0,对应驱动必须是com.mysql:mysql-connector-j8.x。驱动下载地址在各 Maven 仓库都能找到,注意别再用老版本的mysql-connector-java,坐标虽然兼容,但类名已经变了。
连接串是我最常被问到的点。一个标准的 MySQL JDBC 连接串长这样:
String url = "jdbc:mysql://localhost:3306/demo_db" + "?useSSL=false" + "&serverTimezone=Asia/Shanghai" + "&allowPublicKeyRetrieval=true" + "&characterEncoding=utf8";useSSL=false:本地开发不需要 TLS 加密,不加这个 MySQL 8 默认会尝试 SSL 握手,容易报奇怪的警告。serverTimezone=Asia/Shanghai:不设置的话,驱动可能拿不到数据库时区,报错提示也会让你加上。allowPublicKeyRetrieval=true:MySQL 8 使用 caching_sha2_password 认证,非 SSL 连接下需要这个参数。characterEncoding=utf8:避免中文乱码。
如果你用的是 Spring Boot,依赖直接写spring-boot-starter-jdbc就行,它会帮你把 HikariCP 连接池和数据源自动配置都带进来。手动写原生 JDBC 测试时,只需要在 pom.xml 里加一个驱动依赖就够了。
2. 原生 JDBC 实战:从零写一个可用的数据访问层
2.1 五步走:加载驱动、建立连接、执行、处理结果、释放资源
原生 JDBC 的套路五步,我建议直接背下来,写代码时照着做就不会漏:
- 加载驱动
- 获取连接
- 创建 Statement / PreparedStatement
- 执行 SQL 并处理 ResultSet
- 关闭资源
一个最简单查询用户的方法长这样:
public User findById(Long id) { String url = "jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=Asia/Shanghai"; String user = "root"; String password = "123456"; String sql = "SELECT id, name, email FROM user WHERE id = ?"; User result = null; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { result = new User(); result.setId(rs.getLong("id")); result.setName(rs.getString("name")); result.setEmail(rs.getString("email")); } } } catch (SQLException e) { // 实际项目中要记录日志 e.printStackTrace(); } return result; }注意Class.forName("com.mysql.cj.jdbc.Driver")这行在新版驱动里可以不写,因为依赖包里有 SPI 配置文件,DriverManager 会自动加载。但手写时我还是习惯写上,一方面兼容老版本,另一方面让阅读代码的人知道这里加载了驱动,逻辑更清晰。
Cursor和ResultSet的处理有个小技巧:如果你不确定 SQL 返回多少条,建议用rs.next()配合循环遍历,而不是直接rs.getString,避免拿不到数据时抛异常。处理完一行就封装成业务对象,这是我推荐的写法,因为 ResultSet 和 Connection 是绑定的,一旦连接关闭,结果集也就废了。
2.2 参数怎么传:PreparedStatement 为什么是必须的
新手最容易犯的错误是用字符串拼接 SQL:
String sql = "SELECT * FROM user WHERE name = '" + name + "'"; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql);一旦name里传入' or '1'='1,SQL 就变成了:
SELECT * FROM user WHERE name = '' or '1'='1'这就是经典的 SQL 注入。所以项目中必须用PreparedStatement,它有两个核心价值:
一是预编译。SQL 骨架先发给数据库解析,后面的参数只作为字面量传递,根本不会被当成 SQL 执行,注入自然失效。
二是类型安全。比如ps.setDate(1, date)只接受java.sql.Date,你传LocalDateTime会直接编译报错,强迫你先做转换。我自己的习惯是统一用LocalDateTime做业务时间类型,写 JDBC 时再转成java.sql.Timestamp:
ps.setTimestamp(1, Timestamp.valueOf(localDateTime));查询结果也是一样,rs.getTimestamp("create_time").toLocalDateTime()转回来。这里特别提醒一下,千万别图省事用rs.getString("create_time")再转字符串,格式化容易乱。
2.3 事务控制和批量操作:别让代码崩在最后一刻
JDBC 默认情况下,每一条 SQL 执行完会自动提交,也就是说conn.setAutoCommit(true)。如果一次业务操作要执行多条 SQL,比如转账场景里一个人扣款、另一个人加款,就必须手动控制事务:
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, amount); ps1.setLong(2, fromId); ps1.executeUpdate(); } // 执行加款 try (PreparedStatement ps2 = conn.prepareStatement("UPDATE account SET balance = balance + ? WHERE id = ?")) { ps2.setBigDecimal(1, amount); ps2.setLong(2, toId); ps2.executeUpdate(); } conn.commit(); } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { // 回滚失败要重点记录 } } throw e; } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) { } } }这段代码虽然啰嗦,但意义重大。注意事务里的多条 SQL 必须用同一个 Connection,因为事务是绑定在连接上的,不同的连接各走各的,谈不上原子性。finally 里把setAutoCommit(true)恢复默认值,也是一个容易被忽略的细节,连接归还给连接池后如果不复位,下个使用者可能“莫名其妙”被套进一个没提交的事务里。
批量操作也是 JDBC 里特别实用的能力。批量插入时用addBatch()+executeBatch(),能明显减少网络往返:
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO user(name) VALUES (?)")) { for (int i = 0; i < 10000; i++) { ps.setString(1, "user" + i); ps.addBatch(); if (i % 1000 == 0) { ps.executeBatch(); } } ps.executeBatch(); }MySQL 连接串加上rewriteBatchedStatements=true后,驱动会把多条 INSERT 重写成一条多 VALUES 的语句,性能提升非常明显,我实测过批量插入从几十秒降到几百毫秒。
2.4 资源释放:finally 与 try-with-resources 的正确姿势
资源释放是 JDBC 最容易踩坑的地方。ResultSet、Statement、Connection 这三个都是资源,如果不关闭,数据库连接会被一直持有,连接池很快就满了。
Java 7 之后最优雅的写法是 try-with-resources,方法执行完自动调用 close():
try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 处理 } }这里有个顺序问题:关闭顺序应该先 ResultSet,再 Statement,最后 Connection。try-with-resources 里资源的关闭顺序是逆序,正好符合要求,所以放心用。
如果你在一个方法里同时写多个 SQL,不要共用一个 Statement,每段操作独立开一个 try-with-resources。过去很多人喜欢一个 Statement 反复 executeQuery,一方面不直观,另一方面 ResultSet 会互相覆盖,数据错了都找不到原因。这些经验不是文档里能看到的,属于实操后才能总结出来的教训。
3. Spring Boot 集成 JDBC:让连接管理变得省心
3.1 引入 spring-boot-starter-jdbc 之后,连接从哪来
原生 JDBC 的一个痛点就是连接管理:每次手动DriverManager.getConnection太奢侈,生产环境基本没人这么干。Spring Boot 引入spring-boot-starter-jdbc后,会自动配置一个DataSource,默认是 HikariCP 连接池,这也解释了为什么你的项目里什么都没配,就能直接注入JdbcTemplate。
连接池的含义好比一个“租车行”。你不再需要自己买车,而是每次要用时去租,用完还回去。HikariCP 的默认配置里,maximum-pool-size是 10,也就是说最多同时租出 10 个连接。如果 10 个都被占满,第 11 个请求就会等待,直到有连接归还或超时。
在 application.properties 里,最常用的配置是这些:
spring.datasource.url=jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=123456 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.hikari.maximum-pool-size=10 spring.datasource.hikari.minimum-idle=2 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.idle-timeout=600000minimum-idle和maximum-pool-size建议不要设成一样。我见过有人为了省事把两个都设成 10,结果空闲连接一直占着内存;正常情况下让连接数随流量动态伸缩就好。connection-timeout是获取连接的最大等待时间,设太长会让请求卡死,设太短在高并发下会频繁报获取不到连接,我一般从 30 秒起步,再根据监控调整。
为什么 Spring Boot 默认选择 HikariCP?因为它在 Benchmarks 里长期排第一,字节码级别优化做得很极致。你不需要自己写连接池,但至少要知道它是一个池子,连接的创建和销毁都很昂贵,池化是必须的。
3.2 JdbcTemplate:我最常用的三个方法和一个坑
Spring Boot 里集成 JDBC,官方推荐直接使用JdbcTemplate。这个类把原生 JDBC 的样板代码处理掉了,同时保留了 SQL 的完全可控性。我日常最常用的三个方法:
queryForObject:查询单条记录,配合 RowMapper 映射对象。query:查询列表。update:增删改操作。
一个标准示例:
@Service public class UserService { private final JdbcTemplate jdbcTemplate; public UserService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public User findById(Long id) { List<User> list = jdbcTemplate.query( "SELECT id, name FROM user WHERE id = ?", (rs, rowNum) -> { User user = new User(); user.setId(rs.getLong("id")); user.setName(rs.getString("name")); return user; }, id); if (list.isEmpty()) { return null; } return list.get(0); } }这里有个特别典型的坑:queryForObject在查询不到记录时会抛EmptyResultDataAccessException,而不是返回 null。很多新人第一次遇到都会一脸懵。解决办法是像我上面这样先查 List,或者用queryForObject包一层 try-catch。从 Spring 5 开始可以用Optional,但最稳妥的还是 List 判断。
JdbcTemplate还有个兄弟叫NamedParameterJdbcTemplate,它允许在 SQL 里用:name这种命名参数,SQL 可读性好很多,参数多了也不容易对错位置。如果项目中既有简单查询又有复杂动态 SQL,建议优先用 NamedParameterJdbcTemplate,维护起来更舒服。
3.3 多数据源怎么办:DynamicDataSource 的取舍
单体应用也可能遇到多数据源:读写分离、多个业务库。Spring Boot 默认只配一个 DataSource,多数据源需要自己动手。
最简单的方案是基于AbstractRoutingDataSource做动态数据源路由。核心思路是:用一个 Map 管理所有真实的数据源,再通过一个ThreadLocal上下文变量决定当前线程走哪个数据源。AOP 切面里根据方法名或注解切换到目标数据源。切到只读数据源时,把事务设置为只读,还能减少数据库压力。
多数据源最大的坑是“跨数据源事务”。你要是一个事务里同时操作库 A 和库 B,单机本地事务根本管不了。解决方案分三个层次:
- 业务允许拆分,就拆成不同事务,代码里串行调用,保证最终一致。
- 必须强一致,引入分布式事务框架,但成本明显变高。
- 尽量把同一次业务的所有 SQL 收敛到同一个数据源,这是最简单也最容易被忽视的办法。
如果你用 MyBatis-Plus,多数据源可以直接用@DS("slave")注解,它本质也是切面 + 动态路由。但我的建议是:能不用多数据源就不用,一个库能解决的事别拆成两个库,数据库迁移、备份、事务都会变成多个维度的复杂度。
4. 进阶:从 JDBC 到 MyBatis/MyBatis-Plus 的平滑过渡
4.1 原生 JDBC 写多了,最应该怀念和抛弃的东西
手写 JDBC 一段时间后,你会对它又爱又恨。爱的是透明,SQL 完全掌握在自己手里,出了任何问题都可以一步一步跟进去看。恨的是样板代码,一个简单的 CRUD 要写五六遍 try-with-resources,整个类几百行,维护成本很高。
MyBatis 的出现,本质上就是把结果集映射和 SQL 执行流程封装起来,但你依然在 XML 或注解里写 SQL。对比一下,MyBatis 的 Mapper 接口比 JdbcTemplate 更进一步,它把查询结果自动映射到 POJO,列名和属性名可以通过 map-underscore-to-camel-case 自动转换。曾经 JDBC 里最烦人的那坨 ResultSet 映射代码,在这里消失了。
不过使用 ORM 时必须保留一个清醒的认知:ORM 只是帮你生成了一部分 SQL,复杂的动态查询、关联查询、分库分表还是要自己写 SQL。MyBatis 动态 SQL 里的<if>、<foreach>是业务里用得最多的标签,比如列表查询根据条件拼 WHERE 子句。很多人把这个当成“自动拼 SQL”,其实它只是帮你用 XML 表达逻辑,背后还是纯粹的 SQL。
4.2 MyBatis-Plus 根据实体类生成建表 SQL 的原理
网上有个很常见的需求:根据 Java 实体类自动生成创建表的 SQL 语句。MyBatis-Plus 虽然以 CRUD 增强出名,但它本身不直接提供“实体类生成建表 SQL”的功能。不过这个工具类写起来并不难,原理也不复杂。
核心思路是通过反射读取实体类的字段信息,结合注解生成 CREATE TABLE 语句。先定义一个实体类:
@TableName("user") public class User { @TableId(type = IdType.AUTO) private Long id; @TableField(value = "user_name", comment = "用户名") private String userName; @TableField(value = "age", comment = "年龄") private Integer age; }然后写一个生成器,步骤是:
- 用反射拿到实体的所有
Field,过滤掉static和transient。 - 读取
@TableName确定表名,没有注解就用类名转下划线。 - 读取
@TableField获得列名和注释,@TableId标识主键。 - 把 Java 类型映射成 MySQL 类型:String -> varchar,Integer -> int,Long -> bigint,LocalDateTime -> datetime,BigDecimal -> decimal。
- 拼出 CREATE TABLE 语句。
拼接逻辑大致这样:
StringBuilder ddl = new StringBuilder("CREATE TABLE IF NOT EXISTS `").append(tableName).append("` (\n"); for (Field field : fields) { ddl.append(" `").append(columnName).append("` ") .append(mysqlType).append(" ") .append(field.isPrimary ? "PRIMARY KEY AUTO_INCREMENT" : "") .append(" COMMENT '").append(comment).append("',\n"); }这里最容易被忽略的是字段长度。String 类型你得指定 varchar(255) 还是 text,否则默认长度可能不够用。写工具时建议允许通过注解自定义长度,比如@TableField(value = "remark", length = 500),比统一定死 255 灵活得多。
这个功能在什么场景下适用?快速搭建项目的初始化 DDL,或者需要在测试环境同步表结构时。生产环境我不会用它,表结构变更仍然要走专业的 Flyway 或 Liquibase 做版本管理,靠实体类反向生成表结构,对线上运维来说是灾难。
4.3 跨商户商城项目里,数据访问层的分层经验
很多开源项目,比如基于 Spring Boot + MyBatis 的多商户跨境商城,数据访问层如果设计得不好,业务一扩展就崩。我这里想聊聊通用的分层经验。
第一,Controller、Service、Mapper 三层各自职责要清晰。Controller 只做参数校验和返回封装,Service 做业务逻辑和事务控制,Mapper 只做 SQL 和映射。如果 Controller 里直接注入 Mapper,短期写起来爽,但将来要做权限控制、日志记录、事务处理时,你会发现没法插手,因为入口太碎了。
第二,多商户项目的所有查询都必须按租户隔离。最简单的做法是表上带merchant_id字段,然后每一条 SQL 都手动加WHERE merchant_id = ?。但手写很容易漏,更好的办法是用 MyBatis 拦截器,在 SQL 执行前自动改写 WHERE 条件,把当前租户 ID 拼进去。这样能保证“漏一条”的概率小很多,但拦截器写不好也会变成大坑,建议先在测试用例里覆盖各种 SQL 形态。
第三,避免 N+1 查询。商城列表页如果循环查每个商品的店铺信息,一条列表请求会变成几十条 SQL,数据库很容易被打爆。用连表查询把关联数据一次性查出,或者用 MyBatis 的collection映射嵌套结果。N+1 是教科书级别的反面案例,但在真实项目里反复出现,尤其是新人接手时最容易犯。
事务方面,我坚持一个 Service 方法就是一个事务边界。方法内部会调用多个 Mapper 操作,如果有一步失败,整个方法回滚。注意事务不要跨越远程调用(比如 HTTP 调另一个服务),不然本地事务完全管不住别人,只能靠最终一致性方案兜底。这也是许多“看上去很简单,落地很难”的架构问题来源。
5. 常见问题与排查技巧实录
5.1 连接不上 MySQL:时区、SSL、驱动版本三座大山
我帮人排查过不少数据库连接问题,90% 都集中在连接串。典型报错是SQLNonTransientConnectionException: Public Key Retrieval is not allowed,原因就是 MySQL 8 默认插件caching_sha2_password在非 SSL 情况下需要先拿公钥。连接串加上allowPublicKeyRetrieval=true并且useSSL=false就能解决。
另一个高频报错是The server time zone value 'xxx' is unrecognized。MySQL 8 驱动要求明确时区,要么在 MySQL 里执行SET GLOBAL time_zone = '+08:00',要么在连接串里写serverTimezone=Asia/Shanghai。我推荐后者,因为改数据库全局配置影响面太大,连接串参数的作用范围更可控。
还有一类问题是“在客户端工具里能连上,Java 程序连不上”。优先排查:
- 服务器防火墙是否放行了 3306 端口。
- MySQL 用户是否只允许
localhost登录,需要改成%或指定 IP。 - 驱动版本是否与 MySQL 服务端版本兼容,比如 MySQL 5.7 用 8.x 驱动一般没问题,但反过来经常失败。
driver-class-name是否写错,MySQL 8 是com.mysql.cj.jdbc.Driver,老写法com.mysql.jdbc.Driver在新版本驱动里已经移除了。
排查这类问题我习惯先在本机跑一个最小 demo,确认连接串没问题,再检查部署环境和网络,能省很多弯路。
5.2 连接泄露:代码里最常见的隐形杀手
连接泄露和内存泄漏一样,都是“过程很安静,结果很致命”的问题。表现是系统跑着跑着突然大量请求报错:
HikariPool-1 - Connection is not available, request timed out after 30000ms这个报错说明连接池里的所有连接都被借走了,而且一直没有归还。原因大概率是某段代码拿到 Connection 后异常退出,资源没有关闭。
排查思路:
- 看日志,找到哪个 SQL 长期持有连接。
- 在数据源配置里开启
leakDetectionThreshold,当连接借用超过指定时间时,HikariCP 会打印堆栈:spring.datasource.hikari.leak-detection-threshold=60000 - 去 MySQL 里执行
SHOW PROCESSLIST;,看哪些连接Sleep时间很长,基本就是“僵尸连接”。
修复方式很简单:所有使用Connection/PreparedStatement/ResultSet的地方,全部用 try-with-resources,不要在 finally 里手动关一半留一半。如果是 Spring 的JdbcTemplate/ MyBatis,框架会帮忙释放,但自己写原生 JDBC 片段时最容易漏,尤其要检查有没有在 catch 分支里只打了日志而忘了关资源。
5.3 慢 SQL 与连接池监控:Spring Boot 里怎么做
数据库访问层扛不住,通常是慢 SQL 拖垮连接池。一个 SQL 跑 5 秒,连接被占 5 秒,连接池只有 10 个,同一时刻超过 10 个这样的请求,整个应用就瘫痪了。所以监控慢 SQL 和连接池状态,比监控接口响应时间更重要。
基础手段是开启 MySQL 慢查询日志:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;这样记录超过 1 秒的 SQL,定位到具体语句后,用EXPLAIN看执行计划。type如果出现ALL,说明是全表扫描,大概率少了索引。最常见的问题是明明在 WHERE 条件里写了索引字段,但因为对列做了函数运算,比如WHERE YEAR(create_time) = 2025,导致索引失效。改成create_time >= '2025-01-01' AND create_time < '2026-01-01'就能走索引了。
应用侧可以用 Spring Boot Actuator 暴露健康检查和指标,再接入 Spring Boot Admin 做图形化展示。数据源的健康检查已经内置,/actuator/health里能看到数据库是否可用。如果你用 Prometheus + Grafana,还能把 HikariCP 的指标拉出来,比如:
hikaricp_connections_active活跃连接数hikaricp_connections_pending等待获取连接的请求数hikaricp_connections_timeout_total获取连接超时总数
活跃连接长期逼近maximum-pool-size,说明连接池太小或者 SQL 太慢,必须先优化 SQL,而不是一味调大连接池。连接池调大了,数据库的负载并不会变小,只是把压力延后且放大了。
5.4 给第三方提供接口时的数据访问层设计思考
经常有人问,Spring Boot 对外提供的接口(给第三方)应该放在哪里?是单独服务,还是放在对应的业务服务里?
我的建议是分情况。如果只是内部系统之间调用,放在同一个服务里没问题,直接暴露一个 Controller 接口,Service 层复用现有逻辑,数据访问层基本不用动。如果这个接口是给外部第三方用的,比如开放平台、跨境商城对接物流/支付,我强烈建议单独拆一个服务,或者至少在同一个项目里单独建一个模块。原因有三点:
- 接口版本管理更清晰。外部接口不能随便改字段,单独服务可以按版本共存,老版本继续跑,新版本逐步升级。
- 安全隔离更好做。对外开放的接口需要限流、验签、IP 白名单,如果和内部接口混在一起,越权风险很高,Controller 层的防护策略也很难统一。
- 数据访问层能独立伸缩。外部接口流量往往不可控,万一某个第三方疯狂调接口打爆数据库,不能让它牵连内部核心服务。
独立服务的数据访问层也应该设计成“面向接口输出 DTO,而不是直接暴露实体”。你可以用 MapStruct 把实体转成 DTO,这样即使数据库表结构调整,对外字段也不变。Controller 层防爬虫、防刷这块,我用过的有效手段是加次数限制和签名校验:每个第三方一个 appId + 密钥,请求统一带上签名,服务端验签后处理,再配合 Redis 计数器做 QPS 限流。数据库访问层面再设置一个独立的连接池,和内部业务的数据源分开,谁也别拖累谁。
最后再分享一个小技巧:无论服务怎么拆,数据库访问层一定要保证所有 SQL 可追踪。我习惯在业务表里加trace_id字段,或者至少在日志里打印 SQL 和参数,这样第三方说“为什么我的订单状态没更新”时,你能快速定位到具体请求。排查问题的时间,往往比你写代码的时间更值钱。
写到这里,我自己的感受挺深。这些年见过太多同学把 MyBatis、JdbcTemplate 用得飞起,但一遇到连接池耗尽、时区报错、事务失效就束手无策。技术债不是一天积累的,而你对 JDBC 的理解深度,决定了你在这些故障面前是慌乱还是从容。别嫌 JDBC 老,它依然是整个 Java 数据库生态的基石,值得你花一个下午亲手写一遍。