Khi đọc EXPLAIN (ANALYZE, BUFFERS), bạn thấy hai con số về trang: shared hitread. Trực giác tự nhiên là "hit = từ bộ nhớ (nhanh), read = từ đĩa (chậm)". Trực giác đó dẫn tôi tới một kết luận sai khi nhìn một truy vấn có read cao. Sự thật là PostgreSQL đọc dữ liệu qua hai tầng cache, và chữ read không có nghĩa như bạn tưởng. Bài này đo cả hai tầng để thấy read thật sự là gì.

Cache HĐH và double buffering

Hai tầng cache, không phải một

Như bài shared_buffers đã đo, PostgreSQL có một vùng đệm riêng của mình, shared_buffers, giữ các trang nóng trong RAM. Nhưng đó chỉ là tầng thứ nhất. Bên dưới nó còn một tầng nữa: page cache của hệ điều hành — HĐH cũng giữ trong RAM những trang file mà tiến trình đọc gần đây.

Khi PostgreSQL cần một trang, nó tìm ở shared_buffers trước. Trúng thì EXPLAIN ghi shared hit — nhanh nhất, không rời khỏi PG. Không có thì PG đọc trang đó qua HĐH, và EXPLAIN ghi read. Nhưng đây là mấu chốt: khi PG "đọc", HĐH không nhất thiết chạm đĩa — nó thường trả trang đó ngay từ page cache của chính nó (vẫn trong RAM). Chỉ khi cả hai tầng đều không có, trang mới thật sự được lấy từ đĩa. Nghĩa là read có ba mức tốc độ rất khác nhau, và mặc định người ta hay nhầm nó với mức chậm nhất. Khoảng cách giữa ba mức là rất lớn: shared hit tốn chừng vài chục nano giây, OS cache thêm một cú gọi hệ thống và một lần chép bộ nhớ (vẫn dưới một micro giây), còn đọc đĩa thật là hàng trăm micro tới vài mili giây trên SSD — chậm hơn RAM cả nghìn lần. Vì thế gộp cả ba vào một chữ read rồi coi như chúng giống nhau là bỏ sót đúng cái khác biệt lớn nhất.

Một lần tôi đo hớ: read cao không có nghĩa xuống đĩa

Tôi đặt shared_buffers = 32MB (nhỏ, để dễ thấy hiệu ứng), rồi tạo một bảng 130MB — lớn hơn shared_buffers nhiều, nên không thể nằm hết trong tầng một. Quét cả bảng ba lần liên tiếp, EXPLAIN cho:

bảng lớn 130MB:  shared hit=3907,  read=12760
thời gian: 17ms, 17ms, 17ms  (ổn định cả ba lần)

Thấy read=12760, phản xạ của tôi là "12760 trang phải lấy từ đĩa — truy vấn này chắc chậm và phụ thuộc tốc độ đĩa". Nhưng con số thời gian cãi lại: 17ms, ổn định y hệt cả ba lần. Nếu 12760 trang kia thật sự đọc từ đĩa vật lý, thời gian phải cao hơn nhiều và dao động giữa các lần (đĩa có độ trễ, có tranh chấp). Sự ổn định 17ms là dấu hiệu rõ ràng: những trang read đó được page cache của HĐH phục vụ từ RAM. Bảng 130MB không nằm hết trong shared_buffers (32MB), nhưng nó nằm gọn trong RAM của máy qua tầng cache thứ hai.

Cái sai của tôi là đọc read thành "từ đĩa". Đúng ra read chỉ có nghĩa "không tìm thấy trong shared_buffers, phải nhờ HĐH lấy" — và HĐH gần như luôn lấy từ page cache của nó nếu trang còn nóng. Đây là một biến ẩn kinh điển: hai con số (read cao nhưng thời gian thấp và ổn định) tưởng mâu thuẫn, thật ra là vì tôi gán sai ý nghĩa cho read. Muốn biết một read có xuống đĩa thật không, phải nhìn thời gian (và độ dao động), không nhìn riêng con số trang.

Đo tầng một: shared hit sạch

Để thấy tầng shared_buffers rõ ràng, tôi dùng pg_prewarm nạp hẳn một bảng nhỏ 13MB (vừa trong 32MB) vào shared_buffers, rồi quét:

bảng nhỏ 13MB, sau pg_prewarm:  shared hit=1667,  read=0

