Spring Boot 3 数据访问全攻略:HikariCP、Druid、JdbcTemplate 与 MyBatis CRUD
Spring Boot 连接数据库并不难,真正容易出问题的是:连接池到底选哪个、配置为什么不生效、MyBatis 注解和 XML 如何取舍、事务为什么没有回滚,以及旧教程中的代码为什么越抄越报错。本文用一个用户管理案例,把 Spring Boot 数据访问的完整链路一次讲透。
本文示例基于以下环境:
| 组件 | 版本或要求 |
|---|---|
| Spring Boot | 3.5.16 |
| Java | 17+ |
| MyBatis Spring Boot Starter | 3.0.5 |
| MySQL | 8.x / 9.x |
| Druid | 1.2.28,可选 |
代码同样适合大多数 Spring Boot 3.x 项目。Spring Boot 4.x 的核心思路一致,但部分 starter 已进入新的 4.x 版本线,升级时应重新核对兼容矩阵。
一、先看全局:一次数据库请求经历了什么?
一个典型的数据请求大致会经过下面这条链路:
HTTP 请求
↓
Controller:接收和校验参数
↓
Service:编排业务、控制事务
↓
Repository / Mapper:执行数据访问
↓
JdbcTemplate / MyBatis
↓
DataSource
↓
HikariCP / Druid 连接池
↓
MySQL
这几个层次的职责不要混在一起:
- Controller 不应该直接拼 SQL;
- Mapper 不应该承载登录、审批等业务判断;
- Service 负责事务边界,而不是只做一次“原样转发”;
- DataSource 管理数据库连接信息,连接池负责复用连接;
- JdbcTemplate 和 MyBatis 解决的是“怎样执行 SQL”,不是“怎样设计业务”。
JdbcTemplate、MyBatis 和 JPA 怎么选?
| 方案 | 更适合的场景 | 特点 |
|---|---|---|
| JdbcTemplate | SQL 少、查询直接、基础设施代码 | 轻量透明,但复杂映射需要手工处理 |
| MyBatis | SQL 较多、查询复杂、重视 SQL 可控性 | 注解和 XML 均可,动态 SQL 能力强 |
| Spring Data JPA | 领域模型明显、常规 CRUD 较多 | 开发效率高,但复杂 SQL 仍需额外设计 |
本文重点讲 JdbcTemplate 和 MyBatis,因为它们能最直观地展示 Spring Boot 的数据源自动配置与 SQL 执行过程。
二、创建项目:一套干净的 Maven 依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>boot-data-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 只使用密码哈希能力,不会自动开启登录拦截 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
几个旧教程中的依赖不要继续照抄:
mysql:mysql-connector-java已改为com.mysql:mysql-connector-j;- MyBatis Starter 1.x 已结束生命周期,不适用于 Spring Boot 3;
- 不要为了 Druid 再引入 Log4j 1.x;
- Spring Boot 3 应使用专门的
druid-spring-boot-3-starter。
三、创建数据库:表结构先把安全和查询考虑进去
CREATE DATABASE IF NOT EXISTS boot_demo
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
USE boot_demo;
CREATE TABLE app_user (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_app_user_username (username),
KEY idx_app_user_status_created (status, created_at)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
这张表故意没有 password 字段,而是使用 password_hash。密码不能以明文保存,也不应该通过“用户名 + 明文密码”直接查询数据库。
另外,用户名唯一约束不能只在 Java 中判断。并发请求可能同时通过“用户名是否存在”的检查,数据库唯一索引才是最后一道可靠防线。
四、数据源自动配置:默认为什么是 HikariCP?
引入 spring-boot-starter-jdbc 后,Spring Boot 会自动创建 DataSource、JdbcTemplate 和事务管理器。如果 classpath 中存在 HikariCP,Spring Boot 会优先选择它。
常规项目通常不需要手动指定:
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
因为 spring-boot-starter-jdbc 已经带入 HikariCP,自动选择即可。
1. 推荐的数据源配置
spring:
datasource:
url: jdbc:mysql://localhost:3306/boot_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD}
hikari:
pool-name: BootDataHikariPool
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 30000
validation-timeout: 5000
idle-timeout: 600000
max-lifetime: 1800000
数据库密码没有提供默认值,这是有意为之。运行前通过环境变量注入:
$env:DB_USERNAME = "boot_user"
$env:DB_PASSWORD = "your-strong-password"
mvn spring-boot:run
生产环境应使用密钥管理服务、容器 Secret 或部署平台的凭据能力,不要把真实密码提交到 Git。
2. 驱动类通常不需要手写
Spring Boot 可以根据 JDBC URL 推断驱动,因此通常不必配置 driver-class-name。
如果确实需要显式声明,现代 MySQL 驱动类是:
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
旧写法 com.mysql.jdbc.Driver 不应继续使用。
3. 连接池参数不是越大越好
| 参数 | 含义 | 常见误区 |
|---|---|---|
minimum-idle |
最少空闲连接数 | 设得过高会长期占用数据库连接 |
maximum-pool-size |
连接池最大连接数 | 盲目调大可能把压力全部推给数据库 |
connection-timeout |
获取连接的最长等待时间 | 不是 SQL 执行超时 |
idle-timeout |
空闲连接回收时间 | 受 minimum-idle 等配置影响 |
max-lifetime |
单个连接最长生命周期 | 应与数据库、代理的连接生命周期协调 |
连接池大小应该结合数据库最大连接数、应用实例数、SQL 延迟和并发量压测,不要复制一个“万能值”。没有性能数据时,保留框架默认值通常比拍脑袋调优更稳。
五、需要 SQL 监控时:正确切换 Druid
HikariCP 轻量、性能稳定,适合大多数项目;Druid 的优势是 SQL 统计、慢 SQL、WallFilter 和内置监控页面。两者不是“谁绝对更高级”,而是关注点不同。
1. 引入 Spring Boot 3 专用 Starter
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>1.2.28</version>
</dependency>
2. 配置 Druid
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://localhost:3306/boot_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD}
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 30000
validation-query: SELECT 1
test-while-idle: true
test-on-borrow: false
test-on-return: false
filter:
stat:
enabled: true
log-slow-sql: true
slow-sql-millis: 1000
wall:
enabled: true
stat-view-servlet:
enabled: true
url-pattern: /druid/*
reset-enable: false
login-username: ${DRUID_ADMIN_USERNAME}
login-password: ${DRUID_ADMIN_PASSWORD}
allow: 127.0.0.1
启动后,本机访问:
http://localhost:8080/druid/
使用 Starter 后,通常不需要再手动创建 DruidDataSource、StatViewServlet 和 WebStatFilter Bean。配置重复反而容易导致属性绑定错位或注册两次。
3. Druid 监控页不是普通后台页面
Druid 页面可能暴露 SQL、表名、请求路径和数据源信息。生产环境至少应该做到:
- 默认关闭,按需临时开启;
- 限制来源 IP;
- 使用强密码且通过环境变量注入;
- 不直接暴露到公网;
- 在网关或反向代理层增加认证;
- 禁止在线重置统计信息。
allow: 127.0.0.1 只是本地演示配置,多实例和容器环境需要根据实际网络重新设计访问策略。
六、JdbcTemplate:先用最少代码跑通数据访问
Spring Boot 会自动创建 JdbcTemplate,推荐使用构造器注入,而不是字段上的 @Autowired。
1. 创建查询对象
package com.example.bootdata.user;
import java.time.LocalDateTime;
public record UserSummary(
Long id,
String username,
String displayName,
Integer status,
LocalDateTime createdAt) {
}
这里没有把 passwordHash 放进查询结果。敏感字段即使是哈希值,也不应该无意义地在系统各层传播。
2. 编写 Repository
package com.example.bootdata.user;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class UserJdbcRepository {
private static final RowMapper<UserSummary> ROW_MAPPER = (rs, rowNum) ->
new UserSummary(
rs.getLong("id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getInt("status"),
rs.getTimestamp("created_at").toLocalDateTime()
);
private final JdbcTemplate jdbcTemplate;
public UserJdbcRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<UserSummary> findAll(int limit, long offset) {
String sql = """
SELECT id, username, display_name, status, created_at
FROM app_user
ORDER BY id DESC
LIMIT ? OFFSET ?
""";
return jdbcTemplate.query(sql, ROW_MAPPER, limit, offset);
}
public Optional<UserSummary> findById(long id) {
String sql = """
SELECT id, username, display_name, status, created_at
FROM app_user
WHERE id = ?
""";
try {
return Optional.ofNullable(
jdbcTemplate.queryForObject(sql, ROW_MAPPER, id));
} catch (EmptyResultDataAccessException ex) {
return Optional.empty();
}
}
public int updateStatus(long id, int status) {
return jdbcTemplate.update(
"UPDATE app_user SET status = ? WHERE id = ?",
status,
id
);
}
}
参数通过 ? 绑定,不要这样拼接:
// 错误示例:存在 SQL 注入和转义问题
String sql = "SELECT * FROM app_user WHERE username = '" + username + "'";
当 SQL 参数较多时,可以使用自动配置的 NamedParameterJdbcTemplate;在较新的 Spring Framework 中,也可以了解 JdbcClient。它们改善的是调用体验,底层仍然依赖 DataSource 和 JDBC。
七、MyBatis 注解版:简单 SQL 放在 Mapper 中
1. 配置 MyBatis
mybatis:
mapper-locations: classpath:/mapper/**/*.xml
type-aliases-package: com.example.bootdata.user
configuration:
map-underscore-to-camel-case: true
default-statement-timeout: 30
map-underscore-to-camel-case 开启后,可以自动完成类似映射:
display_name → displayName
password_hash → passwordHash
created_at → createdAt
不要再为了这一项配置专门创建 ConfigurationCustomizer,配置文件已经足够清晰。
2. 创建实体类
package com.example.bootdata.user;
import java.time.LocalDateTime;
public class UserEntity {
private Long id;
private String username;
private String passwordHash;
private String displayName;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// 省略标准 getter 和 setter
}
实际项目中可以使用 IDE 生成 getter/setter,也可以按照团队规范使用 Lombok。不要为了文章短而把字段改成 public。
3. 创建 Mapper
package com.example.bootdata.user;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Optional;
@Mapper
public interface UserMapper {
@Select("""
SELECT id, username, display_name, status, created_at, updated_at
FROM app_user
ORDER BY id DESC
""")
List<UserEntity> findAll();
@Select("""
SELECT id, username, password_hash, display_name,
status, created_at, updated_at
FROM app_user
WHERE id = #{id}
""")
Optional<UserEntity> findById(@Param("id") Long id);
@Select("""
SELECT id, username, password_hash, display_name,
status, created_at, updated_at
FROM app_user
WHERE username = #{username}
""")
Optional<UserEntity> findByUsername(@Param("username") String username);
@Insert("""
INSERT INTO app_user(username, password_hash, display_name, status)
VALUES(#{username}, #{passwordHash}, #{displayName}, #{status})
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insert(UserEntity user);
@Update("""
UPDATE app_user
SET display_name = #{displayName},
status = #{status}
WHERE id = #{id}
""")
int update(UserEntity user);
@Delete("DELETE FROM app_user WHERE id = #{id}")
int deleteById(@Param("id") Long id);
}
有两种 Mapper 注册方式,二选一即可:
- 每个接口添加
@Mapper; - 在启动类或配置类添加
@MapperScan("com.example.bootdata.user")。
团队项目中通常更偏向 @MapperScan,因为 Mapper 接口可以保持干净;小项目使用 @Mapper 更直观。
4. 为什么推荐显式写 @Param?
单参数场景中 MyBatis 经常可以推断名称,但显式使用 @Param 能让 SQL 与 Java 方法的契约更明确,也能减少编译参数、重构和多参数场景带来的歧义。
八、MyBatis XML:复杂查询和动态 SQL 放这里
注解适合短 SQL。一旦出现动态条件、多表关联、复用片段或很长的查询,把 SQL 继续塞进 Java 注解会迅速失去可读性。
1. 扩展 Mapper 接口
List<UserEntity> search(UserQuery query);
int updateSelective(UserEntity user);
查询对象:
package com.example.bootdata.user;
public record UserQuery(
String keyword,
Integer status,
int limit,
long offset) {
}
2. 创建 XML 映射文件
文件位置:
src/main/resources/mapper/UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bootdata.user.UserMapper">
<select id="search" resultType="UserEntity">
SELECT id,
username,
display_name,
status,
created_at,
updated_at
FROM app_user
<where>
<if test="keyword != null and keyword != ''">
AND (
username LIKE CONCAT('%', #{keyword}, '%')
OR display_name LIKE CONCAT('%', #{keyword}, '%')
)
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
ORDER BY id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<update id="updateSelective">
UPDATE app_user
<set>
<if test="displayName != null and displayName != ''">
display_name = #{displayName},
</if>
<if test="status != null">
status = #{status},
</if>
</set>
WHERE id = #{id}
</update>
</mapper>
这里的 <where> 会智能处理第一个 AND,<set> 会处理末尾多余的逗号。比手工拼接字符串可靠得多。
3. #{} 和 ${} 的区别必须记牢
WHERE username = #{username}
#{} 使用预编译参数绑定,通常是正确选择。
ORDER BY ${column}
${} 是直接字符串替换,不能直接接收用户输入,否则可能造成 SQL 注入。确需动态列名时,应先在 Java 代码中通过白名单把外部值映射为固定列名。
九、Service 与事务:@Transactional 应该放在哪里?
事务通常放在 Service 的公开方法上,因为 Service 表达的是一次完整业务操作。
1. 配置密码编码器
package com.example.bootdata.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration(proxyBeanMethods = false)
public class PasswordConfig {
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
2. 创建用户与验证密码
package com.example.bootdata.user;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.security.crypto.password.PasswordEncoder;
@Service
public class UserService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
public UserService(UserMapper userMapper, PasswordEncoder passwordEncoder) {
this.userMapper = userMapper;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public Long create(CreateUserCommand command) {
if (userMapper.findByUsername(command.username()).isPresent()) {
throw new IllegalArgumentException("用户名已存在");
}
UserEntity user = new UserEntity();
user.setUsername(command.username());
user.setPasswordHash(passwordEncoder.encode(command.rawPassword()));
user.setDisplayName(command.displayName());
user.setStatus(1);
int rows = userMapper.insert(user);
if (rows != 1 || user.getId() == null) {
throw new IllegalStateException("创建用户失败");
}
return user.getId();
}
@Transactional(readOnly = true)
public boolean verifyPassword(String username, String rawPassword) {
return userMapper.findByUsername(username)
.filter(user -> user.getStatus() == 1)
.map(UserEntity::getPasswordHash)
.map(hash -> passwordEncoder.matches(rawPassword, hash))
.orElse(false);
}
}
命令对象:
public record CreateUserCommand(
String username,
String rawPassword,
String displayName) {
}
正确的登录验证过程是:
- 根据用户名查询用户;
- 取出密码哈希;
- 使用
PasswordEncoder.matches验证; - 不把原始密码写入日志、数据库或异常信息。
不要执行这种查询:
SELECT * FROM app_user
WHERE username = ? AND password = ?;
3. 事务为什么有时不回滚?
Spring 默认通过代理实现声明式事务,几个高频坑是:
- 同一个类内部
this.method()自调用,可能绕过事务代理; - 默认对
RuntimeException和Error回滚,受检异常需要按需求配置rollbackFor; - 异常被
catch后吞掉,事务感知不到失败; - 方法不是通过 Spring 管理的 Bean 调用;
- 多数据源项目使用了错误的事务管理器。
事务是否生效不能靠感觉,至少要写一个“中途抛异常后数据全部回滚”的集成测试。
十、REST CRUD:不要再用 GET 删除数据
推荐的接口设计如下:
| 操作 | HTTP 方法 | 路径 |
|---|---|---|
| 分页查询 | GET | /api/users |
| 查询详情 | GET | /api/users/{id} |
| 创建用户 | POST | /api/users |
| 修改用户 | PUT | /api/users/{id} |
| 删除用户 | DELETE | /api/users/{id} |
请求对象:
package com.example.bootdata.user;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank
@Size(min = 3, max = 64)
String username,
@NotBlank
@Size(min = 8, max = 72)
String password,
@NotBlank
@Size(max = 64)
String displayName) {
}
Controller 示例:
package com.example.bootdata.user;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final UserMapper userMapper;
public UserController(UserService userService, UserMapper userMapper) {
this.userService = userService;
this.userMapper = userMapper;
}
@PostMapping
public ResponseEntity<Void> create(
@Valid @RequestBody CreateUserRequest request) {
Long id = userService.create(new CreateUserCommand(
request.username(),
request.password(),
request.displayName()
));
return ResponseEntity.created(URI.create("/api/users/" + id)).build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
return userMapper.deleteById(id) == 1
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}
示例为了突出数据访问流程做了简化。实际项目的删除操作也应进入 Service,以便集中处理权限、关联数据、审计日志和事务。
为什么不能写成 /delete?id=1 的 GET 链接?因为 GET 应当是安全、只读的请求,可能被浏览器预取、缓存、爬虫访问或被用户误触。删除数据应使用 DELETE,或在传统 HTML 表单中使用受保护的 POST 请求。
十一、集成测试:至少验证映射、主键回填和事务
package com.example.bootdata.user;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Transactional
class UserMapperIntegrationTest {
@Autowired
private UserMapper userMapper;
@Test
void shouldInsertUserAndReturnGeneratedId() {
UserEntity user = new UserEntity();
user.setUsername("test_user");
user.setPasswordHash("{noop}test-only");
user.setDisplayName("测试用户");
user.setStatus(1);
int rows = userMapper.insert(user);
assertThat(rows).isEqualTo(1);
assertThat(user.getId()).isNotNull();
assertThat(userMapper.findById(user.getId())).isPresent();
}
}
@Transactional 会让测试结束后回滚数据,但这不代表可以连接生产数据库。团队项目推荐使用独立测试库或 Testcontainers,让测试环境可重复、可隔离。
如果要测试真实密码流程,使用生产一致的 PasswordEncoder Bean,不要把 {noop} 带进正式数据。
十二、数据库初始化:开发脚本和生产迁移要分开
Spring Boot 可以自动加载:
src/main/resources/schema.sql
src/main/resources/data.sql
这适合简单演示或测试。正式项目更推荐使用 Flyway 或 Liquibase 管理数据库版本:
V1__create_app_user.sql
V2__add_user_status_index.sql
V3__add_audit_table.sql
数据库结构也是代码的一部分,应当进入版本控制、代码评审和发布流程。不要同时混用 Hibernate 自动建表、schema.sql 和 Flyway/Liquibase,维护多个“真相来源”迟早会出现环境漂移。
十三、生产环境必须补齐的 10 个细节
1. 凭据不能写死
使用环境变量、Secret 或密钥管理服务;日志中也不能输出完整 JDBC URL 和密码。
2. 密码只能存哈希
使用 BCrypt、Argon2、PBKDF2 或团队批准的自适应单向算法。不要使用 MD5、SHA-1,也不要自己发明“加盐算法”。
3. 查询不要滥用 SELECT *
只取需要的列,尤其不要在用户列表、日志或接口响应中带出 password_hash。
4. 所有列表都要分页
全表查询在小数据量阶段看不出问题,数据增长后会同时拖垮数据库、连接池、JVM 内存和网络。
5. 索引要围绕查询设计
索引不是越多越好。根据真实 WHERE、JOIN、ORDER BY 和数据分布设计,并使用 EXPLAIN 检查执行计划。
6. 连接池要结合实例数计算
单实例 20 个连接,扩容到 20 个实例就是 400 个连接。应用扩容策略必须与数据库承载能力一起评估。
7. 慢 SQL 要监控但不能暴露
Druid、Actuator 或 APM 都可以提供观察能力;监控入口本身也要鉴权和隔离。
8. 生产环境慎开完整 SQL 日志
SQL 和参数可能包含手机号、邮箱、证件号等敏感数据,也可能产生巨量日志和额外 I/O。
9. 更新与删除要检查影响行数
返回 0 可能代表目标不存在或状态已变化;大于预期行数则可能意味着条件错误。不要无视 Mapper 的返回值。
10. 事务边界要围绕业务而不是单条 SQL
创建订单、扣减库存、写入流水应当作为完整业务考虑;不要给每个 Mapper 方法机械添加事务,再期待它们自动组成原子操作。
十四、旧教程迁移到 Spring Boot 3 的关键变化
| 旧写法 | 推荐写法 |
|---|---|
mysql:mysql-connector-java |
com.mysql:mysql-connector-j |
com.mysql.jdbc.Driver |
自动推断或 com.mysql.cj.jdbc.Driver |
MyBatis Starter 1.3.1 |
Spring Boot 3.5 使用 3.0.5 |
com.alibaba:druid + 手工注册 Bean |
druid-spring-boot-3-starter |
Druid 1.0.9 |
使用当前维护版本并核对发布说明 |
Log4j 1.2.x |
使用 Spring Boot 默认日志体系 / SLF4J |
明文 password |
PasswordEncoder 生成的 password_hash |
GET /delete?id=1 |
DELETE /api/users/{id} |
${userInput} 拼 SQL |
#{userInput} 参数绑定 |
| 手写 MyBatis 驼峰配置 Bean | mybatis.configuration.map-underscore-to-camel-case=true |
另外,<frameset> 已退出现代 HTML 标准。管理后台应使用 CSS Grid、Flexbox 或成熟前端布局,而不是继续复制 frameset 页面模板。
十五、7 类高频报错排查
1. Failed to determine a suitable driver class
检查是否引入 mysql-connector-j、是否配置 spring.datasource.url,以及 JDBC URL 是否拼写正确。
2. Communications link failure
确认 MySQL 是否启动、主机和端口是否可达、用户是否允许从当前地址登录、防火墙是否放行,并检查容器中的 localhost 是否指向了错误目标。
3. No qualifying bean of type 'xxxMapper'
Mapper 接口没有被扫描。添加 @Mapper,或在正确的包路径上配置 @MapperScan。
4. Invalid bound statement (not found)
重点检查 XML 的 namespace 是否等于 Mapper 接口全限定名、id 是否等于方法名、mapper-locations 是否覆盖文件位置,以及资源是否被打进 JAR。
5. 下划线字段映射后仍然为 null
确认开启 map-underscore-to-camel-case;同时检查 Java 属性名、列别名、getter/setter 和 resultMap,不要把拼写错误误判为映射问题。
6. 插入成功但主键仍然是 null
检查表是否为自增主键,并确认使用:
@Options(useGeneratedKeys = true, keyProperty = "id")
keyProperty 指向 Java 属性名,不是数据库列名。
7. Druid 页面 404 或 403
检查是否使用 Spring Boot 3 对应的 Starter、stat-view-servlet.enabled 是否开启、访问路径是否为 /druid/,以及 allow、网关、安全框架是否阻止访问。
总结
Spring Boot 数据访问可以归纳成四层:
- DataSource 与连接池负责连接管理;
- JdbcTemplate 或 MyBatis负责安全、可控地执行 SQL;
- Service负责业务编排和事务边界;
- Controller负责协议、参数校验和响应。
真正值得记住的不是几十个连接池参数,而是几条长期有效的原则:
默认连接池够用时先用 HikariCP;需要 SQL 监控时再评估 Druid;简单 SQL 用注解或 JdbcTemplate,复杂动态 SQL 放 XML;所有外部值都做参数绑定;密码只存哈希;事务放在完整业务边界上。
把这些边界守住,Spring Boot 整合数据库就不再是“依赖能不能跑起来”的问题,而会变成一套可维护、可监控、可演进的数据访问设计。
官方参考
- Spring Boot 3.5:SQL Databases
- MyBatis Spring Boot Starter 官方文档
- MyBatis Spring Boot Starter 3.0.5 发布说明
- MyBatis:Dynamic SQL
- Druid 1.2.28 发布说明
- MySQL Connector/J 官方文档
- Spring Framework:使用
@Transactional - Spring Security:Password Storage
- Spring Boot:Database Initialization
如果这篇文章帮你理清了 Spring Boot 数据访问的完整链路,建议收藏后按“数据源 → JdbcTemplate → MyBatis → 事务 → 测试”的顺序亲手跑一遍。真正掌握数据层,靠的不是背配置,而是知道每一层为什么存在。
更多推荐




所有评论(0)