CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
Spring Data 扩展
这一课你将能做到
说清 Spring Data 家族(JPA / JDBC / MongoDB / Redis)的分工,理解「一服务一库」的选型逻辑
在 problem-service 里用 Spring Data JPA 建出题目与测试用例的一对多关系,并完成分页查询
在 judge-service 里用 MongoDB 存判题记录、用 Redis 缓存判题结果,并说清为什么判题记录适合 Mongo
为什么要学
微服务「拆」的不只是代码,还有数据库。CodeForge 单体时代所有表在一个 MySQL 里,一条 JOIN 就能查出「用户 + 提交记录 + 题目」;拆成 user-service、problem-service、judge-service 三个服务后,数据物理上分开了,跨服务不允许 JOIN,更不允许直连别人的库 。这时每个服务都要回答两个问题:
选什么存储 ——关系型、文档库还是缓存?选型依据是数据的读写模式和一致性要求,而不是「团队只会 MySQL」。
数据怎么聚 ——面板要展示「用户信息 + 在学题目」,两边分属两个库,只能通过服务 API(Feign)聚合。
Spring Data 是解决第二个问题的统一抽象:无论底层是 MySQL、MongoDB 还是 Redis,都提供同一种 Repository 风格的编程模型——定义接口 + 方法名,框架自动生成实现。换存储时,业务代码的心智成本被压到最低。
先建立图像
子项目 存的东西 本站对应 典型场景 Spring Data JPA 关系型数据库(Hibernate 实现) problem-service 的题目/测试用例 强一致性、多表关系、事务 Spring Data JDBC 关系型(轻量,无懒加载无一级缓存) 简单表场景的替代方案 厌倦 JPA 复杂映射时 Spring Data MongoDB 文档数据库 judge-service 的判题明细 写入量大、字段结构多变 Spring Data Redis 键值缓存 判题结果缓存、验证码、登录态 毫秒级读、可丢失、带 TTL
problem-service ──── MySQL(题目/用例,关系模型,需要事务)
judge-service ─┬── MongoDB(判题明细,写多读少,schema 灵活)
└── Redis(最近判题结果缓存,TTL 30 分钟)
user-service ──── MySQL(用户/会员)
▲
三个库互不可见,聚合靠 Feign 调 API
这套「每个服务选最适合的存储」的做法有个专门的名字:polyglot persistence(多语言持久化) 。代价是运维复杂度上升、跨库无事务——所以单体够用时别急着拆。
跟着做
步骤 1:problem-service 接入 JPA
pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
spring:
datasource:
url: jdbc:mysql://localhost:3306/codeforge_problem?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
jpa:
hibernate:
ddl-auto: update # 演示用;生产禁止,表结构走迁移脚本
open-in-view: false # 关掉 OSIV,避免事务边界蔓延到 Controller
show-sql: true
步骤 2:定义题目与测试用例实体(一对多关系) package com.example.problem.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "problem")
public class Problem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
private String difficulty; // EASY / MEDIUM / HARD
@OneToMany(mappedBy = "problem", cascade = CascadeType.ALL, orphanRemoval = true)
private List<TestCase> testCases = new ArrayList<>();
public void addTestCase(TestCase tc) {
testCases.add(tc);
tc.setProblem(this); // 双向关系要两边同时设置
}
// getter/setter 省略
}
package com.example.problem.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "test_case")
public class TestCase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "TEXT")
private String input;
@Column(columnDefinition = "TEXT")
private String expectedOutput;
private boolean sample; // 是否示例用例(前端展示用)
@ManyToOne(fetch = FetchType.LAZY) // 多方必须 LAZY,防止连带查题目
@JoinColumn(name = "problem_id")
private Problem problem;
private LocalDateTime createdAt = LocalDateTime.now();
// getter/setter 省略
}
步骤 3:Repository 与分页查询 package com.example.problem.repository;
import com.example.problem.entity.Problem;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProblemRepository extends JpaRepository<Problem, Long> {
Page<Problem> findByDifficulty(String difficulty, Pageable pageable);
Page<Problem> findByTitleContaining(String keyword, Pageable pageable);
}
Pageable 由 Controller 接收并下传,Spring 自动解析 ?page=0&size=10&sort=id,desc:
package com.example.problem.controller;
import com.example.problem.entity.Problem;
import com.example.problem.repository.ProblemRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/problems")
public class ProblemController {
private final ProblemRepository problemRepository;
public ProblemController(ProblemRepository problemRepository) {
this.problemRepository = problemRepository;
}
@GetMapping
public Page<Problem> list(
@RequestParam(required = false) String difficulty,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
return (difficulty == null || difficulty.isBlank())
? problemRepository.findAll(pageable)
: problemRepository.findByDifficulty(difficulty, pageable);
}
}
启动后 ddl-auto: update 自动建表;造几条数据,访问 http://localhost:8082/problems?difficulty=EASY&page=0&size=5 验证分页。Feign 那一课的 getProblem 接口,实现就可以换成这个 Repository。
步骤 4:judge-service 接入 MongoDB 判题明细(每个测试点的输入输出对比、耗时)字段结构随语言和题型变化,写入频率高、单条不需要事务——文档库正合适。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
spring:
data:
mongodb:
uri: mongodb://localhost:27017/codeforge_judge
package com.example.judge.document;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
import java.util.List;
@Document("submission_records")
public class SubmissionRecord {
@Id
private String id; // Mongo 自动生成的 ObjectId
@Indexed // 建索引,加速按用户查询
private Long userId;
@Indexed
private Long problemId;
private String language; // java / python / cpp ...
private String status; // AC / WA / TLE / RE
private Integer timeMs; // 总耗时
private List<CaseResult> caseResults; // 每个测试点的明细,直接内嵌
public record CaseResult(int index, boolean passed, Integer costMs, String message) {}
private Instant judgedAt = Instant.now();
// getter/setter 省略
}
Repository——方法名即查询条件,框架自动翻译成 MongoDB 查询:
package com.example.judge.repository;
import com.example.judge.document.SubmissionRecord;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
public interface SubmissionRecordRepository extends MongoRepository<SubmissionRecord, String> {
List<SubmissionRecord> findByUserIdAndProblemIdOrderByJudgedAtDesc(
Long userId, Long problemId);
}
步骤 5:判题结果写 Redis 缓存 同一份代码对同一道题的判题结果是不变的——缓存它能显著减少无意义的重复判题(判题要跑不可信代码,成本高还有安全风险)。
Redis 配置(JSON 序列化,避免 JDK 原生序列化的乱码和类路径耦合):
package com.example.judge.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
package com.example.judge.service;
import com.example.judge.document.SubmissionRecord;
import com.example.judge.repository.SubmissionRecordRepository;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class JudgeService {
private static final String CACHE_KEY_PREFIX = "judge:result:";
private final SubmissionRecordRepository recordRepository;
private final RedisTemplate<String, Object> redisTemplate;
public JudgeService(SubmissionRecordRepository recordRepository,
RedisTemplate<String, Object> redisTemplate) {
this.recordRepository = recordRepository;
this.redisTemplate = redisTemplate;
}
public SubmissionRecord judge(Long userId, Long problemId, String codeHash,
String language, String code) {
String cacheKey = CACHE_KEY_PREFIX + problemId + ":" + codeHash;
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof SubmissionRecord hit) {
return hit; // 缓存命中,跳过真实判题
}
SubmissionRecord record = doJudge(userId, problemId, language, code);
recordRepository.save(record); // 明细落 Mongo
redisTemplate.opsForValue().set(cacheKey, record, Duration.ofMinutes(30));
return record;
}
private SubmissionRecord doJudge(Long userId, Long problemId,
String language, String code) {
// 沙箱执行、逐用例比对……课程 Demo 返回模拟结果
SubmissionRecord r = new SubmissionRecord();
r.setUserId(userId);
r.setProblemId(problemId);
r.setLanguage(language);
r.setStatus("AC");
r.setTimeMs(120);
return r;
}
}
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
讲清楚要点
polyglot persistence 是选型原则不是口号 :题目库要关系模型和事务 → MySQL;判题明细写多、结构多变 → MongoDB;热点结果要毫秒级读、允许丢失 → Redis。反过来「全站一把 MySQL 梭哈」在数据模式不匹配时会付出 N 倍成本(比如用 MySQL 存日志然后疯狂清理分区)。
拆库的铁律 :跨服务禁止 JOIN (物理上做不到)、禁止直连别人的库 (表结构一改,调用方集体静默出错——这就是分布式里的隐式耦合)。数据聚合只走两条路:实时走 Feign 调对方 API,异步走事件(MQ 广播「题目更新了」,各服务订阅自己关心的部分)。
JPA 关系映射 :@OneToMany(mappedBy = "problem") 是关系的「被维护端」,真正的外键列在 @ManyToOne 一侧(@JoinColumn(name="problem_id"));双向关系必须用 addTestCase 这类辅助方法同时设置两端,否则内存状态和数据库不一致。
@ManyToOne 默认 EAGER 是坑 :查一个 TestCase 会连带 SELECT 它的题目,层层蔓延。多方一律 FetchType.LAZY;open-in-view: false 关掉 OSIV,让懒加载异常在 Service 层就暴露而不是拖到 Controller 才炸。
N+1 问题 :查 10 个 Problem 再逐个取 testCases = 1 + 10 条 SQL。解法:@EntityGraph(attributePaths = "testCases") 或 JOIN FETCH 一次取回;确定不用关联数据就别映射双向关系。
Mongo 方法名查询 :findByUserIdAndProblemIdOrderByJudgedAtDesc 会被解析为 {userId: ?, problemId: ?} + 按 judgedAt 降序的查询;高频查询字段加 @Indexed,没索引的查询在数据量上来后是全表扫描。
Redis 缓存的三个必配项 :序列化器(用 JSON,别用 JDK 序列化)、TTL(永远设,防脏数据永生)、缓存 key 设计(judge:result:{problemId}:{codeHash} 这种带维度前缀的结构,天然防冲突、好批量清理)。
你可能会懵的地方
为什么判题记录用 Mongo 而不是 MySQL?
三个理由:①写吞吐——每次提交至少写一条记录,判题高峰 Mongo 的写模型更省心;②schema 灵活——caseResults 数组长度不定,不同语言的明细字段不同,文档模型不用为「可选字段」建一堆 NULL 列;③查询模式简单——判题明细只按用户/题目查列表,不需要跨表 JOIN 和复杂事务。反之题目库需要严格约束和事务,留在 MySQL。
一服务一库,那跨服务的数据一致性怎么办?
接受最终一致:写操作在自己库内用本地事务保证原子性;跨服务用「本地事务 + 事件通知」(发 MQ 消息)+ 补偿重试。分布式事务(Seata/SAGA)是最后手段,能靠业务设计绕开就绕开——比如「提交判题」本来就不需要和「扣会员次数」在同一个事务里。
open-in-view 是什么?为什么关掉?
OSIV(Open Session In View)让 Hibernate Session 活到整个 HTTP 请求结束,Controller 里也能触发懒加载。代价:数据库连接被占用整个请求周期,高并发下连接池被拖垮。关掉后,所有需要在视图层用的数据必须在 Service 层(事务内)取齐,架构更干净。
Spring Data JPA 和 MyBatis 怎么选?
JPA:标准 CRUD 和简单关联效率极高,Repository 方法名零 SQL;复杂报表 SQL 写 HQL/JPQL 反而别扭。MyBatis:SQL 全部手写可控,国内主流。本 track 用 JPA 是为了展示 Spring Data 的统一抽象;二者在微服务里共存完全没问题——按团队和场景选。
Redis 缓存和数据库不一致怎么办?
本课场景是「判完写缓存」的只读加速,不存在不一致。通用场景常用「先更新数据库,再删除缓存」(Cache Aside),配合 TTL 兜底;强一致要求高的数据(比如库存扣减)根本不该进这种缓存。
错法对照 错法 问题 正解 user-service 直连 judge 库查提交记录 表结构耦合,跨服务改表静默出错 Feign 调 judge-service 的 API 拿数据 @ManyToOne 保持默认 EAGER查一个用例连带拉整道题,查询失控 显式 FetchType.LAZY ddl-auto: update 上生产表结构漂移无审计、不可回滚 生产用迁移脚本(Flyway/Liquibase) Mongo 高频查询字段不加索引 数据量上去后全表扫描 @Indexed 或手动建复合索引RedisTemplate 不配序列化器 存出 JDK 序列化乱码,跨语言不可读 String + GenericJackson2JsonRedisSerializer
卡点急救
启动报 Unable to determine Dialect 或连不上库 :确认 MySQL 已启动、url 的库名存在(可先手动 CREATE DATABASE codeforge_problem);驱动坐标用 com.mysql:mysql-connector-j(旧坐标 mysql:mysql-connector-java 已弃用)。
LazyInitializationException :在事务外访问了懒加载属性。把关联数据的读取挪进 Service 层事务内,或用 @EntityGraph / JOIN FETCH 一次性取回,或对接口返回直接用 DTO 投影。
Mongo 查询报 Cannot autowire MongoRepository :确认 yml 的 spring.data.mongodb.uri 配置了(新版是 spring.data.mongodb.*,不是 spring.mongodb.*);本地没装 Mongo 可用 Docker 一行起:docker run -d -p 27017:27017 mongo:7。
面试常问
微服务「一服务一库」的一致性如何取舍?
收益:数据自治、存储按需选型、故障隔离、团队解耦。代价:跨服务无事务、无 JOIN,一致性只能最终一致。取舍原则:强一致场景(下单扣库存)尽量收敛在单服务单库内用本地事务;跨服务用「本地事务 + 可靠消息 + 补偿」达成最终一致,必要时才引入 SAGA/Seata。本质是用业务流程设计换技术复杂度。
MyBatis/JPA 场景下的 N+1 问题怎么发现和解决?
现象:查列表后日志里每个元素都多出一条关联查询 SQL。发现:开 show-sql: true 或 p6spy 观察执行计划。解决:①JOIN FETCH 或 @EntityGraph 一次取回;②批处理(in 查询合并,default_batch_fetch_size);③不需要关联就改成 DTO 投影只查本表。本质都是把 N 次往返压成 1 次。
为什么判题记录适合存 MongoDB?
写多读少且单次提交必写一条,Mongo 的写入模型吞吐更好;caseResults 是不定长数组、不同语言明细字段有差异,文档模型免建大量可空列;查询模式只有「按用户/题目查记录」,用不到关系代数和跨表事务。顺带能答出「热点判题结果再叠一层 Redis 缓存,TTL 控制」是加分项。
自测三问
跨服务要展示「用户信息 + 最近判题记录」,正确的做法是什么?错误的两种做法是什么?
mappedBy 写在哪一端?外键列实际建在哪张表?
Redis 缓存的三要素(序列化、TTL、key 设计)分别防止什么问题?
小结一句话 Spring Data 用统一的 Repository 抽象抹平存储差异,微服务按数据特征选存储、按 API 或事件聚合数据——服务边界就是数据库边界。
延伸