Đang tải nội dung…
Đang tải nội dung…
Hai session, 40.000 dòng và một snapshot chưa đóng. VACUUM chạy được chưa có nghĩa dọn được.
PostgreSQL Internals · Phần 1Bối cảnh hư cấu, bằng chứng lab thật. Fortex trong tuyến truyện này là bối cảnh hư cấu; Tom và Ben là nhân vật. Các cuộc trao đổi minh họa quá trình suy nghĩ. Số đo và assertion thuộc lab Docker được dẫn trong bài, không phải số liệu production của Fortex.
Hành trình ở Fortex · Chương 3/8
Màn hình báo cáo của Fortex cần đọc nhiều phần dữ liệu nhất quán. Ben chọn REPEATABLE READ, lấy xong dữ liệu rồi để connection chờ bước xuất file. “Em chưa ghi gì mà,” cậu giải thích. “Giữ thêm một lúc chắc không ảnh hưởng.”
Tom không bảo cậu đóng ngay. Anh hỏi: “Nếu bên khác cập nhật trong lúc em chờ, lần đọc tiếp theo của em phải thấy giá trị nào?”
“Giá trị cũ. Em cần báo cáo nhất quán.”
“Vậy ai giữ giá trị cũ cho em?”
Ben nhìn về ghi chú hôm dọn MySQL. Cậu đã biết file không nhất thiết nhỏ sau khi xóa, nhưng chưa nghĩ một lời hứa dành cho người đọc có thể giữ lịch sử ở lại. Tom mở hai session trong lab: một cho báo cáo của Ben, một cho writer.
“Có phải mọi SELECT đều gây bloat không anh?”
Tom lắc đầu. “Em vừa chỉ ra một nhu cầu có thật: đọc nhất quán. Mình xác định nó giữ snapshot lúc nào và đến lúc nào, rồi mới quyết định ranh giới transaction.”
Nếu đã đọc bài DELETE và file MySQL, bạn đã gặp sự khác nhau giữa xóa dữ liệu và trả lại disk. Bài này giữ câu hỏi đó, nhưng đi thêm một tầng: khi nào database được phép dọn phiên bản cũ?
Phạm vi ở đây là PostgreSQL 17.6, bảng heap, isolation REPEATABLE READ, một transaction đọc và một session ghi. Không suy rộng thành “bất kỳ SELECT nào cũng giữ snapshot mãi”. Một SELECT autocommit kết thúc xong và một transaction đọc kéo dài là hai tình huống khác nhau.
Tuple là một phiên bản của dòng trong heap. Cùng một ID nghiệp vụ có thể có phiên bản trước và sau cập nhật. Snapshot quyết định transaction được nhìn thấy những phiên bản nào. VACUUM dọn phiên bản không còn cần thiết và làm phần không gian đó có thể tái sử dụng, đồng thời còn có các nhiệm vụ bảo trì khác.
Để đối chiếu isolation, xem tài liệu transaction isolation. Trong lab, session A thực hiện lần đọc đầu tiên sau BEGIN, nhờ đó có snapshot thực sự trước lúc session B ghi. Chỉ mở BEGIN rồi chưa làm gì không phải cách tái hiện đầy đủ tình huống này.
Ben tự mô tả lại: “Writer tạo phiên bản mới. Reader của em không tạo chúng, nhưng vẫn cần phiên bản trước.”
“Đúng. Giữ hai vai trò ấy riêng ra,” Tom nói. “Nếu gom hết thành ‘SELECT làm phình bảng’, người đọc sẽ sửa nhầm thứ.”
Session A đọc giá trị của dòng 1. Session B cập nhật dòng 1 rồi commit. Nếu A đang ở REPEATABLE READ, A vẫn phải đọc được dữ liệu theo snapshot của mình khi truy vấn lại.
Nếu phiên bản cũ bị thu hồi ngay khi B commit, database không còn đủ thông tin để đáp ứng lần đọc đó. Vì vậy, phần dọn cần tôn trọng các transaction hoặc cơ chế khác còn có thể cần phiên bản ấy. Tài liệu routine vacuuming giải thích điều kiện thu hồi này.
Sơ đồ thứ tự các bước của lab. Độ dài từng đoạn không biểu diễn thời gian thực thi.
Ở bài này, ta không cần dựng hàng giờ tải ghi để làm đầy disk. Chỉ cần một lần cập nhật đủ rõ để quan sát số phiên bản cũ trước và sau khi đóng transaction.
Ben đề nghị tắt tác vụ nền để không bị chen giữa các mốc đo. Tom đồng ý giới hạn thay đổi ở bảng dùng một lần. Cả hai ghi rõ điều kiện đó cạnh kết quả, để cấu hình phục vụ thí nghiệm không bị mang sang vận hành.
Container PostgreSQL có 1 CPU, giới hạn 512 MB RAM, dữ liệu tmpfs tối đa 256 MB, không mở port. Image được khóa phiên bản và digest trong source. Dataset gồm 40.000 dòng, payload 256 ký tự sinh xác định từ ID.
Lab tắt autovacuum trên đúng bảng dùng một lần, để tác vụ nền không chen vào giữa các lần đo. Đây là cách kiểm soát biến thí nghiệm, không phải cấu hình khuyến nghị cho hệ thống thật.
CREATE TABLE events (
id INT PRIMARY KEY,
payload TEXT NOT NULL
) WITH (autovacuum_enabled = false);
INSERT INTO events
SELECT n, repeat(md5(n::text), 8)
FROM generate_series(1, 40000) n;
Ta dùng extension pgstattuple để lấy độ dài relation và số tuple cũ. Phép đo này quét relation và có yêu cầu quyền truy cập; không nên tùy tiện chạy liên tục trên bảng lớn. Tài liệu pgstattuple cũng lưu ý cách công cụ phân loại tuple và giới hạn khi có ghi đồng thời.
Chạy từ source tải cuối bài:
python3 labs/engine.py postgres-mvcc-vacuum
Script tự điều phối hai session, chờ marker xác nhận snapshot đã được tạo rồi mới cho writer tiếp tục. Không dựa vào việc chèn một khoảng sleep cố định và hy vọng hai session chạy đúng thứ tự.
Tom để Ben điều phối hai session. Cậu không chỉ đếm tuple: cậu yêu cầu reader cũ đọc lại và một reader mới đọc cùng row. “Em muốn chứng minh snapshot thực sự giữ giá trị cũ, chứ không chỉ thấy một transaction đang mở.”
Session A mở transaction và thực hiện lần đọc:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM events;
SELECT payload FROM events WHERE id = 1;
-- Giữ nguyên transaction ở đây.
Session B cập nhật toàn bảng, dùng payload mới có cùng độ dài:
UPDATE events
SET payload = repeat(md5('updated-' || id::text), 8);
VACUUM (VERBOSE, TRUNCATE FALSE, ANALYZE) events;
TRUNCATE FALSE ở lệnh VACUUM có chủ đích: không cho việc cắt các page trống cuối relation làm lẫn hai câu hỏi “tuple có dọn được không” và “file có thu nhỏ không”. Đừng nhầm tùy chọn này với lệnh SQL TRUNCATE TABLE.
Khi A còn mở, script đọc lại payload ở A và ở một session mới. A vẫn thấy giá trị cũ; session mới thấy giá trị đã cập nhật. Đây là kiểm tra rằng snapshot thực sự đang có tác dụng, không chỉ có một kết nối nằm đó mà chưa giữ lịch sử cần thiết.
Sau đó A COMMIT. B chạy lại cùng dạng VACUUM, đo lần nữa. Cuối cùng mới chạy VACUUM FULL để đối chiếu hành vi rewrite file.
| Mốc đo | Dòng hiện tại | Tuple cũ theo pgstattuple | Độ dài heap (MiB) |
|---|---|---|---|
| Seed | 40,000 | 0 | 11,58 |
| Sau UPDATE | 40,000 | 40,000 | 23,15 |
| VACUUM · snapshot còn mở | 40,000 | 40,000 | 23,15 |
| VACUUM · snapshot đã đóng | 40,000 | 0 | 23,15 |
| VACUUM FULL | 40,000 | 0 | 11,58 |
Hai phần biểu đồ dùng hai đơn vị khác nhau: MiB cho heap, số tuple cho phiên bản cũ. Không cộng hoặc suy trực tiếp một đơn vị từ đơn vị kia. Nguồn: report đã xuất bản, SQL và raw log.
Sau khi đóng A, số tuple cũ giảm về 0 qua VACUUM thường, trong khi file vẫn giữ khoảng 23,15 MiB. Phần dung lượng đó đã chuyển sang trạng thái có thể tái sử dụng, nhưng phép đo này cố ý không cho cắt phần cuối file.
Sau VACUUM FULL, relation trở lại khoảng 11,58 MiB. Số dòng hiện tại luôn là 40.000 ở các mốc, và dấu vân tay payload sau khi cập nhật được đối chiếu trước/sau các bước dọn. Ta đã thu hồi phần lịch sử không còn cần, không làm mất dữ liệu hiện tại.
“Công cụ gọi nó là dead. Sao VACUUM chưa dọn?” Ben hỏi.
Tom chỉ sang kết quả đọc ở session A. “Nếu thu hồi ngay, lần đọc này lấy dữ liệu từ đâu?” Ben thêm điều kiện không còn ai cần cạnh chữ dead, rồi kiểm tra cách công cụ phân loại.
dead_tuple_count trong report là cách pgstattuple phân loại tuple theo phép kiểm tra visibility của công cụ. Nó không đồng nghĩa “mọi tuple trong số này đều có thể bị VACUUM xóa ngay ở thời điểm đo”.
Lab cho một ví dụ trực tiếp: công cụ báo 40.000 tuple cũ, nhưng snapshot A vẫn đọc được giá trị cũ và lần VACUUM đầu chưa dọn chúng. Điều kiện an toàn để thu hồi còn phải xét transaction khác.
Cũng vì vậy, chỉ nhìn một con số thống kê rồi tăng tần suất autovacuum có thể chưa giải quyết đúng nút thắt. Nếu một snapshot vẫn cần lịch sử, gọi tác vụ dọn thêm lần nữa không làm nhu cầu đó biến mất.
Tuy nhiên, đừng chốt nguyên nhân là “có transaction lâu” chỉ từ một ảnh dashboard. Thực tế còn có các horizon hoặc cơ chế giữ dữ liệu khác; cần kiểm tra đúng session, isolation, snapshot, replication và trạng thái vacuum của hệ đang vận hành. Lab này chứng minh một nguyên nhân cụ thể bằng cách giữ rồi thả nó ra.
“Đóng reader, dọn tuple, rồi file có thể vẫn giữ chỗ để dùng lại,” Ben nói, tự nối lại bài MySQL.
Tom mỉm cười. “Lần này em đã tách đúng ba việc. Giờ chọn việc Fortex cần, đừng chạy cả ba chỉ vì có lệnh.”
Đó là ba việc giải quyết ba phần khác nhau, không phải ba mức mạnh dần của cùng một nút bấm.
| Việc làm | Giải quyết phần nào? | Không giải quyết phần nào? | Đánh đổi |
|---|---|---|---|
| Kết thúc transaction giữ snapshot | Bỏ một nguyên nhân khiến phiên bản cũ còn cần giữ | Không tự chạy thay toàn bộ công việc vacuum | Cần hiểu tác vụ đang chạy; ngắt session có thể làm hỏng job báo cáo hoặc transaction nghiệp vụ |
| VACUUM thường | Thu hồi tuple đủ điều kiện để không gian được dùng lại | Không bảo đảm co toàn bộ relation về kích thước tối thiểu | I/O và tài nguyên bảo trì; thường cho phép DML tiếp tục |
| VACUUM FULL | Rewrite bảng để thu gọn file | Không sửa việc ứng dụng tiếp tục giữ snapshot lâu ở những đợt ghi sau | ACCESS EXCLUSIVE lock và dung lượng cho bản mới; không phải công việc bảo trì mặc định |
| Timeout cho transaction bỏ quên | Hạn chế thời gian một kết nối nằm idle trong transaction | Không thay thế việc đóng transaction đúng và timeout tác vụ đang thực thi | Có thể ngắt workflow hợp lệ nếu đặt không phù hợp |
Phạm vi lock, phần dung lượng tạm và các tùy chọn VACUUM cần đối chiếu tài liệu lệnh VACUUM của PostgreSQL 17. Việc timeout là một biện pháp vận hành được tài liệu hỗ trợ, không nằm trong các assertion của lab; xem idle_in_transaction_session_timeout.
Một hệ có workload cập nhật liên tục thường cần cơ chế vacuum giữ trạng thái ổn định. Nếu cứ thu nhỏ bảng rồi nó lại tăng đầy ngay, bạn có thể đang tiêu tốn công sức để đi qua cùng một vòng cấp phát và rewrite.
Ben bỏ ý định giữ transaction qua bước xuất file. Cậu đề nghị lấy dữ liệu cần thiết trong một ranh giới rõ, đóng transaction rồi mới xử lý phần ngoài DB; với báo cáo quá lớn, nhóm sẽ phải thiết kế cách xuất phù hợp thay vì giả định gom hết vào RAM. Tom yêu cầu đo thời gian snapshot và kiểm tra yêu cầu nhất quán trước khi chốt.
Trong thử nghiệm này, snapshot chỉ được giữ trong vài bước ngắn. Không có kết luận rằng “một SELECT chắc chắn làm server hết disk sau N phút”. Để đi tới hết disk còn phụ thuộc tốc độ ghi, độ dài transaction, số relation, headroom và những nguồn tăng dung lượng khác.
Nhưng quan hệ nhân quả ở đây đã đủ rõ để dùng khi chẩn đoán: giữ snapshot → cập nhật tạo phiên bản cũ → VACUUM chưa dọn được; thả snapshot → cùng thao tác VACUUM dọn được. Chỉ thay đổi điều kiện giữ lịch sử, không đổi dataset hay tăng tài nguyên.
Nếu một query đọc khác đang chậm, heap lớn cũng có thể làm tăng phần công việc phải đi qua. Bài về index và Seq Scan giải thích vì sao số page và lượng dòng cần lấy quan trọng hơn việc query có một index mang tên đẹp hay không.
Ben viết checklist này cho người trực sau mình. Cậu thêm cả câu hỏi “ai còn cần lịch sử?” trước danh sách lệnh bảo trì.
Một session không ghi dữ liệu vẫn có thể ảnh hưởng đến thời điểm người khác được dọn dữ liệu. Nhìn thấy điều đó trên hai session nhỏ sẽ dễ hơn nhiều so với lần đầu nhận ra nó khi ổ đĩa đã gần đầy.
Trong sổ, Ben viết: “Không ghi dữ liệu không có nghĩa không giữ tài nguyên.” Cậu bắt đầu nhìn vòng đời của request thay vì chỉ nhìn từng câu SQL.
Ngày tiếp theo, bài toán tồn kho đặt trước mặt cậu một thử thách khó chịu hơn: lần này transaction đã đóng đúng, các lệnh đều thành công, mà kết quả vẫn sai.
Chương trước: Có index rồi, vì sao database vẫn quét cả bảng? · Chương tiếp: Hai request cùng trừ kho, vì sao chỉ mất một lần? · Bắt đầu hành trình
PostgreSQL 17.6 · 40.000 dòng · 1 CPU · 512 MB
Tải source Docker Lab (.zip)COMMIT 7f7f0e83eed945de4f3bbd059424e68314739762
Đọc kỹ README trước khi chạy. Các lệnh thực nghiệm chuẩn trong Docker Lab:
make setupmake seedmake reproducemake verifymake cleanupMôi trường Docker độc lập, giới hạn tài nguyên và tự động dọn dẹp sau khi chạy. Số liệu trong bài viết là kết quả lần chạy chính thức; mỗi lần chạy mới đều lưu lịch sử riêng.