← 全部文章

竞赛排行榜 Redis ZSet 重构 + 封榜功能实施方案

竞赛排行榜 Redis ZSet 重构 + 封榜功能实施方案

更新于 2026.08.07

本文目录 41 节
竞赛排行榜 Redis ZSet 重构 + 封榜功能实施方案封面

日期:2026-03-12
状态:待批阅(v2 — 并发安全 & 性能优化)
前置文档:docs/CONTEST_RANKING_DEV_PLAN.md(Phase 3.8 节原始设计)


一、背景

原始设计方案(DEV_PLAN Phase 3.8)明确指出:比赛进行中排行榜应基于 Redis ZSet 实现,比赛结束后落库到 contest_ranking 表。但当前代码完全跳过了 Redis,直接在 MySQL 上做全量读写:每次判题完成都全表扫描 + 批量 UPDATE。

本方案将 ContestRankServiceImpl 重写为 Redis 优先,同时实现 封榜(Freeze Scoreboard) 功能。

v2 优化要点

编号优化项说明
A数据结构:String(JSON) → Hash按题目粒度 HSET,消除并发竞争
B原子性:Lua 脚本“更新 Detail + 更新 ZSet Score” 在 Redis 层面原子执行
C分页准确性:封禁即 ZREM避免分页后过滤导致条数不足
DFinalize 事务安全DB 先提交,Redis 清理后置(容错)
EScore 范围校验ACM penalty 上限防溢出
F缓存预热大型比赛首次提交前初始化 ZSet

二、Redis 数据结构设计

2.1 排名 ZSet

Key类型Score 编码说明
contest:rank:{contestId}ZSetACM: solvedCount * 1_000_000_000 - totalPenalty分值越高排名越前
OI: totalScore
  • member: userId(字符串)
  • ACM score 编码:solvedCount * 10^9 - totalPenalty。ZSet 按 score 降序排列时,solved 越多越靠前;solved 相同时 penalty 越小(减的越少)score 越大越靠前。
  • penalty 上限保护:计算 score 后做范围校验 Math.max(0, Math.min(score, Long.MAX_VALUE)),并将 totalPenalty 钳位到 [0, 999_999_999],避免脏数据导致编码溢出。
  • OI score 编码:直接使用 totalScore。

2.2 用户题目明细 Hash

Key类型FieldValue说明
contest:detail:{contestId}:{userId}HashquestionId(字符串)单道题的 ContestProblemDetail JSON按题粒度读写

为什么用 Hash 而不是 String(JSON)?

  • 更新某道题时只需 HSET key questionId detailJSON,无需读取-反序列化-修改-序列化-写回整个数组。
  • 消除了并发场景下两个 MQ 消费线程同时更新同一用户不同题目时的 Race Condition。
  • 查询时 HGETALL 或 HMGET 一次取回所有题目明细,IO 开销与 String 方案一致。

2.3 Key 生命周期

时机操作
比赛开始 / 缓存预热将所有已报名用户以 score=0 加入 ZSet,Hash key 按需创建
首次提交(惰性)若预热未执行,ZSet 和 Hash key 在首次更新时自然创建
比赛进行中每次判题完成后通过 Lua 脚本原子更新 Hash field + ZSet score
比赛结束finalizeContestRanking(): DB 落库提交 → 删除 Redis key
异常恢复若 rankFinalized=false 且 Redis key 不存在,从 question_submit 表重建

三、核心流程

3.1 MQ 消费 → 排行更新(封榜拦截点)

StatsConsumer 收到 TYPE_CONTEST_JUDGE 消息
  │
  ├─ 查询 Contest 对象
  │
  ├─ 【封榜判断】now >= freezeTime && status == RUNNING ?
  │     ├─ YES → 跳过排行更新,直接 ACK(提交记录已落库,不丢数据)
  │     └─ NO  → 继续
  │
  ├─ ACM: contestRankService.updateACMRanking()
  └─ OI:  contestRankService.updateOIRanking()

这是整个封榜的核心:封榜后,MQ 消费端直接跳过排行更新。Redis 中的数据保持封榜那一刻的状态。前端看到的排行榜就是封榜前最后一刻的样子,不需要 frozen 标志、不需要 ? 显示。

3.2 ACM 排行更新(Redis 版 — Lua 原子操作)

