Open edX 成绩数据模型深度解析:从 Course Grades 到 Subsection Grades 与 Problem Scores 的持久化存储架构
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
导读
本文以 lms/djangoapps/grades/docs/data-model.rst 为核心,系统讲解 Open edX 平台(LMS)中成绩数据的持久化存储模型。你将掌握grades_persistentcoursegrade、grades_persistentsubsectiongrade、grades_visibleblocks、grades_persistentsubsectiongradeoverride、courseware_studentmodule与submissions_score六张核心表的字段语义、索引设计与读取场景,并通过 models.py 等源码印证底层实现原理,从而能够在运维、排障、二次开发与数据分析时准确理解和使用这套成绩存储体系。
一、成绩数据模型总览:三级持久化存储架构
Open edX 的成绩系统采用"课程级 → 小节级 → 问题级"的三级分层存储设计,其核心目标是鲁棒性(robust grading):让学习者已获得的分数不因课程内容后续变更而丢失或失真。这一设计意图在 models.py 的模块文档字符串中表述得非常清楚:
Robust grading allows student scores to be saved per-subsection independent of any changes that may occur to the course after the score is achieved. We also persist students' course-level grades, and update them whenever a student's score or the course grading policy changes.
对应到具体存储载体:
| 成绩层级 | 存储表 | 说明 |
|---|---|---|
| 课程级(Course Grades) | grades_persistentcoursegrade | 每个学习者每门课程一条记录,保存百分制成绩、字母成绩与通过时间 |
| 小节级(Subsection Grades) | grades_persistentsubsectiongrade+grades_visibleblocks | 每个学习者每个小节一条记录,配合可见块(VisibleBlocks)快照 |
| 小节级覆盖(Overrides) | grades_persistentsubsectiongradeoverride | 教员/工作人员对小节成绩的覆盖,及历史审计表 |
| 问题级(Problem Scores) | courseware_studentmodule(普通题目)或submissions_score(ORA 开放性题目) | 记录具体题目的得分 |
从源码结构看,这三级模型分别对应 models.py 中的PersistentCourseGrade、PersistentSubsectionGrade、VisibleBlocks、PersistentSubsectionGradeOverride四个 Django Model,问题级数据则分别位于 lms/djangoapps/courseware/models.py 的StudentModule以及 ORA 子应用的submissions相关表中。
二、课程成绩:grades_persistentcoursegrade
2.1 表结构与字段详解
表名:grades_persistentcoursegrade
表说明:保存学习者课程成绩的持久化值(Persistent values for learners' course grades)。
唯一约束派生索引:('course_id', 'user_id'),即每个学习者每门课程至多一条成绩记录;同时派生course_id单列索引。
附加索引:
user_id:支撑学习者的课程仪表盘查询course_id, passed_timestamp:支撑课程完成度统计
字段明细:
| 字段名 | 类型 | 说明 | 是否包含在数据包(Data Package)中 |
|---|---|---|---|
| course_id | CourseKey | 所属课程的课程键。示例:course-v1:org+course+run(新式课程)或org/course/run(旧式课程) | Y |
| user_id | Integer | 学习者用户 ID。示例:41446 | Y |
| course_edited_timestamp | DateTime | 成绩计算时课程的最后编辑时间戳。当前仅用于调试目的。示例:2016-12-21 15:50:23.645000 | N |
| course_version | String (255) | 成绩计算时课程在 Split Modulestore 中的版本号。当前仅用于调试。注意:旧版 Mongo modulestore 不支持版本概念,因此该字段对于此类课程为 NULL,应改用course_edited_timestamp理解课程内容的日期信息。示例:58ff632f00d9e7501e0148c4 | N |
| grading_policy_hash | String (255) | 课程评分策略(grading policy)的 SHA-1 摘要,用于在策略变化时检测并更新成绩。示例:NiGhcAFSrpyijXbow/XKE1Cp1GA= | Y |
| percent_grade | Float | 按评分策略计算的课程成绩小数百分比。示例:0.91(即 91%) | Y |
| letter_grade | String (255) | 按评分策略计算的字母成绩(如 A→D、Pass)。若学习者成绩为 Fail 或 F,此字段为空。示例:Pass或A | Y |
| passed_timestamp | DateTime | 学习者首次通过课程的时间。若为空,表示从未通过;若非空但letter_grade为空,表示学习者从通过状态转为未通过状态。注意:由于成绩由平台异步计算(外部评分器、ORA 评分等),该时间与触发通过的题目提交时间之间存在延迟。示例:2017-05-02 15:51:04.395055 | Y |
| created | DateTime | 该用户该课程成绩首次计算的时间。注意:回填(Backfilled)的成绩此值会被设置为最终计算并回填的时间。 | Y |
| modified | DateTime | 该用户该课程成绩最后更新的时间。回填成绩同样取回填时间。 | Y |
2.2 索引设计意图与源码印证
从源码 models.py 的PersistentCourseGrade.Meta可以看到索引设计注释与文档一一对应:unique_together提供(course_id, user_id)用于查询单个成绩,(course_id)隐式创建便于教员查看课程全部成绩;显式索引(passed_timestamp, course_id)用于追踪首次及格时间,(modified, course_id)用于按时间段查找更新的成绩。这与数据模型文档中的"附加索引"完全一致。
预期的读取使用场景:
| 读取场景 | 功能/团队 | 所需索引 |
|---|---|---|
| 进度页展示学习者课程成绩及成绩分解信息 | LMS Progress Page | course_id, user_id |
| 课程仪表盘展示学习者每门已注册课程的成绩 | LMS Student Dashboard | user_id |
| 成绩报告为课程内每个学习者生成含课程成绩的 CSV | LMS Grade Report | course_id |
| 统计课程完成情况 | Analytics/Course Completion | course_id, passed_timestamp |
2.3 写入路径:update_or_create 与通过事件
课程成绩的持久化入口是PersistentCourseGrade.update_or_create(models.py),其核心逻辑包括:
- 以
(user_id, course_id)为查找键执行update_or_create,将course_version缺省值规范为空字符串; - 首次通过处理:若本次计算标记为
passed=True且该成绩尚无passed_timestamp,则发送COURSE_GRADE_PASSED_FIRST_TIME信号、写入当前时间戳,并发送COURSE_GRADE_PASSED_UPDATE_IN_LEARNER_PATHWAY信号(该信号定义于 lms/djangoapps/grades/signals/signals.py),支撑学习者路径(Learner Pathway)等下游功能; - 发送
course_grade_calculated事件(events.course_grade_calculated); - 更新请求级缓存
grades_cache.{course_id}; - 发送 Open edX 标准事件
PERSISTENT_GRADE_SUMMARY_CHANGED(事件类型org.openedx.learning.course.persistent_grade_summary.changed.v1),携带完整的成绩快照数据,供事件总线消费者使用(见 models.py)。
2.4 grading_policy_hash 的生成原理
grading_policy_hash是课程成绩中一个容易被忽略但非常关键的字段——它是检测评分策略变更、触发成绩重算的依据。其生成逻辑位于 transformer.py:
ordered_policy = json.dumps( course.grading_policy, separators=(',', ':'), # 去除空格以获得更紧凑的表示 sort_keys=True, ) return b64encode(sha1(ordered_policy.encode('utf-8')).digest()).decode('utf-8')即:将课程的grading_policy字典先按键排序序列化为紧凑 JSON,再取 SHA-1 摘要并 Base64 编码。由于序列化是确定性的,任何策略变化都会导致哈希值变化,从而让系统能够检测到策略变更并据此重新计算、更新成绩。运行时该值通过 course_data.py 的CourseData.grading_policy_hash属性从块结构(Block Structure)或课程对象上获取,测试用例可在 lms/djangoapps/grades/tests/test_transformer.py 中看到具体哈希值(如ChVp0lHGQGCevD0t4njna/C44zQ=)的断言验证。
三、小节成绩:Subsection Grades 的两表协同设计
小节成绩由两张表协同工作:Subsection Grade(grades_persistentsubsectiongrade)与Visible Blocks(grades_visibleblocks)。此外,教员/工作人员可以覆盖小节成绩,最近的覆盖记录保存在Subsection Grade Override表中,覆盖历史保存在Subsection Grade Override History表(grades_historicalpersistentsubsectiongradeoverride,由 django-simple-history 自动生成)中用于审计。
3.1 Subsection Grade 表:grades_persistentsubsectiongrade
表名:grades_persistentsubsectiongrade
表说明:保存学习者小节成绩的持久化值。
唯一约束派生索引:('course_id', 'user_id', 'usage_key')
course_idcourse_id, user_idcourse_id, user_id, usage_key
附加索引:visible_blocks_hash(外键引用 VisibleBlocks 的哈希列)
字段明细:
| 字段名 | 类型 | 说明 | 是否包含在数据包(DP)中 |
|---|---|---|---|
| course_id | CourseKey | 所属课程的课程键。示例:course-v1:org+course+run(新式)或org/course/run(旧式) | Y |
| course_version | String (255) | 成绩计算时课程在 Split Modulestore 中的版本号。当前仅用于调试。示例:58ff632f00d9e7501e0148c4 | N |
| created | DateTime | 该用户该小节成绩首次计算的时间。回填成绩取最终回填时间。 | Y |
| earned_all | Float | 该小节中用户聚合的total_weighted_earned得分,即小节内所有题目weighted_earned值之和。 | Y |
| earned_graded | Float | 小节内所有**计分(graded)**题目的weighted_earned值之和。 | Y |
| first_attempted | DateTime | 用户在小节内首次尝试题目的时间。若用户未尝试过该小节,则不存在该小节的记录。回填成绩会尽力推导该值——取小节内可用题目已尝试分数的created日期最小值。 | Y |
| modified | DateTime | 该用户该小节成绩最后更新的时间。回填成绩取回填时间。 | Y |
| possible_all | Float | 小节内所有题目的weighted_possible值之和(总分)。 | Y |
| possible_graded | Float | 小节内所有计分题目的weighted_possible值之和。 | Y |
| subtree_edited_timestamp | DateTime | 成绩计算时小节内容或其任意后代内容最后编辑的时间戳。当前仅用于调试。示例:2016-12-21 15:50:23.645000 | N |
| usage_key | UsageKey | 小节的用途键(别名:module_id、location)。示例:block-v1:org+course+run+type@sequential+block@1234(新式课程)或i4x://org/course/sequential/1234(旧式课程) | Y |
| user_id | Integer | 学习者用户 ID。示例:41446 | Y |
| visible_blocks | VisibleBlocks | 指向grades_visibleblocks表的外键。 | N |
预期的读取使用场景:
| 读取场景 | 功能/团队 | 所需索引 |
|---|---|---|
| 与之前成绩比较,判断是否需要条件性更新(如提高分数重算 Rescore to Increase) | Rescore to Increase | course_id, user_id, usage_key |
| 详细成绩报告为课程内每个学习者生成含小节成绩的 CSV | LMS Grade Report | course_id |
| 进度页展示学习者小节成绩分解 | LMS Progress Page | course_id, user_id |
源码印证:PersistentSubsectionGrade(models.py)除了上述字段外还有几个值得注意的实现细节:
- 主键使用
UnsignedBigIntAutoField(无符号大整数自增),源码注释明确指出"该表主键需要足够大"; usage_key对旧式 Mongo 课程可能未填充 run 值,因此提供了full_usage_key属性将 run 补齐后再比较(models.py);- 模型还定义了
(modified, course_id, usage_key)与(first_attempted, course_id, user_id)两个组合索引(见 models.py),分别支撑"按时间段/课程/小节查询更新成绩"与"查询用户在某课程中所有已尝试小节"两类场景; update_or_create_grade与bulk_create_grades提供单条与批量两种写入路径,写入时会先经由VisibleBlocks.cached_get_or_create/bulk_get_or_create确保可见块记录存在,再以visible_blocks_id(即哈希值)直接落库,避免多余查询。
3.2 Visible Blocks 表:grades_visibleblocks
表名:grades_visibleblocks
表说明:保存计算小节成绩时,该学习者在小节内可见块的有序列表。多个学习者很可能共享同一份可见块列表,因此这份数据被独立存放,供 Subsection Grade 表中的多行记录引用。
唯一约束派生索引:('hashed')
hashed
附加索引:course_id
字段明细:
| 字段名 | 类型 | 说明 | 是否包含在数据包(DP)中 |
|---|---|---|---|
| course_id | CourseKey | 所属课程的课程键。 | N |
| hashed | String (100) | blocks_json值的 SHA1 哈希。 | N |
| blocks_json | LongText | 包含以下信息的 JSON:version:数据格式版本号的整数;course_key:所属课程的序列化 CourseKey;blocks:小节内用户可访问的所有块(block)的序列化 UsageKey 有序列表。注意:blocks 字段保存的是计算小节成绩时用户可见的全部块的使用键列表。当用户对小节内容的访问权限发生变化时(分班 cohort 变更、角色变更、课程团队增删单元/题目等),该值会随之改变,并在表中创建带新哈希值的新行。 | N |
设计精妙之处:由于grades_visibleblocks以blocks_json的 SHA1 哈希作为唯一键,相同的可见块集合只存一行,多个学习者(同一分班、同一角色)可共享同一条记录,从而显著减少冗余。其 JSON 结构中的 version 字段则用于支持未来数据格式的演进。
源码印证:VisibleBlocks(models.py)内部使用BlockRecordList与BlockRecord两个工具类来序列化/反序列化可见块数据(BLOCK_RECORD_LIST_VERSION = 1,见 models.py)。BlockRecord是包含locator、weight、raw_possible、graded四个字段的命名元组,记录了块在参与成绩计算时的定位符、权重、原始满分与是否计分。序列化时采用separators=(',', ':')与sort_keys=True生成紧凑、确定性 JSON,再计算 base64 编码的 SHA-1 摘要(hash_value)。该模型还实现了完整的请求级缓存体系(bulk_read、cached_get_or_create、bulk_create、bulk_get_or_create),以visible_blocks_cache.{course_key}.{user_id}为键,避免同一请求周期内重复读写数据库。
3.3 Subsection Grade Overrides:成绩覆盖与审计
表名:grades_persistentsubsectiongradeoverride
表说明:保存指定小节最近一次的覆盖记录。在成绩计算中,覆盖值取代持久化的小节成绩汇总。其历史版本表grades_historicalpersistentsubsectiongradeoverride保存此前各次覆盖的滚动记录,用于审计目的。
唯一约束派生索引:('id')
id
附加索引:created、modified、grade_id
字段明细:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int(11) | 覆盖记录的自增 ID。 |
| created | datetime(6) | 覆盖首次创建的时间。 |
| modified | datetime(6) | 覆盖最后修改的时间。 |
| earned_all_override | double | 被覆盖的总得分(含计分与不计分题目)。注意:该字段的实际用法尚不明确,因为某些情况下其为 NULL,且不参与成绩计算。 |
| possible_all_override | double | 小节的总可能得分(含计分与不计分题目)。 |
| earned_graded_override | double | 小节的被覆盖得分。 |
| possible_graded_override | double | 小节的计分题目总可能得分。 |
| grade_id | bigint(20) unsigned | 与grades_persistentsubsectiongrade.id一一对应,指明该覆盖作用于哪条成绩。 |
| override_reason | varchar(300) | 教员提供的覆盖原因。示例:Student bribed me with doughnuts so I'm increasing their score. |
| system | varchar(100) | 执行覆盖的系统来源。示例:GRADEBOOK、grade-import |
源码印证:PersistentSubsectionGradeOverride(models.py)实现要点:
- 通过
OneToOneField与PersistentSubsectionGrade关联(related_name='override'),一条成绩至多一条覆盖; - 使用
django-simple-history的HistoricalRecords自动维护审计历史,且通过apps.app_configs判断避免在 CMS(Studio)环境中因 grades app 未安装而导致的历史记录连接失败; update_or_create_override接收requesting_user,并将该用户挂到_history_user非字段属性上,使历史记录能正确标注操作人;_prepare_override_params定义了字段白名单映射:earned_all_override ← earned_all、possible_all_override ← possible_all、earned_graded_override ← earned_graded、possible_graded_override ← possible_graded;调用方未显式指定的覆盖字段会回退到原成绩的对应值,保证覆盖记录的完整性。
四、问题得分:Problem Scores 的两种存储路径
学习者在具体题目上的得分,依据题目类型存储在两张不同的 SQL 表中:常规题目存储在courseware_studentmodule,ORA(开放型题目)存储在submissions_score。
4.1 通用用户态存储:courseware_studentmodule
表名:courseware_studentmodule
表说明:面向任意 xBlock/xModule(不限于题目类型)的用户特定状态的通用存储。除用户状态外,还设有独立字段保存可计分块(scorable blocks)的 earned 与 possible 成绩。
唯一约束派生索引:('student', 'module_id', 'course_id')
studentstudent, module_idstudent, module_id, course_id
附加索引:module_type、module_id、course_id、grade、done、created、modified
字段明细:
| 字段名 | 类型 | 说明 |
|---|---|---|
| student | User | 指向 User 表的外键。 |
| state | String | 自由格式字符串,由对应 xBlock 按上下文解释(如题目作答状态)。 |
| module_type | String (32) | xBlock 的块类型,例如:problem、video、html、chapter 等。 |
| module_id | UsageKey (255) | xBlock 的用途键(usage key)。 |
| modified | DateTime | 行最后修改时间。 |
| max_grade | Float | 用户提交题目时该题目的raw_possible得分。持久化此值可保证题目内容后续变化不影响用户在该题上的历史得分。 |
| grade | Float | 用户在该题目上的raw_earned得分。 |
| done | String | 可能取值:Not Applicable(不适用)、Finished(已完成)、Incomplete(未完成)。 |
| created | DateTime | 行创建时间。 |
| course_id | CourseKey (255) | xBlock 所属课程的课程键。 |
源码印证:StudentModule实现在 lms/djangoapps/courseware/models.py。其中module_state_key字段以module_id作为数据库列名(db_column='module_id'),与文档描述一致;done字段实际存储为三字符缩写na/f/i(分别对应 NOT_APPLICABLE、FINISHED、INCOMPLETE,见 models.py)。此外还提供了两个实用类方法:
all_submitted_problems_read_only(course_id):按课程过滤出所有module_type='problem'且 grade 非空的已提交题目记录,若环境配置了只读副本则自动路由到只读副本查询(using("read_replica")),支撑成绩报告等重读场景;save_state/get_state_by_params:状态保存与批量查询的封装,保存时使用update_or_create保证幂等。
4.2 ORA 开放型题目得分:submissions_score
表名:submissions_score
表说明:ORA(Open Response Assessment,开放型互评)提交系统一组表结构中的一员,专门保存 ORA 题目的得分。
唯一约束派生索引:('id')
id
附加索引:student_item_id、submission_id、created_at
字段明细:
| 字段名 | 类型 | 说明 |
|---|---|---|
| created_at | DateTime | 行创建时间。 |
| points_earned | Positive Integer | 用户在该题目上的weighted_earned得分。 |
| points_possible | Float | 用户提交题目时该题目的weighted_possible得分。持久化此值可保证题目内容后续变化不影响历史得分。注意:由于points_earned与points_possible已是加权后的值,成绩聚合时不会再次应用题目权重。 |
| reset | Boolean | 指示此行得分应重置当前最高分。 |
| student_item | StudentItem | 指向submissions_studentitem表的外键。 |
| submission | Submission | 指向submissions_submission表的外键。 |
理解要点:与courseware_studentmodule中保存的是raw_earned/raw_possible(原始分)不同,submissions_score直接保存加权后的weighted_earned/weighted_possible。这意味着两类表在成绩聚合时的处理方式不同:小节聚合(Subsection Grade 中的earned_all/possible_all等字段)直接对courseware_studentmodule的原始分应用权重后求和,而对submissions_score则直接累加其加权值,不再二次加权。这一点对于理解不同题型成绩计算的差异至关重要。
五、"Include in Data Package" 列的含义
三张主表中多张字段表末尾都有"Include in DP(Data Package)"一列,标注为 Y(包含)或 N(不包含)。它标识该字段是否纳入 Open edX 面向分析/数据仓库导出的**数据包(Data Package)**中。从字段分布规律可以看出:
- 用户、课程、成绩数值、时间戳等与分析维度强相关的字段(
course_id、user_id、percent_grade、letter_grade、passed_timestamp、created、modified、earned_all、possible_all等)均为Y; - 仅用于调试/内部实现细节的字段(
course_edited_timestamp、course_version、subtree_edited_timestamp、visible_blocks引用、blocks_json、hashed等)均为N。
这一设计让数据分析团队可以放心导出标 Y 的字段,而不会把面向排障的内部实现细节污染到分析数据集中。
六、延伸阅读与调试建议
- 成绩模型完整源码见 lms/djangoapps/grades/models.py,其中
BlockRecordList、VisibleBlocks、PersistentSubsectionGrade、PersistentCourseGrade、PersistentSubsectionGradeOverride依次定义,注释中包含了大量索引设计动机说明; - 模型层单元测试见 lms/djangoapps/grades/tests/test_models.py,可参考其对必填字段完整性(如
grading_policy_hash缺失触发IntegrityError)的断言来理解字段约束; - 成绩事件(course/subsection grade calculated 事件)见 lms/djangoapps/grades/events.py,事件集成测试见 lms/djangoapps/grades/tests/integration/test_events.py;
- 成绩表结构迁移历史位于 lms/djangoapps/grades/migrations 目录,例如 0006_persistent_course_grades.py 即课程成绩表的初始迁移,可用于对照实际建表 SQL;
StudentModule完整定义见 lms/djangoapps/courseware/models.py,其历史表StudentModuleHistory位于同一文件后半部分(成绩报告依赖该历史表回溯题目得分变化)。
运维排障速查:当学习者成绩"未更新"时,可按层级依次排查——先看grades_persistentcoursegrade的grading_policy_hash是否与当前策略哈希一致(不一致说明策略变更后未触发重算);再看grades_persistentsubsectiongrade的earned_graded/possible_graded是否反映了最新作答;最后检查courseware_studentmodule或submissions_score中的原始得分记录,并留意submissions_score的加权字段在聚合时不再二次加权的特殊规则。
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考