ข้าม​ไป​ยัง​เนื้อหา

UNIQUE ที่​ยอม​ให้​ซ้ำ — soft delete กับ NULL ที่​ไม่​เท่ากับ​ตัว​เอง

ตาราง users มี UNIQUE (email, deleted_at) เขียน​ไว้​ชัดเจน แล้ว​มัน​รับ​อีเมล​เดียวกัน ซ้ำ​ได้ 3 แถว​รวด ทุก​แถว​ยัง​ไม่​ถูก​ลบ

ไม่มี error ไม่มี​คำ​เตือน รหัส​ออก​เป็น​ศูนย์

ข้อ​จำกัด​ถูก​ประกาศ​ไว้​จริง ฐาน​ข้อมูล​ก็​บังคับ​มัน​จริง แต่​มัน​บังคับ​คนละ​ข้อ​กับ​ที่​คุณ​คิด

CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL,
deleted_at timestamptz,
UNIQUE (email, deleted_at)
);
INSERT INTO users (email) VALUES ('a@shop.test');
INSERT INTO users (email) VALUES ('a@shop.test');
INSERT INTO users (email) VALUES ('a@shop.test');
id | email | deleted_at
----+-------------+------------
1 | a@shop.test |
2 | a@shop.test |
3 | a@shop.test |
(3 rows)

deleted_at ของ​ทั้ง​สาม​แถว​เป็น NULL และ​ใน​สายตา​ของ index NULL ไม่​เท่ากับ NULL · สาม​แถว​นี้​จึง​เป็น​คนละ​คู่​กัน​ทั้งหมด ข้อ​จำกัด​ไม่​ถูก​ละเมิด​สัก​ครั้ง

คำ​ที่​หลอก​คือ​คำ​ว่า UNIQUE ไม่ใช่​คำ​ว่า NULL

ทุก​คน​รู้​ว่า NULL ไม่​เท่ากับ​อะไร​เลย รวม​ทั้ง​ตัว​มัน​เอง · ที่​หลุด​คือ​การ​เอา​กฎ​นั้น ไป​วาง​ใน​บริบท​ของ index

ใน WHERE กฎ​นี้​ทำให้​คุณ หาไม่​เจอ · ใน index กฎ​เดียวกัน​ทำให้​คุณ ใส่​ได้​เพิ่ม · ผล​ตรง​ข้าม​กัน​จาก​กฎ​ข้อ​เดียว

ตรรกะ​สาม​ค่า​ไม่​ได้​เปลี่ยน​พฤติกรรม​ของ​มัน สิ่ง​ที่​เปลี่ยน​คือ​ใคร​เป็น​คน​ถาม

SELECT count(*) FILTER (WHERE deleted_at = NULL) AS "= NULL",
count(*) FILTER (WHERE deleted_at IS NULL) AS "IS NULL"
FROM users;
เขียน​ว่าได้​กี่​แถวเพราะ
WHERE deleted_at = NULL0NULL = NULL ให้​ค่า NULL ซึ่ง​ไม่ใช่ TRUE แถว​จึง​ไม่​ผ่าน​ตัว​กรอง
WHERE deleted_at IS NULL3IS NULL เป็น​ตัว​ตรวจ​สถานะ ไม่ใช่​การ​เปรียบ​เทียบ

ใน WHERE การ​ที่ NULL ไม่​เท่ากับ​ตัว​เอง​ทำให้​ผลลัพธ์ หาย​ไป · ใน index มัน​ทำให้​แถว เพิ่ม​เข้า​มา​ได้ เพราะ index ตัดสิน​ว่า​ซ้ำ​จาก​การ​เทียบ​ค่า และ​การ​เทียบ​ที่​ไม่​ให้ TRUE แปล​ว่า​ไม่​ซ้ำ

ประกาศ​ให้​ชัด​ว่า​เรา​หวง​ความ​ไม่​ซ้ำ​เฉพาะ​ใน​แถว​ที่​ยัง​ไม่​ถูก​ลบ

