1. 概述
1.1 背景
在考试系统中,当大量学生同时开始考试时,系统需要为每个学生创建考试记录 (ExamRecord) 和答题记录 (ExamAnswerRecord)。传统的"按需创建"模式在高并发场景下存在以下问题:
- 性能瓶颈:每次开始考试都需要执行数据库写入操作,响应时间在 200-500ms
- 并发压力:1000+ 学生同时开考时,数据库压力激增,可能导致超时或失败
- 用户体验:学生点击"开始考试"后需要等待较长时间才能进入考试界面

本文介绍了 CodeSpirit 项目中针对高并发考试场景的考试记录预生成方案。传统按需创建模式在大量学生同时开考时会导致数据库压力大、响应慢。该方案通过定时任务在凌晨低负载时段批量预生成考试和答题记录,结合缓存优化,将开始考试耗时从 200-500ms 降至 10-50ms。核心设计包括状态管理 (NotStarted)、缓存策略 (基于考试结束时间)、分批处理及容错机制。实施要点涵盖定时任务配置、日志记录及性能调优建议,适用于大规模、高并发的在线考试系统。
在考试系统中,当大量学生同时开始考试时,系统需要为每个学生创建考试记录 (ExamRecord) 和答题记录 (ExamAnswerRecord)。传统的"按需创建"模式在高并发场景下存在以下问题:

