ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+JDBC+MySQL源码包:JdbcTemplate避坑

SpringBoot+JDBC+MySQL源码包:JdbcTemplate避坑

简介:面向Java初学者以及需要快速跑通数据库示例的开发者,这套Spring Boot整合JDBC连接MySQL的完整方案覆盖从环境准备到代码落地的常见环节,适合课程实验、小型项目脚手架及自学练手。压缩包共136个文件、约637.42MB,包含Java源码与编译后的class、properties配置、SQL建表脚本,以及配套的MySQL安装程序、运行库dll和说明文档等,安装与运行所需组件相对齐全。已有601人学习下载,可作为入门参考。读者可据此对照控制器、实体类与数据访问层的组织方式,理解JDBC在Spring Boot中的配置与调用流程,并借助现成脚本快速建库建表、验证增删改查效果,减少环境搭建与依赖排查的时间。

1. 从一次 “Communications link failure” 说起:这套源码包到底解决了什么

上周有个开发者把一张报错截图甩进群里,说照着网上的教程搭 Java + SpringBoot 连 MySQL,Communications link failure从下午三点一直报到现在,application.yml改了七八遍,用户名密码来回换,连不上还是连不上。我让他把pom.xml和连接串发过来,问题一目了然:驱动坐标还停留在老版本,URL 里没有带时区参数,MySQL 8 的服务端默认认证插件也对不上。

这套 SpringBoot + JDBC + MySQL 的整套源码包,走的就是把这个过程全部摊开的路子。它没有引入 MyBatis 或者 JPA,只用 Spring 自带的JdbcTemplate走原生 JDBC 通道,配一份可直接安装的数据库安装包和初始化脚本,从建库、建表、配置数据源到接口返回数据,整条链路完整可跑。适合刚接触 SpringBoot 数据层、想把 JDBC 那层黑匣子打开看清楚的开发者,也适合平时用 ORM 用惯了、回头补底层课的人。

2. 工程骨架与依赖选型:pom.xml 里每个坐标为什么留在那

拿到一份源码包,我习惯先不动手跑,先把目录和pom.xml过一遍。这一步能看出作者的技术取舍,也能提前判断跑起来会不会翻车。这套工程的结构非常克制,没有多余的模块分层,也没有为了“看起来专业”硬塞一堆 starter。

2.1 目录结构:一个最小可运行的数据访问工程长什么样

工程采用标准的 Maven 单模块布局,包名按controller / service / dao / entity四层切分。这个分层不新鲜,但在这类以教学和二次开发为目标的工程里,它是最好读的一种——找接口去 controller,找业务逻辑去 service,找 SQL 去 dao,找字段去 entity,四层各管一段,不会互相串。

路径作用二次开发时的改动频率
pom.xml依赖与构建配置低,加新依赖时才动
src/main/java/.../JdbcDemoApplication.java启动类极低
.../controller/UserController.javaHTTP 接口入口高,加接口必改
.../service/UserService.java与实现类业务逻辑与事务边界高
.../dao/UserDao.java所有 SQL 的落脚点最高
.../entity/User.java表字段的 Java 映射中
src/main/resources/application.yml数据源与连接池参数中,换环境必改
src/main/resources/schema.sql、data.sql建表与初始化数据低
sql/init.sql手工执行的完整初始化脚本低

这里有个细节值得留意:schema.sql和data.sql放在resources下,Spring Boot 启动时会自动扫描并执行,而sql/init.sql是给手工导入准备的。两套脚本内容基本一致,一套自动化、一套手动化。我一般会保留这个设计,因为初学者第一次连数据库时,用命令行手工导入更容易看清每一步发生了什么,等熟悉了再让框架自动执行。

2.2 pom.xml 依赖拆解:为什么只留三个坐标

整个工程的核心依赖只有三块,其余都是它们的传递依赖:

<!-- Web 层:提供内嵌 Tomcat 和 @RestController 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 数据访问核心:带入 spring-jdbc、JdbcTemplate、HikariCP、事务管理器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- MySQL 驱动:运行时才需要,编译期不依赖 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

