Cách tối ưu truy vấn SQL: 20 phương pháp hay nhất đã được kiểm chứng

Một truy vấn SQL chậm là một trong những cách dễ nhất để phá hỏng một ứng dụng vốn rất nhanh.

Bạn có thể sở hữu kiến trúc sạch sẽ, bộ đệm (cache) tuyệt vời và một máy chủ mạnh mẽ — nhưng vẫn phải nhìn trang web treo lâu chỉ vì một truy vấn quét cả triệu dòng mà không có chỉ mục (index).

Trong nhiều năm qua, tôi đã tối ưu hàng trăm truy vấn SQL trên nhiều hệ thống production.

Phần lớn thành công đến từ cùng một nhóm kỹ thuật nhỏ, được áp dụng lặp đi lặp lại.

Một số kỹ thuật thì hiển nhiên. Một số khác lại đi ngược lại với những lời khuyên mà có thể bạn đã từng nghe.

Để dễ ghi nhớ, tôi đã gom 20 kỹ thuật đã được kiểm chứng thành 6 chủ đề mà bạn có thể áp dụng bất cứ khi nào truy vấn bị chậm.

Trong bài viết này, chúng ta sẽ cùng khám phá:

  • Viết truy vấn thân thiện với chỉ mục (sargable)
    • Sử dụng chỉ mục một cách khôn ngoan
    • Tránh dùng hàm trên cột trong mệnh đề WHERE
    • Tránh ký tự đại diện (wildcard) ở đầu trong LIKE
    • Khớp chính xác kiểu dữ liệu
  • Chỉ lấy đúng dữ liệu bạn cần
    • Ngừng dùng SELECT *
    • Lọc sớm
    • Phân trang bằng Keyset thay vì OFFSET
    • Chỉ truy vấn những gì đã thay đổi
  • Đơn giản hóa JOIN và subquery
    • Giảm độ phức tạp của JOIN
    • Chọn đúng loại JOIN
    • Thay subquery thừa bằng JOIN hoặc CTE
    • Ưu tiên EXISTS hơn IN
  • Thiết kế schema cho nhu cầu đọc
    • Chuẩn hóa (normalize) một cách khôn ngoan
    • Sử dụng materialized view
  • Làm cho thao tác ghi và transaction hiệu quả
    • Xử lý theo lô (batch) các thao tác lớn
    • Giữ transaction ngắn gọn
  • Để cơ sở dữ liệu giúp bạn
    • Đọc kế hoạch thực thi (execution plan)
    • Giữ thống kê (statistics) luôn được cập nhật
    • Sử dụng query hint một cách tiết kiệm
    • Giám sát và tinh chỉnh liên tục

Bắt đầu thôi.

Tất cả các truy vấn trong bài viết này đều được kiểm thử trên cơ sở dữ liệu PostgreSQL. Các ý tưởng tương tự cũng áp dụng cho các cơ sở dữ liệu khác, mặc dù cú pháp chính xác có thể khác nhau.

Viết truy vấn thân thiện với chỉ mục (Sargable)

Một chỉ mục chỉ có ích nếu truy vấn của bạn cho phép cơ sở dữ liệu sử dụng nó.

Một truy vấn “sargable” (bắt nguồn từ Search ARGument ABLE — có thể tìm kiếm theo đối số) là truy vấn mà cơ sở dữ liệu có thể nhảy thẳng tới các dòng cần thiết thông qua chỉ mục, thay vì phải quét toàn bộ bảng. Bốn kỹ thuật dưới đây sẽ giúp truy vấn của bạn luôn sargable.

1. Sử dụng chỉ mục một cách khôn ngoan

Chỉ mục là đòn bẩy lớn nhất cho hiệu suất đọc.

Chỉ mục là một cấu trúc dữ liệu đã được sắp xếp, giúp cơ sở dữ liệu tìm thấy các dòng mà không cần quét toàn bộ bảng — giống như phần mục lục ở cuối cuốn sách giúp bạn khỏi phải đọc từng trang.

Hãy tạo chỉ mục trên các cột mà bạn thường xuyên lọc, join, sắp xếp và group — tức là các cột xuất hiện trong mệnh đề WHERE, JOIN, ORDER BY và GROUP BY.

