ASP.NET Core: Các phương pháp hay nhất về hiệu suất với gRPC


Khóa học qua video:
Lập trình Python All Lập trình C# All SQL Server All Lập trình C All Java PHP HTML5-CSS3-JavaScript
Đăng ký Hội viên
Tất cả các video dành cho hội viên

Trong bài viết này

  1. Tái sử dụng các kênh gRPC
  2. Kết nối đồng thời
  3. ServerGarbageCollection trong ứng dụng khách
  4. Cân bằng tải
  5. Giao tiếp giữa các tiến trình
  6. Duy trì hoạt động cho ping
  7. Kiểm soát lưu lượng
  8. Truyền phát
  9. Tải trọng nhị phân

gRPC được thiết kế cho các dịch vụ hiệu suất cao. Tài liệu này giải thích cách đạt được hiệu suất tốt nhất có thể từ gRPC.

Tái sử dụng các kênh gRPC

Kênh gRPC nên được sử dụng lại khi thực hiện lời gọi gRPC. Việc sử dụng lại kênh cho phép các lời gọi được ghép kênh thông qua kết nối HTTP/2 hiện có.

Nếu một kênh mới được tạo cho mỗi lời gọi gRPC thì lượng thời gian cần thiết để hoàn thành có thể tăng lên đáng kể. Mỗi lời gọi sẽ yêu cầu nhiều chuyến đi khứ hồi mạng giữa máy khách và máy chủ để tạo kết nối HTTP/2 mới:

  1. Mở một socket
  2. Thiết lập kết nối TCP
  3. Đàm phán TLS
  4. Bắt đầu kết nối HTTP/2
  5. Thực hiện lời gọi gRPC

Các kênh được chia sẻ và sử dụng lại một cách an toàn giữa các lời gọi gRPC:

  • Máy khách gRPC được tạo bằng các kênh. Máy khách gRPC là các đối tượng nhẹ và không cần phải lưu vào bộ nhớ đệm hoặc sử dụng lại.
  • Nhiều máy khách gRPC có thể được tạo từ một kênh, bao gồm các loại máy khách khác nhau.
  • Một kênh và các máy khách được tạo từ kênh đó có thể được nhiều luồng sử dụng một cách an toàn.
  • Máy khách được tạo từ kênh có thể thực hiện nhiều lời gọi đồng thời.

Factory máy khách gRPC cung cấp một cách tập trung để định cấu hình các kênh. Nó tự động sử dụng lại các kênh cơ bản. Để biết thêm thông tin, hãy xem tích hợp factory máy khách gRPC trong .NET.

Kết nối đồng thời

Các kết nối HTTP/2 thường có giới hạn về số lượng luồng đồng thời tối đa (yêu cầu HTTP hoạt động) trên một kết nối cùng một lúc. Theo mặc định, hầu hết các máy chủ đều đặt giới hạn này là 100 luồng đồng thời.

Kênh gRPC sử dụng một kết nối HTTP/2 duy nhất và các lời gọi đồng thời được ghép kênh trên kết nối đó. Khi số lượng lời gọi hiện hoạt đạt đến giới hạn luồng kết nối, các lời gọi bổ sung sẽ được xếp hàng đợi trong máy khách. Lời gọi xếp hàng đợi các lời gọi hiện hoạt hoàn tất trước khi chúng được gửi đi. Các ứng dụng có mức tải cao hoặc các lời gọi gRPC truyền phát trong thời gian dài có thể gặp các vấn đề về hiệu suất do các lời gọi xếp hàng vì giới hạn này.

.NET 5 giới thiệu property SocketsHttpHandler.EnableMultipleHttp2Connections. Khi được đặt thành true, các kết nối HTTP/2 bổ sung sẽ được tạo bởi một kênh khi đạt đến giới hạn luồng đồng thời. Khi một GrpcChannel được tạo, nội bộ của nó SocketsHttpHandler sẽ tự động được cấu hình để tạo các kết nối HTTP/2 bổ sung. Nếu một ứng dụng định cấu hình trình xử lý của riêng nó, hãy xem xét cài đặt EnableMultipleHttp2Connections thành true:

var channel = GrpcChannel.ForAddress("https://localhost", new GrpcChannelOptions
{
    HttpHandler = new SocketsHttpHandler
    {
        EnableMultipleHttp2Connections = true,

        // ...configure other handler settings
    }
});