CREATE UNIQUE INDEX users_live_email
ON users (email)
WHERE deleted_at IS NULL;

deleted_at หาย​ไป​จาก key แล้ว มัน​ย้าย​ไป​อยู่​ใน​เงื่อนไข​แทน · index เก็บ​เฉพาะ​แถว​ที่​ยัง​ไม่​ถูก​ลบ และ​ใน​กลุ่ม​นั้น email ต้อง​ไม่​ซ้ำ

ERROR: duplicate key value violates unique constraint "users_live_email"
DETAIL: Key (email)=(a@shop.test) already exists.

SQLSTATE ที่​ได้​คือ 23505 และ​ชื่อ​ที่​ปรากฏ​คือ users_live_email ซึ่ง​เป็น​ชื่อ index ไม่ใช่​ชื่อ constraint เพราะ​รูป​นี้​ไม่​ได้​สร้าง constraint ขึ้น​มา

PostgreSQL 15 ขึ้น​ไป​ให้​สั่ง​ได้​ตรง ๆ ว่า​ให้​ถือว่า NULL เท่า​กัน

CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL,
deleted_at timestamptz,
UNIQUE NULLS NOT DISTINCT (email, deleted_at)
);

รูป​นี้​ปิด​รู​เดิม​ได้​เหมือน​กัน และ​ข้อความ​ที่​ได้​บอก​ตรง ๆ ว่า​มัน​เทียบ null ด้วย

DETAIL: Key (email, deleted_at)=(a@shop.test, null) already exists.

อ่าน​ดู​แล้ว​รูป​นี้​สะอาด​กว่า เพราะ​มัน​ยัง​เป็น constraint จริง ไม่ใช่ index ที่​แขวน​ไว้​เฉย ๆ · แต่​พอ​วัด​ทรง​ที่​สอง มัน​เปิด​รู​ที่ทาง​แรก​ไม่มี

ลบ​ทั้ง​ชุด​ใน​คำ​สั่ง​เดียว แล้ว now() ให้​ค่า​เดียวกัน​ทุก​แถว

now() คืน​เวลา​ของ transaction ไม่ใช่​เวลา​ของ​แต่ละ​แถว · UPDATE users SET deleted_at = now() หนึ่ง​คำ​สั่ง​จึง​เขียน​ค่า เดีย​วกันเป๊ะ ลง​ทุก​แถว

ถ้า​มี​สอง​แถว​ที่ email เท่า​กัน แถว​หนึ่ง​เคย​ถูก​ลบ​ไป​แล้ว​และ​อีก​แถว​เพิ่ง​สมัคร​ใหม่ พอ​ลบ​ทั้ง​ชุด ทั้ง​คู่​จะ​มี (email, deleted_at) ชุด​เดียวกัน

ERROR: duplicate key value violates unique constraint "users_email_deleted_at_key"
DETAIL: Key (email, deleted_at)=(a@shop.test, 2026-08-29 16:04:16.001299+00) already exists.
ทาง​แก้แถว​ซ้ำ​ตอน​ยัง​ไม่​ลบลบ​ทั้ง​ชุด​ใน​คำ​สั่ง​เดียว
UNIQUE (email, deleted_at) เดิมยอม​ให้​ซ้ำผ่าน
partial indexปฏิเสธผ่าน · เหลือ 2 แถว deleted_at ค่า​เดียว
NULLS NOT DISTINCTปฏิเสธล้ม SQLSTATE 23505
ทาง​แก้​ที่​อ่าน​แล้ว​สะอาด​กว่า ไม่​ได้​แปล​ว่า​ปลอดภัย​กว่า

NULLS NOT DISTINCT บังคับ​ให้ (email, deleted_at) ไม่​ซ้ำ ทั้ง​ตาราง ไม่ใช่​เฉพาะ​แถว​ที่​ยัง​ไม่​ถูก​ลบ · การ​ลบ​ซ้ำ​ที่​เวลา​เดียวกัน​จึง​กลาย​เป็นการ​ละเมิด