updateACMRanking(contestId, userId, questionId, accepted):
  1. 从 question_submit 表查该用户该题的全部提交(按时间序)
  2. calculateACMProblemDetail() → 得到 solved/attempts/penalty/firstSolveTime
     (不再需要 frozen 字段,因为封榜后根本不会进入这个方法)
  3. 将该题 detail 序列化为 JSON
  4. 执行 Lua 脚本(原子操作):
     a. HSET contest:detail:{contestId}:{userId} {questionId} {detailJSON}
     b. HGETALL → 遍历所有 field 反序列化 → 汇总 solvedCount / totalPenalty
     c. 范围校验:totalPenalty = min(totalPenalty, 999_999_999)
     d. score = solvedCount * 1_000_000_000 - totalPenalty
     e. ZADD contest:rank:{contestId} {score} {userId}
     f. 返回更新后的 score

原子性保证:步骤 a-e 封装在单个 Lua 脚本中执行,在 Redis 层面不可分割。 即使短时间内同一用户多次提交不同题目的 MQ 消息被并发消费, 每次执行仅修改自己的 Hash field,汇总和 ZADD 在脚本内完成,不存在覆盖风险。

3.3 OI 排行更新(Redis 版 — Lua 原子操作)

updateOIRanking(contestId, userId, questionId, passedCases, totalCases):
  1. 计算 score = round(passedCases / totalCases * fullScore)
  2. 将该题 detail 序列化为 JSON
  3. 执行 Lua 脚本(原子操作):
     a. HGET contest:detail:{contestId}:{userId} {questionId} → 旧记录
     b. 取最高分:若旧分 >= 新分 → 直接返回(不更新)
     c. HSET 写入新 detail
     d. HGETALL → 遍历所有 field → 汇总 totalScore = Σ各题得分
     e. ZADD contest:rank:{contestId} {totalScore} {userId}
     f. 返回更新后的 totalScore

3.4 查询排行榜

getContestRanking(contestId, page, pageSize):
  Contest contest = getById(contestId)

  【OI 可见性】OI + 未结束 + 非管理员 → 返回空

  【数据源选择】
  if contest.rankFinalized == true:
      → 查 MySQL contest_ranking 表(已有逻辑不变)
  else:
      → 查 Redis:
        1. ZREVRANGE contest:rank:{contestId} start end  → 得到有序 userId 列表
           (ZSet 中已不包含被封禁用户,分页数据即为可见数据)
        2. HGETALL contest:detail:{contestId}:{userId}   → 每人各题明细
        3. 批量查用户信息(nickname/avatar)
        4. 组装 ContestRankVO 返回

  【分页】start = (page-1)*pageSize, end = start+pageSize-1
  【total】ZCARD contest:rank:{contestId}

分页准确性保证:被封禁/作弊用户在标记时即通过 ZREM 从 ZSet 中移除, 因此 ZSet 中的数据就是可见数据,ZREVRANGE 分页结果条数始终准确, 不会出现”过滤后不足 pageSize”的断层问题。

3.5 比赛结束 → 固化 + 揭榜

finalizeContestRanking(contestId):
  ── DB 事务 ──────────────────────────
  1. 从 question_submit 表全量重算每个用户的每道题结果
     (包含封榜后的提交,因为此时封榜已解除)
  2. 按排序规则计算最终排名
  3. 批量 UPSERT 到 contest_ranking 表
  4. 设置 contest.rankFinalized = true
  5. DB 事务提交
  ── 事务提交后 ─────────────────────────
  6. 删除 Redis key: contest:rank:{contestId} + contest:detail:{contestId}:*
  7. 日志记录

事务安全:步骤 1-5 在 @Transactional 内完成。Redis 清理放在事务提交之后。 如果 Redis 删除失败(网络抖动等),rankFinalized=true 已生效, 后续查询自动走 DB,不影响数据一致性。残留 Redis key 可通过 TTL 或运维清理。

为什么要从 DB 重算而不是从 Redis 读? 因为封榜后的提交没有更新 Redis,Redis 里的数据是封榜那一刻的快照。最终排名必须包含所有提交。

3.6 触发时机

在 ContestServiceImpl.updateContestStatuses() 中:

未开始 → 进行中 的比赛:
  1. 更新 status = RUNNING
  2. 调用 warmUpContestRanking(contestId)  // 缓存预热

进行中 → 已结束 的比赛:
  1. 更新 status = ENDED
  2. 调用 finalizeContestRanking(contestId) // 落库 + 揭榜

缓存预热的好处:

  • 避免比赛初期提交时才逐个创建 ZSet member 导致的排序开销
  • 排行榜在比赛一开始就能展示所有参赛者(score=0)
  • 幂等设计:若 ZSet 已存在数据(如调度器重复触发),直接跳过

四、封榜功能设计

4.1 封榜效果

项目封榜前封榜后
排行榜实时更新冻结在封榜那一刻的状态
排行单元格正常显示 AC/WA维持封榜前的样子(不显示 ?)
提交记录(非管理员)可看所有人只能看自己的
提交记录(管理员)可看所有人可看所有人
提交代码正常提交、正常判题正常提交、正常判题(只是不更新排行)

