SpringNotes

第 11 章:数据访问与事务

zjc 于 2026-01-11 发布

这是《Spring Boot 与 Spring Cloud 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 数据访问层的目标不是消除 SQL,而是把连接管理、事务边界、异常转换、资源释放和重复模板代码交给框架,让开发者专注业务模型和 SQL 质量。

11.1 技术选择

技术 适合场景
Spring JDBC / JdbcClient 明确 SQL 控制、复杂查询、低抽象成本
Spring Data JPA 领域模型稳定、CRUD 丰富、对象图清晰
MyBatis / MyBatis-Plus SQL 可控、团队熟悉、复杂报表多
R2DBC Reactive 数据访问
JOOQ 类型安全 SQL

选择原则:

  1. 团队能力;
  2. SQL 复杂度;
  3. 性能要求;
  4. 领域模型复杂度;
  5. 维护周期;
  6. 招聘成本;
  7. 现有代码风格。

不要因为某个框架流行就频繁迁移。

11.2 DataSource 与连接池

常用 HikariCP 配置:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/order?useSSL=false&serverTimezone=Asia/Shanghai
    username: order
    password: ${DB_PASSWORD}
    hikari:
      pool-name: order-hikari
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 2000
      validation-timeout: 1000
      max-lifetime: 1800000
      leak-detection-threshold: 10000

连接池不是越大越好。过大可能造成数据库上下文切换和锁等待,过小会造成请求排队。应基于数据库最大连接数、服务实例数和单请求持连接时间计算。

估算:

每实例最大连接 * 实例数 + 运维预留 <= 数据库可承受连接

11.3 JdbcClient

Spring Framework 6.1 引入 JdbcClient,提供更简洁的 JDBC API:

@Repository
public class JdbcOrderRepository {
    private final JdbcClient jdbcClient;

    public JdbcOrderRepository(JdbcClient jdbcClient) {
        this.jdbcClient = jdbcClient;
    }

    public Optional<Order> findById(Long id) {
        return jdbcClient.sql("select id, user_id, status, amount from orders where id = :id")
                .param("id", id)
                .query(Order.class)
                .optional();
    }

    public int insert(Order order) {
        return jdbcClient.sql("""
                insert into orders(id, user_id, status, amount, created_at)
                values(:id, :userId, :status, :amount, :createdAt)
                """)
                .param("id", order.id())
                .param("userId", order.userId())
                .param("status", order.status())
                .param("amount", order.amount())
                .param("createdAt", order.createdAt())
                .update();
    }
}

SQL 必须使用绑定参数,避免字符串拼接导致注入。

11.4 Spring Data JPA

实体:

@Entity
@Table(name = "orders")
public class OrderEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 64)
    private String userId;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private BigDecimal amount;

    @CreationTimestamp
    private Instant createdAt;
}

Repository:

public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
    List<OrderEntity> findByUserIdAndStatus(String userId, OrderStatus status, Pageable pageable);
}

适合:

  1. 领域模型与表结构较接近;
  2. 常规 CRUD 和简单查询;
  3. 持久化 dirty tracking 有价值;
  4. 团队理解 Hibernate 生命周期。

复杂报表、批量大写入、多表优化 SQL 时,应使用投影 DTO、原生 SQL 或 JDBC。

11.5 N+1 问题

错误代码:

List<OrderEntity> orders = repository.findAll();
for (OrderEntity order : orders) {
    total += order.getItems().size(); // 每次可能触发一条 SQL
}

解决:

@EntityGraph(attributePaths = "items")
List<OrderEntity> findByUserId(String userId);

或 JPQL fetch join:

@Query("select distinct o from OrderEntity o join fetch o.items where o.userId = :userId")
List<OrderEntity> findWithItems(String userId);

开启 SQL 日志是发现 N+1 的第一步:

logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

Hibernate 5 与 6 的绑定日志参数不同,需要按版本调整 logger。

11.6 事务基础

声明式事务:

@Service
public class OrderService {

    @Transactional
    public Order create(CreateOrderCommand command) {
        Order order = Order.create(command);
        repository.save(order);
        outboxRepository.save(OrderEvent.outbox(order));
        return order;
    }
}

