这段时间我一直在梳理一套分布式在线考试系统的重构方案改动最大的部分是把整条考试业务链路从同步调用切成事件处理。为什么会有这次重构原因特别直白系统里接入了基于 Spring AI 的自动判卷能力AI 推理要时间主观题打分要时间如果交卷接口还按老办法同步处理一个考生交卷就会卡住几十秒请求量稍微上来一点整个考试服务就会被拖垮。这篇内容适合正在做在线教育、考试平台、或者任何“AI 异步判分”场景的开发者参考我把完整的事件处理架构、消息契约、可靠性方案和踩坑记录都整理出来了。事件驱动这个方案不是银弹它的收益和代价都很明显。但如果你的系统里已经出现了“一个接口里串联了太多慢服务”的迹象那这篇文章里的大部分思路可以直接抄到你的项目里。1. 考试系统什么时候必须走向事件驱动1.1 一次交卷背后的连锁调用在没有做异步化改造之前交卷动作的流程非常简单粗暴考生点击交卷后端接口收到请求然后开始按顺序执行一系列操作。首先要保存考生填写的所有答案这部分是关系库写入本身并不慢。接下来要重新计算客观题得分因为客观题答案可以和标准答案直接做比对。再接下来就是主观题判卷在引入 Spring AI 之后主观题部分会把考生答案、题目原文、评分标准一起组装成 prompt发送给模型服务然后等待模型返回分数和评语。这个过程是典型的“慢外部 I/O”网络往返加推理时间通常需要几秒甚至几十秒。等模型结果回来之后系统还要更新总分重新按知识点聚合错题生成成绩分析报告最后再通过推送或者页面查询把结果展示给学生。从功能视角看没有任何问题每个环节都必要。但从系统稳定性视角看这是灾难一次交卷请求把数据库写入、外部 AI 调用、内存聚合计算、消息通知全部串在一个 HTTP 请求里任何一个环节慢都会占用 Tomcat 线程池里的一个线程。假设并发交卷人数到了 200AI 接口平均响应时间是 5 秒那这 200 个线程就会被活活“挂”住 5 秒。线程池一旦被打满后续请求全部排队最终表现就是系统卡死、接口超时、数据库连接池耗尽。我印象很深的一次故障是排查跟踪日志时发现的某个时间段内所有交卷接口的耗时都到了 30 秒以上但 AI 调用本身的平均耗时只有 4 秒。原因很简单不是单次调用慢而是线程都在排队等着执行调用。系统资源不是不够是被同步等待机制浪费了。这个问题靠加机器能缓解一时做不了本质解决。1.2 同步架构暴露的三个致命问题这类系统里最典型的三个问题我在很多项目里反复见过。第一个是长事务问题。交卷接口里只有保存答案那一小段真正在操作数据库但 AI 判卷的结果也要放到同一个“请求级事务”的边界里思考。一旦 AI 调用异常要么整个事务回滚连考生已提交的有效答案都一起丢了要么处心积虑做部分提交事务边界模糊不清。实际上 AI 判卷根本没有理由跟答案保存放在同一个事务里。第二个是资源占用极度不均衡。保存答案、客观题判分属于毫秒级操作AI 推理属于秒级操作把两者放在同一个线程里执行等于让整个系统按照最慢环节的速度运行。所有原本可以立刻返回的结果都要陪着 AI 一起等。这里的资源浪费不只是用户体验层面的还有成本层面的线程占用、数据库连接占用、内存占用全部成倍放大。第三个是扩展性受限。同步调用模式下考试服务的扩容只能“整体扩容”。你想单独给 AI 判卷这个环节增加处理能力做不到因为它不是独立部署的单元。想单独把成绩聚合的逻辑拆出去也做不到因为它跟接口请求绑定在一起。系统越大这种耦合带来的改造阻力就越明显。1.3 选择事件驱动前要想清楚什么我自己不太赞成一上来就铺开事件风暴搞大设计这个题目的正确做法是先从痛点出发找到最不能忍的链路再局部引入异步。我的判断标准很简单只要链路里有“外部 AI 调用”或者有“多个下游都不需要即时返回结果”就值得做事件化。反过来如果只是一个小型考试模块交卷的人少主观题判分也不依赖 AI那同步接口完全够用别折腾。另外事件驱动意味着系统从“强一致”变成“最终一致”。考试结果不会在交卷那一瞬间完整出现在考生面前而是会先显示“试卷已提交正在批改中”过一会儿再刷新出分数。产品层面必须接受这个改动否则技术再合理也推不下去。我在这类重构里最常说的一句话是分布式系统里接口不会骗人但产品预期会骗人。把交互文案和状态提示设计好最终一致性完全可以被用户平滑接受。2. 事件如何分层从考试状态到答题行为2.1 事件分类不能只靠一个 eventType很多系统做事件化时最后都做成了一个“大杂烩”所有事件都往同一个 topic 里塞消费者拿到消息后写一长串 if-else 分辨类型。短时间能用等事件种类超过十个之后排查问题会变成噩梦。我建议在考试系统里把事件按业务域分成三组考试会话事件、答题行为事件、AI 判卷事件。每一组事件天然有独立的消费场景和运维方式分开建模比混在一起清晰得多。考试会话事件负责描述一次考试从创建到归档的完整生命周期典型事件包括“考试创建”“考试发布”“考生确认到场”“考试开始”“试卷提交”“考试强制结束”“成绩发布”。这类事件的特征是状态性强它们之间有明显的前后顺序每个事件都会改变考试实例的当前状态。答题行为事件负责描述考生在做题过程中的具体动作比如“进入下一题”“答案暂存”“答案修改”“题目切换”。这类事件量很大但单个事件对实时性要求不高适合批量收集用于后续的异常行为分析、答题时间统计和 AI 辅助学情分析。AI 判卷事件则是这套系统里比较特殊的一组它们是在 Spring AI 接入后才出现的比如“判卷任务创建”“AI 评分完成”“AI 评分失败”“人工复核完成”。这类事件产生方和消费方可能在同一个服务内也可能跨服务需要单独设计状态流转。用表格来看可能会更直观事件分组代表事件主要产生方主要消费方考试会话事件考试开始、试卷提交、考试成绩归档考试网关服务状态管理服务、消息通知服务答题行为事件答案暂存、题目切换、答案提交考试网关服务行为分析服务、题目推荐服务AI 判卷事件判卷任务创建、AI 评分完成判卷调度服务成绩聚合服务、复核工作台2.2 事件消息体要有一个统一模板事件消息体设计是这套架构里最容易被低估的环节如果每个业务的字段定义都靠“约定”后面一定会有人踩坑。我在项目里定了一个公开的消息模板所有事件必须包含五部分事件唯一 ID、事件类型、发生时间、业务负载、链路追踪 ID。事件唯一 ID 用于幂等处理重复消费的时候靠它去重。事件类型用点分格式表达完整分类例如exam.paper.submitted。发生时间必须是事件真实发生的时间不是消息落库时间这能有效避免时间偏差问题。业务负载是具体的业务字段允许随时间扩展但一定不能复用payload字段承载两种含义完全不同的数据。链路追踪 ID 是排查问题时的命脉它把网关入口、事件发布、消费者处理串联成一条完整链路。下面是exam.paper.submitted事件的一个精简示例实际项目中我会再扩充一些字段但基础结构保持不变{ eventId: 5a1e392a-6b11-4d8f-9f10-9f8c4f8f4a12, eventType: exam.paper.submitted, occurredAt: 2026-03-18T10:25:36.128Z, traceId: trace-20260318-10928374, payload: { examId: EX20260318001, paperId: P20260301, studentId: STU202600128, submitType: manual, submitTime: 2026-03-18T10:25:30.000Z, answerStoreId: ANS20260318-00921 } }注意负载里的answerStoreId这是指向已落库答案的引用不是把整套答案 JSON 塞进事件里。消息中间件不是数据库如果每道主观题的判卷事件都把完整答案带上消息体积会膨胀好几倍Topic 积压时内存和磁盘都会跟着遭殃。2.3 顺序、分片和事件版本约定分散式事件系统里最容易出的问题就是消息乱序考试系统对此尤其敏感。考生修改答案的事件顺序只要反了最终落库的答案就是错误的。要解决乱序核心手段靠分片键。消息中间件允许某个主题拆成多个分区同一个分区内的消息能保证顺序。对于同一个考生的同一个考试必须让所有相关事件落到同一个分区。我通常用examId studentId作为分片键因为这两个字段组合起来能唯一确定一个考生在一个考试里的所有行为。事件版本约定也很关键。一个事件定义在发布之后几乎必然要演进要么加字段要么改语义。我要求在事件体里显式声明eventVersion初始版本是 1新增字段时版本号加一。消费者拿到事件后要容忍“未来版本”的字段也就是反序列化时不能因为多出字段就报错。这个约定看着简单但在多人协作的项目里能省掉大量线下对齐成本。3. Spring AI 判卷链路的事件化改造3.1 AI 评分为什么不放进 HTTP 链路先把一个很反直觉的事实说清楚Spring AI 框架本身的调用开销并不大真正耗时的大头是它背后接的模型服务。模型推理需要时间网络传输需要时间在大模型场景下一次调用跑几秒到几十秒都很常见。把这道题放回考试系统里考生交卷后系统需要把一道主观题的答案、题目原文、参考答案、评分细则组装成一句话发送给模型服务然后听 Model 输出结果。这个过程是不能被“优化”到毫秒级的因为推理时间不受我们的业务系统控制。如果把 AI 评分放进 HTTP 链路里请求线程要一直占着等结果这跟“打一次饭却占着食堂一个窗口”是同一个道理。事件化之后交卷接口只负责两件事保存答案、把“提交”事件发到队列。真正调用 AI 评分的环节挪到消费者里异步执行交卷接口立刻返回线程释放用户体验也从“一直转圈等结果”变成“提示已交卷正在判卷”。3.2 用事件消费者接 AI 判卷任务在我的设计里AI 判卷不是直接消费原始的交卷事件而是由判卷调度服务消费exam.paper.submitted事件然后按题号拆分出多个独立的判卷任务每个判卷任务再作为一个事件发送到判卷主题。这样做的原因是一份试卷通常有多道主观题如果一次处理整份试卷消费者线程会被最长的一道题卡住拆细后每道题的判卷进度独立可控部分失败也只需要重判那一题。消费者核心逻辑大概长这样Component public class AiMarkingJobConsumer { private final AiGradingService gradingService; private final ConsumedEventRepository consumedEventRepository; private final MarkingTaskPublisher taskPublisher; public void onMarkingTaskReceived(MarkingTaskEvent event) { String eventId event.getEventId(); if (consumedEventRepository.isDuplicated(eventId, ai-marking-job)) { return; } try { AiGradingResult result gradingService.grade( event.getQuestionId(), event.getAnswerContent(), event.getRubric() ); taskPublisher.publishGradingCompleted(event, result); consumedEventRepository.markConsumed(eventId, ai-marking-job); } catch (AiServiceException ex) { // 交给重试机制处理这里不吞异常 throw ex; } } }消费者里的“先查重、再处理、后标记”三个步骤是铁律。这一步如果做漏了后面第四章讲的幂等方案就形同虚设。这里还需要注意模型调用不像普通数据库操作它没有事务概念。模型已经执行完推理但消息中间件在消费者返回之前又投递了一次同一条消息这时候必须靠事件 ID 和消费记录表来判断“这个判卷结果我已经处理过了”。不要尝试依赖“消息确认”来保证只处理一次——全部消息系统都是至少一次投递没有哪一款能天然保证精确一次。3.3 判卷状态机与结果回写一份试卷从提交到成绩发布内部状态不能只有一个“进行中”否则出问题时很难定位到底卡在哪一步。我设计的判卷状态机有五个状态提交完成、判卷排队中、AI 评分中、待人工复核可选、成绩已归档。正常情况下状态从“提交完成”走到“AI 评分中”再由“成绩已归档”结束。当 AI 评分置信度低于阈值或者模型返回格式不合法时会跳到“待人工复核”状态。AI 评分结果回写也不能直接更新总成绩表必须先写入“评分明细表”把每道题的得分、判定依据、模型耗时、消息事件 ID 都留痕。之后再产生一个exam.ai.score.completed事件由成绩聚合服务读取明细并汇总。这样做最大的好处是审计链路完整考生如果对分数有异议可以调出某个事件对应的一条模型判卷记录比一张只有最终分数的表有说服力得多。4. 不丢消息、不乱顺序最终一致性的落地做法4.1 生产端用本地消息表兜底先承认一个事实消息中间件自己在网络分区、进程崩溃时也可能丢消息。成熟的中间件虽然可靠性高但你不应该把“不丢消息”的赌注全押在它身上。我的方案是经典的本地消息表也叫 Outbox 模式。核心思想是业务操作和事件发送动作处在同一个本地事务边界里。业务数据写进业务表事件数据写进outbox_event表两个写入要么一起成功要么一起失败。事务提交后后台任务读取outbox_event中状态为待发送的记录投递到消息中间件投递成功后再把该记录标记为已发送。注意这里不存在“先发消息再改数据库”的不一致窗口因为投递动作本来就在事务之外。反过来说消息中间件投递成功后如果后台任务还没来得及把状态改成已发送就宕机了重启后同一条消息会再次投递这是预期的现象交给消费者端幂等去解决。我建表结构时大致沿用这样一个精简版本CREATE TABLE outbox_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0, retry_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, sent_time DATETIME NULL, UNIQUE KEY uk_event_id (event_id) );STATUS字段只表示“待发送/已发送”不能表示“业务成功/业务失败”。业务结果是否正确要由消费者端最终确认。4.2 消费端幂等表是最后的防线既然生产端保证的是“至少一次投递”消费端就必须做到“处理多次和实际结果只生效一次”。我维护了一张消费记录表主键就是事件的eventId加上消费者组名这样即使同一个事件被多个消费者组各消费一次也不会误判为重复。CREATE TABLE consumed_event ( event_id VARCHAR(64) NOT NULL, consumer_group VARCHAR(64) NOT NULL, consume_time DATETIME NOT NULL, result_summary VARCHAR(512) NULL, PRIMARY KEY (event_id, consumer_group) );消费逻辑的每一步顺序都不能乱。先尝试插入消费记录插入成功的才继续执行业务。如果有并发消费同一事件的情况唯一索引会拦住第三次插入后到的消费者直接判定为重复。这个方法比“先执行业务再插入记录”更安全因为它能避免业务执行完成但记录插入失败导致的重复执行。第一次插入成功之后业务处理中如果发生异常消费记录会保留。这里要注意记录一旦写入复议中必须要有“处理中、成功、失败”三个状态因为如果异常后直接删除记录消息重投时业务就没了幂等判断依据。4.3 重试、死信和监控消费者处理失败后最忌讳直接catch掉然后打印日志因为消息没有被确认中间件会持续重新投递形成死循环。正确做法是设定有限次数的重试例如初始重试 3 次每次间隔指数退避10 秒、30 秒、60 秒逐步拉开。超过重试次数后事件要被路由到死信主题。死信不是终点它是人工排查的起点。我的运维习惯是把死信主题里的消息体加上原始事件 ID 和异常堆栈然后设置一个定时任务每天扫一次确认是代码缺陷还是外部依赖故障。监控方面至少要盯三个指标消费者积压数量、消费者处理耗时、重复消费率。积压数量上升通常意味着消费能力不够处理耗时上升可能是模型服务变慢重复消费率异常上升说明幂等表可能出了问题或者消息被错误重投。这三个指标能覆盖事件驱动系统里 80% 的故障信号。5. 线上问题排查实录与压测数据5.1 重复消费导致成绩翻倍有一次线上收到考生反馈说自己提交一份试卷后最后显示的总分明显不对客观题部分被算了两遍。查了两小时最终定位到问题出在“成绩聚合”这个消费者上。生产端事件做了重试投递消息中间件重复投递了这个事件但新写的聚合逻辑当时没写幂等结果同一份客观题成绩被累计了两次。这个教训让我后来定死了一条规则任何会改变业务结果的处理逻辑都必须先把事件 ID 写进消费记录表再执行真正的业务更新。如果业务更新失败消费记录要回滚让消息有机会重投。5.2 AI 超时把队列堵死还有一次是 AI 服务接口超时生成环境下模型服务很不稳定某个时间段响应时间从 2 秒直接跳到 30 秒。消费者的处理和超时时间一开始设得比较小结果大量判卷事件抛异常进入重试重试又继续打满线程池从表面上看就是一个超时把整个队列消费进度拖慢了十倍。排查下来发现超时跟排队重试叠在一起形成了“失败风暴”。后来我把 AI 调用的超时分开设置连接超时短一些读超时拉长并且把模型调用单独隔离到独立线程池。消费者线程只负责“把事件投递给判卷执行器”不再亲自陷入模型调用的等待这样一条判卷事件执行慢了也不会拖住其他判卷事件。5.3 压测验证削峰效果重构完成之后我用一套模拟项目做了压测。同步模式下200 个并发交卷几乎让接口 P99 延迟到了 20 秒以上。切换事件驱动后同样的并发量下交卷接口 P99 控制在 1.5 秒以内AI 判卷的积压曲线会在几分钟内自然消化完。这个结果不意外因为本质上系统把“实时处理”和“延时处理”两个负载拆开了慢外部依赖不再阻塞核心交卷体验。6. 最后再说几句我的实践经验这套事件处理架构做下来我最深的一个体会是事件驱动真正考验人的不是框架选型而是你有没有能力把每条消息的语义定义清楚。事件名、事件 ID、事件版本、分片键、幂等键这些看起来“不太有技术含量”的约定反而是线上稳定性的定海神针。还有一点是在实际项目里反复验证过的消费者逻辑一定要尽量保持“轻”。判卷服务里我只让消费者负责接收任务、创建 AI 调用任务、记录消费状态真正耗时的模型推理放到独立的执行组件里。如果消费者本身做了一大堆复杂的本地业务故障面会被瞬间放大。最后分享一个小技巧在开发阶段就把事件结构做成单独的“事件契约包”服务与服务之间依赖这个包而不是各自定义。这样无论以后事件加字段还是拆消费者双方都不需要反复对齐代码排查问题时也能直接根据契约包的版本定位消息结构差异。这套事件处理架构已经能支撑现有的考试并发场景了。后续再扩展的话我会把重心放在事件回溯和异常自动修复上让出问题的判卷事件能通过管理台一键重投而不是靠手工改数据库状态。