两个用户同时预订会议室 A 的 10:00—11:00。两个请求都先查询“没有冲突”,随后各自插入一条预约,两个事务也都成功提交。SQL 没报错,唯一键没有重复,会议室却被卖了两次。
PostgreSQL 真正形成差异的地方,不是比另一种数据库多几个数据类型,而是能把“同一资源的时间段不得重叠”变成提交时持续成立的数据库约束。
这篇文章只证明这一个判断。它不证明 PostgreSQL 在所有 OLTP 场景都优于 MySQL,也不证明所有业务规则都应该塞进数据库。
先查再插,为什么挡不住并发
最常见的预约逻辑分两步:
SELECT1FROMroom_bookingWHEREroom_id=101ANDslot&&tstzrange('2026-09-01 10:00+08','2026-09-01 11:00+08','[)');-- 没查到冲突后再执行INSERTINTOroom_booking(room_id,slot,booked_by)VALUES(101,tstzrange('2026-09-01 10:00+08','2026-09-01 11:00+08','[)'),'alice');这段逻辑在单会话下没有问题,在并发下却存在检查与写入之间的空窗:事务 A 和 B 都能在对方提交前看到“没有预约”,然后分别插入不同主键。普通唯一约束只能识别某个值是否重复,无法表达两个时间范围是否相交。
把查询和插入写在同一个 Read Committed 事务里也没有自动补上这个空窗。事务边界只能规定一组操作怎样提交,不能凭空知道业务不变量是什么。
把“不重叠”写成数据库能执行的规则
以下实验仅用于 PostgreSQL 18.6 测试库。btree_gist是 PostgreSQL 自带扩展,需要当前用户有创建扩展的权限;实验会创建表和 GiST 索引,不应直接复制到生产库。
CREATEEXTENSIONIFNOTEXISTSbtree_gist;CREATETABLEroom_booking(booking_idbigintGENERATED ALWAYSASIDENTITYPRIMARYKEY,room_idbigintNOTNULL,slot tstzrangeNOTNULL,booked_bytextNOTNULL,CHECK(NOTisempty(slot)),EXCLUDEUSINGgist(room_idWITH=,slotWITH&&));tstzrange把开始和结束时间组合为一个有边界语义的值。[)表示包含开始、不包含结束,因此 10:00—11:00 与 11:00—12:00 可以首尾相接。
排他约束声明:任意两行不能同时满足room_id相等且slot重叠。官方文档说明,排他约束通过索引实现;这里btree_gist为bigint的相等比较提供 GiST 操作符类,范围类型本身支持重叠操作符&&。
先验证串行结果:
INSERTINTOroom_booking(room_id,slot,booked_by)VALUES(101,tstzrange('2026-09-01 10:00+08','2026-09-01 11:00+08','[)'),'alice');INSERTINTOroom_booking(room_id,slot,booked_by)VALUES(101,tstzrange('2026-09-01 10:30+08','2026-09-01 11:30+08','[)'),'bob');第二条语句应以 SQLSTATE23P01,即exclusion_violation失败。这个结果证明数据库拒绝了已存在的重叠预约,但还没有证明并发提交时同样安全。
两个并发事务才是主实验
清空实验数据后打开两个会话:
TRUNCATEroom_booking RESTARTIDENTITY;会话 A:
BEGIN;INSERTINTOroom_booking(room_id,slot,booked_by)VALUES(101,tstzrange('2026-09-01 10:00+08','2026-09-01 11:00+08','[)'),'alice');-- 暂不提交会话 B:
BEGIN;INSERTINTOroom_booking(room_id,slot,booked_by)VALUES(101,tstzrange('2026-09-01 10:30+08','2026-09-01 11:30+08','[)'),'bob');会话 B 此时通常会等待,因为数据库还不知道会话 A 最终提交还是回滚。回到会话 A:
COMMIT;会话 A 提交后,会话 B 应收到23P01并进入事务失败状态:
ROLLBACK;最终验收不是“其中一条 INSERT 报错”,而是业务不变量仍成立:
SELECTbooking_id,room_id,slot,booked_byFROMroom_bookingORDERBYbooking_id;结果应只有 Alice 的一条预约。若会话 A 改为ROLLBACK,会话 B 则可以继续成功,这说明等待不是固定拒绝,而是根据并发事务的最终状态裁决。
这个实验能证明什么
- 相同会议室的重叠时间段不能同时提交;
- 约束在并发写入路径上生效,不依赖应用先查询到对方记录;
- 失败是可识别的
23P01,应用可以把它转换成“时间段已被占用”。
这个实验不能证明什么
- 不能证明排他约束在任意数据量和并发下都满足业务延迟;
- 不能证明跨数据库、外部支付或消息发送也获得原子性;
- 不能证明所有预约规则都能写成范围重叠;容量、清洁间隔、优先级和审批可能需要额外建模;
- 不能用本机一次实验外推生产吞吐。
实验结束后清理对象:
DROPTABLEroom_booking;只有确认测试库中没有其他对象依赖btree_gist时,才考虑执行DROP EXTENSION btree_gist;保留扩展本身通常不影响数据正确性。
四种方案面对同一条业务规则
共同前提是:多个应用实例并发写入,同一会议室的时间段不得重叠,冲突必须在提交前被识别。
| 方案 | 冲突怎样被发现 | 成立条件 | 代价与失效边界 |
|---|---|---|---|
| 应用先查再插 | 应用查询现有预约 | 只有单写者,或另有可靠串行化机制 | 多实例并发存在竞态;重试也可能再次撞上 |
| 锁定一行资源记录 | 所有写者先锁同一会议室行 | 所有入口严格遵循相同锁顺序 | 热门资源会串行等待;遗漏入口就失效 |
| Serializable + 完整事务重试 | PostgreSQL 检测不可串行化依赖 | 所有相关读写使用 Serializable,应用按40001重试完整事务 | 冲突高时回滚增加;外部副作用必须可重放或延后 |
| 范围类型 + 排他约束 | 数据库在写入和提交路径检查重叠 | 不变量可表达为操作符关系,所需 GiST 操作符类存在 | 维护索引有写入成本;复杂规则仍需其他设计 |
排他约束不是“最高级”的方案,而是这个不变量最直接的表达。若规则是“同一资源永远只能有一个占用区间”,约束比依赖每个调用方正确加锁更稳。若规则跨多次查询、多个聚合条件且无法表达为约束,Serializable 更合适。若热点会议室的冲突极高,主动锁定资源行可能让等待更可预测。
PostgreSQL 的分界线到底在哪里
仅仅列出 MVCC、JSONB、数组、范围类型和丰富索引,仍然是在做功能清单。真正影响选型的是这些能力能否共同维护业务不变量:
业务时间段 → 范围类型保存边界语义 → && 操作符定义重叠 → GiST 索引寻找潜在冲突 → 排他约束进入并发写入裁决 → 提交后的数据继续满足不重叠规则这条链才是 PostgreSQL 在此场景中的价值。范围类型负责表达,操作符负责比较,索引负责访问,约束负责拒绝非法状态,事务负责决定并发结果何时可见。它们不是四个孤立功能。
但这个结论有明确边界:
- 预约写入吞吐极高且允许异步冲突处理时,事件流或分区化架构可能更适合;
- 跨多个独立数据库的规则不能靠单库排他约束解决;
- 搜索相关性、海量列式聚合和无限文档灵活性仍可能需要专用系统;
- 使用数据库约束并不免除压测、监控、错误映射和容量规划。
所以“选择 PostgreSQL”不能从“它功能更多”推出。更可靠的判断是:业务是否存在必须在并发提交时持续成立、又能由 PostgreSQL 类型、操作符和约束准确表达的不变量。
上线前真正要验证的五件事
- 用生产相同的时区、边界约定和数据分布验证
[)是否符合业务; - 并发压测冲突率、等待时间、
23P01比例和成功吞吐; - 确认所有写入入口都经过同一约束,批量导入和迁移脚本也不例外;
- 把
23P01映射为可理解的业务冲突,不把它当系统故障无限重试; - 同时核对最终预约结果,而不是只看事务成功数和数据库错误数。
面试表达
PostgreSQL 的差异不只是类型更多。它能把tstzrange的重叠语义、GiST 访问方法和排他约束组合起来,让并发预约在提交路径上持续满足“不重叠”。代价是索引维护和冲突等待;无法表达为约束的跨行规则,再考虑显式锁或 Serializable,并重试完整事务。
官方资料
- PostgreSQL 18.6 Release Notes
- Range Types
- Exclusion Constraints
- btree_gist
- Transaction Isolation
- Serialization Failure Handling
- PostgreSQL 18.6 源码标签 REL_18_6