CTF平台实时榜单功能后端设计方案

最近复盘项目的时候,把CTF平台中实时榜单的后端设计部分整理了一下,在此分享出来。



1.CTF平台实时榜单功能介绍(需求分析)

线上CTF竞赛(夺旗赛)通常是在限定的时间内访问题目,解出flag后在平台上提交,提交后能够获得一定的分数,最后会按总分进行排序确定此次竞赛的成绩。

在CTF竞赛中,榜单功能是访问量最高的一个功能,通过查看榜单的排名,做题情况,能够感知题目的难度,方便及时调整团队做题的策略。这个榜单通常需要反映全部战队的一个做题情况,包括每个题的解出情况,大致如下图(截图来自i春秋,第二届“祥云杯”网络安全大赛暨吉林省第四届大学生网络安全大赛)。

大致会有如下功能:

  • 1.整体按总分进行排序。
  • 2.对于每一行,除了显示战队的基础信息,总分之外,还会显示每一个题目的解出情况,包括是否一二三血。
  • 3.实时榜单,有任何题目解出情况,题目变动情况(增删题目),总榜单都会实时变动。

实时榜单功能作为整个CTF平台最重要的功能之一,同时也是访问量最高的一个功能,对这个功能做一个比较全面的设计是很有必要的。

在这里插入图片描述


2.实时榜单具体功能点特点分析

对实时榜单整个接口来说,一般具体以下特点:

  • 1.接口访问特别频繁,特别是在比赛快结束的时候,访问量几乎会翻几倍。
  • 2.比赛过程中,除了极少数签到题,一般的题目不会集中在一个时间段内提交,实际解题成功的数量在总队伍数里面占比较少。
  • 3.一般CTF竞赛会分批次的上题,也就是说:并不是比赛开始后,题目就不再变动了的。但是,上题次数不多,一般集中上题1-3次
  • 4.一般CTF题目的数量不会太多,总题目一般就20-50道左右
  • 5.一般参赛队伍数不会太多,一般参赛队伍一般在100-1500左右
  • 6.排名是实时会变动的。

3.难点分析

从上面的具体情况可以发现,其实整体的排序计算开销是非常小的,因为队伍数和题目数都非常少,主要的开销集中在数据库IO,而榜单接口的访问并发非常大,如果每次访问的请求最终都打到数据库上,很有可能造成数据库宕机。

如果简单的对榜单进行缓存,那么只要有人交题,就会导致缓存失效。这个时候还是有可能导致数据库宕机。

因此我们要设计好对榜单的缓存机制,尽量少访问数据库。

4.整体设计思路

通过上面的分析,我们设计榜单的关键就在如何在整个榜单维护在redis缓存中,从而降低数据库的查询次数。

在比赛期间,我们完全可以将整个榜单全部缓存到redis中,当比赛结束时,如果再次查询榜单(这个时候这个接口的访问量已经非常少了),我们可以从数据库中查出并将整个榜单存储到数据库中(因为此时整个榜单已经不会再变动)。

我们如果要保证缓存中的榜单总是实时正确的,就需要在有任何队伍分数发生变化的时候或者新增题目的时候,重新生成整份榜单并存回redis中,注意,这个时候,我们不需要从数据库中查询记录,只需要把提交记录保存到数据库,并且重新生成缓存即可。

除此之外,我们还需要注意一个问题,因为很有可能会出现多个线程对redis完成读,更新,写入的一个事务,这其中是存在线程安全问题的,针对上述提交正确次数不会过于集中的情况,我们可以对每个更新事务加锁来解决线程安全的问题。

总结一下:整体设计思路是:比赛过程中把榜单维护在redis中,比赛结束后,再将整份榜单记录写入到数据库。

5.部分涉及的实体类介绍

以下实体类我只将与榜单功能相关的字段写出来了,实际情况可以再添加更多的字段。

5.1 Team类

存储团队信息的实体类,和数据库字段一一对应。