Toàn bộ shared hit, không một read nào — vì cả bảng đã nằm trong tầng một. Và pg_buffercache (một extension cho nhìn vào trong shared_buffers) xác nhận:

nho: 1667 trang (13MB — cả bảng)  trong shared_buffers
lon: 2071 trang (16MB — chỉ một phần 130MB)  trong shared_buffers

Bảng nhỏ nằm trọn trong tầng một; bảng lớn chỉ có 16MB lọt vào, phần còn lại sống ở tầng hai (OS cache) — đúng như phép đo thời gian ở trên gợi ý.

Double buffering: cái giá của hai tầng

Có hai tầng cache đem lại một hệ quả: một trang nóng có thể nằm ở cả hai cùng lúc — trong shared_buffers trong page cache của HĐH. Đó là double buffering: cùng 13MB của bảng nhỏ có thể chiếm RAM hai lần. Đây là lý do PostgreSQL cố ý không đặt shared_buffers chiếm hết RAM (khuyến nghị thường là khoảng 25%): nó dựa vào OS cache làm tầng hai, và để RAM cho tầng đó làm việc.

Điều này lật ngược một trực giác sai phổ biến: "cho shared_buffers càng lớn càng nhanh". Đẩy shared_buffers lên chiếm gần hết RAM khiến hai chuyện xấu: double buffering nhân đôi phần bộ nhớ cho các trang nóng, bỏ đói OS cache — tầng hai không còn đủ RAM để giữ phần dữ liệu không lọt tầng một. Kết quả có thể chậm hơn, không nhanh hơn. shared_buffers là một con số cần cân, không phải cứ to là tốt. Cũng vì hai tầng này mà việc "hâm nóng" một CSDL sau khi khởi động lại có hai giai đoạn: OS cache nạp lại khá nhanh khi dữ liệu được đọc, còn shared_buffers thì trống trơn cho tới khi truy vấn chạy qua — đó là lý do vài truy vấn đầu sau khi restart thường chậm hơn, rồi ổn định dần khi cả hai tầng ấm lên.

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

Hệ quả đầu tiên: đọc read trong EXPLAIN cho đúng — nó là "không ở shared_buffers", không phải "từ đĩa". Một truy vấn read cao chưa chắc chậm; nếu dữ liệu nóng, HĐH phục vụ từ RAM. Muốn biết có I/O đĩa thật không, nhìn thời gian và độ ổn định của nó qua nhiều lần chạy, hoặc bật track_io_timing để đo trực tiếp thời gian I/O. Như bài chi phí một truy vấn đã đo, số trang là thước đo công việc — nhưng công việc đó rơi vào tầng cache nào mới quyết định tốc độ.

Hệ quả thứ hai: đừng chỉnh shared_buffers to hết cỡ. Vì có tầng OS cache, một shared_buffers vừa phải cộng nhiều RAM tự do cho HĐH thường tốt hơn một shared_buffers khổng lồ. Con số mang theo: PostgreSQL đọc qua hai tầng cache (shared_buffers rồi OS page cache); 'shared hit' là trúng tầng một, 'read' là phải nhờ HĐH — mà HĐH thường trả từ page cache của nó (RAM), không phải đĩa. Đo: bảng 130MB cho read=12760 nhưng 17ms ổn định (OS cache phục vụ), bảng nhỏ prewarm cho shared hit=1667 read=0; một trang nóng nằm ở cả hai tầng là double buffering, nên shared_buffers to hết RAM có thể chậm hơn. Trước khi lo một truy vấn read cao, xem thời gian của nó — read không đồng nghĩa với đĩa.

Thử ba mươi giây

Trên một PostgreSQL bất kỳ, chạy EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM bảng_lớn; hai lần liên tiếp. Lần đầu bạn có thể thấy read cao (dữ liệu chưa vào cache nào); lần hai, shared hit tăng và read giảm — nhưng để ý: kể cả khi read vẫn còn, thời gian lần hai thường vẫn nhanh và ổn định, vì OS cache đã giữ các trang đó. Nếu có pg_buffercache, chạy SELECT count(*) FROM pg_buffercache WHERE relfilenode = pg_relation_filenode('bảng_lớn'); để đếm chính xác bao nhiêu trang của bảng đang nằm trong shared_buffers — bạn sẽ thấy với bảng lớn hơn shared_buffers, con số đó nhỏ hơn tổng số trang, phần còn lại sống ở tầng OS cache mà EXPLAIN gọi là read.