# Thực hành: tìm N+1, over-fetching và database quá tải trong code của khách hàng

> Gộp 45 câu SELECT thành một truy vấn đã kéo độ trễ từ gần một phút xuống 5-6 giây, nhưng nếu sửa quá tay, bạn sẽ rơi ngay vào antipattern tiếp theo.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-chan-doan-antipattern-hieu-nang/

Trong một bài chẩn đoán mẫu của Microsoft, method `GetProductsInSubCategoryAsync` chạy 45 câu SELECT cho mỗi lần gọi, và câu nào cũng mở một kết nối SQL mới. Kết quả trả về vẫn đúng, nên test chức năng khó mà phát hiện được lỗi này. Nhưng khi có 1.000 người dùng cùng lúc, độ trễ lên tới gần một phút.

Thử hình dung bạn là FDE được mời đến để đưa một agent vào chạy trên hệ thống sẵn có của khách. Nếu hệ thống đó có những endpoint như trên thì agent của bạn sẽ chậm theo, và khách sẽ cho rằng sản phẩm của bạn chậm. Vì thế, biết cách tìm ra chỗ nghẽn trong code của khách là một kỹ năng đáng để luyện từ bây giờ.

Ba antipattern dưới đây nên được rà cùng một lượt: chatty I/O, extraneous fetching và busy database. Lý do là sửa lỗi này rất dễ đẩy hệ thống sang lỗi kia. Ví dụ code dùng C# với Entity Framework và đã được đơn giản hóa cho dễ đọc, còn ý tưởng thì áp dụng được cho mọi ORM.

## Bạn cần chuẩn bị gì trước khi mở code?

Bạn cần ba thứ: quyền đọc source code, một công cụ tracing hoặc APM cho thấy câu SQL mà ORM thực sự sinh ra, và số liệu theo từng request chứ không chỉ số tổng. Nếu khách chưa bật tracing thì hãy xin bật trước. Nhìn code mà không có trace thì bạn chỉ đang đoán.

## Bước 1: Đọc trace chậm nhất, đừng đọc trung bình

Hướng dẫn của Microsoft nhắc một điều nghe thì hiển nhiên: nếu chỉ nhìn giá trị trung bình, bạn có thể bỏ sót những vấn đề sẽ tệ đi rất nhiều khi tải tăng. Vì thế hãy sắp xếp trace theo thời gian xử lý giảm dần và mở vài request ở đầu danh sách.

Với mỗi trace, bạn cần trả lời một câu hỏi: request này gửi bao nhiêu câu lệnh đến cùng một data store? Dấu hiệu của chatty I/O là một instance ứng dụng gửi rất nhiều request nhỏ đến cùng một data store.

Mỗi lần I/O đều tốn một khoản chi phí riêng, và khi các khoản này cộng dồn lại thì cả hệ thống chậm đi.

**Kiểm tra:** bạn đã ghi lại số query của từng endpoint chậm. Nếu một màn hình danh sách cần tới 45 query thì đó là chỗ phải đào tiếp.

## Bước 2: Tìm N+1 mà ORM đang che

Một thủ phạm quen thuộc là N+1. Blog AppSignal định nghĩa đây là trường hợp một câu query được chạy lại cho từng kết quả của câu query trước đó. ORM có thể che mất lỗi này vì nó âm thầm lấy từng bản ghi con một, nên trong code bạn chỉ thấy một vòng `foreach` trông rất vô hại:

```csharp
// Minh họa, đơn giản hóa: mỗi lần truy cập Items có thể sinh thêm một query
var orders = context.Orders.ToList();      // 1 query
foreach (var o in orders)
{
Render(o.Items);                        // +1 query cho mỗi order
}
```

Với 44 order, đoạn code này chạy 45 câu query. Tài liệu của Ebean ORM mô tả đúng hiện tượng đó: muốn nạp một object graph thì phải chạy N + 1 câu SQL. Nếu số query bạn đếm được ở bước 1 tăng theo số dòng dữ liệu, gần như chắc chắn bạn đang gặp N+1.

**Kiểm tra:** trong trace có nhiều câu SELECT giống hệt nhau, chỉ khác giá trị khóa ngoại.

## Bước 3: Gộp lại bằng eager loading

Cách sửa chuẩn là eager loading: số query giữ nguyên dù số dòng tăng lên. Trong Entity Framework, bạn dùng `Include` để lấy bảng con trong cùng một lần truy vấn:

```csharp
// Minh họa: order và items được lấy trong một lần truy vấn
var orders = context.Orders
.Include(o => o.Items)
.ToList();
```