Khi nhiều cột được sử dụng cùng nhau, một chỉ mục tổng hợp (composite index) duy nhất bao phủ tất cả sẽ tốt hơn nhiều so với việc tạo nhiều chỉ mục đơn cột riêng lẻ:

-- Chỉ mục tổng hợp cho các bộ lọc thường dùng trên status và order_date
CREATE INDEX idx_orders_status_order_date
    ON orders (status, order_date);

Chỉ mục này tăng tốc các truy vấn lọc theo status, và theo status kết hợp với order_date.

Thứ tự các cột rất quan trọng: chỉ mục trên (status, order_date) giúp ích cho các truy vấn lọc theo status trước, nhưng không giúp ích cho các truy vấn chỉ lọc theo order_date.

Bạn cũng có thể tạo một covering index (chỉ mục bao phủ) để lưu thêm giá trị của các cột khác bên trong chỉ mục. Điều này hữu ích khi truy vấn chỉ cần các cột đã có sẵn trong chỉ mục, nhờ đó cơ sở dữ liệu tránh được việc phải đọc các dòng thực tế từ bảng.

Nó đặc biệt hữu ích cho các truy vấn tra cứu nhỏ, thường xuyên.

-- Bao phủ một truy vấn đọc thường dùng mà không cần lấy dòng từ bảng
CREATE INDEX idx_orders_status_order_date_covering
    ON orders (status, order_date)
    INCLUDE (customer_id, total_amount);

SELECT customer_id, total_amount
FROM orders
WHERE status = 'paid'
  AND order_date >= DATE '2026-01-01';

Lưu ý: chỉ mục không miễn phí. Mỗi chỉ mục phải được cập nhật trong mỗi lần insert, update, delete, đồng thời chiếm dung lượng đĩa. Hãy đánh chỉ mục cho các cột mà truy vấn của bạn thực sự dùng đến, chứ không phải mọi cột.

Để tìm hiểu sâu hơn về các loại chỉ mục và đánh đổi, hãy xem bài viết Tối ưu hiệu suất SQL với các chiến lược đánh chỉ mục (Optimizing SQL Performance with Indexing Strategies).

2. Tránh dùng hàm trên cột trong WHERE

Bọc một cột bên trong một hàm là một trong những cách phổ biến nhất khiến chỉ mục vô tình bị vô hiệu hóa.

Khi bạn gọi hàm trên một cột, cơ sở dữ liệu phải tính toán hàm đó cho từng dòng trước khi so sánh, vì vậy nó không thể sử dụng chỉ mục trên cột gốc:

-- Tệ: hàm trên order_date ngăn cản việc quét bằng chỉ mục
SELECT * FROM orders
WHERE EXTRACT(YEAR FROM order_date) = 2025;

Hãy viết lại điều kiện để so sánh cột gốc với một khoảng giá trị (range). Cách này giữ cho truy vấn sargable và để chỉ mục làm việc đúng vai trò của nó:

-- Tốt: một khoảng sargable trên cột gốc
SELECT * FROM orders
WHERE order_date >= '2025-01-01'
  AND order_date <  '2026-01-01';

Cả hai truy vấn đều trả về cùng các dòng, nhưng chỉ truy vấn thứ hai mới có thể sử dụng chỉ mục trên order_date.

Quy tắc tương tự áp dụng cho LOWER(email), CAST(...), và các phép toán số học trên cột. Nếu bạn thường xuyên cần lọc theo một giá trị đã tính toán, hãy tạo một expression index (chỉ mục biểu thức) cho chính biểu thức đó.

3. Tránh ký tự đại diện ở đầu trong LIKE

Một mẫu LIKE bắt đầu bằng ký tự đại diện (wildcard) sẽ không thể sử dụng chỉ mục thông thường.

Cơ sở dữ liệu đọc chỉ mục từ trái sang phải, vì vậy nó cần biết phần đầu của giá trị. Một mẫu như '%son' giấu đi phần đầu và buộc phải quét toàn bộ bảng:

-- Tệ: ký tự đại diện ở đầu buộc phải quét toàn bộ bảng
SELECT * FROM customers
WHERE last_name LIKE '%son';