spring-boot-starter-jdbc是这套工程的关键。很多人以为它只是“连数据库用的”,实际上它一次性带进来三样东西:spring-jdbc(提供JdbcTemplate和RowMapper)、HikariCP(Spring Boot 2.x 之后的默认连接池)、DataSourceTransactionManager(@Transactional生效的基础)。也就是说,只要这一个 starter,DAO 层、连接池、事务三件事全都齐了,不需要额外再引druid或者c3p0。

驱动坐标这里用的是com.mysql:mysql-connector-j。MySQL 驱动在 8.0.31 之后换了 groupId,老坐标是mysql:mysql-connector-java,新坐标是com.mysql:mysql-connector-j。两个坐标在功能上兼容,但新工程没必要再用老的。<scope>runtime</scope>表示编译期不需要这个包,只有运行和测试时才进 classpath,这样打包出来的依赖边界更清晰。

提示:如果你在本地仓库里看到同时存在新旧两个驱动坐标,务必在pom.xml里排掉一个。两个版本同时出现在 classpath 上,DriverManager加载到哪个是不确定的,报错信息还会互相干扰。

2.3 配置文件:数据源四个核心参数与连接池默认值

application.yml是每次换机器都要改的地方,也是翻车最集中的地方。这套工程的配置长这样:

server: port: 8081 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # 连接串关键参数:字符集、时区、SSL、公钥获取 url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 hikari: maximum-pool-size: 10 # 连接池上限,按并发量调 minimum-idle: 2 # 常驻空闲连接,避免冷启动抖动 connection-timeout: 30000 # 拿不到连接时最多等 30 秒 idle-timeout: 600000 # 空闲 10 分钟回收 max-lifetime: 1800000 # 连接最长存活 30 分钟,比 MySQL 的 wait_timeout 短

连接串里那四个参数,每一个都是踩过坑之后留下来的。useUnicode=true&characterEncoding=utf8决定客户端和服务端握手时用的字符集,不写就有可能在插入中文时变成问号。serverTimezone=Asia/Shanghai是 MySQL 8 的硬要求,服务端时区没配的话驱动会直接抛The server time zone value '...' is unrecognized。useSSL=false是本地开发环境省掉证书握手,生产环境该开还得开。allowPublicKeyRetrieval=true针对 MySQL 8 默认的caching_sha2_password认证插件,第一次连接没有缓存时需要走公钥交换,不加这个参数就会报Public Key Retrieval is not allowed。

HikariCP 的参数里,max-lifetime要略小于 MySQL 服务端的wait_timeout(默认 28800 秒,但很多云环境会改成更短)。连接池里躺着一个已经被服务端单方面掐掉的连接,拿它去执行 SQL 就是Communications link failure,这个坑后面会单独讲。

3. 核心链路拆解:从 Controller 到 JdbcTemplate 的一次完整查询

配置只是把门打开,真正的活儿在代码里。这套工程的代码量不大,一条查询链路从头到尾大概两百行,但每一层该干什么、不该干什么,边界划得很清楚。把这条链路读透,后面用不用 MyBatis 都是同一套思路。

3.1 实体与表结构对齐:字段映射的三种写法

表结构先定下来,user表五个字段:

CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `password` VARCHAR(100) NOT NULL COMMENT '密码(示例明文,生产须加密)', `age` INT DEFAULT NULL COMMENT '年龄', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

对应实体类:

