Skip to content

「同一时段不能重叠」守在哪里 ​

排班系统的需求只有一句:同一个人同一时间只能在一个班上。模型里有一个 overlaps 方法,保存前检查一遍,单元测试覆盖了包含、相交、相邻几种情况。上线一段时间后,还是查到了同一个人在同一时段有两条班次。

这条规则看起来简单,难在它的形状:它不是某一行数据自己的约束,而是同一个成员的所有班次之间的约束。单行的约束放进构造器就能守住;跨行的约束要回答两个问题:判断「重叠」时看到的是不是全部班次,以及两个并发的请求能不能同时通过检查。

本文从需求出发,把这条规则依次放在唯一键、应用层检查、锁住成员、数据库排他约束四个位置上,看哪些位置守得住。实验见文末,并发交错由闩锁固定。

成员 19:0010:0011:0012:0013:0014:00事务 A [9:00, 12:00)事务 B [10:00, 13:00)重叠 10:00—12:00规则放在哪里并发提交后的结果唯一键 (member_id, starts)只拒绝开始时间相同的行两条都写入应用层先查后插(REPEATABLE READ)两边查询时都看不到对方未提交的行两条都提交聚合根:先 FOR UPDATE 锁住成员行后到的等待,再查时看到了先到的班次1 条成功,1 条报「重叠」EXCLUDE (member_id WITH =, period WITH &&)数据库在约束上让后到的等待1 条成功,1 条违反约束半开区间 [9:00, 12:00) 与 [12:00, 15:00) 首尾相接被允许;闭区间 [15:00, 18:00] 后接 18:00 开始的班次被拒绝
图 1 · 实测(MySQL 8.4.11、PostgreSQL 18.6):两个事务同时为同一个成员排 9:00—12:00 和 10:00—13:00。唯一键表达不了区间关系;各自先查再插,都看不到对方未提交的行,两条都写入。只有让同一成员的请求排队(锁住成员行,即聚合根),或者把「相等且相交」交给数据库的排他约束,才只留下一条。

一、从需求到规则:先把「重叠」说清楚 ​

「不能重叠」需要先回答三个业务问题,答案会直接变成模型:

问题不同的答案模型里的体现
一个班 9:00—12:00,另一个 12:00—15:00,算重叠吗?交接班允许首尾相接时,不算区间是半开的 [start, end)
约束的范围是谁?同一个成员;不同成员同一时段可以约束带上 member_id
取消的班次还占时间吗?通常不占约束只对有效状态生效

第一条最容易被忽略。把区间写成闭区间 [9:00, 12:00],12:00 这一刻同时属于前后两个班,首尾相接也成了重叠;写成半开区间,相邻的两段恰好拼起来,中间没有缝隙也没有重叠。两个半开区间重叠的判断只有一行:

java
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 看不到其他班次。值对象的构造即合法,见实体与值对象。

二、唯一键表达的不是这条规则 ​

最直接的想法是加一个唯一键:

text
唯一键 (member_id, starts),已有 09:00—12:00,再插入 10:00—11:00:插入成功,现在有 2 条重叠的值班

唯一键只能表达「这几列的值不能相同」。两个班的开始时间不同,就都能通过,哪怕一个完全包含另一个。把 (member_id, starts, ends) 都放进唯一键也一样:重叠是两个区间的关系,不是某几列取值相等。这类约束在 MySQL 里没有对应的声明式约束可用。

三、先查后插:每个事务都看不到对方 ​

于是在应用层检查:插入前查一下这个成员有没有与新区间重叠的班次。

sql
SELECT COUNT(*) FROM duty WHERE member_id = ? AND starts < :end AND ends > :start

单线程时它完全正确。两个请求同时为同一个人排 9:00—12:00 和 10:00—13:00 时:

text
两个事务各自先查「有没有重叠」再插入(REPEATABLE READ):提交成功 2 个;表里重叠的值班 2

两个事务查询时,对方的行都还没提交,各自看到的都是「没有重叠」,然后各自插入。这是典型的写偏斜:每个事务单独看都没有违反规则,合起来违反了。它们插入的是不同的行,不会在同一行上发生写冲突,所以 REPEATABLE READ 不会拦住它们。

这里的问题不在于检查写得不对,而在于检查和写入之间没有任何东西让两个请求排队。

四、锁住成员:让规则的范围成为排队的单位 ​

规则的范围是「同一个成员」。如果每次给这个成员排班前,先锁住代表这个成员的那一行,同一个成员的排班请求就被串行化了:

sql
SELECT id FROM member WHERE id = ? FOR UPDATE;   -- 后到的事务在这里等待
SELECT COUNT(*) FROM duty WHERE member_id = ? AND starts < :end AND ends > :start;
INSERT INTO duty ...;
text
检查前先 SELECT … FOR UPDATE 锁住成员行(后到的事务在这里等待先到的提交):提交成功 1 个,另一个失败(IllegalStateException:重叠);表里的值班 1

后到的事务等先到的提交之后,再执行检查,这时读到了刚提交的班次,检查生效。

这正是聚合在做的事。把「一个成员的排班表」看成一个聚合,根是成员,overlaps 的检查放在根的方法里,并发控制作用在根上:

java
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 可以直接声明这条规则:

sql
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 索引。

text
两个事务并发插入重叠区间:提交成功 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') 的排他约束。

区间的开闭在交接时刻的差别,画在时间轴上:

9:0010:0011:0012:0013:0014:0015:0016:0017:0018:0019:0020:00成员 1[09, 12)[12, 15)[15, 18][18, 20)被拒绝:18:00 同时属于两段首尾相接,插入成功成员 2[09, 12)不同成员同一时段:插入成功区间统一写成半开 [start, end):相邻两段拼起来,中间没有缝隙也没有重叠
图 2 · 实测(PostgreSQL 18.6,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),并在需求里确认首尾相接是否允许。
  • 先写清约束的范围(同一个成员、同一个房间),它决定了聚合的根和锁的粒度。
  • 跨行的规则不能只靠「先查后插」;要么所有路径都先锁住范围,要么交给数据库约束。
  • 唯一键只表达取值相等,不表达区间关系。
  • 模型里的检查负责业务语言的错误,数据库约束负责最后一道防线。
  • 并发测试用闩锁固定交错,不依赖时机。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权