Một giao dịch mở lâu trông vô hại — nó chỉ ngồi đó, không ghi gì thêm. Nhưng trong PostgreSQL, một giao dịch để mở, dù chỉ "idle in transaction" (mở BEGIN rồi ngồi im), có thể âm thầm khiến cả một bảng phình to gấp mấy lần và làm truy vấn chậm dần. Cái hại không nằm ở việc nó làm gì, mà ở việc nó giữ một cái nhìn cũ về dữ liệu. Bài này đo trực tiếp tác hại đó bằng hai kịch bản y hệt nhau, chỉ khác một điểm: có hay không một giao dịch dài mở song song.

Giao dịch dài gây hại

Vì sao VACUUM cần biết "ai còn đang xem"

Như bài MVCC đã đo, mỗi lần UPDATE không sửa tại chỗ mà tạo một phiên bản mới của hàng, để lại phiên bản cũ thành hàng chết (dead tuple). VACUUM (đã gặp ở bài VACUUM) dọn những hàng chết đó để lấy lại chỗ. Nhưng VACUUM không được xóa bừa: nó chỉ dọn được hàng chết mà không giao dịch nào còn có thể cần thấy.

Để biết điều đó, VACUUM nhìn giá trị xmin nhỏ nhất đang hoạt động trong toàn hệ thống — tức giao dịch cũ nhất còn mở. Mọi hàng chết sinh ra sau mốc đó vẫn có thể nằm trong tầm nhìn của giao dịch cũ ấy (nó chụp một snapshot lúc bắt đầu và có quyền thấy dữ liệu như lúc đó). Nên nếu có một giao dịch mở từ rất lâu, VACUUM buộc phải giữ lại mọi hàng chết mới hơn — không phải vì chúng còn cần thật, mà vì có thể có ai đó còn nhìn. Đó là gốc rễ của tác hại.

Kịch bản A: không có giao dịch dài

Trước hết, đo trạng thái lành mạnh. Một bảng 200.000 hàng, tôi lặp 5 vòng, mỗi vòng UPDATE toàn bộ bảng (tạo 200.000 hàng chết) rồi chạy VACUUM:

không có giao dịch dài mở:
  vòng 1..5:  bảng = 14 MB,  hàng chết còn lại = 0

Bảng đứng yên ở 14 MB suốt cả năm vòng, và sau mỗi lần VACUUM, số hàng chết về 0. VACUUM dọn sạch phiên bản cũ và đánh dấu chỗ đó để tái dùng cho vòng UPDATE sau, nên bảng không lớn lên. Đây là điều ta mong đợi: cập nhật liên tục nhưng dung lượng ổn định. Bảng nhích từ 7 lên 14 MB ở vòng đầu (VACUUM giữ khoảng trống để tái dùng chứ không trả ngay về đĩa), rồi giữ nguyên — không có gì tích lũy.

Một lần tôi đo hớ: VACUUM chạy mà không dọn được gì

Giờ tôi lặp lại đúng thí nghiệm đó, nhưng lần này ở một phiên khác (phiên A) tôi mở một giao dịch dài và để yên: BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM t; rồi không commit. Nó chỉ đọc một lần rồi ngồi im — đúng kiểu một request treo, hay một dòng BEGIN bị quên commit trong code.

Rồi tôi chạy lại 5 vòng UPDATE + VACUUM ở phiên B. Tôi trông đợi kết quả giống kịch bản A — VACUUM dọn, bảng đứng yên. Nhưng:

có giao dịch dài mở song song:
  vòng 1:  14 MB,  hàng chết = 200000
  vòng 2:  21 MB,  hàng chết = 400000
  vòng 3:  28 MB,  hàng chết = 600000
  vòng 4:  35 MB,  hàng chết = 800000
  vòng 5:  41 MB,  hàng chết = 1000000

Bảng phình từ 14 lên 41 MB, và hàng chết chất chồng tới một triệu — dù tôi chạy VACUUM sau mỗi vòng. Đây chính là chỗ tôi suýt kết luận sai: VACUUM chạy trơn tru, không báo một lỗi nào, nên phản xạ đầu tiên của tôi là "VACUUM hỏng rồi" hay "chắc autovacuum tắt". Cả hai đều sai. VACUUM làm đúng việc của nó — nó không dám dọn, vì phiên A với snapshot cũ khiến mọi hàng chết vẫn "có thể còn được nhìn thấy".