public class User { private Long id; private String username; private String password; private Integer age; private LocalDateTime createTime; // 省略 getter / setter,实际工程用 Lombok @Data 更省事 }

字段映射有三种写法,这套工程用的是最直白的一种——手动RowMapper。第二种是按别名自动映射(BeanPropertyRowMapper),第三种是完全交给 ORM。这里选手动映射的原因是显式:数据库里叫create_time,Java 里叫createTime,中间的转换逻辑看得见、改得动,出问题时不用去猜框架干了什么。

注意:BIGINT一定要用Long接,用Integer在一定量级之后会溢出;DATETIME推荐用LocalDateTime接,老式的java.util.Date在时区处理上很容易出玄学问题。

3.2 DAO 层:JdbcTemplate 的 query / update / queryForObject

DAO 层是这套工程的主体,所有 SQL 都集中在这一个类里:

@Repository public class UserDao { private final JdbcTemplate jdbcTemplate; // 构造器注入,JdbcTemplate 由 starter-jdbc 自动装配 public UserDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } // 行映射器:把 ResultSet 的一行转成一个 User 对象 private static final RowMapper<User> USER_ROW_MAPPER = (rs, rowNum) -> { User u = new User(); u.setId(rs.getLong("id")); u.setUsername(rs.getString("username")); u.setPassword(rs.getString("password")); u.setAge(rs.getInt("age")); // NULL 会返回 0,需业务侧判空 u.setCreateTime(rs.getTimestamp("create_time").toLocalDateTime()); return u; }; // 查询列表:参数用 ? 占位,杜绝拼接注入 public List<User> findAll() { String sql = "SELECT id, username, password, age, create_time FROM user ORDER BY id DESC"; return jdbcTemplate.query(sql, USER_ROW_MAPPER); } // 查单条:queryForObject 查不到会抛 EmptyResultDataAccessException public User findById(Long id) { String sql = "SELECT id, username, password, age, create_time FROM user WHERE id = ?"; return jdbcTemplate.queryForObject(sql, USER_ROW_MAPPER, id); } // 更新:返回受影响行数,业务层可据此判断是否真的改到了 public int updateAge(Long id, Integer age) { String sql = "UPDATE user SET age = ? WHERE id = ?"; return jdbcTemplate.update(sql, age, id); } // 插入并回填主键 public Long insert(User user) { String sql = "INSERT INTO user(username, password, age) VALUES (?, ?, ?)"; KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(conn -> { PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setInt(3, user.getAge()); return ps; }, keyHolder); return keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); } }

几个方法对应的场景不一样,混用就是坑。query返回List,查不到是空集合,安全;queryForObject返回单个对象,查不到抛异常、查到多条也抛异常,只适合“按主键查”这种确定唯一的情况;update返回int,插入、更新、删除都用它,返回 0 就说明条件没命中,这个返回值不要丢,业务层判断很有用。

参数传递全部用?占位符,由PreparedStatement做预编译,值不会进入 SQL 文本,注入风险从根上就没了。我看到过有人图省事用字符串拼接"... WHERE username = '" + name + "'",测试环境一直没事,上线第一天就被扫了。

插入回填主键这里有个容易忽略的点:GeneratedKeyHolder拿到的 key 在高版本驱动里可能是BigInteger,直接强转Long会在某些驱动版本上抛ClassCastException,所以代码里用getKey().longValue()转一道更稳妥。

3.3 Service 与 Controller:事务边界放在哪一层

Service 层的职责是把多个 DAO 调用组合成一个业务动作,并且在这里声明事务边界:

@Service public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } // 事务加在 Service 的公开方法上,不要加在 DAO 上 @Transactional(rollbackFor = Exception.class) public Long register(User user) { if (userDao.findByUsername(user.getUsername()) != null) { throw new IllegalStateException("用户名已存在"); } return userDao.insert(user); } }

@Transactional加在 Service 而不是 DAO,是因为事务描述的是“一段业务操作要么全成要么全败”,DAO 只是其中一个动作。rollbackFor = Exception.class这一行不能省,Spring 默认只对RuntimeException和Error回滚,受检异常抛出去事务照样提交,数据就脏了。

Controller 层只做参数接收和结果包装,不写业务判断:

@RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public User getById(@PathVariable Long id) { return userService.findById(id); } @PostMapping public Long create(@RequestBody User user) { return userService.register(user); } }

这层很薄是对的。Controller 一旦开始写if (user.getAge() > 150)这种判断,同一套校验逻辑就会在多个接口里重复三遍,改规则时总会漏掉一处。

