PAYATHON 2026

DDD 中如何建模并维护 many to many 关系的 invariants?

技术老齐

结论

不需要把全校学生和课程放进一个 EnrollmentBoard aggregate。要严格满足这三个不变量(invariants),可以保留较小的模型,在一次数据库事务中协调选课,用锁、唯一约束等机制处理并发。

同一个学生也可以有两个模型:Student 管理学生资料,StudentSchedule 或 StudentEnrollments 管理选课。它们通过 studentId 关联,分别负责各自的业务规则。

首先要明确选课上限和重复选课的计算范围:按学期、课程,还是具体教学班计算。这里假设每名学生每学期最多选 5 个教学班,且不能重复选择同一教学班。具体教学班称为 CourseOffering。如果业务还禁止同一学期选择同一课程的不同教学班,就需要按 courseId 增加约束。

为什么 EnrollmentBoard 容易变得过大

many to many 描述的是关联关系,不能直接决定 aggregate 边界。划分 aggregate 时,要考虑哪些状态必须一起保持一致,以及并发请求会怎样修改这些状态。

这三个规则涉及的范围不同:

规则需要协调的范围
不能重复选课某个学生与某个教学班的选课记录
每学期最多选 5 门某个学生在该学期的全部有效选课
教学班不能超员某个教学班的全部有效选课

EnrollmentBoard 把这些范围放在一起,便于在一个对象内表达规则,也会让大量互不相关的请求进入同一个一致性边界。即使两名学生选择的是两门不同课程,也可能争用同一个 aggregate 的锁或版本号。

原来的 Course.enrollStudent() 在串行执行时可以完成检查。并发请求却可能同时看到“还剩一个名额”,然后分别添加学生。因此,即使所有逻辑都写在同一个 aggregate 方法中,持久化时仍然需要并发控制。

先拆分业务职责

可以按以下方式划分:

  • Student:管理姓名、邮箱等学生资料。
  • Course:管理课程定义,例如名称、课程说明。
  • CourseOffering:管理某学期实际开放的教学班及容量。
  • StudentSchedule:管理某个学生在某学期的选课限制。
  • Enrollment:记录学生与教学班之间的选课关系,必要时包含状态、选课时间等。

CourseOffering 对应问题中“将课程纳入可选课范围”的动作。课程目录里有一门课程,不代表它已经在某个学期开班。

这些概念不必全部成为 aggregate root。Enrollment 是否独立成为 aggregate,取决于它是否有独立的生命周期和操作。把它单独提取出来,仍然需要解决学生选课上限与教学班容量之间的一致性问题。

如果规定每次选课只能修改一个 aggregate,就需要重新设计名额分配流程;如果允许同一数据库事务协调少量 aggregate,就可以直接实现当前规则。需要明确选择哪种方式,避免为了容纳所有规则而不断扩大 EnrollmentBoard。

在同一数据库内,用事务维护严格约束

假设选课数据存放在同一个支持事务和行锁的关系数据库中,可以采用以下流程:

  1. 锁定该学生在本学期的 StudentSchedule 记录。
  2. 锁定目标 CourseOffering 记录。
  3. 检查是否已经选课、学生是否达到上限、教学班是否已满。
  4. 插入 Enrollment。
  5. 提交事务后,返回选课成功。

每次选课只需锁定这两条协调记录,再查询相关选课数量,无须加载全校学生和课程。

以下是 TypeScript 风格的持久化伪代码。tx 的方法用于说明事务语义,不对应某个 ORM 的现成 API:

async function enroll(
  studentId: number,
  termId: number,
  offeringId: number
): Promise<void> {
  await database.transaction(
    { isolationLevel: "READ COMMITTED" },
    async (tx) => {
      // 两个方法都读取并锁定对应行,例如 SELECT ... FOR UPDATE。
      const schedule = await tx.lockStudentSchedule(studentId, termId);
      const offering = await tx.lockCourseOffering(offeringId);

      if (!schedule || !offering) {
        throw new Error("Student schedule or course offering not found");
      }

      if (offering.termId !== termId) {
        throw new Error("Term mismatch");
      }

      if (await tx.hasEnrollment(studentId, offeringId)) {
        throw new Error("Already enrolled");
      }

      const studentCourseCount =
        await tx.countStudentEnrollments(studentId, termId);

      if (studentCourseCount >= 5) {
        throw new Error("Student course limit reached");
      }

      const offeringStudentCount =
        await tx.countOfferingEnrollments(offeringId);

      if (offeringStudentCount >= offering.studentLimit) {
        throw new Error("Course offering is full");
      }

      await tx.insertEnrollment({
        studentId,
        offeringId
      });
    }
  );
}