Một số ORM có sẵn cách khác. Ebean, chẳng hạn, cho phép lazy loading theo lô và chỉ định trước những quan hệ cần nạp sẵn. Vì vậy, trước khi tự viết lại query bằng tay, hãy xem ORM của khách đã hỗ trợ những gì.

Bạn nên đo lại sau khi sửa, vì con số có thể thay đổi rất nhiều. Trong ví dụ của Microsoft, sau khi gộp thành một truy vấn, độ trễ ở mức 1.000 người dùng giảm từ gần một phút xuống còn 5-6 giây, còn throughput tăng từ 410 lên 3.970 request mỗi phút.

**Kiểm tra:** chạy lại cùng kịch bản tải và so số query trên mỗi request. Con số này phải giữ nguyên khi bạn tăng số order trong dữ liệu test.

## Ca mẫu: từ một trace đến bảng trước và sau

Thử hình dung khách than màn hình "Đơn hàng gần đây" chậm vào giờ cao điểm. Bạn mở request chậm nhất và đếm được 201 câu SELECT: một câu lấy 200 order, 200 câu lấy items, mỗi câu chỉ khác `OrderId`.

Bạn mở thêm một request của khách hàng chỉ có 20 order và đếm được 21 câu. Số query tăng đúng theo số dòng, nên chẩn đoán gần như chắc chắn là N+1 chứ không phải database yếu.

Sau khi thêm `Include` và chạy lại cùng kịch bản tải, cả hai request chỉ còn một truy vấn. Bạn ghi một bảng gồm endpoint, số query mỗi request và độ trễ của request chậm nhất, trước và sau. Cột độ trễ phải là số đo thật trên hệ thống của khách, không phải ước đoán.

## Bước 4: Đừng sửa quá tay thành extraneous fetching

Đến đây bạn nên chậm lại một chút. Extraneous fetching, tức lấy về nhiều dữ liệu hơn mức cần, thường sinh ra chính từ việc sửa chatty I/O quá đà. Khi đã gộp được query, người ta dễ có xu hướng lấy luôn mọi thứ cho chắc ăn.

Trong code review, có hai dấu hiệu bạn tìm được chỉ bằng một lệnh đơn giản:

```bash
grep -rn "AsEnumerable" src/
grep -rni "select \*" src/
```

Mỗi chỗ gọi `AsEnumerable` là một gợi ý rằng code có vấn đề: mọi thao tác phía sau nó đều chạy trên client, sau khi toàn bộ dữ liệu đã bị kéo về. Câu `SELECT *` và các lệnh lấy entity không có điều kiện lọc cũng thuộc nhóm này:

```csharp
// Minh họa: kéo toàn bộ Sales về app rồi mới lọc và cộng
var total = context.Sales.AsEnumerable()
.Where(s => s.Year == 2024)
.Sum(s => s.Amount);
```

Khi bỏ `AsEnumerable`, phần lọc và phần cộng sẽ được dịch thành SQL và chạy trong database. Để xác nhận bằng số liệu thay vì cảm giác, hãy theo dõi tỷ lệ giữa lượng dữ liệu database trả ra và lượng dữ liệu ứng dụng trả cho client. Nếu hai con số chênh nhau nhiều, ứng dụng đang lấy thừa.

Hiệu quả có thể rất lớn. Trong ví dụ mẫu của Microsoft, khi chuyển một phép tổng hợp từ client vào database, dữ liệu mỗi transaction giảm từ hơn 280 KB xuống 53 byte. Throughput ổn định tối đa tăng từ khoảng 2.000 lên hơn 25.000 request mỗi phút.

## Bước 5: Khi database bận chạy code thay vì trả dữ liệu

Lỗi thứ ba đi theo chiều ngược lại. Busy database xảy ra khi stored procedure và trigger khiến database server dành phần lớn thời gian để chạy code, thay vì lưu và trả dữ liệu. Nguyên nhân thường là database bị dùng như một service để định dạng dữ liệu, xử lý chuỗi hay chạy business logic, chứ không chỉ là nơi lưu trữ.

Bạn có thể nhận ra lỗi này bằng cách so hai chỉ số: mức xử lý của database và lưu lượng dữ liệu ra vào. Nếu database xử lý rất nhiều mà dữ liệu đi ra lại rất ít, hãy đọc source code để xem phần xử lý đó có nên đặt ở tầng khác không.

Trong cùng bộ ví dụ đó, một truy vấn tạo XML ngay trong database đẩy CPU/DTU lên 100% rất nhanh. Đoạn code dưới đây là bản phác họa đơn giản hóa của kiểu sửa này, không phải code gốc:

