Cache Kết Quả Truy Vấn: Mẹo Giảm Tải Database .NET

Chúng tôi từng phải cache kết quả truy vấn gấp trong đêm, vì dashboard của khách đơ liên tục lúc cao điểm. Lúc mở log lên soi, cả nhóm mới ngã ngửa. Một câu SQL y hệt nhau bị gọi lại gần hai trăm lần chỉ trong một phút. Database không sập vì thiếu tài nguyên. Nó sập vì bị hỏi đi hỏi lại một câu hỏi mà câu trả lời gần như không đổi. Bài này chúng tôi kể lại đúng thứ đã làm hôm đó, và cách áp cache cho đúng chỗ trong dự án .NET của bạn.

Vấn Đề Truy Vấn Database Lặp Lại Không Cần Thiết

Tình huống đêm hôm đó không hiếm. Rất nhiều ứng dụng .NET tụi mình từng đụng đều mắc đúng lỗi này. Mở lại một trang, gọi lại một API, database vẫn bị hỏi đúng một câu như cũ. Ví dụ dễ hình dung nhất là danh sách danh mục sản phẩm. Danh mục gần như không đổi trong một ngày, có khi cả tuần mới thêm một mục mới. Vậy mà mỗi lần người dùng mở trang chủ, ứng dụng lại chạy nguyên một câu SELECT xuống bảng Categories. Kèm theo đó là vài phép JOIN không nhẹ nhàng gì.

Chúng tôi hay gặp một biến thể khác của lỗi này khi dùng Entity Framework. Đây là thư viện giúp .NET giao tiếp với database, mà không cần viết tay từng câu SQL. Bật lazy loading, mỗi lần code đọc một thuộc tính liên quan, EF tự động bắn thêm một query riêng xuống database. Đọc danh sách 50 đơn hàng kèm thông tin khách, hệ thống có khi bắn ra 51 query thay vì một. Dân trong nghề gọi đây là N+1 query, và nó âm thầm ăn hiệu năng hơn nhiều người tưởng.

Tải không cần thiết này cộng dồn rất nhanh. Một request tốn 15 mili giây cho câu query có vẻ vô hại. Nhân với vài nghìn request mỗi phút vào giờ cao điểm, con số đó bắt đầu ăn hết CPU và kết nối của database server. Rồi các query khác cũng bị vạ lây, phải xếp hàng chờ. Kể cả những query thật sự cần dữ liệu tươi, ví dụ số dư tài khoản, cũng không ngoại lệ.

Vấn đề nặng nhất rơi vào đúng loại dữ liệu ít thay đổi nhưng bị hỏi liên tục. Ví dụ như cấu hình hệ thống, danh sách quyền, bảng giá, hay danh mục sản phẩm. Chúng tôi hay gọi vui đây là “dữ liệu tĩnh bị đối xử như dữ liệu động”. Ứng dụng không phân biệt được đâu là dữ liệu gần như bất biến, đâu là dữ liệu đổi theo từng giây. Kết quả là mọi thứ đều bị truy vấn lại từ đầu, dù chẳng cần thiết.

Chúng tôi từng ngồi review code cùng một bạn dev mới vào nghề. Cả hai phát hiện đúng kiểu lỗi này, nằm trong một vòng lặp foreach gọi database bên trong. Nếu đội của bạn có thói quen soi kỹ những chỗ như vậy, đó là một cách bắt lỗi sớm trước khi code lên production. Tham khảo thêm nguyên tắc review code của dân backend mà chúng tôi hay áp dụng, cũng đáng để thử.

Cache Kết Quả Truy Vấn Giúp Giảm Tải Database Ra Sao

Cache, nói dễ hiểu, là một chỗ lưu tạm kết quả đã tính ra. Lần sau cần, ứng dụng lấy lại ngay, không phải làm lại từ đầu. Trong .NET, chỗ lưu tạm đó thường là bộ nhớ ngay trên server, dùng qua IMemoryCache. Nếu bạn chạy nhiều server song song, cần một kho lưu trữ dùng chung như Redis, truy cập qua IDistributedCache. Ý tưởng chung chỉ có vậy: lần đầu tốn công truy vấn, những lần sau lấy từ bộ nhớ, khỏi hỏi lại database.

