开学季课表查询高并发下的缓存更新策略,核心打法就一句话:用Cache Aside模式为主干,消息队列做异步失效,本地缓存给热点key兜底,这套组合能保证选课和查课表的高峰窗口里,数据库连接池始终有喘息的余地。
课表查询的流量特征:读多写少,但热点高度集中
先看清问题,再聊方案,开学第一周,教务系统要面对的请求大致分成三类:课表查询、选课操作、成绩查询,课表查询占了相当大的比例,而且这几个场景的流量结构完全不同。
- 课表数据在学期初就基本固定,写入量很少,属于典型的读多写少
- 同院系同年级的学生几乎在同一时间查同一份课表,热点key高度集中
- 选课有倒计时,整点并发是常态,瞬时QPS冲到平时的数倍甚至一个数量级
这几个特征决定了缓存策略的取舍,课表本身更新频率低,适合长时间缓存;但选课和成绩查询依赖实时数据,缓存失效的节奏必须精准,把这两个场景混在一起处理,是不少高校教务系统在九月初卡死的根源。
课表系统缓存更新策略对比:三种方案怎么选
行业共识认为,读写比超过十比一的场景,缓存是刚需,但选哪种更新策略,直接决定高峰期会不会翻车,生产环境里常用的主流打法就三种。
Cache Aside:先更新库,再删缓存
这是最接地气的一种,业务代码负责两件事:读的时候先查缓存,没命中就回源数据库;写的时候更新数据库,然后删缓存,下一次读请求进来,缓存未命中,再从数据库拉一次新的。
优点是逻辑简单,每个开发都看得懂,课表管理员改一次排课,数据库更新完,旧的缓存key被删掉,下个请求自然拿到新数据。
缺点在于删缓存和更新库之间有一个极小的时间窗口,极端情况下会读到旧数据,不过对课表这种场景,慢几秒完全可以接受。
Read Through:把缓存当成唯一的入口
应用层只跟缓存打交道,缓存自己负责去数据库加载数据,Redis里可以模拟实现,但真正落地多见于本地缓存框架,比如Caffeine的LoadingCache。

