「同一时段不能重叠」守在哪里
排班系统的需求只有一句:同一个人同一时间只能在一个班上。模型里有一个
overlaps方法,保存前检查一遍,单元测试覆盖了包含、相交、相邻几种情况。上线一段时间后,还是查到了同一个人在同一时段有两条班次。
这条规则看起来简单,难在它的形状:它不是某一行数据自己的约束,而是同一个成员的所有班次之间的约束。单行的约束放进构造器就能守住;跨行的约束要回答两个问题:判断「重叠」时看到的是不是全部班次,以及两个并发的请求能不能同时通过检查。
本文从需求出发,把这条规则依次放在唯一键、应用层检查、锁住成员、数据库排他约束四个位置上,看哪些位置守得住。实验见文末,并发交错由闩锁固定。
一、从需求到规则:先把「重叠」说清楚
「不能重叠」需要先回答三个业务问题,答案会直接变成模型:
| 问题 | 不同的答案 | 模型里的体现 |
|---|---|---|
| 一个班 9:00—12:00,另一个 12:00—15:00,算重叠吗? | 交接班允许首尾相接时,不算 | 区间是半开的 [start, end) |
| 约束的范围是谁? | 同一个成员;不同成员同一时段可以 | 约束带上 member_id |
| 取消的班次还占时间吗? | 通常不占 | 约束只对有效状态生效 |
第一条最容易被忽略。把区间写成闭区间 [9:00, 12:00],12:00 这一刻同时属于前后两个班,首尾相接也成了重叠;写成半开区间,相邻的两段恰好拼起来,中间没有缝隙也没有重叠。两个半开区间重叠的判断只有一行:
record TimeSlot(LocalDateTime start, LocalDateTime end) {
TimeSlot {
if (!start.isBefore(end)) throw new IllegalArgumentException("开始必须早于结束:" + start + " / " + end);
}
boolean overlaps(TimeSlot other) {
return start.isBefore(other.end) && other.start.isBefore(end);
}
}值对象守住了「开始早于结束」这条单个区间的规则,也给出了「重叠」的唯一定义。它守不住「这个人的所有班次两两不重叠」,因为一个 TimeSlot 看不到其他班次。值对象的构造即合法,见实体与值对象。
二、唯一键表达的不是这条规则
最直接的想法是加一个唯一键:
唯一键 (member_id, starts),已有 09:00—12:00,再插入 10:00—11:00:插入成功,现在有 2 条重叠的值班唯一键只能表达「这几列的值不能相同」。两个班的开始时间不同,就都能通过,哪怕一个完全包含另一个。把 (member_id, starts, ends) 都放进唯一键也一样:重叠是两个区间的关系,不是某几列取值相等。这类约束在 MySQL 里没有对应的声明式约束可用。
三、先查后插:每个事务都看不到对方
于是在应用层检查:插入前查一下这个成员有没有与新区间重叠的班次。
SELECT COUNT(*) FROM duty WHERE member_id = ? AND starts < :end AND ends > :start单线程时它完全正确。两个请求同时为同一个人排 9:00—12:00 和 10:00—13:00 时:
两个事务各自先查「有没有重叠」再插入(REPEATABLE READ):提交成功 2 个;表里重叠的值班 2两个事务查询时,对方的行都还没提交,各自看到的都是「没有重叠」,然后各自插入。这是典型的写偏斜:每个事务单独看都没有违反规则,合起来违反了。它们插入的是不同的行,不会在同一行上发生写冲突,所以 REPEATABLE READ 不会拦住它们。
这里的问题不在于检查写得不对,而在于检查和写入之间没有任何东西让两个请求排队。
四、锁住成员:让规则的范围成为排队的单位
规则的范围是「同一个成员」。如果每次给这个成员排班前,先锁住代表这个成员的那一行,同一个成员的排班请求就被串行化了:
SELECT id FROM member WHERE id = ? FOR UPDATE; -- 后到的事务在这里等待
SELECT COUNT(*) FROM duty WHERE member_id = ? AND starts < :end AND ends > :start;
INSERT INTO duty ...;检查前先 SELECT … FOR UPDATE 锁住成员行(后到的事务在这里等待先到的提交):提交成功 1 个,另一个失败(IllegalStateException:重叠);表里的值班 1后到的事务等先到的提交之后,再执行检查,这时读到了刚提交的班次,检查生效。
这正是聚合在做的事。把「一个成员的排班表」看成一个聚合,根是成员,overlaps 的检查放在根的方法里,并发控制作用在根上:
class MemberSchedule { // 聚合根:一个成员的全部有效班次
private final MemberId member;
private final List<Duty> duties;
Duty assign(TimeSlot slot) {
if (duties.stream().filter(Duty::active).anyMatch(d -> d.slot().overlaps(slot)))
throw new IllegalStateException("与已有班次重叠:" + slot);
...
}
}仓储加载 MemberSchedule 时锁住成员行(或者用成员行上的版本号做乐观锁),保存时一起提交。聚合的边界由这条不变量决定:它的范围是一个成员,聚合就是一个成员的排班表,不是整个排班周期,也不是单个班次。边界画大画小的代价,见聚合边界。
这个做法的适用条件和代价:
- 每个成员的班次数量有限,加载全部有效班次的成本可以接受。班次很多时,只加载与新区间相关的那一段时间,但锁仍然加在成员上。
- 所有修改排班的路径都必须先拿这把锁,包括批量导入、后台调整。漏掉一条路径,规则就在那条路径上失守,而且没有任何报错。
- 同一成员的请求被串行化。对排班这种低频写入影响很小;如果是热点资源(一个会议室被大量并发预订),锁等待会排起长队。
五、数据库排他约束:把规则交给数据库
PostgreSQL 可以直接声明这条规则:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE duty (
id SERIAL PRIMARY KEY,
member_id INT,
period TSRANGE,
EXCLUDE USING gist (member_id WITH =, period WITH &&)
);意思是:任意两行,如果 member_id 相等,并且 period 相交(&&),就不允许同时存在。btree_gist 扩展让普通的整数列也能参与 GiST 索引。
两个事务并发插入重叠区间:提交成功 1 个,另一个失败(PSQLException:conflicting key value violates exclusion constraint "duty_member_id_period_excl")
另一天:半开区间 [09:00, 12:00) 之后接 [12:00, 15:00):插入成功;闭区间 [15:00, 18:00] 之后接 [18:00, 20:00):被拒绝(18:00 同时属于两段)
另一个成员同一时段:插入成功并发插入时,数据库在约束的索引上让后到的事务等待,先到的提交后,后到的报违反约束。应用层不需要锁,也不会因为某条路径忘了加锁而失守。区间的开闭在 TSRANGE 字面量里写明,[ 是闭、) 是开,第一节的业务问题在这里有了一个精确的落点。「取消的班次不占时间」可以写成带 WHERE (status <> 'CANCELLED') 的排他约束。
区间的开闭在交接时刻的差别,画在时间轴上:
EXCLUDE USING gist (member_id WITH =, period WITH &&)):[09:00, 12:00) 之后接 [12:00, 15:00) 可以插入;[15:00, 18:00] 之后接 [18:00, 20:00) 被拒绝,因为 18:00 这一刻同时属于两段。另一个成员同一时段不受影响代价同样要算清楚:
- 数据库专有。 换成 MySQL 没有对应的声明式约束,规则得回到应用层加锁。
- 报错是技术语言。 用户需要的是「与 9:00—12:00 的班次重叠」,而不是约束名。模型里的
overlaps检查仍然要有,用来给出业务语言的错误;约束是最后一道防线,防的是并发和漏掉的路径。 - 规则变了,约束也要迁移。 「同一个人每天最多 8 小时」这种规则,排他约束表达不了,又回到聚合里。
六、怎样选
| 位置 | 守得住并发吗 | 适用条件 | 反例 |
|---|---|---|---|
| 唯一键 | 不表达这条规则 | 规则真的是「某几列取值不能相同」 | 区间重叠、数量上限 |
| 应用层先查后插 | 守不住 | 只有单线程写入(离线批处理) | 任何并发写入的接口 |
| 聚合根 + 锁成员 | 守得住,前提是所有路径都拿锁 | 规则范围小(一个成员)、写入不频繁;需要表达更复杂的规则 | 热点资源上的大量并发预订 |
| 数据库排他约束 | 守得住,与代码路径无关 | 使用 PostgreSQL;规则能写成「相等且相交」 | 跨数据库产品、按累计时长的规则 |
两者常常一起用:聚合负责业务语言和复杂规则,排他约束负责在所有路径都失守时挡住脏数据。和构造器里写了校验,对象就一定合法吗的结论一致,模型里的检查只管经过它的路径,数据库约束管的是所有落盘的数据。
七、测试要证明什么
这条规则的测试分三层,各自证明不同的事:
- 值对象的单元测试:包含、相交、首尾相接、完全不相交,以及开始不早于结束时构造失败。证明「重叠」的定义正确。
- 聚合的单元测试:已有班次时分配重叠的班次被拒绝,取消的班次不参与判断。证明规则在聚合里被执行。
- 并发测试:两个事务同时为同一个人排重叠的班,最终只有一条。这一层单元测试做不到,需要真实数据库,并且要用闩锁让两个事务在「查询之后、插入之前」交错,否则测试大多数时候碰巧通过。
只有前两层时,测试全绿,开头的问题仍然会出现。
八、检查清单
- 区间统一用半开
[start, end),并在需求里确认首尾相接是否允许。 - 先写清约束的范围(同一个成员、同一个房间),它决定了聚合的根和锁的粒度。
- 跨行的规则不能只靠「先查后插」;要么所有路径都先锁住范围,要么交给数据库约束。
- 唯一键只表达取值相等,不表达区间关系。
- 模型里的检查负责业务语言的错误,数据库约束负责最后一道防线。
- 并发测试用闩锁固定交错,不依赖时机。
配套实验
- codesphere-labs/ddd/interval-overlap:MySQL 8.4.11、PostgreSQL 18.6、JDK 25.0.4.1:唯一键、并发先查后插、锁住成员行、PostgreSQL 排他约束四种做法,以及半开与闭区间的首尾相接(验证记录)
参考资料