4.2 封榜拦截(StatsConsumer 改动)

// StatsConsumer.handleStatsMessage() 中,在调用排行更新前增加:
if (StatsMessage.TYPE_CONTEST_JUDGE.equals(message.getType())
        && message.getContestId() != null) {
    Contest contest = contestMapper.selectById(message.getContestId());
    if (contest == null) { channel.basicAck(...); return; }

    // 封榜判断:设置了 freezeTime + 当前 >= freezeTime + 比赛仍在进行
    if (contest.getFreezeTime() != null
            && contest.getStatus() == ContestStatusEnum.RUNNING
            && !LocalDateTime.now().isBefore(contest.getFreezeTime())) {
        log.info("比赛 {} 已封榜,跳过排行更新 (userId={}, questionId={})",
                message.getContestId(), message.getUserId(), message.getQuestionId());
        channel.basicAck(deliveryTag, false);
        return;
    }

    // 正常更新排行 ...
}

4.3 提交记录可见性控制(ContestServiceImpl 改动)

getContestSubmissions() 方法增加封榜过滤:

Contest contest = getById(contestId);
boolean isFrozen = contest.getFreezeTime() != null
    && contest.getStatus() == ContestStatusEnum.RUNNING
    && !LocalDateTime.now().isBefore(contest.getFreezeTime());
boolean isAdmin = UserRoleEnum.ADMIN.getRole().equals(BaseContext.getCurrentRole())
    || UserRoleEnum.SUPER_ADMIN.getRole().equals(BaseContext.getCurrentRole());

LambdaQueryWrapper<QuestionSubmit> wrapper = new LambdaQueryWrapper<QuestionSubmit>()
    .eq(QuestionSubmit::getContestId, contestId)
    .orderByDesc(QuestionSubmit::getCreateTime);

// 封榜期间,非管理员只能看自己的提交
if (isFrozen && !isAdmin) {
    wrapper.eq(QuestionSubmit::getUserId, userId);
}

4.4 揭榜

揭榜 = 比赛结束后的 finalizeContestRanking(),它从 DB 全量重算包含封榜后提交的最终排名,然后写入 contest_ranking 表。此后查询走 MySQL,显示的就是完整的最终结果。


五、数据库变更

5.1 contest 表新增 rankFinalized 字段

ALTER TABLE contest ADD COLUMN rankFinalized TINYINT NOT NULL DEFAULT 0
    COMMENT '排行是否已落库:0-未落库 1-已落库';

5.2 Contest 实体新增字段

// Contest.java
private Boolean rankFinalized;

六、Redis Key 常量更新

// RedisKeyPrefixConstant.java 新增
public static final String CONTEST_DETAIL_PREFIX = "contest:detail:";

/** Hash key: contest:detail:{contestId}:{userId},field 为 questionId */
public static String contestDetailKey(Long contestId, Long userId) {
    return CONTEST_DETAIL_PREFIX + contestId + ":" + userId;
}

(CONTEST_RANK_PREFIX 和 contestRankKey() 已存在,不需要改。)


七、接口变更

7.1 ContestRankService 接口修改

public interface ContestRankService {
    /** 获取比赛排行榜(分页) */
    Page<ContestRankVO> getContestRanking(Long contestId, PageQueryDTO dto);

    /** 判题完成后更新用户排行(ACM) */
    void updateACMRanking(Long contestId, Long userId, Long questionId, boolean accepted);

    /** 判题完成后更新用户排行(OI) */
    void updateOIRanking(Long contestId, Long userId, Long questionId, int passedCases, int totalCases);

    /** 重新计算整场比赛的排名顺序(仅用于风控操作后触发) */
    void recalculateRanks(Long contestId);

    /** 比赛结束:从 DB 全量重算最终排行,落库 contest_ranking,清理 Redis */
    void finalizeContestRanking(Long contestId);

    /** 缓存预热:比赛开始时将所有报名用户以 score=0 加入 ZSet */
    void warmUpContestRanking(Long contestId);
}

7.2 AdminContestController 新增手动固化接口

/** 手动触发排行落库(应急用) */
@PostMapping("/finalize-ranking")
public Result<Void> finalizeRanking(@RequestParam Long contestId) {
    contestRankService.finalizeContestRanking(contestId);
    return Result.success();
}

八、ContestRankServiceImpl 完整重写

下面是重写后的核心逻辑伪代码,按方法列出:

8.1 依赖注入

@Service
@Slf4j
@RequiredArgsConstructor(onConstructor_ = {@Lazy})
public class ContestRankServiceImpl implements ContestRankService {

