Cái bẫy tối ưu Index
Xin chào, mình là Tuấn.
Khi backend engineer gặp một query chậm, phản xạ đầu tiên thường là:
“Check WHERE với ORDER BY, rồi thêm composite index.”
Mình cũng từng nghĩ vậy.
Và thực tế, nhiều trường hợp cách đó hoạt động hoàn hảo.
Nhưng có một lần, một optimization tưởng chừng đúng lại biến thành sự cố production. Query đọc nhanh hẳn, EXPLAIN plan sạch đẹp, mọi thứ có vẻ hoàn hảo.
Thế mà từ từ, cả hệ thống production bắt đầu chậm dần.
- CPU database tăng vọt.
- Disk I/O tăng đột biến.
- API latency leo dần.
Mình mất một lúc mới nhận ra vấn đề thực sự:
Mình tối ưu đường đọc, nhưng hoàn toàn bỏ qua chi phí ghi.
Nếu bạn đang định chạy CREATE INDEX để cứu một API chậm, hãy dành vài phút đọc bài này trước.
Bài toán ban đầu
Một ngày, team product yêu cầu một tính năng đơn giản:
“Tạo API trả về top 20 sản phẩm hot nhất trong một danh mục.”
Về cơ bản là ranking trending real-time.
Nhìn qua thì giải pháp có vẻ đơn giản — chỉ cần sort sản phẩm theo score rồi trả top 20.
Bảng products lúc đó đã có khoảng 10 triệu dòng, traffic đã tới hàng nghìn request mỗi giây. Vì API này sẽ xuất hiện ở vị trí nổi bật trên sản phẩm, response chậm là không chấp nhận được.
Suy nghĩ của mình lúc đó rất đơn giản:
Thêm đúng index là xong.
Optimization “hoàn hảo”
Query trông như thế này:
SELECT
p.id,
p.name,
p.interest_score
FROM products p
WHERE p.status = 'ACTIVE'
AND p.stock_quantity > 0
AND p.category_id = 42
ORDER BY p.interest_score DESC
LIMIT 20;
Chạy trên bảng hàng triệu dòng sẽ gây full scan và sort, rõ ràng không scale được.
Nên mình áp dụng giải pháp kinh điển:
CREATE INDEX idx_products_category_status_score
ON products (category_id, status, interest_score DESC);
Kết quả nhìn tuyệt vời.
- Query nhanh vượt trội
- EXPLAIN plan sạch đẹp
- Response time giảm ngay lập tức
Từ góc nhìn query performance, mọi thứ có vẻ đã giải quyết xong. Lúc đó mình khá tự tin với cách fix này.
Tiếc là sự tự tin đó không kéo dài được lâu.
Khi production bắt đầu “lạ”
Vấn đề nằm ở thứ mình hoàn toàn bỏ qua.
interest_score không phải cột tĩnh.
Mỗi khi user tương tác với sản phẩm — xem chi tiết, like, thêm vào giỏ — score tăng lên. Kiểu này xảy ra liên tục:
UPDATE products
SET interest_score = interest_score + 1
WHERE id = ?;
Ban đầu, điều này có vẻ vô hại. Tăng một con số là một trong những thao tác phổ biến nhất trong bất kỳ hệ thống nào.
Nhưng khi interest_score trở thành một phần của index, cái update đơn giản đó không còn đơn giản nữa.
Hệ thống không sập — nó chết ngạt từ từ
Loại sự cố production tệ nhất là loại không nổ ra ồn ào.
Không có crash. Không có lỗi rõ ràng. Hệ thống chỉ chậm dần, chậm dần.
Theo thời gian mình quan sát thấy:
- API latency tăng dần
- CPU database tăng vọt
- Disk I/O tăng đột biến
- Một số request bắt đầu timeout
- Slow query log đầy các câu
UPDATE
Ban đầu mình đổ lỗi cho traffic tăng. Rốt cuộc, query SELECT đã được index và nhìn hoàn hảo mà.
Nhưng sau khi monitor hệ thống kỹ hơn, thủ phạm thực sự mới lộ ra — tải nặng đến từ việc update interest_score.
Vấn đề thực sự
Bản thân index không sai. Vấn đề thực sự nằm ở chi phí ghi ẩn.
Mỗi khi interest_score thay đổi, database không thể đơn giản update một con số tại chỗ. Vì cột này tham gia vào index dùng cho sorting, database phải duy trì cấu trúc index.
Nói một cách dễ hiểu:
Record phải được xoá khỏi vị trí cũ trong index rồi chèn lại vào vị trí mới.
Với vài update thì chuyện này không đáng kể. Nhưng khi hàng nghìn update mỗi giây đổ vào hệ thống, việc duy trì index đó trở nên cực kỳ tốn kém.
Nói cách khác: index tối ưu đọc, nhưng nó tăng chi phí ghi lên đáng kể.
Vấn đề Hotspot
Tương tác của user không phân bố đều. Sản phẩm hot nhận được nhiều click hơn hẳn so với sản phẩm khác.
Điều đó có nghĩa là nhiều update đập vào cùng một row liên tục, tạo ra contention bên trong database. Dù code nhìn vô hại:
UPDATE products
SET interest_score = interest_score + 1
WHERE id = ?;
Bên dưới, database đang xử lý concurrent update nặng nề vào cùng vùng data và index page. Hệ thống thực chất đang tự đánh nhau với chính nó.
Đó là lúc mình nhận ra điều quan trọng:
Một index giúp query nhanh hơn không có nghĩa là hệ thống khoẻ hơn.
Và nhìn lại, sai lầm thiết kế thực sự là cố ép bảng transactional chính xử lý luôn ranking real-time.
Quyết định khó: Xoá index
Xoá index lúc đầu cảm thấy sai. Rốt cuộc, nó đã cải thiện query performance đáng kể mà.
Nhưng metrics nói rõ ràng. Chừng nào index còn tồn tại, write contention vẫn còn.
Nên mình xoá nó.
Kết quả thấy ngay:
- Áp lực ghi giảm đáng kể
- Disk I/O ổn định
- CPU database trở về mức bình thường
Query ranking chậm lại, nhưng ít nhất toàn bộ hệ thống không còn bị kéo xuống bởi một cột update nữa.
Khoảnh khắc đó dạy mình một bài học quan trọng:
Có những vấn đề nhìn như bài toán tối ưu SQL nhưng thực ra là bài toán kiến trúc.
Hướng đi khác: Thiết kế lại kiến trúc
Thay vì ép database xử lý cả dữ liệu persistent lẫn ranking real-time, mình tách trách nhiệm ra.
Score update được chuyển sang Redis Sorted Sets.
Khi user tương tác, mình tăng score trong Redis:
ZINCRBY trending:cat:42 1 12345
Flow mới trở nên đơn giản:
- User action → update score trong Redis
- Khi cần ranking → lấy top ID từ Redis
- Lấy chi tiết sản phẩm từ database bằng
id IN (...)
Cách này cho phép mỗi hệ thống tập trung vào thế mạnh của nó.
Tất nhiên, thiết kế này cũng có trade-off. Redis có thể trả về sản phẩm hết hàng hoặc inactive — nên mình phải lấy nhiều kết quả hơn rồi filter trong database. Mình cũng chấp nhận eventual consistency thay vì đồng bộ real-time hoàn hảo.
Nhưng nhìn chung, hệ thống trở nên ổn định và scale tốt hơn hẳn.
Bài học rút ra
Từ sau sự cố đó, mình tiếp cận slow query khác hẳn.
Trước khi thêm index, mình tự hỏi:
- Cột này có bị update thường xuyên không?
- Index này sẽ tạo bao nhiêu overhead cho write?
- Mình đang tối ưu một query, hay tối ưu toàn bộ workload?
Với những giá trị biến động cao như ranking score, like count, view count — mình tránh update trực tiếp trên bảng transactional chính. Phần lớn trường hợp, bottleneck thực sự không phải SQL syntax, mà là chọn đúng hệ thống cho workload.
Cái composite index kia không sai về mặt kỹ thuật. Nhưng trong bối cảnh traffic production, nó là quyết định sai.
Và bây giờ, mình ít quan tâm query có nhanh hơn không. Mình quan tâm câu hỏi này hơn:
Thay đổi này có thực sự làm toàn bộ hệ thống khoẻ hơn không?
Vì trong hệ thống production, đúng sai không được định nghĩa bởi tốc độ một query. Mà bởi cách toàn bộ hệ thống hoạt động dưới traffic thực.