Suốt các bài trước, EXPLAIN (ANALYZE, BUFFERS) luôn in một dòng Buffers: shared hit=... read=.... Đến giờ ta chỉ đếm tổng số trang; bài này mổ vào chính hai chữ hitread — chúng phân biệt trang lấy từ RAM với trang lấy từ đĩa, và là cửa sổ nhìn vào bộ đệm của PostgreSQL. Tôi đo bộ đệm ấy nóng lên thế nào qua các lần chạy, và vấp một kết quả mâu thuẫn khiến tôi phải học về một cơ chế cache tinh vi.

Bộ đệm shared_buffers

shared_buffers: cache trang của PostgreSQL

PostgreSQL không đọc thẳng từ đĩa mỗi lần cần dữ liệu. Nó giữ các trang bảng và chỉ mục vừa dùng trong shared_buffers — một vùng RAM dùng chung cho cả máy chủ, mặc định 128MB. Khi cần một trang, nó tìm trong shared_buffers trước; có sẵn thì dùng ngay (rất nhanh), không có thì phải đọc trang đó vào rồi mới dùng.

EXPLAIN (ANALYZE, BUFFERS) phơi bày đúng cơ chế này qua hai con số:

  • shared hit = số trang tìm thấy sẵn trong shared_buffers (RAM) — không tốn một lần đọc đĩa nào.
  • shared read = số trang chưa có trong cache, phải đọc vào (từ đĩa, hoặc như ta sẽ thấy, từ cache của hệ điều hành).

Lần đầu chạm tới dữ liệu, cache còn trống ("lạnh"), nên phần lớn là read. Chạy lại, dữ liệu đã nằm trong shared_buffers ("nóng"), nên thành hit — nhanh hơn vì né được đĩa.

Đo: read thành hit khi cache nóng

Tôi tạo một bảng nhỏ 13MB (nhỏ hơn 1/4 của shared_buffers 128MB), khởi động lại PostgreSQL để xóa sạch cache, rồi chạy SELECT count(*) FROM ts WHERE v = 7 ba lần liên tiếp với EXPLAIN (ANALYZE, BUFFERS):

Lần chạy Buffers Thời gian
1 (cache lạnh) shared read=1622 6,7 ms
2 (cache nóng) shared hit=1622 4,8 ms
3 (cache nóng) shared hit=1622 4,8 ms

Con số lật hoàn toàn: lần 1 đọc 1622 trang từ đĩa (read), lần 2 và 3 lấy đúng 1622 trang đó từ RAM (hit). Cache đã "nóng lên" — toàn bộ bảng giờ nằm trong shared_buffers, không lần đọc đĩa nào nữa. Thời gian giảm từ 6,7 xuống 4,8 ms. Điều đáng chú ý là số trang không đổi giữa các lần (đều 1622) — cùng một khối lượng công việc đọc, chỉ khác nguồn lấy trang: lần đầu phải nạp vào cache, lần sau đã có sẵn. Đó chính là ý nghĩa của việc phân biệt hit với read: cùng số trang mà chi phí khác nhau tùy trang đó đang ở đâu.

Chỉ giảm ~28%, không phải hàng chục lần như "RAM nhanh hơn đĩa" gợi ý — và đó là một manh mối quan trọng tôi sẽ quay lại ở phần cuối. Nhưng trước hết, một kết quả làm tôi bối rối.

Một lần tôi đo hớ: bảng lớn không chịu nóng lên

Thấy bảng 13MB nóng lên đẹp đẽ, tôi mở rộng phép đo sang một bảng 81MB — vẫn nhỏ hơn shared_buffers 128MB, nên tôi chắc mẩm nó cũng sẽ nằm gọn vào cache sau lần chạy đầu. Tôi chạy SELECT count(*) với điều kiện lọc ba lần, chờ thấy read chuyển thành hit.

Nhưng không. Lần 1: shared read=10310. Lần 2: shared hit=96 read=10214. Lần 3: hit=192 read=10118. Sau ba lần, gần như vẫn toàn read — cache hầu như không nóng lên chút nào, dù bảng thừa sức nhét vừa shared_buffers. Phản xạ đầu tiên của tôi: "shared_buffers hỏng, hay quá nhỏ?". Nhưng 81MB < 128MB, con số không có lý.