    @Lazy private final ContestService contestService;
    private final ContestRankingMapper contestRankingMapper;
    private final ContestRegistrationMapper contestRegistrationMapper;
    private final ContestQuestionMapper contestQuestionMapper;
    private final QuestionSubmitService questionSubmitService;
    private final UserService userService;
    private final RedisTemplate<String, Object> redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;       // 用于 Lua 脚本执行
    private final ObjectMapper objectMapper;

    /** ACM penalty 上限:约 31 年,防止脏数据溢出 */
    private static final long MAX_PENALTY = 999_999_999L;
    private static final long ACM_SCORE_UNIT = 1_000_000_000L;

8.2 getContestRanking() — 查询排行

@Override
public Page<ContestRankVO> getContestRanking(Long contestId, PageQueryDTO dto) {
    Contest contest = contestService.getById(contestId);
    if (contest == null) return emptyPage(dto);

    // OI 赛制比赛未结束时,仅管理员可看
    if (contest.getType() == ContestTypeEnum.OI
            && contest.getStatus() != ContestStatusEnum.ENDED) {
        if (!isAdmin()) return emptyPage(dto);
    }

    // 数据源选择
    if (Boolean.TRUE.equals(contest.getRankFinalized())) {
        return getContestRankingFromDB(contestId, dto);   // 已落库,走 MySQL
    } else {
        return getContestRankingFromRedis(contestId, dto); // 进行中,走 Redis
    }
}

8.3 getContestRankingFromRedis() — 从 Redis 读取排行

private Page<ContestRankVO> getContestRankingFromRedis(Long contestId, PageQueryDTO dto) {
    String rankKey = RedisKeyPrefixConstant.contestRankKey(contestId);
    int start = (dto.getPage() - 1) * dto.getPageSize();
    int end = start + dto.getPageSize() - 1;

    // 1. 从 ZSet 获取排名列表(score 降序)
    //    ZSet 中已不含被封禁用户(封禁时已 ZREM),分页数据即为可见数据
    Set<ZSetOperations.TypedTuple<Object>> tuples =
        redisTemplate.opsForZSet().reverseRangeWithScores(rankKey, start, end);
    Long total = redisTemplate.opsForZSet().zCard(rankKey);

    if (tuples == null || tuples.isEmpty()) return emptyPage(dto);

    // 2. 批量读取用户详细信息和题目明细
    Set<Long> userIds = new LinkedHashSet<>();
    for (var tuple : tuples) {
        Long userId = Long.parseLong(tuple.getValue().toString());
        userIds.add(userId);
    }
    Map<Long, OtherUserVO> userVOMap = userService.getOtherUserVOMap(userIds);

    // 查出比赛题目顺序
    List<ContestQuestion> contestQuestions = getContestQuestions(contestId);
    Contest contest = contestService.getById(contestId);

    List<ContestRankVO> voList = new ArrayList<>();
    int rank = start + 1;
    for (var tuple : tuples) {
        Long userId = Long.parseLong(tuple.getValue().toString());

        // 从 Hash 读取该用户各题明细
        List<ContestProblemDetail> details = getDetailFromRedis(contestId, userId);
        List<ContestProblemDetail> aligned = alignProblemDetails(details, contestQuestions);

        OtherUserVO user = userVOMap.get(userId);
        Double score = tuple.getScore();

        // 从 score 反解 solvedCount / totalPenalty
        int solvedCount = 0, totalPenalty = 0, totalScore = 0;
        if (contest.getType() == ContestTypeEnum.ACM && score != null) {
            solvedCount = (int) (score.longValue() / ACM_SCORE_UNIT);
            totalPenalty = (int) (solvedCount * ACM_SCORE_UNIT - score.longValue());
        } else if (score != null) {
            totalScore = score.intValue();
        }

        voList.add(ContestRankVO.builder()
                .rank(rank++)
                .userId(userId)
                .nickname(user != null ? user.getNickname() : "未知用户")
                .avatar(user != null ? user.getAvatar() : null)
                .solvedCount(solvedCount)
                .totalPenalty(totalPenalty)
                .totalScore(totalScore)
                .problemResults(aligned)
                .build());
    }

    Page<ContestRankVO> result = new Page<>(dto.getPage(), dto.getPageSize(),
            total != null ? total : 0);
    result.setRecords(voList);
    return result;
}

与 v1 差异:不再做 bannedUserIds 后置过滤。被封禁用户在标记时已从 ZSet 移除, 因此这里直接返回 ZSet 分页结果,条数始终等于 pageSize(除最后一页外)。

8.4 Lua 脚本定义

为确保 “更新 Hash field + 汇总 + ZADD” 在 Redis 层面原子执行,定义两个 Lua 脚本:

ACM 更新脚本

/**
 * KEYS[1] = detailKey (contest:detail:{contestId}:{userId})
 * KEYS[2] = rankKey   (contest:rank:{contestId})
 * ARGV[1] = questionId (string)
 * ARGV[2] = detailJSON (该题的 ContestProblemDetail JSON)
 * ARGV[3] = userId (string, ZSet member)
 * ARGV[4] = ACM_SCORE_UNIT (1000000000)
 * ARGV[5] = MAX_PENALTY (999999999)
 *
 * 返回: 更新后的 score
 */
private static final String ACM_UPDATE_LUA = """
    -- 1. 写入该题明细
    redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
    -- 2. 读取所有题目明细,汇总 solvedCount 和 totalPenalty
    local all = redis.call('HGETALL', KEYS[1])
    local solved = 0
    local penalty = 0
    for i = 1, #all, 2 do
        local detail = cjson.decode(all[i+1])
        if detail.solved == true then
            solved = solved + 1
            penalty = penalty + (detail.penalty or 0)
        end
    end
    -- 3. 范围校验
    local maxPenalty = tonumber(ARGV[5])
    if penalty > maxPenalty then penalty = maxPenalty end
    if penalty < 0 then penalty = 0 end
    -- 4. 计算 score 并 ZADD
    local unit = tonumber(ARGV[4])
    local score = solved * unit - penalty
    redis.call('ZADD', KEYS[2], score, ARGV[3])
    return tostring(score)
    """;

private static final RedisScript<String> ACM_UPDATE_SCRIPT =
    new DefaultRedisScript<>(ACM_UPDATE_LUA, String.class);

OI 更新脚本

/**
 * KEYS[1] = detailKey
 * KEYS[2] = rankKey
 * ARGV[1] = questionId (string)
 * ARGV[2] = newDetailJSON
 * ARGV[3] = userId (string)
 * ARGV[4] = newScore (int, 本次提交该题得分)
 *
 * 返回: "SKIP" 表示旧分更高未更新,否则返回更新后的 totalScore
 */
private static final String OI_UPDATE_LUA = """
    -- 1. 取最高分判断
    local old = redis.call('HGET', KEYS[1], ARGV[1])
    if old then
        local oldDetail = cjson.decode(old)
        if oldDetail.score and oldDetail.score >= tonumber(ARGV[4]) then
            return "SKIP"
        end
    end
    -- 2. 写入新 detail
    redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
    -- 3. 汇总 totalScore
    local all = redis.call('HGETALL', KEYS[1])
    local total = 0
    for i = 1, #all, 2 do
        local detail = cjson.decode(all[i+1])
        total = total + (detail.score or 0)
    end
    -- 4. ZADD
    redis.call('ZADD', KEYS[2], total, ARGV[3])
    return tostring(total)
    """;

private static final RedisScript<String> OI_UPDATE_SCRIPT =
    new DefaultRedisScript<>(OI_UPDATE_LUA, String.class);

8.5 updateACMRanking() — Lua 原子版

@Override
public void updateACMRanking(Long contestId, Long userId, Long questionId, boolean accepted) {
    Contest contest = contestService.getById(contestId);
    if (contest == null) return;

    // 从 DB 查该用户该题全部提交
    List<QuestionSubmit> submits = questionSubmitService.lambdaQuery()
            .eq(QuestionSubmit::getContestId, contestId)
            .eq(QuestionSubmit::getUserId, userId)
            .eq(QuestionSubmit::getQuestionId, questionId)
            .orderByAsc(QuestionSubmit::getCreateTime)
            .list();

    // 计算该题 ACM 结果
    ContestProblemDetail detail = calculateACMProblemDetail(submits, contest.getStartTime());
    detail.setQuestionId(questionId);

    // penalty 范围校验
    if (detail.getPenalty() != null) {
        detail.setPenalty((int) Math.min(Math.max(detail.getPenalty(), 0), MAX_PENALTY));
    }

    String detailJSON = objectMapper.writeValueAsString(detail);
    String detailKey = RedisKeyPrefixConstant.contestDetailKey(contestId, userId);
    String rankKey = RedisKeyPrefixConstant.contestRankKey(contestId);

    // 执行 Lua 脚本:原子完成 HSET + 汇总 + ZADD
    stringRedisTemplate.execute(ACM_UPDATE_SCRIPT,
            List.of(detailKey, rankKey),
            questionId.toString(), detailJSON, userId.toString(),
            String.valueOf(ACM_SCORE_UNIT), String.valueOf(MAX_PENALTY));
}

8.6 updateOIRanking() — Lua 原子版

@Override
public void updateOIRanking(Long contestId, Long userId, Long questionId,
                             int passedCases, int totalCases) {
    ContestQuestion cq = contestQuestionMapper.selectOne(
            new LambdaQueryWrapper<ContestQuestion>()
                    .eq(ContestQuestion::getContestId, contestId)
                    .eq(ContestQuestion::getQuestionId, questionId));
    if (cq == null) return;
    int fullScore = cq.getScore();
    int score = totalCases > 0
            ? (int) Math.round((double) passedCases / totalCases * fullScore)
            : 0;

    ContestProblemDetail detail = ContestProblemDetail.builder()
            .questionId(questionId).score(score).totalScore(fullScore).build();
    String detailJSON = objectMapper.writeValueAsString(detail);
    String detailKey = RedisKeyPrefixConstant.contestDetailKey(contestId, userId);
    String rankKey = RedisKeyPrefixConstant.contestRankKey(contestId);

    // 执行 Lua 脚本:原子完成"取最高分 + HSET + 汇总 + ZADD"
    String result = stringRedisTemplate.execute(OI_UPDATE_SCRIPT,
            List.of(detailKey, rankKey),
            questionId.toString(), detailJSON, userId.toString(),
            String.valueOf(score));

    if ("SKIP".equals(result)) {
        log.debug("OI: 用户 {} 题目 {} 已有更高分,跳过", userId, questionId);
    }
}

8.7 recalculateRanks() — 封禁即 ZREM

管理员封禁/标记作弊后调用。立即从 ZSet 中移除被封禁用户,保证分页查询数据的准确性。

@Override
public void recalculateRanks(Long contestId) {
    Contest contest = contestService.getById(contestId);
    if (contest == null) return;

    if (Boolean.TRUE.equals(contest.getRankFinalized())) {
        // 已落库:走 MySQL 逻辑(保持原来的实现)
        recalculateRanksInDB(contestId);
    } else {
        // 进行中:从 ZSet 中移除被封禁用户
        Set<Long> bannedUserIds = getBannedUserIds(contestId);
        String rankKey = RedisKeyPrefixConstant.contestRankKey(contestId);
        for (Long bannedUserId : bannedUserIds) {
            redisTemplate.opsForZSet().remove(rankKey, bannedUserId.toString());
            // Hash detail 保留(finalize 时仍需全量重算)
        }
        log.info("比赛 {} 已从 ZSet 移除 {} 个封禁用户", contestId, bannedUserIds.size());
    }
}

注意:只移除 ZSet member,不删除 Hash detail key。 因为 finalizeContestRanking() 落库时需要从 DB 全量重算,不依赖 Redis detail。 若后续管理员解禁用户,可通过重新触发一次该用户的 MQ 消息或手动重算来恢复。

8.8 finalizeContestRanking() — 核心:DB 先提交,Redis 后清理

@Override
@Transactional
public void finalizeContestRanking(Long contestId) {
    Contest contest = contestService.getById(contestId);
    if (contest == null) return;
    if (Boolean.TRUE.equals(contest.getRankFinalized())) {
        log.info("比赛 {} 排行已落库,跳过", contestId);
        return;
    }

    // 1. 查出比赛所有题目
    List<ContestQuestion> questions = getContestQuestions(contestId);

    // 2. 查出所有参赛用户(status=0 为正常)
    List<ContestRegistration> allRegs = contestRegistrationMapper.selectList(
            new LambdaQueryWrapper<ContestRegistration>()
                    .eq(ContestRegistration::getContestId, contestId));
    Set<Long> bannedUserIds = allRegs.stream()
            .filter(r -> r.getStatus() != 0).map(ContestRegistration::getUserId).collect(Collectors.toSet());
    Set<Long> allUserIds = allRegs.stream().map(ContestRegistration::getUserId).collect(Collectors.toSet());

    // 3. 对每个用户,从 question_submit 表全量重算
    List<ContestRanking> rankings = new ArrayList<>();
    for (Long userId : allUserIds) {
        ContestRanking ranking = buildRankingFromSubmits(contestId, userId, contest, questions);
        if (bannedUserIds.contains(userId)) {
            ranking.setRank(0);
        }
        rankings.add(ranking);
    }

    // 4. 排序正常用户
    List<ContestRanking> normal = rankings.stream()
            .filter(r -> r.getRank() == null || r.getRank() != 0).collect(Collectors.toList());
    if (contest.getType() == ContestTypeEnum.ACM) {
        normal.sort(Comparator.comparing(ContestRanking::getSolvedCount).reversed()
                .thenComparing(ContestRanking::getTotalPenalty));
    } else {
        normal.sort(Comparator.comparing(ContestRanking::getTotalScore).reversed());
    }
    for (int i = 0; i < normal.size(); i++) {
        normal.get(i).setRank(i + 1);
    }

    // 5. UPSERT 到 contest_ranking 表
    for (ContestRanking r : rankings) {
        ContestRanking existing = contestRankingMapper.selectOne(
                new LambdaQueryWrapper<ContestRanking>()
                        .eq(ContestRanking::getContestId, contestId)
                        .eq(ContestRanking::getUserId, r.getUserId()));
        if (existing != null) {
            r.setId(existing.getId());
            contestRankingMapper.updateById(r);
        } else {
            contestRankingMapper.insert(r);
        }
    }

    // 6. 标记 rankFinalized(仍在事务内)
    contest.setRankFinalized(true);
    contestService.updateById(contest);

    // ═══ 事务提交后,清理 Redis ═══
    // 注:@Transactional 提交后再执行清理。
    // 使用 TransactionSynchronizationManager 注册 afterCommit 回调:
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                try {
                    String rankKey = RedisKeyPrefixConstant.contestRankKey(contestId);
                    redisTemplate.delete(rankKey);
                    for (Long userId : allUserIds) {
                        redisTemplate.delete(
                            RedisKeyPrefixConstant.contestDetailKey(contestId, userId));
                    }
                    log.info("比赛 {} Redis key 清理完成", contestId);
                } catch (Exception e) {
                    // Redis 清理失败不影响数据一致性(rankFinalized=true 已生效)
                    log.warn("比赛 {} Redis key 清理失败,将依赖 TTL 自动过期: {}",
                            contestId, e.getMessage());
                }
            }
        });

    log.info("比赛 {} 排行落库完成,共 {} 条记录", contestId, rankings.size());
}