Bằng chứng nằm ở pg_stat_activity: phiên A có backend_xmin khác NULL (đang giữ một xmin) và xact_start từ lâu. Đó là kẻ chặn. Và để chắc, tôi đóng phiên A rồi chạy lại VACUUM: một triệu hàng chết lập tức trở thành dọn được, và VACUUM FULL co bảng về 7 MB — kích thước gốc. Các hàng chết đó luôn có thể dọn được; chỉ có snapshot của A ngăn lại suốt thời gian nó mở. Bài học đo lường: "VACUUM không dọn được" hầu như không bao giờ là lỗi của VACUUM — nó là dấu hiệu có một giao dịch cũ đang giữ tầm nhìn, và phải đi tìm giao dịch đó, không phải nghi ngờ công cụ.

Không chỉ phình: còn giữ khóa và chặn người khác

Bloat mới là một nửa tác hại. Một giao dịch dài còn giữ mọi khóa nó đã lấy cho tới khi kết thúc. Nếu nó từng UPDATE một hàng rồi ngồi im, hàng đó bị khóa suốt thời gian nó treo, và bất kỳ ai muốn sửa hàng đó phải chờ — như bài khóa hàng đã đo. Một giao dịch quên commit vì thế vừa làm bảng phình, vừa có thể làm treo cả những giao dịch khác. Đây là lý do trạng thái idle in transaction được coi là một trong những thứ nguy hiểm nhất cần theo dõi trên một CSDL production. Và bloat không tự biến mất khi giao dịch đóng: VACUUM thường chỉ đánh dấu chỗ trống để tái dùng chứ không trả đĩa, nên một bảng đã phình lên 41 MB sẽ giữ 41 MB đó cho tới khi bạn chạy VACUUM FULL (khóa cả bảng) hay dựng lại nó — cái giá của vài phút quên commit có thể kéo dài rất lâu sau khi thủ phạm đã đóng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: giữ giao dịch ngắn hết mức. Mở BEGIN ngay trước khi cần và COMMIT/ROLLBACK ngay sau khi xong; đừng bao giờ mở một giao dịch rồi đi gọi một API mạng chậm, chờ người dùng bấm nút, hay xử lý một file lớn ở giữa. Mỗi giây giao dịch mở thêm là thêm hàng chết mà cả hệ thống không được dọn. Lỗi kinh điển là một khối code mở giao dịch, ném ngoại lệ ở giữa, rồi không có đường rollback — giao dịch treo cho tới khi kết nối chết.

Hệ quả thứ hai: theo dõi idle in transaction. Đặt idle_in_transaction_session_timeout để PostgreSQL tự hủy các giao dịch treo quá lâu, và định kỳ soi pg_stat_activity tìm những dòng có xact_start từ rất lâu. Con số mang theo: một giao dịch dài (kể cả idle in transaction) giữ một xmin cũ, khiến VACUUM không dọn được hàng chết sinh ra sau đó — cùng một workload 5 vòng UPDATE+VACUUM, không có giao dịch dài thì bảng đứng yên 14 MB với 0 hàng chết, còn có thì bảng phình lên 41 MB với một triệu hàng chết; VACUUM chạy không lỗi nhưng bất lực, và đóng giao dịch kia thì VACUUM co bảng về 7 MB ngay. Khi thấy bảng phình hay VACUUM "không ăn thua", đừng đổ cho VACUUM — hãy tìm giao dịch cũ nhất đang mở.

Thử ba mươi giây

Trong psql, mở hai cửa sổ. Cửa sổ 1: BEGIN; SELECT 1; rồi để yên (đừng commit). Cửa sổ 2: chạy SELECT pid, state, xact_start, backend_xmin FROM pg_stat_activity WHERE state LIKE 'idle in transaction%'; — bạn sẽ thấy đúng phiên cửa sổ 1, kèm một backend_xmin khác NULL. Chính giá trị backend_xmin đó là "cái neo" giữ hàng chết lại: chừng nào nó còn, VACUUM ở mọi bảng đều không dọn được gì cũ hơn nó. Gõ COMMIT ở cửa sổ 1 và chạy lại truy vấn — dòng đó biến mất, và cái neo được nhấc lên. Nửa phút đó cho bạn thấy vì sao một BEGIN bị quên là kẻ thù thầm lặng của một CSDL khỏe mạnh.