Skip to content

Spring 全栈硬核教程 · 04:Spring Data(JPA / MyBatis / Redis / 事务 / R2DBC) ​

数据访问层是事故密度最高的一层。这一篇除了讲怎么用,更重要的是讲哪些写法是定时炸弹——包括一个几乎所有团队都踩过、但很少有人明确说出来的问题:事务方法里调用 HTTP 接口。


1. 持久层框架选型:一张表定方向 ​

维度Spring Data JPAMyBatisMyBatis-PlusjOOQ
范式ORM,对象导航SQL 映射MyBatis + 单表自动化类型安全 SQL DSL
SQL 掌控弱(复杂查询靠 JPQL/Criteria,容易失控)强,完全可控简单自动 + 复杂手写极强,编译期校验
复杂多表 JOIN❌ 弱项✅ 强项✅ 强项✅ 强项
单表 CRUD 效率✅ 方法名即查询❌ 要写一堆 XML✅ 零代码中等
数据库无关性✅ 强(换库基本不改代码)❌ SQL 方言绑定❌ 同 MyBatis✅ 强
国内使用率中(DDD 团队、外企)高(政企、传统行业)最高(互联网中小团队)低
主要风险N+1 查询、懒加载异常、隐式 SQL 不可控XML 维护成本、动态 SQL 可读性过度依赖 Wrapper 导致复杂逻辑难读生态小众

选型建议:

  • 教学场景:先教 MyBatis。SQL 完全可见,学生能直观看到"我写的 SQL 长什么样、数据库执行了什么",对建立数据库思维至关重要。JPA 那层抽象会让新手永远不知道自己触发了什么 SQL。
  • 国内互联网项目:MyBatis-Plus 是事实标准,单表 CRUD 零代码 + 复杂查询手写 SQL 的组合最务实。
  • JPA 适合:数据模型稳定、以单表/简单关联为主、团队有 DDD 实践、需要数据库无关性的场景。
  • 别在一个项目里混用 JPA 和 MyBatis——两套一级缓存/会话管理机制打架,事务边界会变得极其难以推理。

2. JPA 的三个必知陷阱 ​

2.1 N+1 查询:ORM 最著名的性能杀手 ​

java
// ❌ 看起来人畜无害,实际执行了 1 + N 条 SQL
List<Order> orders = orderRepository.findAll();          // 1 条:查 100 个订单
for (Order o : orders) {
    System.out.println(o.getUser().getName());           // N 条:每个订单查一次用户!
}

100 个订单 = 101 次数据库往返。本地测试数据少感觉不出来,生产上直接把数据库打爆。

三种解法:

java
// 解法1:JOIN FETCH(最直接)
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findByStatusWithUser(@Param("status") OrderStatus status);

// 解法2:@EntityGraph(更声明式)
@EntityGraph(attributePaths = {"user", "items"})
List<Order> findByStatus(OrderStatus status);

// 解法3:批量抓取(全局配置,缓解而非根治)
// application.yml: spring.jpa.properties.hibernate.default_batch_fetch_size: 100

怎么发现自己中招了:开发环境开 spring.jpa.show-sql: true 或引入 p6spy,看一个接口到底打了多少条 SQL。很多团队上线后才发现某个列表接口打了三百条 SQL,而这本来在开发阶段一眼就能看出来。

2.2 LazyInitializationException ​

懒加载属性只能在事务/Session 打开期间访问。Controller 里访问 Service 返回的实体的懒加载字段 → 事务早就关了 → 抛异常。

正确做法:Service 层内完成 DTO 转换,绝不把实体对象暴露到 Controller 层。这条规则同时解决了懒加载异常、循环序列化、字段泄露三个问题。

spring.jpa.open-in-view 的争议:Spring Boot 默认 true,它把 Session 延长到整个请求,掩盖了懒加载异常——代价是数据库连接被占用到视图渲染结束,高并发下连接池极易打满。生产环境建议显式设为 false,然后老老实实在 Service 层处理好数据加载。这是 Spring Boot 里最有争议的默认值之一,很多资深工程师认为它的默认值选错了。