Hiệu quả nhìn thấy rõ nhất là tốc độ phản hồi. Một câu query phức tạp có thể mất 200-300 mili giây vì phải JOIN nhiều bảng. Đọc từ cache, thời gian đó rút xuống dưới 1 mili giây, vì dữ liệu đã nằm sẵn trong RAM. Người dùng cảm nhận ngay: trang tải nhanh hơn hẳn, không còn cảm giác giật khi bấm vào mục hay xem.

Cache còn cứu bạn ở một chỗ ít ai để ý: connection pool. Đây là nhóm kết nối tới database mà ứng dụng dùng chung. Mỗi kết nối đều có giới hạn, thường vài chục tới vài trăm tuỳ cấu hình server. Truy vấn lặp vô ích chiếm hết chỗ trong pool này. Những request khác phải xếp hàng chờ, dù bản thân chúng chẳng nặng nề gì. Giảm số lần gọi xuống database, gián tiếp bạn cũng đang giải phóng pool cho những việc thật sự cần.

Cái lợi thứ hai ít người để ý hơn: cache giúp hệ thống chịu tải tốt hơn khi lượng người dùng tăng đột biến. Một website bán hàng vào đợt sale cũng gặp đúng bài toán này — traffic dồn về cùng lúc. Nếu ngay từ khâu thiết kế website bán hàng đã tính trước bài toán cache, hệ thống sẽ nhẹ nhàng hơn nhiều. Database không bị dồn ép đến mức nghẽn hay treo giữa giờ cao điểm. Chúng tôi từng thấy một hệ thống nội bộ giảm gần 70% số lần gọi xuống database. Đổi lại chỉ nhờ thêm đúng một lớp cache cho API danh mục. Con số đó tự nó nói lên vấn đề nằm ở đâu.

Thủ Thuật Áp Dụng Cache Đúng Cách Trong Dự Án .NET

Không phải dữ liệu nào cũng nên cache. Đây là chỗ nhiều bạn mới học sai lầm nhất: thấy chậm là cache bừa. Kể cả dữ liệu đổi theo từng giây, như số dư ví điện tử hay trạng thái đơn hàng, cũng dễ bị cache nhầm. Cache loại dữ liệu này, bạn sẽ trả kết quả cũ cho người dùng đúng lúc họ cần số mới nhất. Chúng tôi có một quy tắc đơn giản: tự hỏi dữ liệu này đổi mỗi bao lâu. Nếu người dùng thấy bản cũ vài giây hay vài phút mà vẫn ổn, cứ cache thoải mái. Danh mục sản phẩm, cấu hình hệ thống, hay danh sách quyền đều thuộc nhóm này — cache được, không lăn tăn.

Thời gian hết hạn cache, hay TTL, là chỗ quyết định cache có hiệu quả hay phản tác dụng. TTL là viết tắt của time to live — tức khoảng thời gian dữ liệu được giữ trong bộ nhớ, trước khi bị xoá đi. Đặt TTL quá dài, dữ liệu cũ trong cache và dữ liệu thật trong database lệch nhau, người dùng thấy thông tin sai. Đặt TTL quá ngắn, cache gần như vô nghĩa vì hết hạn liên tục, database vẫn bị hỏi dồn dập như cũ. Với danh mục sản phẩm ít đổi, chúng tôi hay đặt sliding expiration khoảng 30-60 phút. Nói dễ hiểu, mỗi lần có người đọc, đồng hồ hết hạn sẽ tự reset lại từ đầu. Còn với dữ liệu gần thời điểm cập nhật, chúng tôi đặt absolute expiration ngắn hơn — chỉ vài phút, để tránh lệch quá lâu.

