Chuyện gì đang xảy ra
Bạn insert hoặc update một hàng và PostgreSQL báo lỗi:
ERROR: duplicate key value violates unique constraint "users_pkey"
DETAIL: Key (id)=(42) already exists.
Đọc dòng DETAIL trước — nó chỉ chính xác cột nào (id) và giá trị nào (42) gây ra xung đột. Như vậy là 90% việc debug đã xong. Câu hỏi còn lại là tại sao giá trị đó đã tồn tại.
Các nguyên nhân phổ biến
- Sequence của
SERIALhoặcBIGSERIALbị lệch so với dữ liệu thực trong bảng — rất hay xảy ra sau khi import hàng loạt hoặcpg_restore - Bạn tự chỉ định ID đã tồn tại trong bảng
- Một cột unique (email, username, slug) nhận giá trị trùng lặp
- Hai request đồng thời cùng insert một hàng giống nhau
Nguyên nhân 1: Sequence bị lệch (phổ biến nhất)
Hình dung thế này: bạn vừa chạy pg_restore trên cơ sở dữ liệu có 50.000 người dùng. Các hàng đó được import kèm ID tường minh, nên sequence của PostgreSQL chưa bao giờ tăng lên. Nó vẫn nghĩ ID tiếp theo là 1 — trong khi bảng đã có dữ liệu đến tận 50000. Mọi lần insert mới đều bị lỗi ngay lập tức.
Kiểm tra giá trị sequence so với ID max thực tế
-- Sequence sẽ sinh ra giá trị nào tiếp theo?
SELECT last_value FROM users_id_seq;
-- ID cao nhất hiện có trong bảng là bao nhiêu?
SELECT MAX(id) FROM users;
Nếu last_value thấp hơn MAX(id), đó chính là vấn đề.
Cách sửa: reset sequence
-- Reset về max(id) để lần insert tiếp theo lấy max(id) + 1
SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));
Không muốn hardcode tên sequence? Dùng pg_get_serial_sequence thay thế:
SELECT setval(
pg_get_serial_sequence('users', 'id'),
(SELECT MAX(id) FROM users)
);
Lần INSERT tiếp theo không chỉ định id sẽ nhận MAX(id) + 1 — gọn gàng và an toàn.
Sửa tất cả sequence bị lệch cùng lúc
Sau khi restore cả một cơ sở dữ liệu, hàng chục bảng có thể bị ảnh hưởng cùng lúc. Chạy script này một lần để reset toàn bộ sequence khóa chính trong một lượt:
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT
tc.table_name,
kc.column_name,
pg_get_serial_sequence(tc.table_name, kc.column_name) AS seq
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kc
ON tc.constraint_name = kc.constraint_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND pg_get_serial_sequence(tc.table_name, kc.column_name) IS NOT NULL
LOOP
EXECUTE format(
'SELECT setval(%L, COALESCE(MAX(%I), 1)) FROM %I',
r.seq, r.column_name, r.table_name
);
END LOOP;
END;
$$;
Nguyên nhân 2: Giá trị trùng trong cột unique
Constraint không phải lúc nào cũng trên khóa chính. Trường hợp này nhắm vào cột email:
ERROR: duplicate key value violates unique constraint "users_email_key"
DETAIL: Key (email)=(user@example.com) already exists.
Tìm hàng bị xung đột
SELECT * FROM users WHERE email = 'user@example.com';
Phương án A: Bỏ qua bản trùng lặp
INSERT INTO users (email, name)
VALUES ('user@example.com', 'Alice')
ON CONFLICT (email) DO NOTHING;
Phương án B: Upsert — cập nhật hàng đã có thay vì insert mới
INSERT INTO users (email, name, updated_at)
VALUES ('user@example.com', 'Alice Updated', NOW())
ON CONFLICT (email)
DO UPDATE SET
name = EXCLUDED.name,
updated_at = EXCLUDED.updated_at;
EXCLUDED là bảng ảo chứa hàng bị lỗi insert. Tham chiếu nó để lấy các giá trị mới bạn muốn ghi vào.
Nguyên nhân 3: Xung đột ID tường minh khi insert hàng loạt
Đang migrate dữ liệu từ hệ thống khác? Nếu các hàng nguồn mang ID riêng và một số ID đó đã tồn tại trong bảng đích, mọi hàng xung đột sẽ bị lỗi.
Phát hiện xung đột trước khi insert
-- Xem những ID nào sắp import đã tồn tại rồi
SELECT s.id
FROM staging_users s
INNER JOIN users u ON s.id = u.id;
Chỉ insert các hàng không xung đột
INSERT INTO users (id, email, name)
SELECT id, email, name FROM staging_users
ON CONFLICT (id) DO NOTHING;
Nguyên nhân 4: Race condition khi insert đồng thời
Hai API request đến cách nhau vài mili-giây. Cả hai đều kiểm tra cơ sở dữ liệu, cả hai đều thấy chưa có hàng nào, và cả hai cùng thực hiện insert. Một cái thắng. Cái kia vướng constraint.
Kiểm tra ở tầng ứng dụng không thể ngăn điều này — khoảng trống giữa lúc kiểm tra và lúc insert đủ để request thứ hai len vào. Hãy xử lý ở tầng cơ sở dữ liệu:
# Python (psycopg2) — bắt lỗi và coi như "đã tồn tại"
try:
cursor.execute(
"INSERT INTO users (email) VALUES (%s)",
(email,)
)
conn.commit()
except psycopg2.errors.UniqueViolation:
conn.rollback()
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
Tốt hơn nữa, đẩy xử lý xung đột vào chính câu SQL để lệnh insert idempotent ngay từ đầu:
INSERT INTO users (email)
VALUES ('user@example.com')
ON CONFLICT (email) DO NOTHING
RETURNING id;
Xác nhận cách sửa đã có tác dụng
-- Kiểm tra sequence đã vượt qua max hiện tại chưa
SELECT
last_value AS sequence_next,
(SELECT MAX(id) FROM users) AS table_max
FROM users_id_seq;
-- Thử insert bình thường — không chỉ định id, phải thành công
INSERT INTO users (email, name) VALUES ('test@example.com', 'Test');
SELECT * FROM users WHERE email = 'test@example.com';
Những điểm cần ghi nhớ
- Sau
pg_restorehoặc bất kỳ lần import hàng loạt nào có ID tường minh — reset sequence trước khi ứng dụng đi vào hoạt động. Thêm bước này vào runbook restore để không bao giờ bỏ sót. ON CONFLICTluôn tốt hơn kiểm tra trùng lặp ở tầng ứng dụng. Nó chạy nguyên tử bên trong cơ sở dữ liệu; code ứng dụng không thể đảm bảo điều đó.- Dòng
DETAILlà công cụ debug nhanh nhất. Nó chỉ rõ tên constraint và giá trị xung đột — bắt đầu từ đó, không phải từ stack trace. - Cân nhắc chuyển sang dùng
GENERATED ALWAYS AS IDENTITY(PostgreSQL 10+) thay choSERIAL. PostgreSQL quản lý identity column chặt chẽ hơn, khiến việc sequence bị lệch khó xảy ra hơn nhiều.