2.3 save() 不一定执行 INSERT ​

JPA 的 save() 语义是"保存或更新"——如果实体有 ID 且数据库存在,它会先 SELECT 再 UPDATE。想强制插入或做批量插入时,这个隐式行为会带来意外的 SQL。批量插入务必用 saveAll() + 开启 JDBC batch:

yaml
spring.jpa.properties.hibernate.jdbc.batch_size: 50
spring.jpa.properties.hibernate.order_inserts: true

3. MyBatis-Plus 实战要点 ​

java
@TableName("t_order")
@Data
public class Order {
    @TableId(type = IdType.ASSIGN_ID)     // 雪花算法 ID,分布式友好
    private Long id;

    private BigDecimal amount;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @Version                               // ⭐ 乐观锁版本号
    private Integer version;

    @TableLogic                            // ⭐ 逻辑删除,delete 自动变 update
    private Integer deleted;
}

乐观锁:库存扣减的正确姿势 ​

java
// ❌ 并发下超卖
Product p = mapper.selectById(id);
p.setStock(p.getStock() - 1);
mapper.updateById(p);

// ✅ 方案1:乐观锁(@Version + OptimisticLockerInnerInterceptor)
// MyBatis-Plus 自动生成:UPDATE ... SET stock=?, version=version+1 WHERE id=? AND version=?
// 更新影响行数为 0 说明被人抢先了,需要重试

// ✅ 方案2:数据库原子操作(高并发下更优,没有重试开销)
@Update("UPDATE t_product SET stock = stock - #{n} WHERE id = #{id} AND stock >= #{n}")
int deductStock(@Param("id") Long id, @Param("n") int n);
// 返回 0 = 库存不足,直接失败,无需重试

选型:写冲突不激烈用乐观锁(重试成本低);秒杀这种高冲突场景直接用方案2的数据库原子操作,把并发控制下推给数据库的行锁,避免应用层大量无效重试。


4. ⭐ 事务的四个生产级陷阱 ​

4.1 事务方法里调用 HTTP 接口 = 定时炸弹 ​

java
// ❌ 这段代码在生产上一定会出事
@Transactional
public void createOrder(CreateOrderCmd cmd) {
    Order order = orderRepository.save(...);           // 占用数据库连接
    paymentClient.charge(order);                       // ⚠️ HTTP 调用,可能耗时 5 秒甚至超时 30 秒
    inventoryClient.deduct(order);                     // ⚠️ 又一个外部调用
    orderRepository.updateStatus(order.getId(), PAID);
}

为什么是炸弹:事务从方法进入就持有数据库连接,直到方法结束才释放。中间这两个 HTTP 调用如果慢了(下游抖动、网络问题),数据库连接就被白白占着。并发一上来,连接池瞬间打满,整个应用的所有数据库操作全部卡死——一个下游服务的抖动,被放大成了自己的全站故障。

正确做法:

java
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderTxService txService;
    private final PaymentClient paymentClient;

    public void createOrder(CreateOrderCmd cmd) {
        Order order = txService.saveOrder(cmd);        // 短事务,快进快出
        paymentClient.charge(order);                   // 事务外调用
        txService.markPaid(order.getId());             // 另一个短事务
    }
}

铁律:事务里只做数据库操作。HTTP 调用、消息发送、文件 IO、大量计算,全部移到事务外。 这条规则能避免掉一大半的"数据库连接池打满"事故。

4.2 REQUIRES_NEW 的连接放大 ​

REQUIRES_NEW 会挂起当前事务、从连接池再取一个连接。一个请求链路上套三层,就同时占用 3 个连接。连接池 20 个连接,7 个并发请求就能打满并自我死锁(外层事务等内层完成,内层在等连接池里已经没有的连接)。

建议:REQUIRES_NEW 只用在"必须独立提交"的场景(审计日志),且必须评估连接池容量。

4.3 事务提交前发消息 = 数据不一致 ​