3.4 批量写入:batchUpdate 与逐条执行的性能差别

初始化数据或者导入场景,逐条调insert是最慢的写法。每条 SQL 都要走一次网络往返、一次解析、一次提交,一千条数据就是一千次往返。batchUpdate会把多条语句攒在一个批次里发过去:

public int[] batchInsert(List<User> users) { String sql = "INSERT INTO user(username, password, age) VALUES (?, ?, ?)"; return jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { User u = users.get(i); ps.setString(1, u.getUsername()); ps.setString(2, u.getPassword()); ps.setInt(3, u.getAge()); } @Override public int getBatchSize() { return users.size(); } }); }

要真正吃到批量的性能,连接串上还得加rewriteBatchedStatements=true。不加这个参数,MySQL 驱动默认还是把批量拆成单条发出去,代码写了批量、性能一点没变,这是最容易被忽略的一处配置。加上之后,驱动会把多条INSERT重写成INSERT INTO ... VALUES (...), (...), (...),千条级别的插入耗时能差出一个数量级。

4. 把项目跑起来:MySQL 安装包、建库脚本与启动验证

代码读完,该动手了。这一章的步骤按顺序走一遍,中间不要跳步,尤其是数据库那一段,跳过去后面会花更多时间回来补。

4.1 MySQL 安装包的解压式安装步骤

源码包里的安装包是免安装的压缩版,解压到不含中文和空格的路径下,比如D:\mysql\mysql-8.0。然后在这个目录下建一个my.ini:

[mysqld] basedir=D:/mysql/mysql-8.0 datadir=D:/mysql/mysql-8.0/data port=3306 character-set-server=utf8mb4 default-time-zone='+08:00'

接下来三条命令,用管理员权限打开命令行依次执行:

# 1. 初始化数据目录,--initialize-insecure 表示 root 初始无密码 mysqld --initialize-insecure --console # 2. 注册为 Windows 服务(服务名自取) mysqld --install MySQL8 # 3. 启动服务 net start MySQL8

--initialize-insecure生成的是一个空密码的 root 账号,本地开发够用,装完先改密码再干活。default-time-zone='+08:00'这行很关键,服务端时区定了,客户端连接串里的serverTimezone才有一个正确的基础,两边对不上就是那令人抓狂的 8 小时偏差。

服务起来之后用mysql -u root -p登录,能进到mysql>提示符就说明数据库这半边通了。

4.2 建库建表脚本与初始化数据

把工程里sql/init.sql的内容整段贴进去执行,或者用source命令导入:

CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE demo_db; CREATE TABLE IF NOT EXISTS `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `age` INT DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO `user`(username, password, age) VALUES ('zhangsan', '123456', 24), ('lisi', '123456', 31), ('wangwu', '123456', 28);

字符集统一用utf8mb4而不是utf8。MySQL 里的utf8实际上是utf8mb3,最多三个字节,存不了 emoji 和部分生僻字,插入时报Incorrect string value基本都是这个原因。库、表、连接串三处的字符集要保持一致,任何一处掉队都会在某个时刻冒出来。

4.3 启动项目与三类接口的验证顺序

在工程根目录执行mvn spring-boot:run,或者在 IDE 里直接跑启动类。控制台出现Started JdbcDemoApplication in x.xxx seconds就说明容器起来了,启动过程中如果schema.sql被执行,还会看到建表语句的日志。

验证顺序建议按“读 → 写 → 事务”三步走,每一步失败都能快速定位到是哪一层的问题:

# 第一步:查列表,验证连接、驱动、SQL、映射全链路 curl http://127.0.0.1:8081/api/user # 第二步:按主键查,验证参数绑定 curl http://127.0.0.1:8081/api/user/1 # 第三步:写入,验证主键回填和事务提交 curl -X POST http://127.0.0.1:8081/api/user \ -H "Content-Type: application/json" \ -d '{"username":"zhaoliu","password":"123456","age":26}'

第一步能出数据,说明数据源配对了、驱动加载了、表建好了、字段映射没错。第二步能出单条,说明占位符传参正常。第三步返回主键 ID,说明写入和自增回填都通。三步依次通过,这套工程就算完整跑通了。

提示:如果第一步就返回 500,先看控制台的异常栈第一行,是Communications link failure还是Access denied还是Unknown database,这三种对应的排查方向完全不同,别混着改配置。

5. 避坑与排查:连接池、时区、驱动版本这些坑我踩过

跑通一次不难,难的是换个环境、换台机器、换个 MySQL 版本之后还能跑通。下面这几条是这套工程在实际使用中高频出现的翻车点,每条都按现象、原因、解决三段来写,遇到时对着查。

5.1 连接阶段的报错:从 Communications link failure 说起

现象:启动时或第一次请求数据库时报Communications link failure,异常栈里还能看到The last packet sent successfully to the server was 0 milliseconds ago。

原因:这个报错覆盖面很广,本质是 TCP 层握手失败,常见的有三种——MySQL 服务没启动、端口不对、URL 里的主机名解析不到。还有一种比较隐蔽的:连接池里躺着一个已经被服务端wait_timeout掐掉的连接,复用它的时候报同样的错。

解决:先在本机执行telnet 127.0.0.1 3306或者mysql -h 127.0.0.1 -P 3306 -u root -p确认服务端可达;再用netstat -ano | findstr 3306看端口是否被别的进程占用;两者都正常的话,检查 HikariCP 的max-lifetime是否小于服务端的wait_timeout,把它调到 1800000(30 分钟)以内,让连接池主动淘汰老连接,而不是等服务端先动手。

5.2 中文乱码与时间字段差 8 小时

现象:插入的中文在数据库里显示成???,或者查询出来的create_time比实际时间晚/早 8 小时。

原因:乱码是字符集链条断了——库、表、连接三处只要有一处不是utf8mb4就会断。时间偏差是时区链条断了——服务端default-time-zone、连接串serverTimezone、JVM 默认时区三者不一致,驱动在转换DATETIME时按错误的基准换算。

解决:字符集按库 → 表 → 连接串顺序统一改成utf8mb4,连接串上加useUnicode=true&characterEncoding=utf8;时区在my.ini里设default-time-zone='+08:00',连接串上加serverTimezone=Asia/Shanghai,同时在启动参数里显式指定-Duser.timezone=Asia/Shanghai,三处对齐之后偏差就消失了。这类问题属于血泪经验,改一处不管用,得整条链一起看。

5.3 驱动版本与类名错配

现象:启动直接失败,报ClassNotFoundException: com.mysql.jdbc.Driver,或者报Loading class 'com.mysql.jdbc.Driver'. This is deprecated。

原因:com.mysql.jdbc.Driver是 5.x 时代的类名,8.x 驱动改成了com.mysql.cj.jdbc.Driver。工程里如果照抄了老教程的配置,类名对不上就加载不到。还有一种情况是pom.xml里新旧两个驱动坐标同时存在,classpath 上打架。

解决:driver-class-name统一写com.mysql.cj.jdbc.Driver,pom.xml里只保留com.mysql:mysql-connector-j一个驱动坐标,用mvn dependency:tree | findstr mysql确认没有第二个版本混进来。另外 8.x 驱动其实可以省略driver-class-name这一行,Spring Boot 会根据 URL 自动推断,写与不写都行,写了更直观。

5.4 事务不生效与连接池耗尽

现象一:Service 方法上明明加了@Transactional,抛异常之后数据还是写进去了。现象二:并发稍微上来一点,接口开始大面积超时,日志里出现Connection is not available, request timed out after 30000ms。

原因:事务不生效最常见的是同类内部调用——A 方法调本类的 B 方法,B 上的@Transactional根本不经过代理,注解形同虚设;另一个原因是异常被try-catch吞了,代理层感知不到异常自然不会回滚。连接池耗尽的根因通常是连接泄漏,比如手工拿了Connection却没在finally里关,或者某个慢查询长时间占着连接不释放。

解决:事务方法统一放在 Service 的公开方法上,跨方法调用通过注入自身代理或者拆分到另一个 Bean;异常要么往外抛,要么在 catch 里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。连接池这块,先看 HikariCP 的activeConnections指标是不是只涨不跌,是的话重点排查手工获取连接的地方;不是的话再看是不是慢 SQL 把池子占满了,把maximum-pool-size从 10 调到 20 只是缓解,根源还是得从 SQL 上找。

6. 二次开发:把这份 Demo 改造成能干活的 DAO 层

这套工程的价值不在于它现在能跑,而在于它是一个干净的起点。真接到业务上,第一件事往往是把散落在 DAO 里的 SQL 收拢,第二件事是补上分页和动态条件这两块 Demo 里没有的东西。

6.1 把散落的 SQL 收进 Repository

单个UserDao还能看,实体一多,每个 DAO 里重复的RowMapper、重复的分页 SQL 就开始碍事了。我一般会抽一个泛型基类出来,把公共部分沉下去:

public abstract class BaseRepository<T> { @Autowired protected JdbcTemplate jdbcTemplate; // 子类只需告诉基类表名和行映射规则 protected abstract String tableName(); protected abstract RowMapper<T> rowMapper(); public List<T> findAll() { return jdbcTemplate.query("SELECT * FROM " + tableName(), rowMapper()); } public int count() { Integer n = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM " + tableName(), Integer.class); return n == null ? 0 : n; } }

tableName()由子类提供,虽然这里有字符串拼接入 SQL,但值来自代码常量而不是外部输入,不存在注入面。真正来自用户的参数,一律走?占位符,这条线不能松。

6.2 动态条件与分页的最小实现

列表接口一旦开始带筛选条件,SQL 就得动态拼。用StringBuilder加参数列表是最朴素也最好控的写法:

public List<User> search(String username, Integer minAge, int page, int size) { StringBuilder sql = new StringBuilder( "SELECT id, username, password, age, create_time FROM user WHERE 1=1"); List<Object> args = new ArrayList<>(); if (StringUtils.hasText(username)) { sql.append(" AND username LIKE ?"); args.add("%" + username + "%"); } if (minAge != null) { sql.append(" AND age >= ?"); args.add(minAge); } sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?"); args.add(size); args.add((page - 1) * size); return jdbcTemplate.query(sql.toString(), USER_ROW_MAPPER, args.toArray()); }

WHERE 1=1是个老技巧,作用是让后续每个条件都能以AND开头,不用判断“这是第几个条件”。args和 SQL 的追加顺序必须严格对应,我习惯每一处append后面紧跟一个args.add,中间不插别的逻辑,这样顺序错不了。分页用LIMIT ? OFFSET ?,深分页(offset 很大)在百万级表上会明显变慢,那时候得换成基于游标的分页,这是另一个话题了。

6.3 改造后的验证方式

改完不能只靠手点接口。我会补两组测试:一组是 DAO 层针对真实库的集成测试,跑之前用@Sql脚本灌一批固定数据,跑完断言条数和字段值;另一组是分页边界的用例,page=1,size=10、page=1000,size=10(超范围应该返回空集合而不是报错)、size传 0 或负数时应该被拦截。这两组过了,接口层的改动基本不会出大事。

另外建议把慢查询日志打开,my.ini里加slow_query_log=1和long_query_time=1,跑一轮压测再看日志,哪条 SQL 拖后腿一目了然。这比盯着接口响应时间猜要快得多。

从前我改这类数据访问代码,图快,改完直接点两下接口就提交,结果有一次分页参数算错,线上翻到第二页就开始重复数据,排查了半天才发现是offset少减了一个 1。从那以后我每次动分页和动态条件,都强制把边界用例跑一遍再提交,哪怕只改了一行 SQL。这套源码包如果你打算接着往下写,也建议先把这几组测试补上再动业务逻辑,省下来的时间远比写测试花的多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进