Chúng tôi gặp chuyện này khá thường xuyên trong các dự án .NET. Ứng dụng chạy êm trên máy local, chức năng nào cũng ổn, nhưng vừa đưa lên production thì thời gian phản hồi tăng vọt. Ai cũng nghi cấu hình server trước tiên. Khi lần ngược lại, gốc rễ thường nằm ở cách code xử lý dữ liệu và tải.
Bài này tổng hợp những gì đội ngũ rút ra sau nhiều lần đi qua đúng vấn đề đó. Mục tiêu của chúng tôi là biến việc tối ưu hiệu năng ứng dụng .NET thành một công việc có kế hoạch, thay vì chữa cháy mỗi khi có sự cố. Bạn sẽ thấy phần lớn điểm nghẽn không nằm ở những thứ quá phức tạp, mà ở những thói quen rất nhỏ được lặp đi lặp lại.
Vì sao ứng dụng chạy tốt ở local nhưng chậm khi lên production
Môi trường local gần như không bao giờ tái hiện đúng điều kiện thật. Số người dùng đồng thời thấp, dữ liệu test chỉ vài trăm dòng. Production thì khác hẳn, với hàng triệu bản ghi và hàng nghìn request mỗi phút. Một đoạn code bạn bấm thử vài lần thấy vô hại bỗng trở thành điểm nghẽn khi phải gánh tải thật.
Lỗi thứ hai cũng rất hay gặp là release mà không đo gì cả. Có một dự án của đội chúng tôi chỉ kiểm tra tính đúng đắn của chức năng, không đo thời gian phản hồi hay bộ nhớ. Sau khi ra mắt, trang danh sách đơn hàng mất gần 8 giây mới tải xong. Nếu có một bước đo trước khi deploy, chúng tôi đã thấy vấn đề ngay từ đầu và tiết kiệm được cả một tuần hỗn loạn.
Các điểm nghẽn hiệu năng phổ biến trong .NET
Truy vấn database không tối ưu và N+1 query là lỗi kinh điển nhất. Khi duyệt một danh sách, với mỗi phần tử lại gọi thêm một truy vấn để lấy dữ liệu liên quan, tổng số câu lệnh gửi xuống database sẽ bằng số phần tử cộng một. Với 20 bản ghi thì chẳng sao. Với 20.000 bản ghi trên production, database quá tải rõ rệt.
Cách xử lý thường gặp là dùng Include trong Entity Framework để nạp sẵn dữ liệu liên quan, hoặc gộp truy vấn bằng LINQ để lấy toàn bộ dữ liệu cần thiết trong một lần. Điều quan trọng là phải nhìn vào câu SQL thực tế được sinh ra, vì nhiều khi code trông rất gọn nhưng lại kéo theo hàng chục truy vấn ngầm.
Cấu hình caching và garbage collection cũng hay bị bỏ qua. Nếu dữ liệu ít thay đổi mà vẫn truy vấn lại ở mỗi request, bạn đang lãng phí tài nguyên vô ích. Ngược lại, cache quá nhiều đối tượng lớn làm bộ nhớ phình ra và kích hoạt GC thường xuyên hơn. Theo kinh nghiệm của chúng tôi, nên trả lời rõ ba câu hỏi: dữ liệu nào đáng cache, sống bao lâu, và khi nào cần làm mới. Thiếu một trong ba, cache dễ trở thành nguồn bug khó tìm hơn chính điểm nghẽn ban đầu.
Nếu bạn muốn thiết kế tầng dữ liệu đủ nhẹ ngay từ đầu, hãy đọc thêm những sai lầm thường gặp khi dùng Entity Framework. Nhiều lỗi trong đó chính là nguyên nhân gây ra N+1 query mà chúng tôi vừa nhắc tới.
Xử lý bất đồng bộ sai cách làm nghẽn luồng
Async/await giúp giải phóng luồng trong lúc chờ I/O, nhưng dùng sai thì còn tệ hơn không dùng. Lỗi phổ biến nhất là gọi .Result hoặc .Wait() trên một Task, khiến luồng bị chặn ngay tại chỗ. Khi số request đồng thời tăng, thread pool cạn dần và cả ứng dụng chậm theo dây chuyền.
Triệu chứng điển hình là CPU không cao nhưng throughput thấp. Đây là dấu hiệu rất dễ bị nhầm lẫn với lỗi hạ tầng, nên khi gặp tình huống này, bạn nên kiểm tra mã bất đồng bộ trước khi nghĩ đến việc nâng cấp server.
Một điểm khác là quên async xuyên suốt. Action đã là async, nhưng bên trong lại gọi một phương thức đồng bộ có I/O thì lợi ích coi như mất. Hãy kiểm tra toàn bộ đường đi của request, từ controller xuống repository, và đảm bảo mọi chỗ có I/O đều được await khi có thể.
- Tránh .Result và .Wait() trong mã xử lý request
- Đảm bảo I/O (database, HTTP, file) đều được await
- Dùng ConfigureAwait(false) ở tầng thư viện khi phù hợp
Công cụ và quy trình đo lường hiệu năng
Profiler là thứ không thể thiếu. Visual Studio có sẵn Performance Profiler, còn dotTrace hay PerfView cho phép bạn thấy chính xác hàm nào ngốn thời gian và bộ nhớ. Quan trọng là dùng chúng ngay trong giai đoạn phát triển, không đợi đến khi sự cố đã xảy ra. Phát hiện sớm luôn rẻ hơn nhiều so với sửa khi người dùng đã bắt đầu phàn nàn.
Benchmark trước và sau khi tối ưu giúp biết thay đổi có thật sự hiệu quả hay không. BenchmarkDotNet khá tiện vì xử lý tốt các yếu tố nhiễu như warm-up và số vòng lặp. Đừng tối ưu theo cảm tính. Chúng tôi từng thấy một đồng nghiệp sửa một hàm tiện ích chạy vài micro giây, bỏ qua truy vấn database đang ngốn gần hết thời gian request, rồi kết luận rằng tối ưu không có tác dụng. Số liệu đo được là thứ duy nhất giúp tránh kiểu nhận định sai như vậy.
Cuối cùng là cảnh báo tự động. Khi thời gian phản hồi p95 vượt ngưỡng hoặc bộ nhớ tăng bất thường, hệ thống giám sát nên báo ngay cho đội dev. Nhờ vậy bạn biết vấn đề khi nó vừa xuất hiện, thay vì chờ khách hàng phản ánh rồi mới lần mò lại từ đầu.
Kết luận: tối ưu hiệu năng là quá trình liên tục
Hiệu năng không phải việc làm một lần trước khi release. Mỗi lần thêm tính năng hay đổi cấu trúc dữ liệu đều có thể đưa vào một điểm nghẽn mới. Hãy xem việc đo lường và rà soát là một phần của quy trình hằng ngày, không phải dự án riêng chỉ làm khi có sự cố.
Chúng tôi khuyến khích cả đội cùng hiểu về hiệu năng, đừng để riêng một hai người phụ trách. Khi mọi người đều biết tránh N+1 query, dùng async đúng cách và đo trước khi merge, chất lượng ứng dụng sẽ ổn định hơn nhiều. Bạn cũng có thể tham khảo các lưu ý về debug và tối ưu mã cho dev .NET để có thêm mẹo thực hành, và những kinh nghiệm thực tế về LINQ trong C# và .NET nếu bạn đang làm việc nhiều với truy vấn dữ liệu.
Muốn đi sâu hơn vào cách tối ưu hiệu năng ứng dụng .NET theo hướng thực chiến, bạn có thể bắt đầu từ việc đo đạc hiện trạng của chính dự án đang làm. Chỉ khi có số liệu cụ thể, các quyết định tối ưu mới thật sự có cơ sở. Đội ngũ chúng tôi vẫn áp dụng đúng thói quen này trong các dự án hiện tại.


