📦 本文基于的完整项目源码:https://github.com/meirongdev/shop

2026-04 实践更新 当前主线仓库里,MySQL 领域服务的集成测试基线已经基本统一为 @ServiceConnection;少数仍保留 @DynamicPropertySource 的测试,主要是因为除了数据库容器之外还需要额外挂接自定义属性,而不是数据库接线本身没有迁移完成。

在 Shop Platform 的 15 个服务中,11 个领域服务各自拥有独立的 MySQL 8.4 数据库。Spring Boot 3.5 结合 Spring Data JPA 3.5 提供了比较完善的数据库集成能力,但默认配置未必直接适合当前生产环境。需要额外说明的是:当前仓库已经稳定落地的是 ddl-auto: validate、Flyway 迁移、@ServiceConnection 集成测试,以及部分服务显式关闭 OSIV;像 Hikari 数值和 @EntityGraph 更适合作为“可参考实践”,不应直接写成“仓库现状”。

下面从六个维度整理一下 Spring Boot 3.5 下数据库实践里更常遇到的点。


HikariCP 连接池调优#

为什么需要调优#

Spring Boot 默认的 HikariCP 配置是 maximum-pool-size: 10。这个值适合当起点,但对容器化环境来说,既可能偏小,也可能偏大。

一个更稳妥的压测起点#

spring:
  datasource:
    hikari:
      maximum-pool-size: 10     # 先作为压测起点
      minimum-idle: 2           # 最小空闲连接
      connection-timeout: 5000  # 获取连接超时(ms)
      idle-timeout: 300000      # 空闲连接超时(5 分钟)
      max-lifetime: 1200000     # 连接最大生命周期(20 分钟)

怎么确定 maximum-pool-size#

HikariCP 作者 Brett Wooldridge 的公式:

connections = ((core_count × 2) + effective_spindle_count)

对于云原生环境(单容器 1-2 CPU),这条公式更适合当成压测起点

  • 1 CPU 容器:通常先从 5 左右开始测
  • 2 CPU 容器:通常先从 8-12 开始测

当前 shop 仓库并没有统一提交 spring.datasource.hikari.maximum-pool-size / minimum-idle 这类覆盖配置,所以更准确的结论应该是:仓库还没有把连接池数值调优固化下来,后续应依据实际的 DB RT、并发量和连接等待时间再定。

参考:HikariCP Pool SizingSpring Boot DataSource Properties


open-in-view: false 的影响#

什么是 OSIV#

Open Session In View(OSIV)是 Spring Boot 常见的默认行为——spring.jpa.open-in-view: true。它让 JPA Session 贯穿整个 HTTP 请求,包括视图渲染阶段。

为什么关掉它#

spring:
  jpa:
    open-in-view: false

OSIV 的问题:

  1. 隐藏 N+1 查询:在 Controller 中 lazy 关联的实体被访问时才触发查询,开发者不易察觉
  2. 连接占用时间长:Session 从请求开始到视图渲染结束,数据库连接被长期持有
  3. 性能不可预测:视图层可能触发意外查询,响应时间不稳定

关掉后的替代方案#

关闭 open-in-view 后,延迟加载的关联在 Service 层之外访问会抛 LazyInitializationException。解决方案:

方案一:@EntityGraph(一种常见写法)

public interface OrderRepository extends JpaRepository<OrderEntity, String> {
    @EntityGraph(attributePaths = {"items"})
    Optional<OrderEntity> findByIdWithItems(String id);
}

方案二:JOIN FETCH

@Query("select o from OrderEntity o join fetch o.items where o.id = :id")
Optional<OrderEntity> findByIdWithItems(@Param("id") String id);

方案三:在 @Transactional 方法内完成数据加载

@Transactional(readOnly = true)
public OrderDetailView getOrderDetail(String orderId) {
    OrderEntity order = orderRepository.findById(orderId).orElseThrow();
    List<OrderItemEntity> items = order.getItems();
    return new OrderDetailView(order, items);
}

当前 shop 仓库里能直接验证的是:auth-server 显式设置了 spring.jpa.open-in-view: false,而 order-service 等服务把响应组装放在事务边界内完成;仓库中暂时还没有可直接引用的 @EntityGraph 用法,所以这里更适合把 @EntityGraph / JOIN FETCH 当成后续应对复杂关联查询的常见模式,而不是现状描述。

参考:Spring Boot open-in-view DocumentationVlad Mihalcea: open-in-view


@EntityGraph 防 N+1#

N+1 问题#

// ❌ N+1 查询:1 次查订单 + N 次查订单项
List<ShopOrderEntity> orders = orderRepository.findByBuyerId(buyerId);
for (ShopOrderEntity order : orders) {
    order.getItems().size(); // 每次触发一次 SELECT
}

@EntityGraph 解决#

@EntityGraph(attributePaths = {"items"})
List<ShopOrderEntity> findByBuyerIdOrderByCreatedAtDesc(String buyerId);

生成的 SQL:

SELECT o.*, i.* FROM shop_order o
LEFT JOIN order_item i ON o.id = i.order_id
WHERE o.buyer_id = ?
ORDER BY o.created_at DESC

在常见场景下,这通常能把 N+1 收敛到一条 SQL。

@EntityGraph vs JOIN FETCH#

维度 @EntityGraph JOIN FETCH
声明位置 Repository 方法注解 @Query 中写 JPQL
灵活性 低(只能指定关联路径) 高(可以写复杂查询)
返回值 可以返回 Entity 可能产生重复(需 DISTINCT)
常见场景 标准 CRUD 复杂查询