Có một số cách giải quyết cho ứng dụng .NET Core 3.1:

  • Tạo các kênh gRPC riêng cho các khu vực của ứng dụng có lượng tải cao. Ví dụ: dịch vụ gRPC Logger có thể có mức tải cao. Sử dụng một kênh riêng để tạo LoggerClient trong ứng dụng.
  • Ví dụ: sử dụng nhóm kênh gRPC để tạo danh sách các kênh gRPC. Random được sử dụng để chọn kênh từ danh sách mỗi khi cần kênh gRPC. Sử dụng phân phối ngẫu nhiên Random các lời gọi trên nhiều kết nối.

Quan trọng

Tăng giới hạn luồng đồng thời tối đa trên máy chủ là một cách khác để giải quyết vấn đề này. Trong Kestrel, điều này được định cấu hình bằng MaxStreamsPerConnection.

Không nên tăng giới hạn luồng đồng thời tối đa. Quá nhiều luồng trên một kết nối HTTP/2 sẽ gây ra các vấn đề mới về hiệu suất:

  • Tranh chấp luồng giữa các luồng đang cố gắng ghi vào kết nối.
  • Mất gói kết nối khiến tất cả lời gọi bị chặn ở lớp TCP.

ServerGarbageCollection trong ứng dụng khách

Trình thu gom rác .NET có hai chế độ: thu gom rác (Garbage Collection - GC) máy trạm và thu gom rác máy chủ. Mỗi trình thu gom rác đều được điều chỉnh cho khối lượng công việc khác nhau. Các ứng dụng ASP.NET Core sử dụng máy chủ GC theo mặc định.

Các ứng dụng có tính đồng thời cao thường hoạt động tốt hơn với máy chủ GC. Nếu ứng dụng khách gRPC đang gửi và nhận nhiều lời gọi gRPC cùng lúc thì việc cập nhật ứng dụng để sử dụng máy chủ GC có thể mang lại lợi ích về hiệu suất.

Để bật GC máy chủ, hãy đặt <ServerGarbageCollection> trong tệp dự án của ứng dụng:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

Để biết thêm thông tin về việc thu gom rác, hãy xem Thu gom rác của máy trạm và máy chủ.

Ghi chú

Các ứng dụng ASP.NET Core sử dụng máy chủ GC theo mặc định. Việc bật <ServerGarbageCollection> chỉ hữu ích trong các ứng dụng máy khách gRPC không phải máy chủ, chẳng hạn như trong ứng dụng console máy khách gRPC.

Cân bằng tải

Một số bộ cân bằng tải không hoạt động hiệu quả với gRPC. Bộ cân bằng tải L4 (vận chuyển) hoạt động ở cấp độ kết nối, bằng cách phân phối các kết nối TCP trên các điểm cuối. Cách tiếp cận này hoạt động tốt để tải các lệnh gọi API cân bằng tải được thực hiện bằng HTTP/1.1. Các lời gọi đồng thời được thực hiện bằng HTTP/1.1 được gửi trên các kết nối khác nhau, cho phép các lời gọi được cân bằng tải trên các điểm cuối.

Vì bộ cân bằng tải L4 hoạt động ở cấp độ kết nối nên chúng không hoạt động tốt với gRPC. gRPC sử dụng HTTP/2, ghép nhiều lời gọi trên một kết nối TCP. Tất cả các lời gọi gRPC qua kết nối đó đều đi đến một điểm cuối.

Có hai lựa chọn để cân bằng tải gRPC một cách hiệu quả:

  • Cân bằng tải phía máy khách
  • Cân bằng tải proxy L7 (ứng dụng)

Ghi chú

Chỉ các lời gọi gRPC mới có thể được cân bằng tải giữa các điểm cuối. Sau khi lời gọi gRPC truyền phát được thiết lập, tất cả tin nhắn được gửi qua luồng sẽ chuyển đến một điểm cuối.

Cân bằng tải phía máy khách

Với cân bằng tải phía máy khách, máy khách biết về điểm cuối. Đối với mỗi lời gọi gRPC, nó sẽ chọn một điểm cuối khác để gửi lời gọi đến. Cân bằng tải phía máy khách là một lựa chọn tốt khi độ trễ là quan trọng. Không có proxy giữa máy khách và dịch vụ nên lời gọi sẽ được gửi trực tiếp đến dịch vụ. Nhược điểm của cân bằng tải phía máy khách là mỗi máy khách phải theo dõi các điểm cuối có sẵn mà nó nên sử dụng.

Cân bằng tải máy khách Lookaside là một kỹ thuật trong đó trạng thái cân bằng tải được lưu trữ ở vị trí trung tâm. Máy khách định kỳ truy vấn vị trí trung tâm để lấy thông tin sử dụng khi đưa ra quyết định cân bằng tải.

Để biết thêm thông tin, hãy xem cân bằng tải phía máy khách gRPC.

