
Câu chuyện về HttpClient và Giải pháp tối ưu IHttpClientFactory
February 18, 2025Khi mình cải thiện hiệu suất ứng dụng, mình nhận ra rằng việc sử dụng socket hiệu quả hơn và khá quan trọng. Trong hệ thống microservices, giao tiếp giữa các dịch vụ trở nên phức tạp hơn khi số lượng service tăng lên hoặc khi hệ thống monolith được phân tách.
Một trong những phương pháp giao tiếp phổ biến nhất là sử dụng giao thức HTTP. Nếu bạn phát triển microservices bằng C# hoặc bất kỳ ngôn ngữ .NET nào, rất có thể bạn đã sử dụng HttpClient giống như mình.
Dưới đây là bài viết đưa ra các kỹ thuật từ cơ bản đến nâng cao cũng như cách sử dụng HttpClient sai cách trước đây của mình đi kèm với cách giải quyết khi gặp phải.
Khi làm việc với các ứng dụng .NET Core, việc thực hiện các HTTP request là một nhiệm vụ rất phổ biến như các trường hợp tích hợp api với bên thứ 3 hay giao tiếp các service với nhau. Thông thường, chúng ta sẽ sử dụng HttpClient để gửi và nhận dữ liệu từ các API.
1. Tạo mới HttpClient cho mỗi request
Đây là cách mình (và nhiều người khác) thường sử dụng HttpClient là tạo mới một đối tượng HttpClient cho mỗi lần gọi HTTP request, ví dụ:
using (var client = new HttpClient())
{
var response = await client.GetAsync("<https://ntechdevelopers.com>");
// Xử lý response...
}
Câu lệnh using là một cách tiếp cận phổ biến trong C# để làm việc với các đối tượng cần được giải phóng tài nguyên khi không còn sử dụng. Khi block using kết thúc, phương thức Dispose của HttpClient sẽ được gọi để giải phóng tài nguyên.
Đây là một thói quen tốt và được khuyến khích bởi tài liệu chính thức. Trên thực tế, hầu hết các tài liệu và ví dụ về HttpClient (kể cả những tài liệu mới) đều cho rằng nên sử dụng using.
Tuy nhiên, HttpClient không giống các đối tượng thông thường. Mặc dù HttpClient triển khai giao diện IDisposable, nó thực chất là một đối tượng dùng chung (shared object), reentrant và thread-safe.
Do đó, thay vì tạo mới một instance HttpClient mỗi lần sử dụng, chúng ta nên sử dụng một HttpClient instance duy nhất trong toàn bộ vòng đời ứng dụng.
Cách làm này dẫn đến vấn đề socket exhaustion (cạn kiệt socket). Nguyên nhân là khi HttpClient bị dispose, các socket không được giải phóng ngay lập tức mà rơi vào trạng thái TIME_WAIT. Khi số lượng kết nối tăng lên, hệ thống có thể không còn socket để sử dụng, dẫn đến lỗi.

