การระงับ "ปัญหา n+1"
มาเรียนรู้ว่า Gato GraphQL หลีกเลี่ยง "ปัญหา n+1" ได้อย่างสมบูรณ์ตั้งแต่การออกแบบสถาปัตยกรรมได้อย่างไร
"ปัญหา n+1" คืออะไร
"ปัญหา n+1" หมายความโดยพื้นฐานว่า จำนวน queries ที่ถูกดำเนินการกับฐานข้อมูลอาจมากเท่ากับจำนวนโหนดในกราฟ
หมายความว่าอย่างไร? ลองดูตัวอย่าง: สมมติว่าเราต้องการดึงรายชื่อผู้กำกับ และสำหรับแต่ละคนให้ดึงภาพยนตร์ของเขา/เธอ ผ่าน query ต่อไปนี้:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}เพื่อให้มีประสิทธิภาพ เราคาดหวังว่าจะต้องดำเนินการเพียง 2 queries เพื่อดึงข้อมูลจากฐานข้อมูล: 1 ครั้งเพื่อดึงข้อมูลผู้กำกับ และ 1 ครั้งเพื่อดึงข้อมูลภาพยนตร์ทั้งหมดของผู้กำกับทั้งหมด
อย่างไรก็ตาม เพื่อตอบสนอง query นี้ GraphQL จำเป็นต้องดำเนินการ queries "n+1" ครั้งกับฐานข้อมูล: 1 ครั้งแรกเพื่อดึงรายชื่อผู้กำกับ N คน (ในกรณีนี้คือ 10 คน) และสำหรับผู้กำกับ N คนแต่ละคน จะต้องดำเนินการ 1 query เพื่อดึงรายชื่อภาพยนตร์ของเขา/เธอ ในกรณีของเรา เราต้องดำเนินการ 1+10=11 queries
ปัญหานี้เกิดขึ้นเพราะ resolvers ของ GraphQL จัดการทีละ 1 object เท่านั้น ไม่ใช่ objects ทั้งหมดที่เป็นประเภทเดียวกันพร้อมกัน ในกรณีของเรา resolver ที่จัดการ objects ของ type Query (ซึ่งเป็น root type) จะถูกเรียกครั้งแรกครั้งเดียวเพื่อดึงรายชื่อ Director objects ทั้งหมด แล้ว resolver ที่จัดการ type Director จะถูกเรียกครั้งละหนึ่งครั้งสำหรับแต่ละ Director object เพื่อดึงรายชื่อภาพยนตร์ของเขา/เธอ
กล่าวอีกนัยหนึ่ง: resolvers ของ GraphQL มองเห็นต้นไม้ ไม่ใช่ป่า
ปัญหานี้แย่กว่าที่เห็นในตอนแรก เพราะจำนวนโหนดในกราฟเพิ่มขึ้นแบบเอกซ์โปเนนเชียลตามจำนวนระดับของกราฟ ดังนั้น ชื่อ "n+1" จึงใช้ได้เฉพาะกับกราฟที่ลึก 2 ระดับ สำหรับกราฟที่ลึก 3 ระดับ ควรเรียกว่า "ปัญหา N2+n+1"! และต่อไปเรื่อยๆ...
ตัวอย่างเช่น ต่อจากตัวอย่างข้างต้น ลองเพิ่มรายชื่อนักแสดง/นักแสดงหญิงของแต่ละภาพยนตร์ลงใน query ด้วย แบบนี้:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
actors(first: 10) {
name
}
}
}
}
}จากนั้น queries ที่ดำเนินการกับฐานข้อมูลคือ: 1 ครั้งแรกเพื่อดึงรายชื่อผู้กำกับ 10 คน จากนั้น 1 query เพื่อดึงรายชื่อภาพยนตร์ของผู้กำกับแต่ละคนจาก 10 คน และสุดท้าย 1 query เพื่อดึงรายชื่อนักแสดง/นักแสดงหญิงสำหรับภาพยนตร์แต่ละเรื่องจาก 10 เรื่องของผู้กำกับแต่ละคนจาก 10 คน ซึ่งรวมทั้งหมดคือ 1+10+100=111 queries
หลังจากสังเกตพฤติกรรมนี้ "ปัญหา n+1" อาจถือได้ง่ายๆ ว่าเป็นอุปสรรคด้านประสิทธิภาพที่ใหญ่ที่สุดของ GraphQL: หากปล่อยทิ้งไว้ การ query กราฟที่ลึกหลายระดับอาจช้ามาก จนทำให้ GraphQL แทบไม่มีประโยชน์
วิธีแก้ทั่วไปสำหรับ "ปัญหา n+1"
วิธีแก้มาตรฐานสำหรับ "ปัญหา n+1" ถูกนำเสนอครั้งแรกโดย utility DataLoader กลยุทธ์ของมันเรียบง่ายมาก: เลื่อนการ resolve ส่วนของ query ออกไปจนถึงขั้นตอนภายหลัง ซึ่ง objects ทั้งหมดที่เป็นประเภทเดียวกันสามารถ resolve ได้พร้อมกันใน query เดียว กลยุทธ์นี้เรียกว่า "batching" ซึ่งแก้ปัญหา "n+1" ได้อย่างมีประสิทธิภาพ
นอกจากนี้ DataLoader ยังแคช objects หลังจากดึงมาแล้ว ดังนั้นหาก query ที่ตามมาต้องการโหลด object ที่โหลดแล้ว มันสามารถข้ามการดำเนินการและดึง object จากแคชแทนได้ กลยุทธ์นี้เรียกว่า "caching" ซึ่งส่วนใหญ่เป็นการปรับปรุงเพิ่มเติมจาก "batching"
ปัญหาของวิธีแก้แบบ "batching/deferred"
ในทางเทคนิค ไม่มีปัญหาใดๆ กับกลยุทธ์ "batching" หรือ "deferred": มันทำงานได้ดี
(ต่อจากนี้ เรียกกลยุทธ์นี้ว่า "deferred" เท่านั้น)
อย่างไรก็ตาม ปัญหาคือกลยุทธ์นี้เป็นการคิดเพิ่มเติมภายหลัง: นักพัฒนาอาจ implement server ก่อน แล้วเมื่อสังเกตว่า resolve queries ช้า จึงตัดสินใจนำกลไก deferred มาใช้ ดังนั้น การ implement resolvers อาจมีขั้นตอนที่ไม่จำเป็น ทำให้กระบวนการพัฒนาสะดุด นอกจากนี้ เนื่องจากนักพัฒนาต้องเข้าใจวิธีการทำงานของกลไก "deferred" ทำให้การ implement ซับซ้อนกว่าที่ควรจะเป็น
ปัญหานี้ไม่ได้อยู่ที่ตัวกลยุทธ์เอง แต่อยู่ที่ GraphQL server นำเสนอฟีเจอร์นี้เป็น add-on แม้ว่าหากปราศจากมัน การ query อาจช้ามากจนทำให้ GraphQL แทบไม่มีประโยชน์
วิธีแก้ปัญหานี้จึงตรงไปตรงมา: กลยุทธ์ "deferred" ไม่ควรเป็น add-on แต่ควรถูกบรรจุอยู่ใน GraphQL server เอง แทนที่จะมี 2 กลยุทธ์การดำเนินการ query คือ "normal" และ "deferred" ควรมีเพียง 1 อย่าง คือ "deferred" และ GraphQL server ต้องดำเนินการกลไก "deferred" แม้ว่านักพัฒนาจะ implement resolver แบบ "normal" (กล่าวคือ GraphQL server รับภาระความซับซ้อนเพิ่มเติม ไม่ใช่นักพัฒนา)
และนั่นคือสิ่งที่ Gato GraphQL ทำ
ทำให้ "deferred" เป็นกลยุทธ์เดียวที่ GraphQL server ดำเนินการ
ปัญหาของ GraphQL servers ส่วนใหญ่คือ ความรับผิดชอบในการ resolve object types (object, union และ interface) เป็น objects นั้นทำโดย resolvers เองเมื่อประมวลผล parent node (เช่น: films => directors) แทนที่จะมอบหมายงานนี้ให้กับ dataloading engine
Gato GraphQL โอนความรับผิดชอบนี้จาก resolver ไปยัง data-loading engine ของ server ดังนี้:
- Resolvers ส่งคืน IDs ไม่ใช่ objects เมื่อ resolve ความสัมพันธ์ระหว่าง parent node และ child node
- เมื่อได้รับรายการ IDs ของ type หนึ่งๆ entity
DataLoaderจะดึง objects ที่สอดคล้องกันจาก type นั้น - Data-loading engine ของ server ทำหน้าที่เชื่อมสองส่วนนี้: ก่อนอื่นดึง object IDs จาก resolvers และก่อนที่จะดำเนินการ nested query สำหรับความสัมพันธ์ (ซึ่งเมื่อถึงเวลานั้นจะสะสม IDs ทั้งหมดที่ต้องการ resolve สำหรับ type นั้นแล้ว) มันจะดึง objects สำหรับ IDs เหล่านั้นผ่าน
DataLoader(ซึ่งสามารถรวม IDs ทั้งหมดไว้ใน query เดียวได้อย่างมีประสิทธิภาพ)
แนวทางนี้สามารถสรุปได้ว่า: "จัดการกับ IDs ไม่ใช่ objects"
ลองใช้ตัวอย่างเดิมเพื่อแสดงให้เห็นแนวทางใหม่นี้ query ด้านล่างดึงรายชื่อผู้กำกับและภาพยนตร์ของพวกเขา:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}ให้สังเกต 2 fields ที่ต้องดึงจากผู้กำกับแต่ละคน คือ name และ films และความแตกต่างของพวกมันในปัจจุบัน:
Field name เป็น scalar type มันสามารถ resolve ได้ทันที เนื่องจากเราคาดว่า object ของ type Director จะมี property ชนิด string ชื่อ name ที่มีชื่อผู้กำกับ ดังนั้น เมื่อเราได้ Director object แล้ว ไม่จำเป็นต้องดำเนินการ query พิเศษเพื่อ resolve property นี้
Field films นั้น เป็น list ของ object type โดยปกติแล้วมันไม่สามารถ resolve ได้ทันที เนื่องจากมันอ้างอิงถึงรายการ objects ของ type Film ซึ่งยังต้องดึงจากฐานข้อมูลผ่าน 1 queries หรือมากกว่า ดังนั้น นักพัฒนาจำเป็นต้อง implement กลไก "deferred" สำหรับมัน
ลองพิจารณาพฤติกรรมที่แตกต่างออกไป และให้ field films ถูก resolve เป็นรายการ IDs (แทนที่จะเป็นรายการ objects) เนื่องจากเราคาดว่า Director object จะมี property ชื่อ filmIDs ที่มี IDs ของภาพยนตร์ทั้งหมด ของ type array of string (สมมติว่า ID แทนด้วย string) field นี้ก็สามารถ resolve ได้ทันทีเช่นกัน โดยไม่ต้อง implement กลไก "deferred"
สุดท้าย นอกจาก ID แล้ว resolver ยังต้องให้ข้อมูลเพิ่มเติมหนึ่งอย่าง: type ของ object ที่คาดหวัง (ในตัวอย่างของเรา อาจเป็น [(Film, 2), (Film, 5), (Film, 9)]) อย่างไรก็ตาม ข้อมูลนี้เป็นข้อมูลภายใน ส่งต่อไปยัง engine และไม่จำเป็นต้องแสดงใน response ของ query
การ implement แนวทางที่ปรับใช้ในโค้ด
มาดูกันว่า Gato GraphQL implement แนวทางนี้ใน PHP code อย่างไร โค้ดด้านล่างแสดง resolvers ที่แตกต่างกัน (เพื่อความชัดเจน โค้ดทั้งหมดด้านล่างได้รับการแก้ไข)
FieldResolvers
FieldResolvers รับ object ของ type เฉพาะ และ resolve fields ของมัน สำหรับความสัมพันธ์ มันยังต้องระบุ type ของ object ที่ resolve ไปด้วย นี่คือ contract ของมัน:
interface FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = []);
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}การ implement มีลักษณะดังนี้:
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;
}
}โปรดสังเกตว่าด้วยการลบ logic ที่จัดการ promises/deferred objects ออก โค้ดที่ resolve field author กลายเป็นสิ่งที่เรียบง่ายและกระชับมาก
TypeResolvers
TypeResolvers คือ objects ที่จัดการ type เฉพาะ: พวกมันรู้ชื่อของ type และ TypeDataLoader ที่โหลด objects ของ type นั้น รวมถึงสิ่งอื่นๆ
Data-loading engine เมื่อ resolve fields จะได้รับ IDs จาก TypeResolver class หนึ่งๆ จากนั้น เมื่อดึง objects สำหรับ IDs เหล่านั้น data-loading engine จะถามว่า TypeResolver ใช้ TypeDataLoader object ใดในการโหลด objects เหล่านั้น
Contract ของพวกมันถูกกำหนดดังนี้:
interface TypeResolverInterface
{
public function getTypeName(): string;
public function getTypeDataLoaderClass(): string;
}ในตัวอย่างของเรา class UserTypeResolver กำหนดว่า type User ต้องโหลดข้อมูลผ่าน class UserTypeDataLoader:
class UserTypeResolver implements TypeResolverInterface
{
public function getTypeName(): string
{
return 'User';
}
public function getTypeDataLoaderClass(): string
{
return UserTypeDataLoader::class;
}
}TypeDataLoaders
TypeDataLoaders รับรายการ IDs ของ type เฉพาะ และส่งคืน objects ที่สอดคล้องกันของ type นั้น นี่คือ contract ของมัน:
interface TypeDataLoaderInterface
{
public function getObjects(array $ids): array;
}การดึงข้อมูล users ทำดังนี้:
class UserTypeDataLoader implements TypeDataLoaderInterface
{
public function getObjects(array $ids): array
{
$userAPI = UserAPIFacade::getInstance();
return $userAPI->getUsers($ids);
}
}การดำเนินการ query ที่ (ใหญ่จริงๆ)
ลองทดสอบว่ากลยุทธ์นี้ทำงานได้ ไปที่ GraphiQL client ใน Gato GraphQL และดำเนินการ query ด้านล่าง ซึ่งเกี่ยวข้องกับกราฟที่ลึก 10 ระดับ (posts => author => posts => tags => posts => comments => author => posts => comments => author) และไม่สามารถ resolve ได้ในเวลาที่เหมาะสมหาก "ปัญหา n+1" เกิดขึ้น
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
}
}
}
}
}
}
}
}
}
}
}การเลื่อนลงดูผลลัพธ์จะเห็นว่า response มีขนาดใหญ่แค่ไหน มี entities เกี่ยวข้องมากแค่ไหน และดึงข้อมูลกี่ระดับ แต่ก็ยังดำเนินการได้รวดเร็ว โดยไม่มีปัญหาใดๆ