Loại bỏ "vấn đề n+1"
Hãy cùng tìm hiểu cách Gato GraphQL hoàn toàn tránh được "vấn đề n+1" ngay từ thiết kế kiến trúc.
"Vấn đề n+1" là gì
"Vấn đề n+1" về cơ bản có nghĩa là số lượng queries được thực thi đối với cơ sở dữ liệu có thể lớn bằng số lượng node trong đồ thị.
Điều đó có nghĩa là gì? Hãy xem qua một ví dụ: giả sử chúng ta muốn lấy danh sách các đạo diễn và, với mỗi người trong số họ, danh sách phim của họ, thông qua query sau:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}Để hiệu quả, chúng ta kỳ vọng chỉ cần thực thi 2 queries để lấy dữ liệu từ cơ sở dữ liệu: 1 để lấy dữ liệu của các đạo diễn, và 1 để lấy dữ liệu cho tất cả phim của tất cả đạo diễn.
Tuy nhiên, để đáp ứng query này, GraphQL sẽ cần thực thi "n+1" queries đối với cơ sở dữ liệu: 1 lần đầu tiên để lấy danh sách N đạo diễn (10 trong trường hợp này) và sau đó, với mỗi đạo diễn trong N đạo diễn, 1 query để lấy danh sách phim của họ. Trong trường hợp của chúng ta, phải thực thi 1+10=11 queries.
Vấn đề này nảy sinh vì các resolver của GraphQL chỉ xử lý 1 đối tượng mỗi lần, chứ không xử lý tất cả các đối tượng cùng loại cùng một lúc. Trong trường hợp của chúng ta, resolver xử lý các đối tượng của kiểu Query (đây là kiểu gốc) sẽ được gọi một lần đầu tiên để lấy danh sách tất cả các đối tượng Director và sau đó, resolver xử lý kiểu Director sẽ được gọi một lần cho mỗi đối tượng Director, để lấy danh sách phim của họ.
Nói cách khác: các resolver của GraphQL thấy cây cối, không thấy cánh rừng.
Vấn đề này thực ra tệ hơn vẻ ngoài ban đầu, vì số lượng node trong một đồ thị tăng theo cấp số nhân so với số cấp độ của đồ thị. Vì vậy, tên "n+1" chỉ đúng với đồ thị sâu 2 cấp. Với đồ thị sâu 3 cấp, nên gọi là vấn đề "N2+n+1"! Và cứ thế tiếp tục...
Chẳng hạn, tiếp tục ví dụ trên, hãy thêm danh sách diễn viên của mỗi bộ phim vào query, như sau:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
actors(first: 10) {
name
}
}
}
}
}Khi đó, các queries được thực thi đối với cơ sở dữ liệu là: 1 lần đầu tiên để lấy danh sách 10 đạo diễn, sau đó 1 query để lấy danh sách phim của mỗi đạo diễn cho từng người trong 10 đạo diễn, và cuối cùng 1 query để lấy danh sách diễn viên cho mỗi trong 10 bộ phim của mỗi trong 10 đạo diễn. Tổng cộng là 1+10+100=111 queries.
Sau khi nhận ra hành vi này, "vấn đề n+1" có thể dễ dàng được coi là trở ngại hiệu suất lớn nhất của GraphQL: nếu không được kiểm soát, việc truy vấn các đồ thị sâu vài cấp có thể trở nên chậm đến mức khiến GraphQL gần như vô dụng.
Giải pháp tổng quát cho "vấn đề n+1"
Giải pháp tiêu chuẩn cho "vấn đề n+1" được cung cấp lần đầu bởi tiện ích DataLoader. Chiến lược của nó rất đơn giản: trì hoãn việc giải quyết các phân đoạn của query đến một giai đoạn sau, trong đó tất cả các đối tượng cùng loại có thể được giải quyết cùng nhau trong một query duy nhất. Chiến lược này, gọi là "batching", giải quyết hiệu quả vấn đề "n+1".
Ngoài ra, DataLoader lưu cache các đối tượng sau khi lấy về, để nếu một query sau đó cần tải một đối tượng đã được tải, nó có thể bỏ qua thực thi và lấy đối tượng từ cache. Chiến lược này, gọi là "caching", chủ yếu là một sự tối ưu hóa bổ sung cho "batching".
Các vấn đề với giải pháp "batching/trì hoãn"
Về mặt kỹ thuật, không có bất kỳ vấn đề gì với chiến lược "batching" hay "trì hoãn": nó hoạt động tốt.
(Từ đây trở đi, hãy chỉ gọi chiến lược này là "trì hoãn".)
Tuy nhiên, vấn đề là chiến lược này là một suy nghĩ sau: nhà phát triển có thể triển khai server trước rồi, sau khi nhận thấy việc giải quyết queries chậm như thế nào, mới quyết định đưa cơ chế trì hoãn vào. Do đó, việc triển khai các resolver có thể liên quan đến một số bước không cần thiết, tạo thêm trở ngại cho quá trình phát triển. Ngoài ra, vì nhà phát triển phải hiểu cách hoạt động của cơ chế "trì hoãn", việc triển khai trở nên phức tạp hơn mức cần thiết.
Vấn đề này không nằm ở bản thân chiến lược, mà ở chỗ server GraphQL cung cấp chức năng này như một tiện ích bổ sung, mặc dù không có nó, việc truy vấn có thể chậm đến mức khiến GraphQL gần như vô dụng.
Giải pháp cho vấn đề này vì vậy rất rõ ràng: chiến lược "trì hoãn" không nên là một tiện ích bổ sung mà phải được tích hợp sẵn trong chính server GraphQL. Thay vì có 2 chiến lược thực thi queries, "bình thường" và "trì hoãn", chỉ nên có 1, "trì hoãn". Và server GraphQL phải thực thi cơ chế "trì hoãn" ngay cả khi nhà phát triển triển khai resolver theo cách "bình thường" (nói cách khác, server GraphQL đảm nhiệm phần phức tạp thêm, không phải nhà phát triển).
Và đó chính xác là những gì Gato GraphQL làm.
Biến "trì hoãn" thành chiến lược duy nhất được thực thi bởi server GraphQL
Vấn đề với hầu hết các server GraphQL là trách nhiệm giải quyết các kiểu đối tượng (object, union và interface) như các đối tượng được thực hiện bởi chính các resolver khi xử lý node cha (ví dụ: films => directors), thay vì ủy thác nhiệm vụ này cho engine tải dữ liệu.
Gato GraphQL chuyển trách nhiệm này ra khỏi resolver và vào engine tải dữ liệu của server, như sau:
- Các resolver trả về ID, không phải đối tượng, khi giải quyết mối quan hệ giữa node cha và node con
- Cho một danh sách các ID của một kiểu nhất định, một thực thể
DataLoaderlấy các đối tượng tương ứng từ kiểu đó - Engine tải dữ liệu của server là cầu nối giữa 2 phần này: đầu tiên nó lấy các ID đối tượng từ các resolver và, ngay trước khi thực thi query lồng nhau cho mối quan hệ (vào thời điểm đó nó sẽ đã tích lũy tất cả các ID cần giải quyết cho kiểu cụ thể), nó lấy các đối tượng cho những ID đó thông qua
DataLoader(có thể bao gồm tất cả các ID vào một query duy nhất một cách hiệu quả).
Cách tiếp cận này có thể được tóm tắt là: "Làm việc với ID, không phải với đối tượng".
Hãy sử dụng cùng ví dụ trước đó để hình dung cách tiếp cận mới này. Query bên dưới lấy danh sách các đạo diễn và phim của họ:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}Hãy chú ý đến 2 trường cần lấy từ mỗi đạo diễn, name và films, và cách chúng hiện tại khác nhau như thế nào:
Trường name thuộc kiểu scalar. Nó có thể được giải quyết ngay lập tức, vì chúng ta có thể kỳ vọng đối tượng kiểu Director chứa một thuộc tính kiểu string tên là name, chứa tên của đạo diễn. Do đó, một khi chúng ta có đối tượng Director, không cần thực thi thêm query để giải quyết thuộc tính này.
Tuy nhiên, trường films là một danh sách của kiểu đối tượng. Thông thường không thể giải quyết ngay lập tức, vì nó tham chiếu đến một danh sách các đối tượng, kiểu Film, vẫn cần được lấy từ cơ sở dữ liệu qua 1 hoặc nhiều queries bổ sung. Do đó, nhà phát triển sẽ cần triển khai cơ chế "trì hoãn" cho nó.
Bây giờ, hãy xem xét hành vi khác, và để trường films được giải quyết như một danh sách ID (thay vì danh sách đối tượng). Vì chúng ta có thể kỳ vọng đối tượng Director chứa một thuộc tính tên là filmIDs chứa các ID của tất cả phim của nó, kiểu array of string (giả sử ID được biểu diễn dưới dạng string), thì trường này cũng có thể được giải quyết ngay lập tức, mà không cần triển khai cơ chế "trì hoãn".
Cuối cùng, ngoài ID, resolver phải cung cấp thêm một mảnh thông tin: kiểu của đối tượng kỳ vọng (trong ví dụ của chúng ta, có thể là [(Film, 2), (Film, 5), (Film, 9)]). Tuy nhiên thông tin này là nội bộ, được chuyển cho engine, và không cần xuất hiện trong phản hồi của query.
Triển khai cách tiếp cận đã điều chỉnh trong code
Hãy xem Gato GraphQL triển khai cách tiếp cận này trong code PHP như thế nào. Code bên dưới minh họa các resolver khác nhau (để rõ ràng, tất cả code bên dưới đã được chỉnh sửa).
FieldResolvers
FieldResolvers nhận một đối tượng của một kiểu cụ thể và giải quyết các trường của nó. Đối với các mối quan hệ, nó cũng phải chỉ ra kiểu của đối tượng mà nó giải quyết thành. Đây là hợp đồng của chúng:
interface FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = []);
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}Việc triển khai của nó trông như thế này:
class PostFieldResolver implements FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = [])
{
$post = $object;
switch ($field) {
case 'title':
return $post->title;
case 'author':
return $post->authorID; // This is an ID, not an object!
}
return null;
}
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
{
switch ($field) {
case 'author':
return UserTypeResolver::class;
}
return null;
}
}Hãy chú ý rằng, bằng cách loại bỏ logic xử lý các promise/đối tượng trì hoãn, code giải quyết trường author đã trở nên rất đơn giản và súc tích.
TypeResolvers
TypeResolvers là các đối tượng xử lý một kiểu cụ thể: chúng biết tên của kiểu và TypeDataLoader nào tải các đối tượng của kiểu đó, cùng các thông tin khác.
Engine tải dữ liệu, khi giải quyết các trường, sẽ được cung cấp các ID từ một lớp TypeResolver nhất định. Sau đó, khi lấy các đối tượng cho những ID đó, engine tải dữ liệu sẽ hỏi TypeResolver nên dùng đối tượng TypeDataLoader nào để tải các đối tượng đó.
Hợp đồng của chúng được định nghĩa như sau:
interface TypeResolverInterface
{
public function getTypeName(): string;
public function getTypeDataLoaderClass(): string;
}Trong ví dụ của chúng ta, lớp UserTypeResolver định nghĩa rằng kiểu User phải có dữ liệu được tải thông qua lớp UserTypeDataLoader:
class UserTypeResolver implements TypeResolverInterface
{
public function getTypeName(): string
{
return 'User';
}
public function getTypeDataLoaderClass(): string
{
return UserTypeDataLoader::class;
}
}TypeDataLoaders
TypeDataLoaders nhận một danh sách các ID của một kiểu cụ thể và trả về các đối tượng tương ứng của kiểu đó. Đây là hợp đồng của chúng:
interface TypeDataLoaderInterface
{
public function getObjects(array $ids): array;
}Việc lấy người dùng được thực hiện như sau:
class UserTypeDataLoader implements TypeDataLoaderInterface
{
public function getObjects(array $ids): array
{
$userAPI = UserAPIFacade::getInstance();
return $userAPI->getUsers($ids);
}
}Thực thi một query (thực sự) lớn
Hãy kiểm tra xem chiến lược này có hoạt động không. Vào GraphiQL client trong Gato GraphQL và thực thi query bên dưới, liên quan đến một đồ thị sâu 10 cấp (posts => author => posts => tags => posts => comments => author => posts => comments => author) và không thể được giải quyết trong thời gian hợp lý nếu "vấn đề n+1" đang xảy ra.
query {
posts(pagination:{ limit:10 }) {
excerpt
title
url
author {
name
url
posts(pagination:{ limit:10 }) {
title
tags(pagination:{ limit:10 }) {
slug
url
posts(pagination:{ limit:10 }) {
title
comments(pagination:{ limit:10 }) {
content
date
author {
name
posts(pagination:{ limit:10 }) {
title
url
comments(pagination:{ limit:10 }) {
content
date
author {
name
username
url
}
}
}
}
}
}
}
}
}
}
}Cuộn xuống kết quả, chúng ta sẽ thấy phản hồi lớn như thế nào, bao gồm bao nhiêu thực thể, bao nhiêu cấp được lấy về, và thế mà nó được thực thi nhanh chóng, không gặp bất kỳ khó khăn nào.