1. 先回答一个问题:为什么这套数据库设计要围绕SpringAI重做一遍
最近在重构一个在线考试系统,技术栈选型时引入了SpringAI作为智能层,用来做AI组卷、主观题辅助评分和考情分析。项目推进到数据库设计阶段时,团队内部争论了几个回合,最后我按“核心业务方案”的粒度重新梳理了一版数据结构。这篇文章就是这次设计的完整记录,包含了表结构、状态机设计、并发与幂等处理,以及SpringAI接入后新增的那些数据实体该怎么落库。
先说结论:在线考试系统的数据库设计,难点从来不是建几张表,而是三件事。
第一,考试业务天然有一堆状态:题目有启用/停用,试卷有草稿/发布/归档,考试有未开始/进行中/已交卷,答题记录有暂存/已提交/已评分。这些状态之间还有流转关系,比如试卷发布之后不能再改题,考试结束之后答题记录不能再回写。稍不注意,状态就乱了。
第二,考试系统有大量“同一份数据在不同视角下的不同版本”。考生看到的卷子,题目顺序可能是打乱的;老师看到的卷子,题目顺序是固定的。考生答题时提交的是答案,但系统里还要保留答题时间、修改次数、IP、设备指纹这类过程数据,用来做异常行为分析。
第三,引入SpringAI之后,数据模型又多了一个维度——AI不是简单的“调一个接口返回结果”,而是有任务、有输入、有输出、有评审意见、有确定性评分与AI评分的对照关系。这一层不提前设计好,后面接DeepSeek或者OpenAI的时候会非常难受。
所以我最终给出的设计原则是:核心考试数据要严格遵循经典的关系模型,保证事务和一致性;AI相关数据全部走“任务-结果”模式,异步落库,允许中间状态;所有涉及“快照”语义的数据(比如考生答卷、题目内容)必须留副本,不允许通过关联查询回看历史。
这套方案不是从零做出来的,而是在一个已经运行了两年的老系统基础上做的微调。老系统的表结构其实也够用,但在SpringAI接入之后暴露出几个问题——AI评分结果没有独立表、题目和试卷之间缺少版本快照、主观题作答内容存储过于随意。这次的“微调”重点就是补上这些缺口,同时保持老业务的数据兼容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心业务域拆解:考试系统由哪几张“地基表”撑起来
先不急着上SQL,把业务域理清楚。一套在线考试系统,不管接不接AI,核心业务域都逃不出这五个:用户与权限、题库、试卷、考试过程、成绩与归档。SpringAI的接入会在这五个域上各自新增一小块数据,但地基不会变。
2.1 用户与权限域:考生、老师、管理员,一张表还是三张表
考试系统的用户角色至少分三类:考生、出题老师、系统管理员。有一些系统还有“审题老师”“巡考员”这类中间角色。
我的建议是:统一账号表 + 角色表 + 用户角色关联表,不要拆成student表和teacher表。
原因很简单:一个用户在医院体检系统里可能是患者,在考试系统里可能是考生;将来系统要对接统一身份认证时,一张账号表远远比拆开的表好接。而且考试系统里的考生还要挂班级、年级、学校这类组织维度,这些信息不适合直接塞进账号表。
账号表(user_account)我最终保留了这些字段:
- id,主键
- username、password_hash(密码不入库,只存哈希)
- real_name、id_card_no(身份证号要做掩码或加密)
- user_type(1-考生,2-老师,3-管理员)
- status(0-禁用,1-启用)
- created_at、updated_at
考生扩展信息单独放一张student_profile,挂user_id,存学号、班级、年级、学校ID等。
2.2 题库域:题目表和选项表,为什么选项必须拆出来
题库是考试系统的核心资产。题目的类型要覆盖单选题、多选题、判断题、填空题、简答题、案例分析题。
题目表(question_bank)设计如下:
- id
- question_type(1-单选,2-多选,3-判断,4-填空,5-简答,6-案例分析)
- content(题干,TEXT)
- analysis(解析,TEXT,考后展示用)
- difficulty(1-5,难度系数)
- score(默认分值)
- status(0-草稿,1-审核通过,2-已上线,3-已下线)
- creator_id(出题人)
- reviewer_id(审核人)
- knowledge_point_id(关联知识点)
- created_at、updated_at
选项为什么必须拆成单独的表?核心原因是复用和统计分析。选项如果以JSON塞在题目表里,后面做“学生错选哪个干扰项最多”这类统计时,会写出一堆恶心的JSON解析SQL。拆成question_option之后,每道题的选项按display_order排序,正确答案的标识用is_correct字段标记,统计分析和组卷都方便。
2.3 知识点维度:AI组卷和智能推荐的锚点
知识点表(knowledge_point)这层很多团队会忽略。我认为在引入SpringAI之后,知识点表的重要性上升了一个层级,因为AI组卷、AI生成题目、AI考情分析都依赖知识点的精确标注。
知识点表采用树形结构:
- id
- parent_id(父子层级,可以做到三级)
- name
- subject_id(所属学科)
- sort_order
一道题可以关联多个知识点,但是要区分“主知识点”和“次知识点”。这个用关联表question_knowledge来做,既灵活又不会破坏题目的核心数据。
2.4 试卷域:模板、实例、快照,三层结构各管一件事
很多数据库设计最容易出错的地方就在试卷这里。原因很简单:一张试卷在系统里其实有三个不同的东西。
一是试卷模板(paper_template)。这是老师出题时维护的原始卷子,包含题目顺序、分值、总分。它代表的是“这套卷子长什么样”。
二是考试场次(exam_session)。这是某一次考试的具体配置,比如周六上午十点开考、时长90分钟、开放给哪些班级,以及试卷模板的选择。它代表的是“这套卷子在什么时候、给谁考”。
三是考生试卷实例(exam_paper_instance)。这是某个考生在某个场次里拿到的实际卷子。它代表的是“这个考生考的那套卷子的原始状态”。
为什么要这么拆?因为同一个考试场次,不同的考生拿到的卷子,题目顺序可能是不同的(防作弊),但试卷模板是固定的。如果没有实例层,考试结束后想还原某个考生的答题上下文,就只能靠一堆时间戳来猜。
试卷模板表(paper_template)字段:
- id
- name
- total_score(总分,通常100或150)
- duration_minutes(建议考试时长)
- status(0-草稿,1-已发布,2-已归档)
- creator_id
- created_at、updated_at
试卷模板与题目的关联表(paper_template_question):
- id
- template_id
- question_id
- question_order(题目在卷面上的顺序)
- score(该题在本次考试中的分值,可覆盖题库默认分值)
- section_type(题型分组,比如第一部分单选、第二部分多选)
需要说明:这套设计里模板和实例用同一张表结构,但是实例里的题目ID会指向题目表的“快照副本”。快照的具体做法见下一节。
2.5 考试过程域:场次、实例、答题记录,一条链跑通
考试场次表(exam_session):
- id
- title(考试名称)
- paper_template_id(关联试卷模板)
- start_time、end_time
- duration_minutes
- exam_type(1-正式考试,2-模拟考试,3-随堂测验)
- status(0-未开始,1-进行中,2-已结束,3-已发布成绩)
- publisher_id(发布人)
- created_at、updated_at
考试场次与考生的关系用exam_session_student关联,记录哪些学生参加了这个场次。关联表里有一个字段必须留:student_status(0-未参加,1-已参加,2-已交卷,3-异常交卷)。原因:有些学生报名了但不来考,有些学生提交后显示超时,这些状态都要落到关联表里。
考生试卷实例表(exam_paper_instance):
- id
- session_id(考试场次)
- student_id(考生)
- template_id(关联模板)
- total_score(实际总分,可能因为题目调整而变化)
- status(0-未开始答题,1-答题中,2-已交卷,3-已判分,4-已发布)
- submit_time(交卷时间)
- ip_address、device_info(环境信息,作弊分析用)
- score(最终得分,冗余字段,避免每次联表算)
答题记录表(exam_answer_record):
- id
- instance_id(关联考生试卷实例)
- question_id(题目ID)
- question_type(冗余题目类型,避免连表查询)
- answer_content(考生作答内容,选择题存选项ID,简答题存文本)
- is_correct(客观题直接判对错,主观题初始为NULL)
- score(该题得分)
- duration_seconds(该题耗时)
- updated_at(最近一次修改时间)
- modify_count(修改次数,防作弊参考)
这套结构的核心价值是:查“某次考试的数据统计”,只需走instance和answer_record;查“某套卷子的命题质量”,只需走template和question;查“某个考生的历史学习轨迹”,只需走instance和session。
3. 快照设计:为什么题目改了,历史考试数据不能跟着变
这是在线考试系统数据库设计里最容易被忽视、又最影响数据正确性的一环。我见过很多系统,考生交卷之后,老师把某个题目的题干改了一个字,结果历史统计里这道题的正确率全变了。原因就是表结构里没有做快照。
数据库设计里的快照,通俗说就是“把某个时刻的数据复制一份存下来”。考试系统里有两处必须做快照。
3.1 题目快照:试卷实例里存题目的“影印件”
第一处是题目快照。考生交卷之后,试卷实例里引用的题目内容不能再指向题库的实时题目。因为题目可能被修改、删除、下线。正确的做法是把题干、选项、答案、分值在考生实例里存一份副本。
具体表设计,我是在exam_paper_instance_question这张表里做了冗余:
- id
- instance_id
- question_id(关联题库,用于回溯)
- question_content(题干快照)
- question_type
- options_json(选项快照)
- correct_answer(客观题答案快照)
- score(分值快照)
- question_order
注意search_options_json不要存成Text就完事。建议在应用层统一序列化成一个结构稳定的JSON,选项顺序、选项内容、选项ID都保留。将来做“题目变更对历史考试的影响分析”时,直接对比题库当前内容和快照内容即可。
3.2 试卷快照:模板一旦发布,考试期间不能改
第二处是试卷快照。模板发布之后,到考试结束之前,不应该允许修改模板里的题目和分值。这个约束可以在数据库层面用状态机卡,也可以在应用层做校验。但为了让数据库兜底,我在paper_template表加了一个version字段,每次重新编辑都会生成新版本,发布后的版本进入只读状态。
之所以强调这个问题,是因为AI评分阶段需要读取“考生作答时的那道题”的评分标准。如果老师和AI用的是不同版本的题目内容,评分一定会出偏差。
4. 答题链路:客观题即时判、主观题异步AI评分的状态机设计
考试过程中,答题数据是整个系统压力最大的部分,尤其是大规模在线考试,成千上万个考生同时在答题。数据库设计在这个阶段的重点不是表结构有多美,而是能不能扛住并发,能不能保证数据不错、不丢、不重复。
4.1 答题接口的幂等设计:防止连点、重试、断网重传
答题接口最怕的就是重复提交。学生答完一道题,可能因为网络问题点了两次提交,或者前端超时重试,后端的Insert如果没做幂等,就会产生两条答题记录,判分时取哪条都尴尬。
我的做法是:在exam_answer_record表上建一个唯一索引,约束条件是(instance_id,question_id)。用数据库的唯一索引来保证一个考生对一道题只有一条答题记录。添加答案走Insert on duplicate key update,天然幂等,不用在应用层加分布式锁。
这条SQL要特别注意索引顺序。因为查询模式是“查某一份实例下的所有答题记录”,instance_id放前面,question_id放后面,正好命中联合索引。
4.2 暂存与交卷:状态字段解决“改来改去”的问题
考生答题是允许反复修改的。所以答题记录的数据更新比较频繁。这里我设置了modify_count字段,每修改一次加1,既不丢失历史修改痕迹,又不用每次都插入一条新记录。
同时,exam_paper_instance的status字段承担了状态机的核心职责:
- 未开始答题(status=0):考生还没进入答题页
- 答题中(status=1):考生已保存或提交过至少一题
- 已交卷(status=2):考生主动交卷或系统自动交卷
- 已判分(status=3):AI评分和客观题评分都完成
- 已发布(status=4):成绩对考生可见
这个状态机的流转有个关键设计:答题中到已交卷这个转变,是“终态”,一旦写入数据库,就不允许再往exam_answer_record里写任何新数据。这个约束不属于数据库强制约束,但要在应用层加一个开关,并且要用事务保证“更新状态为已交卷”和“计算总分”在同一个事务里完成。
4.3 主观题评分:AI评分任务表是怎么把流程盘活的
引入SpringAI之后,主观题(简答题、案例分析题)的阅卷不再是单一的人工模式,而是“AI预评分 + 人工抽检”的模式。这个过程是异步的,而且是有状态的。如果用传统的方式在主表上加一个score字段,你会发现根本没法追踪:AI评分到哪一步了?失败了没有?重试了几次?
所以这里我设计了独立的AI评分任务表(ai_grading_task)和AI评分结果表(ai_grading_result)。
ai_grading_task的字段:
- id
- instance_id(关联考生试卷实例)
- question_id
- task_type(1-AI预评分,2-AI复核)
- task_status(0-待处理,1-处理中,2-成功,3-失败)
- retry_count
- model_provider(模型提供方,如deepseek、openai)
- model_name(模型名称)
- prompt_version(提示词版本)
- max_tokens
- temperature
- created_at、updated_at、finished_at
ai_grading_result的字段:
- id
- task_id
- instance_id
- question_id
- ai_score(AI给出的分数)
- ai_confidence(AI自信度,用于判断是否进入人工复核)
- evidence(评分依据片段)
- raw_response(原始响应,方便排查问题)
- review_status(0-待人工抽检,1-人工已复核)
- reviewer_id(复核人)
- created_at、updated_at
这张表的价值在于:把AI评分从“调用返回一个结果”变成了“可追踪、可重试、可审计的异步流程”。比如AI服务超时了,task_status停在“处理中”,定时任务去重试;比如客观题判分和主观题AI判分不在同一个事务里,要用最终一致性来保证成绩数据不丢。
5. 实战踩坑:SpringAI连DeepSeek时content为空,和数据表有什么关系
这个坑非常值得单独拿出来讲,因为很多人以为它是代码问题,其实它在数据库设计阶段就该预防。
用SpringAI调用DeepSeek(或者其他OpenAI兼容接口)时,我遇到过一种诡异的情况:接口返回的HTTP状态是200,但返回体里的choices[0].message.content是null。排查了两天,最后发现是DeepSeek的API在某些参数组合下会把content放到一个叫reasoning_content的字段里,而不是标准content字段。
这和数据表设计有什么关系?关系很大。
如果AI评分的结果落库方式是直接把返回体存进一个JSON字段,后续要做数据分析(比如统计AI评分置信度、分析评分稳定性)时,就得从JSON里反复取字段,非常痛苦。正确的做法是:在ai_grading_result表里单独留一个reasoning_content字段,存储模型推理过程文本,和content分开存。将来做AI评分效果评估时,这两部分数据分开用,互不污染。
还有一个建议:在ai_grading_task表里加一个model_provider字段,不要默认写死。因为同一套系统可能先接DeepSeek,后面又切换成其他模型,如果表里没有记录“这次任务是哪家模型评的”,数据对比时完全无从下手。
这些细节不是数据库设计的核心难点,但确实是实际运行中一定会踩的坑。
6. 成绩计算怎么落库:客观题机器判、主观题AI兜底的对账机制
考试结束后,成绩计算是一个多数据源汇总的过程。客观题得分从exam_answer_record的is_correct和score字段来,主观题得分从ai_grading_result的ai_score来,但这两个源头的数据完成时间不一定相同。客观题是交卷时就算完的,主观题可能还在AI评分队列里排队。
这就需要一个对账机制,我把它放在exam_paper_instance上,用两个冗余字段:objective_score(客观题总分)和subjective_score(主观题总分)。这两个字段初始为NULL,当两个字段都非NULL时,instance的状态才能从“已交卷”流转到“已判分”。
这个设计的好处是:状态机天然地表达了“成绩计算完成”这个业务含义,不用额外加一张对账表。而成绩对外发布时,读取的是instance_score这个最终冗余字段,不用每次都去汇总答题明细表。
成绩表(exam_score_record)我建议单独建,而不是只依赖exam_paper_instance。核心原因有两个:第一,成绩发布后可能会涉及申诉、修正,修正过程要留痕,不能直接改主表数据;第二,成绩的查询频次极高,单独建表方便做读写分离和缓存。
exam_score_record字段:
- id
- instance_id
- session_id
- student_id
- total_score
- objective_score
- subjective_score
- rank(班级排名或年级排名,按需)
- status(0-未发布,1-已发布,2-已修正)
- appeal_status(0-无申诉,1-申诉中,2-申诉完成)
- appeal_reason、appeal_result
- 修正记录使用单独表exam_score_revision,记录修改前后分值、操作人、原因、时间。
这套结构在考试结束后,可以做到:
- 查成绩:走exam_score_record,速度极快
- 查成绩明细:走exam_paper_instance + exam_answer_record,向考生展示每道题得分
- 查申诉处理:走exam_score_revision,保留完整审计日志
7. 考后数据归档与冷热分离:别让一张表背上三年的数据
在线考试系统的数据有个明显特点:热数据和冷数据的分界线非常清晰。
热数据是当前学期、当前正在进行的考试相关数据,包括答题记录、AI评分任务、成绩数据。这些数据要求高并发读写,响应时间要控制在毫秒级。
冷数据是历史学期、历史年份的考试数据,包括旧试卷模板、旧答题记录、旧成绩。这些数据几乎不会被在线访问,只有做数据分析和教学评估时才会偶尔查询。
如果所有数据都放在同一张表里,表的体积会越来越大,索引性能急剧下降。我采用的策略是“分表 + 归档”。
具体的做法:
- 表名前加考试年度标识,如exam_answer_record_2024、exam_answer_record_2025。分表键用考试场次的开始年份。
- 每个学年结束后,提供一个定时任务,把超过一年的数据迁移到归档库(可以是同一个MySQL实例的历史库,也可以是ClickHouse、TiDB等分析型存储)。
- 归档时保留原始表结构,方便按年份回溯。归档之前先做一次历史数据完整性校验,比如“该场次所有考生的成绩是否都已生成”“答题记录是否完整”。
入库时怎么决定写到哪张表?方案是:由路由层根据session的结束时间动态决定。比如你查询2024年的考试成绩,SQL自动路由到exam_answer_record_2024。这个可以在MyBatis的拦截器或者Spring的AbstractRoutingDataSource里实现,应用层无感知。
很多中小团队觉得分表麻烦,前期数据量不大确实可以不做。但考试系统的数据量有一个特点:一次大考几万人同时在线,产生的答题记录瞬间就是几十万行,一年积累下来上千万行并不夸张。如果一开始不分表,等性能出问题再拆,迁移成本会指数级上升。
8. 索引、并发与性能:一次大考压垮MySQL的三个细节
最后聊三个实操细节。这些都是在真实环境里被压测逼出来的优化。
8.1 联合索引的顺序:为查询而生
考试系统最常见的查询是:
- 查某场次某考生的所有答题记录:WHERE session_id = ? AND student_id = ?
- 查某一题的全部考生作答:WHERE question_id = ? AND session_id = ?
- 查某个AI评分任务的状态:WHERE task_status = ? AND created_at < ?
这些查询场景不同,联合索引的列顺序也不同。我的经验是:
- exam_answer_record表:联合索引(instance_id, question_id),这个在前文提过,还要额外建一个(session_id, question_id)的索引,用于考后题目正确率统计。
- ai_grading_task表:联合索引(task_status, retry_count),用于定时任务扫描待重试任务。
- exam_paper_instance表:联合索引(session_id, student_id),用于查询某场次某考生的实例。
- exam_score_record表:联合索引(session_id, status, total_score),用于成绩排行和发布状态查询。
索引不是越多越好,特别是考试系统里,写操作很频繁。每个索引都会拖慢写速度。所以我的原则是:只给三类查询建索引——按考试查、按考生查、按题目查。其他的统计查询,晚上跑离线任务去算。
8.2 数据库层的乐观锁:防止成绩被并发覆盖
成绩计算的过程中,有一种并发场景很危险:AI评分回调写主观题得分的同时,人工复核也在写同一道题的分值。两个事务同时UPDATE同一条记录,后提交的会把先提交的覆盖掉。
解决方案是给exam_answer_record表加一个version字段,更新时带上WHERE version = 旧值,影响行数为0则重试。这是标准的乐观锁做法,实现简单,不需要引入分布式锁。
这里有一个坑:所有涉及“先读后写”逻辑的更新操作都要带version,不只是成绩计算。比如考生修改答案、AI评分任务状态更新,都要带。否则某天并发量上来,一定会出现数据错乱。
8.3 事务边界的控制:别把MQ消费和数据库事务绑死在一次调用里
AI评分是异步流程。考生交卷后,主流程把交卷事务提交掉,然后向MQ发一条消息,由消息消费者去创建AI评分任务。这里必须注意:不要在一个事务里既更新考试状态又发送MQ消息。
因为事务尚未提交时,MQ消费者可能已经把消息拉走了,去查数据库发现考试实例还是“答题中”状态,就会产生逻辑错误。
正确做法是“本地消息表”或者“事务消息”。我在实操中采用了本地消息表:在同一个数据库事务里,更新考试状态和插入一条MQ消息记录(状态为待发送),事务提交后由单独的发送任务扫描这张表去发MQ。这样既保证了业务数据和消息的一致性,又不用改数据库配置。
这种模式的代价是多一张消息表,多一个扫描任务,但换来的是可靠性和可追溯性,对考试系统这种对数据一致性要求很高的场景,非常值得。
9. 这套方案和老系统兼容吗
前面提到,这套方案是在老系统上做的微调。兼容性是我最关注的。
老系统的表结构和上面提到的核心表基本一致,主要差异在三处:
第一,老系统没有独立的AI评分表和AI评分结果表。这次微调新增了ai_grading_task和ai_grading_result两张表,不涉及老表结构的修改,落地成本低。
第二,老系统的exam_answer_record表没有唯一索引(instance_id, question_id),导致出现过两次重复答题记录。微调时加了唯一索引,同时清理了存量数据。
第三,老系统没有快照表。微调时新增了exam_paper_instance_question快照表,但存量数据需要跑一个一次性任务,从题库和模板表把数据回填到快照表。
我的建议是:如果老系统已经运行了一段时间,微调时优先保证新老表并存,不要急着删老字段。先把新增的SpringAI相关表跑起来,验证无误之后,再逐步把核心业务查询切到新表结构上。这样能最大程度降低回归风险。
在线考试系统的数据库设计,永远不要追求一步到位。先把业务链路跑通,把状态机理清,把幂等和一致性兜住,后面再慢慢加AI能力,这套地基是能撑住的。