Cân bằng tải proxy

Proxy L7 (ứng dụng) hoạt động ở cấp độ cao hơn proxy L4 (vận chuyển). Proxy L7 hiểu HTTP/2. Proxy nhận các lệnh gọi gRPC được ghép kênh trên một kết nối HTTP/2 và phân phối chúng trên nhiều điểm cuối phụ trợ. Sử dụng proxy đơn giản hơn cân bằng tải phía máy khách nhưng làm tăng thêm độ trễ cho các lời gọi gRPC.

Có rất nhiều proxy L7 có sẵn. Một số tùy chọn là:

Giao tiếp giữa các tiến trình

Các lời gọi gRPC giữa máy khách và dịch vụ thường được gửi qua socket TCP. TCP rất tốt cho việc giao tiếp qua mạng, nhưng giao tiếp giữa các tiến trình (Inter-process communication - IPC) hiệu quả hơn khi máy khách và dịch vụ ở trên cùng một máy.

Hãy cân nhắc sử dụng phương tiện truyền tải như socket miền Unix hoặc các đường ống được đặt tên cho các lời gọi gRPC giữa các quy trình trên cùng một máy. Để biết thêm thông tin, hãy xem Giao tiếp giữa các tiến trình với gRPC.

Duy trì hoạt động cho ping

Các ping duy trì hoạt động có thể được sử dụng để duy trì kết nối HTTP/2 trong thời gian không hoạt động. Việc có sẵn kết nối HTTP/2 hiện có khi ứng dụng tiếp tục hoạt động cho phép các lệnh gọi gRPC ban đầu được thực hiện nhanh chóng mà không bị chậm trễ do kết nối được thiết lập lại.

Các ping duy trì hoạt động được định cấu hình trên SocketsHttpHandler:

var handler = new SocketsHttpHandler
{
    PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
    KeepAlivePingDelay = TimeSpan.FromSeconds(60),
    KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
    EnableMultipleHttp2Connections = true
};

var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions
{
    HttpHandler = handler
});

Đoạn code trên định cấu hình kênh gửi ping duy trì hoạt động đến máy chủ cứ sau 60 giây trong thời gian không hoạt động. Ping đảm bảo máy chủ và mọi proxy đang sử dụng sẽ không đóng kết nối do không hoạt động.

Ghi chú

Giữ ping hoạt động chỉ giúp duy trì kết nối. Các lệnh gọi gRPC chạy trong thời gian dài trên kết nối vẫn có thể bị máy chủ hoặc proxy trung gian chấm dứt do không hoạt động.

Kiểm soát lưu lượng

Kiểm soát luồng HTTP/2 là tính năng giúp ứng dụng không bị tràn ngập dữ liệu. Khi sử dụng kiểm soát lưu lượng:

  • Mỗi kết nối và yêu cầu HTTP/2 đều có sẵn một cửa sổ bộ đệm. Cửa sổ bộ đệm là lượng dữ liệu mà ứng dụng có thể nhận cùng một lúc.
  • Kiểm soát luồng được kích hoạt nếu cửa sổ bộ đệm được lấp đầy. Khi được kích hoạt, ứng dụng gửi sẽ tạm dừng gửi thêm dữ liệu.
  • Sau khi ứng dụng nhận đã xử lý xong dữ liệu thì sẽ có khoảng trống trong cửa sổ bộ đệm. Ứng dụng gửi sẽ tiếp tục gửi dữ liệu.

Kiểm soát lưu lượng có thể có tác động tiêu cực đến hiệu suất khi nhận được tin nhắn lớn. Nếu cửa sổ bộ đệm nhỏ hơn tải trọng tin nhắn đến hoặc có độ trễ giữa máy khách và máy chủ thì dữ liệu có thể được gửi theo các đợt start/stop.

Các vấn đề về hiệu suất kiểm soát lưu lượng có thể được khắc phục bằng cách tăng kích thước cửa sổ bộ đệm. Trong Kestrel, điều này được định cấu hình với LaunchConnectionWindowSize và LaunchStreamWindowSize khi khởi động ứng dụng:

builder.WebHost.ConfigureKestrel(options =>
{
    var http2 = options.Limits.Http2;
    http2.InitialConnectionWindowSize = 1024 * 1024 * 2; // 2 MB
    http2.InitialStreamWindowSize = 1024 * 1024; // 1 MB
});

