Tối ưu hóa EF Core: Từ GroupBy Memory-side sang Server-side Aggregation
Trong các hệ thống phần mềm quản trị dữ liệu lớn, việc tổng hợp báo cáo tài chính hay tính toán tổng số dư là tác vụ diễn ra liên tục. Một trong những lỗi phổ biến nhất khiến hệ thống bị chậm hoặc tràn RAM (Out of Memory) là việc Entity Framework Core kéo toàn bộ dữ liệu thô về bộ nhớ rồi mới thực hiện GroupBy.
Vấn đề: Client Evaluation vs Server Evaluation
Hãy xem xét đoạn code LINQ tưởng chừng vô hại sau:
// CẢNH BÁO: Đoạn code này có thể gây thảm họa hiệu năng!
var summary = await _dbContext.Transactions
.Where(t => t.CompanyId == companyId && t.CreatedAt >= fromDate)
.ToListAsync(); // <-- Toàn bộ dữ liệu được tải vào RAM tại đây
var result = summary
.GroupBy(t => t.Category)
.Select(g => new CategoryReportDto
{
Category = g.Key,
TotalAmount = g.Sum(x => x.Amount),
Count = g.Count()
})
.ToList();
Khi gọi ToListAsync() trước khi GroupBy, SQL Server chỉ nhận lệnh SELECT * FROM Transactions WHERE .... Nếu có 500,000 dòng dữ liệu, EF Core sẽ phải:
- Tạo 500,000 instance
Transactiontrong bộ nhớ Heap. - Ép Garbage Collector (GC) hoạt động nặng nề.
- Chờ chuyển giao hàng trăm Megabyte qua đường truyền mạng mạng giữa App Server và Database Server.
Giải pháp: Đẩy GroupBy xuống Database Server
Để SQL Server thực hiện gom nhóm và chỉ trả về đúng các dòng kết quả tổng hợp, chúng ta phải giữ biểu thức ở dạng IQueryable cho tới tận bước cuối cùng:
// Tối ưu: Sinh ra câu lệnh SQL "GROUP BY Category" thuần túy
var result = await _dbContext.Transactions
.AsNoTracking()
.Where(t => t.CompanyId == companyId && t.CreatedAt >= fromDate)
.GroupBy(t => t.Category)
.Select(g => new CategoryReportDto
{
Category = g.Key,
TotalAmount = g.Sum(x => x.Amount),
Count = g.Count()
})
.ToListAsync(); // Chỉ tải các dòng tổng hợp về bộ nhớ
Câu lệnh SQL được sinh ra sẽ cực kỳ gọn nhẹ:
SELECT [t].[Category], SUM([t].[Amount]) AS [TotalAmount], COUNT(*) AS [Count]
FROM [Transactions] AS [t]
WHERE [t].[CompanyId] = @p0 AND [t].[CreatedAt] >= @p1
GROUP BY [t].[Category]
Một số lưu ý quan trọng
AsNoTracking(): Luôn thêmAsNoTracking()cho các truy vấn chỉ đọc (read-only query) để tắt Change Tracker, giảm 30-40% chi phí CPU và RAM.- Null check trong Sum(): Khi cột số có thể
nullable(ví dụdecimal?), cần chú ýg.Sum(x => x.Amount ?? 0)để tránh lỗi dịch LINQ trên các phiên bản EF Core cũ. - Index hỗ trợ: Đảm bảo bảng có Composite Index bao gồm
(CompanyId, CreatedAt) INCLUDE (Category, Amount)để câu truy vấn đạt Index Seek thay vì Table Scan.
Hy vọng mẹo nhỏ này giúp các bạn kiểm soát và tối ưu tốt hơn hiệu năng các câu truy vấn báo cáo trong dự án .NET!