java
// ❌ 消息发出去了,事务回滚了 → 下游按"订单已创建"处理,实际库里没有
@Transactional
public void createOrder(...) {
    orderRepository.save(order);
    mqProducer.send(new OrderCreatedMsg(order.getId()));   // 事务还没提交!
}

// ✅ 用 @TransactionalEventListener 保证提交后才发
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent e) {
    mqProducer.send(new OrderCreatedMsg(e.orderId()));
}

更严格的场景需要本地消息表 / 事务性发件箱(Transactional Outbox):消息先作为一条记录写进同一个事务的数据库表,由独立的投递任务扫表发送。这是保证"数据库操作和消息发送最终一致"的标准方案,06 篇会详细展开。

4.4 只读事务的价值被低估 ​

java
@Transactional(readOnly = true)
public Page<OrderDTO> queryOrders(OrderQuery q, Pageable p) { ... }

readOnly = true 会让 Hibernate 跳过脏检查(dirty checking)和快照,减少内存开销;在读写分离架构下还能让路由自动走从库。查询方法统一加上它,是低成本高收益的优化。


5. 连接池:HikariCP 参数硬核解读 ​

yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20        # ⚠️ 不是越大越好,见下文
      minimum-idle: 10             # 建议等于 maximum-pool-size,避免频繁创建销毁
      connection-timeout: 3000     # ⭐ 拿不到连接时快速失败(3s),比默认30s好得多
      idle-timeout: 600000
      max-lifetime: 1740000        # ⚠️ 必须小于数据库的 wait_timeout,否则用到已被服务端关闭的死连接
      leak-detection-threshold: 60000   # ⭐ 连接借出超60秒未归还就打印堆栈,排查连接泄漏神器

反直觉:连接池不是越大越好 ​

数据库处理并发连接本身有成本(上下文切换、锁竞争、内存)。业界经验公式是 连接数 ≈ (核心数 × 2) + 有效磁盘数——对一台 8 核数据库,大约 20 个连接就够了,调到 200 反而因为数据库端资源争抢而降低总吞吐。

实战方法:从 10-20 开始压测,观察数据库端的 CPU 和锁等待。如果应用侧在排队等连接但数据库 CPU 很闲,才考虑调大;如果数据库 CPU 已经打满,调大连接池只会让情况更糟。

leak-detection-threshold 强烈建议开启——它能在连接泄漏时直接打印出"是哪行代码借了连接没还",比事后猜测有效一百倍。


6. Redis 集成与缓存设计 ​

6.1 三种接入方式 ​

方式定位场景
RedisTemplate最底层,全命令可用需要精细控制(ZSet 排行榜、Pipeline 批量操作)
@Cacheable 等 Spring Cache声明式,一个注解搞定简单的方法结果缓存
Redisson分布式组件全家桶分布式锁、限流器、延迟队列、布隆过滤器

6.2 声明式缓存与它的坑 ​

java
@Cacheable(value = "books", key = "#id", unless = "#result == null")
public BookDTO getById(Long id) { ... }

@CacheEvict(value = "books", key = "#id")
public void update(Long id, UpdateCmd cmd) { ... }

坑1:和 @Transactional 一样是 AOP 代理实现,同类内部调用失效。 坑2:默认 JDK 序列化,Redis 里存的是一堆乱码且跨语言不可读,务必配置 JSON 序列化器。 坑3:默认没有过期时间,缓存会一直堆积,必须在 RedisCacheConfiguration 里设 TTL。

6.3 缓存三大经典故障 ​

故障场景解法
穿透查一个根本不存在的 key,缓存不命中→每次都打数据库(常被用于恶意攻击)缓存空值(短 TTL)+ 布隆过滤器前置拦截
击穿单个热点 key 过期瞬间,大量并发同时回源热点数据逻辑过期不物理过期(后台异步刷新);或加互斥锁只放一个请求回源
雪崩大量 key 同时过期,或 Redis 整体宕机过期时间加随机抖动;Redis 高可用集群;熔断降级兜底
java
// 防雪崩:过期时间加抖动
long jitter = ThreadLocalRandom.current().nextLong(300);
redis.opsForValue().set(key, value, Duration.ofMinutes(30).plusSeconds(jitter));

