学生成绩查询系统:从 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_id和score,可以把这两个字段加入索引(覆盖索引idx_student_semester_cover(student_id, semester, course_id, score)),避免回表查主键索引,在高并发下能再降 1-2ms。💡 数据量估算 :本文假设每人每学期 ~10 门课。如果系统支持大量选修课(每人 50+ 门),建议按课程类别拆分缓存 key(如
grade:{sid}:{semester}:required和grade:{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)。生产级方案有两种:
- 短 TTL 兜底(推荐):Caffeine TTL 设 1-5 分钟,即使 Pub/Sub 消息丢失,最多脏读几分钟。成绩场景容忍度足够。
- 可靠广播:用 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 重试),用业务维度去重更合理。如果消息处理成功但推送失败,当前实现会清除状态允许重试------如果你的业务要求可重试的推送,建议把推送逻辑抽到单独的消息队列。
⚠️ 生产监控必做:
- MQ Lag 监控:消费速度跟不上生产速度 → 堆积超过阈值(如 1 万条)→ 自动扩容 Worker Pod 或触发入口限流
- 死信队列(DLQ):处理失败的消息进入死信队列,人工介入排查,不要静默丢失
- 前端进度展示:显示"当前排队第 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 Adapter 或 KEDA。没有这些组件时,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万 │
├─────────────┼──────────────┼─────────────┼──────────────┼──────────────┤
│ 成本:★☆ │ 成本:★★☆ │ 成本:★★★☆ │ 成本:★★★★☆ │ 成本:★★★★★ │
│ 复杂度:★☆ │ 复杂度:★★☆ │ 复杂度:★★★☆ │ 复杂度:★★★★☆ │ 复杂度:★★★★★│
└─────────────┴──────────────┴─────────────┴──────────────┴──────────────┘
十、总结
四个核心思想(按优先级排序)
-
缓存预热 > 懒加载 。查分场景中"每个学生查自己的学号",没有共享热点 key。靠学生首次访问来填充缓存------第一波就 DB 打穿。必须在成绩发布时主动预热缓存,这是查分系统的核心设计原则。
-
对症下药,不要全套三板斧 。缓存穿透(布隆过滤器)适用,缓存雪崩(随机 TTL)适用。缓存击穿的分布式锁在"每人查自己"的学生端场景中基本不适用------不要把电商秒杀的套路无脑搬到查分场景。
-
用现成的,别手写。Redisson RLock 代替手写 SETNX+Lua,RRateLimiter 代替手写令牌桶------你的代码量减少 50%,Bug 减少 80%。手写只用于理解原理,生产环境优先用成熟工具。
-
认证在缓存之前,安全在性能之前。缓存 key 暴露的数据必须在 Controller 层做身份校验------不要让"性能优化"成为数据泄露的通道。这条没有商量余地。
-
优雅降级 > 硬扛到底 。与其让用户看到 500 错误,不如给他一个排队页面、一个异步通知、一个"微信通知你"。损失一点实时性,换来系统不死。 但注意合规边界:旧成绩数据宁可不说,也不要说错。
最重要的一句话
查分系统本质上是一道缓存预热 + 异步削峰的设计题,而不是一个高并发编程题。你会写多线程没用------预热策略、限流位置、降级预案,这些才决定了查分那天你的手机会不会被学生打爆。