Khuyến nghị:

  • Nếu dịch vụ gRPC thường nhận được tin nhắn lớn hơn 96 KB, kích thước cửa sổ luồng mặc định của Kestrel, thì hãy cân nhắc việc tăng kích thước kết nối và cửa sổ luồng.
  • Kích thước cửa sổ kết nối phải luôn bằng hoặc lớn hơn kích thước cửa sổ luồng. Luồng là một phần của kết nối và người gửi bị giới hạn bởi cả hai.

Quan trọng

Việc tăng kích thước cửa sổ của Kestrel cho phép Kestrel đệm nhiều dữ liệu hơn thay mặt cho ứng dụng, điều này có thể làm tăng mức sử dụng bộ nhớ. Tránh cấu hình kích thước cửa sổ lớn không cần thiết.

Truyền phát

Truyền phát hai chiều gRPC có thể được sử dụng để thay thế các lệnh gọi gRPC đơn nhất trong các tình huống hiệu suất cao. Khi luồng hai chiều đã bắt đầu, việc truyền phát tin nhắn qua lại sẽ nhanh hơn gửi tin nhắn với nhiều lệnh gọi gRPC đơn nhất. Tin nhắn truyền phát được gửi dưới dạng dữ liệu trên yêu cầu HTTP/2 hiện có và loại bỏ chi phí tạo yêu cầu HTTP/2 mới cho mỗi lệnh gọi đơn nhất.

Ví dụ dịch vụ:

public override async Task SayHello(IAsyncStreamReader<HelloRequest> requestStream,
    IServerStreamWriter<HelloReply> responseStream, ServerCallContext context)
{
    await foreach (var request in requestStream.ReadAllAsync())
    {
        var helloReply = new HelloReply { Message = "Hello " + request.Name };

        await responseStream.WriteAsync(helloReply);
    }
}

Ví dụ máy khách:

var client = new Greet.GreeterClient(channel);
using var call = client.SayHello();

Console.WriteLine("Type a name then press enter.");
while (true)
{
    var text = Console.ReadLine();

    // Send and receive messages over the stream
    await call.RequestStream.WriteAsync(new HelloRequest { Name = text });
    await call.ResponseStream.MoveNext();

    Console.WriteLine($"Greeting: {call.ResponseStream.Current.Message}");
}

Việc thay thế các lời gọi đơn nhất bằng truyền phát hai chiều vì lý do hiệu suất là một kỹ thuật nâng cao và không phù hợp trong nhiều trường hợp.

Sử dụng lời gọi truyền phát là một lựa chọn tốt khi:

  1. Cần có thông lượng cao hoặc độ trễ thấp.
  2. gRPC và HTTP/2 được xác định là nút thắt cổ chai về hiệu suất.
  3. Một nhân viên trong máy khách đang gửi hoặc nhận tin nhắn thông thường bằng dịch vụ gRPC.

Lưu ý về độ phức tạp và hạn chế bổ sung của việc sử dụng lời gọi truyền phát thay vì lời gọi đơn nhất:

  1. Luồng có thể bị gián đoạn do lỗi dịch vụ hoặc kết nối. Cần có logic để khởi động lại luồng nếu có lỗi.
  2. RequestStream.WriteAsync không an toàn cho đa luồng. Mỗi lần chỉ có thể ghi một tin nhắn vào luồng. Gửi tin nhắn từ nhiều luồng qua một luồng yêu cầu hàng đợi nhà sản xuất/người tiêu (producer/consumer) dùng như Channel<T> để sắp xếp các tin nhắn.
  3. Phương thức truyền phát gRPC bị giới hạn ở việc nhận một loại tin nhắn và gửi một loại tin nhắn. Ví dụ: rpc StreamingCall(stream RequestMessage) returns (stream ResponseMessage) sẽ nhận RequestMessage và gửi ResponseMessage. Sự hỗ trợ của Protobuf đối với các tin nhắn không xác định hoặc có điều kiện sử dụng Any và oneof có thể giải quyết được hạn chế này.

Tải trọng nhị phân

Tải trọng nhị phân được hỗ trợ trong Protobuf với loại giá trị vô hướng bytes. Property được tạo trong C# sử dụng ByteString làm loại property.

syntax = "proto3";

message PayloadResponse {
    bytes data = 1;
}  

Protobuf là một định dạng nhị phân giúp tuần tự hóa các tải trọng nhị phân lớn một cách hiệu quả với chi phí tối thiểu. Các định dạng dựa trên văn bản như JSON yêu cầu mã hóa byte thành base64 và tăng thêm 33% kích thước tin nhắn.

Khi làm việc với tải trọng ByteString lớn, có một số phương pháp hay nhất để tránh các bản sao và phân bổ không cần thiết sẽ được thảo luận bên dưới.

Gửi tải trọng nhị phân

