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.

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.
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 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ữ 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.
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.
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:
max_connections thế nào.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.
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.
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:
ORDER BY)Plan chậm thường đến từ vài pattern:
WHERE + ORDER BY của bạn (ví dụ (user_id, status, created_at))WHERE (ví dụ WHERE lower(email) = $1), khiến phải scan trừ khi thêm expression index tương ứngNế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 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 đó.
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:
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.
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:
EXPLAIN (hoặc EXPLAIN ANALYZE ở môi trường an toàn) và tìm index scan phù hợp.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.
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.
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ế.
Dùng một baseline nhỏ bạn có thể lặp lại:
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.
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:
Khi đã tìm được những truy vấn tồi nhất, chuyển sang sampling hoặc tăng ngưỡng.
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:
Nhớ rằng pool nhân với số instance (20 kết nối × 10 instance = 200 kết nối).
Đo các cuộc gọi DB thành hai phần:
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.
Chạy EXPLAIN (ANALYZE, BUFFERS) trên đúng SQL mà API gửi và chú ý:
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:
WHERE + ORDER BY thường dùng.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ụ:
active = trueMộ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 rằng Postgres thực sự dùng nó cho các truy vấn có traffic cao.
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):
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ế:
SELECT cột cần thiết (tránh SELECT *).Trước khi thay đổi gì, ghi lại một baseline nhỏ:
Hành trình tối ưu:
Một vài sai lầm phổ biến:
Một kiểm tra trước ra mắt nhanh:
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 , 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).
ORDER BYSửa cái báo đỏ lớn nhất trước; đừng tối ưu mọi thứ cùng lúc.
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.
EXPLAINidORDER BY giống nhau giữa các request.(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.
include= hoặc fields= để client lựa chọn trường nặng.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.
EXPLAIN các truy vấn chậm nhất và sửa lỗi lớn nhất.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.
EXPLAIN sau khi thêm chỉ mục hoặc thay filter.SELECT *