Sáu tháng triển khai Production: Cơn ác mộng mang tên Join
Sáu tháng trước, tôi tiếp nhận một dashboard báo cáo cho một nền tảng thương mại điện tử đang mở rộng quy mô lên 100.000 người dùng đầu tiên. Mọi thứ trông có vẻ ổn trên máy chủ staging. Tuy nhiên, khi chúng tôi chạm mốc lưu lượng truy cập cao điểm, cơ sở dữ liệu bắt đầu bị nghẽn. Những truy vấn trước đây chỉ mất 50ms đột ngột kéo dài lên đến 10 giây. Tệ hơn nữa, một số báo cáo bị thiếu dữ liệu khách hàng cho các đơn hàng gần đây, trong khi những báo cáo khác lại tính trùng doanh thu hoàn toàn.
Các log truy vấn chậm (slow query logs) đã chỉ ra nút thắt cổ chai rõ ràng. Các lập trình viên của chúng tôi đã sử dụng LEFT JOIN trong khi đáng lẽ phải dùng INNER JOIN. Tệ hơn, một lệnh CROSS JOIN đã vô tình lọt vào một luồng xử lý quan trọng. Điều này tạo ra một tích Descartes (Cartesian product) khổng lồ, suýt chút nữa đã làm sập instance RDS db.t3.medium của chúng tôi trong một đợt flash sale.
Cơ sở dữ liệu quan hệ sống còn nhờ vào các phép Join. Từng làm việc với MySQL, PostgreSQL và MongoDB, tôi đã thấy logic Join có thể giúp ứng dụng vận hành mượt mà hoặc khiến nó sụp đổ hoàn toàn. Dưới đây là cách chúng tôi sửa lại logic và tối ưu hóa các phép Join cho môi trường có độ đồng thời (concurrency) cao.
Nguyên nhân gốc rễ: Tại sao các báo cáo lại sai lệch
Vấn đề chính không chỉ nằm ở mã nguồn chậm; đó là sự thiếu nhất quán về logic. Chúng tôi quản lý hai bảng chính: users (50.000 dòng) và orders (500.000 dòng). Khi người dùng xóa tài khoản bằng phương pháp xóa mềm (soft delete), hoặc khi khách vãng lai đặt hàng, mối quan hệ giữa các bảng này sẽ thay đổi. Đội ngũ của tôi đã viết các phép Join mà không cân nhắc việc các giá trị NULL hoặc các khóa bị thiếu sẽ làm sai lệch kết quả như thế nào.
Trong môi trường Production, một kiểu Join được chọn không khéo léo không chỉ trả về dữ liệu sai. Nó còn khóa các dòng (lock rows), tiêu tốn không gian đĩa tạm thời và đẩy mức sử dụng CPU lên 100%. Chúng tôi phải tiêu chuẩn hóa cách liên kết dữ liệu trên cụm PostgreSQL của mình để ngăn chặn những lỗi âm thầm này.
Chọn đúng kiểu Join cho từng tác vụ
Tôi nhận thấy cách tốt nhất để giải thích về Join cho đội ngũ là phân loại chúng theo dữ liệu “Bắt buộc” (Required) và “Tùy chọn” (Optional). Sự thay đổi tư duy này đã thay đổi cách chúng tôi tiếp cận mọi tính năng mới.
1. INNER JOIN: Bộ lọc nghiêm ngặt
Sử dụng INNER JOIN khi bạn chỉ muốn lấy các bản ghi tồn tại ở cả hai bảng. Nếu một đơn hàng không có user_id hợp lệ, nó không nên xuất hiện trong báo cáo “Doanh số khách hàng đã đăng ký”.
-- Chỉ trả về các đơn hàng liên kết với một người dùng hiện có và đã đăng ký
SELECT orders.id, users.email
FROM orders
INNER JOIN users ON orders.user_id = users.id;
Điểm cần lưu ý: Nếu bạn có 1.000 đơn hàng nhưng trong đó có 200 đơn của khách vãng lai (guest checkout) với user_id là NULL, thì 200 đơn hàng đó sẽ biến mất khỏi kết quả. Chính kịch bản này đã gây ra lỗi báo cáo doanh thu ban đầu của chúng tôi.
2. LEFT JOIN: Người báo cáo bao quát
Đây là kiểu Join phổ biến nhất trong hầu hết các ứng dụng. Nó trả về mọi dòng từ bảng bên trái, bất kể có dữ liệu khớp ở bảng bên phải hay không. Nếu không có dữ liệu khớp, cơ sở dữ liệu sẽ điền NULL vào các cột của bảng bên phải.
-- Trả về TẤT CẢ người dùng, ngay cả những người chưa mua bất cứ thứ gì
SELECT users.name, orders.order_date
FROM users
LEFT JOIN orders ON users.id = orders.user_id;
Hiện tại chúng tôi sử dụng kiểu này để phân tích tỷ lệ rời bỏ (churn analysis). Bằng cách lọc WHERE orders.id IS NULL, chúng tôi có thể ngay lập tức xác định những người dùng đã đăng ký nhưng chưa bao giờ hoàn tất thanh toán.
3. FULL OUTER JOIN: Bản đồ toàn diện
Tôi hiếm khi dùng RIGHT JOIN vì bạn có thể đạt được kết quả tương tự bằng cách đổi thứ tự bảng trong LEFT JOIN. Tuy nhiên, FULL OUTER JOIN là cần thiết khi bạn cần một bản đồ hoàn chỉnh của hai tập dữ liệu, bao gồm cả các dòng không khớp từ cả hai phía. Đây là một cách tuyệt vời để kiểm tra (audit) việc đồng bộ hóa dữ liệu giữa các dịch vụ khác nhau.
Lưu ý rằng MySQL không hỗ trợ FULL OUTER JOIN một cách trực tiếp. Bạn phải mô phỏng nó bằng cách sử dụng UNION giữa LEFT JOIN và RIGHT JOIN. Trong PostgreSQL, nó được hỗ trợ sẵn và hiệu quả hơn đáng kể.
-- PostgreSQL: Tìm tất cả người dùng và tất cả đơn hàng, liên kết ở những nơi có thể
SELECT users.name, orders.id
FROM users
FULL OUTER JOIN orders ON users.id = orders.user_id;
Các kỹ thuật tối ưu hóa thực sự hiệu quả
Sửa lại logic chỉ là một nửa chặng đường. Chúng tôi còn phải làm cho các truy vấn chạy nhanh hơn. Join các bảng có hàng triệu dòng rất tốn kém, nhưng ba chiến lược sau đây đã giúp giảm thời gian tải dashboard từ 10 giây xuống còn dưới 400ms.
Đánh Index cho các khóa ngoại (Foreign Keys)
Nghe có vẻ cơ bản, nhưng nhiều lập trình viên chỉ đánh index for khóa chính (primary key) mà quên mất khóa ngoại (foreign key). Nếu bạn đang Join orders.user_id với users.id, cả hai cột này bắt buộc phải có index. Nếu không, bộ máy thực thi sẽ thực hiện quét tuần tự (“Sequential Scan”). Điều này có nghĩa là nó sẽ đọc từng dòng một trong bảng orders cho mỗi người dùng trong bảng users.
Lọc dữ liệu trước khi Join
Đừng Join hai bảng khổng lồ rồi mới dùng mệnh đề WHERE. Bằng cách đưa các bộ lọc vào một Common Table Expression (CTE), bạn sẽ giảm kích thước tập dữ liệu trước khi phép Join bắt đầu. Điều này ngăn cơ sở dữ liệu lãng phí tài nguyên cho những dữ liệu mà bạn sắp loại bỏ.
-- Thường hiệu quả hơn: Lọc các đơn hàng trước
WITH completed_orders AS (
SELECT user_id, total
FROM orders
WHERE status = 'completed' -- Trạng thái: hoàn thành
AND created_at > NOW() - INTERVAL '30 days' -- Trong vòng 30 ngày qua
)
SELECT u.name, co.total
FROM users u
JOIN completed_orders co ON u.id = co.user_id;
Đọc kế hoạch thực thi (Execution Plan)
Bất cứ khi nào một truy vấn có vẻ chậm chạp, hãy chạy EXPLAIN ANALYZE. Hãy tìm kiếm các “Nested Loops” trên các bảng lớn; đây thường là dấu hiệu của việc thiếu index. Lý tưởng nhất là bạn muốn thấy “Hash Joins” cho các tập dữ liệu lớn, chưa sắp xếp hoặc “Merge Joins” nếu dữ liệu đã được đánh index và sắp xếp.
Lời kết
Hãy tạm quên các biểu đồ Venn đi. Làm chủ các phép Join thực chất là hiểu cách dữ liệu liên kết với nhau và cách bộ máy thực thi xử lý các mối quan hệ đó. Trong trường hợp của chúng tôi, chỉ đơn giản là chuyển sang các phép LEFT JOIN có index và sử dụng CTE để lọc trước dữ liệu đã cứu vãn môi trường Production. Nếu cơ sở dữ liệu của bạn đang gặp khó khăn, hãy bắt đầu với các index. Sau đó, hãy xem lại các kiểu Join của mình. Có khả năng bạn đang yêu cầu cơ sở dữ liệu cung cấp nhiều dữ liệu hơn mức bạn thực sự cần.

