news 2026/9/17 6:38:02

Open edX 成绩数据模型深度解析:从 Course Grades 到 Subsection Grades 与 Problem Scores 的持久化存储架构

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Open edX 成绩数据模型深度解析:从 Course Grades 到 Subsection Grades 与 Problem Scores 的持久化存储架构

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_persistentcoursegradegrades_persistentsubsectiongradegrades_visibleblocksgrades_persistentsubsectiongradeoverridecourseware_studentmodulesubmissions_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 中的PersistentCourseGradePersistentSubsectionGradeVisibleBlocksPersistentSubsectionGradeOverride四个 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_idCourseKey所属课程的课程键。示例:course-v1:org+course+run(新式课程)或org/course/run(旧式课程)Y
user_idInteger学习者用户 ID。示例:41446Y
course_edited_timestampDateTime成绩计算时课程的最后编辑时间戳。当前仅用于调试目的。示例:2016-12-21 15:50:23.645000N
course_versionString (255)成绩计算时课程在 Split Modulestore 中的版本号。当前仅用于调试。注意:旧版 Mongo modulestore 不支持版本概念,因此该字段对于此类课程为 NULL,应改用course_edited_timestamp理解课程内容的日期信息。示例:58ff632f00d9e7501e0148c4N
grading_policy_hashString (255)课程评分策略(grading policy)的 SHA-1 摘要,用于在策略变化时检测并更新成绩。示例:NiGhcAFSrpyijXbow/XKE1Cp1GA=Y
percent_gradeFloat按评分策略计算的课程成绩小数百分比。示例:0.91(即 91%)Y
letter_gradeString (255)按评分策略计算的字母成绩(如 A→D、Pass)。若学习者成绩为 Fail 或 F,此字段为空。示例:PassAY
passed_timestampDateTime学习者首次通过课程的时间。若为空,表示从未通过;若非空但letter_grade为空,表示学习者从通过状态转为未通过状态。注意:由于成绩由平台异步计算(外部评分器、ORA 评分等),该时间与触发通过的题目提交时间之间存在延迟。示例:2017-05-02 15:51:04.395055Y
createdDateTime该用户该课程成绩首次计算的时间。注意:回填(Backfilled)的成绩此值会被设置为最终计算并回填的时间。Y
modifiedDateTime该用户该课程成绩最后更新的时间。回填成绩同样取回填时间。Y

2.2 索引设计意图与源码印证

从源码 models.py 的PersistentCourseGrade.Meta可以看到索引设计注释与文档一一对应:unique_together提供(course_id, user_id)用于查询单个成绩,(course_id)隐式创建便于教员查看课程全部成绩;显式索引(passed_timestamp, course_id)用于追踪首次及格时间,(modified, course_id)用于按时间段查找更新的成绩。这与数据模型文档中的"附加索引"完全一致。

预期的读取使用场景

读取场景功能/团队所需索引
进度页展示学习者课程成绩及成绩分解信息LMS Progress Pagecourse_id, user_id
课程仪表盘展示学习者每门已注册课程的成绩LMS Student Dashboarduser_id
成绩报告为课程内每个学习者生成含课程成绩的 CSVLMS Grade Reportcourse_id
统计课程完成情况Analytics/Course Completioncourse_id, passed_timestamp

2.3 写入路径:update_or_create 与通过事件

课程成绩的持久化入口是PersistentCourseGrade.update_or_create(models.py),其核心逻辑包括:

  1. (user_id, course_id)为查找键执行update_or_create,将course_version缺省值规范为空字符串;
  2. 首次通过处理:若本次计算标记为passed=True且该成绩尚无passed_timestamp,则发送COURSE_GRADE_PASSED_FIRST_TIME信号、写入当前时间戳,并发送COURSE_GRADE_PASSED_UPDATE_IN_LEARNER_PATHWAY信号(该信号定义于 lms/djangoapps/grades/signals/signals.py),支撑学习者路径(Learner Pathway)等下游功能;
  3. 发送course_grade_calculated事件(events.course_grade_calculated);
  4. 更新请求级缓存grades_cache.{course_id}
  5. 发送 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 Gradegrades_persistentsubsectiongrade)与Visible Blocksgrades_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_id
  • course_id, user_id
  • course_id, user_id, usage_key

