Một thư viện có quy tắc dọn kho: sách cũ hết người mượn thì được đem đi tái chế để lấy chỗ. Nhưng có một quy tắc an toàn — chừng nào còn một độc giả đăng ký muốn xem một phiên bản cũ, thủ thư không được đụng vào bất kỳ cuốn nào cũ hơn thời điểm người đó đăng ký, phòng khi họ cần đối chiếu. Giờ tưởng tượng một độc giả đăng ký rồi bỏ đi ăn trưa, quên hủy đăng ký. Thủ thư vẫn chăm chỉ đi dọn mỗi giờ, nhưng chẳng vứt được cuốn nào — kho cứ đầy lên. Trong PostgreSQL, "độc giả quên hủy đăng ký" đó là một giao dịch mở bị quên commit, và bài này là lần tôi tưởng thủ thư lười, hóa ra là có người đang giữ chỗ.
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.
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 (đúng cái độc giả đăng ký chưa hủy). Đó 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.
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ở.
Điều rút ra rộng hơn cả chuyện VACUUM: "công cụ dọn chạy mà không ăn thua" hầu như không bao giờ là lỗi của công cụ — nó là dấu hiệu có ai đó đang giữ một tham chiếu cũ, và việc cần làm là đi tìm cái tham chiếu đó, chứ không phải nghi ngờ công cụ. Ở đây, cái tham chiếu là một giao dịch quên commit.
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ề đến code hằng ngày
Điều đầ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.
Điều 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. Gói lại bằng số: 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ở.
Và cái mẫu hình "một tham chiếu cũ bị quên giữ khiến bộ dọn không thu hồi được tài nguyên" này chính là bản chất của một trong những lớp bug dai dẳng nhất mọi ngôn ngữ: rò rỉ bộ nhớ. Trong Java, garbage collector cũng "chạy trơn tru mà không dọn được" khi một object đáng lẽ chết vẫn bị một static collection, một listener chưa gỡ, hay một ThreadLocal giữ tham chiếu — GC không hỏng, chỉ là còn một cái neo, y hệt backend_xmin của phiên A. Go cũng vậy với một goroutine treo giữ tham chiếu, hay một context không bao giờ cancel. Ngay cả một file handle hay connection quên đóng cũng cùng một câu chuyện: tài nguyên không được thu hồi không phải vì bộ dọn kém, mà vì có kẻ đang cầm. Mẫu số chung đáng mang theo: khi bộ thu hồi tài nguyên (GC, VACUUM, connection pool) chạy mà tài nguyên vẫn tích tụ, đừng nghi bộ thu hồi — hãy đi tìm cái tham chiếu sống mà lẽ ra phải chết; gần như luôn có một thứ được mở/đăng ký/giữ mà quên đóng.
Muốn tự thấy thì 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.