← 全部文章

封禁用户 & 标记作弊&排行榜 系统排查报告

后端全链路 + 前端排行榜展示,设计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 设为 0
  • finalizeContestRanking() — 落库时: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中等用户提交比赛题目关系 selectCount1不变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(报名状态)可变封禁/作弊操作极低(管理员手动)

核心原则:

  1. 不可变数据(ContestQuestion):预热时写入缓存,赛后清理,无需失效机制
  2. 可变但极低频数据(Contest、Question、User):采用 Cache-Aside + 写时主动失效 策略
  3. 所有缓存设置兜底 TTL:即使失效机制异常,缓存也会在 TTL 后自动过期,防止永久脏数据
  4. 写入操作统一走 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
Contestcontest:info:{id}可变预热 / cache-miss 回源updateContest()、ContestEventConsumer 状态变更后主动 DELETE比赛时长+30min
ContestQuestioncontest:questions:{id}不可变预热赛后清理(赛中无需失效)比赛时长+30min
Questionquestion:info:{id}可变cache-miss 回源updateQuestion() 后主动 DELETE10min
Useruser:info:{id}可变cache-miss 回源修改个人信息后主动 DELETE5min
封禁名单contest:banned:{id}可变封禁操作时 SADD赛后清理比赛时长+30min

六、修改文件清单

文件修改内容
封禁 & 作弊逻辑修复
ContestRankServiceImpl.java拆分 getBannedUserIds;修改 recalculateRanks/recalculateRanksInDB/finalizeContestRanking;VO 构建时注入 cheated 标记;新增 rebuildUserRanking
ContestRankService.java新增 rebuildUserRanking 接口声明
ContestRankVO.java新增 cheated 字段
ContestServiceImpl.javaregisterContest 增加封禁检查;提交时合并重复 registration 查询
AdminContestController.java封禁时写入 Redis Set + 清理 detail Hash;取消作弊时调用 rebuildUserRanking
ContestRanking.java(可选)新增 cheated 列,或查询时 join registration 表
contest_ranking 表(可选)新增 cheated 列
ContestDetailView.vue排行榜 nickname 前加作弊标记
types/index.tsContestRankVO 增加 cheated 字段
高频 DB 优化 & 缓存
RedisKeyPrefixConstant.java新增 contestBannedKey、contestInfoKey、contestQuestionsKey、questionInfoKey、userInfoKey
StatsConsumer.java封禁校验改从 Redis 读取;Contest 信息改从 Redis 读取
StatsMessage.java新增 submitTime 字段(ACM 增量更新需要)
ContestRankServiceImpl.javaupdateACMRanking 改为增量更新(消除 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.javacreateContest / updateContest 末尾调用 contestSchedulerService.scheduleContest(contest)
StatsSyncScheduler.java第 5 步 N+1 查询改为 selectBatchIds 批量查询
init.sql / migrationcontest 表新增 idx_status_startTime、idx_status_endTime 索引

七、建议优先级

  1. P1(紧急):修复封禁用户可重新报名的漏洞
  2. P2(紧急):MQ 消费时从 Redis 校验封禁状态,防止 ZADD 回排行榜
  3. S1(紧急):废弃 ContestStatusScheduler 30 秒轮询,改为 TaskScheduler 精确调度 + Redis 分布式锁,消除全表扫描
  4. S2(紧急):contest 表补充 (status, startTime) 和 (status, endTime) 索引
  5. P3(重要):拆分封禁/作弊逻辑,作弊用户保留排行但加标记
  6. P4(重要):前后端增加 cheated 标记字段和展示
  7. D1(重要):ACM 排名更新改增量计算,消除全量查提交记录
  8. D2(重要):Contest 对象 Redis 缓存 + 写时主动失效
  9. P5(一般):取消作弊后恢复排行的 Redis 路径
  10. S3(一般):StatsSyncScheduler N+1 查询改为 selectBatchIds 批量查询
  11. D3(中等):提交时合并重复 registration 查询
  12. D4(中等):Question 题目信息 Redis 缓存 + 写时主动失效
  13. D5-D8(中等):题目列表缓存(不可变,预热即可)、用户信息缓存
  14. P6(轻微):封禁时清理 detail Hash
  15. P7(轻微):多条 registration 记录的 selectOne 风险

评论