partial index ไม่​สนใจ​แถว​ที่​ถูก​ลบ​ไป​แล้ว​เลย ลบ​กี่​ครั้ง​ที่​เวลา​ไหน​ก็ได้

ถ้า​คุณ​จะ​ใช้ NULLS NOT DISTINCT กับ soft delete ต้อง​ยอมรับ​ว่า เวลา​ที่​ลบ​กลาย​เป็น ส่วน​หนึ่ง​ของ key และ​งาน​ที่​ลบ​เป็น​ชุด​ต้อง​เขียน​ให้​แต่ละ​แถว​ได้​เวลา​ไม่​ซ้ำ​กัน

ตาราง​ที่​ใช้ soft delete มา​นาน แถว​ส่วน​ใหญ่​คือ​แถว​ที่​ถูก​ลบ​ไป​แล้ว

วัด​บน​ตาราง 100,000 แถว ที่​เหลือ​แถว​ยัง​ไม่​ถูก​ลบ 5,000 แถว

indexไบต์
partial · WHERE deleted_at IS NULL221,184
เต็ม · (email, deleted_at)4,988,928

index เต็ม​ต้อง​เก็บ​ทุก​แถว​ที่​ถูก​ลบ​ไป​แล้ว​ด้วย ทั้ง​ที่​ไม่มี query ไหน​ถาม​ถึง​มัน​ใน​ฐานะ ข้อ​จำกัด​ความ​ไม่​ซ้ำ · ส่วน​ที่​ใหญ่​กว่า​คือ​ส่วน​ที่​ไม่​ได้​ทำงาน

ข้อ​จำกัด​ที่​ประกาศ​ไว้ กับ​ข้อ​จำกัด​ที่​ได้​จริง เป็น​คนละ​ข้อ​กัน​ได้

  • UNIQUE (email, deleted_at) ไม่​ได้ บังคับ​ว่า​อีเมล​ที่​ยัง​ไม่​ถูก​ลบ​ต้อง​ไม่​ซ้ำ · มัน​บังคับ​ว่า​คู่​ของ​สอง​ค่า​ต้อง​ไม่​ซ้ำ และ​คู่​ที่​มี NULL ไม่​เคย​ซ้ำ​กับ​ใคร
  • ตรรกะ​สาม​ค่า​ข้อ​เดียวกัน ทำให้ WHERE หาไม่​เจอ และ​ทำให้ index ยอมรับ​เพิ่ม
  • partial index ย้าย deleted_at จาก key ไป​เป็น​เงื่อนไข · เล็ก​กว่า​และ​ไม่​ผูก​กับ​เวลา​ที่​ลบ
  • NULLS NOT DISTINCT ปิด​รู​เดิม​ได้ แต่​ทำให้​เวลา​ที่​ลบ​กลาย​เป็น​ส่วน​หนึ่ง​ของ key
🔗 วิธี​วัด​ซ้ำ

ทุก​ตัวเลข​ใน​หน้า​นี้​มา​จาก PostgreSQL 18.6 ตัว​จริง​ใน container ที่​คุม​ตัวแปร​แล้ว

scripts/pg-transaction-spike/up.sh
python3 scripts/unique-permits-duplicates/probe.py
npm run verify:unique-duplicates

probe มี​ช่อง​ควบคุม​สอง​ช่อง คือ​ทรง​ที่​ต้อง​ถูก​ปฏิเสธ​ต้อง​ถูก​ปฏิเสธ​จริง และ​ทรง​ที่​ควร​ผ่าน ต้อง​ผ่าน​จริง · ถอด partial index ออก​แล้ว​มัน​แดง​ทันที · และ​เมื่อ​ฐาน​ข้อมูล​ไม่​ขึ้น มัน​ออก​ด้วย รหัส 2 ซึ่ง​แปล​ว่า​วัด​ไม่​ได้ ไม่ใช่​รหัส 1 ที่​แปล​ว่า​ข้อ​อ้าง​ผิด