幂等性:开头检查 rankFinalized==true 即返回,多次调用无副作用。

事务安全:DB 操作(步骤 1-6)在 @Transactional 内。Redis 清理通过 TransactionSynchronization.afterCommit() 确保在 DB 事务成功提交后才执行。 即使 Redis 删除失败,rankFinalized=true 已持久化,查询自动走 DB。

8.9 Redis 读写辅助方法(Hash 版)

/** 从 Hash 读取该用户所有题目明细 */
private List<ContestProblemDetail> getDetailFromRedis(Long contestId, Long userId) {
    String key = RedisKeyPrefixConstant.contestDetailKey(contestId, userId);
    Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
    if (entries.isEmpty()) return new ArrayList<>();

    List<ContestProblemDetail> details = new ArrayList<>(entries.size());
    for (Object value : entries.values()) {
        details.add(objectMapper.readValue(value.toString(),
                ContestProblemDetail.class));
    }
    return details;
}

/** 向 Hash 写入单道题的明细(非原子场景用,如初始化) */
private void saveDetailFieldToRedis(Long contestId, Long userId,
                                     Long questionId, ContestProblemDetail detail) {
    String key = RedisKeyPrefixConstant.contestDetailKey(contestId, userId);
    redisTemplate.opsForHash().put(key, questionId.toString(),
            objectMapper.writeValueAsString(detail));
}