参考:JPA EntityGraph DocumentationVlad Mihalcea: JPA Entity Graph


@Transactional 传播行为#

传播类型对比#

传播类型 行为 适用场景
REQUIRED(默认) 加入现有事务,没有则新建 绝大多数场景
REQUIRES_NEW 始终新建事务,挂起现有事务 日志记录、审计
NESTED 嵌套事务(SavePoint) 部分提交场景
NOT_SUPPORTED 非事务执行 批量操作
NEVER 不能在事务中 纯查询工具
MANDATORY 必须在事务中 强制事务上下文

shop 项目当前更容易验证的事务实践#

@Service
public class OrderApplicationService {

    @Transactional
    public OrderApi.OrderResponse createOrder(OrderApi.CreateOrderRequest request) {
        // 创建订单 + 写入订单项 + 写入 outbox
    }

    @Transactional(readOnly = true)
    public List<OrderApi.OrderResponse> listOrders(OrderApi.ListOrdersRequest request) {
        // 只读事务,完成订单和订单项的组装
    }
}

readOnly = true 的作用

  • Hibernate 跳过脏检查(提升性能)
  • MySQL 可能选择只读优化路径
  • 文档意图:明确这个方法不修改数据

在当前仓库里,最常见的是 REQUIRED + readOnly = true 这组组合。REQUIRES_NEW 当然仍然是常见设计手段,但这篇文章更适合把它表述为“可选策略”,而不是“shop 仓库已经大量采用的事实”。

参考:Spring @Transactional DocumentationPropagation Behaviors


Testcontainers @ServiceConnection 集成#

传统测试的痛点#

// ❌ 旧方式:手动管理容器生命周期
@Testcontainers
@SpringBootTest
class OrderServiceTest {
    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
            .withDatabaseName("shop_order");

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
}

@ServiceConnection(Spring Boot 3.1+)#

@Testcontainers
public abstract class AbstractMySqlIntegrationTest {

    @Container
    @ServiceConnection
    protected static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
            .withDatabaseName("shop_order")
            .withUsername("shop")
            .withPassword("shop-secret");
}

这也是当前 shop 仓库里的真实写法:@ServiceConnection 自动把容器连接信息注入 Spring 上下文,不需要再手写 DynamicPropertySource

Flyway 自动迁移#

测试启动时,Flyway 自动执行所有迁移脚本:

@Test
void contextLoads() {
    // Flyway 已经执行了 V1__init.sql, V2__...
    // 数据库 schema 是最新的
}

参考:Spring Boot Testcontainers@ServiceConnection Documentation


Flyway 进阶实践#

基线迁移#

对于已有数据的数据库引入 Flyway 时:

-- V1__baseline.sql
-- 这是一个空迁移,标记当前 schema 状态
spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: "1"

repair-on-migrate#

spring:
  flyway:
    repair-on-migrate: true

当手动修改过 schema(如本地开发调试)时,Flyway 先修复 metadata 再执行迁移。注意:生产环境需谨慎使用。

多数据源#

每个服务只有一个数据源,但如果需要多数据源:

@Configuration
public class MultiDataSourceConfig {
    @Bean
    @Primary
    Flyway primaryFlyway(@Qualifier("primaryDataSource") DataSource ds) {
        return Flyway.configure().dataSource(ds).load();
    }

    @Bean
    Flyway secondaryFlyway(@Qualifier("secondaryDataSource") DataSource ds) {
        return Flyway.configure().dataSource(ds).load();
    }
}

参考:Flyway Versioned MigrationsFlyway Baselines


MySQL 8.4 特性应用#

MySQL 8.x(包括 8.4 LTS)已经稳定提供了几类对微服务很有价值的能力:

CTE(通用表表达式)#

-- 递归 CTE:查询订单及其所有子订单
WITH RECURSIVE order_tree AS (
    SELECT id, parent_id, 1 AS level FROM shop_order WHERE id = ?
    UNION ALL
    SELECT o.id, o.parent_id, ot.level + 1
    FROM shop_order o JOIN order_tree ot ON o.parent_id = ot.id
)
SELECT * FROM order_tree;

窗口函数#

-- 每个买家的订单按时间排名
SELECT buyer_id, order_no, total_amount,
       ROW_NUMBER() OVER (PARTITION BY buyer_id ORDER BY created_at DESC) AS rn
FROM shop_order;

JSON 函数#

-- 从 JSON 列中提取字段
SELECT JSON_EXTRACT(config, '$.gameType') AS game_type
FROM activity_game;

参考:MySQL 8.4 Release NotesMySQL CTE Documentation


参考与实现位置#


小结#

Spring Boot 3.5 下数据库实战的核心要点:

  • HikariCP 调优:把 pool sizing 公式当成压测起点,尽量不要把某个数字当成通用最优值
  • open-in-view: false:在我当前的实践里,关闭 OSIV 仍然值得优先评估,但当前仓库只在部分服务里显式配置
  • N+1 防护@EntityGraph / JOIN FETCH 是常见工具,但当前仓库还没把它们统一沉淀成主路径
  • @Transactional 传播:当前仓库以 REQUIREDreadOnly = true 为主,REQUIRES_NEW 更适合作为补充策略
  • Testcontainers @ServiceConnection:一行注解完成容器连接注入,Flyway 自动迁移
  • Flyway 进阶:基线迁移、repair-on-migrate、多数据源
  • MySQL 8.4:CTE、窗口函数、JSON 函数提升查询能力

项目仓库:github.com/meirongdev/shop