Fix lỗi PostgreSQL 'could not serialize access due to concurrent update' trong Serializable Transactions

intermediate🐘 PostgreSQL2026-03-22| PostgreSQL 9.1+ trên Linux, macOS, Windows — mọi ứng dụng dùng mức isolation SERIALIZABLE hoặc REPEATABLE READ với ghi đồng thời

Error Message

ERROR: could not serialize access due to concurrent update SQLSTATE: 40001
#postgresql#transaction#serializable#concurrency#isolation-level

Lỗi Gặp Phải

Transaction của bạn đang chạy ở mức cô lập SERIALIZABLE và đột ngột thất bại với:

ERROR: could not serialize access due to concurrent update
SQLSTATE: 40001

PostgreSQL lập tức rollback transaction. Không có trạng thái nào bị lưu dở. Không có cảnh báo. Chỉ là mất trắng.

Lỗi này xảy ra khi hai hoặc nhiều transaction đồng thời đọc và ghi cùng các hàng theo một mẫu không thể tái tạo bằng cách chạy tuần tự từng cái một. PostgreSQL phát hiện xung đột và hủy một trong số chúng — một cách có chủ đích. Đây không phải là lỗi. Đây là cơ sở dữ liệu đang thực thi một đảm bảo mà bạn đã yêu cầu.

Nguyên Nhân

Bên dưới, mức SERIALIZABLE của PostgreSQL sử dụng Serializable Snapshot Isolation (SSI). SSI liên tục theo dõi các phụ thuộc đọc/ghi giữa các transaction đang hoạt động. Khi phát hiện một chu kỳ phụ thuộc — một mẫu mà không có thứ tự tuần tự nào có thể tạo ra — nó sẽ hi sinh một transaction để phá vỡ nó.

Ví dụ điển hình về chu kỳ nguy hiểm:

  • Transaction A đọc các hàng 1–5 và ghi hàng 6
  • Transaction B đọc hàng 6 và ghi các hàng 1–5
  • Thao tác ghi của mỗi transaction phụ thuộc vào thao tác đọc của transaction kia — PostgreSQL hủy một trong số chúng

Với REPEATABLE READ, điều kiện kích hoạt đơn giản hơn: bạn đọc một hàng, một transaction khác commit thay đổi lên hàng đó, rồi bạn cố ghi vào nó. PostgreSQL sẽ không cho phép cả hai lần ghi cùng tồn tại.

Cách Khắc Phục Từng Bước

Bước 1: Thêm Logic Thử Lại (Bắt Buộc)

Mọi ứng dụng sử dụng transaction SERIALIZABLE đều phải thử lại khi gặp 40001. Không có ngoại lệ. Đây không phải là trường hợp ngoại lệ để xử lý sau — đây là hợp đồng cơ bản khi sử dụng mức cô lập này.

Python với psycopg2:

import psycopg2
from psycopg2 import OperationalError
import time
import random

def run_serializable_transaction(conn, operation, max_retries=5):
    for attempt in range(max_retries):
        try:
            with conn.cursor() as cur:
                cur.execute("BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE")
                operation(cur)
                conn.commit()
                return  # thành công
        except OperationalError as e:
            conn.rollback()
            if e.pgcode == '40001':  # serialization_failure
                if attempt  setTimeout(resolve, delay));
        continue;
      }
      throw err;
    } finally {
      client.release();
    }
  }
}

Bước 2: Giảm Khả Năng Xảy Ra Xung Đột

Logic thử lại giúp ứng dụng hoạt động đúng, nhưng tốn kém khi tải nặng. Hãy giảm thiểu tần suất xung đột ngay từ đầu.

Giữ transaction ngắn gọn. Một transaction mở trong 2 giây với 50 người dùng đồng thời là mầm mống của xung đột. Hãy chuyển các phép tính và kiểm tra vào code ứng dụng trước khi mở transaction:

-- Tệ: xử lý chậm bên trong transaction
BEGIN;
  SELECT pg_sleep(2); -- mô phỏng xử lý chậm
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- Tốt hơn: tính toán trước, rồi ghi nhanh
BEGIN;
  UPDATE accounts SET balance = 900 WHERE id = 1;
  UPDATE accounts SET balance = 1100 WHERE id = 2;
COMMIT;

