Hãy tưởng tượng bạn tìm một cuốn sách. Trên bàn làm việc (nhanh nhất) không có — bạn phải đi "lấy" nó. Nhưng "đi lấy" ở đây có hai khả năng rất khác nhau: cuốn sách có thể đang nằm ngay trên kệ trong phòng (vài bước chân), hoặc phải xuống tận kho lưu trữ dưới tầng hầm (cả buổi). Nếu ai đó chỉ nói với bạn "cuốn sách không có trên bàn, phải đi lấy", bạn không thể kết luận là phải xuống hầm — mà tôi đã kết luận đúng như vậy, và sai. PostgreSQL đọc dữ liệu qua hai tầng cache y như phòng-và-hầm này, và chữ read trong EXPLAIN chỉ nói "không có trên bàn", không nói bạn phải đi đâu.
Khi đọc EXPLAIN (ANALYZE, BUFFERS), bạn thấy hai con số về trang: shared hit và read. 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ì.
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.
read cao mà truy vấn vẫn nhanh — chuyện gì vậy?
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ạ đầu tiên 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 ngay: 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 và 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, và 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ề đến code hằng ngày
Điều đầ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 độ.
Điều 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ồ. Gói lại bằng số: 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 được: 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, hãy xem thời gian của nó — read không đồng nghĩa với đĩa.
Và cái mô hình "cache nhiều tầng, mỗi tầng một cấp độ tốc độ" này không phải chuyện riêng của PostgreSQL — nó là cách cả ngành máy tính được xây, và bạn gặp lại nó ở mọi tầng. CPU có L1/L2/L3 rồi mới tới RAM, mỗi bước chậm hơn bậc trước cả chục lần; một "cache hit" ở L1 và một "cache miss" phải xuống RAM khác nhau y như shared hit và read ở đây. Ứng dụng Java/Go điển hình còn chồng thêm tầng nữa: cache trong tiến trình (Caffeine, một map thường) → Redis → rồi mới tới CSDL — và cùng cái bẫy lặp lại, một "Redis hit" nghe thì nhanh nhưng vẫn là một vòng mạng, chậm hơn hẳn cache trong RAM cùng tiến trình. Mẫu số chung đáng mang theo: một con số đếm "lần lấy dữ liệu" không cho bạn biết tốc độ — phải hỏi lấy từ tầng nào, và luôn có một tầng ẩn nhanh hơn (hoặc chậm hơn) cái bạn đang nghĩ tới.
Muốn tự thấy thì 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. Nửa phút đó dạy bạn đọc read theo đúng nghĩa của nó.