Trong PostgreSQL, UPDATE không sửa dòng cũ. Nó ghi một dòng mới và đánh dấu dòng cũ là hết hạn. Phần này đo hậu quả của thiết kế đó — bằng cách nhìn thẳng vào các byte trên đĩa.

Chuỗi phiên bản, mức phình theo số lần cập nhật, và ảnh hưởng của giao dịch dài

Nhìn tận mắt bốn phiên bản

Một bảng, một dòng, ba lần cập nhật:

create table tk(id int primary key, so_du numeric(12,2), ten text)
  with (autovacuum_enabled = off);
insert into tk values (1, 1000.00, 'Tai khoan A');

Mỗi dòng trong PostgreSQL có hai cột hệ thống ẩn: xmin là số hiệu giao dịch đã tạo ra nó, xmax là giao dịch đã xoá nó. Còn ctid là địa chỉ vật lý dạng (số trang, vị trí trong trang):

select ctid, xmin, xmax, so_du from tk;
Sau ctid xmin xmax so_du
ban đầu (0,1) 733 0 1000.00
lần 1 (0,2) 734 0 1100.00
lần 2 (0,3) 735 0 1200.00
lần 3 (0,4) 736 0 1300.00

ctid đổi sau mỗi lần cập nhật — dòng chuyển sang vị trí vật lý khác. Đây đã là một tín hiệu: nếu UPDATE sửa tại chỗ thì địa chỉ không có lý do gì phải đổi.

Bây giờ mở trang đĩa ra xem. Extension pageinspect cho phép đọc thẳng nội dung một trang 8 kB:

create extension pageinspect;
select lp, t_xmin, t_xmax, t_ctid
from heap_page_items(get_raw_page('tk', 0));
 lp  t_xmin  t_xmax  t_ctid
  1     733     734   (0,2)
  2     734     735   (0,3)
  3     735     736   (0,4)
  4     736       0   (0,4)

select count(*) from tk trả về 1. Trên đĩa có 4.

Ba phiên bản đầu đều có t_xmax khác 0 — chúng đã chết. Và t_ctid của mỗi phiên bản trỏ sang phiên bản kế tiếp, tạo thành một chuỗi: khi một giao dịch cũ đang đọc phiên bản 1 và cần biết phiên bản mới nhất là gì, nó lần theo chuỗi này.

Phiên bản thứ tư có t_xmax = 0t_ctid trỏ vào chính nó — đó là dòng còn sống, và là dòng duy nhất bạn nhìn thấy.

Ở quy mô thật

Bảng 500.000 dòng, cập nhật toàn bảng năm lần, không VACUUM:

Bước Số dòng Kích thước Dòng sống Dòng chết
ban đầu 500.000 60 MB 500.000 0
sau lần 1 500.000 120 MB 500.000 499.999
sau lần 2 500.000 180 MB 500.000 999.980
sau lần 3 500.000 240 MB 500.216 1.499.762
sau lần 4 500.000 300 MB 501.745 1.998.175
sau lần 5 500.000 361 MB 501.211 2.498.676

Số dòng không đổi. Kích thước tăng gấp sáu.

Câu update sp set ton = ton + 1 trông như sửa một số nguyên trong 500.000 dòng, nhưng thực tế nó ghi ra 500.000 dòng hoàn toàn mới — kể cả cột mo_ta dài 64 ký tự không hề thay đổi. PostgreSQL không lưu delta; mỗi phiên bản là một bản sao đầy đủ của cả dòng.

Điều đó cũng giải thích một chuyện tưởng vô lý: update sp set ton = ton — gán chính giá trị cũ — vẫn sinh ra 500.000 phiên bản mới và vẫn làm bảng phình gấp đôi. PostgreSQL không so sánh giá trị trước khi ghi.

Chú ý cột "Dòng sống" nhích lên 501.211 ở cuối. Đó là ước lượng từ ANALYZE, không phải số đếm chính xác — trên bảng đầy dòng chết, phép lấy mẫu bắt đầu lệch. Bản thân điều này đã là một hệ quả đáng nhớ: bảng phình làm hỏng cả thống kê mà bộ lập lịch dựa vào (phần 20).

