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

学生成绩查询系统:从 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"的策略(如分布式锁防击穿)在查分场景中收益有限------文章会逐一标注哪些方案是"对症下药",哪些是"牛刀杀鸡"。

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

sql 复制代码
-- 学生表: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 最高的接口:

sql 复制代码
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

实现

java 复制代码
@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 对这套架构来说确实毫无压力。

yaml 复制代码
# 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 缓存"------但对查分业务来说,存粹"懒加载"的缓存几乎没用。原因:

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

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

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

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

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

java 复制代码
/**
 * 成绩发布后的缓存预热器
 *
 * 调用时机:教师上传完成绩、点击"发布"按钮后,由管理员接口触发。
 * 异步执行,不阻塞成绩发布流程。
 */
@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 分钟触发预热。

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

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

markdown 复制代码
1. 教师点击"发布成绩" → 数据写入 MySQL → 触发缓存预热(异步)
2. 预热进行中 → 查询开关 = CLOSED → 学生请求返回"成绩准备中,请稍候"(不查 DB)
3. 预热完成 → 查询开关 = OPEN → 学生正常查分(99% 命中缓存)
java 复制代码
// 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 被打爆。这个开关是缓存预热方案的配套基础设施,缺一不可。

架构

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

实现:Cache-Aside + 预热兜底

预热后的缓存查询逻辑:

java 复制代码
@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%+(预热后)
数据库 QPS 2000 ~10(仅未命中穿透)
平均 RT 45ms 3ms
支撑学生数 ~5000 ~20000

四个必须认清的问题

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


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

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

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

java 复制代码
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 本地版本(多实例不一致)。

java 复制代码
@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"。如果你的系统面向学生端为主,这部分的优先级可以降低。

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

java 复制代码
// 使用 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 同时过期"(⭐⭐⭐ 适用)
vbnet 复制代码
场景:查分系统下午 6 点发布成绩,预热时所有 key 的 TTL 都是 30 分钟。
      6:30 预热 key 集中过期 → 后续请求全部透传到 MySQL → MySQL 挂了

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

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

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


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

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

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

java 复制代码
@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 兜底。


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

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

java 复制代码
@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 时,这个网络往返就成了开销。更进一步------能不能直接不走网络?

架构

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

实现一:Caffeine 本地缓存

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

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

java 复制代码
@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μs JVM 堆内 弱(最终一致)
L2 Redis 0.5~2ms 独立扩展 强(单 key 读写原子)
L3 MySQL 3~5ms 磁盘

实现二:限流

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

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

java 复制代码
@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 复制代码
# 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 复制代码
<!-- 查分排队页面(静态 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 内看到页面"。

架构

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

实现:MQ 削峰

java 复制代码
@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("查询已提交,结果出来后通知你");
    }
}
java 复制代码
@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 主库,同步查询走从库:

yaml 复制代码
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 压测中验证。

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

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

实现依赖:

xml 复制代码
<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 秒内),建议强制走主库,避免从库还没同步到最新数据导致查询返回空。

java 复制代码
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、填充连接池、加载缓存)。

yaml 复制代码
# 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 分钟前的旧成绩"虽然提升了可用性,但在教育行业可能违反数据准确性的合规要求 。成绩属于敏感个人数据,展示不确定正确性的数据存在政策风险。建议降级时返回"系统繁忙"并引导排队,不返回旧数据。

java 复制代码
@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 MySQL DB 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

yaml 复制代码
# 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 流量,真正实现负载均衡
erlang 复制代码
按 semester 分片(反模式):
  分片0: 2025-2026-1 ← 100% 流量
  分片1: 2024-2025-2 ← 0%
  分片2: 2024-2025-1 ← 0%

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

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

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

sql 复制代码
-- 当前学期表(热数据,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() 不打印成绩:

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

九、方案演进全景图

yaml 复制代码
┌─────────────┬──────────────┬─────────────┬──────────────┬──────────────┐
│   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 错误,不如给他一个排队页面、一个异步通知、一个"微信通知你"。损失一点实时性,换来系统不死。 但注意合规边界:旧成绩数据宁可不说,也不要说错。

最重要的一句话

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


相关推荐
你为她披上外套时我正站在窗外7 小时前
5 分钟玩转 siwi-download:CLI + Rust 库上手指南
后端
橘子海全栈攻城狮7 小时前
【最新源码】基于SpringBoot + Vue的超市管理系统的设计与实现D002
java·开发语言·vue.js·spring boot·后端·spring
2501_918582377 小时前
HarmonyOS应用开发实战:小事记 - Stage 模型下 EntryAbility 的启动流程与 Want 解析机制
后端
名字还没想好☜8 小时前
Go 用 errgroup 管理并发子任务:错误收敛、取消传播与限流
开发语言·后端·golang·go·并发
星栈独行9 小时前
Node 框架怎么选?Express、Koa、Egg、NestJS 场景化选型指南
后端·程序人生·node.js
你为她披上外套时我正站在窗外9 小时前
拆解 siwi-download:Rust 异步下载器是怎么炼成的
后端
苍何9 小时前
WAIC深度体验:能跨端使用的 Agent 才是好 Agent!
后端
程序员清风10 小时前
推荐几个我常听的AI播客!
java·后端·面试
JavaGuide10 小时前
Kimi K3 实战:全栈项目、Java 项目改造与 3A 游戏 Demo
后端·ai编程