-- Tốt: một tiền tố đã biết cho phép quét theo khoảng trên chỉ mục
SELECT * FROM customers
WHERE last_name LIKE 'Anders%';

Nếu bạn thực sự cần tìm kiếm bên trong văn bản — tìm kiếm kiểu “contains” — thì chỉ mục B-tree thông thường sẽ không giúp được gì.

Thay vào đó, hãy dùng full-text search hoặc chỉ mục trigram (extension pg_trgm trong PostgreSQL) — thứ được thiết kế riêng cho việc này.

4. Khớp chính xác kiểu dữ liệu

So sánh hai kiểu dữ liệu khác nhau buộc cơ sở dữ liệu phải chuyển đổi một trong hai, và sự chuyển đổi đó có thể âm thầm vô hiệu hóa chỉ mục.

Nếu một cột là integer nhưng bạn so sánh nó với một chuỗi, hoặc bạn join một khóa integer với một khóa bigint, cơ sở dữ liệu sẽ thêm một phép ép kiểu ngầm định (implicit cast) — và chỉ mục trên cột gốc có thể bị bỏ qua.

Hãy giữ kiểu dữ liệu giống hệt nhau ở cả hai phía của mọi phép join và bộ lọc:

CREATE TABLE logs (
    log_id     int PRIMARY KEY,
    event_date timestamptz NOT NULL,
    user_id    int NOT NULL
);

Định nghĩa logs.user_id khớp với kiểu của users.id để phép join sử dụng được chỉ mục ở cả hai phía.

Khớp kiểu dữ liệu là một chi tiết nhỏ ở giai đoạn thiết kế nhưng giúp bạn tránh được một lần quét bảng gây khó hiểu, khó phát hiện về sau.

Chỉ lấy đúng dữ liệu bạn cần

Dữ liệu nhanh nhất để xử lý là dữ liệu bạn không bao giờ đọc.

Mỗi cột và mỗi dòng bạn kéo về đều tốn I/O đĩa, bộ nhớ và thời gian mạng. Bốn kỹ thuật dưới đây giúp thu nhỏ tập kết quả càng sớm và càng nhiều càng tốt.

5. Ngừng sử dụng SELECT *

SELECT * kéo về mọi cột, kể cả những cột bạn không cần.

Điều đó nghĩa là nhiều dữ liệu phải đọc từ đĩa hơn, nhiều dữ liệu phải gửi qua mạng hơn, và nhiều bộ nhớ phải giữ hơn — tất cả chỉ cho những cột mà code của bạn vứt bỏ.

Nó cũng chặn các covering index — nơi chỉ mục một mình có thể trả lời truy vấn mà không cần đụng tới bảng:

-- Tệ: lấy về mọi cột
SELECT * FROM customers;

-- Tốt: chỉ lấy những cột bạn dùng
SELECT customer_id, first_name, last_name
FROM customers;

Liệt kê cột một cách tường minh cũng an toàn hơn. Truy vấn của bạn sẽ không âm thầm thay đổi hình dạng hay bị lỗi khi ai đó thêm hoặc đổi thứ tự một cột.

6. Lọc sớm

Tập dữ liệu mà bạn thao tác càng nhỏ, mọi thứ phía sau càng nhanh.

Bạn có thể đã nghe lời khuyên quen thuộc:

Hãy áp dụng các bộ lọc có độ chọn lọc (selective) cao nhất để cắt giảm số dòng trước khi các phép join và tổng hợp phải xử lý chúng:

SELECT o.order_id, o.total
FROM orders o
WHERE o.order_date >= '2025-01-01'
  AND o.order_date <  '2025-02-01';

Lưu ý: trong hầu hết các trường hợp, bạn không cần tự tay đặt các bộ lọc này vào vị trí. Một cost-based planner hiện đại đã tự đẩy các vị từ (predicate) xuống — nó áp dụng điều kiện WHERE của bạn càng sớm càng tốt và tự sắp xếp lại thứ tự các phép join. Việc viết lại thứ tự chữ trong mệnh đề WHERE hiếm khi thay đổi kế hoạch thực thi.