8.10 缓存预热

/**
 * 比赛开始时(或接近开始时)调用,将所有已报名用户以 score=0 加入 ZSet。
 * 好处:避免比赛初期提交时才逐个创建 ZSet member 导致的排序开销;
 *       排行榜一开始就能展示所有参赛者。
 * 触发点:ContestServiceImpl.updateContestStatuses() 中 NOT_STARTED → RUNNING。
 */
public void warmUpContestRanking(Long contestId) {
    String rankKey = RedisKeyPrefixConstant.contestRankKey(contestId);
    // 若已存在数据(如重启后重复触发),跳过
    Long size = redisTemplate.opsForZSet().zCard(rankKey);
    if (size != null && size > 0) return;

    List<ContestRegistration> regs = contestRegistrationMapper.selectList(
            new LambdaQueryWrapper<ContestRegistration>()
                    .eq(ContestRegistration::getContestId, contestId)
                    .eq(ContestRegistration::getStatus, 0));  // 正常用户

    for (ContestRegistration reg : regs) {
        redisTemplate.opsForZSet().add(rankKey, reg.getUserId().toString(), 0);
    }
    log.info("比赛 {} 缓存预热完成,初始化 {} 个用户", contestId, regs.size());
}

九、ContestProblemDetail 变更

frozen 字段不再使用。封榜后 MQ 消费端直接跳过更新,不需要在数据层面标记冻结状态。

