CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Cloud 微服务入门/课时/Spring Data 扩展
RawBlame
课时目录10 节

Spring Cloud 微服务入门

  1. 01微服务与 Spring Cloud
  2. 02架构模式与术语
  3. 03服务注册与 Eureka
  4. 04Spring Cloud Gateway
  5. 05Cloud Config 配置中心
  6. 06OpenFeign 服务调用
  7. 07熔断与容错
  8. 08Spring Data 扩展
  9. 09Micrometer 与可观测性
  10. 10综合练习与部署直觉

Spring Data 扩展

Spring Data 家族、MongoDB、与 JPA 分工。

30 分钟

Spring Data 扩展

这一课你将能做到

  1. 说清 Spring Data 家族(JPA / JDBC / MongoDB / Redis)的分工,理解「一服务一库」的选型逻辑
  2. 在 problem-service 里用 Spring Data JPA 建出题目与测试用例的一对多关系,并完成分页查询
  3. 在 judge-service 里用 MongoDB 存判题记录、用 Redis 缓存判题结果,并说清为什么判题记录适合 Mongo

为什么要学

微服务「拆」的不只是代码,还有数据库。CodeForge 单体时代所有表在一个 MySQL 里,一条 JOIN 就能查出「用户 + 提交记录 + 题目」;拆成 user-service、problem-service、judge-service 三个服务后,数据物理上分开了,跨服务不允许 JOIN,更不允许直连别人的库。这时每个服务都要回答两个问题:

  1. 选什么存储——关系型、文档库还是缓存?选型依据是数据的读写模式和一致性要求,而不是「团队只会 MySQL」。
  2. 数据怎么聚——面板要展示「用户信息 + 在学题目」,两边分属两个库,只能通过服务 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>

application.yml:

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

判题明细(每个测试点的输入输出对比、耗时)字段结构随语言和题型变化,写入频率高、单条不需要事务——文档库正合适。

pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

application.yml:

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>

讲清楚要点

  1. polyglot persistence 是选型原则不是口号:题目库要关系模型和事务 → MySQL;判题明细写多、结构多变 → MongoDB;热点结果要毫秒级读、允许丢失 → Redis。反过来「全站一把 MySQL 梭哈」在数据模式不匹配时会付出 N 倍成本(比如用 MySQL 存日志然后疯狂清理分区)。
  2. 拆库的铁律:跨服务禁止 JOIN(物理上做不到)、禁止直连别人的库(表结构一改,调用方集体静默出错——这就是分布式里的隐式耦合)。数据聚合只走两条路:实时走 Feign 调对方 API,异步走事件(MQ 广播「题目更新了」,各服务订阅自己关心的部分)。
  3. JPA 关系映射:@OneToMany(mappedBy = "problem") 是关系的「被维护端」,真正的外键列在 @ManyToOne 一侧(@JoinColumn(name="problem_id"));双向关系必须用 addTestCase 这类辅助方法同时设置两端,否则内存状态和数据库不一致。
  4. @ManyToOne 默认 EAGER 是坑:查一个 TestCase 会连带 SELECT 它的题目,层层蔓延。多方一律 FetchType.LAZY;open-in-view: false 关掉 OSIV,让懒加载异常在 Service 层就暴露而不是拖到 Controller 才炸。
  5. N+1 问题:查 10 个 Problem 再逐个取 testCases = 1 + 10 条 SQL。解法:@EntityGraph(attributePaths = "testCases") 或 JOIN FETCH 一次取回;确定不用关联数据就别映射双向关系。
  6. Mongo 方法名查询:findByUserIdAndProblemIdOrderByJudgedAtDesc 会被解析为 {userId: ?, problemId: ?} + 按 judgedAt 降序的查询;高频查询字段加 @Indexed,没索引的查询在数据量上来后是全表扫描。
  7. 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

卡点急救

  1. 启动报 Unable to determine Dialect 或连不上库:确认 MySQL 已启动、url 的库名存在(可先手动 CREATE DATABASE codeforge_problem);驱动坐标用 com.mysql:mysql-connector-j(旧坐标 mysql:mysql-connector-java 已弃用)。
  2. LazyInitializationException:在事务外访问了懒加载属性。把关联数据的读取挪进 Service 层事务内,或用 @EntityGraph / JOIN FETCH 一次性取回,或对接口返回直接用 DTO 投影。
  3. 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 控制」是加分项。

自测三问

  1. 跨服务要展示「用户信息 + 最近判题记录」,正确的做法是什么?错误的两种做法是什么?
  2. mappedBy 写在哪一端?外键列实际建在哪张表?
  3. Redis 缓存的三要素(序列化、TTL、key 设计)分别防止什么问题?

小结一句话

Spring Data 用统一的 Repository 抽象抹平存储差异,微服务按数据特征选存储、按 API 或事件聚合数据——服务边界就是数据库边界。

延伸

  • Spring Data 官方文档首页
  • Spring Data MongoDB 参考文档
  • 下一课:Micrometer 与可观测性——服务一多,怎么知道「谁慢了、谁挂了」。
加载本节练习…
← 上一课熔断与容错下一课 →Micrometer 与可观测性