Truy cập các hàng theo thứ tự nhất quán. Khi nhiều transaction thao tác trên cùng các hàng, luôn cập nhật chúng theo cùng một thứ tự — ưu tiên khóa chính nhỏ trước là một lựa chọn tốt. Cách này có thể giảm 30–50% lỗi serialization trong các tác vụ ghi nhiều:

-- Luôn cập nhật ID nhỏ hơn trước
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- id nhỏ hơn trước
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

Khóa sớm với SELECT FOR UPDATE. Nếu bạn biết sẽ ghi vào một hàng, hãy khóa nó ngay khi đọc. Cách này chuyển một lần hủy tiềm năng thành một lần chờ — các transaction khác sẽ xếp hàng thay vì bị hủy:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
  -- các transaction khác ghi vào hàng 1 sẽ chờ ở đây
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

Bước 3: Xem Xét Lại Có Thực Sự Cần SERIALIZABLE Không

Nếu lỗi 40001 xuất hiện liên tục, hãy tự hỏi: bạn có thực sự cần mức cô lập này không?

-- Kiểm tra mức cô lập transaction hiện tại
SHOW transaction_isolation;

-- Kiểm tra mặc định của cơ sở dữ liệu
SHOW default_transaction_isolation;

-- Hạ xuống cho một phiên
SET default_transaction_isolation = 'read committed';

Hầu hết ứng dụng chạy tốt với READ COMMITTED — mặc định của PostgreSQL — kết hợp với SELECT FOR UPDATE khi cần bảo vệ ở cấp độ hàng. Hãy dành SERIALIZABLE cho các chu kỳ đọc-sửa-ghi thực sự trên nhiều hàng mà kết quả tổng thể phải trông như một thao tác nguyên tử. Với các thao tác chuyển số dư hay cập nhật bộ đếm đơn giản, READ COMMITTED kết hợp khóa tường minh sẽ rẻ hơn và không gây xung đột.

Bước 4: Giám Sát Lỗi Serialization Trên Môi Trường Production

Trước khi tối ưu bất kỳ điều gì, hãy lấy số liệu cơ bản:

-- Rollback và xung đột theo từng cơ sở dữ liệu
SELECT datname,
       xact_commit,
       xact_rollback,
       conflicts
FROM pg_stat_database
WHERE datname = current_database();

-- Các bảng có số dead tuple cao (dấu hiệu của việc cập nhật liên tục)
SELECT relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

Số xact_rollback cao so với xact_commit là dấu hiệu trực tiếp của áp lực serialization. Hãy ghi lại số lần thử lại trong ứng dụng của bạn — đột biến trong log thử lại thường tiết lộ các hàng bị tranh chấp trước cả khi hệ thống giám sát của bạn phát hiện ra.

Xác Nhận Cách Khắc Phục Hoạt Động

Tái tạo lỗi một cách có chủ ý trong môi trường phát triển. Mở hai terminal:

-- Terminal 1: bắt đầu nhưng chưa commit
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM accounts WHERE id = 1;
-- để nguyên, chuyển sang terminal 2

-- Terminal 2: commit một thay đổi xung đột
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM accounts WHERE id = 1;
UPDATE accounts SET balance = 999 WHERE id = 1;
COMMIT;  -- thành công

-- Terminal 1: bây giờ thử ghi vào cùng hàng đó
UPDATE accounts SET balance = 888 WHERE id = 1;
COMMIT;  -- thất bại với 40001

Với logic thử lại hoạt động đúng, ứng dụng của bạn sẽ tự động thành công ở lần thử tiếp theo mà không cần thông báo gì. Kiểm tra log: bạn muốn thấy các lần thử lại được ghi lại rồi một commit thành công — chứ không phải lỗi 40001 nổi lên đến người dùng.

Tóm Tắt Nhanh

  • SQLSTATE 40001 = serialization_failure — luôn an toàn để thử lại
  • SQLSTATE 40P01 = deadlock_detected — cũng có thể thử lại, nguyên nhân gốc khác
  • Luôn rollback trước khi thử lại — không bao giờ thử lại trên một transaction đang mở đã thất bại
  • Dùng exponential backoff với jitter (ví dụ: 100ms, 200ms, 400ms + độ trễ ngẫu nhiên) để tránh bão thử lại khi tải cao
  • Ghi lại mỗi lần thử lại để tìm ra các điểm tranh chấp nóng trước khi chúng trở thành sự cố

Related Error Notes