// 防击穿:互斥锁回源(Redisson)
public BookDTO getWithLock(Long id) {
    BookDTO cached = readCache(id);
    if (cached != null) return cached;

    RLock lock = redisson.getLock("lock:book:" + id);
    try {
        if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
            cached = readCache(id);              // ⭐ 双重检查:等锁期间别人可能已经填好了
            if (cached != null) return cached;
            BookDTO fresh = loadFromDb(id);
            writeCache(id, fresh);
            return fresh;
        }
        return loadFromDb(id);                   // 拿不到锁就降级直查(或返回旧值)
    } finally {
        if (lock.isHeldByCurrentThread()) lock.unlock();
    }
}

6.4 分布式锁:别手写 SETNX ​

手写 SETNX 锁的常见漏洞:忘记设过期时间(进程崩溃导致死锁)、业务执行超时导致锁提前释放(两个请求同时持锁)、误删别人的锁。

Redisson 的 RLock 已经处理好了这些:自动续期(Watchdog 机制,业务没执行完就一直延长锁)、锁标识校验(只能解自己的锁)、可重入。生产场景直接用 Redisson,别手写。

更进一步的真相:分布式锁在任何实现下都无法做到 100% 可靠(网络分区、GC 停顿、时钟漂移)。对强一致要求的场景(扣钱、扣库存),最终防线应该是数据库的唯一约束或原子更新,分布式锁只是用来减少无效竞争的优化手段,不能当作正确性的唯一保障。


7. 读写分离与分库分表 ​

java
// 轻量方案:AbstractRoutingDataSource + ThreadLocal + AOP 注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DS { String value() default "master"; }

public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.get();
    }
}

陷阱:@Transactional 方法里的数据源在事务开始时就确定了,事务中途切数据源不会生效。所以"读走从库"的注解必须加在没有外层事务的方法上。

规模化方案:分库分表直接上 ShardingSphere(分片、读写分离、影子库、数据加密一站式),别自己造轮子。自研分库分表中间件是经典的"看起来两周能搞定,实际两年还在填坑"的项目。


8. R2DBC:响应式数据访问 ​

java
public interface BookRepository extends ReactiveCrudRepository<Book, Long> {
    Flux<Book> findByAuthor(String author);
}
维度JDBCR2DBC
模型阻塞非阻塞
生态极其成熟不成熟(部分数据库驱动功能残缺,缺少成熟 ORM)
配套Hibernate/MyBatis 全支持只有 Spring Data R2DBC(能力远弱于 JPA)
值得上吗——⚠️ 多数场景不值得

诚实的结论:R2DBC 只在"全链路 WebFlux 且数据库是瓶颈"的场景有意义。虚拟线程出现后,R2DBC 的必要性进一步下降——用虚拟线程 + 传统 JDBC,既拿到了高并发,又保留了成熟生态和简单心智。除非你有非常明确的理由,否则不建议在 2026 年新项目里引入 R2DBC。


9. 本篇自测 ​

  1. N+1 查询是什么?三种解法分别适合什么场景?怎么在开发阶段发现它?
  2. spring.jpa.open-in-view 默认为 true 有什么代价?为什么建议关掉?
  3. 为什么说"事务方法里调 HTTP 接口"是定时炸弹?它会引发什么样的连锁故障?
  4. REQUIRES_NEW 在高并发下的连接放大问题怎么产生的?
  5. 为什么连接池不是越大越好?怎么确定合适的大小?
  6. leak-detection-threshold 能解决什么问题?
  7. 缓存穿透、击穿、雪崩的区别和各自解法?
  8. 为什么不建议手写 SETNX 分布式锁?分布式锁能作为正确性的唯一保障吗?

下一篇(05 · Spring Security):过滤器链的完整顺序、认证授权的核心模型、JWT 的取舍、OAuth2 到底在解决什么问题,以及 Spring Security 7 那些破坏性变更——如果你的项目要升级 Boot 4,这一篇的迁移清单请务必读完。

基于 Vite 强力驱动 | 纯静态轻量托管