Hãy xem một ví dụ nhỏ minh họa cách sử dụng HttpClient không đúng cách:
using System;
using System.Net.Http;
namespace ConsoleApplication
{
public class Program
{
public static async Task Main(string[] args)
{
Console.WriteLine("Starting connections");
for (int i = 0; i < 10; i++)
{
using (var client = new HttpClient())
{
var result = await client.GetAsync("<https://ntechdevelopers.com>");
Console.WriteLine(result.StatusCode);
}
}
Console.WriteLine("Connections done");
}
}
}
Đoạn mã này sẽ gửi 10 yêu cầu HTTP đến trang web ntechdevelopers.com và in ra mã trạng thái (Status Code). Mọi thứ có vẻ hoạt động ổn định.
Nhưng vấn đề ẩn giấu khi chúng ta thử sử dụng công cụ netstat để kiểm tra trạng thái socket sau khi chạy chương trình, sẽ thấy nhiều kết nối vẫn ở trạng thái TIME_WAIT.
TCP 10.211.55.6:12050 waws-prod-bay-017:http TIME_WAIT
TCP 10.211.55.6:12051 waws-prod-bay-017:http TIME_WAIT
...
Trạng thái TIME_WAIT cho biết kết nối đã bị đóng ở phía của chúng ta nhưng vẫn chờ thêm các gói dữ liệu đến. Theo mặc định trên Windows, trạng thái này sẽ được giữ trong 240 giây.
Điều này dẫn đến:
- Tiêu tốn tài nguyên hệ thống.
- Giới hạn số lượng kết nối mới do tình trạng socket exhaustion.
Lỗi như sau có thể xảy ra:
System.Net.Sockets.SocketException: Only one usage of each socket address
(protocol/network address/port) is normally permitted.
Để khắc phục vấn đề này chúng ta nên sử dụng một instance HttpClient duy nhất được chia sẻ trong toàn bộ ứng dụng, đó là cách số 2 bên dưới.
2. Sử dụng HttpClient dưới dạng static hoặc singleton
Một giải pháp cải thiện là khai báo HttpClient dưới dạng static hoặc singleton để tái sử dụng đối tượng:
public static readonly HttpClient Client = new HttpClient();
Quay lại vì dụ trên thì chúng ta xử lý như sau:
using System;
using System.Net.Http;
namespace ConsoleApplication
{
public class Program
{
private static HttpClient Client = new HttpClient();
public static async Task Main(string[] args)
{
Console.WriteLine("Starting connections");
for (int i = 0; i < 10; i++)
{
var result = await Client.GetAsync("<https://ntechdevelopers.com>");
Console.WriteLine(result.StatusCode);
}
Console.WriteLine("Connections done");
Console.ReadLine();
}
}
}
Việc này giải quyết được vấn đề socket exhaustion vì HttpClient sẽ duy trì các kết nối và tái sử dụng chúng. Bên cạnh đó kỹ thuật này còn có những lợi ích sau:
- Tái sử dụng socket: Sử dụng HttpClient duy nhất sẽ giảm đáng kể trạng thái TIME_WAIT trên socket.
- Hiệu suất cải thiện: Tận dụng kết nối được tái sử dụng (connection reuse) thay vì mở mới kết nối cho mỗi request.
- Giảm tiêu tốn tài nguyên: Giải quyết vấn đề socket exhaustion trong các ứng dụng có tải lớn.
Đoạn mã trên cho ra kết quả từ môi trường production mà mình theo dõi được:
- Trước khi sửa: 4000 socket trung bình, và đỉnh điểm vượt quá 5000 socket.
- Sau khi sửa: Giảm xuống dưới 400 socket, thường chỉ khoảng 100 socket.
Tuy nhiên, giải pháp này có một vấn đề lớn khác, đó là DNS caching.
- Thông tin DNS (địa chỉ IP của URI) được lưu trong HttpClient suốt vòng đời của nó. Nếu địa chỉ IP của URI thay đổi, HttpClient không nhận ra và không thể gửi request đến địa chỉ mới. Khi này chắc chắn bạn giao tiếp được với service đích được nữa.
- Nguồn gốc vấn đề thực ra, vấn đề không phải ở bản thân HttpClient, mà nằm ở HttpMessageHandler, một lớp quản lý luồng kết nối (connection pool) bên trong HttpClient.
Vậy nên, dùng kỹ thuật này chỉ khi:
- Ứng dụng của bạn có vòng đời ngắn (ví dụ: các ứng dụng chạy batch).
- API mà bạn kết nối không thay đổi địa chỉ IP thường xuyên.
Để giải quyết các vấn đề DNS Caching này, từ .NET Core 2.1, Microsoft đã giới thiệu HttpClientFactory nhằm quản lý HttpMessageHandler hiệu quả hơn. Đó chính là kỹ thuật số 3 bên dưới.
3. Giải pháp nâng cao với IHttpClientFactory
Từ .NET Core 2.1, Microsoft giới thiệu IHttpClientFactory để giải quyết các vấn đề trên một cách triệt để. Đây là giải pháp tối ưu được khuyến nghị sử dụng trong mọi ứng dụng .NET Core hiện đại.

