封禁用户 & 标记作弊&排行榜 系统排查报告
后端全链路 + 前端排行榜展示,设计DB、Redis、MQ
更新于 2026.03.20
本文目录 8 节

封禁用户 & 标记作弊 系统排查报告
排查日期:2026-03-13 排查范围:后端全链路 + 前端排行榜展示
一、现状概述
当前系统将”封禁用户”和”标记作弊”视为同一套逻辑的不同 status 值,共用 recalculateRanks() 方法,后果完全相同——都是从排行榜上移除。这不符合业务需求。
现有 status 定义(ContestRegistration.status):
| 值 | 含义 | 当前后果 |
|---|---|---|
| 0 | 正常 | 正常参赛 |
| 1 | 取消资格(封禁) | 从排行榜移除 |
| 2 | 作弊 | 从排行榜移除(与封禁相同) |
二、逐项排查
2.1 问题一:封禁和作弊应当是两条完全不同的路径
需求定义
| 维度 | 封禁用户 | 标记作弊 |
|---|---|---|
| 目的 | 取消参赛资格 | 标记作弊行为,但保留排名记录 |
| 提交权限 | 不得再提交 | 可以继续提交(或由管理员决定) |
| 排行榜 | 完全移除,不显示 | 保留在排行榜,名字前加 [已被标记为作弊] |
| 再报名 | 不得通过再次报名获取参赛资格 | 不涉及 |
| 提交记录 | 保留,但不参与排名计算 | 保留,排名也保留 |
当前代码问题
问题 1-1:两个接口共用同一个 recalculateRanks 后果
AdminContestController.java:103-128:
// 封禁
@PostMapping("/user/ban")
public Result<Void> banUser(...) {
updateRegistrationStatus(contestId, userId, 1, reason);
contestRankService.recalculateRanks(contestId); // ← 与作弊相同
}
// 标记作弊
@PostMapping("/user/cheat")
public Result<Void> markCheat(...) {
updateRegistrationStatus(contestId, userId, cheat ? 2 : 0, reason);
contestRankService.recalculateRanks(contestId); // ← 与封禁相同
}
问题 1-2:recalculateRanks 对 status != 0 一视同仁
ContestRankServiceImpl.java:627-635:
private Set<Long> getBannedUserIds(Long contestId) {
List<ContestRegistration> bannedRegs = contestRegistrationMapper.selectList(
new LambdaQueryWrapper<ContestRegistration>()
.eq(ContestRegistration::getContestId, contestId)
.ne(ContestRegistration::getStatus, 0)); // ← status=1 和 status=2 都被当作"封禁"
return bannedRegs.stream()
.map(ContestRegistration::getUserId)
.collect(Collectors.toSet());
}
此方法被以下位置调用:
recalculateRanks()— Redis 路径:从 ZSet 移除recalculateRanksInDB()— DB 路径:rank 设为 0finalizeContestRanking()— 落库时:rank 设为 0
结论:标记作弊的用户被当作封禁用户处理,从排行榜完全移除,不符合需求。
问题 1-3:封禁后用户无法被阻止重新报名
ContestServiceImpl.java:334-363(报名逻辑):
@Override
public void registerContest(Long contestId, ContestRegisterDTO dto) {
// ...
Long userId = BaseContext.getCurrentId();
// 检查重复报名
if (isRegistered(contestId, userId)) {
throw new BusinessException(MessageConstant.CONTEST_ALREADY_REGISTERED);
}
// ... 直接插入新记录
contestRegistrationMapper.insert(reg);
}
而 isRegistered 方法:
@Override
public boolean isRegistered(Long contestId, Long userId) {
return contestRegistrationMapper.selectCount(
new LambdaQueryWrapper<ContestRegistration>()
.eq(ContestRegistration::getContestId, contestId)
.eq(ContestRegistration::getUserId, userId)
.eq(ContestRegistration::getStatus, 0)) > 0; // ← 只查 status=0
}
漏洞:isRegistered 只查 status=0 的记录。被封禁的用户(status=1)不会命中 isRegistered,也就不会触发”已报名”的重复检查。如果用户再次调用 /contest/register,会直接插入一条新的 status=0 的记录,绕过封禁重新获得参赛资格。
2.2 问题二:标记作弊的接口与前端展示
2.2.1 接口层面
现状:标记和取消作弊已合并在一个接口中,通过 cheat 参数区分。
AdminContestController.java:116-128:
@PostMapping("/user/cheat")
public Result<Void> markCheat(@RequestBody Map<String, Object> body) {
// cheat=true → status=2, cheat=false → status=0
updateRegistrationStatus(contestId, userId, cheat ? 2 : 0, reason);
contestRankService.recalculateRanks(contestId);
}
问题 2-1:接口本身存在,但缺少权限注解。当前依赖 /admin/** 路径前缀由拦截器统一鉴权(WebMvcConfiguration 中未排除 /admin/**,由 Sa-Token 拦截器保护),这一点暂无问题,但接口本身没有显式的 @SaCheckRole 注解,安全性依赖路径匹配。
问题 2-2:取消作弊时将 status 直接设回 0,但 recalculateRanks 的 Redis 路径不会把用户加回 ZSet(详见 2.3.1)。
2.2.2 排行榜展示层面(前端 + 后端 VO)
后端 ContestRankVO 缺少作弊标记字段:
public class ContestRankVO {
private Integer rank;
private Long userId;
private String nickname;
private String avatar;
private Integer solvedCount;
private Integer totalPenalty;
private Integer totalScore;
private List<ContestProblemDetail> problemResults;
// ← 缺少 cheated / cheatMarked 字段
}
前端排行榜(ContestDetailView.vue)直接显示 item.nickname,没有任何作弊标记渲染逻辑。
后端构建 VO 时也没有查询 ContestRegistration.status 来设置标记:
getContestRankingFromRedis()— 仅从 Redis 取数据拼装,不查 registration 状态getContestRankingFromDB()— 直接按rank > 0过滤,作弊用户(rank=0)被过滤掉
结论:当前系统完全不具备”在排行榜中标记作弊但保留显示”的能力。
2.3 问题三:潜在问题排查
2.3.1 比赛中取消作弊无法恢复排行
位置:ContestRankServiceImpl.java:219-239
@Override
public void recalculateRanks(Long contestId) {
// ...
if (Boolean.TRUE.equals(contest.getRankFinalized())) {
recalculateRanksInDB(contestId, contest); // DB 路径有完整重排
return;
}
// Redis 路径:只做移除,不做恢复
Set<Long> bannedUserIds = getBannedUserIds(contestId);
if (bannedUserIds.isEmpty()) {
return; // ← 取消作弊后 bannedUserIds 为空,直接 return
}
String rankKey = prefixedRankKey(contestId);
for (Long bannedUserId : bannedUserIds) {
stringRedisTemplate.opsForZSet().remove(rankKey, bannedUserId.toString());
}
// ← 没有"把 status=0 的用户重新 ZADD 回去"的逻辑
}
后果:比赛进行中,取消作弊标记后用户从排行榜上消失且无法恢复,只有等下一次提交触发 Lua 脚本的 ZADD 才能重新出现。
2.3.2 封禁后用户仍可继续提交并重新上榜
提交校验(QuestionSubmitServiceImpl.java:95-101):
ContestRegistration reg = contestRegistrationMapper.selectOne(...);
if (reg != null && reg.getStatus() != null && reg.getStatus() != 0) {
throw new BusinessException("您已被取消参赛资格");
}
这段代码查的是 数据库中已有的那条记录。但结合问题 1-3(封禁用户可以重新报名),如果用户重新报名产生了一条新的 status=0 记录,而 selectOne 查到的可能是新记录,就绕过了封禁。
此外,即使不重新报名,selectOne 在存在多条记录时行为不确定(MyBatis-Plus 会抛异常或取第一条),这本身也是潜在风险。
2.3.3 Redis detail Hash 未清理
位置:ContestRankServiceImpl.java:230-238(Redis 路径)
封禁/作弊时只从 ZSet 移除了用户:
stringRedisTemplate.opsForZSet().remove(rankKey, bannedUserId.toString());
// ← 没有删除 contest:detail:{contestId}:{userId}
后果:
- detail Hash 数据残留,占用内存
- 如果后续有其他逻辑读取 detail 数据,可能产生不一致
- 比赛结束落库时
finalizeContestRanking是从提交记录重建的,所以不受此影响
2.3.4 封禁后判题结果仍会触发排名更新
位置:StatsConsumer.java:56-103
MQ 消费者收到 TYPE_CONTEST_JUDGE 消息后,直接调用排名更新:
if (contest != null && contest.getType() == ContestTypeEnum.OI) {
contestRankService.updateOIRanking(...);
} else {
contestRankService.updateACMRanking(...);
}
没有检查该用户是否已被封禁。两个 update 方法内部的 Lua 脚本会执行 ZADD,将用户重新加入 ZSet。
后果:管理员封禁用户 → recalculateRanks 从 ZSet 移除 → 该用户之前的提交仍在判题队列中 → 判题完成后 MQ 消息到达 → Lua 脚本 ZADD 把用户加回排行榜。封禁操作被”撤销”。
2.3.5 落库时的封禁判定可能不完整
位置:ContestRankServiceImpl.java:258-261
Set<Long> bannedUserIds = allRegs.stream()
.filter(reg -> reg.getStatus() != null && reg.getStatus() != 0)
.map(ContestRegistration::getUserId)
.collect(Collectors.toSet());
如果一个用户因为问题 1-3 有两条 registration 记录(一条 status=1 被封禁,一条 status=0 重新报名),allUserIds 会包含该用户,bannedUserIds 也会包含该用户(因为有一条 status=1)。由于 Set<Long> 按 userId 去重,该用户会同时出现在两个集合中,最终 rank 被设为 0——这恰好是正确的结果,但属于”偶然正确”,而非设计保证。
三、问题汇总
| 编号 | 严重程度 | 问题描述 | 涉及文件 |
|---|---|---|---|
| P1 | 严重 | 封禁用户可通过重新报名绕过封禁 | ContestServiceImpl.registerContest() |
| P2 | 严重 | 封禁后判题消息仍会把用户 ZADD 回排行榜 | StatsConsumer.handleStatsMessage() |
| P3 | 严重 | 封禁与作弊共用同一逻辑,作弊用户被完全移除出排行榜 | ContestRankServiceImpl.getBannedUserIds() |
| P4 | 中等 | ContestRankVO 缺少作弊标记字段,前端无法展示 | ContestRankVO.java |
| P5 | 中等 | 比赛中取消作弊后用户无法恢复到排行榜 | ContestRankServiceImpl.recalculateRanks() Redis 路径 |
| P6 | 轻微 | 封禁时 Redis detail Hash 未清理 | ContestRankServiceImpl.recalculateRanks() Redis 路径 |
| P7 | 轻微 | 多条 registration 记录时 selectOne 行为不确定 | QuestionSubmitServiceImpl.doSubmitQuestion() |
| D1 | 严重 | ACM 每次判题全量查提交记录,O(N) 增长 | ContestRankServiceImpl.updateACMRanking() |
| D2 | 中等 | Contest 对象在判题链路中被重复查询 2 次 | StatsConsumer + ContestRankServiceImpl |
| D3 | 中等 | 提交时 registration 被重复查询 2 次 | QuestionSubmitServiceImpl.doSubmitQuestion() |
| D4 | 中等 | 比赛/题目列表等稳定数据每次请求都查 DB | 多处 |
| S1 | 严重 | ContestStatusScheduler 每 30 秒对无索引的 contest 表执行 2 次全表扫描 | ContestStatusScheduler + ContestServiceImpl.updateContestStatuses() |
| S2 | 严重 | contest 表缺少 (status, startTime) 和 (status, endTime) 索引 | init.sql |
| S3 | 中等 | StatsSyncScheduler 刷新排行榜 score 时 N+1 逐个 selectById 查用户 | StatsSyncScheduler.flushStatsToDatabase() 第 5 步 |
四、赛时高频数据库操作排查
比赛期间存在三条热路径:用户提交代码、MQ 消费判题结果、用户查看排行榜。每条路径上的数据库查询均在高并发下被反复命中,需评估是否应迁移到 Redis 缓存。
4.1 热路径一:用户提交代码
入口:QuestionSubmitServiceImpl.doSubmitQuestion()
每次比赛提交执行 5 次 DB 查询:
| # | 操作 | 代码位置 | 说明 |
|---|---|---|---|
| ① | questionService.getById(questionId) | 第 76 行 | 查题目是否存在 |
| ② | contestService.getById(contestId) | 第 84 行 | 查比赛状态 |
| ③ | contestService.isRegistered(contestId, userId) | 第 91 行 | 查 registration 表 status=0 |
| ④ | contestRegistrationMapper.selectOne(...) | 第 95-98 行 | 再查一次 registration 表检查封禁 |
| ⑤ | contestQuestionMapper.selectCount(...) | 第 102-105 行 | 查题目是否属于该比赛 |
问题分析:
- ③ 和 ④ 重复查询同一张表:
isRegistered查status=0是否存在,紧接着 ④ 又selectOne查同一条记录判断status != 0。完全可以合并为一次查询。 - ① 题目信息:比赛期间题目不变,适合缓存。
- ② 比赛信息:比赛期间
Contest对象仅 status 字段会随定时器更新,可缓存并在状态变更时主动失效。 - ⑤ 比赛题目关系:比赛开始后不会变更,适合缓存。
4.2 热路径二:MQ 消费判题结果
入口:StatsConsumer.handleStatsMessage() → ContestRankServiceImpl.updateACMRanking/updateOIRanking()
StatsConsumer 层(每条判题消息 1 次):
| # | 操作 | 代码位置 | 说明 |
|---|---|---|---|
| ① | contestMapper.selectById(message.getContestId()) | StatsConsumer 第 75 行 | 查比赛信息(状态、封榜时间、赛制类型) |
ACM 排名更新(每条 ACM 判题消息 2 次):
| # | 操作 | 代码位置 | 说明 |
|---|---|---|---|
| ② | contestService.getById(contestId) | ContestRankServiceImpl 第 150 行 | 再查一次比赛信息(取 startTime) |
| ③ | questionSubmitService.lambdaQuery()...list() | ContestRankServiceImpl 第 155-160 行 | 查该用户在该题的全部提交记录,用于计算罚时 |
OI 排名更新(每条 OI 判题消息 1 次):
| # | 操作 | 代码位置 | 说明 |
|---|---|---|---|
| ② | contestQuestionMapper.selectOne(...) | ContestRankServiceImpl 第 184-187 行 | 查该题的满分值 |
问题分析:
- ① 和 ② 重复查询 Contest:StatsConsumer 已经查过一次 Contest,但没有传递给
updateACMRanking,导致后者又查一次。每条判题消息查了 2 次 Contest 表。 - ③ 全量查提交记录是最重的操作:ACM 每次判题结果都要查该用户在该题的全部提交记录(
SELECT * FROM question_submit WHERE contestId=? AND userId=? AND questionId=? ORDER BY createTime)。如果一个用户对一道题提交了 20 次,每次判题结果到达都要查出全部 20 条记录。这是 O(N) 增长的查询,随提交次数增长越来越慢。 - OI 的 ② 查满分值:比赛期间题目满分不会变,完全可以缓存。
4.3 热路径三:用户查看排行榜
入口:ContestRankServiceImpl.getContestRanking() → getContestRankingFromRedis()
比赛进行时每次查看排行榜执行 3 次 DB 查询:
| # | 操作 | 代码位置 | 说明 |
|---|---|---|---|
| ① | contestService.getById(contestId) | 第 127 行 | 查比赛信息 |
| ② | getContestQuestions(contestId) | 第 401 行 | 查该比赛所有题目列表 |
| ③ | userService.getOtherUserVOMap(userIds) | 第 400 行 | 批量查用户信息(昵称、头像) |
问题分析:
- ① 和 ② 是稳定数据:比赛信息和题目列表在比赛期间不变,每次排行榜请求都查 DB 是浪费。
- ③ 用户信息:昵称和头像在比赛期间极少变更,可以短 TTL 缓存。
- 如果 100 个用户同时刷排行榜,每秒就是 300 次 DB 查询,仅为了查基本不变的数据。
4.4 高频 DB 操作汇总
| 编号 | 严重程度 | 热路径 | 操作 | 每次触发次数 | 数据变化频率 | 建议 |
|---|---|---|---|---|---|---|
| D1 | 严重 | 判题结果 | ACM 全量查提交记录 | 1 | 每次提交增长 | 改为增量计算,见方案 |
| D2 | 严重 | 判题结果 | Contest 被重复查 2 次 | 2 | 极低 | StatsConsumer 传递给 update 方法 / Redis 缓存 |
| D3 | 中等 | 用户提交 | registration 被查 2 次(③④重复) | 2 | 仅管理员操作时变 | 合并为 1 次查询 |
| D4 | 中等 | 用户提交 | Contest 对象查询 | 1 | 极低 | Redis 缓存 |
| D5 | 中等 | 用户提交 | 比赛题目关系 selectCount | 1 | 不变 | Redis Set 缓存 |
| D6 | 中等 | 排行榜 | Contest + 题目列表查询 | 2 | 不变 | Redis 缓存 |
| D7 | 中等 | 排行榜 | 用户信息批量查询 | 1 | 极低 | 短 TTL Redis 缓存 |
| D8 | 中等 | 判题结果 | OI 查题目满分值 | 1 | 不变 | Redis 缓存 / 方法参数传递 |
4.5 定时任务性能排查
系统共有 3 个定时/启动任务,逐一排查:
4.5.1 ContestStatusScheduler —— 每 30 秒全表扫描(严重)
文件:ContestStatusScheduler.java → ContestServiceImpl.updateContestStatuses()
当前行为:每 30 秒执行以下 2 条 SQL:
-- 查询 1:哪些比赛该开始了
SELECT * FROM contest WHERE status = 0 AND startTime <= NOW();
-- 查询 2:哪些比赛该结束了
SELECT * FROM contest WHERE status = 1 AND endTime <= NOW();
问题:
| 问题 | 说明 |
|---|---|
| 无索引 | contest 表没有 status 列索引,也没有 (status, startTime) 或 (status, endTime) 联合索引,两条查询都是全表扫描 |
| 无条件轮询 | 即使系统中没有任何比赛,或所有比赛都已结束,仍然每 30 秒扫描两次全表 |
| SELECT * | lambdaQuery().list() 返回完整 Contest 对象,包括 description(TEXT 类型),浪费带宽和内存 |
| 设计缺陷 | 比赛的开始/结束时间在创建时就已确定,完全可以精确调度,不需要轮询 |
4.5.2 StatsSyncScheduler —— 每 5 秒增量落库(一般)
文件:StatsSyncScheduler.java
当前行为:每 5 秒从内存聚合器 drain 增量,按 ID 逐条 UPDATE。
评价:
stats.isEmpty()可以快速短路,无数据时不查 DB ✓- 按主键 ID 更新,不是全表扫描 ✓
- 问题:第 5 步刷新排行榜 score 时,对每个 dirty userId 执行
selectById查完整 User 对象,属于 N+1 查询。如果 5 秒内有 50 个用户提交,就是 50 次SELECT * FROM user WHERE id = ?。
// 第 120-134 行:N+1 查询
for (Long userId : rankDirtyUserIds) {
User user = userMapper.selectById(userId); // ← 每个用户查一次 DB
// 计算 score 并写入 Redis ZSet
}
4.5.3 RankInitializer —— 启动时全表扫描(可接受)
文件:RankInitializer.java
当前行为:应用启动时,如果 Redis 排行榜为空,全量查用户表同步。
评价:
- 仅启动时执行一次,且有
zCard > 0的短路判断 ✓ - 使用
select(id, solvedCount, submitCount)只查需要的字段 ✓ - 当前可接受,但用户量增长到十万级以上时需分批查询
4.6 数据库索引缺失排查
| 表 | 缺失索引 | 受影响查询 | 严重程度 |
|---|---|---|---|
contest | (status, startTime) | 定时器查待开始比赛 | 严重 |
contest | (status, endTime) | 定时器查待结束比赛 | 严重 |
contest_registration | (contestId, userId, status) | 提交时查报名状态、封禁检查 | 中等(现有 uk_contest_user 覆盖了 (contestId, userId) 但不含 status) |
4.7 赛时 DB 总压力估算
假设一场 200 人参加的比赛,每人平均提交 30 次,比赛时长 3 小时:
提交阶段(每次提交 5 次 DB 查询,其中 2 次重复):
200 × 30 × 5 = 30,000 次 DB 查询
判题结果阶段(ACM,每次 3 次 DB 查询,其中 1 次是全量提交记录):
200 × 30 × 3 = 18,000 次 DB 查询
排行榜轮询(假设每人每分钟刷 1 次,3 次 DB/次):
200 × 180 × 3 = 108,000 次 DB 查询
定时器轮询(每 30 秒 2 次全表扫描,3 小时):
360 × 2 = 720 次全表扫描
StatsSyncScheduler N+1(每 5 秒,假设平均 10 个 dirty user):
2,160 × 10 = 21,600 次 DB 查询
总计:约 177,600 次 DB 查询 + 720 次全表扫描
优化后(精确调度 + 缓存 + 消除 N+1):约 24,000 次(减少 86%),0 次全表扫描
五、修复方案
5.1 封禁用户(status=1)路径重构
5.1.1 阻止重新报名
在 ContestServiceImpl.registerContest() 中增加封禁检查:
// 检查是否被封禁(无论 status 值,只要存在记录就查)
ContestRegistration existingReg = contestRegistrationMapper.selectOne(
new LambdaQueryWrapper<ContestRegistration>()
.eq(ContestRegistration::getContestId, contestId)
.eq(ContestRegistration::getUserId, userId));
if (existingReg != null) {
if (existingReg.getStatus() == 1) {
throw new BusinessException("您已被取消参赛资格,无法重新报名");
}
if (existingReg.getStatus() == 0) {
throw new BusinessException(MessageConstant.CONTEST_ALREADY_REGISTERED);
}
// status=2(作弊)是否允许重新报名?按需求不涉及,暂维持已报名状态
}
5.1.2 MQ 消费时校验封禁状态(从 Redis 读取)
MQ 消费是高频路径,封禁状态应从 Redis 查询而非数据库。
Redis Key 设计:
contest:banned:{contestId} → Set<String> (存放被封禁的 userId)
写入时机:管理员执行封禁操作时写入 Redis Set
// AdminContestController.banUser() 中
stringRedisTemplate.opsForSet().add(
RedisKeyPrefixConstant.contestBannedKey(contestId), userId.toString());
MQ 消费时校验(StatsConsumer.handleStatsMessage()):
// 从 Redis 检查用户是否已被封禁(O(1),无 DB 开销)
String bannedKey = RedisKeyPrefixConstant.contestBannedKey(message.getContestId());
Boolean isBanned = stringRedisTemplate.opsForSet()
.isMember(bannedKey, message.getUserId().toString());
if (Boolean.TRUE.equals(isBanned)) {
log.info("用户 {} 已被封禁,跳过排名更新", message.getUserId());
channel.basicAck(deliveryTag, false);
return;
}
RedisKeyPrefixConstant 新增:
public static final String CONTEST_BANNED_PREFIX = "contest:banned:";
public static String contestBannedKey(Long contestId) {
return CONTEST_BANNED_PREFIX + contestId;
}
清理时机:比赛结束落库后,随其他 Redis key 一起删除。
5.1.3 封禁时清理 Redis detail Hash
在 recalculateRanks() Redis 路径中增加 detail 清理:
for (Long bannedUserId : bannedUserIds) {
stringRedisTemplate.opsForZSet().remove(rankKey, bannedUserId.toString());
stringRedisTemplate.delete(prefixedDetailKey(contestId, bannedUserId)); // 新增
}
5.2 标记作弊(status=2)路径重构
5.2.1 拆分 getBannedUserIds,区分封禁和作弊
/** 获取被封禁的用户(status=1),从排行榜完全移除 */
private Set<Long> getBannedUserIds(Long contestId) {
return contestRegistrationMapper.selectList(
new LambdaQueryWrapper<ContestRegistration>()
.eq(ContestRegistration::getContestId, contestId)
.eq(ContestRegistration::getStatus, 1))
.stream().map(ContestRegistration::getUserId)
.collect(Collectors.toSet());
}
/** 获取被标记作弊的用户(status=2),保留在排行榜但加标记 */
private Set<Long> getCheatedUserIds(Long contestId) {
return contestRegistrationMapper.selectList(
new LambdaQueryWrapper<ContestRegistration>()
.eq(ContestRegistration::getContestId, contestId)
.eq(ContestRegistration::getStatus, 2))
.stream().map(ContestRegistration::getUserId)
.collect(Collectors.toSet());
}
5.2.2 recalculateRanks 仅移除封禁用户,不移除作弊用户
@Override
public void recalculateRanks(Long contestId) {
Contest contest = contestService.getById(contestId);
if (contest == null) return;
if (Boolean.TRUE.equals(contest.getRankFinalized())) {
recalculateRanksInDB(contestId, contest);
return;
}
// 仅移除封禁用户(status=1),不移除作弊用户(status=2)
Set<Long> bannedUserIds = getBannedUserIds(contestId);
String rankKey = prefixedRankKey(contestId);
for (Long bannedUserId : bannedUserIds) {
stringRedisTemplate.opsForZSet().remove(rankKey, bannedUserId.toString());
stringRedisTemplate.delete(prefixedDetailKey(contestId, bannedUserId));
}
}
5.2.3 recalculateRanksInDB 区分封禁和作弊
private void recalculateRanksInDB(Long contestId, Contest contest) {
Set<Long> bannedUserIds = getBannedUserIds(contestId); // status=1
Set<Long> cheatedUserIds = getCheatedUserIds(contestId); // status=2
List<ContestRanking> rankings = contestRankingMapper.selectList(...);
List<ContestRanking> normal = new ArrayList<>();
List<ContestRanking> banned = new ArrayList<>();
List<ContestRanking> cheated = new ArrayList<>();
for (ContestRanking ranking : rankings) {
if (bannedUserIds.contains(ranking.getUserId())) {
banned.add(ranking);
} else if (cheatedUserIds.contains(ranking.getUserId())) {
cheated.add(ranking);
} else {
normal.add(ranking);
}
}
// 正常用户:正常排名
// 排序逻辑不变...
for (int i = 0; i < normal.size(); i++) {
normal.get(i).setRank(i + 1);
contestRankingMapper.updateById(normal.get(i));
}
// 作弊用户:保留排名但加标记(rank 使用负数或特殊标记)
// 方案 A:rank 正常计算但 VO 中加 cheated 字段
// 方案 B:rank 使用 -1 表示作弊(需前端配合)
// 推荐方案 A
for (ContestRanking ranking : cheated) {
// rank 保留原值(或重新计算),在 VO 层面加 cheated=true
contestRankingMapper.updateById(ranking);
}
// 封禁用户:rank=0
for (ContestRanking ranking : banned) {
ranking.setRank(0);
contestRankingMapper.updateById(ranking);
}
}
5.2.4 ContestRankVO 增加作弊标记字段
public class ContestRankVO {
private Integer rank;
private Long userId;
private String nickname;
private String avatar;
private Integer solvedCount;
private Integer totalPenalty;
private Integer totalScore;
private List<ContestProblemDetail> problemResults;
private Boolean cheated; // 新增:是否被标记为作弊
}
5.2.5 构建 VO 时查询作弊状态
在 getContestRankingFromRedis() 和 getContestRankingFromDB() 中:
Set<Long> cheatedUserIds = getCheatedUserIds(contestId);
// 构建 VO 时
voList.add(ContestRankVO.builder()
// ...其他字段
.cheated(cheatedUserIds.contains(userId))
.build());
5.2.6 finalizeContestRanking 同步修改
落库时同样区分封禁和作弊:
- 封禁用户(status=1):rank = 0,查询时
rank > 0过滤掉 - 作弊用户(status=2):rank 正常计算,在
ContestRanking表中增加cheated字段(或在查询时通过 join registration 表获取)
5.2.7 前端展示修改
ContestDetailView.vue 排行榜表格中:
<td>
<span v-if="item.cheated" style="color: red;">[已被标记为作弊]</span>
{{ item.nickname }}
</td>
types/index.ts 中 ContestRankVO 增加:
export interface ContestRankVO {
// ...existing fields
cheated?: boolean;
}
5.3 取消作弊后恢复排行(Redis 路径)
需要在取消作弊时,将用户重新加回 Redis ZSet。可在 AdminContestController.markCheat() 的 cheat=false 分支中,手动触发该用户的排名重建:
if (!cheat) {
// 取消作弊后,重建该用户的 Redis 排名数据
contestRankService.rebuildUserRanking(contestId, userId);
}
rebuildUserRanking 方法遍历该用户的所有比赛提交,重新计算并 ZADD 回 Redis。
5.4 赛时高频 DB 操作优化方案
5.4.0 缓存一致性总体策略
引入 Redis 缓存前,必须先明确每个缓存对象在赛中是否可变,以及对应的失效机制。
赛中数据可变性排查结果:
| 缓存对象 | 赛中是否可变 | 变更入口 | 变更频率 |
|---|---|---|---|
| Contest(比赛信息) | 可变 | ContestServiceImpl.updateContest() 允许 RUNNING 状态修改;updateContestStatuses() 改 status | 极低(管理员手动) |
| ContestQuestion(题目-比赛关系) | 不可变 | setContestQuestions() 限制 status != NOT_STARTED 不可修改 | 无 |
| Question(题目信息) | 可变 | QuestionServiceImpl.updateQuestion() 无赛中限制,管理员随时可改题面/描述 | 低(澄清题意时) |
| User(用户信息) | 可变 | 用户修改昵称/头像 | 极低 |
| ContestRegistration(报名状态) | 可变 | 封禁/作弊操作 | 极低(管理员手动) |
核心原则:
- 不可变数据(ContestQuestion):预热时写入缓存,赛后清理,无需失效机制
- 可变但极低频数据(Contest、Question、User):采用 Cache-Aside + 写时主动失效 策略
- 所有缓存设置兜底 TTL:即使失效机制异常,缓存也会在 TTL 后自动过期,防止永久脏数据
- 写入操作统一走 Service 层,在 Service 方法中嵌入缓存失效逻辑,而非在 Controller 层或多处分散处理
5.4.1 Contest 对象 Redis 缓存(解决 D2、D4、D6)
Redis Key:
contest:info:{contestId} → String (JSON 序列化的 Contest 核心字段)
TTL = 比赛剩余时长 + 30 分钟(兜底)
写入时机:
- 比赛开始时
warmUpContestRanking中写入缓存
失效时机:
ContestServiceImpl.updateContest()修改后主动删除缓存(下次读取时回源 DB)updateContestStatuses()更新状态后主动删除缓存- 比赛结束清理时一并删除
// ContestServiceImpl.updateContest() 中新增
@Override
@Transactional
public void updateContest(ContestUpdateDTO dto) {
// ... 原有逻辑 ...
updateById(contest);
// 主动失效缓存
stringRedisTemplate.delete(RedisKeyPrefixConstant.contestInfoKey(dto.getId()));
}
改造点:
- 提供
getContestCached(contestId)方法,优先从 Redis 读取,miss 时回源 DB 并写入缓存 StatsConsumer中的contestMapper.selectById()改用缓存方法updateACMRanking中的contestService.getById()改用缓存方法,或由 StatsConsumer 传入 Contest 对象getContestRanking中的contestService.getById()改用缓存方法
5.4.2 比赛题目列表 Redis 缓存(解决 D5、D6、D8)
比赛开始后 setContestQuestions() 代码已限制不可修改(status != NOT_STARTED 直接抛异常),属于不可变数据。
Redis Key:
contest:questions:{contestId} → String (JSON 序列化的 List<ContestQuestion>)
TTL = 比赛剩余时长 + 30 分钟
写入时机:warmUpContestRanking 时预热
失效时机:比赛结束时清理(赛中不会变,无需主动失效)
改造点:
- 提供
getContestQuestionsCached(contestId)方法 updateOIRanking中的contestQuestionMapper.selectOne()改为从缓存中查找getContestRankingFromRedis中的getContestQuestions()改用缓存
5.4.3 Question 题目信息 Redis 缓存(新增)
题目信息(标题、描述、难度等)在赛中可能被管理员修改(如澄清题意),是可变数据,需要主动失效。
高频读取场景:
| 场景 | 调用链 | 频率 |
|---|---|---|
| 用户提交代码 | doSubmitQuestion() → questionService.getById() | 每次提交 |
| 查看比赛题目列表 | getContestProblems() → questionService.listByIds() | 每次打开题目页 |
| 查看提交列表 | getContestSubmissions() → questionService.listByIds() | 每次刷提交 |
| 排行榜展示 | 不直接查 Question,通过 ContestQuestion 关联 | — |
Redis Key:
question:info:{questionId} → String (JSON 序列化的 Question 核心字段)
TTL = 10 分钟(兜底,防止失效机制失败后长期脏数据)
写入时机:getById cache-miss 时回源 DB 并写入
失效时机:QuestionServiceImpl.updateQuestion() 修改后主动删除缓存
// QuestionServiceImpl.updateQuestion() 中新增
@Override
public void updateQuestion(QuestionUpdateDTO dto) {
// ... 原有逻辑 ...
updateById(question);
// 主动失效缓存
stringRedisTemplate.delete(RedisKeyPrefixConstant.questionInfoKey(dto.getId()));
}
改造点:
- 提供
getQuestionCached(questionId)/listQuestionsCached(ids)方法 doSubmitQuestion()中的questionService.getById()改用缓存方法getContestProblems()中的questionService.listByIds()改用缓存方法
为什么 TTL 设 10 分钟而非更长: 题目信息是面向用户的内容,修改后用户应尽快看到更新(如题面澄清)。10 分钟 TTL 作为兜底,确保即使主动失效未生效,最迟 10 分钟后也能看到最新内容。正常情况下主动失效是即时的。
5.4.4 提交时合并重复的 registration 查询(解决 D3)
doSubmitQuestion 中 isRegistered 和随后的 selectOne 查同一张表同一条记录,合并为一次查询:
// 合并前:2 次 DB 查询
if (!contestService.isRegistered(contestId, userId)) { throw ... }
ContestRegistration reg = contestRegistrationMapper.selectOne(...);
if (reg.getStatus() != 0) { throw ... }
// 合并后:1 次 DB 查询(或 1 次 Redis 查询)
ContestRegistration reg = contestRegistrationMapper.selectOne(
new LambdaQueryWrapper<ContestRegistration>()
.eq(ContestRegistration::getContestId, contestId)
.eq(ContestRegistration::getUserId, userId));
if (reg == null) {
throw new BusinessException(MessageConstant.CONTEST_NOT_REGISTERED);
}
if (reg.getStatus() == 1) {
throw new BusinessException("您已被取消参赛资格");
}
// status=2 的作弊用户按需求仍可提交
进一步优化:封禁状态也可从 contest:banned:{contestId} Redis Set 中判断,减少 DB 查询。
5.4.5 ACM 排名更新避免全量查提交记录(解决 D1)
当前问题:每次判题完成都查 SELECT * FROM question_submit WHERE contestId=? AND userId=? AND questionId=? ORDER BY createTime,随提交次数 O(N) 增长。
优化方案:将 ACM 题目状态维护在 Redis Hash 中,避免每次重算
在 contest:detail:{contestId}:{userId} 的 Hash 中已经存储了每道题的 ContestProblemDetail(包含 solved、attempts、penalty、firstSolveTime)。
当前 updateACMRanking 的做法是:查出全部提交 → 从头重算 detail → 写入 Redis。
优化方案是增量更新:
// 1. 先从 Redis Hash 读取该题的当前 detail
String existingJson = stringRedisTemplate.opsForHash()
.get(detailKey, questionId.toString());
ContestProblemDetail existing = existingJson != null ? fromJson(existingJson) : null;
// 2. 如果已经 AC,跳过(ACM 首次 AC 后不再更新)
if (existing != null && Boolean.TRUE.equals(existing.getSolved())) {
return;
}
// 3. 增量更新
if (accepted) {
// 本次 AC:计算 firstSolveTime 和 penalty
int attempts = (existing != null ? existing.getAttempts() : 0) + 1;
int firstSolveTime = (int) Duration.between(contestStart, submitTime).toMinutes();
int penalty = firstSolveTime + (attempts - 1) * 20;
detail = new ContestProblemDetail(solved=true, attempts, penalty, firstSolveTime, ...);
} else {
// 本次未 AC:仅 attempts+1
int attempts = (existing != null ? existing.getAttempts() : 0) + 1;
detail = new ContestProblemDetail(solved=false, attempts, 0, null, ...);
}
// 4. Lua 脚本原子写入(现有逻辑不变)
这样每次判题结果只需 1 次 Redis HGET + 1 次 Lua 脚本,完全消除 DB 查询。
注意:需要在 StatsMessage 中增加 submitTime 字段,由判题服务回传提交时间,用于计算 firstSolveTime。
5.4.6 用户信息短 TTL 缓存(解决 D7)
排行榜展示的用户昵称和头像在比赛期间极少变更。
Redis Key:
user:info:{userId} → Hash (nickname, avatar) TTL=300s
失效时机:用户修改个人信息时主动删除(如有对应接口);TTL 5 分钟兜底。
此为通用优化,不仅排行榜受益,比赛提交列表等场景也可复用。
5.5 定时任务优化方案
5.5.1 比赛状态调度:TaskScheduler 精确调度 + Redis 分布式锁(解决 4.5.1)
当前方案的根本问题:比赛的开始/结束时间在创建时就已确定,不需要每 30 秒盲目全表扫描。
方案选型过程
最初考虑了 RabbitMQ DLX(Dead Letter Exchange)+ per-message TTL 方案,但评估后放弃,原因如下:
| 问题 | 说明 |
|---|---|
| HOL Blocking 无法根治 | RabbitMQ DLX 底层是 FIFO 队列,TTL 只检查队首消息。每小时一次的扫描窗口内可以升序投递,但跨扫描周期仍会阻塞:若已有 TTL=20h 的消息在队首,新建一场 2 小时后开始的比赛投递的消息会被追加到队尾,实际触发时间可能偏差 20h。 |
| 多实例扫描器重复投递 | @Scheduled 在多实例下所有实例同时触发扫描,需要 ShedLock 等额外组件才能保证只有一个实例投递。 |
| 新增基础设施重 | 需要 2 个 Exchange + 2 个 Queue + 消息体类 + 扫描器 + 消费者 5 个新组件,运维复杂度高。 |
delayed-message 插件弃用 | RabbitMQ 官方插件已于 2026 年 1 月归档弃用,而原生 DLX 不能真正解决 HOL Blocking。 |
选定方案:TaskScheduler 精确调度 + Redis 分布式锁
系统已部署 Redis,利用 setIfAbsent 即可实现分布式锁,无需引入任何新组件。
- 精确触发:每场比赛创建时直接
schedule()到对应时间点,秒级精度,无排队阻塞。 - 多实例安全:多个实例同时触发,但只有抢到 Redis 分布式锁的实例真正执行状态变更;数据库状态幂等判断作为第二道保障。
- 轻量:核心逻辑 2 个方法,无 MQ 队列配置,无扫描器,无消息体类。
架构概览:
创建/修改比赛
↓
ContestServiceImpl.scheduleContest(contest)
├─ taskScheduler.schedule(startContest, startTime)
└─ taskScheduler.schedule(endContest, endTime)
时间到达时(多个实例同时触发)
↓
ContestSchedulerService.startContest(contestId)
├─ Redis SETNX contest:lock:start:{id} TTL=30s
├─ 抢锁失败 → return(其他实例已处理)
├─ 抢锁成功 → 查 DB 幂等校验(status == NOT_STARTED?)
└─ 更新状态 → warmUpContestRanking
应用启动时
↓
ContestRecoveryInitializer.@PostConstruct
├─ 查所有 NOT_STARTED 且 startTime > now → 重新调度 START
├─ 查所有 RUNNING 且 endTime > now → 重新调度 END
├─ 查所有 NOT_STARTED 且 startTime <= now → 立即执行 startContest(补偿)
└─ 查所有 RUNNING 且 endTime <= now → 立即执行 endContest(补偿)
Step 1:ContestSchedulerService —— 持有 Future,执行状态变更
@Service
@Slf4j
@RequiredArgsConstructor
public class ContestSchedulerService {
private final TaskScheduler taskScheduler;
private final ContestMapper contestMapper;
private final ContestRankService contestRankService;
private final StringRedisTemplate stringRedisTemplate;
/** contestId → (startFuture, endFuture) */
private final Map<Long, ScheduledFuture<?>> startFutures = new ConcurrentHashMap<>();
private final Map<Long, ScheduledFuture<?>> endFutures = new ConcurrentHashMap<>();
// ──── 调度 ────
/** 创建或修改比赛时调用,取消旧任务并重新调度 */
public void scheduleContest(Contest contest) {
cancelIfPresent(startFutures, contest.getId());
cancelIfPresent(endFutures, contest.getId());
LocalDateTime now = LocalDateTime.now();
if (contest.getStartTime().isAfter(now)) {
startFutures.put(contest.getId(), taskScheduler.schedule(
() -> startContest(contest.getId()),
contest.getStartTime().toInstant(ZoneOffset.ofHours(8))));
}
if (contest.getEndTime().isAfter(now)) {
endFutures.put(contest.getId(), taskScheduler.schedule(
() -> endContest(contest.getId()),
contest.getEndTime().toInstant(ZoneOffset.ofHours(8))));
}
}
private void cancelIfPresent(Map<Long, ScheduledFuture<?>> map, Long contestId) {
ScheduledFuture<?> old = map.remove(contestId);
if (old != null) old.cancel(false);
}
// ──── 执行(多实例安全) ────
public void startContest(Long contestId) {
String lockKey = "contest:lock:start:" + contestId;
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(locked)) return;
try {
Contest contest = contestMapper.selectById(contestId);
if (contest == null || contest.getStatus() != ContestStatusEnum.NOT_STARTED) return;
contest.setStatus(ContestStatusEnum.RUNNING);
contestMapper.updateById(contest);
contestRankService.warmUpContestRanking(contestId);
log.info("比赛[{}]已精确触发开始", contestId);
} finally {
stringRedisTemplate.delete(lockKey);
}
}
public void endContest(Long contestId) {
String lockKey = "contest:lock:end:" + contestId;
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 60, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(locked)) return;
try {
Contest contest = contestMapper.selectById(contestId);
if (contest == null || contest.getStatus() != ContestStatusEnum.RUNNING) return;
contest.setStatus(ContestStatusEnum.ENDED);
contestMapper.updateById(contest);
contestRankService.finalizeContestRanking(contestId);
log.info("比赛[{}]已精确触发结束", contestId);
} finally {
stringRedisTemplate.delete(lockKey);
}
}
}
Step 2:ContestServiceImpl —— 创建/修改时触发调度
// createContest() 末尾
contestSchedulerService.scheduleContest(contest);
// updateContest() 末尾(时间可能变更)
contestSchedulerService.scheduleContest(contest);
时间修改后旧的 ScheduledFuture 被 cancel(false) 取消,新任务重新按新时间点调度,多实例下各自取消自己的 Future,到点时 Redis 锁保证只有一个实例真正执行。
Step 3:ContestRecoveryInitializer —— 启动时恢复调度 + 补偿
@Component
@Slf4j
@RequiredArgsConstructor
public class ContestRecoveryInitializer {
private final ContestMapper contestMapper;
private final ContestSchedulerService contestSchedulerService;
@PostConstruct
public void recover() {
LocalDateTime now = LocalDateTime.now();
// ① 重新调度未来事件(上次宕机前已调度的任务已丢失)
contestMapper.selectList(new LambdaQueryWrapper<Contest>()
.eq(Contest::getStatus, ContestStatusEnum.NOT_STARTED)
.gt(Contest::getStartTime, now))
.forEach(contestSchedulerService::scheduleContest);
contestMapper.selectList(new LambdaQueryWrapper<Contest>()
.eq(Contest::getStatus, ContestStatusEnum.RUNNING)
.gt(Contest::getEndTime, now))
.forEach(contestSchedulerService::scheduleContest);
// ② 立即补偿宕机期间错过的事件
contestMapper.selectList(new LambdaQueryWrapper<Contest>()
.eq(Contest::getStatus, ContestStatusEnum.NOT_STARTED)
.le(Contest::getStartTime, now))
.forEach(c -> contestSchedulerService.startContest(c.getId()));
contestMapper.selectList(new LambdaQueryWrapper<Contest>()
.eq(Contest::getStatus, ContestStatusEnum.RUNNING)
.le(Contest::getEndTime, now))
.forEach(c -> contestSchedulerService.endContest(c.getId()));
log.info("比赛调度恢复完成");
}
}
效果对比:
| 维度 | 轮询方案(当前) | TaskScheduler + Redis 锁 |
|---|---|---|
| DB 查询频率 | 每 30 秒 2 次全表扫描(2880 次/天) | 启动时全量查一次 + 事件触发时 1 次主键查询 |
| 扫描范围 | 全表 SELECT * 无索引 | 启动恢复时命中 (status, startTime/endTime) 复合索引 |
| 触发延迟 | 最多 30 秒 | 秒级以内(JVM 定时器精度) |
| HOL Blocking | 不适用 | 不存在(每个任务独立调度) |
| 运行时开销 | 每 30 秒必然触发 DB 扫描 | 无比赛时零开销 |
| 比赛时间修改 | 下次轮询自动生效 | cancel 旧 Future + 重新 schedule |
| 宕机恢复 | 重启后 30s 内自动扫到 | @PostConstruct 立即恢复 + 补偿 |
| 多实例安全 | 重复执行(靠 DB update 幂等兜底) | Redis 分布式锁 + DB 状态幂等双重保障 |
| 新增基础设施 | 无 | 无(复用已有 Redis) |
5.5.2 StatsSyncScheduler N+1 查询优化(解决 4.5.2)
当前问题:刷新排行榜 score 时,逐个 selectById 查用户。
优化方案:批量查询替代 N+1。
// 优化前(N+1):
for (Long userId : rankDirtyUserIds) {
User user = userMapper.selectById(userId); // 每个用户查 1 次
// ...
}
// 优化后(批量):
if (!rankDirtyUserIds.isEmpty()) {
List<User> users = userMapper.selectBatchIds(rankDirtyUserIds);
for (User user : users) {
int solved = user.getSolvedCount() == null ? 0 : user.getSolvedCount();
int submits = user.getSubmitCount() == null ? 0 : user.getSubmitCount();
int acceptRateBasis = submits > 0
? (int) Math.floor((double) solved * 10000 / submits) : 0;
double score = solved * 100_000D + acceptRateBasis;
stringRedisTemplate.opsForZSet().add(
RedisKeyPrefixConstant.USER_RANK_ZSET, user.getId().toString(), score);
}
}
50 个 dirty user 从 50 次 DB 查询降为 1 次 WHERE id IN (...) 查询。
进一步优化:由于步骤 3、4 已经分别更新了 solvedCount 和 submitCount,可以在内存中直接计算新的 score,完全不查 DB:
// 终极方案:内存计算,0 次 DB 查询
for (Long userId : rankDirtyUserIds) {
// 从 Redis ZSet 获取当前 score,反推出 solvedCount 和 submitCount
// 或者在聚合器中维护 (solvedCount, submitCount) 的最新值
// 直接计算新 score 并 ZADD
}
5.5.3 数据库索引补充
-- contest 表:定时器查询和通用状态查询
ALTER TABLE contest ADD INDEX idx_status_startTime (status, startTime);
ALTER TABLE contest ADD INDEX idx_status_endTime (status, endTime);
即使迁移到精确调度方案,这些索引仍有价值:启动恢复时 WHERE status != 2 的查询、管理后台按状态筛选比赛列表等。
5.4.7 缓存一致性保障总表
| 缓存对象 | Redis Key | 赛中可变 | 写入时机 | 失效机制 | 兜底 TTL |
|---|---|---|---|---|---|
| Contest | contest:info:{id} | 可变 | 预热 / cache-miss 回源 | updateContest()、ContestEventConsumer 状态变更后主动 DELETE | 比赛时长+30min |
| ContestQuestion | contest:questions:{id} | 不可变 | 预热 | 赛后清理(赛中无需失效) | 比赛时长+30min |
| Question | question:info:{id} | 可变 | cache-miss 回源 | updateQuestion() 后主动 DELETE | 10min |
| User | user:info:{id} | 可变 | cache-miss 回源 | 修改个人信息后主动 DELETE | 5min |
| 封禁名单 | contest:banned:{id} | 可变 | 封禁操作时 SADD | 赛后清理 | 比赛时长+30min |
六、修改文件清单
| 文件 | 修改内容 |
|---|---|
| 封禁 & 作弊逻辑修复 | |
ContestRankServiceImpl.java | 拆分 getBannedUserIds;修改 recalculateRanks/recalculateRanksInDB/finalizeContestRanking;VO 构建时注入 cheated 标记;新增 rebuildUserRanking |
ContestRankService.java | 新增 rebuildUserRanking 接口声明 |
ContestRankVO.java | 新增 cheated 字段 |
ContestServiceImpl.java | registerContest 增加封禁检查;提交时合并重复 registration 查询 |
AdminContestController.java | 封禁时写入 Redis Set + 清理 detail Hash;取消作弊时调用 rebuildUserRanking |
ContestRanking.java(可选) | 新增 cheated 列,或查询时 join registration 表 |
contest_ranking 表(可选) | 新增 cheated 列 |
ContestDetailView.vue | 排行榜 nickname 前加作弊标记 |
types/index.ts | ContestRankVO 增加 cheated 字段 |
| 高频 DB 优化 & 缓存 | |
RedisKeyPrefixConstant.java | 新增 contestBannedKey、contestInfoKey、contestQuestionsKey、questionInfoKey、userInfoKey |
StatsConsumer.java | 封禁校验改从 Redis 读取;Contest 信息改从 Redis 读取 |
StatsMessage.java | 新增 submitTime 字段(ACM 增量更新需要) |
ContestRankServiceImpl.java | updateACMRanking 改为增量更新(消除 DB 查全部提交);updateOIRanking 题目满分从缓存读取;排行榜查询中的 Contest/题目列表改用缓存 |
ContestServiceImpl.java | 新增 getContestCached;warmUpContestRanking 中预热 Contest 和题目列表缓存;updateContest 中主动失效缓存 |
QuestionServiceImpl.java | 新增 getQuestionCached / listQuestionsCached;updateQuestion 中主动失效缓存 |
QuestionSubmitServiceImpl.java | 比赛提交时合并 registration 查询;getById 改用缓存 |
UserServiceImpl.java(可选) | getOtherUserVOMap 增加 Redis 缓存层 |
| 定时任务重构(TaskScheduler + Redis 分布式锁) | |
ContestStatusScheduler.java | 删除(废弃 @Scheduled(fixedRate=30_000) 轮询) |
ContestSchedulerService.java(新建) | 持有 startFutures/endFutures Map;scheduleContest 调度/取消任务;startContest/endContest 加 Redis 分布式锁执行状态变更 |
ContestRecoveryInitializer.java(新建) | @PostConstruct 启动时重新调度所有未来事件,并立即补偿宕机期间错过的事件 |
ContestServiceImpl.java | createContest / updateContest 末尾调用 contestSchedulerService.scheduleContest(contest) |
StatsSyncScheduler.java | 第 5 步 N+1 查询改为 selectBatchIds 批量查询 |
init.sql / migration | contest 表新增 idx_status_startTime、idx_status_endTime 索引 |
七、建议优先级
- P1(紧急):修复封禁用户可重新报名的漏洞
- P2(紧急):MQ 消费时从 Redis 校验封禁状态,防止 ZADD 回排行榜
- S1(紧急):废弃
ContestStatusScheduler30 秒轮询,改为TaskScheduler精确调度 + Redis 分布式锁,消除全表扫描 - S2(紧急):
contest表补充(status, startTime)和(status, endTime)索引 - P3(重要):拆分封禁/作弊逻辑,作弊用户保留排行但加标记
- P4(重要):前后端增加 cheated 标记字段和展示
- D1(重要):ACM 排名更新改增量计算,消除全量查提交记录
- D2(重要):Contest 对象 Redis 缓存 + 写时主动失效
- P5(一般):取消作弊后恢复排行的 Redis 路径
- S3(一般):
StatsSyncSchedulerN+1 查询改为selectBatchIds批量查询 - D3(中等):提交时合并重复 registration 查询
- D4(中等):Question 题目信息 Redis 缓存 + 写时主动失效
- D5-D8(中等):题目列表缓存(不可变,预热即可)、用户信息缓存
- P6(轻微):封禁时清理 detail Hash
- P7(轻微):多条 registration 记录的 selectOne 风险
评论
无需登录,审核通过后公开。只有博主可以回复。
正在加载…
已公开的评论