考试记录预生成方案通过定时任务 (每天凌晨 1 点) 批量预生成所有已发布且尚未开始的考试的记录和答题记录,将数据库写入操作从"考试开始时刻"提前到"凌晨低负载时段",从而:
sequenceDiagram
participant Admin as 管理员
participant Controller as ExamSettingsController
participant Service as ExamSettingService
participant ScheduledTask as 定时预生成任务
participant Cache as 缓存层
participant DB as 数据库
participant Student as 学生
participant StartExam as CreateExamRecordAsync
Note over Admin,Service: 阶段 1:考试发布
Admin->>Controller: 发布考试
Controller->>Service: PublishExamSettingAsync
Service->>DB: 更新考试状态为 Published
Service-->>Admin: 返回成功
Note over ScheduledTask,DB: 阶段 2:定时预生成 (每天凌晨 1 点)
ScheduledTask->>DB: 查询已发布且尚未开始的考试
ScheduledTask->>DB: 检查是否已预生成
ScheduledTask->>DB: 分批创建 ExamRecord(NotStarted)
ScheduledTask->>DB: 批量创建 ExamAnswerRecord
ScheduledTask->>Cache: 写入预生成记录 ID(过期时间=考试结束时间)
ScheduledTask->>ScheduledTask: 打印详细日志
Note over Student,StartExam: 阶段 3:学生开始考试
Student->>StartExam: 点击开始考试
StartExam->>Cache: 查询预生成记录ID
alt 缓存命中
StartExam->>DB: 加载预生成记录
StartExam->>DB: UPDATE 状态为 InProgress<br/>设置 StartTime
StartExam-->>Student: 快速启动 (10-50ms) ✅
else 缓存未命中 (新增学生)
StartExam->>DB: 动态创建完整记录
StartExam-->>Student: 常规启动 (200-500ms) ⚠️
end
Note over ScheduledTask,DB: 阶段 4:定时清理 (每天凌晨 2 点)
ScheduledTask->>DB: 查询已结束考试的 NotStarted 记录
ScheduledTask->>DB: 批量删除未使用记录
ScheduledTask->>Cache: 清理相关缓存
graph TB
A[考试发布] --> B[更新状态为 Published]
B --> C[等待定时任务执行]
D["定时任务 每天凌晨 1 点"] --> E[查询已发布且尚未开始的考试]
E --> F{是否已预生成?}
F -->|是 | G[跳过该考试]
F -->|否 | H[获取学生分组列表]
H --> I[分批处理学生列表]
I --> J["创建 ExamRecord Status=NotStarted"]
J --> K[创建 ExamAnswerRecord 列表]
K --> L["写入缓存 Key: exam:pregenerated:examId:studentId:1 Value: recordId Expire: 考试结束时间 +1 小时"]
L --> M{是否还有批次?}
M -->|是 | I
M -->|否 | N[预生成完成]
O[学生开始考试] --> P[查询缓存]
P --> Q{缓存命中?}
Q -->|是 | R[加载预生成记录]
R --> S["更新状态为 InProgress 设置 StartTime"]
S --> T["快速启动 ✅"]
Q -->|否 | U[动态创建记录]
U --> V["常规启动 ⚠️"]
W["定时清理任务 每天凌晨 2 点"] --> X[查询已结束考试]
X --> Y[查找 NotStarted 记录]
Y --> Z[批量删除]
Z --> AA[清理缓存]
IExamRecordPreGenerationService)职责:
关键方法:
PreGenerateExamRecordsAsync(long examId) - 为指定考试预生成所有学生记录PreGenerateBatchAsync(long examId, IEnumerable<long> studentIds, int attemptNumber) - 批量预生成指定学生记录GetPreGeneratedRecordCacheKey(long examId, long studentId, int attemptNumber) - 生成缓存键ExamRecordService)职责:
关键方法:
CreateExamRecordAsync(long examId, long studentId, ...) - 创建或激活考试记录
定时预生成任务处理器 (ExamRecordScheduledPreGenerationTaskHandler):
手动预生成任务处理器 (ExamRecordPreGenerationTaskHandler):
清理任务处理器 (ExamRecordCleanupTaskHandler):
public enum ExamRecordStatus {
NotStarted = 0, // 未开始 (预生成状态)
InProgress = 1, // 进行中
Submitted = 2, // 已提交
Graded = 3 // 已批改
}
预生成 → NotStarted
↓ 开始考试 → InProgress
↓ 提交考试 → Submitted
↓ 批改完成 → Graded
exam:pregenerated:{examId}:{studentId}:{attemptNumber}
示例:
exam:pregenerated:123:456:1
核心原则:缓存过期时间 = 考试结束时间 + 1 小时缓冲
设计理由:
BATCH_SIZE)DELAY_BETWEEN_BATCHES_MS)STOP_BEFORE_EXAM_START_MINUTES)示例日志:
⚠️ 距离考试开始时间不足 5 分钟,停止预生成。已处理:800/1000,剩余:200 名学生未处理
NotStarted 状态的记录-- 默认查询 (排除预生成记录)
SELECT * FROM ExamRecord WHERE Status != 0 -- NotStarted
-- 显式查询预生成记录
SELECT * FROM ExamRecord WHERE Status = 0 -- NotStarted
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 开始考试耗时 | 200-500ms | 10-50ms | 90%+ |
| 数据库写入压力 | 高峰期集中 | 分散到发布时 | 98% |
| 并发支持能力 | 500+ | 1000+ | 2 倍 |
| 缓存命中率 | - | >85% | - |
AddRangeAsync)DeleteRangeAsync)Status = Published AND StartTime > 当前时间)AttemptNumber = 1)EndTime < 当前时间)NotStarted[INFO] ========================================
[INFO] 考试记录定时预生成任务开始执行
[INFO] ========================================
[INFO] 找到 3 个已发布且尚未开始的考试
[INFO] 考试 123 (数学期末考试) 已预生成,跳过
[INFO] 开始为考试 456 (英语期末考试) 预生成记录
[INFO] 获取到 1000 名学生需要预生成记录
[INFO] 开始分批预生成,每批 50 名学生,共 20 批,批次间延迟 200ms
[INFO] 第 1/20 批完成:成功 50,失败 0 ...
[WARN] ⚠️ 距离考试开始时间不足 5 分钟,停止预生成。已处理:800/1000,剩余:200 名学生未处理
[INFO] 考试 456 预生成完成 - 成功:798, 跳过:200
[INFO] 定时预生成完成 - 总计:3, 成功:2, 跳过:1, 失败:0
[INFO] ========================================
[INFO] ✅ 命中预生成记录,快速启动:考试 ID=123, 学生 ID=456, 记录 ID=789
[WARN] ⚠️ 未命中预生成记录,执行动态创建:考试 ID=123, 学生 ID=999
以下配置参数在 ExamRecordPreGenerationService.cs 中以常量形式定义:
| 参数 | 默认值 | 说明 |
|---|---|---|
BATCH_SIZE | 50 | 每批次处理的学生数量 |
DELAY_BETWEEN_BATCHES_MS | 200ms | 批次间延迟时间,避免系统压力过大 |
STOP_BEFORE_EXAM_START_MINUTES | 5 分钟 | 开考前多久停止预生成,确保系统资源优先服务于考试 |
调整建议:
{
"ScheduledTasks": {
"Tasks": [
{
"Id": "exam-record-scheduled-pregeneration",
"Name": "考试记录定时预生成",
"Description": "每天凌晨 1 点为所有已发布且尚未开始的考试预生成记录",
"Type": "Cron",
"CronExpression": "0 0 1 * * *",
"HandlerType": "CodeSpirit.ExamApi.Tasks.ExamRecordScheduledPreGenerationTaskHandler",
"Timeout": "00:30:00",
"Enabled": true
},
{
"Id": "exam-record-cleanup",
"Name": "考试记录垃圾数据清理",
"Description": "清理未使用的预生成考试记录",
"HandlerType": "CodeSpirit.ExamApi.Tasks.ExamRecordCleanupTaskHandler",
"CronExpression": "0 0 2 * * *",
"Parameters": "{\"cleanupDays\": 7}",
"Enabled": true
}
]
}
}
Cron 表达式说明:
0 0 1 * * * 表示每天凌晨 1 点执行 (预生成任务)0 0 2 * * * 表示每天凌晨 2 点执行 (清理任务)NotStarted考试记录预生成方案通过提前创建和缓存优化两大核心策略,显著提升了系统在高并发场景下的性能和用户体验。方案设计充分考虑了容错、扩展性和可维护性,是一个生产级的优化方案。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online