/**
 * @author ATFWUS
 * @version 1.0
 * @date 2021/11/12 16:48
 * @description
 */
public class Team {
    // 团队名称
    private String teamName;
    // 团队id
    private String id;
    // 公司或学校名称
    private String belongName;
    // 组队性质
    private String companyOrSchool;

    public String getTeamName() {
        return teamName;
    }

    public void setTeamName(String teamName) {
        this.teamName = teamName;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getBelongName() {
        return belongName;
    }

    public void setBelongName(String belongName) {
        this.belongName = belongName;
    }

    public String getCompanyOrSchool() {
        return companyOrSchool;
    }

    public void setCompanyOrSchool(String companyOrSchool) {
        this.companyOrSchool = companyOrSchool;
    }

    @Override
    public String toString() {
        return "Team{" +
                "teamName='" + teamName + ''' +
                ", id='" + id + ''' +
                ", belongName='" + belongName + ''' +
                ", companyOrSchool='" + companyOrSchool + ''' +
                '}';
    }
}

5.2 Problem类

题目的实体类,和数据库字段一一对应。

/**
 * @author ATFWUS
 * @version 1.0
 * @date 2021/11/12 16:49
 * @description
 */
public class Problem {

    // 题目id
    private String problemId;
    // 题目名称
    private String problemName;
    // 题目类型
    private String tag;
    // 题目分数
    private Double score;

    public String getProblemId() {
        return problemId;
    }

    public void setProblemId(String problemId) {
        this.problemId = problemId;
    }

    public String getProblemName() {
        return problemName;
    }

    public void setProblemName(String problemName) {
        this.problemName = problemName;
    }

    public String getTag() {
        return tag;
    }

    public void setTag(String tag) {
        this.tag = tag;
    }

    public Double getScore() {
        return score;
    }

    public void setScore(Double score) {
        this.score = score;
    }