```sql
-- Trước (minh họa): database tự ghép chuỗi XML cho từng dòng
SELECT ''
+ '' + CAST(Total AS varchar(20)) + ''
FROM Orders WHERE CustomerId = @customerId;
```

```csharp
// Sau (minh họa): database chỉ trả cột thô, ứng dụng lo phần định dạng
var rows = context.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new { o.Id, o.Total })
.ToList();
var xml = BuildOrderXml(rows);   // hàm định dạng chạy ở tầng ứng dụng
```

Sau khi chuyển phần định dạng sang ứng dụng, throughput tăng từ 12 lên hơn 400 request mỗi giây.

**Điểm mấu chốt:** Database nên làm việc tổng hợp dữ liệu. Phần định dạng và trình bày thì để ứng dụng lo.

Bước 4 và bước 5 kéo theo hai hướng ngược nhau: bước 4 đẩy việc vào database, bước 5 kéo việc ra khỏi database. Nhiều hệ database được tối ưu cho những việc như tính giá trị tổng hợp trên tập dữ liệu lớn, nên đừng chuyển những việc đó ra ngoài.

Vì vậy, hãy chuyển phần định dạng, ghép chuỗi và business logic sang ứng dụng, còn SUM, COUNT, GROUP BY thì giữ lại trong database.

## Những lỗi hay gặp khi làm ở site khách

Lỗi đầu tiên là sửa trước rồi mới đo. Nếu không có số query và throughput trước khi sửa, bạn không chứng minh được gì với khách. Bạn cũng không biết mình vừa sửa đúng hay vừa đẩy hệ thống sang một antipattern khác.

Lỗi thứ hai là chỉ chạy thử với dữ liệu nhỏ. N+1 với 3 dòng thì gần như không thấy gì, nó chỉ lộ ra khi có hàng nghìn dòng.

Lỗi thứ ba nằm ở cách làm việc với khách nhiều hơn là ở kỹ thuật. Stored procedure của khách có thể do một đội khác viết, và đội đó có lý do riêng. Hãy mang số liệu đến, ví dụ "CPU database ở 100% nhưng dữ liệu trả ra chỉ vài byte", rồi đề xuất chuyển phần định dạng ra ngoài thay vì đề xuất bỏ hết stored procedure.

## Kỹ năng này thể hiện thế nào trong CV và khi phỏng vấn?

Khi đọc job description FDE, đừng chỉ tìm chữ "N+1". Hãy để ý những yêu cầu về debug hệ thống production hay xử lý sự cố hiệu năng trên hệ thống của khách, vì kỹ năng trong bài này thuộc đúng nhóm đó.

Trong CV, hãy viết mỗi lần xử lý theo một mẫu cố định: bạn thấy dấu hiệu gì trong trace, nguyên nhân là gì, đã sửa thế nào, và chỉ số trước sau ra sao. Chỉ số có thể là số query mỗi request hoặc số byte mỗi transaction.

Nếu chưa có dự án thật, bạn có thể dựng một API nhỏ có quan hệ cha con. Cố ý viết N+1, đo, sửa bằng eager loading, rồi cố ý kéo thừa dữ liệu bằng `AsEnumerable` và đo lại. Một repo có đủ cả ba lần đo sẽ thuyết phục hơn nhiều so với một dòng "có kinh nghiệm tối ưu hiệu năng".

Ở site khách, ít ai nhớ bạn đã viết lại bao nhiêu dòng code. Người ta sẽ nhớ endpoint từng phải chờ gần một phút giờ chỉ còn vài giây, và bạn có trace để chứng minh.

**Thử ngay tuần này:**

- Mở trace của 5 request chậm nhất trong một dự án bạn đang làm và đếm số câu SQL trong mỗi request.
- Chạy grep -rn "AsEnumerable" và grep -rni "select \*" trên codebase, rồi đọc kỹ từng chỗ tìm được.
- Viết một mục trong CV theo mẫu: chẩn đoán gì, sửa gì, chỉ số trước và sau khi sửa.

## Nguồn

- [Chatty I/O antipattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/antipatterns/chatty-io/)

- [Extraneous Fetching antipattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/antipatterns/extraneous-fetching/)

- [Busy Database antipattern - Azure Architecture Center | Microsoft Learn](https://learn.microsoft.com/en-us/azure/architecture/antipatterns/busy-database/)

- [N+1 Queries Explained (AppSignal Blog)](https://blog.appsignal.com/2020/06/09/n-plus-one-queries-explained)

- [N+1 - Ebean ORM docs](https://ebean.io/docs/query/background/nplus1)