Điều thực sự hữu ích là cung cấp cho planner một bộ lọc chọn lọc, sargable và một chỉ mục để áp dụng nó. Vì vậy, hãy tập trung vào việc làm cho bộ lọc thân thiện với chỉ mục, thay vì quan tâm nó được gõ ở đâu.

7. Phân trang bằng Keyset thay vì OFFSET — khi có thể

OFFSET trông có vẻ là cách dễ dàng để triển khai phân trang, nhưng nó càng lúc càng chậm khi bạn đi càng sâu.

Để trả về trang thứ 1.000, cơ sở dữ liệu vẫn phải đọc và vứt bỏ mọi dòng phía trước. OFFSET 100000 nghĩa là đọc 100.000 dòng chỉ để bỏ qua chúng.

Keyset pagination (còn gọi là seek pagination) ghi nhớ giá trị cuối cùng bạn đã thấy và nhảy thẳng qua nó:

-- Phân trang dựa trên khóa ở một cột đã đánh chỉ mục
SELECT *
FROM orders
WHERE order_id > 1000
ORDER BY order_id
LIMIT 10;

Mỗi trang đều tốn chi phí như nhau, dù là trang 2 hay trang 2.000, vì chỉ mục nhảy thẳng tới order_id > 1000.

Đánh đổi ở đây: keyset pagination cho bạn điều hướng trang sau/trang trước nhanh chóng, nhưng bạn không thể nhảy ngẫu nhiên đến bất kỳ số trang nào.

8. Chỉ truy vấn những gì đã thay đổi

Đọc lại toàn bộ một bảng trong mỗi lần chạy là điều lãng phí khi chỉ có vài dòng thay đổi.

Hãy theo dõi một watermark — điểm xử lý cuối cùng — và chỉ lấy các dòng mới hơn nó:

-- Chỉ lấy các bản ghi được cập nhật kể từ lần chạy trước
SELECT *
FROM records
WHERE modified_date > '2025-05-20 00:00';

Mô hình incremental này biến một lần quét toàn bộ bảng thành một lần đọc theo khoảng nhỏ có chỉ mục.

Hãy đánh chỉ mục cho modified_date, lưu lại mốc nước cao mới sau mỗi lần chạy, và công việc đồng bộ của bạn sẽ luôn nhanh ngay cả khi bảng ngày càng lớn.

Đơn giản hóa JOIN và Subquery

Join và subquery là nơi truy vấn trở nên đắt đỏ, và cũng là nơi ẩn chứa những cuộc “dọn dẹp” lớn nhất.

Mục tiêu là yêu cầu cơ sở dữ liệu làm ít việc hơn và diễn đạt công việc đó dưới hình thức mà nó xử lý tốt nhất.

9. Giảm độ phức tạp của JOIN

Mỗi phép join là thêm một lượng công việc. Càng ít bảng cơ sở dữ liệu phải khớp với nhau, truy vấn càng nhanh.

Một lỗi phổ biến là join một bảng mà bạn chưa bao giờ thực sự đọc dữ liệu từ đó:

-- Tệ: join bảng suppliers nhưng không bao giờ dùng tới
SELECT p.product_name, c.category_name
FROM products p
JOIN categories c ON p.category_id = c.category_id
JOIN suppliers  s ON p.supplier_id = s.supplier_id;

Bỏ phép join không cần thiết đi, cơ sở dữ liệu sẽ làm ít việc hơn mà vẫn cho cùng kết quả:

-- Tốt: chỉ giữ những phép join đóng góp vào kết quả đầu ra
SELECT p.product_name, c.category_name
FROM products p
JOIN categories c ON p.category_id = c.category_id;

Hãy đọc danh sách SELECT và WHERE của bạn và loại bỏ mọi bảng được join nhưng không được tham chiếu trong hai danh sách đó.

10. Chọn đúng loại JOIN

Loại join thay đổi cả kết quả lẫn chi phí.

Hãy dùng INNER JOIN khi bạn cần các dòng khớp từ cả hai phía, chỉ dùng LEFT JOIN khi bạn thực sự cần cả những dòng không khớp, và dùng EXISTS khi bạn chỉ cần biết liệu có tồn tại một dòng khớp hay không:

-- Chỉ kiểm tra sự tồn tại: EXISTS dừng lại ở dòng khớp đầu tiên
SELECT c.customer_id, c.name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id
      AND o.total > 100
);

Dùng LEFT JOIN ở nơi mà INNER JOIN đã đủ — sẽ buộc cơ sở dữ liệu phải mang theo các dòng không khớp, rồi sau đó chỉ vứt bỏ chúng.

Để có bài phân tích đầy đủ về các loại join và khi nào nên dùng loại nào, hãy xem bài viết Hướng dẫn đầy đủ về các loại JOIN trong SQL (A Complete Guide to Different Types of Joins in SQL).

11. Thay subquery thừa bằng JOIN hoặc CTE

Một correlated subquery (subquery tương quan) chạy một lần cho mỗi dòng, điều này có thể rất khủng khiếp với một tập kết quả lớn.

Ở đây, subquery chạy cho mỗi đơn hàng chỉ để tra tên khách hàng:

-- Tệ: subquery chạy cho từng dòng
SELECT o.order_id,
       (SELECT c.name FROM customers c
        WHERE c.customer_id = o.customer_id) AS customer_name
FROM orders o;

Một phép join duy nhất làm cùng công việc đó trong một lần, chỉ một lượt:

-- Tốt: một phép join thay cho một lần tra cứu trên mỗi dòng
SELECT o.order_id, c.name AS customer_name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;

Khi cùng một subquery cần được dùng nhiều lần trong một câu lệnh, hãy đưa nó vào một common table expression (CTE) với WITH để nó chỉ được viết một lần và dễ đọc hơn.

Cùng khám phá ví dụ truy vấn chậm:

-- Tệ: cùng một subquery tổng hợp bị lặp lại
SELECT
  c.customer_id,
  c.name,
  (
    SELECT SUM(o.total_amount)
    FROM orders o
    WHERE o.customer_id = c.customer_id
      AND o.order_date >= DATE '2026-01-01'
  ) AS total_spent,
  (
    SELECT COUNT(*)
    FROM orders o
    WHERE o.customer_id = c.customer_id
      AND o.order_date >= DATE '2026-01-01'
  ) AS order_count
FROM customers c;

Và đây là truy vấn nhanh hơn với CTE:

-- Tốt: tính toán kết quả một lần rồi tái sử dụng
WITH customer_order_totals AS (
  SELECT
    customer_id,
    SUM(total_amount) AS total_spent,
    COUNT(*) AS order_count
  FROM orders
  WHERE order_date >= DATE '2026-01-01'
  GROUP BY customer_id
)
SELECT
  c.customer_id,
  c.name,
  COALESCE(t.total_spent, 0) AS total_spent,
  COALESCE(t.order_count, 0) AS order_count
FROM customers c
LEFT JOIN customer_order_totals t ON t.customer_id = c.customer_id;

12. Ưu tiên EXISTS hơn IN

EXISTS có thể short-circuit: nó dừng lại ở dòng khớp đầu tiên, trong khi IN có thể phải xây dựng toàn bộ danh sách giá trị trước.

-- Kiểm tra sự tồn tại và dừng lại ở kết quả trùng đầu tiên
SELECT p.product_id, p.product_name
FROM products p
WHERE EXISTS (
    SELECT 1 FROM order_details od
    WHERE od.product_id = p.product_id
);

Lưu ý: đừng coi “EXISTS luôn thắng IN” là một quy tắc cứng nhắc. PostgreSQL hiện đại thường viết lại IN, EXISTS, và thậm chí một số phép join thành cùng một kế hoạch thực thi, nên cả hai có thể hoạt động giống hệt nhau.

Trường hợp vẫn gây đau đầu là NOT IN với một subquery có thể trả về NULL — nó tạo ra kết quả gây bất ngờ và một kế hoạch thực thi tồi, vì vậy hãy ưu tiên NOT EXISTS trong trường hợp đó. Như mọi khi, hãy kiểm tra kế hoạch thực thi thay vì đoán mò.

Thiết kế schema cho nhu cầu đọc

Một số truy vấn dù viết thế nào cũng vẫn chậm, bởi vì chúng tính toán lại cùng một kết quả nặng nề lặp đi lặp lại.