calculateACMProblemDetail() 简化——移除 freezeTime 参数:

private ContestProblemDetail calculateACMProblemDetail(
        List<QuestionSubmit> submits, LocalDateTime contestStart) {
    // 与原来相同,去掉 freezeTime 和 frozen 相关逻辑
}

前端 ContestProblemResult.frozen 字段和 result--frozen / ? 显示逻辑可以保留但不再使用(后端不再返回 frozen=true)。


十、修改清单

#类型文件改动
数据库
1DDLsql/init.sqlcontest 表新增 rankFinalized TINYINT DEFAULT 0
实体
2改model/entity/Contest.java新增 private Boolean rankFinalized;
常量
3改RedisKeyPrefixConstant.java新增 CONTEST_DETAIL_PREFIX + contestDetailKey()
服务接口
4改ContestRankService.java新增 finalizeContestRanking(), warmUpContestRanking()
核心重写
5重写ContestRankServiceImpl.javaRedis Hash + Lua 脚本实现(含预热)
MQ 消费者
6改StatsConsumer.java加封榜拦截判断
比赛状态调度
7改ContestServiceImpl.updateContestStatuses()NOT_STARTED→RUNNING 时触发 warmUpContestRanking();RUNNING→ENDED 时触发 finalizeContestRanking()
8改ContestServiceImpl注入 ContestRankService(@Lazy)
提交记录
9改ContestServiceImpl.getContestSubmissions()封榜期间非管理员只查自己
管理接口
10改AdminContestController.java新增 POST /admin/contest/finalize-ranking
前端(可选)
11改ContestDetailView.vue移除 frozen/? 显示逻辑(或保留但后端不再返回)

