学生成绩查询系统:从 5000 人到 50 万人,你会怎么设计它?

0 阅读30分钟

学生成绩查询系统:从 5000 人到 50 万人,你会怎么设计它?

引言

每年期末考试结束的那一刻,全年级 2 万学生同时涌入教务系统查成绩——然后系统就挂了。平时一天几百次访问,期末一天几十万次。为了一年只用 3 天的峰值去买一堆服务器,领导不批。但查分那天系统崩了,学生投诉电话能打爆。

这不是技术难题,这是一道成本与体验的平衡题:平时 5 QPS,期末峰值 5000 QPS,差了 1000 倍——你的系统怎么办?

本文围绕如何实现一个真实的"学生成绩查询系统"为主线,从 5000 人的独立学院到 50 万人的省考试院,一步步演进取架构方案。

环境信息:Spring Boot 3.2.7 | JDK 21 | Redis 7.x | MySQL 8.0 | JMeter 5.6


一、先看清战场:成绩查询的业务特性

架构选型之前,必须先理解业务的流量特征。成绩查询和电商秒杀看起来都是"高并发",但本质完全不同:

维度电商秒杀成绩查询
流量模式瞬时尖峰 + 持续高并发快速爬坡 + 30 分钟内回落
读写比例大量写(下单)+ 读99% 读
数据一致性强一致(库存不能超卖)最终一致(成绩 10 秒前缓存的值依然正确)
用户体验2 秒无响应就流失可以排队等待,甚至接受"30 秒后通知你"
热点数据少量商品是热点所有学生的成绩都是热点(人人都查自己)

关键洞察(务必先读懂)

成绩查询的"读多写少 + 最终一致性容忍"决定了缓存是主武器,不是辅助手段。成绩一旦发布就几乎不变,天然适合缓存。而流量尖峰 + 用户可以排队等待的特性,又给了我们使用异步削峰的空间。

但这里有一个容易误判的关键点:电商秒杀是 "1 个 iPhone 页面被 10000 人同时刷新"(热点 key),而成绩查询是 "10000 个学生各查各的学号"(分散 key)。这意味着很多针对"热点 key"的策略(如分布式锁防击穿)在查分场景中收益有限——文章会逐一标注哪些方案是"对症下药",哪些是"牛刀杀鸡"。

数据模型(贯穿所有版本)

-- 学生表:5 万行
CREATE TABLE t_student (
    id BIGINT PRIMARY KEY,
    student_no VARCHAR(20) NOT NULL UNIQUE,  -- 学号
    name VARCHAR(50) NOT NULL,
    class_id BIGINT NOT NULL,
    INDEX idx_class (class_id)
);

-- 课程表:500 行(基本静态)
CREATE TABLE t_course (
    id BIGINT PRIMARY KEY,
    course_name VARCHAR(100) NOT NULL,
    credit DECIMAL(3,1) NOT NULL
);

-- 成绩表:每学期 50 万行(5 万学生 × 10 门课)
CREATE TABLE t_score (
    id BIGINT PRIMARY KEY,
    student_id BIGINT NOT NULL,
    course_id BIGINT NOT NULL,
    score DECIMAL(5,1),            -- 分数
    semester VARCHAR(20) NOT NULL,  -- 学期 2025-2026-1
    publish_time DATETIME,          -- 成绩发布时间
    INDEX idx_student_semester (student_id, semester),
    INDEX idx_publish (publish_time)
);

核心查询:一个学生查自己某学期的所有成绩——这就是 QPS 最高的接口:

SELECT c.course_name, s.score, s.semester
FROM t_score s
JOIN t_course c ON s.course_id = c.id
WHERE s.student_id = ? AND s.semester = ?;

单次查询耗时:MySQL 8.0, 50 万行, idx_student_semester 索引 → ~3-5ms

💡 优化提示:如果查询只需要 course_idscore,可以把这两个字段加入索引(覆盖索引 idx_student_semester_cover(student_id, semester, course_id, score)),避免回表查主键索引,在高并发下能再降 1-2ms。

💡 数据量估算:本文假设每人每学期 ~10 门课。如果系统支持大量选修课(每人 50+ 门),建议按课程类别拆分缓存 key(如 grade:{sid}:{semester}:requiredgrade:{sid}:{semester}:elective),避免单个 key 的 JSON 体积过大导致 Redis 带宽瓶颈。


二、V1.0:单体架构 —— "5000 人的独立学院,一台机器够用"

适用体量

  • 学生数:< 5000
  • 峰值 QPS:< 300
  • 部署:单实例 Spring Boot + 单机 MySQL

架构

浏览器 → Nginx → Spring Boot → MySQL

实现

@RestController
@RequestMapping("/api/grade")
public class GradeController {

    private final ScoreMapper scoreMapper;

    @GetMapping("/{studentId}")
    public List<ScoreVO> query(@PathVariable Long studentId,
                                @RequestParam String semester) {
        return scoreMapper.selectByStudentAndSemester(studentId, semester);
    }
}

为什么够用

Tomcat 200 线程,每次查询 5ms(裸 SQL 耗时)→ 理想条件下理论 QPS = 200/0.005 = 40000

但这不是生产可用 QPS。 算上 JSON 序列化、Spring 框架开销、GC 停顿、网络 I/O、TCP 连接建立、MySQL buffer pool 波动,单实例的实际稳定 QPS 在 2000~4000 之间。300 QPS 对这套架构来说确实毫无压力。

# V1.0 单库连接池配置
# 计算公式:connections = ((core_count * 2) + effective_spindle_count)
# 4 核服务器 + SSD 硬盘 → 约 10 个连接。读写分离后从库需更多(见 V4.0)。
spring:
  datasource:
    hikari:
      maximum-pool-size: 20       # 适度超额,压测后确定最终值
      minimum-idle: 5
      connection-timeout: 3000    # 3 秒超时,不要等 30 秒
      idle-timeout: 600000
      max-lifetime: 1800000

