代表性面试主题

如何用 PostgreSQL 18 temporal constraints 防止时间区间重叠?

后端困难
Offer.cc 编辑团队发布 更新

题干

请设计房间预订表,要求同一房间的有效时间区间不能重叠,并让预订明细引用对应期间。说明 PostgreSQL 18 的约束语法、边界语义、迁移和并发验证。

题目与背景

系统保存房间预订及其明细。过去由应用先查询冲突再插入,竞争请求仍可能产生重叠区间。请用 PostgreSQL 18 的 temporal constraints 在数据库层保证同一房间的期间不重叠,并让子表引用覆盖父表的有效期间。

面试官考察什么

重点是理解 WITHOUT OVERLAPS 用于主键或唯一约束的最后一个范围列,PERIOD 用于期间外键;数据库会把相同前缀键的非空范围视为不可重叠集合。答案还要说明半开区间、空范围、NULL、更新拆分、现有脏数据迁移及并发冲突处理。

先问清楚的澄清问题

时间模型

确认使用 tstzrange 还是 daterange、时区和边界是否采用半开区间。区间端点和相邻预订是否允许接触会直接影响约束结果。

业务键与引用关系

确认房间 ID 是否构成业务前缀、明细期间必须完全落在父期间还是只需存在重叠,以及是否允许一笔预订跨多个版本。

迁移与并发

确认旧表是否已有重叠或空区间、迁移窗口和失败回滚方式。并发插入必须依赖数据库约束和事务错误处理,不能只依赖应用锁。

30 秒回答框架

“我把房间 ID 作为前缀,把有效期范围作为最后一列,用 PRIMARY KEY (room_id, during WITHOUT OVERLAPS) 保证同房间期间不重叠。明细表用 FOREIGN KEY (room_id, PERIOD during) 引用父表的期间键。先清理重叠和空范围,再分阶段加约束;并发冲突让事务捕获唯一或排他性错误并重试,明确半开区间与时区规则。”

深入解答步骤

第一步:选择范围类型和边界

使用适合业务的离散或连续范围类型,并统一半开区间约定。拒绝空范围,明确相邻区间是否可接受;时区时间必须统一转换,避免夏令时造成意外重叠。

第二步:定义 temporal 主键

把实体标识列放在前面,范围列放在最后,并使用 WITHOUT OVERLAPS。该约束表达同一实体前缀下的范围不可重叠,同时继续提供主键的非空和唯一身份语义。

sql
CREATE TABLE room_booking (
  room_id bigint NOT NULL,
  during tstzrange NOT NULL,
  guest_id bigint NOT NULL,
  PRIMARY KEY (room_id, during WITHOUT OVERLAPS)
);

第三步:定义期间外键

如果明细记录也带有期间,使用 PERIOD 引用父表的 temporal 主键或唯一约束。确认业务需要的是完整覆盖语义,并测试父记录拆分或缩短时的级联行为。

sql
CREATE TABLE booking_charge (
  room_id bigint NOT NULL,
  during tstzrange NOT NULL,
  amount numeric NOT NULL,
  FOREIGN KEY (room_id, PERIOD during)
    REFERENCES room_booking (room_id, PERIOD during)
);

第四步:清理历史数据

上线前找出同一房间的重叠、空范围、NULL 和非法边界,制定合并、拆分或作废策略。先在影子表验证约束,再分批修复,避免一次迁移锁住大表。

第五步:处理并发写入

两个事务同时插入相同房间和重叠期间时,让数据库判定冲突;应用捕获约束错误后重新读取可用时间或返回明确冲突。不要把先查后插当作唯一保护,也不要用不可控的全局锁替代约束。

第六步:评估更新和删除语义

更新范围可能与自身或其他行冲突,拆分预订应在一个事务中完成。删除或缩短父期间前,验证期间外键的动作规则,避免产生孤儿明细或隐式扩大父期间。

第七步:验证查询与运维

测试相邻、包含、完全相同、空范围、跨时区和边界精度场景。监控约束错误率、迁移锁等待和索引大小;为写入 API 设计重试上限,防止高竞争下形成重试风暴。

高质量示例回答

我会选择 tstzrange 并统一半开区间,把 (room_id, during WITHOUT OVERLAPS) 定义为主键,再用 (room_id, PERIOD during) 的外键表达明细期间必须由父记录覆盖。迁移前清理重叠、空范围和非法端点;并发插入依赖数据库约束,应用捕获冲突并重试或返回可用时段。测试覆盖相邻与重叠边界、父期间拆分、时区转换和高竞争写入。

常见错误

  • 错误: 只在应用层先查再插入。→ 原因: 竞争事务可以同时通过检查。→ 改进: 用 temporal 约束做最终仲裁。
  • 错误: 把范围列放在主键前面。→ 原因: 语法和前缀语义要求范围列在最后。→ 改进: 先列业务键,再写 WITHOUT OVERLAPS
  • 错误: 认为相邻区间一定冲突。→ 原因: 结果取决于范围边界语义。→ 改进: 统一半开区间并测试端点。
  • 错误: 迁移时直接启用约束。→ 原因: 历史重叠或空范围会导致失败和长锁。→ 改进: 先审计清理,再分阶段发布。

追问与回答

追问 1:[10:00, 11:00)[11:00, 12:00) 会冲突吗?

在统一半开区间约定下不会,因为 11:00 只属于第二个区间。若使用闭区间或混合边界,必须先明确业务规则再建约束。

追问 2:为什么范围列必须放在最后?

temporal 主键按前缀列分组,再要求最后的范围列彼此不重叠;把范围列放在前面无法表达“同一实体”的分组语义。

追问 3:期间外键是否只检查一个瞬时点?

不是。PERIOD 表达引用期间需要被父表期间覆盖,具体组合和边界应依据 PostgreSQL 18 的约束语义及测试结果确认,不能退化成普通单点外键。

追问 4:高并发冲突时如何避免重试风暴?

限制重试次数并加入抖动,先重新读取可用区间;超过阈值就返回冲突或进入排队。监控约束错误率和锁等待,必要时按房间分片写入。

公开来源

同类题目