流程:

TransactionInterceptor
  -> 获取连接并关闭自动提交
  -> 执行业务
  -> 正常提交
  -> 异常按回滚规则回滚
  -> 释放连接

事务应尽量短:

  1. 不在事务中调用慢 RPC;
  2. 不在事务中处理大文件;
  3. 不在事务中发送不可回滚消息;
  4. 批量任务分批提交;
  5. 查询方法按需只读事务。

11.7 传播行为

传播行为 说明
REQUIRED 当前有事务则加入,否则新建
REQUIRES_NEW 挂起当前事务,新建事务
NESTED 保存点嵌套,依赖驱动支持
SUPPORTS 有事务则加入,没有则非事务执行
NOT_SUPPORTED 挂起事务,非事务执行
NEVER 存在事务则报错
MANDATORY 必须已有事务,否则报错

示例:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAudit(AuditRecord record) {
}

REQUIRES_NEW 会占用第二个连接,高并发下容易造成连接池耗尽。操作日志不一定必须独立事务,可通过异步事件或最终一致方案处理。

11.8 隔离级别

隔离级别 常见问题
READ UNCOMMITTED 可能脏读
READ COMMITTED 避免脏读,可能不可重复读
REPEATABLE READ 避免不可重复读,幻读行为与数据库实现有关
SERIALIZABLE 强隔离,代价高

Spring 可声明:

@Transactional(isolation = Isolation.READ_COMMITTED)

实际隔离效果还取决于数据库引擎和配置。MySQL InnoDB 的实现与 SQL 标准 isolation name 不完全一一对应,需要结合当前数据库文档分析。

11.9 事务失效场景

常见错误:

@Service
public class OrderService {

    @Transactional
    public void create(Order order) {
        repository.save(order);
        this.sendNotification(order); // 自调用
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Order order) {
    }
}

失效原因:

场景 原因
自调用 不经过代理
非 public 方法 Spring 事务代理默认不支持
异常被 catch 吞掉 拦截器看不到异常
抛出受检异常 默认不回滚,需 rollbackFor
Bean 未被 Spring 管理 没有代理
数据源/事务管理器配置错误 连接不在事务上下文
多线程执行 事务绑定 ThreadLocal

正确处理受检异常:

@Transactional(rollbackFor = Exception.class)
public void importOrders(File file) throws IOException {
}

11.10 分布式数据一致性

本地事务不能覆盖多个数据库或多个服务。常见方案:

方案 适用
本地消息表 + 异步投递 可接受最终一致
Outbox + CDC 消息可靠性高
TCC 需要强业务补偿
Saga 长流程编排
2PC/XA 强一致,吞吐和运维成本高

Outbox 示例:

本地事务:
  写 orders
  写 outbox_events

后台任务或 CDC:
  读取 outbox
  发送 Kafka
  标记已发送

不要在本地事务提交前直接发送消息,因为消息发送成功而事务回滚会造成幻象消息。

11.11 数据访问监控

指标:

hikaricp_connections_active
hikaricp_connections_pending
hikaricp_connections_timeout
jdbc_query_seconds
jdbc_query_total
spring_data_repository_seconds

慢 SQL:

  1. 开启数据库慢日志;
  2. 应用记录 SQL 耗时;
  3. 使用 APM 或 Micrometer Tracing;
  4. 定期审查执行计划;
  5. 加索引要评估写入成本。

连接池等待告警:

active == maximum
pending > 0
connection timeout count 增长

本章小结

Spring 提供了从 JDBC 到 JPA 的多层次数据访问抽象,核心价值是资源管理、事务边界和异常统一。生产中要控制连接池规模、避免 N+1 和长事务,理解传播行为和事务失效原因。跨库跨服务一致性应引入 Outbox、Saga 或 TCC 等方案,而不是期待 @Transactional 覆盖分布式场景。

思考题

  1. 如何估算 HikariCP 最大连接数?
  2. JPA 和 JDBC 各适合什么场景?
  3. REQUIRES_NEW 有什么风险?
  4. 哪些常见写法会导致事务失效?
  5. 为什么推荐 Outbox 而不是事务内直接发消息?