Cái giá khi đọc

Mọi phép quét đều phải đi qua các phiên bản chết để kiểm tra xem chúng có hiển thị với giao dịch hiện tại không:

Trạng thái select count(*) Trang đọc
Phình 361 MB 43,22 ms 46.152
Sau VACUUM FULL, còn 60 MB 16,94 ms 7.693

Chậm 2,6 lần và đọc gấp 6 lần số trang, cho cùng một kết quả. Đây là chi phí thật của bloat: không phải tiền đĩa, mà là mọi truy vấn đều chậm đi.

DELETE cũng không xoá gì cả

create table xd(id int, gc text) with (autovacuum_enabled = off);
insert into xd select i, md5(i::text) from generate_series(1, 300000) i;
-- 20 MB
delete from xd;
-- vẫn 20 MB, 0 dòng sống, 300.000 dòng chết

Quét bảng rỗng đó vẫn phải đọc 2.500 trang. Một bảng không còn dòng nào mà đọc mất 1,29 ms.

DELETE chỉ ghi xmax vào từng dòng. Không gian được giải phóng ở bước sau, và bước đó là VACUUM:

sau VACUUM thường      : 0 bytes

Ở đây VACUUM thường co được bảng về 0 vì mọi trang trống đều nằm ở cuối tệp — PostgreSQL cắt được phần đuôi. Đó là trường hợp may mắn, và phần sau sẽ đo trường hợp phổ biến hơn nhiều: chỗ trống nằm rải rác ở giữa và VACUUM không cắt được gì.

Chỉ mục cũng phình theo

Mỗi phiên bản dòng mới cần một mục mới trong mọi chỉ mục trỏ tới nó. Bảng 300.000 dòng với fillfactor=100 (không còn chỗ cho cập nhật HOT), cập nhật toàn bảng một lần:

Bảng Khoá chính Chỉ mục trên v
ban đầu 22 MB 6.600 kB 2.056 kB
sau một lần UPDATE 44 MB 13 MB 3.936 kB

Cả ba đều gấp đôi. Và điều đáng chú ý là khoá chính cũng phình, dù cột id không hề bị sửa — vì địa chỉ vật lý mà nó trỏ tới đã đổi.

Đây là một nửa lý do vì sao cập nhật HOT (phần 19) quan trọng đến vậy: khi phiên bản mới nằm vừa trong cùng trang, PostgreSQL bỏ qua toàn bộ việc cập nhật chỉ mục.

Vì sao không xoá ngay bản cũ

Vì có người đang đọc chúng.

Phiên A mở giao dịch ở mức repeatable read và đọc số dư. Trong lúc A còn mở, phiên B cập nhật và commit:

-- Phiên A
begin isolation level repeatable read;
select so_du from ac where id = 1;   -->  1000

-- Phiên B, lúc này:  update ac set so_du = 9999 where id = 1;  commit;

-- Phiên A, đọc lại
select so_du from ac where id = 1;   -->  1000
commit;

-- Sau khi A kết thúc
select so_du from ac where id = 1;   -->  9999

A vẫn thấy 1000 sau khi B đã ghi 9999 và commit xong. Điều đó chỉ khả thi vì phiên bản cũ còn nằm nguyên trên đĩa.

Đây là lợi ích lớn nhất của MVCC và là lý do người ta chấp nhận cái giá ở trên: người đọc không bao giờ chặn người ghi, và người ghi không bao giờ chặn người đọc. A không phải đợi B, B không phải đợi A. Trong một hệ thống dùng khoá đọc, một câu báo cáo chạy ba mươi giây sẽ chặn mọi thao tác ghi trong ba mươi giây đó.

Một giao dịch mở là đủ làm hỏng mọi thứ

Hệ quả trực tiếp: VACUUM không được phép xoá phiên bản nào mà một giao dịch đang mở còn có thể cần.

Bảng 200.000 dòng, ba vòng update + VACUUM ngay sau mỗi lần:

Kích thước cuối Dòng chết còn lại
Không có giao dịch dài 14 MB 0
Có một giao dịch mở từ trước 28 MB 600.000

VACUUM chạy đủ ba lần trong cả hai trường hợp. Ở trường hợp thứ hai nó tự nói ra lý do:

tuples: 0 removed, 800000 remain, 600000 are dead but not yet removable
removable cutoff: 779, which was 4 XIDs old when operation ended

"Dead but not yet removable" — đã chết nhưng chưa được phép dọn. Giao dịch mở kia có backend_xmin = 779, và VACUUM không được đụng vào bất cứ thứ gì mới hơn mốc đó.

Tôi để giao dịch đó mở thêm sáu vòng nữa: bảng lên 69 MB với 1.800.000 dòng chết. Từ 14 MB lên 69 MB, gấp năm lần, chỉ vì một phiên ngồi im không làm gì. Kết thúc nó rồi VACUUM một lần là dọn sạch cả 1,8 triệu dòng.

Đây là nguyên nhân số một của bloat trên máy chủ thật, và nó không phải lỗi của PostgreSQL — nó là hệ quả bắt buộc của việc bảo đảm phiên A vẫn đọc được số 1000.

Cách tìm thủ phạm:

select pid,
       state,
       round(extract(epoch from now() - xact_start)) as mo_bao_lau_giay,
       backend_xmin,
       left(query, 60) as cau_lenh
from pg_stat_activity
where xact_start is not null
  and pid <> pg_backend_pid()
order by xact_start;

Đừng dùng order by backend_xmin — kiểu xid không có toán tử so sánh và PostgreSQL sẽ báo could not identify an ordering operator for type xid. Muốn xếp theo độ cũ thì dùng age(backend_xmin), hàm đó trả về số nguyên.

Trạng thái đáng ngờ nhất là idle in transaction: một ứng dụng đã gửi BEGIN, làm xong việc, nhưng quên COMMIT hoặc ROLLBACK. Nó không tốn CPU, không giữ khoá, không xuất hiện trong log truy vấn chậm — và nó âm thầm chặn VACUUM trên toàn bộ cơ sở dữ liệu.

Chặn cứng chuyện này bằng một tham số:

alter system set idle_in_transaction_session_timeout = '5min';
select pg_reload_conf();

Sau 5 phút, phiên nào còn treo trong giao dịch sẽ bị ngắt. Một ứng dụng bị ngắt kết nối là chuyện dễ phát hiện và dễ sửa; một cơ sở dữ liệu phình gấp năm lần trong ba tuần thì không.

Sai lầm khi đo

Tôi diệt giao dịch dài bằng:

select pg_terminate_backend(pid) from pg_stat_activity
where state like '%transaction%';

và tưởng đã xong. Nhưng phiên đó đang chạy pg_sleep(120) nên state của nó là active, không phải idle in transaction — điều kiện lọc trượt, giao dịch vẫn sống, và bảng tiếp tục phình từ 28 MB lên 69 MB trong lúc tôi đo những thứ khác.

Bài học thẳng vào vấn đề: giao dịch dài gây hại không phụ thuộc vào state. Một câu báo cáo chạy ba mươi phút ở trạng thái active chặn VACUUM y hệt một phiên idle in transaction. Lọc theo backend_xmin is not null mới đúng, vì đó chính là thứ quyết định.

Thử ba mươi giây

select relname,
       n_live_tup as dong_song,
       n_dead_tup as dong_chet,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) as phan_tram_chet,
       pg_size_pretty(pg_relation_size(relid)) as kich_thuoc,
       last_autovacuum
from pg_stat_user_tables
where n_dead_tup > 1000
order by n_dead_tup desc
limit 10;

Bảng nào có phan_tram_chet trên 20% và last_autovacuum từ lâu thì hoặc autovacuum không theo kịp, hoặc có ai đó đang giữ một giao dịch mở. Chạy câu pg_stat_activity ở trên để biết là vế nào.

Phần sau đo VACUUM và bloat kỹ hơn: khi nào không gian được dùng lại, khi nào phải VACUUM FULL, và cái giá của mỗi lựa chọn.