Giải pháp là thay đổi hình dạng dữ liệu của bạn.

13. Chuẩn hóa (normalize) một cách khôn ngoan

Chuẩn hóa giữ cho dữ liệu của bạn sạch sẽ và nhất quán, và đó là lựa chọn mặc định đúng đắn.

Nhưng dữ liệu đã chuẩn hóa hoàn toàn có thể đọc chậm khi một truy vấn nóng phải join và tổng hợp trên cùng các bảng trong mỗi lần yêu cầu.

Với những đường đọc (read path) nặng, việc denormalize là chấp nhận được: tính toán trước một bản tóm tắt và lưu trữ nó.

-- Tính trước tổng số lượng bán được cho mỗi sản phẩm vào một bảng tóm tắt
CREATE TABLE sales_summary AS
SELECT product_id, SUM(quantity) AS total_sold
FROM order_details
GROUP BY product_id;

Giờ đây thao tác đọc chỉ là một phép tra cứu đơn giản thay vì một phép tổng hợp trực tiếp trên toàn bộ bảng order_details.

Đánh đổi là bạn phải giữ bảng tóm tắt luôn đồng bộ — làm mới nó theo lịch trình, hoặc khi dữ liệu nguồn thay đổi. Denormalize một cách có chủ đích, cho những truy vấn nóng cụ thể, chứ không phải ở khắp mọi nơi.

14. Sử dụng Materialized View

Một materialized view lưu trữ kết quả của một truy vấn về mặt vật lý, nhờ đó các thao tác đọc chạm vào các dòng đã được tính toán sẵn thay vì tính toán lại.

Nó là phiên bản có sẵn trong cơ sở dữ liệu của bảng tóm tắt ở trên, và PostgreSQL hỗ trợ trực tiếp:

-- Lưu trữ một phép tổng hợp đã được tính sẵn
CREATE MATERIALIZED VIEW mv_total_sales AS
SELECT product_id, SUM(quantity) AS total_qty
FROM order_details
GROUP BY product_id;

-- Một chỉ mục unique giúp view làm mới mà không khóa các phiên đọc
CREATE UNIQUE INDEX idx_mv_total_sales_product
    ON mv_total_sales (product_id);

-- Làm mới theo lịch trình; các phiên đọc vẫn dùng dữ liệu cũ cho đến khi hoàn tất
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_total_sales;

Các truy vấn trên mv_total_sales nhanh vì phép tổng hợp đã được chạy từ trước.

Dữ liệu chỉ tươi mới bằng lần làm mới gần nhất của bạn, vì vậy hãy dùng materialized view cho những phép tổng hợp đắt đỏ có thể chấp nhận dữ liệu hơi cũ — như dashboard, báo cáo và bảng xếp hạng (leaderboard). Để tìm hiểu thêm, hãy xem bài viết Bắt đầu với Database Views trong SQL (Get Started with Database Views in SQL).

Làm cho thao tác ghi và transaction hiệu quả

Thao tác đọc nhận được phần lớn sự chú ý, nhưng thao tác ghi chậm và transaction dài gây ra tranh chấp khóa (lock contention), khiến truy vấn của mọi người phải chờ đợi.

Hai kỹ thuật dưới đây giúp cho thao tác ghi của bạn gọn nhẹ hơn.

15. Xử lý theo lô (batch) các thao tác lớn

Chạy một câu lệnh cho từng dòng sẽ làm ngập cơ sở dữ liệu với các lượt truy cập khứ hồi (round-trip) và chi phí transaction.

Nhưng một câu lệnh duy nhất chạm tới hàng triệu dòng cũng là một vấn đề — nó giữ khóa trong thời gian dài và có thể làm bùng nổ write-ahead log.

Điểm cân bằng nằm ở batching: xử lý theo từng khối (chunk) cố định một lần. Câu lệnh dưới đây di chuyển các dòng vào bảng lưu trữ (archive) theo từng nhóm 1.000 dòng, xóa mỗi khối ngay sau khi sao chép xong:

-- Di chuyển dòng theo từng nhóm 1000 cho đến khi không còn dòng nào
WITH batch AS (
    DELETE FROM source_table
    WHERE ctid IN (
        SELECT ctid
        FROM source_table
        WHERE processed = false
        LIMIT 1000
    )
    RETURNING col1, col2
)
INSERT INTO archive_table (col1, col2)
SELECT col1, col2 FROM batch;

Chạy đoạn này trong một vòng lặp cho đến khi nó tác động đến 0 dòng.

Mỗi lô commit nhanh chóng, giữ ít khóa, và giữ cho hệ thống vẫn phản hồi tốt trong khi công việc lớn đang chạy.

16. Giữ transaction ngắn gọn

Một transaction giữ các khóa của nó cho đến khi commit, và mọi truy vấn khác cần những dòng đó đều phải chờ.

Transaction mở càng lâu, nó càng tạo ra nhiều tranh chấp. Hãy chỉ bọc quanh các thao tác ghi trong transaction, và thực hiện các công việc chậm — gọi API, I/O file, tính toán nặng — ở bên ngoài nó:

BEGIN;

UPDATE accounts
SET balance = balance - 100
WHERE account_id = 1;

INSERT INTO transactions (account_id, amount)
VALUES (1, -100);

COMMIT;

Transaction này mở ra, thực hiện hai thao tác ghi liên quan, rồi commit ngay lập tức.

Đừng bao giờ để một transaction mở trong lúc chờ người dùng nhập liệu hoặc chờ một lời gọi mạng. Để hiểu khóa và mức cô lập tương tác với nhau ra sao, hãy đọc bài viết Hướng dẫn đầy đủ về các mức cô lập transaction trong SQL (A Complete Guide to Transaction Isolation Levels in SQL).

Để cơ sở dữ liệu giúp bạn: Đo lường, duy trì, và tin tưởng bộ tối ưu hóa

Nhóm cuối cùng này nói về việc làm việc cùng với database engine thay vì đoán mò.

Query planner thông minh hơn những gì nó được ghi nhận. Công việc của bạn là cung cấp cho nó thông tin tốt rồi xác minh kết quả.

17. Đọc kế hoạch thực thi (execution plan)

Execution plan là cách cơ sở dữ liệu nói cho bạn biết chính xác nó sẽ chạy truy vấn của bạn như thế nào.

Trước khi tối ưu bất cứ điều gì, hãy nhìn vào kế hoạch. Trong PostgreSQL, EXPLAIN ANALYZE chạy truy vấn và báo cáo những gì thực sự đã xảy ra:

EXPLAIN ANALYZE
SELECT * FROM orders WHERE total > 100;

Hãy đọc nó để tìm các thao tác đắt đỏ: một Seq Scan trên một bảng lớn thường nghĩa là thiếu chỉ mục hoặc chỉ mục không được dùng, và khoảng cách lớn giữa số dòng ước tính và số dòng thực tế cho thấy thống kê đã cũ.

Execution plan biến việc tối ưu từ đoán mò thành bằng chứng. Bạn sửa đúng những gì kế hoạch chỉ ra là chậm, không cần giả định.

18. Giữ thống kê luôn được cập nhật

Planner chọn giữa quét bảng và tìm theo chỉ mục dựa trên thống kê về dữ liệu của bạn — có bao nhiêu dòng, bao nhiêu giá trị phân biệt, và chúng phân bố ra sao.

Khi thống kê đã cũ, planner đưa ra những phán đoán sai và chọn những kế hoạch tồi, ngay cả khi chỉ mục hoàn hảo.

ANALYZE làm mới chúng:

-- Làm mới thống kê cho planner của một bảng
ANALYZE orders;

PostgreSQL chạy autovacuum để giữ thống kê luôn mới, nhưng sau một lần nạp dữ liệu hàng loạt lớn hoặc một lần cập nhật diện rộng, bạn nên tự chạy ANALYZE.

Đây là nửa còn lại của câu chuyện bộ tối ưu hóa: engine chỉ có thể tối ưu tốt chừng nào thống kê cho phép. Thống kê tốt quan trọng hơn nhiều so với cách diễn đạt chính xác của truy vấn.

19. Sử dụng query hint một cách tiết kiệm

