简介:知识竞赛系统是一套基于C++/MFC开发的可运行在线竞赛平台,面向需要组织答题活动、管理题库、统计排名的开发者或课程设计者。系统覆盖试题的添加、删除、修改,参赛人员录入,在线答题计时、自动评分,以及按正确率、答题速度生成动态排名等完整功能,适用课堂测验、社团比赛等场景。压缩包共61个文件,大小7.68MB,以C++源文件(cpp/h)、工程配置文件(dsp/dsw)、界面资源(bmp/ico/rc)和SQL Server数据库文件(mdf/ldf)为主,并含可直接运行的exe,便于边运行边对照代码理解模块划分。已有394人学习浏览。对于想快速搭建知识竞赛系统的用户,这套资源提供了完整源码、可执行示例和数据库附件,既能作为课程设计参考,也能基于MFC框架扩展新功能,或从数据库脚本中了解排名与成绩的存储逻辑,是学习经典VC++项目结构和在线答题业务流程的实用素材。
1. 知识竞赛系统:从一场翻车的在线竞答说起
去年某公司内部搞安全生产知识竞赛,组织者用的是「Excel 出题 + 问卷星收集答案 + 人工判分」这条野路子。300 道题、100 多人参赛,光导出比对就花了两个晚上,最后因为抢答计时不公被参赛者当场质疑。那一刻我就意识到:知识竞赛看起来只是个答题网页,但一旦涉及实时比分、多人同时在线、倒计时抢答、防重复提交,它就从一个「表单应用」变成了一个「高并发读 + 低延迟写 + 强一致性」的准实时系统。
本文就围绕「知识竞赛系统」这个标题,讲清楚三件事:它应该被拆成哪几个核心模块、每个模块的最小可落地实现是什么、上线前哪些坑不踩不行。读者画像很明确——你可能是负责公司/学校内部竞赛的组织者,也可能是想接外包项目的开发者;新手照着代码能跑通,熟手重点关注参数边界和踩坑章节。我不讲大而全的架构,只讲能在一周内交付、撑得住千人以内的实战方案。
2. 把知识竞赛系统拆成四块:数据模型、竞答状态机、计分引擎与前端交互
很多第一次做这个系统的人最容易犯的错,是一上来就写页面。实际上知识竞赛系统的复杂度全在后端:题目怎么存、一场竞赛的状态怎么流转、计分规则怎么扩展、前端与服务器的时间怎么对齐。这四件事想不清楚,后面每一个功能都会返工。
2.1 为什么选 Spring Boot + Vue 这套组合
知识竞赛系统的典型并发形态是「低持续流量 + 瞬时高峰」:平时没人访问,开赛那一刻所有人同时涌入。这种场景不需要微服务,单体应用加 Redis 就够了。我见过有人为了这个项目上 Nacos 加 Sentinel,最后光运维就把自己劝退了。
后端我一般用 Spring Boot 2.7 + MyBatis Plus + Redis + MySQL,前端用 Vue 3 + Element Plus。选这套不是因为技术新,而是因为:第一,Spring Boot 的 WebSocket 支持和定时任务生态成熟,抢答倒计时这种功能不需要自己造轮子;第二,MyBatis Plus 的 LambdaQueryWrapper 写答题记录的分页查询非常省事;第三,Vue 3 的 Composition API 写倒计时和 WebSocket 重连逻辑比 Options API 清晰得多。
如果你所在团队更熟 Python,用 FastAPI + Redis 也能实现同样的效果,只是 WebSocket 的客户端接入体验不如 Spring 的 STOMP 协议成熟。这个系统核心不在语言,而在「数据一致性」和「状态管理」这两块,下文每一节都会围绕这两个词展开。
2.2 题库与竞赛场次的数据模型设计
先看题库。竞赛题目不能只存题目和答案,还需要:所属分类(用于按比例抽题)、难度系数(用于控制整卷难度)、使用次数(避免同一题被反复抽到,需要定时重置)、状态(启用/停用)。核心表结构如下:
CREATE TABLE `question_bank` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `category_code` VARCHAR(32) NOT NULL COMMENT '分类编码,如 SAFETY/LAW', `difficulty` TINYINT NOT NULL DEFAULT 1 COMMENT '1-简单 2-中等 3-困难', `content` TEXT NOT NULL COMMENT '题干,纯文本或HTML', `options` JSON NOT NULL COMMENT '选项,如 ["A. 选项1", "B. 选项2"]', `correct_answer` VARCHAR(16) NOT NULL COMMENT '正确答案,多选逗号分隔', `analysis` TEXT COMMENT '答案解析,答错后展示', `used_count` INT NOT NULL DEFAULT 0 COMMENT '使用次数,按场次重置', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-停用', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;JSON 选项字段是刻意为之。如果你把选项拆成关联表,每次查询都要 JOIN,抽题和展示都会多一层复杂度;而选项的增改频率极低,JSON 完全够用。correct_answer 用逗号分隔的字符串是为了兼容多选,判分时按 split 后比对集合即可。
竞赛场次表是另一个关键实体:
CREATE TABLE `quiz_session` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `session_code` VARCHAR(32) NOT NULL COMMENT '场次编号,如 QS20250101', `title` VARCHAR(128) NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `duration_seconds` INT NOT NULL DEFAULT 600 COMMENT '单题答题时长', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-未开始 1-进行中 2-已结束', `question_ids` JSON NOT NULL COMMENT '本场次抽题结果ID列表,按顺序存储', `settings` JSON COMMENT '计分规则、是否允许切题等配置' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意 question_ids 这个字段。很多新手会把题目动态地从题库里 JOIN 过来,但竞赛一旦生成试卷,题目就必须固定——否则中途有人改了题库,整场竞赛的公平性就崩了。所以抽题后立即把题目 ID 快照进 JSON 字段,答题时直接按这个列表取题。
2.3 竞答状态机:一场竞赛只有四种状态
竞赛的状态不是想改就改的。我把状态机严格定义为:草稿 -> 已发布 -> 进行中 -> 已结束。草稿可以随意编辑题目和规则;已发布后只允许在看板页面查看,不允许改题;进行中只接受答题提交;已结束之后所有写操作拒绝,只读数据。
这个状态机的价值在于防呆。如果没有状态约束,很容易出现「竞赛还在进行,后台误操作把题目改了」这种事故。实现上用一张状态流转表维护,或者干脆用枚举 + 状态检查方法:
public enum SessionState { DRAFT(0), PUBLISHED(1), RUNNING(2), FINISHED(3); public boolean canTransitTo(SessionState target) { // 只允许相邻状态流转,防止跳跃 return Math.abs(this.ordinal() - target.ordinal()) == 1; } }开赛动作由定时任务触发,到 start_time 自动将状态从 PUBLISHED 切到 RUNNING;到 end_time 自动切到 FINISHED。不要依赖人工点按钮,定时任务 + 状态机双重保障。切状态的同时要发一个 Redis 的 topic 广播,让所有在线选手的前端自动刷新。
2.4 计分引擎:把规则从业务代码里剥出来
计分是最容易被需求变更搞崩的地方。第一期可能只是「答对加 10 分」,第二期就变成「多选题答对但不全给 5 分,答错倒扣 3 分」,第三期再加「速度分:10 秒内答出额外加 5 分」。如果计分逻辑散落在提交接口里,每改一次规则就要动一次主流程,测试成本直线上升。
我的做法是做一个独立的计分策略接口:
public interface ScoreStrategy { // 返回本次答题得分,可能为负数 int calculate(Question question, String userAnswer, int elapsedSeconds); }然后每个规则一个实现类,用 Spring 的策略工厂按场次配置加载。默认策略是「单选答对 +10,答错 0 分」;进阶策略是「多选全对 +15,部分选对 +8,选错 -5」。这样改规则不动主流程,加规则只需要新增一个 Bean,竞赛场次的 settings 字段里指定策略名即可。这套设计的额外好处是计分逻辑可以单独写单元测试,不必每次跑整个应用。
3. 跑通最小可用的答题链路:生成试卷、提交判分与倒计时
系统设计得再清晰,最终还是要落到一条能跑通的答题链路上。这一章我从后端到前端给出最小可用代码,按「抽题 -> 选手答题 -> 提交 -> 判分 -> 倒计时」的顺序往下走。
3.1 动态生成试卷:抽题策略与种子随机
抽题要解决两个问题:题目范围和随机性。范围由前端传入的筛选条件决定;随机性要支持两种模式——「每个人题目顺序不同」和「所有人题目相同但顺序不同」。后一种模式需要用场次 ID 作为随机种子,保证同一个场次内所有选手拿到的题是同一套,只是顺序有差异。
public List<Long> generatePaper(Long sessionId, List<String> categories, Map<String, Integer> quotaMap) { // 用 sessionId 作为随机种子,保证同一场次题目集合固定,顺序可复现 Random random = new Random(sessionId); List<Long> selectedIds = new ArrayList<>(); for (String category : categories) { Integer quota = quotaMap.getOrDefault(category, 5); // 查询该分类下已启用的题目ID List<Long> candidateIds = questionBankMapper.selectIdsByCategory(category); // 洗牌后取前 quota 个 Collections.shuffle(candidateIds, random); selectedIds.addAll(candidateIds.subList(0, Math.min(quota, candidateIds.size()))); } // 整体再洗一次,打乱分类顺序 Collections.shuffle(selectedIds, random); return selectedIds; }参数说明:quotaMap 的 key 是分类编码,value 是该分类要抽的题数。如果候选题不够 quota,按实际数量抽取,这会导致不同选手遇到不同题量,所以发布前必须校验「候选题数 >= 需求题数」。种子随机的好处是:出问题时可以拿 sessionId 重新跑一遍抽题过程,精确复现线上试卷,排查「某道题是不是没被抽到」这类问题非常有用。
3.2 提交判分接口与幂等处理
提交接口是知识竞赛系统的命门,因为它同时涉及计分、倒计时校验、幂等防重。下面这段代码是后端判分的完整流程:
@PostMapping("/api/answer/submit") public Result submitAnswer(@RequestBody SubmitAnswerRequest req) { // 1. 校验场次状态:必须为 RUNNING QuizSession session = sessionMapper.selectById(req.getSessionId()); if (session.getStatus() != SessionState.RUNNING.ordinal()) { return Result.fail("竞赛不在进行中"); } // 2. 幂等校验:同一用户同一场次同一题只能提交一次 // 数据库层有唯一索引 (user_id, session_id, question_id) 兜底 AnswerRecord exist = answerRecordMapper.selectByUserAndQuestion( req.getUserId(), req.getSessionId(), req.getQuestionId()); if (exist != null) { return Result.fail("该题已作答,请勿重复提交"); } // 3. 倒计时校验:服务器当前时间不能超过题目截止时间 long deadline = session.getStartTime().getTime() + req.getQuestionIndex() * session.getDurationSeconds() * 1000L; if (System.currentTimeMillis() > deadline + 2000L) { return Result.fail("答题超时,本次提交不计分"); } // 4. 判分:委托给计分策略 Question question = questionBankMapper.selectById(req.getQuestionId()); int score = scoreStrategyFactory.getStrategy(session.getSettings()) .calculate(question, req.getUserAnswer(), req.getElapsedSeconds()); // 5. 写库 + 更新 Redis 实时比分 AnswerRecord record = new AnswerRecord(/* 填充字段 */); answerRecordMapper.insert(record); redisTemplate.opsForValue().increment("score:" + req.getSessionId() + ":" + req.getUserId(), score); // 6. 返回本机得分,不返回总分(总分由排行榜接口统一拉取) return Result.success(score); }这段代码里有三个细节值得注意。第一,倒计时校验的 deadline 是按「单选题持续时间 + 题目序号」推算的,而不是前端传一个剩余秒数过来——前端时间可以伪造,服务器必须自己算。第二,deadline 加了 2000ms 的宽容窗口,这是为了防止选手网络抖动导致请求晚到 1 秒被误判超时,但这个值不能太大,否则抢答模式就名存实亡。第三,Redis 的 INCR 是原子操作,天然规避了多线程下分数覆盖的问题,这是计分一致性的第一道保障。
3.3 倒计时:用服务器时间戳而不是前端累加
前端倒计时的坑非常隐蔽。新手喜欢在 mounted 里取剩余秒数,然后用 setInterval 每秒减 1。这个方案在选手切到后台标签页时会被浏览器节流,回来时倒计时已经不准了。正确的做法是:页面加载时从服务器拉一个绝对截止时间戳,前端只做「当前时间与截止时间的差」计算。
// Vue 3 Composition API 中的倒计时逻辑 const deadline = ref(0) const remainSeconds = ref(0) let timer = null function fetchDeadline(sessionId, questionIndex) { axios.get(`/api/session/${sessionId}/deadline`, { params: { questionIndex } }).then(res => { // 服务器返回的是绝对时间戳,单位 ms deadline.value = res.data.deadline startCountdown() }) } function startCountdown() { if (timer) clearInterval(timer) // 每 250ms 刷新一次,避免 setInterval 误差累积 timer = setInterval(() => { const diff = deadline.value - Date.now() remainSeconds.value = Math.max(0, Math.ceil(diff / 1000)) if (remainSeconds.value <= 0) { clearInterval(timer) autoSubmit() // 时间到自动提交 } }, 250) }参数说明:刷新间隔设成 250ms 而不是 1000ms,是为了让剩余秒数在临界点时显示更准确,避免出现「明明还有 0.2 秒却显示 0 秒」的体验问题。前端只负责展示和到时自动提交,最终判定以服务器的时间戳为准——即使前端被节流或卡顿,服务器也能正确判断超时。
4. 实时对战怎么做:WebSocket 计分广播与断线重连
知识竞赛系统与普通答题系统的最大分水岭,就是排行榜和前几名的分数是否实时跳动。做不好,观众席和大屏端就是「死水一潭」;做好了,紧张感直接拉满。这一章讲我是怎么选型、怎么设计消息协议、怎么处理断线重连的。
4.1 为什么不用轮询:算一笔实时性的账
排行榜实时刷新,最朴素的做法是前端每 2 秒拉一次接口。听起来没问题,但算一笔账:500 人同时在线,每人 2 秒拉一次,后端每秒要扛 250 次请求,其中有大量请求拿到的数据和上一次完全相同。更致命的是,如果某次某个选手的分数刚更新,而你的轮询刚好错过,大屏上就会出现「分数回跳」的视觉 Bug。
WebSocket 的方案是:只有分数变化、场次状态变化、题目切换这三个事件发生时,服务器才主动推送消息给前端。平时连接是安静的,不占资源。对于知识竞赛这种「事件稀疏、但来了就必须立刻到」的场景,WebSocket 是明确的正解。实现上用 Spring Boot 自带的 WebSocket + STOMP 协议,不需要引入 Netty。
4.2 WebSocket 房间模型与消息协议
竞赛要按场次隔离,不能让 A 场次的选手收到 B 场次的广播。所以第一件事是按 sessionId 建房间:
@Component public class QuizWebSocketHandler implements WebSocketHandler { private static final Map<String, Set<WebSocketSession>> ROOMS = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String sessionId = extractSessionId(session.getAttributes()); ROOMS.computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet()).add(session); } // 分数变化时调用此方法广播 public void broadcastScore(Long sessionId, List<LeaderboardEntry> topList) { Set<WebSocketSession> sessions = ROOMS.get(String.valueOf(sessionId)); if (sessions == null || sessions.isEmpty()) return; String payload = objectMapper.writeValueAsString(topList); TextMessage message = new TextMessage(JSON.stringify(Map.of( "type", "SCORE_CHANGED", "data", topList, "timestamp", System.currentTimeMillis() ))); // 逐个发送,失败则移除该连接 sessions.forEach(s -> { try { synchronized (s) { s.sendMessage(message); } } catch (Exception e) { sessions.remove(s); } }); } }消息协议我固定为三段式:type 表示消息类型(SCORE_CHANGED / SESSION_FINISHED / QUESTION_CHANGED),data 是业务数据,timestamp 是服务器时间。前端拿到消息后先判断 type,再做对应处理。这样消息格式统一,后续加新类型不用改协议。
注意 synchronized (s) 这行,WebSocketSession 的 sendMessage 不是线程安全的,多个线程同时往一个连接发消息会抛异常。Redis 的分数变更监听线程和定时任务线程都可能触发广播,所以这里必须加锁。
4.3 断线重连与分数补偿
选手手机锁屏、地铁过隧道、Wi-Fi 切换,都会导致 WebSocket 断开。如果断开期间他答了题(HTTP 请求是通的),他的分数已经变了,但重连后拿到的排行榜还是旧的,体验非常割裂。解决方案是断线重连后做一次「分数补偿」——重新拉取全量排名和当前场次的答题进度。
function connectWebSocket(sessionId) { ws = new WebSocket(`wss://api.example.com/ws/quiz?sessionId=${sessionId}`) ws.onclose = () => { // 指数退避重连:1s -> 2s -> 4s -> 8s,最大 30s reconnectDelay = Math.min(reconnectDelay * 2, 30000) setTimeout(() => connectWebSocket(sessionId), reconnectDelay) } ws.onopen = () => { reconnectDelay = 1000 // 重连成功后重置延迟 // 补偿逻辑:通知服务器当前用户已就位,拉取全量状态 ws.send(JSON.stringify({ type: 'SYNC_REQUEST', sessionId, lastTimestamp: lastReceivedTimestamp })) } ws.onmessage = (event) => { const msg = JSON.parse(event.data) lastReceivedTimestamp = msg.timestamp if (msg.type === 'SCORE_CHANGED') updateLeaderboard(msg.data) if (msg.type === 'SYNC_RESPONSE') { // 全量补偿:覆盖本地榜单和当前题目索引 syncEntireState(msg.data) } } }参数说明:lastTimestamp 是前端本地最后一条消息的服务器时间戳,服务器收到 SYNC_REQUEST 后,会把该时间戳之后的所有消息重放给这个连接。这个「增量重放」方案比分页拉排行榜更精确,因为它能覆盖「重连期间分数多次变化」的情况。重连延迟用指数退避,避免弱网环境下几百个客户端同时疯狂重连,把服务器打垮。
5. 知识竞赛系统常见坑与排查:六个真实翻车现场
这一章全部来自真实交付中的血泪经验。知识竞赛系统看起来简单,但每一类坑都对应着一个明确的架构取舍失误。我按「现象 -> 原因 -> 解决」的格式写,方便你直接对照排查。
5.1 并发抢答分数错乱
现象:开赛前 3 秒大量选手同时提交第一题答案,后台日志发现某些选手的分数和他提交的答案对不上,有人答错了反而加分。
原因:最初的实现是先查答案,再在 Java 代码里算好分数,最后 UPDATE 用户总分。两个并发请求同时读到旧总分,分别加上自己的分数再写回,后写的把先写的覆盖了——典型的读改写竞态。
解决:把「计算分数」和「累加总分」两步合并为一条原子 SQL:UPDATE user_session_score SET total_score = total_score + #{score} WHERE user_id = #{userId},依赖 InnoDB 的行锁避免覆盖。查询分数只用于展示,不参与计算。Redis 的 INCR 也可以,但要处理好 Redis 与 MySQL 的双写一致性问题。
5.2 前端倒计时漂移
现象:选手反馈倒计时还剩 10 秒时突然跳变到 5 秒,还有人明明显示还有 3 秒,提交后却被判超时。
原因:前端用 setInterval 每秒减 1,浏览器切到后台后定时器被节流,回来时补执行了几次回调,视觉上就是跳变。更严重的是计时基准是「前端收到题目的时间」,网络慢的选手天然吃亏。
解决:放弃前端累计计时,改为「服务器下发绝对截止时间戳,前端算差值」。同时,提交判分时以服务器时间为准,不信任前端上传的剩余秒数。这两个方案双管齐下后,计时类投诉归零。
5.3 重复提交刷分
现象:有选手通过抓包重放提交接口,对同一道题反复提交,分数直线上升。
原因:接口层只做了用户登录校验,没有做「同一用户同一场次同一题只能提交一次」的幂等约束。
解决:数据库加唯一索引(user_id, session_id, question_id),让重复请求直接撞索引报错。同时在提交接口开头先查一次答题记录,有则直接拒绝并返回「该题已作答」。数据库唯一索引是兜底,代码判断是为了返回友好的提示文案,两者缺一不可。
5.4 大屏端轮询压垮后端
现象:现场有一块排行榜大屏,前端每 1 秒轮询一次/api/leaderboard,加上 300 名选手各自的 WebSocket 连接,开赛后后端 CPU 飙升,接口响应从 20ms 劣化到 800ms。
原因:大屏端的轮询完全没有必要,因为 WebSocket 广播已经覆盖了所有分数变化;而且大屏逻辑简单,重连成本极低,轮询属于「双通道重复消费」。
解决:大屏端接入同一个 WebSocket 房间,不做轮询。同时给排行榜接口加一层 5 秒的 Redis 缓存,即使有别的客户端误用轮询,也只会打到缓存而不是数据库。
5.5 选手切后台导致交卷延迟
现象:选手把页面切到后台去看其他应用,回来时倒计时已经归零,但系统没有自动提交,该选手整场比赛作废。
原因:浏览器对后台标签页的定时器做了节流,最小间隔被拉长到 1 分钟以上,前端「到时自动提交」的逻辑在后台根本不执行。
解决:前端在 visibilitychange 事件里检测到页面从隐藏变为可见时,立即触发一次过期检查:如果当前时间已超过截止时间,立刻调用提交接口(后端会正确判超时并计 0 分)。同时服务器侧再加一道保护:选手答题超时后,下次任何请求(比如切下一题)都先补记 0 分,再继续正常答题流程。
5.6 setInterval 内存泄漏与僵尸连接
现象:跑了一天的竞赛,后端内存持续上涨,查看 JVM 堆发现 WebSocketSession 对象数量远超在线人数。
原因:客户端直接关闭页面没有走关闭握手,服务器端的 onClose 没被触发;加上前端定时器没有在组件卸载时清理,导致 session 对象泄漏。
解决:后端增加心跳检测——服务器每 30 秒 ping 一次,连续 3 次无 pong 则主动关闭连接。前端在 Vue 组件 onUnmounted 里 clearInterval 并关闭 WebSocket。这两个措施配合后,僵尸连接的数量稳定在 0。
6. 进阶:防作弊、性能验证与部署前必做的三件事
做到这里,系统能跑、能实时排名、能断线重连,但离「能交付」还差一步:防作弊策略、真实验证手段、以及几个部署细节。这三件事做扎实,你的系统才敢面对真实的竞赛现场。
先说服自己:完全防作弊是不可能的,但知识竞赛系统只要把作弊成本提高到「不如老实答题」就够了。我的做法是三层叠加。第一层是题序打乱——每个选手拿到的题序由他的 user_id 加 session_id 做哈希种子,保证相邻选手的题序不同,抄同桌的答案行不通。第二层是时间审计——答题记录表里存上「题目下发时间、提交时间、前端上报的停留秒数」三个字段,赛后用 SQL 查出「提交时间差小于 3 秒且连续多题」的记录,这类异常数据拉出来人工复核。第三层是行为检测——记录选手切出页面的次数,切出超过 3 次标记为可疑,在后台管理页面展示给组织者。
性能验证这件事,我强烈建议不要用 JMeter 压「纯接口」,要压「真实链路」:WebSocket 连接数、Redis 读写混合、MySQL 写入并发。最小可复现的验证脚本是先开 500 个 WebSocket 连接保持在线,再用并发 200 的 HTTP 请求打提交接口。看两个指标:提交接口的 TP99 延迟(应小于 300ms)和 Redis 的内存增长曲线(应平稳,不能有持续上升)。如果 TP99 超标,优先检查是不是答案查询没有走缓存——每个选手提交时都会查一次正确答案,这个查询必须用本地缓存兜住。
最后三个部署细节,都是我用真金白银踩出来的。第一,Tomcat 的 maxThreads 要从默认的 200 调大到 500,否则 300 人同时提交接口时,HTTP 线程会先于业务代码被榨干。第二,MySQL 的连接池不要用默认的 HikariCP 配置跑竞赛——把 maximumPoolSize 从 10 调到 50,因为答题记录写入是高频操作,连接等待会拖垮 TP99。第三,Nginx 的 proxy_read_timeout 设置成 3600s,否则长轮询和 WebSocket 连接会被 Nginx 默认的 60s 阈值切断。
做这个项目最大的教训,是我第一次交付时把所有精力都花在了页面好不好看上,结果上线当天被并发打穿。后来我把同样的时间花在状态机、幂等、心跳检测这些「看不见的地方」,系统的故障率直线下降。希望这段内容能帮你少走一段弯路——毕竟知识竞赛系统真正的技术含量,从来不在答题页面的动画里,而在那几毫秒的原子操作和那几行防重逻辑里。希望帮到你。
本文还有配套的精品资源,点击获取