Lợi ích của IHttpClientFactory
- Quản lý connection pooling hiệu quả: IHttpClientFactory quản lý HttpMessageHandler (lớp chịu trách nhiệm tạo kết nối HTTP) theo cơ chế pooling. Điều này giúp giảm chi phí tạo socket mới và tái sử dụng kết nối hiệu quả.
- Tự động xử lý DNS updates: Khi DNS thay đổi, IHttpClientFactory sẽ tự động tạo mới các kết nối, đảm bảo ứng dụng luôn sử dụng địa chỉ IP mới nhất.
- Dễ dàng cấu hình và tùy chỉnh: Bạn có thể cấu hình các client khác nhau để phục vụ các mục đích khác nhau.
- Tích hợp tốt với Dependency Injection (DI): IHttpClientFactory tích hợp hoàn hảo với DI, giúp việc quản lý HttpClient trở nên dễ dàng.
- Hỗ trợ testing thông qua mocking: Cho phép thay thế HttpClient bằng các mock client trong quá trình viết unit test.
Cách sử dụng IHttpClientFactory
1. Sử dụng cơ bản
Đăng ký IHttpClientFactory trong Startup.cs:
services.AddHttpClient();
Sử dụng IHttpClientFactory để tạo client:
public class MyService
{
private readonly IHttpClientFactory _httpClientFactory;
public MyService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task CallApiAsync()
{
var client = _httpClientFactory.CreateClient();
var response = await client.GetAsync("<https://ntechdevelopers.com>");
// Xử lý response...
}
}
2. Named Client
Khi bạn cần cấu hình riêng cho từng API, có thể sử dụng Named Client:
services.AddHttpClient("MyNamedClient", client =>
{
client.BaseAddress = new Uri("<https://api.ntechdevelopers.com>");
client.DefaultRequestHeaders.Add("Authorization", "Bearer token");
});
Sử dụng:
var client = _httpClientFactory.CreateClient("MyNamedClient");
3. Typed Client
Nếu bạn muốn một cách tiếp cận gọn gàng và có khả năng tái sử dụng cao hơn, hãy sử dụng Typed Client:
public class MyApiClient
{
private readonly HttpClient _client;
public MyApiClient(HttpClient client)
{
_client = client;
_client.BaseAddress = new Uri("<https://api.ntechdevelopers.com>");
}
public async Task<string> GetDataAsync()
{
return await _client.GetStringAsync("/data");
}
}
Đăng ký trong DI container:
services.AddHttpClient<MyApiClient>();
Sử dụng:
var myApiClient = serviceProvider.GetRequiredService<MyApiClient>();
await myApiClient.GetDataAsync();
4. Quản lý Lifetime của HttpClient
Mặc định, HttpMessageHandler có thời gian sống (lifetime) là 2 phút. Bạn có thể thay đổi bằng cách sử dụng SetHandlerLifetime:
services.AddHttpClient()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
Việc này giúp quản lý tốt hơn các kết nối dài và giảm thiểu chi phí kết nối lại. Ngoài ra đối với cách triển khai này bạn còn kết hợp với những thư viện ngoài như Polly
Tóm lại bạn nên chọn giải pháp kỹ thuật nào?
- Tạo mới HttpClient: Chỉ nên sử dụng nếu ứng dụng gửi rất ít request và không cần tái sử dụng kết nối.
- Static hoặc Singleton HttpClient: Tốt hơn so với tạo mới, nhưng có thể gặp vấn đề về DNS caching.
- IHttpClientFactory: Là giải pháp tối ưu nhất, đặc biệt với các ứng dụng dài hạn, có yêu cầu kết nối ổn định và hiệu suất cao.
Lời khuyên của mình là luôn sử dụng IHttpClientFactory trong các ứng dụng .NET Core hiện đại để đảm bảo hiệu suất và tính linh hoạt. Hy vọng bài viết này giúp bạn hiểu rõ hơn về cách sử dụng HttpClient và chọn được giải pháp phù hợp cho ứng dụng của mình.