附加索引visible_blocks_hash(外键引用 VisibleBlocks 的哈希列)

字段明细

字段名类型说明是否包含在数据包(DP)中
course_idCourseKey所属课程的课程键。示例:course-v1:org+course+run(新式)或org/course/run(旧式)Y
course_versionString (255)成绩计算时课程在 Split Modulestore 中的版本号。当前仅用于调试。示例:58ff632f00d9e7501e0148c4N
createdDateTime该用户该小节成绩首次计算的时间。回填成绩取最终回填时间。Y
earned_allFloat该小节中用户聚合的total_weighted_earned得分,即小节内所有题目weighted_earned值之和。Y
earned_gradedFloat小节内所有**计分(graded)**题目的weighted_earned值之和。Y
first_attemptedDateTime用户在小节内首次尝试题目的时间。若用户未尝试过该小节,则不存在该小节的记录。回填成绩会尽力推导该值——取小节内可用题目已尝试分数的created日期最小值。Y
modifiedDateTime该用户该小节成绩最后更新的时间。回填成绩取回填时间。Y
possible_allFloat小节内所有题目的weighted_possible值之和(总分)。Y
possible_gradedFloat小节内所有计分题目的weighted_possible值之和。Y
subtree_edited_timestampDateTime成绩计算时小节内容或其任意后代内容最后编辑的时间戳。当前仅用于调试。示例:2016-12-21 15:50:23.645000N
usage_keyUsageKey小节的用途键(别名:module_idlocation)。示例:block-v1:org+course+run+type@sequential+block@1234(新式课程)或i4x://org/course/sequential/1234(旧式课程)Y
user_idInteger学习者用户 ID。示例:41446Y
visible_blocksVisibleBlocks指向grades_visibleblocks表的外键。N

预期的读取使用场景

读取场景功能/团队所需索引
与之前成绩比较,判断是否需要条件性更新(如提高分数重算 Rescore to Increase)Rescore to Increasecourse_id, user_id, usage_key
详细成绩报告为课程内每个学习者生成含小节成绩的 CSVLMS Grade Reportcourse_id
进度页展示学习者小节成绩分解LMS Progress Pagecourse_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_gradebulk_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_idCourseKey所属课程的课程键。N
hashedString (100)blocks_json值的 SHA1 哈希。N
blocks_jsonLongText包含以下信息的 JSON:version:数据格式版本号的整数;course_key:所属课程的序列化 CourseKey;blocks:小节内用户可访问的所有块(block)的序列化 UsageKey 有序列表。注意:blocks 字段保存的是计算小节成绩时用户可见的全部块的使用键列表。当用户对小节内容的访问权限发生变化时(分班 cohort 变更、角色变更、课程团队增删单元/题目等),该值会随之改变,并在表中创建带新哈希值的新行。N

设计精妙之处:由于grades_visibleblocksblocks_json的 SHA1 哈希作为唯一键,相同的可见块集合只存一行,多个学习者(同一分班、同一角色)可共享同一条记录,从而显著减少冗余。其 JSON 结构中的 version 字段则用于支持未来数据格式的演进。

源码印证VisibleBlocks(models.py)内部使用BlockRecordListBlockRecord两个工具类来序列化/反序列化可见块数据(BLOCK_RECORD_LIST_VERSION = 1,见 models.py)。BlockRecord是包含locatorweightraw_possiblegraded四个字段的命名元组,记录了块在参与成绩计算时的定位符、权重、原始满分与是否计分。序列化时采用separators=(',', ':')sort_keys=True生成紧凑、确定性 JSON,再计算 base64 编码的 SHA-1 摘要(hash_value)。该模型还实现了完整的请求级缓存体系(bulk_readcached_get_or_createbulk_createbulk_get_or_create),以visible_blocks_cache.{course_key}.{user_id}为键,避免同一请求周期内重复读写数据库。