这个方案要求所有选课请求遵守同一套锁定规则:

  • 同一学生的并发选课请求,必须先取得同一条 StudentSchedule 记录的锁。
  • 同一教学班的并发选课请求,必须取得同一条 CourseOffering 记录的锁。
  • 取得锁之后,再读取当前数量并执行写入。

锁定协调记录,可以协调尚未插入的 Enrollment。只锁定已有选课记录是不够的,因为目标选课记录可能还不存在。

代码假设在 READ COMMITTED 下,取得锁后的计数查询能看到此前已提交的选课。实现时要核对数据库的锁与快照语义,确保这一假设成立。用于决策的数据不能来自旧快照或异步读库。

数据库约束仍然需要保留

禁止重复选课还需要数据库唯一约束。例如,当一条记录代表一个有效选课关系时,可以使用:

UNIQUE (student_id, offering_id)

应用层检查提供清楚的业务反馈,唯一约束拒绝重复写入。

如果保留退课历史,同一个学生可能多次选入、退出同一教学班。这时要区分“历史记录”和“当前有效关系”,只约束有效关系的唯一性,不能让所有历史记录共用上述唯一键。

“最多 5 门”和“不能超员”属于跨记录约束。普通行级 CHECK 不能直接对另一张表的全部选课记录执行计数检查,仍需使用上述协调机制,或采用经过完整设计的计数器、名额记录等方案。

计数不等于加载完整集合

COUNT 查询不需要把每条选课记录加载成领域对象。可以针对学生学期和教学班建立合适的索引,再根据实际数据量评估查询成本。

如果计数查询成为瓶颈,可以维护:

StudentSchedule.enrolledCount
CourseOffering.enrolledCount

插入选课记录和更新两个计数器必须在同一事务内完成,退课时也要对称处理。使用计数器后,还需要维护计数器与选课记录之间的一致性,不能直接用异步更新的统计数字判断严格的容量限制。

应用服务负责协调,领域模型负责表达规则

事务流程通常放在应用服务中,由它开启事务、取得锁、调用领域行为并保存变更。

领域模型表达 StudentSchedule 的选课上限和 CourseOffering 的容量规则,数据库提供唯一性和并发写入保护,共同保证这些规则在并发请求下成立。

“一次事务只修改一个 aggregate”是常见的设计指导。如果选择在一个本地事务内修改多个 aggregate,就要明确接受它们在选课操作上的事务耦合。对于同库部署、要求规则即时成立的系统,这种取舍可以是合理的。

如果它们分属不同服务或数据库,就不能直接沿用这个方案,可以考虑预留名额的流程:

  1. 预留学生的一个选课额度。
  2. 预留教学班的一个座位。
  3. 两边都成功后确认 Enrollment;失败时释放预留。

预留本身也必须计入额度和容量。流程还要处理幂等、超时释放,以及预留过期后才到达的确认请求。仅靠“先写一边,再发事件更新另一边”,无法保证整个过程中始终满足这三个严格约束。

按专业拆分 EnrollmentBoard 是否合适

能否按专业拆分,取决于业务规则能否在各专业内独立执行。

例如,两个专业的学生都能选择同一个教学班,两个 EnrollmentBoard 就仍然会竞争同一批座位。学生跨专业选课时,“最多 5 门”的限制也可能跨越多个 board。

只有学校已经为不同专业分配独立的座位配额,且配额总和不超过容量,才有条件按配额分别维护容量。单纯按专业切分数据,无法自动切分一致性约束。

按学期划分通常更符合选课业务,但也只能缩小数据范围。同一学期内,仍然需要协调学生额度和教学班容量。

Student 与 StudentEnrollments 可以并存

Student 维护学生资料,StudentSchedule(studentId, termId) 维护学生某学期的选课。它们描述同一个现实中的学生,管理的业务状态却不同。修改姓名时无须加载选课,调整选课时也无须加载邮箱、年龄等资料。

每类数据都应有明确的写入入口:

  • 学生资料通过 Student 修改。
  • 有效选课通过选课流程修改。
  • 课程容量通过受约束的容量调整流程修改。

所有选课写入,包括管理员调整、批量导入和退课,都必须遵守同一套事务与锁定协议。下调容量时,要检查现有有效选课人数。批量操作则需要约定一致的锁定顺序,并处理死锁或事务冲突后的重试。

StudentEnrollments 可以作为独立的选课模型,无须依附于全校规模的 EnrollmentBoard。各模型的职责可以保持清楚,每次选课需要协调的数据也能限制在当前学生与当前教学班的范围内。

备注:内容仅供参考。