PostgreSQL 通常允許唯一欄位中有多個 SQL NULL 值。若要診斷非預期的重複鍵錯誤,請找出確切的索引、檢查實際送出的值,並確認其定義是否改變了 NULL 的比較方式。唯一索引
本指南適用於使用 Mac 上的 psql 或 node-postgres,偵錯文章插入作業的人員。指南使用合成的文章資料列,並提供診斷步驟:重現預設行為、追查失敗的規則,然後選擇適當的修正方式。
可為 NULL 的唯一欄位實際允許什麼?
可為 NULL 的欄位可以包含 NULL;唯一性限制會決定哪些組合可以重複。在一般的 UNIQUE (slug) 下,重複的非 NULL slug 會遭到拒絕,而重複的 NULL 則允許。在一般的 UNIQUE (section, slug) 下,受限制的任一欄位為 NULL 時,該限制允許其他相同的組合。資料表上的其他唯一性規則仍然適用。限制條件
PostgreSQL 15 引入了 NULLS NOT DISTINCT,會在唯一性檢查時將 NULL 視為相等。對於複合鍵,必須整個組合相符:('notes', NULL) 會與另一個 ('notes', NULL) 衝突,但不會與 ('guides', NULL) 衝突。PostgreSQL 14 不支援該子句。版本 15、唯一索引
| 實際規則或輸入 | 重複值是否可能造成重複鍵錯誤? |
|---|---|
一般的 UNIQUE (slug) 搭配 SQL NULL |
不會,僅套用此規則時如此 |
UNIQUE NULLS NOT DISTINCT (slug) |
會,在 PostgreSQL 15 或更新版本中 |
一般的 UNIQUE (slug) 搭配 '' |
會;這是空字串 |
一般的 UNIQUE (slug) 搭配 'NULL' |
會;這是文字 |
| 同一資料列上的另一個唯一鍵 | 會,與可為 NULL 的 slug 無關 |
SQL NULL、空字串和文字 'NULL' 是不同的輸入。PostgreSQL 的比較文件明確區分 NULL 與一般文字值;請使用 IS NULL 測試是否為 NULL。比較函數與運算子
在 Mac Terminal 重現預設行為
你需要現有的 PostgreSQL 連線,且 Terminal 中必須能使用 psql 命令。使用平常採用的連線設定開啟工作階段。例如,如果你的本機資料庫名為 postgres,請執行以下命令。-d 選項會選取資料庫。psql
psql -d postgres
選擇特定版本的語法前,請先確認所連線伺服器的版本。server_version 會回報伺服器版本,這是 NULLS NOT DISTINCT 適用與否的相關版本界線。預設選項
SHOW server_version;
請在全新的工作階段中執行一次以下完整的基準範例。其一般唯一性限制適用於 PostgreSQL 14 及更新版本;它只會建立暫時的示範資料表。
CREATE TEMP TABLE article_probe (
article_id integer PRIMARY KEY,
title text NOT NULL,
slug text,
CONSTRAINT article_probe_slug_key UNIQUE (slug)
);
INSERT INTO article_probe (article_id, title, slug)
VALUES
(1, 'Paper shapes', NULL),
(2, 'Color arrangements', NULL),
(3, 'Desk sketches', ''),
(4, 'Pattern notes', 'NULL');
SELECT article_id, title, slug,
slug IS NULL AS is_sql_null,
slug = '' AS is_empty_string
FROM article_probe
ORDER BY article_id;
依照顯示的定義,四次插入都應成功。前兩列的 is_sql_null = true 為 NULL;第三列有 is_empty_string = true;第四列則包含字面文字。涉及 NULL 的比較本身可能會回傳 NULL,因此請使用明確的 NULL 判定欄來解讀結果。限制條件、比較函數與運算子
這個基準範例提供了實用的判斷界線。如果應用程式發生失敗,但此範例成功,請比較應用程式實際使用的資料表定義與提交的值,以及基準範例中的內容。僅憑欄位可為 NULL,無法判定是哪條規則造成失敗。
找出錯誤背後的確切規則
請從 SQLSTATE 23505 著手;這是 PostgreSQL 對 unique_violation 使用的代碼。請檢查代碼,而不要比對錯誤訊息的措辭。接著記錄錯誤回報的結構描述、資料表、限制名稱,以及可取得的詳細資訊。即使索引是直接透過 CREATE UNIQUE INDEX 建立,PostgreSQL 仍會在錯誤的限制名稱欄位中將索引視為限制條件。PostgreSQL 錯誤代碼、錯誤與通知訊息欄位
在與基準範例相同的 psql 工作階段中,執行以下診斷查詢來檢查索引。pg_indexes 檢視會顯示各索引的結構描述、名稱與重建後的定義。調整查詢時,請同時比對錯誤回報的資料表與結構描述。pg_indexes
SELECT schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE tablename = 'article_probe'
ORDER BY schemaname, indexname;
請檢視每個唯一性定義,並先查看錯誤訊息中的名稱。即使 slug 為 NULL,重複的 article_id 仍會違反主鍵。主鍵要求值唯一且不可為 NULL;資料列中其他欄位可為 NULL,並不會放寬這項要求。限制條件
請留意基準範例與實際定義之間的三種差異:
- NULL 相等性:
NULLS NOT DISTINCT會刻意讓重複的 NULL 鍵互相衝突。 - 運算式: 唯一性規則可能套用於計算所得的值,而非儲存的欄位。
- 述詞: 部分索引只會對符合其
WHERE條件的資料列套用規則。
若是運算式索引,調查衝突時請重現索引運算式。例如,對 lower(slug) 建立的索引,可能會拒絕兩個只有大小寫不同的非 NULL slug。對 COALESCE(slug, '') 建立的索引,會將 NULL 和空字串都映射為相同的索引值;因此,這些輸入可能會發生衝突。這是運算式索引唯一性以及 COALESCE 已文件化行為所導致的結果。運算式索引、條件運算式
部分唯一索引也需要仔細檢視。對 section 建立且附有 WHERE slug IS NULL 的索引,會在 slug 為 NULL 的資料列中,對每個 section 最多允許一筆相符資料列。它不會對 slug 本身強制唯一性。PostgreSQL 文件指出,部分唯一索引會在述詞選取的子集中強制唯一性。部分索引
檢查 node-postgres 實際送出的內容
node-postgres 會將 JavaScript null 和 undefined 查詢參數都轉換為 SQL NULL。空字串和字串 'NULL' 仍是一般文字輸入。另外,請區分提供 NULL 參數與省略 SQL 欄位:PostgreSQL 會將省略的欄位設為其宣告的預設值;若沒有預設值,則設為 NULL。查詢、INSERT
以下完整程式會顯示這些差異。你需要 Node.js、npm 和現有的 PostgreSQL 連線設定。在工作資料夾中安裝相依套件,將程式儲存為 diagnose-null.mjs,然後執行。用戶端會使用標準的 PostgreSQL 連線環境變數。連線
npm install pg
import pg from 'pg';
const { Client } = pg;
const client = new Client();
async function main() {
await client.connect();
try {
await client.query(`
CREATE TEMP TABLE article_node_probe (
title text NOT NULL,
slug text DEFAULT 'untitled',
CONSTRAINT article_node_probe_slug_key UNIQUE (slug)
)
`);
const samples = [
{ title: 'Paper shapes', slug: null },
{ title: 'Color arrangements', slug: undefined },
{ title: 'Desk sketches', slug: '' },
{ title: 'More sketches', slug: '' },
{ title: 'Pattern notes', slug: 'NULL' },
{ title: 'More patterns', slug: 'NULL' },
];
for (const sample of samples) {
console.log('Input:', {
title: sample.title,
type: typeof sample.slug,
value: sample.slug,
});
try {
const result = await client.query(
`INSERT INTO article_node_probe (title, slug)
VALUES ($1, $2)
RETURNING title, slug, slug IS NULL AS is_sql_null`,
[sample.title, sample.slug],
);
console.log('Inserted:', result.rows[0]);
} catch (error) {
if (error.code !== '23505') throw error;
console.log('Duplicate:', {
code: error.code,
schema: error.schema,
table: error.table,
constraint: error.constraint,
detail: error.detail,
});
}
}
} finally {
await client.end();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
PGDATABASE=postgres node diagnose-null.mjs
在此程式的結構描述下,前兩列應儲存 SQL NULL,即使欄位宣告了預設值也一樣。第一個空字串和第一個 'NULL' 字串應能插入;重複插入時應產生 23505。程式會回報這些預期中的衝突,並重新拋出其他錯誤。其診斷屬性名稱與 node-postgres 的 DatabaseError 欄位相符。查詢、DatabaseError 原始碼
準備本文時,已依照取得的文件檢視資料庫範例,但未在 PostgreSQL 上執行。
查詢可為 NULL 的鍵,避免漏掉符合的資料列
使用 slug = NULL 進行診斷查詢時,找不到 slug 為 NULL 的資料列。若要固定搜尋 NULL,請使用 IS NULL;若值可能為 NULL 或非 NULL,請使用 IS NOT DISTINCT FROM。後者會在比較時將兩個 NULL 視為相等。比較函數與運算子
此診斷查詢會在同一工作階段中,對前面的 article_probe 資料表執行。它應回傳兩筆 slug 為 NULL 的資料列。
SELECT article_id, title, slug
FROM article_probe
WHERE slug IS NOT DISTINCT FROM NULL::text
ORDER BY article_id;
若要進行參數化查詢,對應的述詞片段為 WHERE slug IS NOT DISTINCT FROM $1::text。對複合規則,請比對所有鍵元件,並重現任何索引運算式或部分索引述詞。找到符合的 NULL 資料列,可以確認目前儲存的內容;這些資料列是否違反唯一性,仍取決於索引定義。
選擇符合預期規則的修正方式
如果文章可以有任意數量的缺漏 slug,一般的 UNIQUE (slug) 已能表達此需求。請修正錯誤的文字值、非預期的運算式,或錯誤訊息指出的其他重複鍵。改變 NULL 的處理方式,無法解決其他鍵上的衝突。
如果需求是每個 section 只能有一個 NULL slug,PostgreSQL 15 或更新版本可以直接表達此規則。以下獨立示範最後刻意執行一筆預期會產生 23505 的插入:第一列已佔用該 section 與 NULL 的組合。版本 15、唯一索引
CREATE TEMP TABLE article_one_missing (
section text NOT NULL,
slug text,
title text NOT NULL,
CONSTRAINT article_one_missing_key
UNIQUE NULLS NOT DISTINCT (section, slug)
);
INSERT INTO article_one_missing (section, slug, title)
VALUES ('notes', NULL, 'Paper shapes');
-- Expected: 23505 on article_one_missing_key.
INSERT INTO article_one_missing (section, slug, title)
VALUES ('notes', NULL, 'Color arrangements');
在 PostgreSQL 14 中,對這種特定的雙欄設計,可以使用部分唯一索引。以下獨立範例實作相同的規則,因為 section 不為 NULL,而 slug 是唯一可為 NULL 的鍵元件。這種做法是根據 PostgreSQL 文件所述的部分索引行為推導而得。部分索引
CREATE TEMP TABLE article_legacy (
section text NOT NULL,
slug text,
title text NOT NULL
);
CREATE UNIQUE INDEX article_legacy_present_key
ON article_legacy (section, slug)
WHERE slug IS NOT NULL;
CREATE UNIQUE INDEX article_legacy_missing_key
ON article_legacy (section)
WHERE slug IS NULL;
INSERT INTO article_legacy (section, slug, title)
VALUES ('notes', NULL, 'Paper shapes');
-- Expected: 23505 on article_legacy_missing_key.
INSERT INTO article_legacy (section, slug, title)
VALUES ('notes', NULL, 'Color arrangements');
第一個索引會防止同一 section 中重複出現已填入的 slug。第二個索引則允許每個 section 有一個缺漏的 slug。若鍵中還有其他可為 NULL 的元件,就必須進一步考慮其可能組合;這兩個索引的做法僅適用於上述結構描述。
最後,請明確選擇衝突處理方式。ON CONFLICT DO NOTHING 會略過發生衝突的插入,但不會修正底層值。ON CONFLICT DO UPDATE 需要指定衝突目標,而其中的欄位、運算式及適用的述詞必須能識別預期的規則。這兩種動作都不會在一般唯一性規則允許重複 NULL 時,額外造成 NULL 衝突。INSERT
完成診斷的條件是:你能將錯誤回報的索引名稱連結到其定義、重現提交的鍵值,並依照該規則說明衝突原因。修正後,請再次提交完全相同的輸入,並檢查回傳結果及儲存的資料列,確認預期的文章已妥善處理。
來源
- 限制條件 — PostgreSQL 全球開發小組。
- 唯一索引 — PostgreSQL 全球開發小組。
- 版本 15 — PostgreSQL 全球開發小組。
- psql — PostgreSQL 全球開發小組。
- 預設選項 — PostgreSQL 全球開發小組。
- PostgreSQL 錯誤代碼 — PostgreSQL 全球開發小組。
- 錯誤與通知訊息欄位 — PostgreSQL 全球開發小組。
- pg_indexes — PostgreSQL 全球開發小組。
- 運算式索引 — PostgreSQL 全球開發小組。
- 條件運算式 — PostgreSQL 全球開發小組。
- 部分索引 — PostgreSQL 全球開發小組。
- 比較函數與運算子 — PostgreSQL 全球開發小組。
- INSERT — PostgreSQL 全球開發小組。
- 查詢 — node-postgres。
- 連線 — node-postgres。
- pg-protocol messages.ts: DatabaseError — node-postgres。