3.3 Subsection Grade Overrides:成绩覆盖与审计

表名grades_persistentsubsectiongradeoverride
表说明:保存指定小节最近一次的覆盖记录。在成绩计算中,覆盖值取代持久化的小节成绩汇总。其历史版本表grades_historicalpersistentsubsectiongradeoverride保存此前各次覆盖的滚动记录,用于审计目的。

唯一约束派生索引('id')

  • id

附加索引createdmodifiedgrade_id

字段明细

字段名类型说明
idint(11)覆盖记录的自增 ID。
createddatetime(6)覆盖首次创建的时间。
modifieddatetime(6)覆盖最后修改的时间。
earned_all_overridedouble被覆盖的总得分(含计分与不计分题目)。注意:该字段的实际用法尚不明确,因为某些情况下其为 NULL,且不参与成绩计算。
possible_all_overridedouble小节的总可能得分(含计分与不计分题目)。
earned_graded_overridedouble小节的被覆盖得分。
possible_graded_overridedouble小节的计分题目总可能得分。
grade_idbigint(20) unsignedgrades_persistentsubsectiongrade.id一一对应,指明该覆盖作用于哪条成绩。
override_reasonvarchar(300)教员提供的覆盖原因。示例:Student bribed me with doughnuts so I'm increasing their score.
systemvarchar(100)执行覆盖的系统来源。示例:GRADEBOOKgrade-import

源码印证PersistentSubsectionGradeOverride(models.py)实现要点:

  • 通过OneToOneFieldPersistentSubsectionGrade关联(related_name='override'),一条成绩至多一条覆盖;
  • 使用django-simple-historyHistoricalRecords自动维护审计历史,且通过apps.app_configs判断避免在 CMS(Studio)环境中因 grades app 未安装而导致的历史记录连接失败;
  • update_or_create_override接收requesting_user,并将该用户挂到_history_user非字段属性上,使历史记录能正确标注操作人;
  • _prepare_override_params定义了字段白名单映射:earned_all_override ← earned_allpossible_all_override ← possible_allearned_graded_override ← earned_gradedpossible_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')

  • student
  • student, module_id
  • student, module_id, course_id

附加索引module_typemodule_idcourse_idgradedonecreatedmodified

字段明细

字段名类型说明
studentUser指向 User 表的外键。
stateString自由格式字符串,由对应 xBlock 按上下文解释(如题目作答状态)。
module_typeString (32)xBlock 的块类型,例如:problem、video、html、chapter 等。
module_idUsageKey (255)xBlock 的用途键(usage key)。
modifiedDateTime行最后修改时间。
max_gradeFloat用户提交题目时该题目的raw_possible得分。持久化此值可保证题目内容后续变化不影响用户在该题上的历史得分。
gradeFloat用户在该题目上的raw_earned得分。
doneString可能取值:Not Applicable(不适用)、Finished(已完成)、Incomplete(未完成)。
createdDateTime行创建时间。
course_idCourseKey (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_idsubmission_idcreated_at

字段明细

字段名类型说明
created_atDateTime行创建时间。
points_earnedPositive Integer用户在该题目上的weighted_earned得分。
points_possibleFloat用户提交题目时该题目的weighted_possible得分。持久化此值可保证题目内容后续变化不影响历史得分。注意:由于points_earnedpoints_possible已是加权后的值,成绩聚合时不会再次应用题目权重。
resetBoolean指示此行得分应重置当前最高分。
student_itemStudentItem指向submissions_studentitem表的外键。
submissionSubmission指向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_iduser_idpercent_gradeletter_gradepassed_timestampcreatedmodifiedearned_allpossible_all等)均为Y
  • 仅用于调试/内部实现细节的字段(course_edited_timestampcourse_versionsubtree_edited_timestampvisible_blocks引用、blocks_jsonhashed等)均为N