Các thể hiện (instance) của ByteString thường được tạo bằng cách sử dụng ByteString.CopyFrom(byte[] data). Phương thức này phân bổ một ByteString mới và một byte[] mới. Dữ liệu được sao chép vào mảng byte mới.

Có thể tránh được việc phân bổ và sao chép bổ sung bằng cách sử dụng UnsafeByteOperations.UnsafeWrap(ReadOnlyMemory<byte> bytes) để tạo thể hiện của ByteString.

var data = await File.ReadAllBytesAsync(path);

var payload = new PayloadResponse();
payload.Data = UnsafeByteOperations.UnsafeWrap(data);

Byte không được sao chép với UnsafeByteOperations.UnsafeWrap nên chúng không được sửa đổi trong khi ByteString sử dụng.

UnsafeByteOperations.UnsafeWrap yêu cầu Google.Protobuf phiên bản 3.15.0 trở lên.

Đọc tải trọng nhị phân

Dữ liệu có thể được đọc một cách hiệu quả từ các thể hiện của ByteString bằng cách sử dụng các property ByteString.Memory và ByteString.Span.

var byteString = UnsafeByteOperations.UnsafeWrap(new byte[] { 0, 1, 2 });
var data = byteString.Span;

for (var i = 0; i < data.Length; i++)
{
    Console.WriteLine(data[i]);
}

Các property này cho phép mã đọc dữ liệu trực tiếp từ a ByteString mà không cần phân bổ hoặc sao chép.

Hầu hết các API .NET đều có quá tải (overload) ReadOnlyMemory<byte> và byte[], do đó ByteString.Memory được khuyến nghị là sử dụng dữ liệu cơ bản. Tuy nhiên, có những trường hợp ứng dụng có thể cần lấy dữ liệu dưới dạng mảng byte. Nếu cần có một mảng byte thì có thể sử dụng phương thức MemoryMarshal.TryGetArray để lấy một mảng từ một ByteString mà không cần phân bổ bản sao dữ liệu mới.

var byteString = GetByteString();

ByteArrayContent content;
if (MemoryMarshal.TryGetArray(byteString.Memory, out var segment))
{
    // Success. Use the ByteString's underlying array.
    content = new ByteArrayContent(segment.Array, segment.Offset, segment.Count);
}
else
{
    // TryGetArray didn't succeed. Fall back to creating a copy of the data with ToByteArray.
    content = new ByteArrayContent(byteString.ToByteArray());
}

var httpRequest = new HttpRequestMessage();
httpRequest.Content = content;

Đoạn code trên:

  • Cố gắng lấy một mảng ByteString.Memory từ MemoryMarshal.TryGetArray.
  • Sử dụng ArraySegment<byte> nếu nó được lấy thành công. Phân đoạn có tham chiếu đến mảng, offset và count.
  • Nếu không, hãy quay lại phân bổ một mảng mới với ByteString.ToByteArray().

Dịch vụ gRPC và tải trọng nhị phân lớn

gRPC và Protobuf có thể gửi và nhận tải trọng nhị phân lớn. Mặc dù Protobuf nhị phân hiệu quả hơn JSON dựa trên văn bản trong việc tuần tự hóa các tải trọng nhị phân, nhưng vẫn có những đặc điểm hiệu suất quan trọng cần lưu ý khi làm việc với các tải trọng nhị phân lớn.

gRPC là framework RPC dựa trên tin nhắn, có nghĩa là:

  • Toàn bộ tin nhắn được tải vào bộ nhớ trước khi gRPC có thể gửi nó.
  • Khi nhận được tin nhắn, toàn bộ tin nhắn sẽ được giải tuần tự hóa vào bộ nhớ.

Tải trọng nhị phân được phân bổ dưới dạng mảng byte. Ví dụ: tải trọng nhị phân 10 MB phân bổ một mảng byte 10 MB. Các tin nhắn có tải trọng nhị phân lớn có thể phân bổ các mảng byte trên vùng đối tượng lớn. Phân bổ lớn ảnh hưởng đến hiệu suất và khả năng mở rộng của máy chủ.

Lời khuyên để tạo các ứng dụng hiệu suất cao với tải trọng nhị phân lớn:

Nguồn: learn.microsoft.com
» Tiếp: Giao tiếp giữa các tiến trình với gRPC
« Trước: Những cân nhắc về bảo mật trong gRPC cho ASP.NET Core
Khóa học qua video:
Lập trình Python All Lập trình C# All SQL Server All Lập trình C All Java PHP HTML5-CSS3-JavaScript
Đăng ký Hội viên
Tất cả các video dành cho hội viên
Copied !!!