Có một tình huống nâng cao đáng nhắc tới: cache stampede. Đây là hiện tượng nhiều request cùng lúc phát hiện cache vừa hết hạn. Cả đám cùng dồn xuống database để tính lại kết quả. Với dữ liệu ít người hỏi, chuyện này không đáng lo. Nhưng với một API được gọi hàng nghìn lần mỗi giây, mọi chuyện khác hẳn. Cache hết hạn đúng lúc cao điểm có thể tạo ra một cú dồn tải bất ngờ. Cách xử lý phổ biến là dùng khoá tạm, hay còn gọi là lock, để chỉ một request được phép tính lại. Các request khác đợi, hoặc dùng tạm dữ liệu cũ trong lúc chờ.

Theo dõi hiệu quả cache cũng quan trọng không kém việc bật nó lên. Một cache đặt sai chỗ có khi làm hệ thống chậm hơn, chứ không nhanh hơn. Lý do thường là tỷ lệ trúng cache quá thấp. Dân kỹ thuật hay gọi con số này là cache hit rate. Đó là tỷ lệ số lần lấy được dữ liệu từ cache, thay vì phải hỏi lại database. Tỷ lệ này thấp nghĩa là cache gần như không được dùng, chỉ tốn thêm bước kiểm tra vô ích. Chúng tôi từng viết công cụ đo tốc độ phản hồi mỗi khi thêm tính năng mới vào hệ thống. Tinh thần cũng gần giống việc đo tốc độ trang khi nhúng thêm tính năng mới. Số liệu đo được luôn đáng tin hơn cảm giác “chắc là nhanh hơn rồi”.

  • Cache dữ liệu theo user nhưng dùng chung một key cho mọi người — kết quả là user A thấy dữ liệu của user B.
  • Quên xoá cache khi dữ liệu gốc vừa cập nhật, khiến hệ thống hiển thị thông tin cũ dai dẳng.
  • Cache cả những câu query hiếm khi được gọi lại, tốn bộ nhớ mà gần như không giúp giảm tải gì đáng kể.

Xử lý lỗi thứ hai — quên xoá cache — là chỗ tốn thời gian nhất trong thực tế. Việc này còn có tên riêng: cache invalidation. Cách chúng tôi hay làm là gắn việc xoá cache liền vào đúng hàm cập nhật dữ liệu. Không tách nó ra thành một job riêng chạy định kỳ. Một số đội còn tự động hoá luôn phần theo dõi, cảnh báo ngay khi cache hit rate tụt bất thường. Tụi mình từng thử tinh thần đó cho vài tác vụ giám sát nội bộ. Hướng đi là AI agent giúp tự động hoá quy trình cho dev. Không bắt buộc phải làm ngay từ đầu, nhưng nếu hệ thống đủ lớn, đây là bước đáng đầu tư.

Một Thói Quen Nhỏ Giúp Database Nhẹ Gánh Lâu Dài

Nhìn lại đêm hôm đó, thứ cứu hệ thống không phải một con server mạnh hơn. Mà là một buổi chiều ngồi rà lại đúng những câu query bị gọi lặp vô ích. Cache kết quả truy vấn nghe đơn giản. Nhưng làm đúng đòi hỏi bạn hiểu rõ dữ liệu nào đổi nhanh, dữ liệu nào gần như đứng yên. Bỏ qua bước hiểu dữ liệu, cache chỉ mang lại rắc rối mới, thay vì hiệu năng tốt hơn.

Nếu dự án của bạn đang có dấu hiệu chậm dần khi lượng người dùng tăng, đừng vội nâng cấp server. Hãy mở log lên, tìm những câu query lặp lại nhiều nhất trong một phút. Rồi thử cache đúng một bảng dữ liệu ít đổi nhất trước — thường chỉ vài dòng code. Hiệu quả thấy được ngay trong buổi chiều hôm đó. Muốn xem thêm những mẹo nhỏ tụi mình rút ra từ việc vá lỗi thực tế mỗi ngày? Ghé lại chuyên mục thủ thuật .NET hằng ngày trên site — còn khá nhiều câu chuyện tương tự đang chờ kể.