Hình dung bạn hỏi "gọi món pizza này mất bao lâu". Người trong bếp trả lời "bốn phút" — đúng, đó là thời gian nướng. Nhưng bạn ngồi ở nhà thì thấy bốn mươi phút, vì còn xếp đơn, đóng hộp, và anh giao hàng luồn qua phố. Hai con số đều thật, chỉ là đo từ hai chỗ khác nhau, và nếu bạn muốn pizza tới nhanh hơn thì tối ưu cái lò bốn phút là tối ưu nhầm chỗ. Trace và cơ sở dữ liệu đúng là hai người đó, và bài này đo cho ra khoảng cách giữa họ.
Trace cho bạn một span db.query với một con số. PostgreSQL có pg_stat_statements cũng cho một con số cho đúng truy vấn đó. Hai con số không bằng nhau, và khoảng cách giữa chúng là thứ đáng đo nhất trong bài này — vì nó quyết định việc tối ưu SQL có ích hay vô ích. (Đo bằng PostgreSQL 16 thật trong container Docker, không có phần cứng đặc biệt nào.)
Bảng số liệu
PostgreSQL 16 thật, bảng orders 200 000 dòng có chỉ mục, chạy từ một client Python qua mạng Docker. Mỗi truy vấn chạy 2 000 lần; lấy trung vị của ba lần chạy.
| Truy vấn | Client đo | PostgreSQL tự báo | Chênh | Phần ở ngoài CSDL |
|---|---|---|---|---|
SELECT theo chỉ mục |
0,060 ms | 0,007 ms | 0,053 ms | 88,3% |
INSERT một dòng |
0,103 ms | 0,004 ms | 0,099 ms | 96,1% |
SELECT gộp cả bảng |
85,110 ms | 84,861 ms | 0,249 ms | 0,3% |
Chỗ quyết định bạn đi tối ưu cái gì
Đây là phần đáng dừng lại lâu, vì đọc sai nó là tối ưu nhầm hàng tuần.
Với truy vấn nhanh, phần lớn thời gian không nằm trong cơ sở dữ liệu. SELECT theo chỉ mục mất 0,060 ms nhìn từ ứng dụng, nhưng PostgreSQL chỉ tốn 0,007 ms để thực thi nó. 88,3% còn lại là dựng câu lệnh, đi qua mạng, quay về, và driver phân tích kết quả. Với INSERT thì còn cực đoan hơn: 96,1%. PostgreSQL chèn một dòng trong 0,004 ms — bốn micro-giây. Mọi thứ khác là chi phí đi lại.
Điều làm tôi thấy rõ nhất khi nhìn ba dòng cùng lúc: chi phí cố định đó gần như không đổi dù truy vấn nặng hay nhẹ. Nó là 0,053 ms với SELECT chỉ mục, 0,099 ms với INSERT, và 0,249 ms với truy vấn gộp cả bảng. Cùng một bậc độ lớn — trong khi phần thực thi trong CSDL trải từ 0,004 ms tới 84,861 ms, chênh hơn hai vạn lần. Nên với truy vấn gộp cả bảng, bức tranh lật ngược hoàn toàn: chỉ 0,3% nằm ngoài. Đây mới là truy vấn đáng đi tối ưu SQL, và cũng là truy vấn duy nhất trong ba cái đáng.
Cốt lõi: hai con số không mâu thuẫn nhau — chúng đo hai thứ khác nhau. Span đo thứ người dùng cảm nhận; pg_stat_statements đo thứ SQL của bạn tốn. Hiệu của chúng mới là con số đáng xem, và không công cụ nào tự tính nó cho bạn.
Vì sao khoảng cách lại lớn đến vậy
Một lời gọi cơ sở dữ liệu từ ứng dụng đi qua nhiều chặng, và chỉ đúng một chặng trong đó là thực thi SQL: driver dựng thông điệp giao thức, ghi vào socket, gói tin đi qua ngăn xếp mạng, PostgreSQL đọc và phân tích, tra kế hoạch đã lưu, thực thi — đây là phần duy nhất pg_stat_statements đếm — rồi tuần tự hoá kết quả, gửi ngược lại, và driver dựng đối tượng Python.
Với truy vấn quét 200 000 dòng và tính trung bình, phần thực thi là 84,861 ms và nó nuốt trọn mọi thứ khác. Với truy vấn tra một chỉ mục rồi lấy mười dòng, phần thực thi chỉ 7 micro-giây — nhỏ hơn cả thời gian gói tin đi một vòng trên mạng ảo của Docker. Đó là lý do vì sao "tối ưu truy vấn" thường không cải thiện gì: nếu SQL của bạn đã chỉ tốn 7 µs, làm nó nhanh gấp đôi tiết kiệm được 3,5 µs trên tổng 60 µs — cải thiện 6%. Còn gộp hai lời gọi thành một tiết kiệm trọn 53 µs.
Đây cũng là nền tảng số học của vấn đề N+1 truy vấn: một trang gọi 100 truy vấn nhỏ tốn 100 × 0,060 = 6 ms, trong đó 5,3 ms là chi phí đi lại. Viết lại thành một truy vấn duy nhất, dù truy vấn đó chậm gấp mười lần trong CSDL, vẫn nhanh hơn nhiều lần. Số học đơn giản mà quyết định cả kiến trúc.
Nghĩa là gì trong công việc hằng ngày
- Đặt cả hai con số cạnh nhau trước khi quyết định tối ưu gì. Span cho tổng,
pg_stat_statementscho phần thực thi. Nếu hiệu của chúng lớn, vấn đề là số lần gọi chứ không phải câu lệnh. - Với truy vấn dưới một mili-giây, đừng tối ưu SQL — hãy đếm số lần gọi. Ngưỡng thô: nếu
mean_exec_timetrongpg_stat_statementsnhỏ hơn khoảng 0,1 ms thì bạn đang ở vùng mà chi phí cố định chi phối. - Truy vấn
callscao vàmean_exec_timethấp là dấu hiệu N+1. Một câu lệnh gọi hàng chục nghìn lần với thời gian thực thi vài micro-giây gần như luôn là một vòng lặp đáng gộp lại. - Chi phí cố định của bạn sẽ lớn hơn của tôi. 0,053 ms là mạng ảo trên cùng một máy. Qua mạng thật, qua bộ gộp kết nối, qua TLS — con số đó tăng, và tỷ lệ 88,3% cũng tăng theo.
- Nhớ lại phần trước của sê-ri: chi phí một chặng HTTP nội bộ là 0,70 ms. Một lời gọi cơ sở dữ liệu 0,060 ms rẻ hơn thế mười lần — nhưng ứng dụng thường gọi CSDL nhiều hơn gọi dịch vụ khác hàng chục lần.
Có một cách khác để thấy cùng chuyện đó, và nó giải thích vì sao hai công cụ hay bị coi là mâu thuẫn. pg_stat_statements gom mọi lần chạy của cùng một hình dạng câu lệnh lại — WHERE user_id = 42 và WHERE user_id = 99 được đếm chung vì tham số đã bị thay bằng $1. Nó trả lời câu "câu lệnh này tốn bao nhiêu, trung bình, trên toàn hệ thống". Còn span trả lời câu "lần gọi này, của yêu cầu này, mất bao lâu". Vì thế khi một người mở trace thấy 0,060 ms còn người kia mở bảng thống kê CSDL thấy 0,007 ms, cả hai đều đang đọc đúng con số của mình — và cuộc tranh luận "cơ sở dữ liệu có chậm không" thường chỉ là hai người trả lời hai câu hỏi khác nhau mà tưởng là một. Đặt hai con số cạnh nhau và lấy hiệu sẽ chấm dứt tranh luận đó trong ba mươi giây. Hiệu số ấy còn là một chỉ số sức khoẻ tự thân: nếu nó đột nhiên tăng mà mean_exec_time không đổi, cơ sở dữ liệu vẫn khoẻ và vấn đề nằm ở mạng, ở bộ gộp kết nối, hoặc ở chính ứng dụng — ba chỗ mà không ai nghĩ tới khi biểu đồ ghi là "truy vấn chậm".
Cùng một khoảng cách, đổi ngăn xếp
Chuyện "đo ở phía người gọi khác hẳn đo ở phía tài nguyên, và cái hiệu mới là chỗ đáng nhìn" không riêng gì PostgreSQL — nó lặp lại ở mọi ranh giới mà một bên gọi qua bên kia.
Trong Java, đúng khoảng cách này hiện ra giữa thời gian một câu JDBC nhìn từ ứng dụng và mean_exec_time phía DB. Nhưng Java còn thêm một tầng khoảng cách nữa mà ai dùng ORM đều vấp: Hibernate có thể phát sinh N+1 truy vấn ẩn sau một dòng code duy nhất — bạn duyệt một collection lazy trong vòng lặp và JPA lặng lẽ bắn ra trăm câu SELECT. Trace của bạn thấy trăm span db.query bé tí; pg_stat_statements thấy một hình dạng câu lệnh calls cao ngất. Cùng dấu hiệu tôi mô tả ở trên, chỉ là ở Java nó dễ vô tình tạo ra hơn nhiều.
Trong Go, database/sql phơi bày khoảng cách này rất trần trụi vì nó buộc bạn quản lý connection pool tường minh. Thời gian một db.QueryContext gồm cả phần chờ lấy connection từ pool — nếu pool cạn, span phình to mà mean_exec_time phía Postgres vẫn phẳng lì. Đó là ví dụ giáo khoa của "hiệu số tăng nhưng CSDL vẫn khoẻ": nút cổ chai nằm ở pool, không ở SQL.
Và mở rộng ra mọi lời gọi mạng nội bộ — một RPC gRPC, một GET tới Redis, một lần gọi cache — đều có cùng cấu trúc: thời gian phía client luôn bằng thời gian xử lý phía server cộng một hằng số đi-lại gần như cố định. Redis trả lời trong micro-giây, hệt Postgres chèn một dòng trong 4 µs; phần bạn thấy chậm gần như luôn là đường đi, không phải việc.
Mẫu số chung: thời gian đo ở người gọi là thời gian của tài nguyên cộng một khoản đi-lại gần như không đổi, và tối ưu đúng chỗ đòi bạn biết mình đang đứng bên nào của khoản đó. Khi phần đi-lại chi phối (truy vấn nhanh, gọi nhiều lần), cách chữa là gọi ít lần hơn — gộp, batch, cache — chứ không phải làm câu lệnh nhanh hơn. Khi phần xử lý chi phối (truy vấn nặng), mới đến lượt tối ưu bản thân việc. Nên câu hỏi đầu tiên trước mọi lần tối ưu không phải "làm sao nhanh hơn" mà là "trong con số này, bao nhiêu là việc và bao nhiêu là đường đi".
Muốn tự đo cho hệ của mình, chạy đoạn này trên PostgreSQL (cần bật pg_stat_statements):
# Chay tren PostgreSQL cua ban - can pg_stat_statements
psql -c "SELECT
substring(query, 1, 50) AS truy_van,
calls,
round(mean_exec_time::numeric, 4) AS pg_ms,
round(total_exec_time::numeric/1000, 1) AS tong_giay
FROM pg_stat_statements
WHERE calls > 1000
ORDER BY calls DESC
LIMIT 10;"
Nhìn cột pg_ms. Mọi dòng dưới 0,1 ms là một dòng mà tối ưu câu lệnh gần như không giúp được gì — phần lớn thời gian người dùng chờ nằm ở chỗ khác. Rồi đối chiếu cột calls của chính những dòng đó: đấy mới là chỗ có thể cắt.