Tối ưu hiệu năng Go + Postgres: sổ tay thực tế cho API
Sổ tay tối ưu hiệu năng Go + Postgres cho API sinh bởi AI: quản lý pool kết nối, đọc kế hoạch truy vấn, đánh chỉ mục thông minh, phân trang an toàn và tạo JSON nhanh.

Trông "chậm" thế nào với API Go trên Postgres
API sinh bởi AI có thể cảm thấy nhanh khi test ban đầu. Bạn gọi endpoint vài lần, dataset nhỏ, và request đến một lần một. Rồi traffic thực đến: nhiều endpoint hỗn hợp, tải đột biến, cache lạnh hơn, và nhiều hàng hơn bạn nghĩ. Cùng mã đó có thể bắt đầu cảm thấy ngẫu nhiên chậm mặc dù không có gì thực sự hỏng.
Chậm thường xuất hiện theo vài cách: spike độ trễ (đa số request ổn, một vài request chậm hơn 5x–50x), timeout (một phần nhỏ thất bại), hoặc CPU chạy cao (CPU Postgres vì truy vấn, hoặc CPU Go vì JSON, goroutine, logging và retry).
Một kịch bản phổ biến là endpoint danh sách với bộ lọc linh hoạt trả về JSON lớn. Trong DB test, nó quét vài nghìn hàng và xong nhanh. Trong production, nó quét vài triệu hàng, sắp xếp, và chỉ sau đó áp LIMIT. API vẫn "hoạt động", nhưng p95 latency phóng lên và vài request timeout khi có đợt burst.
Để tách chậm do DB và chậm do app, giữ mô hình tinh thần đơn giản.
Nếu database chậm, handler Go dành phần lớn thời gian chờ truy vấn. Bạn cũng có thể thấy nhiều request bị kẹt "in flight" trong khi CPU Go trông bình thường.
Nếu app chậm, truy vấn xong nhanh nhưng thời gian bị tiêu tốn sau truy vấn: xây response object lớn, marshal JSON, chạy thêm truy vấn cho từng hàng, hoặc làm quá nhiều việc cho mỗi request. CPU Go tăng, bộ nhớ tăng, và độ trễ tăng theo kích thước phản hồi.
Hiệu năng "đủ tốt" trước khi ra mắt không cần hoàn hảo. Với nhiều endpoint CRUD, mục tiêu là p95 ổn định (không chỉ trung bình), hành vi dự đoán được khi burst, và không timeout ở mức tải kỳ vọng. Mục tiêu đơn giản: không có request chậm bất ngờ khi dữ liệu và traffic tăng, và tín hiệu rõ ràng khi có drift.
Bắt đầu từ baseline: vài con số quan trọng
Trước khi tối ưu, quyết định xem "tốt" nghĩa là gì cho API của bạn. Không có baseline, dễ mất hàng giờ thay đổi cài đặt mà vẫn không biết mình có cải thiện hay chỉ dịch nút cổ chai.
Ba con số thường cho bạn phần lớn câu chuyện:
- p95 request latency (không phải trung bình)
- tỷ lệ lỗi (HTTP 5xx, timeout, request bị huỷ)
- thời gian DB trên mỗi request (mỗi request chờ Postgres bao lâu)
p95 là chỉ số cho "ngày tồi". Nếu p95 cao mà trung bình ổn, một tập nhỏ request đang làm quá nhiều việc, bị chặn bởi lock, hoặc kích hoạt kế hoạch chậm.
Làm cho các truy vấn chậm hiển hiện sớm. Trong Postgres, bật logging truy vấn chậm với ngưỡng thấp cho kiểm thử trước lúc ra mắt (ví dụ 100–200 ms), và log đầy đủ statement để bạn có thể copy vào client SQL. Giữ việc này tạm thời. Log mọi truy vấn chậm trong production sẽ nhanh chóng ồn ào.
Tiếp theo, test với request giống thực tế, không chỉ một route "hello world". Một tập nhỏ đủ nếu nó khớp hành vi người dùng: một gọi danh sách với filter và sort, một trang chi tiết với vài join, một create/update có validate, và một truy vấn kiểu search với partial match.
Nếu bạn sinh endpoint từ spec (ví dụ với công cụ vibe-coding như Koder.ai), chạy cùng vài request đó lặp lại với input cố định. Điều đó làm cho các thay đổi như chỉ mục, tinh chỉnh phân trang, và viết lại truy vấn dễ đo hơn.
Cuối cùng, chọn mục tiêu bạn có thể nói thành tiếng. Ví dụ: 'Phần lớn request giữ dưới 200 ms p95 ở 50 user đồng thời, và lỗi dưới 0.5%.' Số cụ thể tuỳ sản phẩm, nhưng mục tiêu rõ ràng ngăn việc tinh chỉnh vô tận.
Pool kết nối giữ Postgres ổn định
Pool kết nối giữ một số lượng kết nối DB mở giới hạn và tái sử dụng chúng. Không có pool, mỗi request có thể mở kết nối mới, và Postgres tốn thời gian và bộ nhớ quản lý session thay vì chạy truy vấn.
Mục tiêu là giữ Postgres bận rộn làm việc có ích, không chuyển ngữ cảnh giữa quá nhiều kết nối. Đây thường là lợi ích thiết thực đầu tiên, đặc biệt cho API sinh bởi AI có thể lặng lẽ trở thành các endpoint hay gọi.
Cài đặt khởi đầu đơn giản
Trong Go, bạn thường điều chỉnh max open connections, max idle connections, và lifetime của kết nối. Điểm khởi đầu an toàn cho nhiều API nhỏ là một bội số nhỏ của CPU cores (thường 5 đến 20 kết nối tổng), với số idle tương tự, và tái chế kết nối định kỳ (ví dụ mỗi 30–60 phút).
Nếu chạy nhiều instance API, nhớ pool nhân lên. Pool 20 kết nối trên 10 instance là 200 kết nối chạm Postgres, và đây là lý do các team bất ngờ gặp giới hạn kết nối.
Cách biết pool là vấn đề
Vấn đề pool khác với SQL chậm.
Nếu pool quá nhỏ, request chờ trước khi tới Postgres. Latency spike, nhưng CPU DB và thời gian truy vấn có thể trông ổn.
Nếu pool quá lớn, Postgres trông quá tải: nhiều session active, áp lực bộ nhớ, và độ trễ không đều giữa các endpoint.
Cách nhanh để tách hai thứ là đo thời gian cuộc gọi DB thành hai phần: thời gian chờ kết nối vs thời gian thực thi truy vấn. Nếu phần lớn là "chờ", pool là nút cổ chai. Nếu phần lớn là "trong truy vấn", tập trung vào SQL và chỉ mục.
Kiểm tra nhanh hữu ích:
- Log stats pool (open, in-use, idle) và theo dõi in-use bị dính ở max.
- Thêm timeout khi lấy kết nối để chờ thất bại nhanh ở staging.
- Giám sát kết nối active trong Postgres và gần tới
max_connectionsthế nào. - Xác nhận mỗi request đóng rows và trả kết nối ngay.
- Load test với cùng số instance app bạn định chạy.
pgx pool vs database/sql
Nếu bạn dùng pgxpool, bạn có pool ưu tiên Postgres với stats rõ ràng và mặc định tốt cho hành vi Postgres. Nếu bạn dùng database/sql, bạn có interface chuẩn làm việc đa DB, nhưng cần cấu hình rõ ràng về pool và hành vi driver.
Quy tắc thực tế: nếu bạn chỉ dùng Postgres và muốn kiểm soát trực tiếp, pgxpool thường đơn giản hơn. Nếu bạn phụ thuộc thư viện mong database/sql, giữ lại và đặt pool rõ ràng, đo các lần chờ.
Ví dụ: một endpoint liệt kê orders có thể chạy trong 20 ms, nhưng dưới 100 concurrent user nhảy lên 2 s. Nếu log cho thấy 1.9 s là thời gian chờ connection, tuning query không giúp cho tới khi pool và tổng kết nối Postgres được cỡ hoá đúng.
Kế hoạch truy vấn: đọc nhanh output EXPLAIN
Khi một endpoint cảm thấy chậm, kiểm tra Postgres đang làm gì. Đọc nhanh EXPLAIN thường chỉ ra fix trong vài phút.
Chạy cái này trên đúng SQL mà API gửi:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Một vài dòng quan trọng nhất. Nhìn node trên cùng (Postgres chọn gì) và tổng ở dưới cùng (mất bao lâu). So sánh estimated vs actual rows. Khoảng cách lớn thường nghĩa planner đoán sai.
Những dòng quan trọng thường có nghĩa gì
Nếu bạn thấy Index Scan hoặc Index Only Scan, Postgres đang dùng chỉ mục, thường là tốt. Bitmap Heap Scan có thể ổn cho khớp kích thước trung bình. Seq Scan nghĩa đọc toàn bộ bảng, chỉ ổn khi bảng nhỏ hoặc hầu hết hàng khớp.
Cảnh báo phổ biến:
- Seq Scan trên bảng lớn
- Estimated rows vs actual rows cách xa (ví dụ 10 ước tính vs 10.000 thực tế)
- Sort chiếm phần lớn thời gian (thường kèm
ORDER BY) - "Filter:" loại bỏ nhiều hàng sau khi quét
- Nhiều shared read blocks trong BUFFERS (đọc nhiều dữ liệu)
Tại sao plan sai (và cách sửa dễ)
Plan chậm thường đến từ vài pattern:
- Thiếu chỉ mục cho pattern
WHERE+ORDER BYcủa bạn (ví dụ(user_id, status, created_at)) - Kiểu dữ liệu không khớp (ví dụ so sánh UUID với tham số text), chặn dùng chỉ mục
- Hàm trong
WHERE(ví dụWHERE lower(email) = $1), khiến phải scan trừ khi thêm expression index tương ứng
Nếu plan lạ và estimate sai nhiều, stats thường lỗi thời. Chạy ANALYZE (hoặc để autovacuum bắt kịp) để Postgres học số lượng hàng và phân bố giá trị hiện tại. Điều này quan trọng sau import lớn hoặc khi endpoint mới bắt đầu viết nhiều dữ liệu nhanh.
Chỉ mục cho truy vấn bạn thực sự chạy
Chỉ mục chỉ giúp khi khớp với cách API truy vấn dữ liệu. Nếu tạo từ dự đoán, bạn sẽ có write chậm hơn, lưu trữ lớn hơn, và ít cải thiện.
Cách nghĩ thực tế: chỉ mục là đường tắt cho một câu hỏi cụ thể. Nếu API hỏi câu khác, Postgres bỏ qua đường tắt đó.
Xây chỉ mục quanh filter + thứ tự sắp xếp
Nếu endpoint lọc theo account_id và sắp theo created_at DESC, một chỉ mục tổng hợp thường tốt hơn hai chỉ mục riêng. Nó giúp Postgres tìm hàng đúng và trả chúng theo thứ tự với ít việc hơn.
Nguyên tắc hay giữ vững:
- Chỉ mục các cột bạn lọc nhiều nhất, rồi thêm cột bạn sắp xếp.
- Giữ chỉ mục tổng hợp nhỏ. Hai cột phổ biến; ba đôi khi OK; nhiều hơn thường là ám hiệu.
- Đặt filter chọn lọc nhất trước.
- Tránh các chỉ mục riêng mà bị phủ bởi một chỉ mục tổng hợp tốt hơn.
- Ưu tiên một chỉ mục được chọn tốt thay vì vài cái "có thể hữu ích".
Ví dụ: nếu API có GET /orders?status=paid và luôn hiển thị mới nhất trước, chỉ mục như (status, created_at DESC) là phù hợp. Nếu phần lớn query cũng lọc theo customer, (customer_id, status, created_at) có thể tốt hơn, nhưng chỉ nếu đó là cách endpoint chạy thực tế trong production.
Chỉ mục partial cho các filter phổ biến
Nếu phần lớn traffic nhắm vào một lát hẹp của hàng, partial index có thể rẻ và nhanh hơn. Ví dụ, nếu app chủ yếu đọc record active, chỉ đánh chỉ mục WHERE active = true giữ index nhỏ hơn và dễ ở trong bộ nhớ.
Để xác nhận index hữu ích:
- Chạy
EXPLAIN(hoặcEXPLAIN ANALYZEở môi trường an toàn) và tìm index scan phù hợp. - So sánh thời gian và số hàng đọc với và không có chỉ mục.
- Theo dõi "Rows Removed by Filter" còn cao không. Thường nó nghĩa index không khớp filter.
Xoá chỉ mục không dùng cẩn trọng. Kiểm tra stats usage (chẳng hạn index có bị scan hay không). Drop từng cái một trong cửa sổ rủi ro thấp, và có kế hoạch rollback. Chỉ mục không dùng không vô hại — chúng làm chậm insert và update trên mọi ghi.
Câu hỏi thường gặp
Làm sao để nhanh chóng phân biệt API Go chậm do Postgres hay do code của tôi?
Bắt đầu bằng cách tách riêng thời gian chờ DB và thời gian xử lý ứng dụng.
- Nếu cơ sở dữ liệu chậm, handler phần lớn thời gian đang chờ truy vấn. CPU Go thường bình thường trong khi các request xếp hàng chờ "in flight".
- Nếu ứng dụng chậm, truy vấn trả về nhanh nhưng thời gian bị tiêu tốn vào việc xây object trả về, chạy thêm truy vấn trên từng hàng, marshal JSON lớn, hoặc logging. CPU và bộ nhớ Go thường tăng theo kích thước phản hồi.
Thêm thời điểm đơn giản quanh "chờ kết nối" và "thực thi truy vấn" để xem bên nào chiếm ưu thế.
Những chỉ số nào tôi nên theo dõi đầu tiên trước khi tối ưu?
Dùng một baseline nhỏ bạn có thể lặp lại:
- p95 latency cho các endpoint chính (không phải trung bình)
- tỷ lệ lỗi (5xx, timeout, cancel)
- DB time per request (thời gian chờ Postgres)
Chọn mục tiêu rõ ràng như “p95 dưới 200 ms ở 50 user đồng thời, lỗi dưới 0.5%”. Sau đó chỉ thay đổi một thứ mỗi lần và test lại cùng bộ request.
Tôi có nên bật logging truy vấn chậm của Postgres không, và ngưỡng thực tế là bao nhiêu?
Bật logging truy vấn chậm với ngưỡng thấp trong giai đoạn thử nghiệm trước khi ra mắt (ví dụ 100–200 ms) và log cả câu lệnh để bạn có thể sao chép vào client SQL.
Giữ nó tạm thời:
- Sẽ rất ồn nếu bật lâu trong production.
- Có thể tạo overhead nếu log quá nhiều.
Khi đã tìm được những truy vấn tồi nhất, chuyển sang sampling hoặc tăng ngưỡng.
Cấu hình pool kết nối khởi điểm tốt cho API Go trên Postgres là gì?
Một mặc định thực tế là một bội số nhỏ của số nhân CPU cho mỗi instance API, thường 5–20 kết nối mở tối đa, với số kết nối idle tương tự, và tái tạo kết nối mỗi 30–60 phút.
Hai chế độ lỗi phổ biến:
- Pool quá nhỏ: request phải chờ lấy kết nối dù thời gian truy vấn ở Postgres vẫn tốt.
- Pool quá lớn: Postgres bị quá tải với nhiều session active, độ trễ không đồng đều.
Nhớ rằng pool nhân với số instance (20 kết nối × 10 instance = 200 kết nối).
Làm sao xác nhận pool kết nối là nút cổ chai (không phải SQL)?
Đo các cuộc gọi DB thành hai phần:
- Thời gian chờ lấy kết nối (pool wait)
- Thời gian thực thi truy vấn (Postgres work)
Nếu phần lớn thời gian là chờ pool, điều chỉnh kích thước pool, timeout và số instance. Nếu phần lớn là thực thi truy vấn, tập trung vào EXPLAIN và chỉ mục.
Cũng đảm bảo luôn đóng rows ngay để kết nối được trả về pool.
Khi một endpoint chậm, tôi nên nhìn gì đầu tiên trong EXPLAIN?
Chạy EXPLAIN (ANALYZE, BUFFERS) trên đúng SQL mà API gửi và chú ý:
- Seq Scan trên bảng lớn
- Khoảng cách lớn giữa estimated rows vs actual rows
- Sort chiếm phần lớn thời gian (thường đi kèm
ORDER BY) - "Rows Removed by Filter" rất nhiều
- Nhiều shared read blocks trong BUFFERS (đọc nhiều dữ liệu)
Sửa cái báo đỏ lớn nhất trước; đừng tối ưu mọi thứ cùng lúc.
Làm sao chọn chỉ mục phù hợp cho endpoint danh sách có filter và sắp xếp?
Chỉ mục phải khớp với cách endpoint truy vấn dữ liệu: filter + sort.
Cách tiếp cận tốt:
- Tạo một chỉ mục tổng hợp cho pattern
WHERE+ORDER BYthường dùng. - Giữ nhỏ (2 cột thường, 3 đôi khi).
- Đặt filter chọn lọc nhất trước, rồi cột sắp xếp.
Ví dụ: nếu lọc theo user_id và sắp theo newest, một chỉ mục như (user_id, created_at DESC) thường tạo khác biệt giữa p95 ổn định và các spike.
Khi nào nên dùng chỉ mục partial trong Postgres?
Dùng chỉ mục một phần khi hầu hết traffic nhắm vào một tập hàng xác định.
Ví dụ:
- Nhiều read chỉ cho
active = true - Ít query cho các hàng inactive
Một chỉ mục partial như ... WHERE active = true nhỏ hơn, có khả năng ở trong bộ nhớ cao hơn và giảm overhead ghi so với đánh chỉ mục toàn bộ.
Xác nhận bằng EXPLAIN rằng Postgres thực sự dùng nó cho các truy vấn có traffic cao.
Tại sao phân trang LIMIT/OFFSET bị chậm theo thời gian, và tôi nên dùng gì thay thế?
LIMIT/OFFSET chậm ở các trang sâu vì Postgres vẫn phải bỏ qua (và thường sắp) các hàng bạn đang skip. Trang 1 có thể chạm vài chục hàng; trang 500 có thể buộc DB phải quét và loại bỏ hàng chục nghìn hàng để trả về 20 kết quả.
Ưu tiên dùng phân trang keyset (cursor):
- Dùng sắp xếp ổn định cộng tie-breaker unique (thường là
id). - Giữ
ORDER BYgiống nhau giữa các request. - Mã hóa
(created_at, id)hoặc tương tự vào cursor.
Mỗi trang khi đó có chi phí gần như không đổi khi bảng lớn dần.
Truy vấn DB nhanh nhưng phản hồi vẫn chậm — tôi có nên cắt bớt JSON?
Thông thường nên. Phản hồi nhỏ hơn có thể là chiến thắng nhanh nhất.
Các cách thực tế:
- Chỉ
SELECTcột cần thiết (tránhSELECT *). - Thêm
include=hoặcfields=để client lựa chọn trường nặng. - Giới hạn mảng lồng nhau (ví dụ chỉ 10 event mới nhất) và cung cấp endpoint riêng cho toàn bộ lịch sử.
- Tránh mô hình N+1 (50 hàng + 50 query). Dùng join hoặc batch theo IDs.
Bạn thường giảm được CPU Go, áp lực bộ nhớ và độ trễ đuôi chỉ bằng cách thu gọn payload.
Một lượt tinh chỉnh trước khi có user đầu tiên nên làm gì?
Trước khi thay đổi gì, ghi lại một baseline nhỏ:
- p95 và p99 latency cho endpoint bận nhất
- tỷ lệ lỗi và timeout
- CPU DB và kết nối active
- 5 truy vấn chậm nhất theo tổng thời gian
Hành trình tối ưu:
- Chạy load test 10–15 phút giống người dùng thực (login, list, search, create). Sắp xếp route theo p95 và tổng thời gian.
- Kiểm tra áp lực kết nối trước khi tune SQL. Điều chỉnh pool và idle limits nếu cần.
EXPLAINcác truy vấn chậm nhất và sửa lỗi lớn nhất.- Thêm/điều chỉnh một chỉ mục, rồi test lại.
- Thắt chặt phản hồi và phân trang, rồi test lần nữa.
Ghi nhật ký thay đổi đơn giản: đã thay gì, vì sao, và p95 di chuyển ra sao. Nếu thay đổi không giúp, revert và tiếp tục.
Những lỗi thường gặp và bẫy cần tránh là gì?
Một vài sai lầm phổ biến:
- Xem kích thước pool như nút vặn tăng tốc. Đặt nó càng cao càng tốt thường làm chậm hơn vì Postgres phải quản lý nhiều session.
- "Đánh chỉ mục mọi thứ"—chỉ mục thêm làm chậm ghi và có thể thay đổi kế hoạch truy vấn bất ngờ.
- Nợ phân trang: offset trông ổn ban đầu rồi p95 tăng dần.
- Kích thước payload JSON lớn: nén giúp băng thông nhưng không bớt chi phí xây/allocate/parse object lớn.
Một kiểm tra trước ra mắt nhanh:
- Theo dõi pool wait time và connection counts trong load test nhỏ.
- So sánh trung bình vs p95 cho cùng một endpoint.
- Kiểm tra phân trang khi bảng lớn gấp 10.
- Kiểm tra kích thước phản hồi cho endpoint danh sách.
- Chạy lại
EXPLAINsau khi thêm chỉ mục hoặc thay filter.
Danh sách kiểm tra nhanh và bước tiếp theo trước khi ra mắt là gì?
Trước khi có user thật, bạn muốn bằng chứng rằng API dự đoán được dưới tải. Mục tiêu không phải số hoàn hảo mà là bắt được những vấn đề dẫn đến timeout, spike, hoặc DB không nhận thêm công việc.
Chạy kiểm tra trong staging tương tự production: đo p95 theo endpoint dưới tải, lấy top truy vấn chậm theo tổng thời gian, xem pool wait time, EXPLAIN (ANALYZE, BUFFERS) truy vấn tồi tệ nhất để xác nhận nó dùng chỉ mục như mong đợi, và kiểm tra kích thước payload cho route bận.
Làm một thử nghiệm worst-case: request trang sâu, áp filter rộng nhất, và thử với cold start (khởi động lại API rồi hit request đầu tiên). Nếu phân trang sâu chậm dần, chuyển sang cursor trước khi ra mắt.
Ghi lại mặc định để team giữ lựa chọn nhất quán: giới hạn pool và timeout, quy tắc phân trang (max page size, có cho offset hay không, định dạng cursor), quy tắc truy vấn (chỉ select cột cần thiết, tránh SELECT *, giới hạn filter đắt), và quy ước logging (ngưỡng slow query, giữ sample bao lâu, cách gắn nhãn endpoint).