这一设计让数据分析团队可以放心导出标 Y 的字段,而不会把面向排障的内部实现细节污染到分析数据集中。

六、延伸阅读与调试建议

  • 成绩模型完整源码见 lms/djangoapps/grades/models.py,其中BlockRecordListVisibleBlocksPersistentSubsectionGradePersistentCourseGradePersistentSubsectionGradeOverride依次定义,注释中包含了大量索引设计动机说明;
  • 模型层单元测试见 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_persistentcoursegradegrading_policy_hash是否与当前策略哈希一致(不一致说明策略变更后未触发重算);再看grades_persistentsubsectiongradeearned_graded/possible_graded是否反映了最新作答;最后检查courseware_studentmodulesubmissions_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 6:35:44

Linux EOF与heredoc完全指南:从基础语法到实战避坑

1. EOF 到底是什么&#xff1a;从一个小例子说起我最早接触 EOF&#xff0c;是看同事写初始化脚本时满屏幕的cat << EOF&#xff0c;当时第一反应是“这玩意儿是要读文件直到文件尾吗&#xff1f;”后来才搞清楚&#xff0c;这里的 EOF 根本不是“文件末尾”的意思&#…

作者头像 李华
网站建设 2026/9/17 6:34:40

SpringBoot+Vue构建高效政务管理系统的架构实践

1. 项目背景与核心价值政府管理系统作为政务数字化转型的核心载体&#xff0c;其技术架构的先进性直接决定了行政效率和服务质量。传统单体架构在长期实践中暴露出三个致命缺陷&#xff1a;首先是前后端高度耦合导致的维护成本飙升&#xff0c;每次需求变更都需要全链路回归测试…

作者头像 李华
网站建设 2026/9/17 6:32:45

绕过微软商店,离线安装Microsoft To Do的完整教程

微软商店里的 Microsoft To Do 装了三次都失败&#xff0c;报错代码换来换去&#xff0c;要么卡在“正在下载”半天不动&#xff0c;要么进度条走完提示“无法安装”。这类问题这几年一直没断过&#xff0c;我自己也被折腾过几回。如果你也遇到这种情况&#xff0c;其实不用死磕…

作者头像 李华
网站建设 2026/9/17 6:32:19

Windows下从源码构建Cheat Engine:环境配置与编译避坑指南

很多人第一次接触 Cheat Engine&#xff0c;都是从“打开游戏 -> 扫描数值 -> 修改”这条链路开始的。但如果你在逆向、调试或者做游戏模组测试这条路上走得够久&#xff0c;迟早有一天会不满足于用别人编译好的二进制&#xff0c;而是想把 Cheat Engine 源码拉下来&…

作者头像 李华
网站建设 2026/9/17 6:32:07

Windows下VS Code与Git深度集成实战指南

1. 这不是“又一篇Git教程”&#xff0c;而是Windows开发者每天真实踩坑的现场复盘你是不是也经历过这些瞬间&#xff1a;刚在VS Code里点下CtrlShiftP&#xff0c;输入“Git: Clone”&#xff0c;结果弹出报错“Command git.clone not found”&#xff1b;或者好不容易配好Git…

作者头像 李华
网站建设 2026/9/17 6:31:57

小尺寸低功耗双频WiFi6+BLE模组实战拆解与选型指南

1. 项目概述与定位分析做物联网模组选型这么多年&#xff0c;我见过太多“参数党”产品——规格表上纸面数据一个比一个漂亮&#xff0c;实际贴片打样、做功耗调试时却原形毕露。觅感这款双频 WiFi6&BLE 组合模组&#xff0c;第一次看到规格书时我的第一反应是&#xff1a;…

作者头像 李华