    @Override
    public String toString() {
        return "Preoblem{" +
                "problemId='" + problemId + ''' +
                ", problemName='" + problemName + ''' +
                ", tag='" + tag + ''' +
                ", score=" + score +
                '}';
    }
}

5.3 ProblemSolveRecord

解题记录,和数据库字段一一对应,在使用的时候,是筛选出了所有提交正确的记录。

/**
 * @author ATFWUS
 * @version 1.0
 * @date 2021/11/12 18:21
 * @description 成功解题记录
 */
public class ProblemSolveRecord {
    // 题目id
    private String problemId;
    // 解出团队
    private String teamId;
    // 解出人
    private String userId;
    // 题目名称
    private String problemName;
    // 题目类型
    private String tag;
    // 题目得分
    private Double getScore;
    // 是否一血
    private Boolean firstBlood;
    // 是否二血
    private Boolean secondBlood;
    // 是否三血
    private Boolean tribleBlood;

    public String getProblemId() {
        return problemId;
    }

    public void setProblemId(String problemId) {
        this.problemId = problemId;
    }

    public String getTeamId() {
        return teamId;
    }

    public void setTeamId(String teamId) {
        this.teamId = teamId;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getProblemName() {
        return problemName;
    }

    public void setProblemName(String problemName) {
        this.problemName = problemName;
    }

    public String getTag() {
        return tag;
    }

    public void setTag(String tag) {
        this.tag = tag;
    }

    public Double getGetScore() {
        return getScore;
    }

    public void setGetScore(Double getScore) {
        this.getScore = getScore;
    }

    public Boolean getFirstBlood() {
        return firstBlood;
    }

    public void setFirstBlood(Boolean firstBlood) {
        this.firstBlood = firstBlood;
    }

    public Boolean getSecondBlood() {
        return secondBlood;
    }

    public void setSecondBlood(Boolean secondBlood) {
        this.secondBlood = secondBlood;
    }

    public Boolean getTribleBlood() {
        return tribleBlood;
    }

    public void setTribleBlood(Boolean tribleBlood) {
        this.tribleBlood = tribleBlood;
    }
}

5.4 ProblemSolve

不是数据库中表对应的实体类,代表对某个队伍来说,具体某个题目的解题情况,用于榜单每一行的题目显示,相关字段可以从上述ProblemSolveRecord对象中拷贝。

/**
 * @author ATFWUS
 * @version 1.0
 * @date 2021/11/11 22:11
 * @description 具体题目情况
 */
public class ProblemSolve {
    // 题目id
    private String problemId;
    // 题目名称
    private String problemName;
    // 题目类型
    private String tag;
    // 题目得分
    private Double getScore;
    // 是否一血
    private Boolean firstBlood;
    // 是否二血
    private Boolean secondBlood;
    // 是否三血
    private Boolean tribleBlood;

    public ProblemSolve(String problemId, String problemName, String tag) {
        this.problemId = problemId;
        this.problemName = problemName;
        this.tag = tag;
    }

    public String getProblemId() {
        return problemId;
    }

    public void setProblemId(String problemId) {
        this.problemId = problemId;
    }

    public String getProblemName() {
        return problemName;
    }

    public void setProblemName(String problemName) {
        this.problemName = problemName;
    }

    public String getTag() {
        return tag;
    }

    public void setTag(String tag) {
        this.tag = tag;
    }

    public Double getGetScore() {
        return getScore;
    }

    public void setGetScore(Double getScore) {
        this.getScore = getScore;
    }

    public Boolean getFirstBlood() {
        return firstBlood;
    }

    public void setFirstBlood(Boolean firstBlood) {
        this.firstBlood = firstBlood;
    }

    public Boolean getSecondBlood() {
        return secondBlood;
    }

    public void setSecondBlood(Boolean secondBlood) {
        this.secondBlood = secondBlood;
    }

    public Boolean getTribleBlood() {
        return tribleBlood;
    }

    public void setTribleBlood(Boolean tribleBlood) {
        this.tribleBlood = tribleBlood;
    }
}

5.5 OneTotalRecord

代表榜单中的一行记录,包含了一行需要显示的所有记录字段。其中对于这个团队来说的所有解题情况,都可以靠titles集合来遍历。

注意这里将成功解题的方法添加到了实体类中。

/**
 * @author ATFWUS
 * @version 1.0
 * @date 2021/11/11 21:53
 * @description 总榜单条数据
 */
public class OneTotalRecord {
    // 团队名称
    private String teamName;
    // 团队id
    private String id;
    // 公司或学校名称
    private String belongName;
    // 组队性质
    private String companyOrSchool;
    // 团队总解题数
    private Integer totalSolveNum;
    // 团队总分
    private Double totalScore;
    // 一血数
    private Integer firstBlood;
    // 最后一次成功提交时间
    private Date lastSolveTime;
    // 所有解题数据 具体的分类由前端进行分类
    private List<ProblemSolve> titles = new LinkedList<>();

    public OneTotalRecord(){

    }

    public OneTotalRecord(String teamName, String id, String belongName, String companyOrSchool){

    }

    public String getTeamName() {
        return teamName;
    }

    public void setTeamName(String teamName) {
        this.teamName = teamName;
    }

    public String getBelongName() {
        return belongName;
    }

    public void setBelongName(String belongName) {
        this.belongName = belongName;
    }

    public String getCompanyOrSchool() {
        return companyOrSchool;
    }

    public void setCompanyOrSchool(String companyOrSchool) {
        this.companyOrSchool = companyOrSchool;
    }

    public Integer getTotalSolveNum() {
        return totalSolveNum;
    }

    public void setTotalSolveNum(Integer totalSolveNum) {
        this.totalSolveNum = totalSolveNum;
    }

    public Double getTotalScore() {
        return totalScore;
    }

    public void setTotalScore(Double totalScore) {
        this.totalScore = totalScore;
    }

    public Integer getFirstBlood() {
        return firstBlood;
    }

    public void setFirstBlood(Integer firstBlood) {
        this.firstBlood = firstBlood;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public Date getLastSolveTime() {
        return lastSolveTime;
    }

    public void setLastSolve(Date lastSolve) {
        this.lastSolveTime = lastSolve;
    }

    public List<ProblemSolve> getTitles() {
        return titles;
    }

    public void setTitles(List<ProblemSolve> titles) {
        this.titles = titles;
    }

    public void solveOneProblem(ProblemSolve solve){
        for(ProblemSolve problemSolve : titles){
            if(problemSolve.getProblemId().equals(solve.getProblemId())){
                problemSolve.setGetScore(solve.getGetScore());
                problemSolve.setFirstBlood(solve.getFirstBlood());
                problemSolve.setSecondBlood(solve.getSecondBlood());
                problemSolve.setTribleBlood(solve.getTribleBlood());
                totalScore += solve.getGetScore();
                break;
            }
        }
    }

    @Override
    public String toString() {
        return "OneTotalRecord{" +
                "teamName='" + teamName + ''' +
                ", id='" + id + ''' +
                ", belongName='" + belongName + ''' +
                ", companyOrSchool='" + companyOrSchool + ''' +
                ", totalSolveNum=" + totalSolveNum +
                ", totalScore=" + totalScore +
                ", firstBlood=" + firstBlood +
                ", titles=" + titles +
                '}';
    }
}

6.具体业务功能ServeiceImpl设计

针对上述需求分析,我们的榜单的Service接口只需要三个public方法即可。

  • 1.public List<OneTotalRecord> getAllRecord(String competitionId) 获取指定比赛的榜单。
  • 2.public void addOneSolution(String competitionId, String teamId, ProblemSolve problemSolve) 新增一条成功解题记录时,更新榜单。
  • 3.public void addOneProblem(String competitionId, Problem problem) 新增一个题目的时候,更新逻辑。(可以在批量上题完毕后调用一次)

这里没有给出删除题目的方法,因为比赛过程中删除题目会有很多种不同的处理逻辑,需要自行去实现处理逻辑。

6.1 获取榜单方法 getAllRecord

总体思路就是,先从redis中获取,redis中有直接返回,没有就先判断一下是否是初始化榜单(没有任何成功解题记录就是初始化榜单),如果不是初始化(说明此时缓存失效,需要从数据库中查出),然后获得操作榜单的锁,开始从数据库中查出记录,生成整个榜单。

在这里插入图片描述

    public List<OneTotalRecord> getAllRecord(String competitionId){
        // 1.从redis中查询,如果存在,直接反序列化成对象后返回
        List<OneTotalRecord> redisRecords = getRecordsAllRedis(competitionId);
        if(redisRecords != null){
            return redisRecords;
        }

        // 如果redis中不存在
        // 2.数据库查询出当前比赛是否有成功的解题记录 如果没有,进入初始化流程,初始化后直接返回
        if(checkValidSolve(competitionId)){
            return initRecord(competitionId);
        }
        lock.lock();
        try{
            // 某个线程获得锁时已经成功初始化
            redisRecords = getRecordsAllRedis(competitionId);
            if(redisRecords != null){
                return redisRecords;
            }
            // 此时数据库中有对应的解题记录,但是redis中没有缓存,很有可能是缓存失效 那么需要从数据库中查出所有解题记录,生成榜单
            // 3.查询对应榜单
            List<ProblemSolveRecord> solveRecords = getCompetitionSolveRecord(competitionId);
            // 将榜单列表转化为map 键为团队id 值为解题列表
            Map<String, List<ProblemSolveRecord>> solveMap = new HashMap<>();
            for(ProblemSolveRecord solveRecord : solveRecords){
                if(solveMap.containsKey(solveRecord.getTeamId())){
                    solveMap.get(solveRecord.getTeamId()).add(solveRecord);
                }else{
                    solveMap.put(solveRecord.getTeamId(), new ArrayList<ProblemSolveRecord>());
                    solveMap.get(solveRecord.getTeamId()).add(solveRecord);
                }
            }
            // 4.查询出所有团队
            List<Team> teams = getAllCompetitionTeam(competitionId);
            if(teams.size() == 0){
                return null;
            }
            // 5.查询出所有的题目
            List<Problem> problems = getAllCompetitionProblem(competitionId);
            // 6.初始化每一条记录 数量固定,使用ArrayList
            List<OneTotalRecord> records = new ArrayList<>(teams.size());
            for(Team team : teams){
                // 初始化基本信息
                OneTotalRecord record = new OneTotalRecord(team.getTeamName(), team.getId(), team.getBelongName(), team.getCompanyOrSchool());
                List<ProblemSolve> solveProblemList = record.getTitles();
                // 初始化题目信息
                for(Problem problem : problems){
                    // 初始化解题记录
                    solveProblemList.add(new ProblemSolve(problem.getProblemId(), problem.getProblemName(), problem.getTag()));
                }
                if(solveMap.containsKey(team.getId())){
                    List<ProblemSolveRecord> solveRecordList = solveMap.get(team.getId());
                    // 遍历当前团队解出的每一个题
                    for(int i = 0; i < solveRecordList.size(); i++){
                        ProblemSolveRecord solveRecord = solveRecordList.get(i);
                        String problemId = solveRecord.getProblemId();
                        // 寻找每一个题目对应的记录
                        for(ProblemSolve problemSolve : solveProblemList){
                            // 找到了对应的解题记录
                            if(problemSolve.getProblemId().equals(problemId)){
                                problemSolve.setGetScore(solveRecord.getGetScore());
                                problemSolve.setFirstBlood(solveRecord.getFirstBlood());
                                problemSolve.setSecondBlood(solveRecord.getSecondBlood());
                                problemSolve.setTribleBlood(solveRecord.getTribleBlood());
                                break;
                            }
                        }
                    }
                    solveMap.remove(team.getId());
                }
            }
            // 7.排序
            recordsSortByScore(records);
            // 8.存入redis
            saveRecordsToRedis(records);
            return records;
        }finally {
            lock.unlock();
        }

    }

6.2 新增解题记录后调用 addOneSolution

新增成功解题记录时,只需要获取锁,然后更新对应的记录,最后对榜单重新排序即可。

    /**
     * 当新增一条解题记录时的处理逻辑
     * @param competitionId
     * @param teamId
     * @param
     */
    public void addOneSolution(String competitionId, String teamId, ProblemSolve problemSolve){
        lock.lock();
        try{
            // 1.获取之前的榜单
            List<OneTotalRecord> records = getAllRecord(competitionId);
            // 2.找到指定团队
            for(OneTotalRecord record : records){
                if(record.getId().equals(teamId)){
                    // 新增解题
                    record.solveOneProblem(problemSolve);
                    break;
                }
            }
            // 3.重新排序
            recordsSortByScore(records);
            // 4.存入redis
            saveRecordsToRedis(records);
        }finally {
            lock.unlock();
        }

    }

6.3 新上题后调用 addOneProblem

新上题时,获取锁后,往每一条拌榜单记录里新增一条初始题目记录即可。

    /**
     * 后台新上了题目
     * @param problem
     */
    public void addOneProblem(String competitionId, Problem problem){
        lock.lock();
        try{
            // 1.获取之前的榜单
            List<OneTotalRecord> records = getAllRecord(competitionId);
            // 2.给每条数据新增一个题目
            for(OneTotalRecord record : records){
                record.getTitles().add(new ProblemSolve(problem.getProblemId(), problem.getProblemName(), problem.getTag()));
            }
            // 3.无需重新排序 直接存入redis
            saveRecordsToRedis(records);
        }finally {
            lock.unlock();
        }
    }

6.4 完整service

将原本项目中的serviceImpl的关键方法抽离出来重新处理了一下,只需要按照需求拓展实体类,重写这些方法就可以放在项目中使用。

package awardList;

import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author ATFWUS
 * @version 1.0
 * @date 2021/11/12 16:29
 * @description
 */
public class RecordServiceImpl {

    private final ReentrantLock lock = new ReentrantLock();

    public List<OneTotalRecord> getAllRecord(String competitionId){
        // 1.从redis中查询,如果存在,直接反序列化成对象后返回
        List<OneTotalRecord> redisRecords = getRecordsAllRedis(competitionId);
        if(redisRecords != null){
            return redisRecords;
        }

        // 如果redis中不存在
        // 2.数据库查询出当前比赛是否有成功的解题记录 如果没有,进入初始化流程,初始化后直接返回
        if(checkValidSolve(competitionId)){
            return initRecord(competitionId);
        }
        lock.lock();
        try{
            // 某个线程获得锁时已经成功初始化
             redisRecords = getRecordsAllRedis(competitionId);
            if(redisRecords != null){
                return redisRecords;
            }
            // 此时数据库中有对应的解题记录,但是redis中没有缓存,很有可能是缓存失效 那么需要从数据库中查出所有解题记录,生成榜单
            // 3.查询对应榜单
            List<ProblemSolveRecord> solveRecords = getCompetitionSolveRecord(competitionId);
            // 将榜单列表转化为map 键为团队id 值为解题列表
            Map<String, List<ProblemSolveRecord>> solveMap = new HashMap<>();
            for(ProblemSolveRecord solveRecord : solveRecords){
                if(solveMap.containsKey(solveRecord.getTeamId())){
                    solveMap.get(solveRecord.getTeamId()).add(solveRecord);
                }else{
                    solveMap.put(solveRecord.getTeamId(), new ArrayList<ProblemSolveRecord>());
                    solveMap.get(solveRecord.getTeamId()).add(solveRecord);
                }
            }
            // 4.查询出所有团队
            List<Team> teams = getAllCompetitionTeam(competitionId);
            if(teams.size() == 0){
                return null;
            }
            // 5.查询出所有的题目
            List<Problem> problems = getAllCompetitionProblem(competitionId);
            // 6.初始化每一条记录 数量固定,使用ArrayList
            List<OneTotalRecord> records = new ArrayList<>(teams.size());
            for(Team team : teams){
                // 初始化基本信息
                OneTotalRecord record = new OneTotalRecord(team.getTeamName(), team.getId(), team.getBelongName(), team.getCompanyOrSchool());
                List<ProblemSolve> solveProblemList = record.getTitles();
                // 初始化题目信息
                for(Problem problem : problems){
                    // 初始化解题记录
                    solveProblemList.add(new ProblemSolve(problem.getProblemId(), problem.getProblemName(), problem.getTag()));
                }
                if(solveMap.containsKey(team.getId())){
                    List<ProblemSolveRecord> solveRecordList = solveMap.get(team.getId());
                    // 遍历当前团队解出的每一个题
                    for(int i = 0; i < solveRecordList.size(); i++){
                        ProblemSolveRecord solveRecord = solveRecordList.get(i);
                        String problemId = solveRecord.getProblemId();
                        // 寻找每一个题目对应的记录
                        for(ProblemSolve problemSolve : solveProblemList){
                            // 找到了对应的解题记录
                            if(problemSolve.getProblemId().equals(problemId)){
                                problemSolve.setGetScore(solveRecord.getGetScore());
                                problemSolve.setFirstBlood(solveRecord.getFirstBlood());
                                problemSolve.setSecondBlood(solveRecord.getSecondBlood());
                                problemSolve.setTribleBlood(solveRecord.getTribleBlood());
                                break;
                            }
                        }
                    }
                    solveMap.remove(team.getId());
                }
            }
            // 7.排序
            recordsSortByScore(records);
            // 8.存入redis
            saveRecordsToRedis(records);
            return records;
        }finally {
            lock.unlock();
        }

    }

    /**
     * 当新增一条解题记录时的处理逻辑
     * @param competitionId
     * @param teamId
     * @param
     */
    public void addOneSolution(String competitionId, String teamId, ProblemSolve problemSolve){
        lock.lock();
        try{
            // 1.获取之前的榜单
            List<OneTotalRecord> records = getAllRecord(competitionId);
            // 2.找到指定团队
            for(OneTotalRecord record : records){
                if(record.getId().equals(teamId)){
                    // 新增解题
                    record.solveOneProblem(problemSolve);
                    break;
                }
            }
            // 3.重新排序
            recordsSortByScore(records);
            // 4.存入redis
            saveRecordsToRedis(records);
        }finally {
            lock.unlock();
        }

    }

    /**
     * 后台新上了题目
     * @param problem
     */
    public void addOneProblem(String competitionId, Problem problem){
        lock.lock();
        try{
            // 1.获取之前的榜单
            List<OneTotalRecord> records = getAllRecord(competitionId);
            // 2.给每条数据新增一个题目
            for(OneTotalRecord record : records){
                record.getTitles().add(new ProblemSolve(problem.getProblemId(), problem.getProblemName(), problem.getTag()));
            }
            // 3.无需重新排序 直接存入redis
            saveRecordsToRedis(records);
        }finally {
            lock.unlock();
        }
    }


    /**
     * 初始化一份榜单数据
     * @param competitionId
     * @return
     */
    private List<OneTotalRecord> initRecord(String competitionId){
        lock.lock();
        try{
            // 某个线程获得锁时已经成功初始化
            List<OneTotalRecord> redisRecords = getRecordsAllRedis(competitionId);
            if(redisRecords != null){
                return redisRecords;
            }
            // 1.先查询出所有的队伍
            List<Team> teams = getAllCompetitionTeam(competitionId);
            if(teams.size() == 0){
                return null;
            }
            // 2.查询出所有的题目
            List<Problem> problems = getAllCompetitionProblem(competitionId);
            // 3.初始化每一条记录 数量固定,使用ArrayList
            List<OneTotalRecord> records = new ArrayList<>(teams.size());
            for(Team team : teams){
                // 初始化基本信息
                OneTotalRecord record = new OneTotalRecord(team.getTeamName(), team.getId(), team.getBelongName(), team.getCompanyOrSchool());
                List<ProblemSolve> solveProblemList = record.getTitles();
                // 初始化题目信息
                for(Problem problem : problems){
                    solveProblemList.add(new ProblemSolve(problem.getProblemId(), problem.getProblemName(), problem.getTag()));
                }
            }
            // 4.排序
            recordsSortById(records);
            // 5.存入redis中
            saveRecordsToRedis(records);
            return records;
        }finally {
            lock.unlock();
        }

    }

    /**
     * 对榜单按按照Id排序
     * @param records
     */
    private void recordsSortById(List<OneTotalRecord> records){
        Collections.sort(records, new Comparator<OneTotalRecord>() {
            @Override
            public int compare(OneTotalRecord o1, OneTotalRecord o2) {
                return o1.getId().compareTo(o2.getId());
            }
        });
    }

    /**
     * 对榜单按照总分排序
     * @param records
     */
    private void recordsSortByScore(List<OneTotalRecord> records){
        Collections.sort(records, new Comparator<OneTotalRecord>() {
            @Override
            public int compare(OneTotalRecord o1, OneTotalRecord o2) {
                // 先按总分排
                if(o2.getTotalScore().compareTo(o1.getTotalScore()) != 0){
                    return o2.getTotalScore().compareTo(o1.getTotalScore());
                }
                // 再按解出时间排序
                return o1.getLastSolveTime().compareTo(o2.getLastSolveTime());
            }
        });
    }



    private List<ProblemSolveRecord> getCompetitionSolveRecord(String competitionId){
        return new ArrayList<>();
    }


    /**
     *
     * @param competitionId
     * @return 当前比赛是否有成功解题记录
     */
    private boolean checkValidSolve(String competitionId){
        // TODO 需要重写 从数据库中查询当前比赛是否有成功解题记录
        return false;
    }

    /**
     * 将榜单数据存储到redis中
     * @param records
     */
    private void saveRecordsToRedis(List<OneTotalRecord> records){
        // TODO 需要重写 将榜单数据保存至redis中
    }

    /**
     *
     * @param competitionId
     * @return 总榜单数据
     */
    private List<OneTotalRecord> getRecordsAllRedis(String competitionId){
        // TODO 需要重写 从redis中查询出对应比赛榜单
        return null;
    }

    /**
     *
     * @param competitionId
     * @return 所有参赛团队
     */
    private List<Team> getAllCompetitionTeam(String competitionId){
        // TODO 需要重写 从数据库中查询所有参赛团队
        return new ArrayList<>();
    }

    /**
     *
     * @param competitionId
     * @return 所有比赛题目
     */
    private List<Problem> getAllCompetitionProblem(String competitionId){
        // TODO 需要重写 从数据库中查询所有赛题
        return new ArrayList<>();
    }
}

6.5 实现细节

6.5.1 如何确定具体的数据结构?

存储总记录的数据结构的选择(ArrayList)

比赛一开始,就不可以再报名,也就是说,比赛过程中榜单的总记录数是确定的,不涉及动态插入删除的需求,所以可以直接使用ArryList。并且可以在构造方法中显示的传入大小。

存储单条记录的所有题目的数据结构的选择(LinkedList)

比赛题目会涉及动态的添加或删除,此时选择双链表实现的LinkedList更好。

存储数据库中解题记录的数据结构的选择(HashMap)

因为总记录是使用List存储的,所以要找到指定的队伍添加解题记录,就需要遍历整个List,如果我们将解题记录存放到Map中,键为团队ID,值为该团队解题记录的链表。这样只需要遍历一次即可把一个团队的解题记录全部添加。

6.5.2 为何要加锁

Spring默认的Service对象是单例对象,当多个线程访问的时候,访问的是同一个方法,此时,redis中存储的榜单,就相当于是共享数据,多个线程对共享数据的操作,就需要考虑线程安全的问题。

6.5.3 双检锁思想

在初始化榜单和重写从数据库查出记录生成榜单的方法中,可以看到一个细节,在获得锁后,还是需要判断一下redis中是否存在,这就是典型的双检锁思想,确保redis中的记录只生成唯一一次。

6.5.4 redis缓存策略如何设置

在代码中并没有给出redis具体缓存策略怎么实现,但是可以知道存储的都是榜单对象序列化后的结果。这样将所有对榜单的操作都放在java层中,耦合度小很多。

关于过期时间的策略,建议可以设置久一点,比如一个小时,反正如果榜单有变动,会及时刷新。

6.5.5 比赛时间到后如何处理榜单

比赛过程中,榜单全部在redis中,一旦比赛结束,榜单将不会再变动,这时候可以从数据库中查出对应的记录生成一份最终的榜单,序列化后存入数据库中,或者为每个战队生成一条记录(包含排名)存到数据库中。


7.设计方案总结

7.1 优点

  • 比赛过程中,榜单几乎从redis中获取,解决了比赛过程中榜单接口调用频繁导致数据库压力过大的问题。
  • 这个设计方案,返回的榜单包含了所需的所有信息,前端只需要按tag分一下类即可展示。
  • 效率高,解决了线程安全问题。

7.2 缺陷

  • 使用了直接加锁的方式来解决线程安全问题,一旦某个题出现集中提交且正确的情况,那么会有多个线程阻塞,线程数堆积过多,可能会导致oom。
  • 在很多地方,仍然使用的直接遍历的方式去查找指定的战队,如果战队数过多,这个时间消耗也会很大。
  • 对于动态分数的比赛机制,没有很好的支持。

7.3 改进思路

  • 在应用层采用信号量的方式,限制提交题目接口的并发次数。
  • 空间换时间,对所有战队,将其维护在一个哈希表中,这样每次找到这个战队,只需要O(1)的时间即可。
  • 如果采用动态分数制,那么将所有题目和解出数全部维护在redis中,在排序前,先更新每个战队每个题目的分数。

ATFWUS 2021-11-14

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>