Ở bài trang và tuple ta thấy mỗi hàng cõng 23 byte header, trong đó có xmin và xmax. Hai trường đó không phải thừa — chúng là nền tảng của cơ chế đồng thời quan trọng nhất PostgreSQL: MVCC. Bài này đo trực tiếp điều xảy ra khi bạn UPDATE một hàng, và kết quả trái ngược hẳn hình dung "sửa dữ liệu tại chỗ" mà hầu hết chúng ta mang trong đầu.
MVCC: nhiều phiên bản của cùng một hàng
MVCC (Multi-Version Concurrency Control — kiểm soát đồng thời nhiều phiên bản) là cách PostgreSQL cho nhiều giao dịch đọc và ghi cùng lúc mà không giẫm chân nhau. Ý tưởng cốt lõi: không bao giờ sửa một hàng tại chỗ. Khi bạn UPDATE, PostgreSQL không ghi đè giá trị cũ — nó tạo ra một phiên bản mới của hàng ở chỗ khác, và giữ lại phiên bản cũ.
Hai trường trong header mỗi tuple điều khiển việc này:
xmin: id của giao dịch đã tạo ra phiên bản này.xmax: id của giao dịch đã xóa (hoặc thay) phiên bản này; bằng 0 nghĩa là còn sống.
Khi UPDATE, phiên bản cũ được đặt xmax = id giao dịch đang update (đánh dấu "chết từ đây"), còn phiên bản mới ra đời với xmin mới. Một giao dịch khác bắt đầu trước lần update vẫn thấy phiên bản cũ (vì với nó, giao dịch update chưa xảy ra) — nên đọc không chặn ghi và ghi không chặn đọc. Đây là lý do trong PostgreSQL một truy vấn SELECT dài không khóa bảng lại chặn người khác UPDATE.
Đo: hàng "dời nhà" sau UPDATE
Tôi tạo một bảng, chèn hàng id=1, so_du=100, rồi đọc ctid (địa chỉ vật lý: số trang, số item), xmin, xmax:
TRƯỚC UPDATE: ctid=(0,1) xmin=734 xmax=0
Hàng nằm ở trang 0, item 1, do giao dịch 734 tạo ra và chưa bị ai thay (xmax=0). Giờ tôi chạy UPDATE tk SET so_du = 200 WHERE id = 1 và đọc lại:
SAU UPDATE: ctid=(0,2) xmin=735 xmax=0
ctid đổi từ (0,2) — hàng đã dời sang một tuple mới (item 2), với xmin mới (735, giao dịch update). Rồi tôi mổ trang 0 bằng pageinspect và thấy cả hai phiên bản còn nằm đó:
| Item | ctid | xmin | xmax | Trạng thái |
|---|---|---|---|---|
| 1 (cũ) | (0,2) | 734 | 735 | chết (bị update thay) |
| 2 (mới) | (0,2) | 735 | 0 | sống |
Phiên bản cũ (item 1) giờ có xmax=735 — bị giao dịch 735 thay — và trở thành dead tuple (tuple chết). Nó vẫn chiếm chỗ trên trang, chỉ là không còn "sống". Đáng chú ý, t_ctid của phiên bản cũ trỏ tới (0,2) — nó giữ một "đường dẫn tiếp" tới phiên bản mới, để một giao dịch đang đi theo hàng cũ có thể lần tới bản mới nhất. Một UPDATE đơn giản đổi một con số đã để lại một xác trên trang và một mũi tên trỏ tới người kế nhiệm.
Một lần tôi đo hớ: một hàng, 360KB
Cái làm tôi giật mình đến từ hệ quả tích lũy. Tôi tạo một bảng chỉ một hàng, rồi UPDATE nó 10.000 lần (mô phỏng một bản ghi bị cập nhật liên tục — như số dư tài khoản, bộ đếm lượt xem). Về mặt logic, bảng luôn có đúng một hàng. Tôi đo kích thước bảng trước và sau:
Trước: 8 KB (1 trang, 1 hàng)
Sau 10.000 UPDATE: 360 KB
n_live_tup = 1, n_dead_tup = 10000
Một bảng có đúng một hàng sống mà chiếm 360KB — phình gấp 45 lần. Con số này nghe vô lý: một hàng thì làm sao tốn 360KB? Theo kỷ luật, một con số bất khả như vậy là dấu hiệu tôi quên một đại lượng. Và tôi quên đúng cái vừa đo ở trên: dead tuple. Mỗi lần UPDATE đẻ ra một phiên bản mới và bỏ lại phiên bản cũ làm rác; sau 10.000 lần, bảng chứa 1 hàng sống và 10.000 tuple chết đang chiếm chỗ.
Cái tôi đo hớ là hình dung UPDATE như "sửa giá trị tại chỗ" — đổi 100 thành 200 ngay ô cũ, bảng vẫn nguyên kích thước. Sự thật hoàn toàn khác: UPDATE = chèn phiên bản mới + đánh dấu bản cũ chết, và bản cũ nằm lại cho tới khi có ai dọn. Bài học đo lường: khi một phép đo kích thước ("một hàng mà 360KB") mâu thuẫn với mô hình trong đầu ("một hàng thì nhỏ"), đừng cho là công cụ sai — kiểm lại mô hình. Ở đây mô hình "sửa tại chỗ" sai; mô hình đúng là "mỗi thay đổi để lại một xác", và n_dead_tup là con số phơi bày sự thật đó.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là hiểu vì sao PostgreSQL đọc-ghi đồng thời mượt mà: nhờ giữ phiên bản cũ, một SELECT dài chạy trên ảnh chụp nhất quán tại thời điểm nó bắt đầu, không bị chặn bởi và không chặn các UPDATE diễn ra sau đó. Đây là ưu điểm lớn của MVCC so với các hệ khóa-để-đọc, nơi một người đọc dài có thể chặn người ghi (hoặc ngược lại); nhưng nó không miễn phí — cái giá là các phiên bản cũ phải được lưu và dọn, và cơ sở dữ liệu phải theo dõi giao dịch nào thấy được phiên bản nào.
Hệ quả thứ hai, rất thực tế: bảng bị UPDATE/DELETE nhiều sẽ phình (bloat) nếu không được dọn. Một bảng "nóng" — hàng đợi công việc, bộ đếm, phiên đăng nhập — có thể to hơn nhiều lần dữ liệu logic của nó, vì dead tuple tích lại. Bloat làm mọi truy vấn quét bảng đó chậm đi (phải đọc qua cả xác chết) và tốn đĩa. Đây là lý do UPDATE cột lớn liên tục, hay UPDATE cả bảng thường xuyên, đắt hơn ta tưởng — mỗi lần là một hàng mới cộng một xác. Nó cũng là lý do một mẹo tối ưu quen thuộc: gom nhiều thay đổi nhỏ thành ít UPDATE hơn, hoặc tách cột bị cập nhật liên tục (như bộ đếm) ra bảng riêng, để phần dữ liệu ít đổi không phải sinh phiên bản mới mỗi lần. Công cụ dọn xác là VACUUM (bài tiếp theo), và hiểu MVCC là hiểu vì sao VACUUM tồn tại.
Hệ quả thứ ba là bài học đo lường. Con số mang theo: PostgreSQL không sửa hàng tại chỗ — UPDATE tạo phiên bản mới (ctid đổi (0,1) sang (0,2), xmin mới) và biến bản cũ thành dead tuple (xmax = giao dịch update); một hàng bị UPDATE 10.000 lần làm bảng phình 8KB lên 360KB với 10.000 tuple chết. Đọc xmin/xmax/ctid là nhìn thẳng vào cơ chế; và khi kích thước bảng "vô lý" so với số hàng logic, thủ phạm gần như luôn là dead tuple chưa dọn, không phải công cụ đo sai.
Thử ba mươi giây
Trong PostgreSQL, chọn một hàng và xem địa chỉ vật lý cùng phiên bản của nó: SELECT ctid, xmin, xmax, * FROM ten_bang WHERE dieu_kien;. Ghi lại ctid, rồi UPDATE hàng đó một cột bất kỳ và chạy lại truy vấn — bạn sẽ thấy ctid đổi, chứng tỏ hàng đã dời sang một tuple mới chứ không sửa tại chỗ. Rồi kiểm sức khỏe bảng: SELECT relname, n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'ten_bang';. Nếu n_dead_tup cao so với n_live_tup, bảng của bạn đang bloat — đầy xác chết chờ VACUUM dọn, đúng cái bài này đo.