Hai kết quả mâu thuẫn — bảng nhỏ nóng lên, bảng lớn không, dù cả hai đều nhỏ hơn cache — theo kỷ luật nghĩa là có biến ẩn. Và biến ẩn ở đây là một chiến lược cache tinh vi của PostgreSQL: với một quét tuần tự trên bảng lớn (lớn hơn khoảng 1/4 shared_buffers), PostgreSQL cố ý không nạp cả bảng vào cache. Nó dùng một "ring buffer" nhỏ (256KB) — tái dùng đúng vài trang bộ đệm cho suốt lần quét — thay vì rải bảng khắp shared_buffers. Lý do rất khôn: nếu một lần SELECT count(*) trên bảng khổng lồ được phép nạp trọn bảng vào cache, nó sẽ đá văng mọi dữ liệu nóng khác mà các truy vấn quan trọng đang dùng. Ring buffer bảo vệ cache khỏi bị một lần quét lớn xóa sạch.

Cái tôi đo hớ là cho rằng "chạy lần hai luôn nhanh hơn vì cache nóng" — một quy tắc đúng với bảng nhỏ nhưng sai với seq scan bảng lớn, vì cơ chế ring buffer. Bài học đo lường: đừng coi "lần hai nhanh hơn" là luật tự nhiên; hãy đo cả hai trạng thái (lạnh và nóng), và khi kết quả không như mong đợi, tìm cơ chế ẩn thay vì đổ cho "cache hỏng". Hai con số shared hit/shared read chính là bằng chứng cho biết cache có thật sự nóng lên hay không.

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

Hệ quả đầu tiên là một cảnh báo benchmark then chốt: đo một truy vấn đúng một lần cho con số vô nghĩa — bạn không biết mình đang đo cache lạnh hay nóng. Lần đầu bao giờ cũng chậm hơn (phải nạp cache); nếu chỉ đo một lần rồi so với một truy vấn khác đã chạy nhiều lần (cache nóng), bạn so hai thứ khác điều kiện. Luôn chạy vài lần, ghi rõ đang đo trạng thái nào, hoặc chủ động làm nóng cache (ví dụ pg_prewarm) trước khi đo — đúng nguyên tắc "benchmark hai bên phải cùng điều kiện".

Hệ quả thứ hai: có hai tầng cache, đừng lẫn chúng. shared_buffers là cache của PostgreSQL; bên dưới nó còn có page cache của hệ điều hành. Khi PostgreSQL báo shared read (không có trong shared_buffers), trang đó vẫn có thể được lấy từ page cache của OS — tức từ RAM, không phải đĩa quay thật. Đây là lý do phép đo trên chỉ nhanh lên 28% chứ không phải hàng chục lần: cả "cache lạnh" của tôi thật ra vẫn đọc từ RAM của OS. Trên một máy chủ thật với dữ liệu lớn hơn tổng RAM, khác biệt giữa hit và một read chạm đĩa thật sẽ khổng lồ. Biết có hai tầng giúp bạn không kết luận sai về "đĩa nhanh hay chậm".

Hệ quả thứ ba là bài học đo lường. Con số mang theo: shared hit là trang lấy từ RAM cache của PostgreSQL, shared read là trang phải nạp vào — lần đọc đầu (lạnh) toàn read, lần sau (nóng) thành hit; nhưng một seq scan bảng lớn cố ý KHÔNG nóng lên (ring buffer bảo vệ cache), và 'read' có thể vẫn từ RAM của OS. Trạng thái cache là một biến ẩn khổng lồ trong mọi phép đo cơ sở dữ liệu; đọc shared hit/shared read để biết mình đang đo gì, thay vì tin rằng "chạy lại là nhanh".

Thử ba mươi giây

Chọn một truy vấn và chạy EXPLAIN (ANALYZE, BUFFERS)hai lần liên tiếp, so dòng Buffers: lần đầu bạn sẽ thấy nhiều shared read, lần hai phần lớn (hoặc toàn bộ) chuyển thành shared hit — đó là cache nóng lên. Rồi xem cache của bạn to cỡ nào: SHOW shared_buffers;. Nếu muốn nhìn sâu hơn, cài extension pg_buffercache và chạy SELECT count(*) FROM pg_buffercache WHERE relfilenode IS NOT NULL; để đếm số trang đang nằm trong cache. Và nhớ: nếu một bảng lớn không chịu "nóng lên" dù chạy nhiều lần, đó không phải lỗi — đó là ring buffer đang bảo vệ cache của bạn, đúng cái bài này đo.