Spring 全栈硬核教程 · 04:Spring Data(JPA / MyBatis / Redis / 事务 / R2DBC)
数据访问层是事故密度最高的一层。这一篇除了讲怎么用,更重要的是讲哪些写法是定时炸弹——包括一个几乎所有团队都踩过、但很少有人明确说出来的问题:事务方法里调用 HTTP 接口。
1. 持久层框架选型:一张表定方向
| 维度 | Spring Data JPA | MyBatis | MyBatis-Plus | jOOQ |
|---|---|---|---|---|
| 范式 | 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 最著名的性能杀手
// ❌ 看起来人畜无害,实际执行了 1 + N 条 SQL
List<Order> orders = orderRepository.findAll(); // 1 条:查 100 个订单
for (Order o : orders) {
System.out.println(o.getUser().getName()); // N 条:每个订单查一次用户!
}100 个订单 = 101 次数据库往返。本地测试数据少感觉不出来,生产上直接把数据库打爆。
三种解法:
// 解法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:
spring.jpa.properties.hibernate.jdbc.batch_size: 50
spring.jpa.properties.hibernate.order_inserts: true3. MyBatis-Plus 实战要点
@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;
}乐观锁:库存扣减的正确姿势
// ❌ 并发下超卖
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 接口 = 定时炸弹
// ❌ 这段代码在生产上一定会出事
@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 调用如果慢了(下游抖动、网络问题),数据库连接就被白白占着。并发一上来,连接池瞬间打满,整个应用的所有数据库操作全部卡死——一个下游服务的抖动,被放大成了自己的全站故障。
正确做法:
@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 事务提交前发消息 = 数据不一致
// ❌ 消息发出去了,事务回滚了 → 下游按"订单已创建"处理,实际库里没有
@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 只读事务的价值被低估
@Transactional(readOnly = true)
public Page<OrderDTO> queryOrders(OrderQuery q, Pageable p) { ... }readOnly = true 会让 Hibernate 跳过脏检查(dirty checking)和快照,减少内存开销;在读写分离架构下还能让路由自动走从库。查询方法统一加上它,是低成本高收益的优化。
5. 连接池:HikariCP 参数硬核解读
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 声明式缓存与它的坑
@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 高可用集群;熔断降级兜底 |
// 防雪崩:过期时间加抖动
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. 读写分离与分库分表
// 轻量方案: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:响应式数据访问
public interface BookRepository extends ReactiveCrudRepository<Book, Long> {
Flux<Book> findByAuthor(String author);
}| 维度 | JDBC | R2DBC |
|---|---|---|
| 模型 | 阻塞 | 非阻塞 |
| 生态 | 极其成熟 | 不成熟(部分数据库驱动功能残缺,缺少成熟 ORM) |
| 配套 | Hibernate/MyBatis 全支持 | 只有 Spring Data R2DBC(能力远弱于 JPA) |
| 值得上吗 | —— | ⚠️ 多数场景不值得 |
诚实的结论:R2DBC 只在"全链路 WebFlux 且数据库是瓶颈"的场景有意义。虚拟线程出现后,R2DBC 的必要性进一步下降——用虚拟线程 + 传统 JDBC,既拿到了高并发,又保留了成熟生态和简单心智。除非你有非常明确的理由,否则不建议在 2026 年新项目里引入 R2DBC。
9. 本篇自测
- N+1 查询是什么?三种解法分别适合什么场景?怎么在开发阶段发现它?
spring.jpa.open-in-view默认为 true 有什么代价?为什么建议关掉?- 为什么说"事务方法里调 HTTP 接口"是定时炸弹?它会引发什么样的连锁故障?
REQUIRES_NEW在高并发下的连接放大问题怎么产生的?- 为什么连接池不是越大越好?怎么确定合适的大小?
leak-detection-threshold能解决什么问题?- 缓存穿透、击穿、雪崩的区别和各自解法?
- 为什么不建议手写 SETNX 分布式锁?分布式锁能作为正确性的唯一保障吗?
下一篇(05 · Spring Security):过滤器链的完整顺序、认证授权的核心模型、JWT 的取舍、OAuth2 到底在解决什么问题,以及 Spring Security 7 那些破坏性变更——如果你的项目要升级 Boot 4,这一篇的迁移清单请务必读完。