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

日期: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 | 避免分页后过滤导致条数不足 |
| D | Finalize 事务安全 | DB 先提交,Redis 清理后置(容错) |
| E | Score 范围校验 | ACM penalty 上限防溢出 |
| F | 缓存预热 | 大型比赛首次提交前初始化 ZSet |
二、Redis 数据结构设计
2.1 排名 ZSet
| Key | 类型 | Score 编码 | 说明 |
|---|---|---|---|
contest:rank:{contestId} | ZSet | ACM: 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 | 类型 | Field | Value | 说明 |
|---|---|---|---|---|
contest:detail:{contestId}:{userId} | Hash | questionId(字符串) | 单道题的 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)。
十、修改清单
| # | 类型 | 文件 | 改动 |
|---|---|---|---|
| 数据库 | |||
| 1 | DDL | sql/init.sql | contest 表新增 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.java | Redis 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 状态,结果正确 |
十三、测试要点
- 创建 ACM 比赛(with freezeTime),提交 → Redis ZSet 和 Hash detail 正常更新
- 到达 freezeTime,继续提交 → Redis 不再更新,排行保持封榜前状态
- 封榜后非管理员查看提交记录 → 仅看到自己的
- 比赛结束 →
finalizeContestRanking自动触发,contest_ranking表有数据,rankFinalized=true - 比赛结束后查排行 → 走 MySQL,包含封榜后的提交结果
- Redis key 已被清理(ZSet + 所有 Hash detail key)
- 管理员手动 finalize → 幂等,多次调用无副作用
- 未设置 freezeTime 的比赛 → 全程实时更新,无封榜
- OI 赛制 → 排行期间对普通用户不可见,结束后落库正常
- 管理员封禁用户 → 立即 ZREM 从 ZSet 移除,排行不显示该用户,分页条数准确
- Lua 原子性:模拟同一用户短时间提交两道不同题目 → Hash 各 field 独立更新,ZSet score 正确
- OI 取最高分:同一题提交低分后再提交高分 → 更新;提交高分后再提交低分 → 跳过(SKIP)
- 缓存预热:比赛从 NOT_STARTED 转 RUNNING → ZSet 已包含所有报名用户(score=0)
- penalty 溢出保护:构造极端 penalty 值 → 钳位到
[0, 999_999_999] - finalize 事务安全:模拟 Redis 删除失败 → DB 数据正确,查询走 DB
评论
无需登录,审核通过后公开。只有博主可以回复。
正在加载…
已公开的评论