十一、不涉及的改动

  • 前端类型定义:ContestRankVO / ContestProblemResult 结构不变
  • MQ 消息格式:StatsMessage 不变
  • 判题服务:不变
  • contest_ranking 表结构:不变(只新增 contest 表的字段)
  • 导出 Excel:导出走 DB,需要比赛结束后(rankFinalized=true)才可用,逻辑不变

十二、异常恢复

场景恢复方案
Redis 宕机,比赛进行中重启后 Redis key 丢失。此时 rankFinalized=false 且 Redis 为空。排行榜暂时返回空。管理员可调用 POST /admin/contest/finalize-ranking 强制从 DB 全量重算并落库
落库(finalize)DB 事务失败rankFinalized 保持 false。updateContestStatuses() 每 30 秒执行一次,检测到已结束但未落库的比赛会重新触发 finalize
落库成功但 Redis 清理失败rankFinalized=true 已生效,查询自动走 DB,数据一致。残留 Redis key 不影响业务,可通过 TTL 自动过期或运维手动清理
封榜拦截与 MQ 消费时序MQ 消费是顺序的(同一 queue 单消费者)。封榜判断使用 LocalDateTime.now(),不依赖消息时间戳,只要比赛 freezeTime 到了就立刻停止更新
Lua 脚本执行失败单次提交的排行更新丢失,但提交记录已在 DB。下一次该用户该题的提交会重新从 DB 查全量提交并覆盖计算,自动修复
并发更新同一用户不同题Hash HSET 仅修改各自 field,Lua 脚本内汇总,不存在覆盖风险
并发更新同一用户同一题Lua 脚本原子执行,后发请求的汇总基于最新 Hash 状态,结果正确

十三、测试要点

  1. 创建 ACM 比赛(with freezeTime),提交 → Redis ZSet 和 Hash detail 正常更新
  2. 到达 freezeTime,继续提交 → Redis 不再更新,排行保持封榜前状态
  3. 封榜后非管理员查看提交记录 → 仅看到自己的
  4. 比赛结束 → finalizeContestRanking 自动触发,contest_ranking 表有数据,rankFinalized=true
  5. 比赛结束后查排行 → 走 MySQL,包含封榜后的提交结果
  6. Redis key 已被清理(ZSet + 所有 Hash detail key)
  7. 管理员手动 finalize → 幂等,多次调用无副作用
  8. 未设置 freezeTime 的比赛 → 全程实时更新,无封榜
  9. OI 赛制 → 排行期间对普通用户不可见,结束后落库正常
  10. 管理员封禁用户 → 立即 ZREM 从 ZSet 移除,排行不显示该用户,分页条数准确
  11. Lua 原子性:模拟同一用户短时间提交两道不同题目 → Hash 各 field 独立更新,ZSet score 正确
  12. OI 取最高分:同一题提交低分后再提交高分 → 更新;提交高分后再提交低分 → 跳过(SKIP)
  13. 缓存预热:比赛从 NOT_STARTED 转 RUNNING → ZSet 已包含所有报名用户(score=0)
  14. penalty 溢出保护:构造极端 penalty 值 → 钳位到 [0, 999_999_999]
  15. finalize 事务安全:模拟 Redis 删除失败 → DB 数据正确,查询走 DB

评论