什么时候不够

当年级主任把查分入口发到群里,学生瞬间涌入,QPS 从 10 跳到 2000。Tomcat 线程池 200 个、HikariCP 连接池全部打满,请求开始排队 → RT 飙升 → 雪崩

瓶颈位置:MySQL 连接池。20 个连接,每个查询 5ms,单机极限约 4000 QPS(理想值)。实际环境中因为网络、锁竞争等因素,2000 QPS 以上就开始不稳定。


三、V2.0:引入 Redis 缓存 —— "2 万人的本科院校,扛过查分高峰期"

适用体量

  • 学生数:5000 ~ 20000
  • 峰值 QPS:300 ~ 2000
  • 新增武器:Redis 集中式缓存 + 缓存预热

先纠正一个误区:查分场景到底该不该用缓存?

很多人的第一反应是"加个 Redis 缓存"——但对查分业务来说,存粹"懒加载"的缓存几乎没用。原因:

懒加载模式(有问题):
  成绩发布  学生涌入查分
   学生 A 查自己的成绩  Redis miss   DB  回写缓存
   学生 B 查自己的成绩  Redis miss   DB  回写缓存
   ...
   5000 个学生各自查了 1   5000  Redis miss  5000  DB 查询

结果:Redis 缓存形同虚设,DB 仍然被打爆。

每个学生查自己的学号 = 5000 个不同的缓存 key = 首轮查询全是 miss。

结论:查分场景的缓存,不能靠"学生首次查询"来被动填充,必须在成绩发布时主动预热。

核心策略:缓存预热(Cache Preheating)

/**
 * 成绩发布后的缓存预热器
 *
 * 调用时机:教师上传完成绩、点击"发布"按钮后,由管理员接口触发。
 * 异步执行,不阻塞成绩发布流程。
 */
@Service
public class GradeCachePreheater {

    private final StringRedisTemplate redis;
    private final ScoreMapper scoreMapper;
    private final ObjectMapper objectMapper;
    private static final String CACHE_PREFIX = "grade:";
    private static final Duration TTL = Duration.ofMinutes(30);

    /**
     * 对指定学期的所有学生成绩进行缓存预热
     *
     * @param semester  学期,如 "2025-2026-1"
     * @param batchSize 每批处理的学生数,避免一次性加载过多数据
     */
    @Async  // Spring 异步,不阻塞发布接口
    public void preheat(String semester, int batchSize) {
        long lastId = 0;
        int totalPreheated = 0;

        while (true) {
            // 分批查询:每次查 batchSize 个学生的成绩
            List<StudentScoreBatch> batch = scoreMapper
                    .selectBatchBySemester(semester, lastId, batchSize);

            if (batch.isEmpty()) break;

            // 按 studentId 分组,批量写入 Redis
            Map<String, String> cacheMap = new LinkedHashMap<>();
            for (StudentScoreBatch row : batch) {
                String key = CACHE_PREFIX + row.getStudentId() + ":" + semester;
                // 每个 key 加随机偏移,避免雪崩
                cacheMap.put(key, objectMapper.writeValueAsString(row.getScores()));
            }

            // Pipeline 批量写入,大幅减少网络往返
            // 注意:executePipelined 由 Spring 管理连接生命周期,执行完自动归还
            // 超大批量(> 10 万条)时建议调大 Redis 超时时间,避免 Pipeline 执行超时
            redis.executePipelined((RedisCallback<Object>) connection -> {
                cacheMap.forEach((key, value) -> {
                    byte[] rawKey = key.getBytes();
                    byte[] rawValue = value.getBytes();
                    connection.setEx(rawKey,
                            TTL.plusSeconds(ThreadLocalRandom.current().nextInt(300))
                                .getSeconds(),
                            rawValue);
                });
                return null;
            });

            totalPreheated += batch.size();
            lastId = batch.get(batch.size() - 1).getStudentId();
        }

        log.info("缓存预热完成, semester={}, 共预热 {} 个学生的成绩", semester, totalPreheated);
    }
}

💡 性能提示ObjectMapper 是线程安全的,应复用单例(上面已经是 private final 字段)。如果预热数据量非常大(50 万学生),JSON 序列化的 GC 压力可能成为瓶颈——此时可考虑改用 MessagePack 或 Protobuf 替代 JSON 序列化。

预热效果:成绩发布后,学生在手机端收到通知 → 点开查分页面 → 99% 命中 Redis 缓存 → DB 压力接近零。

⚠️ 预热耗时估算:5 万学生 × 10 门课,Pipeline 批量写入,约 2-5 分钟完成。如果数据量更大(50 万+),建议提前 10 分钟触发预热。

关键:预热完成前,必须挡住流量

预热需要一个"查询开关"——成绩发布 ≠ 可以查分。正确的时序:

1. 教师点击"发布成绩" → 数据写入 MySQL → 触发缓存预热(异步)
2. 预热进行中 → 查询开关 = CLOSED → 学生请求返回"成绩准备中,请稍候"(不查 DB)
3. 预热完成 → 查询开关 = OPEN → 学生正常查分(99% 命中缓存)
// Redis 中的查询开关
// 发布成绩时设为 CLOSED,预热完成后设为 OPEN
public class GradePublishService {

    private final StringRedisTemplate redis;
    private final GradeCachePreheater preheater;

    public void publish(String semester) {
        // 1. 关闭查询开关(所有查分请求返回"准备中"页面)
        redis.opsForValue().set("grade:switch:" + semester, "CLOSED");

        // 2. 异步预热
        preheater.preheat(semester, 1000)
            .thenRun(() -> {
                // 3. 预热完成 → 打开开关
                redis.opsForValue().set("grade:switch:" + semester, "OPEN");
                log.info("成绩发布完成, 查询开关已打开: semester={}", semester);
            });
    }
}

// Controller 层检查开关
public Result query(Long studentId, String semester) {
    String status = redis.opsForValue().get("grade:switch:" + semester);
    if (!"OPEN".equals(status)) {
        return Result.accepted("成绩准备中,请稍候...");
    }
    // ... 正常查分
}

