Đánh giá một quán ăn qua độ đầy của bãi đỗ xe thường đúng — xe đông thì đồ ngon. Cho tới hôm bạn phát hiện bên cạnh có buổi nhạc hội miễn phí, và cái bãi đầy chẳng liên quan gì tới bếp. Con số đại diện (bãi xe) đã tách rời khỏi thứ nó lẽ ra đo (đồ ăn ngon) vì một yếu tố bạn không thấy. "Tỉ lệ trúng đệm phải trên 99%" là một cái bãi đỗ xe như vậy trong thế giới PostgreSQL, và phần này đo xem con số đó thật sự nói lên điều gì — ít hơn nhiều so với người ta tưởng.
Có hai lớp đệm, thống kê chỉ thấy một
Khi PostgreSQL cần một trang dữ liệu, nó đi qua ba nơi:
shared_buffers (PostgreSQL) -> bộ đệm trang (hệ điều hành) -> đĩa
pg_statio_user_tables đếm heap_blks_hit khi trang có sẵn ở lớp thứ nhất, và heap_blks_read khi không có. Nhưng "không có ở lớp thứ nhất" không có nghĩa là "đọc từ đĩa" — lớp thứ hai thường trả về ngay.
Đây là toàn bộ nội dung của bài này.
Bằng chứng
Máy chủ với shared_buffers = 32MB, bảng 661 MB, bật track_io_timing:
Buffers: shared read=19184
I/O Timings: shared read=27.752
Execution Time: 115.745 ms
19.184 trang được đánh dấu là "read" — tức trượt đệm hoàn toàn. Nhưng tổng thời gian I/O chỉ 27,752 ms:
27,752 ms / 19.184 trang = 1,4 micro giây mỗi trang
Một lần đọc ngẫu nhiên từ SSD NVMe mất ít nhất 100 micro giây. Từ đĩa quay thì vài mili giây. 1,4 micro giây là tốc độ của một phép sao chép bộ nhớ.
Những trang đó đến từ bộ đệm của hệ điều hành, và pg_statio không biết điều đó.
Tăng shared_buffers: tỉ lệ nhảy vọt, thời gian gần như đứng yên
Cùng truy vấn, cùng dữ liệu, chỉ đổi shared_buffers:
shared_buffers |
Tỉ lệ trúng | Thời gian | Trang "read" |
|---|---|---|---|
| 32 MB | 0,00% | 122 ms | 109.998 |
| 128 MB | 0,00% | 140 ms | 109.998 |
| 512 MB | 83,33% | 101 ms | 18.333 |
| 1 GB | 83,33% | 94 ms | 18.333 |
Tỉ lệ trúng đi từ 0% lên 83% — theo lời khuyên phổ biến thì đây là khác biệt giữa "thảm hoạ" và "chấp nhận được". Thời gian thì đi từ 122 ms xuống 94 ms: giảm 23%.
Nếu bạn chỉ nhìn tỉ lệ trúng, bạn sẽ kết luận rằng cấu hình 32 MB đang hỏng nặng. Nếu bạn nhìn thời gian, bạn thấy nó chậm hơn một phần tư.
Chú ý dòng 128 MB: tỉ lệ trúng vẫn 0% và thời gian tệ hơn 32 MB. Bảng 661 MB không vừa vào 128 MB, nên mỗi lần quét lại đẩy hết ra — đệm lớn hơn nhưng vẫn không đủ thì không giúp gì, mà lại lấy mất bộ nhớ của hệ điều hành.
Nhìn vào trong shared_buffers
Extension pg_buffercache cho biết chính xác cái gì đang nằm trong đệm:
create extension pg_buffercache;
select c.relname, count(*) as so_trang,
pg_size_pretty(count(*) * 8192) as kich_thuoc,
round(100.0 * count(*) / (select count(*) from pg_buffercache), 1) as phan_tram
from pg_buffercache b
left join pg_class c on c.relfilenode = b.relfilenode
group by c.relname order by count(*) desc limit 6;
Sau khi chạy một truy vấn quét bảng lớn:
to_ 3.938 trang 31 MB 96,1%
(trống) 84 trang 672 kB 2,1%
pg_operator 11 trang 88 kB 0,3%
96,1% bộ đệm bị một bảng chiếm. Mọi thứ khác — kể cả catalog hệ thống mà mọi truy vấn đều cần — đã bị đẩy ra.
Đây là hiện tượng đáng biết: một truy vấn báo cáo quét bảng lớn có thể làm chậm mọi truy vấn khác trong vài phút sau đó, không phải vì nó chiếm CPU mà vì nó xoá sạch bộ đệm.
PostgreSQL có phòng vệ một phần cho chuyện này — quét tuần tự trên bảng lớn hơn một phần tư shared_buffers dùng một vùng đệm vòng nhỏ thay vì chiếm cả đệm. Nhưng như số đo cho thấy, nó không ngăn được hoàn toàn.
Đệm hai lớp: cùng dữ liệu nằm hai nơi
MemTotal: 16.354.680 kB
Cached: 8.332.588 kB
Hệ điều hành đang giữ 8,3 GB trong bộ đệm trang. PostgreSQL giữ thêm 32 MB nữa trong shared_buffers — và phần lớn 32 MB đó là bản sao của dữ liệu đã có trong 8,3 GB kia.
Đó là lý do đặt shared_buffers rất lớn không phải lúc nào cũng tốt: mỗi byte bạn cấp cho PostgreSQL là một byte hệ điều hành không dùng được, và nếu dữ liệu bị lưu hai lần thì bạn mất một nửa bộ nhớ hiệu dụng.
Quy tắc 25% RAM phổ biến chính là từ chỗ này. Nó không phải con số tối ưu về mặt lý thuyết — nó là điểm cân bằng giữa "PostgreSQL kiểm soát được cái gì nằm trong đệm" và "để hệ điều hành làm phần còn lại".
effective_cache_size không cấp phát gì
Tham số này hay bị nhầm với shared_buffers. Nó không cấp phát một byte nào — nó chỉ là con số bạn nói với bộ lập lịch: "tổng cộng có khoảng chừng này bộ nhớ đệm, kể cả của hệ điều hành".
Bộ lập lịch dùng nó để ước lượng chi phí đọc chỉ mục: nếu đệm lớn, khả năng trang đã có sẵn cao, nên quét chỉ mục rẻ hơn.
Tôi thử đổi từ 128MB lên 4GB:
effective_cache_size = 128MB -> Bitmap Heap Scan (cost=282.18..49314.17 rows=25000)
effective_cache_size = 4GB -> Bitmap Heap Scan (cost=282.18..49314.17 rows=25000)
Kế hoạch không đổi, chi phí ước lượng cũng không đổi tới hai chữ số thập phân.
Tôi không kết luận rằng tham số này vô dụng — tôi kết luận rằng trên truy vấn cụ thể này, với độ chọn lọc này, nó không đủ để lật lựa chọn. Nó có ảnh hưởng ở vùng ranh giới giữa quét chỉ mục và quét tuần tự, và truy vấn của tôi không nằm ở vùng đó.
Đặt nó bằng khoảng 50–75% RAM là an toàn, và nó miễn phí vì không cấp phát gì.
Cách đọc tỉ lệ trúng cho đúng
Tỉ lệ trúng vẫn có ích, nhưng phải đọc cùng thứ khác:
select relname,
heap_blks_hit, heap_blks_read,
round(100.0 * heap_blks_hit / nullif(heap_blks_hit + heap_blks_read, 0), 2) as ti_le
from pg_statio_user_tables
where heap_blks_read > 0
order by heap_blks_read desc limit 10;
Rồi với bảng đứng đầu, chạy truy vấn thật với EXPLAIN (ANALYZE, BUFFERS) và bật track_io_timing:
alter system set track_io_timing = on;
select pg_reload_conf();
Con số cần nhìn là I/O Timings chia cho số trang read:
| Thời gian mỗi trang | Nghĩa là |
|---|---|
| Dưới 10 µs | Đến từ bộ đệm hệ điều hành — không phải vấn đề |
| 50–200 µs | Đọc SSD thật |
| Trên 1 ms | Đọc đĩa quay, hoặc đĩa mạng, hoặc máy chủ đang quá tải I/O |
Chỉ ở hai hàng cuối thì tỉ lệ trúng thấp mới là vấn đề đáng sửa.
Ba việc theo thứ tự
- Đặt
shared_bufferskhoảng 25% RAM rồi dừng lại. Đây là điểm khởi đầu hợp lý, không phải con số cần tinh chỉnh. - Đặt
effective_cache_sizekhoảng 50–75% RAM. Miễn phí, và nó giúp bộ lập lịch chọn đúng. - Bật
track_io_timing. Chi phí gần như bằng không trên máy chủ hiện đại, và không có nó thì bạn không phân biệt được đệm hệ điều hành với đĩa thật.
Điều không nên làm là tăng shared_buffers lên 50% hay 75% RAM để đuổi theo con số tỉ lệ trúng. Số đo ở đầu bài cho thấy phần thưởng nhỏ, và phần 32 đã đo một trường hợp mà cấp thêm bộ nhớ còn làm chậm đi.
Mẫu số chung
Cái bẫy "tỉ lệ trúng 99%" là một ví dụ sách giáo khoa của luật Goodhart: một chỉ số chỉ là đại diện cho mục tiêu, và nó hữu ích đúng chừng nào nó còn nhúc nhích cùng chiều với mục tiêu ấy. Ở đây mục tiêu là độ trễ thấp, còn tỉ lệ trúng là đại diện — và một tầng bạn quên mất (bộ đệm hệ điều hành) đã cắt đứt mối tương quan giữa hai thứ, khiến một "miss" gần như miễn phí. Đuổi theo cái đại diện lúc đó là tối ưu nhầm thứ, đôi khi ngược cả mục tiêu. Lời chữa luôn giống nhau: buộc cái đại diện trở về một thước đo trực tiếp của chính mục tiêu — ở đây là micro-giây mỗi trang, chứ không phải phần trăm trúng.
Đúng cái bẫy proxy-tách-rời-mục-tiêu này nhan nhản khắp nghề:
- Trong Java/kiểm thử, độ phủ mã (coverage) là đại diện cho "chất lượng test", nhưng 100% phủ với không một câu assert nào chẳng chứng minh gì; đuổi theo coverage đẻ ra những test chạy qua dòng lệnh mà không kiểm gì cả. Thước đo trực tiếp là "test có bắt được bug khi tôi cố tình gài lỗi không".
- Ở tầng vận hành,
%CPUlà đại diện cho "máy có cần thêm sức không", nhưng một máy có thể 100% CPU mà vẫn khoẻ, hoặc 30% CPU mà đang chết ngộp vì chờ I/O. Nhìn CPU thay cho độ trễ thật là đọc nhầm bãi đỗ xe. - Và nói chung, "số dòng code", "số ticket đóng", "số commit" đều là đại diện cho năng suất — hữu ích tới đúng lúc ai đó bắt đầu nhắm vào chúng, thì chúng thôi đo được điều cần đo.
Nên trước mỗi con số bạn định tối ưu, câu hỏi đúng là "đây là mục tiêu, hay chỉ là đại diện cho mục tiêu — và mối liên hệ giữa hai thứ có còn đúng trong ngữ cảnh này không". Nếu là đại diện, luôn kèm một thước đo trực tiếp bên cạnh. Bật cái rẻ nhất trước:
select name, setting, unit from pg_settings
where name in ('shared_buffers','effective_cache_size','track_io_timing');
Nếu track_io_timing đang off, bật nó. Đó là thay đổi rẻ nhất trong bài, và nó biến cột read từ một con số không diễn giải được thành một con số nói cho bạn biết chính xác dữ liệu đến từ đâu.
Phần sau đo WAL và checkpoint: cấu hình nào làm máy chủ dừng lại vài giây một lần, và cách nhận ra nó.