- SQL
期間の重複チェックSQL|予約の重なり判定条件と排他制約の書き方
期間の重複チェックとは、2つの期間が少しでも重なっているかを判定することで、条件は「既存の開始 < 新規の終了 AND 既存の終了 > 新規の開始」の1式です。 会議室や備品の予約、担当の割り当て期間、キャンペーン期間など、同じ対象に期間を重ねて登録させたくない場面で使います。
この記事では、判定式そのものに加えて、端点を含むかどうかで不等号がどう変わるか、チェック用の SELECT だけでは同時登録を防げないこと、データベースの制約で防ぐ書き方までを、実際に流した SQL と出力で示します。
確認環境:PostgreSQL 18.4、MySQL 8.0.46(いずれも Linux 上、確認日 2026-10-06)。出力例のデータベース名は appdb に置き換えています。
この記事の結論
- 重なりの判定は 「既存の開始 < 新規の終了 AND 既存の終了 > 新規の開始」 の1式で足ります。
- 期間は 開始を含み終了を含まない半開区間 で持つと、「12:00 まで」と「12:00 から」が並んで登録できます。端点も重なりとみなす閉区間なら、不等号を
<=と>=に変えます。 - SELECT で確かめてから INSERT する方法は、同時に2件届くと両方とも登録されます。
- PostgreSQL は 排他制約(EXCLUDE USING gist) か WITHOUT OVERLAPS 付きの主キー でデータベース側が止めます。
- MySQL は 親の行を SELECT ... FOR UPDATE でロック してからチェックと登録を行い、1件ずつ順番に処理します。
期間の重複チェックの早見表
既存の予約が 10:00〜12:00 のとき、新しい期間がどの位置にあるかで判定がどうなるかをまとめました。値はすべて後述の SQL を流した結果です。
時刻 08 09 10 11 12 13 14
既存 [=========)
1 前に離れている [====)
2 開始をまたぐ [=========)
3 内側に収まる [===)
4 全体を覆う [===================)
5 終了をまたぐ [=========)
6 後ろに離れている [====)
| 位置関係 | 新規の期間 | 半開区間の式(<、>) | 閉区間の式(<=、>=) | BETWEEN を2つ OR |
|---|---|---|---|---|
| 1 前に離れている | 08:00-09:00 | 重ならない | 重ならない | 重ならない |
| 2 開始をまたぐ | 09:00-11:00 | 重なる | 重なる | 重なる |
| 3 内側に収まる | 10:30-11:30 | 重なる | 重なる | 重なる |
| 4 全体を覆う | 09:00-13:00 | 重なる | 重なる | 重ならない(見落とし) |
| 5 終了をまたぐ | 11:00-13:00 | 重なる | 重なる | 重なる |
| 6 後ろに離れている | 13:00-14:00 | 重ならない | 重ならない | 重ならない |
| 開始にぴったり接する | 09:00-10:00 | 重ならない | 重なる | 重なる |
| 終了にぴったり接する | 12:00-13:00 | 重ならない | 重なる | 重なる |
| 期間の持ち方 | 意味 | 判定式 |
|---|---|---|
| 半開区間(開始を含み、終了を含まない) | 10:00 以上 12:00 未満 | 既存の開始 < 新規の終了 AND 既存の終了 > 新規の開始 |
| 閉区間(開始も終了も含む) | 10月1日から10月3日まで(3日とも使う) | 既存の開始 <= 新規の終了 AND 既存の終了 >= 新規の開始 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「期間の重複チェックSQL|予約の重なり判定条件と排他制約の書き方」
判定式が1式で済む理由
2つの期間が重ならないのは、「新規が既存より前に終わる」か「新規が既存より後に始まる」のどちらかだけです。
- 重ならない:新規の終了 <= 既存の開始 OR 新規の開始 >= 既存の終了
- 重なる(上の否定):既存の開始 < 新規の終了 AND 既存の終了 > 新規の開始
「重なる6パターンを全部書く」のではなく、「重ならない2パターンの否定」と考えると、条件が2つの比較だけになります。
6つの位置関係をまとめて確かめるSQL
次の SQL は PostgreSQL 18.4 でそのまま流せます。既存の1件に対して8通りの候補を当て、3種類の式の結果を横に並べます。
CREATE TABLE reservations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id int NOT NULL,
start_at timestamp NOT NULL,
end_at timestamp NOT NULL,
CHECK (start_at < end_at)
);
INSERT INTO reservations (room_id, start_at, end_at)
VALUES (1, '2026-10-06 10:00', '2026-10-06 12:00');
WITH candidates(label, new_start, new_end) AS (
VALUES
('1 前に離れている', timestamp '2026-10-06 08:00', timestamp '2026-10-06 09:00'),
('2 開始をまたぐ', '2026-10-06 09:00', '2026-10-06 11:00'),
('3 内側に収まる', '2026-10-06 10:30', '2026-10-06 11:30'),
('4 全体を覆う', '2026-10-06 09:00', '2026-10-06 13:00'),
('5 終了をまたぐ', '2026-10-06 11:00', '2026-10-06 13:00'),
('6 後ろに離れている', '2026-10-06 13:00', '2026-10-06 14:00'),
('7 開始にぴったり接する', '2026-10-06 09:00', '2026-10-06 10:00'),
('8 終了にぴったり接する', '2026-10-06 12:00', '2026-10-06 13:00')
)
SELECT c.label,
to_char(c.new_start, 'HH24:MI') || '-' || to_char(c.new_end, 'HH24:MI') AS new_period,
(r.start_at < c.new_end AND r.end_at > c.new_start) AS half_open,
(r.start_at <= c.new_end AND r.end_at >= c.new_start) AS closed,
(c.new_start BETWEEN r.start_at AND r.end_at
OR c.new_end BETWEEN r.start_at AND r.end_at) AS between_2
FROM candidates c CROSS JOIN reservations r
ORDER BY c.label;
label | new_period | half_open | closed | between_2
------------------------+-------------+-----------+--------+-----------
1 前に離れている | 08:00-09:00 | f | f | f
2 開始をまたぐ | 09:00-11:00 | t | t | t
3 内側に収まる | 10:30-11:30 | t | t | t
4 全体を覆う | 09:00-13:00 | t | t | f
5 終了をまたぐ | 11:00-13:00 | t | t | t
6 後ろに離れている | 13:00-14:00 | f | f | f
7 開始にぴったり接する | 09:00-10:00 | f | t | t
8 終了にぴったり接する | 12:00-13:00 | f | t | t
(8 rows)
CHECK (start_at < end_at) は必ず付けます。開始と終了が逆の行が入ると、どの判定式も意図どおりに働かなくなるためです。
閉区間と半開区間で不等号を使い分ける
| 項目 | 半開区間 | 閉区間 |
|---|---|---|
| 終了の値の意味 | その瞬間は含まない(12:00 未満) | その値も含む(3日まで) |
| 向いているデータ | 時刻つきの予約、連続する割り当て期間 | 日付だけで「◯日から◯日まで」と入力する画面 |
| 判定式の不等号 | <、> | <=、>= |
| 隣り合う期間(12:00 終了と 12:00 開始) | 重ならない | 重なる |
| 長さの計算 | 終了 − 開始 | 終了 − 開始 + 1(日単位の場合) |
迷ったら保存は半開区間にそろえるのがおすすめです。画面で「10月1日〜10月3日」と閉区間で入力された値は、保存時に終了を翌日の 10月4日 に直しておくと、時刻つきのデータと同じ式で判定でき、隣り合う期間もすき間なく並べられます。
BETWEEN で書く方法との違い
BETWEEN は両端を含むので、閉区間の判定になります。また、「新規の開始が既存の中にある OR 新規の終了が既存の中にある」という2条件だけでは、上の表の「4 全体を覆う」を見落とします。BETWEEN で正しく書くには「既存の開始が新規の中にある」という3つ目の条件が要ります。
| 書き方 | 条件の数 | 「全体を覆う」 | 端点が接する場合 |
|---|---|---|---|
| 開始 < 終了 AND 終了 > 開始 | 2 | 検出する | 重ならない |
| 開始 <= 終了 AND 終了 >= 開始 | 2 | 検出する | 重なる |
| BETWEEN を2つ OR | 2 | 見落とす | 重なる |
| BETWEEN を3つ OR | 3 | 検出する | 重なる |
比較2つの式は、条件が少なく、不等号を変えるだけで閉区間と半開区間を切り替えられます。
SELECT でのチェックだけでは同時登録をすり抜ける
「重なる行が 0 件なら INSERT する」という流れは、1人ずつ操作している間は正しく動きます。ところが、2つの処理がほぼ同時に届くと、どちらも「0 件」を見てから INSERT するため、両方とも登録されます。
次の SQL を race.sql として保存し、2つの端末から 0.5 秒差で流しました(pg_sleep はチェックと登録の間の処理時間の代わりです)。
BEGIN;
SELECT count(*) AS overlapping FROM reservations
WHERE room_id = 1 AND start_at < '2026-10-06 15:00' AND end_at > '2026-10-06 14:00';
SELECT pg_sleep(2);
INSERT INTO reservations (room_id, start_at, end_at)
VALUES (1, '2026-10-06 14:00', '2026-10-06 15:00');
COMMIT;
セッションA・セッションBとも、出力は同じでした(pg_sleep の行は省いています)。
BEGIN
overlapping
-------------
0
(1 row)
INSERT 0 1
COMMIT
id | room_id | start_at | end_at
----+---------+---------------------+---------------------
1 | 1 | 2026-10-06 10:00:00 | 2026-10-06 12:00:00
2 | 1 | 2026-10-06 14:00:00 | 2026-10-06 15:00:00
3 | 1 | 2026-10-06 14:00:00 | 2026-10-06 15:00:00
(3 rows)
14:00〜15:00 の予約が2件入りました。相手のトランザクションがまだコミットしていない行は SELECT から見えないため、チェックの式が正しくても起こります。判定式の正しさと、同時実行での安全さは別の話です。
既存データ同士の重なりを自己結合で洗い出す
制約を後から付ける前に、すでに重なっている行を探します。同じテーブルを2回結合し、a.id < b.id で同じ組が2回出るのを避けます。
SELECT a.id AS id_a, b.id AS id_b, a.room_id,
GREATEST(a.start_at, b.start_at) AS overlap_from,
LEAST(a.end_at, b.end_at) AS overlap_to
FROM reservations a
JOIN reservations b
ON a.room_id = b.room_id
AND a.id < b.id
AND a.start_at < b.end_at
AND a.end_at > b.start_at
ORDER BY a.id, b.id;
id_a | id_b | room_id | overlap_from | overlap_to
------+------+---------+---------------------+---------------------
2 | 3 | 1 | 2026-10-06 14:00:00 | 2026-10-06 15:00:00
(1 row)
GREATEST と LEAST で、重なっている部分の開始と終了も取り出せます。この SQL は MySQL 8.0.46 でも同じ形で動きました。
PostgreSQL:排他制約(EXCLUDE USING gist)で防ぐ
排他制約は、「どの2行を比べても、指定した演算子がすべて真になる組は作らせない」という制約です。「room_id が等しく、かつ期間が重なる(&&)」組を禁止すれば、重複予約をデータベース側が止めます。
整数の列を = で GiST インデックスに含めるには、btree_gist 拡張が必要です。
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE reservations
ADD CONSTRAINT reservations_no_overlap
EXCLUDE USING gist (room_id WITH =, tsrange(start_at, end_at) WITH &&);
重なった行が残ったまま流すと、制約は作られず、どの行がぶつかっているかが表示されます。
ERROR: could not create exclusion constraint "reservations_no_overlap"
DETAIL: Key (room_id, tsrange(start_at, end_at))=(1, ["2026-10-06 14:00:00","2026-10-06 15:00:00")) conflicts with key (room_id, tsrange(start_at, end_at))=(1, ["2026-10-06 14:00:00","2026-10-06 15:00:00")).
id = 3 の行を削除してから同じ ALTER TABLE を流すと、制約が付きました。続けて3件登録します。
-- 既存の 10:00-12:00 と重なる
INSERT INTO reservations (room_id, start_at, end_at) VALUES (1, '2026-10-06 11:00', '2026-10-06 13:00');
-- 12:00 ちょうどで接する
INSERT INTO reservations (room_id, start_at, end_at) VALUES (1, '2026-10-06 12:00', '2026-10-06 13:00');
-- 別の部屋
INSERT INTO reservations (room_id, start_at, end_at) VALUES (2, '2026-10-06 11:00', '2026-10-06 13:00');
ERROR: conflicting key value violates exclusion constraint "reservations_no_overlap"
DETAIL: Key (room_id, tsrange(start_at, end_at))=(1, ["2026-10-06 11:00:00","2026-10-06 13:00:00")) conflicts with existing key (room_id, tsrange(start_at, end_at))=(1, ["2026-10-06 10:00:00","2026-10-06 12:00:00")).
INSERT 0 1
INSERT 0 1
1件目だけがエラーになり、端点が接する2件目と別の部屋の3件目は登録されました。tsrange(開始, 終了) は3つ目の引数を省くと [)、つまり半開区間になります(PostgreSQL 公式ドキュメント「Range Types」)。閉区間で扱いたいときは tsrange(start_at, end_at, '[]') と書きます。
SELECT tsrange('2026-10-06 10:00', '2026-10-06 12:00') AS default_bounds,
tsrange('2026-10-06 10:00', '2026-10-06 12:00')
&& tsrange('2026-10-06 12:00', '2026-10-06 13:00') AS half_open_touch,
tsrange('2026-10-06 10:00', '2026-10-06 12:00', '[]')
&& tsrange('2026-10-06 12:00', '2026-10-06 13:00', '[]') AS closed_touch;
default_bounds | half_open_touch | closed_touch
-----------------------------------------------+-----------------+--------------
["2026-10-06 10:00:00","2026-10-06 12:00:00") | f | t
(1 row)
同時に2件届いたときの動き
制約を付けた状態で、先ほどと同じ「チェック → 2秒待つ → INSERT」を 16:00〜17:00 の期間で2セッションから流しました。
-- セッションA
overlapping
-------------
0
INSERT 0 1
COMMIT
-- セッションB
overlapping
-------------
0
ERROR: conflicting key value violates exclusion constraint "reservations_no_overlap"
DETAIL: Key (room_id, tsrange(start_at, end_at))=(1, ["2026-10-06 16:00:00","2026-10-06 17:00:00")) conflicts with existing key (room_id, tsrange(start_at, end_at))=(1, ["2026-10-06 16:00:00","2026-10-06 17:00:00")).
ROLLBACK
どちらも SELECT では 0 件でしたが、後から INSERT したセッションBだけがエラーになり、登録は1件に保たれました。アプリケーションはこのエラー(psql の詳細表示で確かめた SQLSTATE は 23P01)を受け取って「その時間は予約済みです」と案内します。
PostgreSQL 18:WITHOUT OVERLAPS 付きの主キーで防ぐ
PostgreSQL 18 では、主キーや UNIQUE 制約の最後の列に WITHOUT OVERLAPS を付けられます。その列は等しいかどうかではなく重なりで比べられ、「同じ room_id で期間が重なる行は1つだけ」というキーになります。列の型は範囲型(または複数範囲型)です。
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
room_id int NOT NULL,
period tsrange NOT NULL,
booked_by text NOT NULL,
PRIMARY KEY (room_id, period WITHOUT OVERLAPS)
);
INSERT INTO room_bookings VALUES (1, '[2026-10-06 10:00, 2026-10-06 12:00)', 'sato');
INSERT INTO room_bookings VALUES (1, '[2026-10-06 12:00, 2026-10-06 13:00)', 'suzuki');
INSERT INTO room_bookings VALUES (1, '[2026-10-06 11:30, 2026-10-06 12:30)', 'tanaka');
INSERT INTO room_bookings VALUES (1, 'empty', 'kato');
INSERT 0 1
INSERT 0 1
ERROR: conflicting key value violates exclusion constraint "room_bookings_pkey"
DETAIL: Key (room_id, period)=(1, ["2026-10-06 11:30:00","2026-10-06 12:30:00")) conflicts with existing key (room_id, period)=(1, ["2026-10-06 10:00:00","2026-10-06 12:00:00")).
ERROR: empty WITHOUT OVERLAPS value found in column "period" in relation "room_bookings"
psql の \d room_bookings では、主キーが GiST インデックスで作られていることが分かります。
Indexes:
"room_bookings_pkey" PRIMARY KEY, gist (room_id, period)
btree_gist を入れずに同じ CREATE TABLE を流すと、次のエラーになりました。
ERROR: data type integer has no default operator class for access method "gist"
HINT: You must specify an operator class for the index or define a default operator class for the data type.
| 項目 | EXCLUDE USING gist | WITHOUT OVERLAPS 付き主キー |
|---|---|---|
| 使える版(今回動かした版) | PostgreSQL 18.4 | PostgreSQL 18.4 |
| 期間の列 | 開始・終了の2列のまま式で範囲にできる | 範囲型の列が必要 |
| 等しさ以外の条件 | 演算子を自由に組み合わせられる | 最後の列が重なり、他の列は等しさ |
| 主キーとして使う | 別に主キーを用意する | そのまま主キーになる |
| 空の範囲(empty) | 登録できる | 登録できない |
公式ドキュメント(CREATE TABLE)には、WITHOUT OVERLAPS 付きの制約は実質的に排他制約で実現され、UNIQUE (id, valid_at WITHOUT OVERLAPS) は EXCLUDE USING GIST (id WITH =, valid_at WITH &&) と同じように働くと書かれています。既存の2列をそのまま使うなら EXCLUDE、新しく作るテーブルで範囲型の列を持つなら WITHOUT OVERLAPS、と分けると選びやすくなります。
MySQL:親行の SELECT ... FOR UPDATE で直列化する
MySQL 8.0.46 では EXCLUDE の構文が使えない(構文エラーになる)ため、同じ部屋に対する処理を1件ずつ順番に通す方法を取ります。部屋(親)の行を SELECT ... FOR UPDATE でロックし、そのトランザクションの中でチェックと登録を行います。
CREATE TABLE rooms (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL);
CREATE TABLE reservations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
room_id INT NOT NULL,
start_at DATETIME NOT NULL,
end_at DATETIME NOT NULL,
CHECK (start_at < end_at),
FOREIGN KEY (room_id) REFERENCES rooms(id),
INDEX idx_room_period (room_id, start_at, end_at)
);
INSERT INTO rooms VALUES (1, '会議室A'), (2, '会議室B');
INSERT INTO reservations (room_id, start_at, end_at)
VALUES (1, '2026-10-06 10:00', '2026-10-06 12:00');
ロックを取らずに PostgreSQL と同じ「チェック → 2秒待つ → INSERT」を2セッションで流すと、MySQL 8.0.46 でも 14:00〜15:00 が2件登録されました。次に、親行のロックを先頭に置いた形です。
START TRANSACTION;
SELECT id FROM rooms WHERE id = 1 FOR UPDATE; -- ここで順番待ちになる
SELECT NOW(3) AS lock_acquired;
SELECT COUNT(*) AS overlapping FROM reservations
WHERE room_id = 1 AND start_at < '2026-10-06 17:00' AND end_at > '2026-10-06 16:00';
DO SLEEP(2);
INSERT INTO reservations (room_id, start_at, end_at)
SELECT 1, '2026-10-06 16:00', '2026-10-06 17:00' FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM reservations
WHERE room_id = 1 AND start_at < '2026-10-06 17:00' AND end_at > '2026-10-06 16:00');
SELECT ROW_COUNT() AS inserted;
COMMIT;
| 項目 | セッションA | セッションB(0.5秒後に開始) |
|---|---|---|
| lock_acquired | 02:09:59.720 | 02:10:01.724 |
| overlapping | 0 | 1 |
| inserted | 1 | 0 |
セッションBは、Aがコミットするまで FOR UPDATE の行で約2秒待ち、ロックを取れた時点ではAの予約が見えています。結果として登録は1件だけでした。
+----+---------+---------------------+---------------------+
| id | room_id | start_at | end_at |
+----+---------+---------------------+---------------------+
| 1 | 1 | 2026-10-06 10:00:00 | 2026-10-06 12:00:00 |
| 2 | 1 | 2026-10-06 14:00:00 | 2026-10-06 15:00:00 |
| 4 | 1 | 2026-10-06 16:00:00 | 2026-10-06 17:00:00 |
+----+---------+---------------------+---------------------+
この方法のポイントは3つです。
- ロックはトランザクションの最初の文で取る:今回の分離レベルは既定の REPEATABLE READ でした。ロックを待った後に最初の読み取りが行われたので、セッションBから相手の予約が見えています。
- 予約を登録する処理はすべて同じ親行をロックする:1か所でもロックを取らずに INSERT する経路があると、そこからすり抜けます。
- 待ち時間の扱いを決める:MySQL 8.4 のリファレンス(Locking Reads)には、待たずにすぐエラーにする NOWAIT と、ロック中の行を結果から外す SKIP LOCKED が載っています。今回の環境の innodb_lock_wait_timeout は 50 秒でした。
終了日 NULL(無期限)の扱い
「終了日が決まっていない」を NULL で表すと、素の比較は真にも偽にもならず、重なりを見落とします。PostgreSQL 18.4 で確かめました。
CREATE TABLE assignments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
item_id int NOT NULL,
start_on date NOT NULL,
end_on date, -- NULL は無期限
EXCLUDE USING gist (item_id WITH =, daterange(start_on, end_on, '[)') WITH &&)
);
INSERT INTO assignments (item_id, start_on, end_on) VALUES (7, '2026-10-01', NULL);
SELECT (a.start_on < date '2026-12-31' AND a.end_on > date '2026-12-01') AS plain,
(a.start_on < date '2026-12-31'
AND (a.end_on IS NULL OR a.end_on > date '2026-12-01')) AS with_is_null,
(a.start_on < date '2026-12-31'
AND COALESCE(a.end_on, 'infinity') > date '2026-12-01') AS with_coalesce,
daterange(a.start_on, a.end_on) && daterange('2026-12-01', '2026-12-31') AS with_range
FROM assignments a;
plain | with_is_null | with_coalesce | with_range
-------+--------------+---------------+------------
| t | t | t
(1 row)
plain の列は空(NULL)で、WHERE に書くと 0 件になりました。対応は次の3通りです。
| 方法 | 書き方 | 使える DB | 向いている場面 |
|---|---|---|---|
| IS NULL を OR で足す | (end_on IS NULL OR end_on > 新規の開始) | PostgreSQL、MySQL | 既存の式に足すだけで済ませたい |
| COALESCE で遠い未来に置き換える | COALESCE(end_on, 'infinity') | PostgreSQL(MySQL は '9999-12-31' などの値で代用) | 式を1本に保ちたい |
| 範囲型にまかせる | daterange(start_on, end_on) | PostgreSQL | 排他制約と同じ式で判定したい |
範囲型は、境界に NULL を渡すとその側が無限になります(公式ドキュメント「Range Types」)。上のテーブルでは無期限の行が [2026-10-01,) として扱われ、後から 12月の期間を登録しようとすると排他制約が働きました。
ERROR: conflicting key value violates exclusion constraint "assignments_item_id_daterange_excl"
DETAIL: Key (item_id, daterange(start_on, end_on, '[)'::text))=(7, [2026-12-01,2026-12-31)) conflicts with existing key (item_id, daterange(start_on, end_on, '[)'::text))=(7, [2026-10-01,)).
新規側の終了も無期限になりうる場合は、新規の値にも同じ置き換えを行います。
アプリ側チェック・ロック・制約の違いと選び方
| 方法 | 同時登録への強さ | 必要なもの | 利用者への見せ方 |
|---|---|---|---|
| SELECT でチェックしてから INSERT | すり抜ける | なし | 入力中に「その時間は埋まっています」と早めに知らせられる |
| 親行の SELECT ... FOR UPDATE | 守れる(全経路でロックする前提) | 親テーブル、トランザクション | 順番待ちの後、通常のチェック結果を返す |
| 排他制約(EXCLUDE) | 守れる(INSERT にも UPDATE にも効く) | PostgreSQL、btree_gist | 制約違反のエラーをメッセージに変換する |
| WITHOUT OVERLAPS 付きキー | 守れる | PostgreSQL 18、範囲型の列 | 同上 |
| 条件 | 選ぶもの |
|---|---|
| PostgreSQL で、開始・終了の2列がすでにある | EXCLUDE USING gist を追加 |
| PostgreSQL 18 で新しくテーブルを作る | 範囲型の列 + WITHOUT OVERLAPS 付き主キー |
| MySQL を使っている | 親行の SELECT ... FOR UPDATE + トランザクション内でチェック |
| 画面で空き状況を先に見せたい | 上のいずれか + アプリ側の SELECT チェック(案内用) |
| 取り消した予約を残す(状態列がある) | PostgreSQL は EXCLUDE の末尾に WHERE (status = 'active') を付けて有効な行だけを対象にする(取り消し済みの行と同じ時間の登録が通ることを確認) |
アプリ側のチェックは「早く知らせる」ため、ロックや制約は「必ず守る」ためと役割を分け、両方を組み合わせるのが基本です。
確かめて分かったこと
- BETWEEN を2つ OR でつないだ式は、新規が既存を丸ごと覆う場合を検出しませんでした。 比較2つの式は8通りすべてで期待どおりでした。
- 制約が無いと、PostgreSQL 18.4 でも MySQL 8.0.46 でも、同時の2セッションはどちらも「0 件」を見て両方登録されました。
- 排他制約を付けると、SELECT では 0 件だった2つ目の INSERT がエラーで止まりました。 チェックの SELECT を消さなくても、最後は制約が守ります。
- PostgreSQL では、排他制約でエラーになった INSERT の分も連番が進みました。 id = 4 が欠番になり、次に登録できた行は 5 でした。連番に空きができる前提で設計します。
- btree_gist を DROP EXTENSION ... CASCADE で外すと、それに頼っていた排他制約も一緒に消えました。 拡張の入れ直しは制約の一覧を確かめてから行います。
- WITHOUT OVERLAPS の主キーは GiST インデックスで作られ、整数の列を含めるには btree_gist が必要でした。 空の範囲(empty)は登録できません。
- 終了日 NULL の行は、素の比較では WHERE に一致しませんでした。 範囲型に渡すと上限なしとして扱われます。
よくある質問
Q. 期間の重複チェックの条件式とは? A. 「既存の開始 < 新規の終了 AND 既存の終了 > 新規の開始」です。端点が同じ場合も重なりとみなすなら、不等号を <= と >= にします。
Q. 予約を変更(UPDATE)するときのチェックは?
A. 自分自身の行を除いて同じ式で判定します。SELECT でのチェックには AND id <> 変更する行のid を足します。排他制約は UPDATE にもそのまま効き、終了を 12:30 に延ばして次の予約と重なる UPDATE は同じエラーで止まりました。
Q. 閉区間と半開区間の違いは? A. 終了の値そのものを期間に含めるのが閉区間、含めないのが半開区間です。時刻つきのデータは半開区間にすると、12:00 終了と 12:00 開始を並べて登録できます。
Q. 排他制約が作成できないときは? A. 既存データに重なりがあるか、btree_gist が入っていないかのどちらかが多いです。前者は自己結合の SQL で重なりを洗い出して直し、後者は CREATE EXTENSION btree_gist を先に流します。
Q. MySQL で重複予約を防ぐには? A. 部屋など親の行を SELECT ... FOR UPDATE でロックし、同じトランザクションの中でチェックと INSERT を行います。予約を登録する処理すべてで同じ親行をロックするのが条件です。
まとめ
- 判定式は 「既存の開始 < 新規の終了 AND 既存の終了 > 新規の開始」 の1式。
- 半開区間は <、>、閉区間は <=、>=。保存は半開区間にそろえると扱いやすい。
- BETWEEN は閉区間の判定で、2条件だけだと 「全体を覆う」を見落とす。
- SELECT でのチェックだけでは同時登録をすり抜ける。2セッションで再現できる。
- PostgreSQL は EXCLUDE USING gist、PostgreSQL 18 は WITHOUT OVERLAPS 付き主キー で防ぐ。
- MySQL は 親行の SELECT ... FOR UPDATE をトランザクションの最初に置く。
- 終了日 NULL は IS NULL・COALESCE・範囲型のいずれかで無期限として扱う。
参考・出典
- PostgreSQL 18 Documentation「Range Types」 https://www.postgresql.org/docs/18/rangetypes.html
- PostgreSQL 18 Documentation「CREATE TABLE」(EXCLUDE、WITHOUT OVERLAPS) https://www.postgresql.org/docs/18/sql-createtable.html
- MySQL 8.4 Reference Manual「Locking Reads」 https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「期間の重複チェックSQL|予約の重なり判定条件と排他制約の書き方」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。