不加这个开关,预热那 2-5 分钟里涌入的请求全部 miss → DB 被打爆。这个开关是缓存预热方案的配套基础设施,缺一不可。

架构

浏览器 → Nginx → Spring Boot → Redis(预热后命中率 99%+)→ MySQL(兜底)

实现:Cache-Aside + 预热兜底

预热后的缓存查询逻辑:

@Service
public class GradeService {

    private final ScoreMapper scoreMapper;
    private final StringRedisTemplate redis;
    private final ObjectMapper objectMapper;  // ← 必须声明,否则编译不过

    private static final String CACHE_PREFIX = "grade:";
    private static final Duration TTL = Duration.ofMinutes(30);

    public List<ScoreVO> query(Long studentId, String semester) {
        String cacheKey = CACHE_PREFIX + studentId + ":" + semester;

        // 1. 查 Redis(预热后命中率 > 99%)
        String cached = redis.opsForValue().get(cacheKey);
        if (cached != null) {
            return objectMapper.readValue(cached,
                    new TypeReference<List<ScoreVO>>() {});
        }

        // 2. 未命中(异常情况:预热漏了 / TTL 过期 / 未预热学期)→ 查 DB
        List<ScoreVO> scores = scoreMapper.selectByStudentAndSemester(studentId, semester);

        // 3. 回写缓存
        if (!scores.isEmpty()) {
            String json = objectMapper.writeValueAsString(scores);
            redis.opsForValue().set(cacheKey, json,
                    TTL.plusSeconds(ThreadLocalRandom.current().nextInt(300)));
        }

        return scores;
    }
}

效果

指标V1.0(无缓存)V2.0(Redis 缓存 + 预热)
首轮查询 Redis 命中率99%+(预热后)
数据库 QPS2000~10(仅未命中穿透)
平均 RT45ms3ms
支撑学生数~5000~20000

四个必须认清的问题

下面讨论缓存领域最经典的四个问题。但请注意:它们的严重程度在查分场景中各不相同,每个问题我会标注"查分场景适用度"。


问题一:缓存穿透——"查一个不存在的学号"(⭐⭐⭐ 适用)
攻击者/爬虫:GET /api/grade/99999?semester=2025-2026-1
          → Redis 没有(不存在的学号不会进入预热)
          → MySQL 查了,返回空
          → 1000 个不存在的学号 → 1000 次无效 DB 查询

这个问题在查分场景中确实存在——学生可能输入错误的学号,爬虫可能枚举学号。但需要先做一个判断:真正有多少无效 ID 能到达服务层? 大部分无效 ID 在前端表单校验(学号正则)、网关层 JWT 校验、Controller 层身份比对时就被拦截了。能穿透到缓存层的无效 ID 主要是:爬虫绕过网关、内部接口未加鉴权等。

主推方案:缓存空值(Null Object Pattern)——最简单最可靠

public List<ScoreVO> query(Long studentId, String semester) {
    String cacheKey = CACHE_PREFIX + studentId + ":" + semester;

    String cached = redis.opsForValue().get(cacheKey);
    if (cached != null) {
        // 如果是空值标记,直接返回空(不再穿透到 DB)
        if ("NULL".equals(cached)) {
            return Collections.emptyList();
        }
        return parseFromJson(cached);
    }

    // 查 DB
    List<ScoreVO> scores = scoreMapper.selectByStudentAndSemester(studentId, semester);
    if (scores.isEmpty()) {
        // 缓存空值标记,短 TTL 5 分钟——挡住这批无效请求
        redis.opsForValue().set(cacheKey, "NULL", Duration.ofMinutes(5));
    } else {
        setCache(cacheKey, scores);
    }
    return scores;
}

为什么不用布隆过滤器? 布隆在技术上可行,但它引入了一个独立维护的数据结构——学生退学、学号回收、新生入学都需要同步更新,且不支持删除。多活实例下必须用 RedisBloom(又引入一个 Redis Module 依赖)。而空值缓存:

  • 无额外依赖,代码多 3 行
  • 自动过期,无需维护
  • 攻击者换个无效学号 → 最多穿透一次,5 分钟内该学号不再穿透

可选方案:布隆过滤器(学生规模 > 50 万 + 存在针对性的枚举攻击时考虑)

⚠️ 如果你真的要上布隆,用 Redisson 的 RBloomFilter(基于 Redis),不要用 Guava 本地版本(多实例不一致)。

@Configuration
public class BloomFilterConfig {

    @Bean
    public RBloomFilter<Long> studentBloomFilter(RedissonClient redisson, StudentMapper mapper) {
        RBloomFilter<Long> filter = redisson.getBloomFilter("student:bloom");
        // 初始化:预期 100 万学生,误判率 0.1%
        filter.tryInit(1_000_000L, 0.001);
        // 批量加载(大量数据时分批 add,避免阻塞启动)
        List<Long> allIds = mapper.selectAllIds();
        allIds.forEach(filter::add);
        log.info("布隆过滤器初始化完成, 已加载 {} 个学号", allIds.size());
        return filter;
    }
}

布隆过滤器的维护需要额外工作:新增学生时 filter.add(),且需定时(如每年新生入学后)全量重建以清理退学/毕业的学号(布隆不支持删除,只能重建)。


问题二:缓存击穿——"热点 key 过期时大量请求打到 DB"(⭐ 适用度低)

经典场景:grade:10001:2025-2026-1 过期时,100 个请求同时发现缓存过期,全部去查 MySQL。

但这个场景在查分业务中几乎不会发生。 原因:

  • 查分场景是 N 个不同的 key 各被 1 个学生访问,不是 1 个 key 被 N 个学生访问
  • 分布式锁的粒度是 key 级别的——不同 key 的请求互不阻塞,全部穿透到 DB。
  • 真正的问题不是"同一个 key 被多人打"(这不存在),而是"5000 个不同的 key 同时 miss"(这是 DB 容量问题,用 MQ 削峰解决,见 V4.0)。