Một query hint buộc cơ sở dữ liệu chạy truy vấn theo cách của bạn thay vì cách của planner.

PostgreSQL cố tình không có cú pháp hint. Triết lý của nó là bạn nên sửa nguyên nhân gốc — chỉ mục, thống kê, hình dạng truy vấn — thay vì ghi đè lên planner.

Bạn có thể tác động nó bằng các session setting, nhưng hãy chỉ coi đây là công cụ chẩn đoán trong môi trường phát triển:

-- Chỉ để chẩn đoán: xem kế hoạch trông thế nào khi không có quét tuần tự
SET enable_seqscan = off;

Nếu bạn thực sự cần hint, extension pg_hint_plan có thể thêm chúng — nhưng hãy dùng đến nó cuối cùng, sau khi chỉ mục và thống kê đã khiến bạn thất vọng.

Một hint đóng băng một quyết định đúng hôm nay nhưng có thể sai sau khi dữ liệu của bạn phát triển, vì vậy nó âm thầm trở thành truy vấn chậm của ngày mai.

20. Giám sát và tinh chỉnh liên tục

Tối ưu hóa không phải là một việc làm một lần. Dữ liệu phát triển, mô hình truy cập thay đổi, và truy vấn nhanh của hôm qua trở thành nút thắt cổ chai của hôm nay.

Hãy theo dõi những truy vấn nào thực sự tốn kém nhất. Extension pg_stat_statements tổng hợp thống kê thực thi trên toàn bộ khối lượng công việc của bạn:

-- Tìm những truy vấn chậm nhất theo thời gian thực thi trung bình
SELECT query,
       calls,
       mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

Hãy để danh sách này cho bạn biết nên tối ưu gì tiếp theo.

Và đây là nơi mọi kỹ thuật trong bài viết này hội tụ. Một planner hiện đại đã tự làm những phép viết lại thông minh — đẩy vị từ xuống (predicate pushdown), sắp xếp lại join, biến IN thành một phép join. Bạn không kiếm được nhiều từ việc tinh chỉnh bằng tay văn bản của mệnh đề WHERE. Những lợi ích lớn đến từ việc cung cấp cho planner những gì nó cần: các vị từ sargable, thống kê mới, và các chỉ mục đúng — sau đó đo lường để xác nhận.

Tóm tắt

Tối ưu truy vấn SQL vừa là nghệ thuật, nhưng chắc chắn không phải là khoa học tên lửa. Và nó luôn xứng đáng để đầu tư.

Hãy cùng điểm lại những điểm chính:

  • Giữ truy vấn sargable. Hãy đánh chỉ mục cho những cột bạn lọc, join và sắp xếp, và đừng bao giờ giấu chúng sau một hàm, một ký tự đại diện ở đầu, hay một sự lệch kiểu dữ liệu — đó là điều giúp chỉ mục làm tròn vai trò của nó.
  • Đụng vào ít dữ liệu hơn. Bỏ SELECT *, lọc đến đúng các dòng bạn cần, phân trang theo khóa thay vì OFFSET sâu, và chỉ đọc những gì đã thay đổi.
  • Đơn giản hóa hình dạng truy vấn. Bỏ những phép join bạn không dùng, chọn đúng loại join, và thay các subquery chạy theo từng dòng bằng join hoặc CTE.
  • Thiết kế cho nhu cầu đọc. Denormalize các đường đọc nóng một cách có chủ đích, và tính toán trước những phép tổng hợp nặng bằng materialized view.
  • Giữ thao tác ghi gọn nhẹ. Xử lý theo lô các thao tác lớn và giữ transaction ngắn để tránh tranh chấp khóa.
  • Tin tưởng bộ tối ưu hóa, rồi xác minh. Đọc execution plan, giữ thống kê luôn mới, tránh hint, và để pg_stat_statements chỉ cho bạn đúng nút thắt thực sự.

Trước bất kỳ sự tối ưu nào, hãy nhớ: Đo lường trước. Thường thì bạn sẽ nhận ra truy vấn đã đủ nhanh, và sự tối ưu tốt nhất là sự tối ưu mà bạn không cần phải thực hiện.

Hy vọng bài viết này hữu ích với bạn. Hẹn gặp lại lần sau.