优点是调用方省心,读写都走缓存,逻辑收敛在一个地方,缺点是缓存层和数据库的交互逻辑变重,排查问题要多绕一层,课表模块如果还要承载选课这种强实时需求,Read Through会显得太笨重。
Write Behind:异步写回,快是真快,险也是真险
更新操作先写缓存,攒一批再异步刷到数据库,性能最好,但掉电丢数据的风险摆在那里。
课表查询这种场景,其实用不上Write Behind,成绩录入和选课倒是能蹭一点性能红利,但需要额外的持久化保障,复杂度直线上升。
三种策略怎么选
| 策略 | 适合场景 | 数据一致性 | 实现复杂度 |
|---|---|---|---|
| Cache Aside | 课表查询、公告列表 | 最终一致,秒级延迟可接受 | 低 |
| Read Through | 配置信息、字典数据 | 依赖缓存加载逻辑 | 中 |
| Write Behind | 选课计数、热门榜单 | 有丢失风险,需补偿机制 | 高 |
课表查询的最佳解是Cache Aside,选课和成绩查询在这个基础上加异步刷新即可,没必要切换到更复杂的策略。
高校教务系统缓存击穿解决方案:一个热点key就能拖垮全校
缓存更新的策略定了,接下来要操心的是缓存本身的异常情况,开学季最典型的事故,是某个热门公选课的缓存刚好在整点过期,几千个学生同时请求这个key,缓存全部未命中,请求一股脑儿扎进数据库,连接池被打满之后,正常的课表查询也跟着超时,整个系统发生连锁反应。
这就是缓存击穿,一个key扛不住并发,跟雪崩是两码事,但处理不及时,击穿会迅速演变成雪崩。
互斥锁:只放一个请求回源
用Redis的分布式锁做控制,缓存未命中的时候,先尝试拿锁,拿到的那个请求回源数据库,其他请求原地等待,锁释放之后再查缓存,伪代码长这样:
String lockKey = "lock:course:" + courseId;
boolean locked = redis.setIf
Absent(lockKey, "1", 3, TimeUnit.SECONDS);
if (locked) {
// 回源数据库,重建缓存,释放锁
Course course = courseMapper.selectById(courseId);
redis.set("course:" + courseId, course, 3600, TimeUnit.SECONDS);
redis.delete(lockKey);
} else {
// 短暂休眠后重试,第二次基本能命中缓存
Thread.sleep(100);
Course course = redis.get("course:" + courseId);
}
注意锁必须带过期时间,防止持锁线程自己挂掉,把全班人的请求都堵死。
热点key永不过期:缓存里没有,库里兜着
针对课表这种更新频率极低的数据,干脆不设过期时间,后台单独跑一个定时任务,每天早上六点刷新一次,排课有变动时,管理员触发一次手动刷新就能覆盖。
这样做的好处是缓存击穿的概率降为零,代价是数据更新的实时性完全依赖定时任务,不适合成绩查询这种按学期批量出分的场景。
布隆过滤器挡住不存在的key
一部分缓存穿透来自学生查了不存在的课程编号,请求直接落到数据库,查了个空,每次都绕过缓存,积少成多,数据库同样能被打出压力。
布隆过滤器是省内存的方案,把所有合法课表ID放进去,请求进来先过过滤器,查不到就直接返回空,不碰数据库。
多级缓存:本地缓存是最后一道防线
Redis也扛不住的时候,应用服务器的本地缓存可以再顶一轮,Caffeine内置定时刷新机制,配置好之后,每个节点能把最热门的几百个key留在进程内,请求命中本地缓存直接返回,连Redis都省了。
Cache<String, Course> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
这套组合落地之后,Redis的单点压力被分散到几十个节点上,开学季那几天,服务器内存多分几百兆给Caffeine,比临时加两台Redis机器实在得多。
一套能落地的缓存更新流程:从请求到数据库要走五步
前面聊了策略,下面给一套可以直接照做的流程,这套流程在多数高校教务系统的改造中验证过,核心是让缓存层自己形成闭环。
- 请求进门先查本地缓存
- 本地未命中,带着key查Redis
- Redis也未命中,用互斥锁拦一下,只放一个线程回源数据库
- 数据库结果写回Redis,带上过期时间和随机抖动
- 返回响应,同时把结果放一份到本地缓存

像洋葱一样一层套一层,每一层都是下一层的挡箭牌。
缓存过期时间的设置有个细节:基础数据比如学期校历,设24小时;热点课程设1小时,同时每个key的过期时间再额外加一个五分钟以内的随机数,这样做是为了避免大批key在同一秒集体失效,把一次缓存击穿扩散成缓存雪崩。
不少学生私底下会问成绩查询系统压力大怎么优化,其实跟课表查询是同一个套路,成绩发布的瞬间,热门课程的分数查询就是另一个版本的课表高峰,用缓存把查询挡住,数据库只处理写入和少量回源请求,系统自然稳得住。
监控指标盯三个:缓存命中率、回源QPS、线程池活跃数,命中率掉下来,说明缓存策略出了问题;回源QPS曲线跟数据库慢查询曲线吻合,说明锁没锁住;线程池活跃数打满,就得考虑扩容或者降级。
课表查询缓存常见问题解答
开学季课表查询高并发缓存方案里,Redis本身扛不住怎么办
Redis出问题,防线退到本地缓存,Caffeine里的数据还在,短时间内依然能正常响应,同时把Redis的读写降级为只读,回源逻辑全部走数据库,等Redis恢复后再自动切回来。
选课系统缓存雪崩怎么处理
三个动作同时做,第一个,过期时间全部加随机抖动,这是防雪崩的地基;第二个,用消息队列把缓存重建请求异步化,避免雪崩之后的高峰回源再次压垮数据库;第三个,接口层加限流,超出的请求直接排队,用等待时间换系统稳定性。
课表改了,旧缓存怎么清理
课表修改属于低频操作,走延迟双删,更新数据库之后先删一次缓存,隔几百毫秒再删第二次,把第一次删完之后恰巧又写回旧数据的那个时间窗口补上,不需要引入复杂的分布式事务,课表数据晚几秒生效,对使用体验没有任何损失。