什么时候查分场景需要分布式锁? 教务端场景——辅导员查全班成绩、教务处查全校统计,这些是真正的"热点 key"。如果你的系统面向学生端为主,这部分的优先级可以降低。

下面是分布式锁的实现(仅用于教务端热点查询,学生端无需):

// 使用 Redisson RLock,而不是手写 SETNX + Lua
// 原因:Redisson 内置锁持有者校验,避免死锁和误删
public List<ScoreVO> queryWithLock(Long studentId, String semester) {
    String cacheKey = CACHE_PREFIX + studentId + ":" + semester;

    // 1. 查缓存
    String cached = redis.opsForValue().get(cacheKey);
    if (cached != null) return parseFromJson(cached);

    // 2. 取锁(Redisson RLock)
    RLock lock = redisson.getLock(cacheKey + ":lock");
    try {
        // tryLock(等待时间, 锁过期时间, 时间单位)
        // 等待 0 秒 + leaseTime 3 秒:拿不到立刻返回,拿到锁后 3 秒内必须完成
        // 显式 leaseTime 关掉 watch-dog 自动续期:避免 Full GC 后锁被无限续期导致其他线程饿死
        if (lock.tryLock(0, 3, TimeUnit.SECONDS)) {
            // 双重检查
            cached = redis.opsForValue().get(cacheKey);
            if (cached != null) return parseFromJson(cached);

            // 查 DB → 写缓存
            List<ScoreVO> scores = scoreMapper.selectByStudentAndSemester(studentId, semester);
            if (!scores.isEmpty()) {
                setCache(cacheKey, scores);
            }
            return scores;
        } else {
            // 没拿到锁 → 返回 202,让前端稍后重试(不阻塞线程)
            throw new RetryableException("系统繁忙,请稍后重试");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RetryableException("系统繁忙");
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

问题三:缓存雪崩——"大量 key 同时过期"(⭐⭐⭐ 适用)
场景:查分系统下午 6 点发布成绩,预热时所有 key 的 TTL 都是 30 分钟。
      6:30 预热 key 集中过期 → 后续请求全部透传到 MySQL → MySQL 挂了

这个问题在查分场景中严重。 解决方法很简单,前面代码已经用了:

// TTL 加随机偏移(防雪崩)
Duration actualTTL = baseTTL.plusSeconds(ThreadLocalRandom.current().nextInt(300)); // 0~300s

5 万学生 × 随机偏移 0300 秒 → 缓存过期时间均匀分布在 3035 分钟内,天然错开。


问题四:数据安全——"学生 A 能看到学生 B 的成绩吗?"(⭐⭐⭐⭐⭐ 最高优先级)

前面缓存 key 的设计是 grade:{studentId}:{semester}——任何人都可以通过遍历 studentId 来查询任意学生的成绩。成绩是个人隐私数据,这是合规红线。

解决:身份认证前置 + 缓存 key 绑定用户

@GetMapping("/{studentId}")
public Result query(@PathVariable Long studentId,
                    @RequestParam String semester,
                    @RequestHeader("Authorization") String token) {
    // 第一步:验证身份——必须在查缓存之前
    Long loginUserId = jwtService.parseUserId(token);
    if (!loginUserId.equals(studentId)) {
        throw new ForbiddenException("无权查询他人成绩");
    }

    // 第二步:正常走缓存查询
    return Result.success(gradeService.query(studentId, semester));
}

为什么校验要放在缓存查询之前? 如果先查缓存再校验身份,缓存里已经存了敏感数据的内存引用,而且缓存 key 不区分请求者——存在侧信道泄露风险。正确的顺序永远是:认证 → 授权 → 缓存查询 → DB 兜底。


整合:把上面的方案串起来

实际代码中它们是协同工作的。下面是一个面向学生端查分的生产级查询方法:

@Service
public class GradeService {

    private final ScoreMapper scoreMapper;
    private final StringRedisTemplate redis;
    private final RBloomFilter<Long> studentBloomFilter;
    private final ObjectMapper objectMapper;

    private static final String CACHE_PREFIX = "grade:";
    private static final Duration TTL = Duration.ofMinutes(30);

    /**
     * 学生端查分:不含分布式锁(每个学生查自己的成绩,key 不冲突)。
     * 教务端查询(全班/全校)请使用 queryWithLock()。
     */
    public List<ScoreVO> query(Long studentId, String semester) {
        // ====== 0. 布隆过滤器(可选):快速拒绝无效 ID ======
        if (studentBloomFilter != null && !studentBloomFilter.contains(studentId)) {
            return Collections.emptyList();
        }

        String cacheKey = CACHE_PREFIX + studentId + ":" + semester;

        // ====== 1. L1 本地缓存(Caffeine, 纳秒级)======(详见 V3.0)
        List<ScoreVO> scores = localCache.getIfPresent(cacheKey);
        if (scores != null) return scores;

        // ====== 2. L2 Redis ======
        String cached = redis.opsForValue().get(cacheKey);
        if (cached != null) {
            // 空值标记检查(Null Object Pattern)
            if ("NULL".equals(cached)) return Collections.emptyList();
            scores = parseFromJson(cached);
            localCache.put(cacheKey, scores);  // 回填 L1
            return scores;
        }

        // ====== 3. L3 MySQL(兜底,正常情况下预热后基本不走这里)=======
        scores = scoreMapper.selectByStudentAndSemester(studentId, semester);
        if (scores.isEmpty()) {
            // 缓存空值标记,短 TTL 防穿透
            redis.opsForValue().set(cacheKey, "NULL", Duration.ofMinutes(5));
        } else {
            String json = objectMapper.writeValueAsString(scores);
            Duration actualTTL = TTL.plusSeconds(ThreadLocalRandom.current().nextInt(300));
            redis.opsForValue().set(cacheKey, json, actualTTL);
            localCache.put(cacheKey, scores);
        }
        return scores;
    }

    private List<ScoreVO> parseFromJson(String cached) {
        try {
            return objectMapper.readValue(cached, new TypeReference<List<ScoreVO>>() {});
        } catch (JsonProcessingException e) {
            throw new RuntimeException("缓存数据解析失败", e);
        }
    }
}

这段代码在一个方法里同时解决了:缓存穿透(空值缓存 + 可选 BloomFilter)、缓存雪崩(TTL 随机化)、多级缓存(L1/L2/L3)。 注意——没有分布式锁。因为学生端每人只查自己的学号,key 天然不冲突,锁只会增加延迟和各种边界 Bug。


四、V3.0:多级缓存 + 限流 —— "5 万人的省重点大学,再多也不怕"

适用体量

  • 学生数:20000 ~ 50000
  • 峰值 QPS:2000 ~ 5000
  • 新增武器:Caffeine 本地缓存 + 限流

为什么 Redis 还不够

Redis 虽然快(单机 10 万 QPS),但每次请求都要走一次网络(同机房约 0.5ms)。当 QPS 到 5000 时,这个网络往返就成了开销。更进一步——能不能直接不走网络?

架构

浏览器 → Nginx(限流)→ Spring Boot
                          ├── Caffeine 本地缓存(一级,无网络开销)
                          ├── Redis 分布式缓存(二级)
                          └── MySQL(三级)

实现一:Caffeine 本地缓存

Caffeine 的收益取决于缓存命中率。在学生端场景中,一个学生通常在短时间内会多次查看自己的成绩(比如反复确认不及格科目),Caffeine 能消除同一次会话中的重复网络请求。但如果每个学生只查一次,Caffeine 几乎没有收益。

Caffeine 真正发挥作用的是教务端:辅导员查全班 40 个学生的成绩 → 第 1 次走 Redis → 回填 Caffeine → 后续 39 次全走本地,0ms。

@Configuration
public class CacheConfig {

    @Bean
    public Cache<String, List<ScoreVO>> localGradeCache() {
        return Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(5, TimeUnit.MINUTES)   // 短 TTL,降低多实例不一致影响
                .recordStats()
                .build();
    }
}

关于多实例一致性的坦诚说明:Caffeine 是本地缓存,多实例间天然不一致。如果用 Redis Pub/Sub 做缓存失效广播存在消息丢失风险(Pub/Sub 是 fire-and-forget)。生产级方案有两种:

  1. 短 TTL 兜底(推荐):Caffeine TTL 设 1-5 分钟,即使 Pub/Sub 消息丢失,最多脏读几分钟。成绩场景容忍度足够。
  2. 可靠广播:用 Redis Stream 或 RocketMQ 做消息广播,保证不丢。

💡 为什么不用 Canal + Binlog? Canal 监听 MySQL Binlog → 推送到 Kafka → 消费端清理缓存——这条链路运维成本高、故障点多。对秒杀、金融等强一致性场景有价值,但对成绩查询这种"允许分钟级不一致、TTL 30 分钟自然过期"的场景,严重过度设计。工程原则:选择与你一致性要求匹配的方案,而不是最炫的方案。

缓存层级命中耗时容量一致性
L1 Caffeine< 1μsJVM 堆内弱(最终一致)
L2 Redis0.5~2ms独立扩展强(单 key 读写原子)
L3 MySQL3~5ms磁盘

实现二:限流

V2.0 已经用缓存预热解决了大部分 DB 压力,但还需要一道门槛:用限流兜住极端情况(如缓存预热还没完成就有大量请求涌入)。

推荐方案:Redisson RRateLimiter(真正的令牌桶)

@Configuration
public class RateLimiterConfig {

    @Bean
    public RRateLimiter gradeQueryRateLimiter(RedissonClient redisson) {
        RRateLimiter limiter = redisson.getRateLimiter("rate:grade:query");
        // 初始化:每秒生成 5000 个令牌(可根据实际压测结果调整)
        limiter.trySetRate(RateType.OVERALL, 5000, 1, RateIntervalUnit.SECONDS);
        return limiter;
    }
}

// 在拦截器或 Filter 中使用
@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final RRateLimiter rateLimiter;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {
        if (!rateLimiter.tryAcquire(1, 100, TimeUnit.MILLISECONDS)) {
            response.setStatus(429);
            response.getWriter().write("{\"code\":429,\"msg\":\"系统繁忙,请稍后重试\"}");
            return false;
        }
        return true;
    }
}

**** Redisson 的 RRateLimiter 内置了令牌桶算法(不是简单计数器),自动处理了边界突发、分布式一致性、过期清理等细节。****

替代方案:Nginx 层限流(更轻量,推荐作为第一道防线)

# Nginx 限流:每个 IP 每秒最多 10 个请求
limit_req_zone $binary_remote_addr zone=grade_api:10m rate=10r/s;

location /api/grade/ {
    limit_req zone=grade_api burst=20 nodelay;
    proxy_pass http://backend;
}

⚠️ 校园网注意$binary_remote_addr 按源 IP 限流。高校宿舍楼/教学楼经常整栋楼共享一个出口 IP(NAT),一个学生狂刷可能把全楼的人限掉。有条件的团队应在网关层按 JWT 中的 userId 限流。

限流被拒绝后:排队页面

被限流的请求不是直接 429 打回去——可以给用户一个"排队等待"的体验:

<!-- 查分排队页面(静态 HTML + JS,可部署到 CDN) -->
<div id="queue">
    <h2>当前排队人数:<span id="queueCount">--</span></h2>
    <p>预计等待时间:<span id="waitTime">--</span></p>
    <p>页面会自动刷新,请不要关闭</p>
</div>
<script>
const timer = setInterval(async () => {
    const res = await fetch('/api/grade/queue/status?token=' + queueToken);
    const data = await res.json();
    if (data.ready) {
        clearInterval(timer);
        window.location.href = '/grade-result';
    }
    document.getElementById('queueCount').innerText = data.ahead;
    document.getElementById('waitTime').innerText = data.estimatedWait;
}, 3000);
</script>

五、V4.0:异步削峰 + 读写分离 —— "10 万人的省考试院,用空间换稳定"

适用体量

  • 考生数:50000 ~ 200000
  • 峰值 QPS:5000 ~ 20000
  • 新增武器:消息队列削峰 + MySQL 主从读写分离 + 异步通知

核心思路转变

当体量达到 10 万+,单靠缓存已经不够——即使预热了 99%,剩下 1%(1000 人 × 10 门课)的首轮 miss 也会压垮 DB。

V4.0 换个思路:不要求立刻返回结果,先接下请求,再慢慢处理。

这对于成绩查询是可行的——学生可以接受"点查询后等 30 秒,成绩推送到微信上",而不是"必须 100ms 内看到页面"。

架构

浏览器 → Nginx → 网关 (限流)
                  ├── 同步查询 (缓存命中) → Caffeine → Redis → MySQL 从库
                  └── 异步查询 (缓存未命中) → MQ → Worker → MySQL 主库
                                                         ↓
                                                    微信/短信通知

实现:MQ 削峰

@RestController
public class AsyncGradeController {

    private final RabbitTemplate rabbitTemplate;
    private final GradeService gradeService;

    @PostMapping("/api/grade/query-async")
    public Result asyncQuery(@RequestBody QueryRequest req) {
        // 1. 先尝缓存(预热后的 99% 请求在这一步命中,即时返回)
        List<ScoreVO> cached = gradeService.query(req.getStudentId(), req.getSemester());
        if (cached != null && !cached.isEmpty()) {
            return Result.success(cached);  // 同步返回
        }

        // 2. 缓存未命中 → 放入 MQ 队列,异步处理
        GradeQueryMessage msg = new GradeQueryMessage(
                req.getStudentId(), req.getSemester(),
                req.getNotifyType(), req.getNotifyTarget(),
                System.currentTimeMillis()  // ← 记录请求时间,Worker 用来判断是否过期
        );
        rabbitTemplate.convertAndSend("grade.query.exchange", "grade.query", msg);

        // 3. 返回"排队中"
        return Result.accepted("查询已提交,结果出来后通知你");
    }
}
@Component
@Slf4j
public class GradeQueryWorker {

    private final ScoreMapper scoreMapper;
    private final GradeService cacheService;
    private final NotifyService notifyService;
    private final StringRedisTemplate redis;

    // ⚠️ 必须配置并发消费者,否则单线程处理不过来
    @RabbitListener(
        queues = "grade.query.queue",
        concurrency = "5-10"       // 最少 5 个、最多 10 个消费者线程
    )
    public void handleQuery(GradeQueryMessage msg) {
        // ====== 0. 消息过期检查:堆积超过 5 分钟的消息直接丢弃 ======
        if (msg.getRequestTime() != null
                && System.currentTimeMillis() - msg.getRequestTime() > 300_000) {
            log.info("消息已过期,丢弃: studentId={}, delay={}ms",
                    msg.getStudentId(), System.currentTimeMillis() - msg.getRequestTime());
            return;  // 学生早就走了,不处理也不通知
        }

        // ====== 1. 幂等去重(状态机模式)======
        String dedupKey = "msg:dedup:" + msg.getStudentId() + ":" + msg.getSemester();
        // 用 SETNX 做轻量状态机:INIT → PROCESSING → DONE
        Boolean acquired = redis.opsForValue()
                .setIfAbsent(dedupKey, "PROCESSING", Duration.ofMinutes(5));
        if (!Boolean.TRUE.equals(acquired)) {
            String status = redis.opsForValue().get(dedupKey);
            if ("DONE".equals(status)) {
                return;  // 已处理过
            }
            // PROCESSING 状态 → 前一个消费者正在处理,当前消息是重复投递 → 跳过
            return;
        }

        try {
            // 查 MySQL → 写缓存 → 推送通知
            List<ScoreVO> scores = scoreMapper.selectByStudentAndSemester(
                    msg.getStudentId(), msg.getSemester());
            cacheService.setCache(msg.getStudentId(), msg.getSemester(), scores);
            notifyService.send(msg.getNotifyType(), msg.getNotifyTarget(),
                    "成绩查询结果已出,点击查看");

            // 标记完成
            redis.opsForValue().set(dedupKey, "DONE", Duration.ofHours(1));
        } catch (Exception e) {
            // 处理失败 → 清除状态,允许重试
            redis.delete(dedupKey);
            throw e;  // RabbitMQ 会重新投递
        }
    }
}

⚠️ 幂等状态机说明:使用 (studentId, semester) 而不是 msgId 做幂等键。因为同一个学生的同一次查询可能被告知多次(RabbitMQ 重试),用业务维度去重更合理。如果消息处理成功但推送失败,当前实现会清除状态允许重试——如果你的业务要求可重试的推送,建议把推送逻辑抽到单独的消息队列。

⚠️ 生产监控必做

  1. MQ Lag 监控:消费速度跟不上生产速度 → 堆积超过阈值(如 1 万条)→ 自动扩容 Worker Pod 或触发入口限流
  2. 死信队列(DLQ):处理失败的消息进入死信队列,人工介入排查,不要静默丢失
  3. 前端进度展示:显示"当前排队第 X 位,预计等待 Y 秒"(堆积量 ÷ 消费速率),管理用户预期

读写分离

异步模式下,Worker 写缓存 + 通知的部分只依赖 MySQL 主库,同步查询走从库:

spring:
  datasource:
    master:
      url: jdbc:mysql://master-db:3306/grade
      hikari:
        maximum-pool-size: 20
    slave:
      url: jdbc:mysql://slave-db:3306/grade
      hikari:
        maximum-pool-size: 30   # 从库连接池应 > 主库,因为 99% 的请求走从库

⚠️ 连接池大小不是拍脑袋定的:HikariCP 官方公式 connections = ((core_count * 2) + effective_spindle_count) 给出基准值,然后通过压测确定最终值。从库连接数至少是主库的 1.5 倍(读远多于写)。异步 Worker 线程数 × 单个查询耗时 ÷ CPU 利用率 = 所需连接数,建议在 JMeter 压测中验证。

// 查成绩走从库
@DS("slave")   // baomidou dynamic-datasource
public List<ScoreVO> queryFromSlave(Long studentId, String semester) { ... }

// 写缓存/更新走主库
@DS("master")
public void updateScore(Long studentId, ScoreVO score) { ... }

实现依赖:

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
    <version>4.2.0</version>
</dependency>

⚠️ 主从延迟处理:MySQL 主从复制通常有 0.1-2 秒延迟。对于查分场景,如果成绩刚发布(publish_time 在最近 30 秒内),建议强制走主库,避免从库还没同步到最新数据导致查询返回空。

public List<ScoreVO> queryWithDelayAware(Long studentId, String semester) {
    // 如果是最近 30 秒内发布的成绩,走主库(避免主从延迟导致"查不到")
    if (isRecentlyPublished(semester)) {
        return scoreMapper.selectByStudentAndSemester(studentId, semester);  // @DS 默认主库
    }
    return queryFromSlave(studentId, semester);  // @DS("slave")
}

六、V5.0:云原生弹性伸缩 —— "百万级考生,高考查分级别的挑战"

适用体量

  • 考生数:200000 ~ 1000000+
  • 峰值 QPS:20000 ~ 100000+
  • 新增武器:K8s HPA 自动扩容 + Sentinel 熔断降级 + CDN 静态化

核心思路

当体量到了省考试院级别,任何单点都不可靠。架构的核心不再是"某一个技术点",而是——每一层都能自动伸缩、每一层都有降级预案

架构全景

CDN(静态页面/排队页)

WAF / 高防 IP

SLB(阿里云/腾讯云负载均衡)

Nginx / Kong 网关层(HPA × N)
  ├── 限流(令牌桶)
  └── 路由

Spring Boot 应用层(K8s HPA × N)
  ├── Sentinel 熔断降级
  ├── Caffeine 本地缓存
  └── 同步/异步分流

Redis Sentinel(主从 + 自动故障转移 + 多读副本,独立扩展)

MySQL 主从 + ShardingSphere(按 semester 或 student_id 分片)

MQ(RocketMQ 集群,推荐比 RabbitMQ 更适合高吞吐场景)

通知服务(微信/短信/邮件)

关键能力一:HPA 自动扩容 + 提前预热

⚠️ 重要的工程实际:Prometheus Adapter 采集周期通常是 30 秒,而查分流量是秒级爆发。等 HPA 看到 QPS 上来再扩容——系统已经被打爆了。生产实践的通用做法是:查分前 1 小时人工将 minReplicas 上调到预估值的 50%,并提前将 Pod 预热(warm up JIT、填充连接池、加载缓存)。

# k8s-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: grade-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: grade-api
  minReplicas: 10             # 查分日提前调整为 10(平时 3)
  maxReplicas: 50             # 查分日最多 50 个 Pod
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0   # 检测到负载立即扩容,不要等
      policies:
      - type: Percent
        value: 100
        periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  # 缩容等 5 分钟,避免抖动

HPA 使用自定义指标(如 http_requests_per_second)的前提:集群中部署了 Prometheus AdapterKEDA。没有这些组件时,HPA 只能基于 CPU/内存扩缩容。对于查分这种读密集场景,CPU 指标通常够用——缓存命中消耗的 CPU 极低。

关键能力二:Sentinel 多级降级

⚠️ 合规警示:降级时返回"5 分钟前的旧成绩"虽然提升了可用性,但在教育行业可能违反数据准确性的合规要求。成绩属于敏感个人数据,展示不确定正确性的数据存在政策风险。建议降级时返回"系统繁忙"并引导排队,不返回旧数据。

@RestController
public class GradeControllerV5 {

    @GetMapping("/api/grade/{studentId}")
    @SentinelResource(
        value = "grade-query",
        blockHandler = "gradeQueryBlocked"    // 仅保留限流/熔断降级
    )
    public Result query(@PathVariable Long studentId,
                        @RequestParam String semester) {
        return Result.success(gradeService.query(studentId, semester));
    }

    // 限流时的降级方法——引导排队,不返回旧数据
    public Result gradeQueryBlocked(Long studentId, String semester,
                                     BlockException e) {
        String token = queueService.joinQueue(studentId, semester);
        return Result.accepted("当前查询人数较多,已为您排队")
                .withData("queueToken", token)
                .withData("ahead", queueService.getPosition(token));
    }
}

降级梯队

优先级策略触发条件用户感知
正常查 L1→L2→L3系统正常即时返回
一级降级跳过 L3 MySQLDB RT > 1s"系统繁忙,请稍后重试"
二级降级跳过 Redis,仅本地Redis 连接超时"部分功能不可用"
三级降级返回排队页面应用 QPS > 阈值"已为您排队"
兜底CDN 静态"系统繁忙"页全部不可用友好提示

关键能力三:CDN + 静态化

排队页面是纯 HTML + JS,部署在 CDN 上。即使应用层全部挂了,学生至少能看到一个"系统繁忙"的页面——总比白屏强。

💡 关键设计:限流 token 由网关生成。不要依赖应用层来生成排队 token——万一应用层挂了,排队页里的 /api/grade/queue/status 请求也挂了,用户只能看到永远刷不出来的 "排队中"。正确的做法是:在 Kong/Nginx + OpenResty 的限流插件中,当触发限流时直接生成 token 写入 Redis,前端轮询网关接口(不经过应用层)。


七、V5+:数据量达到千万级——分库分表

随着体量继续增长,单表成绩数据可能达到千万甚至亿级。此时即使有索引,B+Tree 层级增加也会导致单次查询从 3ms 变成 10ms+。

分片策略

查分业务最常用的分片键是 student_id

# ShardingSphere 配置示例
spring:
  shardingsphere:
    datasource:
      names: ds0, ds1
    rules:
      sharding:
        tables:
          t_score:
            actual-data-nodes: ds$->{0..1}.t_score_$->{0..7}
            database-strategy:
              standard:
                sharding-column: student_id
                sharding-algorithm-name: db-inline
            table-strategy:
              standard:
                sharding-column: student_id
                sharding-algorithm-name: tbl-inline
        sharding-algorithms:
          db-inline:
            type: INLINE
            props:
              algorithm-expression: ds${student_id % 2}
          tbl-inline:
            type: INLINE
            props:
              algorithm-expression: t_score_${student_id % 8}

为什么选 student_id 而不是 semester?

  • 查分的核心 SQL 是 WHERE student_id = ? AND semester = ?,student_id 是最频繁的查询条件
  • 按 student_id 分片后,一个学生的所有学期成绩在同一分片,跨库查询少
  • 按 semester 分片是典型反模式:最新学期(如 2025-2026-1)所在分片承载 100% 的流量,其他旧学期分片流量为零——等于没分。而按 student_id 取模,每个分片平均承担 1/N 流量,真正实现负载均衡
按 semester 分片(反模式):
  分片0: 2025-2026-1100% 流量
  分片1: 2024-2025-20%
  分片2: 2024-2025-10%

按 student_id % 4 分片:
  分片025%  分片1 ← 25%  分片2 ← 25%  分片3 ← 25%

冷热数据隔离(更简单的方案)

如果觉得分库分表太重,可以用更务实的做法:

-- 当前学期表(热数据,5 万学生 × 10 门课 = 50 万行)
CREATE TABLE t_score_current LIKE t_score;

-- 历史学期表(冷数据,可能是千万级)
CREATE TABLE t_score_archive LIKE t_score;

-- 查询时优先查当前表,miss 了再查历史表

99% 的查询集中在本学期,冷热分离后,当前表的 50 万行用 MySQL 索引完全够用。


八、安全加固 Checklist

以下四项是生产环境的基础要求,不要等到被通报了再补:

事项说明实现
SQL 注入防护MyBatis 中用 ${} 拼接 semester 参数有注入风险所有动态 SQL 必须用 #{}。如需动态排序/分组,用白名单校验
防学号枚举攻击者遍历 studentId 尝试撞出有效学号布隆过滤器可过滤无效 ID,但真正有效学号仍能被试出来。建议加接口级频控(单 IP 每分钟最多查 20 个不同学号)
日志脱敏成绩数据打印到日志可能泄露日志框架配置脱敏规则,或 ScoreVO.toString() 不打印 score 字段值
接口鉴权JWT 校验放到 Filter/Interceptor 层,不要每个方法手写统一拦截器 + 注解(@RequireStudent),漏加就是事故

ScoreVO 重写 toString() 不打印成绩:

@Override
public String toString() {
    return "ScoreVO{course='" + courseName + "', semester='" + semester + "', score=***}";
}

九、方案演进全景图

┌─────────────┬──────────────┬─────────────┬──────────────┬──────────────┐
   V1.0         V2.0          V3.0         V4.0          V5.0       
   单体架构      Redis 缓存     多级缓存      异步削峰      弹性伸缩   
├─────────────┼──────────────┼─────────────┼──────────────┼──────────────┤
 Spring Boot  + Redis       + Caffeine   + MQ 削峰     + K8s HPA   
 + MySQL      + 缓存预热    + 令牌桶限流  + 读写分离    + Sentinel  
              + 布隆过滤器                + 异步通知    + 多级降级  
├─────────────┼──────────────┼─────────────┼──────────────┼──────────────┤
 ~5000      ~20000      ~50000     ~20 万人      ~100 万人   
 QPS < 300    QPS < 2000    QPS < 5000   QPS < 20000   QPS < 10  
├─────────────┼──────────────┼─────────────┼──────────────┼──────────────┤
 成本:★☆     成本:★★☆    成本:★★★☆   成本:★★★★☆   成本:★★★★★ 
 复杂度:★☆   复杂度:★★☆   复杂度:★★★☆  复杂度:★★★★☆  复杂度:★★★★★│
└─────────────┴──────────────┴─────────────┴──────────────┴──────────────┘

十、总结

四个核心思想(按优先级排序)

  1. 缓存预热 > 懒加载。查分场景中"每个学生查自己的学号",没有共享热点 key。靠学生首次访问来填充缓存——第一波就 DB 打穿。必须在成绩发布时主动预热缓存,这是查分系统的核心设计原则。

  2. 对症下药,不要全套三板斧。缓存穿透(布隆过滤器)适用,缓存雪崩(随机 TTL)适用。缓存击穿的分布式锁在"每人查自己"的学生端场景中基本不适用——不要把电商秒杀的套路无脑搬到查分场景。

  3. 用现成的,别手写。Redisson RLock 代替手写 SETNX+Lua,RRateLimiter 代替手写令牌桶——你的代码量减少 50%,Bug 减少 80%。手写只用于理解原理,生产环境优先用成熟工具。

  4. 认证在缓存之前,安全在性能之前。缓存 key 暴露的数据必须在 Controller 层做身份校验——不要让"性能优化"成为数据泄露的通道。这条没有商量余地。

  5. 优雅降级 > 硬扛到底。与其让用户看到 500 错误,不如给他一个排队页面、一个异步通知、一个"微信通知你"。损失一点实时性,换来系统不死。 但注意合规边界:旧成绩数据宁可不说,也不要说错。

最重要的一句话

查分系统本质上是一道缓存预热 + 异步削峰的设计题,而不是一个高并发编程题。你会写多线程没用——预热策略、限流位置、降级预案,这些才决定了查分那天你的手机会不会被学生打爆。