ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

限时考试系统高并发交卷设计:幂等与异步削峰实战

限时考试系统高并发交卷设计:幂等与异步削峰实战 “科目一离开的太快就像龙卷风”这句话在驾考考生里流传很广。考试时间归零的那一刻不管你最后一题选的是 A 还是 C也不管你有没有点“交卷”按钮页面都会瞬间跳走成绩随后弹出。很多考生的第一反应是我还没检查完怎么就结束了但如果你换个角度站在开发者的位置看这件事会发现这一阵“龙卷风”并不是产品经理的恶趣味而是一套限时在线考试系统在最紧张时刻必须完成的硬动作校验身份、比对时间、锁定答题卡、保存答案、计算成绩、落库持久化、把结果推回给客户端。整个链路要在短短几秒内完成还要保证系统不被同一秒涌入的交卷请求打垮。今天这篇文章不聊驾考技巧只聊一个更实际的问题如果让你来设计这套“到点强制离开”的在线限时考试系统你会怎么做真正的难点并不在答题过程中而在交卷那一刻。这是短时高并发、强一致、弱顺序要求的典型场景很多系统就是在这个瞬间被压垮的。1. 这篇文章真正要解决的问题先给一个明确判断限时考试系统的技术复杂度集中在“交卷”和“到点强收”这两个动作上而不在题库管理或试卷渲染。答题过程对服务端的压力是相对平稳的但交卷是突发性的。一场考试有几千人甚至几万人结束时间一到所有人几乎在同一秒点了交卷按钮服务端要同时处理海量写请求。这个峰值的形态很像一阵龙卷风来得快去得也快但破坏力集中。这类场景并不只在驾考理论考试里出现。限时秒杀、活动倒计时结束、开奖瞬间、限时优惠券结算、线上教学随堂测验业务形态不同技术挑战却高度一致到点那一刻有大量请求同时到达系统必须快速处理并且不能丢数据、不能重复计算、不能出现状态错乱。所以这篇文章要解决的实际问题可以拆成四条如何保证交卷是一个“一次且仅一次”的原子动作用户再怎么狂点按钮都不会产生多条成绩。如何把交卷瞬间的高并发写压力削平不要让判分逻辑堵塞在主线程里。如何处理“用户没有主动交卷、但考试时间已经到了”的强制交卷逻辑并且和主动交卷走同一套幂等机制。如何排查和解决交卷链路中最常见的故障重复提交、成绩丢失、状态卡死、消息积压。如果你是后端开发、系统架构师或者正在做考试 SaaS、在线教育、营销活动平台这篇文章值得收藏。它给的不是某个具体产品的源码而是这类限时系统在“结束瞬间”的通用设计思路。2. 基础概念一套在线限时考试系统的核心链路在深入代码之前先理清几个基础概念后面会反复用到。试卷快照考生点击“开始考试”时服务端把题目和选项生成一份不可变的快照和考生绑定。为什么强调快照因为题目可能被运营修改但已经开始的考试不能被改动否则成绩无法追溯。快照通常以 JSON 或明细表形式存储。答题卡考生本次考试的答题状态集合包括题目 ID、选项答案、是否已答、标记状态。它不是一次写入一条而是在答题过程中高频更新。交卷考生主动点击“交卷”或系统触发“到点强收”后服务端对答题卡进行封存然后进入判分流程。交卷一旦完成答题卡不能再修改。判分根据标准答案和答题卡计算出分数。可以同步做也可以异步做。在考试系统里我更推荐异步。成绩单判分结束后生成的最终结果包含总分、各题型得分、是否正确等明细。成绩单需要持久化并且要有快照备份。把这几个概念串起来就是一条完整链路考生身份校验通过后服务端创建考试记录生成全局唯一的attemptId。服务端下发试卷快照和考试开始时间、结束时间。客户端进入答题页答题过程中将答案写入缓存或增量提交。触发交卷主动点击、时间耗尽、网络断线重连后的服务端判定、管理员强制收卷。服务端校验考试状态生成或更新考试记录保存答题快照。进入判分环节计算得分更新记录状态。推送或由客户端轮询得到最终成绩。这个链路看起来不复杂但每个环节都有自己的坑。为了讲清楚差异我把它和普通请求系统、秒杀系统做一个对比。系统类型请求特征一致性要求峰值特征普通 Web 系统请求平稳读写比例不定只要做到常规一致性没有明显尖峰秒杀系统瞬时高并发读、低成功概率写不允许超卖扣减必须精确秒级尖峰峰值极高限时考试系统考试期间低并发写交卷瞬间高并发写一条考试记录只能有一个最终成绩结束后瞬间出现写尖峰可以看到限时考试系统的核心矛盾是平时压力不大所以很多团队不重视架构但交卷瞬间的峰值非常高如果前期没有设计好削峰、幂等和兜底逻辑往往会在这个时间点出故障。这个故障的代价不是损失一笔订单而是整场考试成绩作废后果严重得多。3. 为什么“龙卷风”会刮起来交卷瞬间的三个致命场景很多开发者第一次接触限时考试系统时会觉得“到点交卷不就是写一条 update 语句吗”。如果只做 Demo 确实如此但在真实场景下至少有三个场景会让这条 update 语句变成事故现场。3.1 主动交卷和强制交卷几乎同时发生考试时间到了A 考生提前 0.1 秒点击了“交卷”按钮请求在网络中传输同一时刻服务端的定时任务扫描到了这条仍处于“答题中”的记录触发了强制交卷逻辑。两个动作同时进入如果没有唯一的业务键和状态约束就可能出现两次处理一次状态置为“已交卷”另一次又尝试更新最终导致成绩重复计算或者状态被覆盖。这个问题的本质是并发控制不是代码顺序问题。处理方法不能依靠“定时任务晚点跑”而要让整个交卷流程具备幂等性不管谁来触发同一个attemptId只能进入判分流程一次。3.2 全考场同一秒提交带来的写放大假设一场考试有 5000 人考试结束后 1 秒内4000 人点击交卷。每一个交卷请求都要做这些事检查考试记录、更新状态、保存答题快照、计算成绩、更新成绩单。如果方案设计成同步执行4000 个请求会同时占满数据库连接池和 Tomcat 线程池。情况会更糟如果判分逻辑还要逐题比对答案每次交卷执行几十次查询数据库的连接数会迅速耗尽其他请求也会跟着被阻塞。这就是“一个交卷高峰拖垮整个服务”的典型原因。解决办法是削峰和异步化交卷请求只需要完成状态锁定和答案快照保存然后把判分任务放入队列由消费者异步处理。客户端那边看到的是“试卷已提交成绩计算中”几秒后再查询到成绩。3.3 网络中断和服务重启导致的状态不确定还有一种情况经常被忽略考生点了交卷但请求在半路因为网络问题丢失或者服务端已经收到请求正在处理时应用突然重启。此时客户端和数据库的状态是不一致的考生要么看到“交卷失败”要么一直停留在答题页但数据库里其实已经保存了答案。这种情况必须用补偿机制解决。服务端要有定时扫描任务把所有“到点仍未交卷”或“状态卡住”的记录找出来重新进入交卷流程。同时交卷流程本身必须支持重复执行也就是幂等。这样才能保证最终一致无论中间出什么岔子只要记录存在就一定会被推到终态。4. 环境准备与前置条件下面我用一个简化示例来演示限时考试系统的交卷设计。技术栈选择 Spring Boot Redis MySQL JDBC消息队列用 Redis List 模拟。这个组合在真实项目中很常见也便于演示。版本号请以你实际项目为准本文重点演示通用思路。先创建一个 Spring Boot 工程加入以下依赖。如果你使用 Maven在pom.xml中添加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency如果你的项目里已经有 Redis 和 MySQL 的配置这一步可以跳过。如果是从零开始在application.yml中配置数据源和 Redisspring: application: name: exam-service datasource: url: jdbc:mysql://localhost:3306/exam_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: change_me redis: host: localhost port: 6379 task: scheduling: pool: size: 4 exam: duration-minutes: 45 force-submit-batch-size: 200 result-ttl-seconds: 1800这里的exam.force-submit-batch-size用来控制强制交卷扫描时的单批处理数量避免一次性拉太多记录导致负载升高。exam.result-ttl-seconds是交卷结果在 Redis 中的过期时间防止内存无限增长。接下来建两张核心表考试记录表和答题快照表。真实项目中还会有用户表、试卷表、题目表本文只保留演示所需的核心结构。CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, attempt_id VARCHAR(64) NOT NULL, user_id VARCHAR(32) NOT NULL, paper_id VARCHAR(32) NOT NULL, status TINYINT NOT NULL COMMENT 0答题中 1已交卷 2判分中 3已完成 4异常, score DECIMAL(5,2) DEFAULT NULL, submit_time DATETIME DEFAULT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, UNIQUE KEY uk_attempt_user (attempt_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE exam_answer_snapshot ( attempt_id VARCHAR(64) NOT NULL, question_id VARCHAR(32) NOT NULL, answer VARCHAR(512), PRIMARY KEY (attempt_id, question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意exam_record表给(attempt_id, user_id)加了唯一索引。这一步很关键它能在数据库层面挡住重复交卷是幂等设计的最后一道防线。5. 核心实现交卷主流程如何做到“一次且仅一次”交卷主流程的设计目标很明确无论请求来自主动点击、超时强制、断线重连后的补交还是用户重复点击同一个尝试只能被处理一次。实现思路是“业务幂等键 Redis 原子操作 数据库唯一索引”三层兜底。5.1 幂等键与状态机每个attemptId在开考时就生成全局唯一。一次考试可以看作一条状态机答题中 - 已交卷 - 判分中 - 已完成。交卷动作要做的是把状态从“答题中”推进到“判分中”不能允许从“已完成”回退到“判分中”。第一步在 Redis 里使用setIfAbsent设置交卷标记。这是原子操作并发环境下只有一个请求能成功。谁成功谁就进入后续流程其他请求直接返回已有结果或“判分中”状态。5.2 服务端时间统一校验考试系统的计时不能用客户端时间因为客户端时钟可能不准确也可能被修改。正确做法是开考时服务端计算endTime交卷时服务端用当前时间与endTime比较。即使请求因为网络延迟晚到了也不拒绝按“超时交卷”处理但答案可以保留避免误伤。5.3 控制层代码示例在ExamController中只做参数接收和身份获取业务逻辑放到 Service 层RestController RequestMapping(/api/exam) public class ExamController { private final ExamSubmitService submitService; public ExamController(ExamSubmitService submitService) { this.submitService submitService; } PostMapping(/submit) public ResultSubmitResponse submit(RequestBody SubmitRequest request) { // 实际项目中从登录态获取 userId不能信任前端传参 String userId SecurityContextHolder.getUserId(); SubmitResponse response submitService.submit(userId, request); return Result.ok(response); } }请求对象包含attemptId、答案列表和客户端结束时间public class SubmitRequest { private String attemptId; private MapString, String answers; private Long clientEndTime; // getter、setter 省略 }5.4 Service 层核心逻辑在ExamSubmitService中交卷流程分成几个清晰的步骤Service public class ExamSubmitService { private final StringRedisTemplate redisTemplate; private final JdbcTemplate jdbcTemplate; public ExamSubmitService(StringRedisTemplate redisTemplate, JdbcTemplate jdbcTemplate) { this.redisTemplate redisTemplate; this.jdbcTemplate jdbcTemplate; } public SubmitResponse submit(String userId, SubmitRequest request) { String attemptId request.getAttemptId(); // 第一步Redis 幂等标记原子操作保证只有一个请求能继续 Boolean first redisTemplate.opsForValue() .setIfAbsent(exam:submit: attemptId, PENDING, Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(first)) { // 已经提交过直接返回当前结果 return queryCurrentResult(attemptId); } // 第二步以服务端时间为准判断是否超时 long serverEndTime getServerEndTime(attemptId); if (System.currentTimeMillis() serverEndTime) { // 超时也继续保存答案但标记为强制交卷 } // 第三步保存答题快照状态从答题中改为已交卷 saveAnswerSnapshot(attemptId, userId, request.getAnswers()); updateRecordStatus(attemptId, 1); // 第四步把判分任务放入队列异步处理 redisTemplate.opsForList().leftPush(exam:score:queue, attemptId); return SubmitResponse.pending(attemptId); } }这段代码里有几个细节值得展开。setIfAbsent是关键。它返回Boolean只有第一次调用会返回true后续重复请求都拿不到处理权。这样就算用户连续点了十次“交卷”也只有第一个请求会真正进入保存答案的流程其余请求直接走queryCurrentResult查询结果。getServerEndTime是从数据库读取该次考试的服务端结束时间通常开考时就已经写入。这个时间不受客户端影响。如果客户端上报的时间和服务器不一致以服务端为准。保存快照时建议把attemptId userId作为条件执行 update而不是无条件 update。SQL 可以写成int updated jdbcTemplate.update( UPDATE exam_record SET status 1, submit_time NOW() WHERE attempt_id ? AND user_id ? AND status 0, attemptId, userId);这里为什么要加status 0条件因为一旦记录已经处于“已交卷”或“判分中”状态就说明已经处理过不能再次覆盖。updated为 0 时说明状态已经改变本次操作不再继续。这相当于在数据库级别再做了一次状态校验。5.5 数据库唯一索引的作用如果 Redis 中的幂等标记因为某种原因丢失了比如 Redis 被清空那么setIfAbsent就拦不住重复请求了。此时数据库唯一索引uk_attempt_user就派上用场即使两个请求同时到达第二个请求在插入或更新时必然被唯一索引拦截不会产生重复记录。所以完整方案是三层防护Redis 原子操作挡住大部分重复请求状态机更新挡住已处理记录数据库唯一索引做最终兜底。三层不是冗余而是应对不同故障场景。6. 交卷洪峰下的削峰与异步判分回到 3.2 描述的场景交卷瞬间几千个请求同时到达。如果交卷接口里同步执行判分会产生两个问题。第一判分需要读取大量答案快照和标准答案逐题比对假如每题 50 道一次判分要执行几十次查询。同步执行时这些查询会占用数据库连接而交卷高峰期的并发请求会把数据库连接池打满。第二判分是高 CPU 操作同步执行会占满 Web 容器线程导致其他接口无响应。所以交卷接口的主链路应该尽量短校验、存快照、入队、返回。真正的判分耗时放到异步消费者中完成。6.1 异步判分消费者下面用Scheduled实现一个最简单的队列消费者。生产环境可以替换为 RocketMQ、Kafka 或 RabbitMQ但核心逻辑是一样的从队列取任务加锁防止重复消费执行判分落库释放锁。Component public class ScoreConsumer { private final StringRedisTemplate redisTemplate; private final JdbcTemplate jdbcTemplate; public ScoreConsumer(StringRedisTemplate redisTemplate, JdbcTemplate jdbcTemplate) { this.redisTemplate redisTemplate; this.jdbcTemplate jdbcTemplate; } Scheduled(fixedDelay 1000) public void consume() { String attemptId redisTemplate.opsForList().rightPop(exam:score:queue); if (attemptId null) { return; } try { Boolean locked redisTemplate.opsForValue().setIfAbsent( exam:score:lock: attemptId, 1, Duration.ofMinutes(2)); if (!Boolean.TRUE.equals(locked)) { return; } doScore(attemptId); } catch (Exception e) { // 生产环境需要记录日志并告警这里简化处理 redisTemplate.opsForList().rightPush(exam:score:queue, attemptId); } } private void doScore(String attemptId) { // 读取答题快照逐题判分计算总分更新 exam_record // 状态更新为已完成 } }这里有一个常见误区为什么用rightPop还要加锁rightPop本身是从队列取出看起来只有一个消费者能拿到但消息发出后消费者可能还没处理完就宕机了任务就丢了。更稳妥的做法是使用可靠的消费确认机制或者把状态一直标记为“判分中”由超时扫描任务兜底。加锁是为了防止同一任务被重复放进队列后重复判分。判分过程的伪代码先从数据库读取exam_answer_snapshot读取标准答案表逐题比对得到总分然后更新exam_record.score和status。由于整个判分过程可能涉及多次查询要控制事务长度避免长事务锁住数据库表。6.2 客户端轮询结果异步判分意味着客户端不能立刻拿到成绩。常见做法是交卷接口返回judging状态客户端每 2 秒轮询一次成绩查询接口如果状态变成“已完成”则展示成绩单。这个查询接口同样要使用attemptId幂等简单示例GetMapping(/result) public ResultResultResponse getResult(RequestParam String attemptId) { // 查询 exam_record返回 status、score 或“判分中”提示 }轮询虽然简单但要注意频率。每 2 秒一次、最多轮询 30 次是常见配置。如果轮询超过次数客户端应提示“成绩计算中请稍后刷新”而不是无限请求。6.3 Redis 缓存结果的作用判分结果可以先写入 Redis 再写入数据库也可以只写数据库。考虑到判分完成后考生和前端最关心的就是成绩而查询量在交卷后几分钟内会形成一个小高峰建议把最终成绩以attemptId为 key 缓存到 Redis设置过期时间减轻数据库查询压力。缓存写入要放在判分成功之后不要放在判分之前。否则会出现缓存里有成绩、数据库里还是“判分中”的不一致情况。如果数据库更新失败缓存也要回滚或等待下一次更新覆盖。7. 到点强制交卷定时扫描与补偿机制异步判分解决了交卷高峰的削峰问题但还有一个关键场景没覆盖有些考生到时间后没有点击交卷甚至因为网络原因已经断线服务端必须主动把这类考试状态推进到“已交卷”。7.1 强制交卷的触发条件强制交卷不是简单地把end_time和NOW()比较而是要考虑状态机。只有status 0答题中且end_time NOW()的记录才需要强收。已经处于“已交卷”或“判分中”的记录即使end_time已过也不能再动。7.2 定时任务示例用一个固定频率的定时任务扫描过期记录把attemptId推入待办队列然后由交卷组件统一处理。这样可以复用主动交卷的整套逻辑Component public class ForceSubmitJob { private final JdbcTemplate jdbcTemplate; private final StringRedisTemplate redisTemplate; public ForceSubmitJob(JdbcTemplate jdbcTemplate, StringRedisTemplate redisTemplate) { this.jdbcTemplate jdbcTemplate; this.redisTemplate redisTemplate; } Scheduled(cron 0 * * * * *) public void forceSubmitExpiredRecords() { ListString expiredAttemptIds jdbcTemplate.queryForList( SELECT attempt_id FROM exam_record WHERE status 0 AND end_time NOW() LIMIT ?, String.class, 200); for (String attemptId : expiredAttemptIds) { redisTemplate.opsForList().leftPush(exam:submit:force, attemptId); } } }这个示例里每分钟扫描一次LIMIT 200 是为了控制单次扫描数量。生产环境建议使用分片扫描比如按照attempt_id的哈希值拆成多个分片让多个定时任务节点分别扫描不同分片避免单实例扫描全表。7.3 强制交卷处理组件强制交卷组件从exam:submit:force队列中取出attemptId读取该考生的答题缓存然后走和主动交卷相同的保存快照、入判分队列流程。由于前面已经设计了幂等键强制交卷和主动交卷同时触发时不会产生冲突。需要额外处理的是如果考生一直在答题但从未保存过答案比如全程断线那么强收时并没有答题快照可保存。此时不能报错而应该生成一份空答题快照正常进入判分流程分数为 0。这样才能保证成绩单的完整性考生即使在极端情况下也有成绩可查。7.4 状态卡死与超时补偿还有一种情况交卷请求已经提交状态变为“判分中”但消费者进程还没处理完就重启导致记录永远卡在“判分中”。这需要另一个超时扫描任务找出状态为“判分中”且更新时间超过一定阈值的记录重新放入判分队列。这里要小心重放判分任务可能造成重复判分。处理办法是沿用之前的 Redis 锁设置合理的过期时间。如果任务处理时间超过锁过期时间需要续期否则可能出现两个消费者同时判分同一份试卷。更稳妥的做法是在数据库里增加一个score_version字段每次判分前比较版本号只有版本号符合时才允许更新。8. 运行结果与效果验证写代码只是第一步更重要的是验证这套设计在真实压力下是否有效。下面用一个最小实验来验证“不丢不重”的目标。8.1 构造实验数据先准备一张考试记录表插入 200 条答题中记录start_time已经过去end_time已经过去保证这些记录可以被强制交卷任务扫描到。实际环境中应该由开考接口自动创建这里为了验证手动插入测试数据即可。INSERT INTO exam_record (attempt_id, user_id, paper_id, status, start_time, end_time) SELECT CONCAT(att-, LPAD(id, 4, 0)), CONCAT(u, LPAD(id, 4, 0)), paper-001, 0, DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 1 MINUTE) FROM (SELECT 1 AS id UNION SELECT 2 UNION SELECT 3 ... );实际项目中不会这样拼 SQL但作为实验数据是可以的。核心是让这些记录满足“答题中、已超时”的条件。8.2 并发模拟交卷请求用 Python 脚本向提交接口发送 200 个并发请求模拟交卷瞬间的压力import threading import requests BASE_URL http://localhost:8080/api/exam/submit def do_submit(user_id, attempt_id): payload { attemptId: attempt_id, answers: { 1001: A, 1002: B, 1003: C }, clientEndTime: 1710000000000 } try: resp requests.post(BASE_URL, jsonpayload, timeout5) print(user_id, resp.status_code, resp.text) except Exception as exc: print(user_id, error, exc) threads [] for i in range(200): t threading.Thread( targetdo_submit, args(fu{i:03d}, fatt-{i:04d}) ) threads.append(t) t.start() for t in threads: t.join()注意这个脚本假设数据库里已经有对应的att-0000到att-0199记录且用户 ID 也是u000到u199。如果你的表结构不同需要调整字段。脚本的作用是模拟同一秒内 200 个交卷请求用于验证系统不会因为重复点击或并发出现重复成绩。8.3 验证结果请求全部发送完成后执行以下 SQL 检查数据状态SELECT status, COUNT(*) FROM exam_record GROUP BY status; SELECT COUNT(*) FROM exam_record WHERE status 3; SELECT COUNT(DISTINCT attempt_id) FROM exam_record;预期结果所有attempt_id的最终状态都应该是3已完成或者少数还在2判分中但经过消费后最终会变成3。COUNT(DISTINCT attempt_id)应该等于总记录数即没有重复的attempt_id。每个attempt_id只有一条成绩记录没有重复成绩。如果发现状态为4异常的记录说明有部分请求在处理过程中抛了异常。此时第一步应该去看应用日志中对应attemptId的堆栈而不是猜测。如果是消息队列消费失败导致的状态卡住重点检查redisTemplate.opsForList().rightPop()附近是否有异常被吞掉。8.4 强制交卷的验证手动把某条记录改回status 0并且时间已超时然后等待强制交卷定时任务执行UPDATE exam_record SET status 0, score NULL WHERE attempt_id att-test-001;等待一个调度周期后再次查询这条记录。如果状态被推进到“已交卷”或“判分中”说明强制交卷任务生效。如果状态仍然为 0检查定时任务的cron表达式、扫描 SQL 条件、以及exam:submit:force队列是否被正常消费。9. 常见问题与排查思路下面把限时交卷链路中最常见的故障整理成一张排查表遇到问题时可以直接对照。问题现象可能原因排查方式解决方案考试时间到了用户还能继续答题服务端没有强制扫描任务或客户端定时器不准查看数据库记录是否已超时查看定时任务日志启动强制交卷任务以服务端时间为准用户重复点击交卷生成了多条成绩幂等键失效或缺少唯一索引查看 Redis 幂等 key 是否存在查看数据库唯一索引使用 setIfAbsent 加数据库唯一索引双保险交卷后成绩一直不出判分队列积压或消费者异常查看 Redis List 的队列长度查看消费者日志增加消费者线程检查异常日志状态卡在“判分中”超过 10 分钟消费者进程重启任务丢失查询状态为 2 的超时记录增加状态超时扫描任务重新入队强制交卷后没有成绩强收时生成空快照失败查看强收组件日志空答题快照也要正常落库避免成绩缺失交卷高峰期数据库连接池满判分逻辑同步执行占用了大量连接查看数据库连接池监控改为异步判分控制事务长度重复请求返回结果不一致状态更新未加条件检查 update 语句是否带 status 条件使用状态机更新加status 当前状态条件排错的大原则是先确认数据状态再确认 Redis 状态最后看应用日志。限时考试系统里状态字段本身就能说明很多问题。如果你发现某条记录状态不合理不要急着改数据先把时间线还原出来考试开始时间、结束时间、请求到达时间、任务消费时间、状态更新时间这些时间点对齐后问题基本就浮出水面了。10. 最佳实践与工程建议最后把前面讲的设计原则沉淀成几条工程建议方便你在实际项目中直接用。10.1 所有计时以服务端为准客户端只展示服务端下发的剩余时间不参与逻辑判断。网络延迟、客户端休眠、系统时间修改都不应该影响考试结束时间。如果使用 WebSocket 或轮询同步剩余时间服务端要定期下发新的serverTime让客户端校准。10.2 幂等键必须全局唯一且自动生成attemptId应该在服务端开考时生成使用 UUID 或雪花算法不能由前端拼接。因为前端生成的值可能重复而且容易被篡改。所有交卷相关接口都必须接收attemptId并把它作为幂等判断的第一依据。10.3 强收任务要分片避免单机扫描全表数据库中可能积压大量历史考试记录如果定时任务每次都扫描全表性能会越来越差。建议按attempt_id的哈希值分片每个节点只扫描自己的分片。同时给(status, end_time)建联合索引让扫描走索引而不是全表扫描。10.4 队列消费要做到“至少一次”靠幂等纠偏分布式环境下消息队列很难做到“恰好一次”。更现实的目标是“至少一次”也就是消息有可能重复消费但系统不能因为重复消费产生错误结果。前面的exam:score:lock和版本号机制就是为了解决这个问题。不要试图在消息层面消灭重复而要在业务处理层面做到幂等。10.5 监控指标要聚焦在四个地方限时考试系统的监控不需要特别复杂重点盯住四个指标Redis 判分队列长度、执行强制交卷任务的延迟、状态为“判分中”的超时记录数、交卷接口的 P99 响应时间。这四个指标能覆盖大部分故障场景。队列积压说明消费能力不足强收任务延迟说明扫描逻辑有问题状态卡死说明消费者异常接口响应变慢说明主链路可能被阻塞。10.6 升级或回滚要走灰度考试系统的特殊性在于交卷高峰只在考试结束后的很短时间出现。如果在这个时间段发布新版本风险很高。建议做到考试开始前完成发布距离考试结束 30 分钟内不发布新代码如果必须回滚优先回滚强收任务和判分消费者而不是整体回滚。回滚前要保留日志方便定位问题。10.7 防作弊是另一条线不建议和交卷主流程耦合本文没有展开讲防作弊但有一点要提醒防作弊逻辑如果放在交卷主链路里会拖慢交卷速度甚至影响正常考生。更合理的做法是异步做行为分析比如切屏次数、答题速度异常、IP 异常考试结束后再统一审查。交卷主流程只负责保证数据被可靠保存判定是否作弊交给后台系统。总结回到开头的话题。科目一“离开得就像龙卷风”是业务侧的设计它必须让所有考生在同一时间点结束考试但系统侧的工程师要做的事情恰恰相反我们要让这场“龙卷风”在技术上变得可控用幂等设计挡住重复提交用异步队列削平交卷洪峰用强制扫描兜住漏网记录用状态机保证数据不会错乱。如果你正在做在线考试、限时活动或者任何有“倒计时结束后集中处理”逻辑的系统这套设计思路可以直接复用。建议你从最小闭环开始先做好交卷主流程的幂等再考虑异步判分最后补上强制交卷和超时补偿。把一个流程用透比一次引入一堆中间件更有价值。建议收藏备用等下次再做限时系统时对照这篇文章检查一遍你的交卷链路看看能不能扛住那一阵“龙卷风”。
返回列表