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 后,通常不需要再手动创建 DruidDataSourceStatViewServletWebStatFilter 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) {
}

正确的登录验证过程是:

  1. 根据用户名查询用户;
  2. 取出密码哈希;
  3. 使用 PasswordEncoder.matches 验证;
  4. 不把原始密码写入日志、数据库或异常信息。

不要执行这种查询:

SELECT * FROM app_user
WHERE username = ? AND password = ?;

3. 事务为什么有时不回滚?

Spring 默认通过代理实现声明式事务,几个高频坑是:

  • 同一个类内部 this.method() 自调用,可能绕过事务代理;
  • 默认对 RuntimeExceptionError 回滚,受检异常需要按需求配置 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 数据访问可以归纳成四层:

  1. DataSource 与连接池负责连接管理;
  2. JdbcTemplate 或 MyBatis负责安全、可控地执行 SQL;
  3. Service负责业务编排和事务边界;
  4. Controller负责协议、参数校验和响应。

真正值得记住的不是几十个连接池参数,而是几条长期有效的原则:

默认连接池够用时先用 HikariCP;需要 SQL 监控时再评估 Druid;简单 SQL 用注解或 JdbcTemplate,复杂动态 SQL 放 XML;所有外部值都做参数绑定;密码只存哈希;事务放在完整业务边界上。

把这些边界守住,Spring Boot 整合数据库就不再是“依赖能不能跑起来”的问题,而会变成一套可维护、可监控、可演进的数据访问设计。


官方参考

如果这篇文章帮你理清了 Spring Boot 数据访问的完整链路,建议收藏后按“数据源 → JdbcTemplate → MyBatis → 事务 → 测试”的顺序亲手跑一遍。真正掌握数据层,靠的不是背配置,而是知道每一层为什么存在。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