รูปแบบการแก้ปัญหา N+1: เช็คลิสต์การปรับแต่ง Database Query ให้เหมาะสม

รูปแบบการแก้ปัญหา N+1: เช็คลิสต์การปรับแต่ง Database Query ให้เหมาะสม

บทนำ

ปัญหา N+1 คือปัญหาด้านประสิทธิภาพที่จำนวนคิวรีเพิ่มขึ้นเป็น "N+1" เนื่องจากการดึงข้อมูลเรคคอร์ดหลัก 1 รายการแล้วตามด้วยการสอบถามข้อมูลที่เกี่ยวข้องแยกกันทีละรายการ ซึ่งสามารถแก้ไขได้โดยผ่าน 3 ขั้นตอน ได้แก่ การตรวจพบ (Detection) -> การวิเคราะห์สาเหตุ (Root Cause Analysis) -> การเลือกรูปแบบการแก้ไข (Pattern Selection) บทความนี้จัดทำขึ้นสำหรับนักพัฒนาแบ็คเอนด์ที่ใช้ ORM, นักออกแบบฐานข้อมูล และผู้รับผิดชอบด้านการเพิ่มประสิทธิภาพ โดยนำเสนอตั้งแต่ขั้นตอนการตรวจพบโดยใช้ Query Log หรือ APM ไปจนถึงวิธีการเลือกรูปแบบการแก้ไข 3 วิธี ได้แก่ JOIN, Batch Processing และ Caching รวมถึงขั้นตอนการตรวจสอบหลังการติดตั้งใช้งาน ในรูปแบบของรายการตรวจสอบ (Checklist) เมื่ออ่านจบ คุณจะสามารถเลือกวิธีที่เหมาะสมที่สุดโดยอิงจากโครงสร้างของข้อมูลที่เกี่ยวข้องในระบบของคุณ (ว่าเป็นค่าเดี่ยวหรือชุดข้อมูล) และความถี่ในการเข้าถึงข้อมูล เพื่อเริ่มดำเนินการปรับปรุงการใช้งานให้จำนวนการรันคิวรีลดลงเหลือไม่เกิน 1/10 ได้ นอกจากนี้ คุณยังสามารถใช้ตารางเปรียบเทียบและรายการตรวจสอบเพื่อเป็นเอกสารสำหรับแบ่งปันเกณฑ์การตัดสินใจในการเลือกรูปแบบภายในทีมได้อีกด้วย

ปัญหา N+1 ไม่สามารถวัดผลการปรับปรุงได้หากไม่ได้ตรวจสอบจำนวนคิวรีที่เกิดขึ้นจริงเป็นตัวเลขก่อนที่จะเลือกรูปแบบการแก้ไข เกณฑ์ในการตัดสินว่าปัญหา N+1 เกิดขึ้นหรือไม่ คือการดูว่าจำนวนคิวรีเพิ่มขึ้นตามสัดส่วนของจำนวนเรคคอร์ดหลัก (parent record) หรือไม่ โดยให้ระบุตำแหน่งที่เป็นสาเหตุอย่างเป็นขั้นตอนจาก 3 มุมมอง ได้แก่ การตรวจสอบด้วยตนเองผ่าน Query Log, การตรวจจับอัตโนมัติด้วย APM/Profiler และการระบุตำแหน่งที่เกิดปัญหาบนโค้ด

ขั้นตอนการตรวจสอบจำนวนการทำงานด้วย Query Log

การตรวจสอบจำนวนการประมวลผล ให้เริ่มจากการบันทึก SQL ที่ออกโดย ORM ลงในล็อก แล้วนับจำนวนคำสั่ง SELECT ที่ถูกประมวลผลในการแสดงผลหน้าจอหรือการตอบกลับ API หนึ่งครั้ง หากเป็น Django คุณสามารถใช้ connection.queries เพื่อดึงจำนวน SQL ที่ประมวลผลไปแล้วได้

python
1from django.db import connection, reset_queries 2 3reset_queries() 4authors = Author.objects.all() 5for author in authors: 6author.books.all() # ดึงข้อมูลที่เกี่ยวข้อง 7print(len(connection.queries))

หากจำนวนเรกคอร์ดหลักมี 10 รายการ แต่จำนวนในล็อกมี 11 รายการ (1+10) แสดงว่ากำลังเกิดปัญหา N+1 หากตรวจสอบพบว่าจำนวนคำสั่งเพิ่มขึ้นหรือลดลงตามสัดส่วนของจำนวนเรกคอร์ด ก็จะสามารถระบุจุดที่ยังไม่ได้รวมเป็น 1 คิวรีด้วย JOIN หรือวิธีอื่นได้ สำหรับขั้นตอนการตรวจสอบ ให้เรียกใช้งานหน้าจอหรือ API เป้าหมาย 1 ครั้งเพื่อบันทึกจำนวนคิวรี จากนั้นเพิ่มจำนวนเรกคอร์ดหลักในข้อมูลทดสอบเป็น 2 เท่าแล้วประมวลผลซ้ำ หากจำนวนคิวรีเพิ่มขึ้นในสัดส่วนที่ใกล้เคียงกัน มีความเป็นไปได้สูงว่าสาเหตุมาจากการเข้าถึงข้อมูลที่เกี่ยวข้องภายในลูป ซึ่งสามารถระบุเลขบรรทัดของโค้ดที่เกี่ยวข้องได้จากล็อก

การตรวจหาอัตโนมัติด้วย APM และ Profiler

การตรวจสอบบันทึก (log) ด้วยตนเองอาจเพียงพอสำหรับการตรวจสอบหน้าจอเดียวในระหว่างการพัฒนา แต่การค้นหาจุดที่เกิดปัญหาอย่างครอบคลุมในสภาพแวดล้อมจริง (production) นั้นต้องใช้ความพยายามอย่างมาก Django Debug Toolbar จะแสดงรายการจำนวนและเนื้อหาของ SQL ที่ถูกเรียกใช้ในแต่ละคำขอ (request) บนเบราว์เซอร์ ทำให้สามารถตรวจสอบการมีอยู่ของ N+1 ในแต่ละหน้าจอระหว่างการพัฒนาได้ทันที สำหรับ Ruby on Rails หากติดตั้ง Bullet gem ในสภาพแวดล้อมการพัฒนาและทดสอบ ระบบจะสามารถแจ้งเตือนผ่าน log หรือสร้างข้อยกเว้น (exception) เมื่อตรวจพบรูปแบบคิวรีที่น่าจะเป็น N+1 ทำให้สามารถตรวจจับได้โดยอัตโนมัติก่อนขั้นตอนการรีวิวโค้ด หากต้องการทราบถึงความถี่ในการเกิดปัญหาและเวลาที่ใช้ในการประมวลผลในสภาพแวดล้อมจริง การใช้เครื่องมือ APM เพื่อแสดงภาพการพุ่งสูงขึ้นของจำนวนการเรียกใช้ SQL ภายใน trace เดียวกัน หรือการทำซ้ำของคิวรีเดิมถือเป็นวิธีที่มีประสิทธิภาพ ในหน้าจอ trace จำนวน SQL ต่อหนึ่งคำขอจะถูกรวบรวมแยกตาม endpoint ทำให้สามารถจัดลำดับความสำคัญในการตรวจสอบ endpoint ที่มีจำนวนการเรียกใช้เพิ่มขึ้นอย่างรวดเร็วหลังจากปล่อยเวอร์ชันใหม่ได้

จุดสังเกตเพื่อระบุตำแหน่งที่เกิดปัญหาในโค้ด

การระบุตำแหน่งที่เกิดปัญหาจะสรุปอยู่ที่การค้นหาบรรทัดที่มีการเข้าถึงรีเลชันแอตทริบิวต์ (relation attribute) ภายในลูป หากในเนื้อหาของลูป เช่น for author in authors: มีการเขียนโค้ดที่เข้าถึงออบเจกต์ที่เกี่ยวข้อง (related object) เช่น author.books.all() หรือ author.profile.name บรรทัดนั้นก็คือจุดที่ทำให้เกิดการคิวรี (query) ตามจำนวนรอบของลูป กรณีที่มีการสืบค้นความสัมพันธ์โดยปริยายภายในเทมเพลตหรือซีเรียลไลเซอร์ (serializer) ก็เป็นจุดที่มองข้ามได้ง่ายเช่นกัน จึงจำเป็นต้องขยายขอบเขตการตรวจสอบให้ครอบคลุมไปถึงไฟล์เทมเพลตและกระบวนการซีเรียลไลซ์ของ API ไม่ใช่แค่เพียงโค้ดในส่วนของวิว (view layer) เท่านั้น

วิธีที่ช่วยให้ระบุตำแหน่งได้อย่างแม่นยำคือการตั้งค่า raiseload() ของ SQLAlchemy ไว้ที่รีเลชันแอตทริบิวต์ที่น่าสงสัย เพื่อให้เกิดข้อยกเว้น (exception) ทันทีที่มีการทำเลซี่โหลด (lazy load) เนื่องจากสแต็กเทรซ (stack trace) ของข้อยกเว้นจะบันทึกหมายเลขบรรทัดที่เป็นต้นเหตุเอาไว้โดยตรง จึงทำให้สามารถจำกัดวงจุดที่เกิดปัญหาได้รวดเร็วกว่าการคำนวณย้อนกลับจากจำนวนรายการในล็อก

ตารางเปรียบเทียบรูปแบบการแก้ไขปัญหา N+1

ตารางเปรียบเทียบรูปแบบการแก้ไขปัญหา N+1

หลักการพื้นฐานคือ หากความสัมพันธ์เป็นค่าเดี่ยว (Foreign Key, 1 ต่อ 1) ให้รวมเป็น 1 คิวรีด้วย JOIN แต่หากความสัมพันธ์เป็นกลุ่ม (หลายต่อหลาย, 1 ต่อหลายที่มีจำนวนรายการมาก) ให้เลือกใช้การประมวลผลแบบ Batch และหากเป็นข้อมูลอ้างอิงที่มีความถี่ในการอัปเดตต่ำ ให้ใช้การแคช (Caching) เพิ่มเติม โดยแบ่งการใช้งานตาม 3 แกนหลัก ได้แก่ ปริมาณข้อมูล, ความถี่ในการเข้าถึง และความถี่ในการอัปเดต ดังนี้:

รูปแบบจำนวนคิวรีรูปแบบความสัมพันธ์ที่เหมาะสมข้อควรระวัง
JOIN (select_related / joinedload / eager_load)1 ครั้งForeign Key, 1 ต่อ 1, 1 ต่อหลายที่จำนวนรายการลูกน้อยจำนวนแถวผลลัพธ์จะขยายตัวถึงระดับ Parent × Child และการใช้หน่วยความจำจะเพิ่มขึ้นจากคอลัมน์ที่ซ้ำซ้อน
การประมวลผลแบบ Batch (prefetch_related / selectinload / IN clause)ประมาณ 2 ครั้งหลายต่อหลาย, 1 ต่อหลายที่จำนวนรายการลูกมากหาก Parent Set มีขนาดใหญ่ จำนวนอาร์กิวเมนต์ใน IN clause จะบวมขึ้น
การดึงข้อมูลแบบ Batch ผ่าน Proxy (@BatchSize ฯลฯ)หลายครั้งแต่ลดจำนวนรายการลงการแก้ไข Proxy แบบ Lazy Loadingจำเป็นต้องปรับ Batch Size ให้เหมาะสมกับขนาดของแถวข้อมูล
การแคช (Redis ฯลฯ)0-1 ครั้งข้อมูลอ้างอิงที่มีความถี่ในการอัปเดตต่ำหากไม่ได้ออกแบบจังหวะการทำ Invalidated ไว้ จะทำให้ส่งคืนค่าเก่าอย่างต่อเนื่อง

หากไม่แน่ใจในการตัดสินใจ ให้เริ่มจากการพิจารณารูปแบบความสัมพันธ์ (ว่าเป็นค่าเดี่ยวหรือกลุ่ม) เพื่อเลือกระหว่าง JOIN หรือการประมวลผลแบบ Batch จากนั้นจึงพิจารณาเพิ่มการแคชเฉพาะข้อมูลที่ยืนยันได้ว่ามีความถี่ในการอัปเดตต่ำเท่านั้น หากนำการแคชไปใช้กับข้อมูลที่มีความถี่ในการอัปเดตสูงก่อน จะทำให้ความเสี่ยงเรื่องความไม่สอดคล้องของข้อมูลจากการลืมทำ Invalidated เพิ่มขึ้น ดังนั้นควรให้ความสำคัญกับการแก้ไขด้วย JOIN หรือการประมวลผลแบบ Batch เป็นลำดับแรก

เกณฑ์การเลือกใช้แต่ละรูปแบบ

เกณฑ์การเลือกจะพิจารณาจาก 2 ปัจจัย ได้แก่ อัตราส่วนจำนวนข้อมูลที่เกี่ยวข้องและความถี่ในการเข้าถึง หากเป็นความสัมพันธ์แบบ 1 ต่อกลุ่ม (1-to-many) ที่ข้อมูลลูกมีเพียงไม่กี่รายการต่อข้อมูลแม่ 1 รายการ การใช้ JOIN ก็เพียงพอแล้ว แต่หากใช้ JOIN กับความสัมพันธ์ที่มีข้อมูลลูกจำนวนหลายสิบรายการขึ้นไป จำนวนแถวในชุดผลลัพธ์จะขยายตัวขึ้นจนเท่ากับจำนวนแม่คูณด้วยจำนวนลูก ซึ่งจะส่งผลให้ปริมาณการรับส่งข้อมูลและการใช้หน่วยความจำแย่ลง ในกรณีนี้ให้เปลี่ยนไปใช้วิธีดึงชุดข้อมูลแม่มาพร้อมกันด้วยการประมวลผลแบบแบตช์ (batch processing) แล้วทำการรวมข้อมูลที่ฝั่งแอปพลิเคชันแทน

ลำดับการตัดสินใจคือ ขั้นแรกให้ตรวจสอบว่า Foreign Key เป็นสิ่งที่จำเป็นและเป็นค่าเดี่ยว (single value) หรือไม่ หากเป็นค่าเดี่ยวให้เลือกใช้ JOIN แต่หากเป็นกลุ่มข้อมูล (collection) ให้กำหนดเบื้องต้นว่าจะใช้การประมวลผลแบบแบตช์ จากนั้นให้ตรวจสอบว่าข้อมูลที่เกี่ยวข้องนั้นเป็นระบบอ้างอิงที่มีความถี่ในการอัปเดตต่ำ (เช่น ข้อมูลหลักหรือค่าการตั้งค่า) หรือไม่ หากเข้าข่าย ให้พิจารณาว่าสามารถข้ามขั้นตอนการประมวลผลแบบแบตช์หรือผลลัพธ์ของ JOIN ด้วยการทำ Caching ได้หรือไม่ ในทางกลับกัน หากนำ Caching ไปใช้กับข้อมูลที่มีความถี่ในการอัปเดตสูง จะทำให้เกิดความไม่สอดคล้องของข้อมูลเนื่องจากการลืมทำ Invalidation ได้

รูปแบบที่ 1: การดึงข้อมูลที่เกี่ยวข้องแบบกลุ่มด้วย JOIN

รูปแบบที่ 1: การดึงข้อมูลที่เกี่ยวข้องแบบกลุ่มด้วย JOIN

ความสัมพันธ์แบบ 1 ต่อ 1 หรือ Foreign Key ที่ซึ่งข้อมูลแม่ 1 รายการต้องมีข้อมูลลูก 1 รายการเสมอ สามารถรวมเข้าด้วยกันในคิวรีเดียวด้วย JOIN เพื่อกำจัดคิวรีแยกย่อยที่เคยเกิดขึ้นภายในลูปได้ สำหรับความสัมพันธ์แบบ 1 ต่อกลุ่ม (1-to-many) ซึ่งข้อมูลลูกมีได้หลายรายการ จำนวนแถวของผลลัพธ์จะเพิ่มขึ้น ดังนั้นการเลือกระหว่าง INNER JOIN และ LEFT JOIN จะส่งผลต่อการจัดการเรคคอร์ดที่ขาดหายไปและจำนวนแถวที่ได้ครับ

การเขียนโปรแกรมรวมเป็น 1 คิวรีด้วย INNER JOIN

การรวมข้อมูลด้วย INNER JOIN เป็นการนำไปใช้งานที่ Django ของ select_related,SQLAlchemy ของ joinedload และ Rails ของ eager_load ต่างก็สร้างคำสั่ง JOIN ขึ้นมาภายใน เพื่อดึงข้อมูลทั้งส่วนที่เป็นข้อมูลหลักและข้อมูลลูกด้วยการ SELECT เพียงครั้งเดียว วิธีนี้เหมาะสำหรับกรณีที่ Foreign Key เป็นค่าบังคับ (NOT NULL) และข้อมูลที่เกี่ยวข้องนั้นมีอยู่จริงเสมอ

python
1# Django: รวมเข้ากับ JOIN ด้วย select_related 2books = Book.objects.select_related("author").all() 3for book in books: 4print(book.author.name) # ไม่มีการคิวรีเพิ่มเติม

เมื่อระบุ select_related("author") การ JOIN ระหว่าง Book และ Author จะถูกรวมเข้าเป็น SQL เพียงคำสั่งเดียว และเมื่อเข้าถึง book.author ภายในลูป ก็จะไม่เกิดการคิวรีเพิ่มเติม สำหรับ SQLAlchemy หากเขียนว่า joinedload(Book.author) และใน Rails หากเขียนว่า Book.eager_load(:author) ก็จะได้ผลลัพธ์เช่นเดียวกัน ซึ่งทั้งหมดจะสร้าง SQL ออกมาในรูปแบบของคำสั่ง SELECT หนึ่งคำสั่งที่มีประโยค JOIN รวมอยู่ด้วย

ข้อควรระวังเมื่อรวมค่า NULL ด้วย LEFT JOIN

หากต้องการรวมข้อมูล Parent ที่ไม่มี Record ที่เกี่ยวข้องอยู่ในผลลัพธ์ด้วย ให้ใช้ LEFT JOIN ในกรณีที่ Foreign Key อนุญาตให้เป็น NULL และมีข้อกำหนดทางธุรกิจที่ห้ามตัดกรณีที่มีความสัมพันธ์เป็น 0 รายการออก (เช่น รายชื่อผู้ใช้ที่ไม่มีอีเมลที่ยังไม่ได้อ่าน) การใช้ INNER JOIN จะทำให้ Record ดังกล่าวหายไป สำหรับ select_related ของ Django นั้นจะสร้าง LEFT OUTER JOIN ให้โดยอัตโนมัติสำหรับ Foreign Key ที่อนุญาตให้เป็น NULL จึงไม่จำเป็นต้องระบุค่าเพิ่มเติมในการใช้งาน

python
1# Django: ดึงข้อมูล profile ที่อนุญาตให้เป็น NULL ได้ใน 1 คิวรี 2authors = Author.objects.select_related("profile").all()

ในฝั่งของแอปพลิเคชัน ต้องเตรียมเงื่อนไขสำหรับการตรวจสอบกรณีที่ Object ที่เกี่ยวข้องเป็น None เสมอ หากเข้าถึงข้อมูลโดยตรง เช่น author.profile.name โดยไม่มีการตรวจสอบ NULL จะทำให้เกิด Exception ใน Record ที่ไม่มีความสัมพันธ์นั้น ดังนั้นควรป้องกันด้วยรูปแบบ เช่น author.profile and author.profile.name

รูปแบบที่ 2: การดึงข้อมูลหลาย ID พร้อมกันด้วย Batch Processing

รูปแบบที่ 2: การดึงข้อมูลหลาย ID พร้อมกันด้วย Batch Processing

ในกรณีที่มีความสัมพันธ์แบบ 1 ต่อกลุ่ม (1-to-many) หรือกลุ่มต่อกลุ่ม (many-to-many) ซึ่งมีจำนวนข้อมูลลูกมาก ควรหลีกเลี่ยงการทำให้จำนวนแถวเพิ่มขึ้นจากการใช้ JOIN โดยใช้วิธีรวบรวมชุด ID ของข้อมูลแม่แล้วสอบถามด้วยคำสั่ง IN จากนั้นจึงนำมาเชื่อมโยงกันในหน่วยความจำที่ฝั่งแอปพลิเคชันแทน แม้ว่าจะไม่สามารถลดจำนวนคิวรีให้เหลือเพียงครั้งเดียวได้ แต่ก็สามารถควบคุมให้เหลือประมาณ 2 ครั้งได้ ประเด็นสำคัญในการนำไปใช้งานคือวิธีการเขียนคำสั่ง IN และการกำหนดขนาดของ Batch ซึ่งทั้งสองอย่างจำเป็นต้องปรับเปลี่ยนตามปริมาณข้อมูล

ตัวอย่างการเขียน Batch Query โดยใช้ IN clause

การดึงข้อมูลแบบแบตช์ด้วย IN clause คือการรวบรวมรายการ ID ของเรคคอร์ดหลักไว้ก่อน แล้วจึงสอบถามข้อมูลด้วยคำสั่ง SELECT เพียงครั้งเดียว จากนั้นจึงนำไปเชื่อมโยงกับแฮช (hash) บนหน่วยความจำในฝั่งแอปพลิเคชัน ซึ่ง prefetch_related ของ Django และ selectinload ของ SQLAlchemy จะสร้างวิธีการนี้ขึ้นมาโดยอัตโนมัติภายในตัว

python
1# การเขียนโค้ดคิวรีแบบแบตช์ด้วย IN clause อย่างง่าย 2author_ids = [a.id for a in authors] 3books = Book.objects.filter(author_id__in=author_ids) 4 5books_by_author = {} 6for book in books: 7books_by_author.setdefault(book.author_id, []).append(book) 8 9for author in authors: 10author.books_cache = books_by_author.get(author.id, [])

ในโค้ดนี้ เราจะดึงข้อมูล Book เพียงครั้งเดียวก่อนเริ่มลูป จากนั้นจึงจัดกลุ่มลงในดิกชันนารีโดยใช้ author_id เป็นคีย์ แล้วจึงนำไปกำหนดให้กับ Author แต่ละตัว ซึ่งต่างจากการใช้ JOIN ตรงที่จำนวนแถวของผลลัพธ์จะไม่ขยายตัวจนเท่ากับจำนวน Parent × Child ทำให้สามารถจำกัดการใช้หน่วยความจำได้แม้ในกรณีที่มีความสัมพันธ์แบบหนึ่งต่อกลุ่ม (one-to-many) ที่มีจำนวนข้อมูลลูกมาก

นอกจากนี้ ยังมีการนำ itertools.groupby มาใช้แทนการเขียนโค้ดจัดกลุ่มลงในดิกชันนารีด้วยตนเอง แต่ต้องระวังว่าจำเป็นต้องเรียงลำดับข้อมูลด้วย author_id ล่วงหน้าเสมอ

วิธีการกำหนดขนาด Batch

ขนาดของ Batch size จะถูกกำหนดโดยพิจารณาทั้งจากขีดจำกัดของพารามิเตอร์ในฐานข้อมูลและปริมาณการใช้หน่วยความจำของแอปพลิเคชัน สำหรับ PostgreSQL และ MySQL นั้น แม้ว่าจะไม่มีการกำหนดขีดจำกัดที่ชัดเจนสำหรับจำนวนรายการใน IN clause แต่หากเกินหลายพันรายการ อาจส่งผลให้แผนการทำงาน (Execution Plan) แย่ลงและทำให้เวลาในการประมวลผลคิวรีนานขึ้นได้ ในส่วนของ @BatchSize ใน JPA/Hibernate จะใช้เพื่อปรับจูนจำนวนรายการที่ต้องการดึงข้อมูลแบบกลุ่ม (Batch) ในขณะที่ทำการแก้ไข Proxy โดยปกติจะกำหนดให้อยู่ในช่วง 10 ถึง 100 รายการ

ลำดับในการตัดสินใจมีดังนี้: ขั้นแรกให้ตรวจสอบขนาดต่อแถวของตารางเป้าหมาย (จำนวนคอลัมน์และความยาวของสตริง) จากนั้นให้ตรวจสอบค่าสูงสุดของจำนวนเรคคอร์ดหลักที่คาดการณ์ไว้ หากขนาดของแถวมีขนาดใหญ่ (เช่น มี URL รูปภาพหรือคอลัมน์ที่เป็นข้อความยาวๆ) ควรจำกัดจำนวนรายการใน IN clause ไว้ที่ประมาณหลักร้อย แต่หากเป็นข้อมูลมาสเตอร์ทั่วไปที่มีขนาดแถวเล็ก การกำหนดไว้ที่ระดับ 1,000 รายการก็มักจะไม่ก่อให้เกิดปัญหาแต่อย่างใด

รูปแบบที่ 3: การลดจำนวนคิวรีด้วยกลยุทธ์ Caching

รูปแบบที่ 3: การลดจำนวนคิวรีด้วยกลยุทธ์ Caching

หลังจากลดจำนวนคิวรีด้วยการใช้ JOIN หรือการประมวลผลแบบแบตช์แล้ว สิ่งที่สามารถลดขั้นตอนการประมวลผลลงได้อีกคือข้อมูลอ้างอิงที่มีความถี่ในการอัปเดตต่ำ โดยให้ใช้สัดส่วนการอ่านและความถี่ในการเปลี่ยนแปลงข้อมูลเป็นเกณฑ์ในการตัดสินใจว่าควรเพิ่มการทำ Caching ไว้ที่จุดใด หากเลือกเป้าหมายผิดพลาดจะมีความเสี่ยงที่จะแสดงค่าเก่าออกมาอย่างต่อเนื่อง ดังนั้นจึงควรพิจารณาแยกส่วนระหว่างการเลือกแหล่งจัดเก็บข้อมูลและการออกแบบช่วงเวลาในการทำให้ข้อมูลเป็นโมฆะ (Invalidation timing)

การใช้ประโยชน์จาก Memory Cache (เช่น Redis)

อินเมมโมรีแคช (In-memory cache) อย่าง Redis เป็นกลไกที่ใช้จัดเก็บผลลัพธ์ที่ได้จากแบตช์โปรเซส (Batch processing) แยกตามคีย์ เพื่อลดการสอบถามข้อมูลไปยังฐานข้อมูลซ้ำในครั้งถัดไป โดยคีย์จะถูกออกแบบโดยการรวม ID หลักและชื่อที่เกี่ยวข้องเข้าด้วยกัน (ตัวอย่างเช่น author:123:books) และค่าจะถูกจัดเก็บในรูปแบบของรายการที่ทำซีเรียไลซ์ (Serialized list) สิ่งที่มักมองข้ามไปในจุดนี้คือ การเขียนโปรแกรมที่เรียก cache.get() แยกกันภายในลูป จะทำให้เกิดจำนวนครั้งในการเรียกใช้งานที่โครงสร้างเหมือนกับปัญหา N+1 ในระดับชั้นของแคชเอง

การตั้งค่าช่วงเวลาการทำ Cache Invalidation

วิธีการที่แน่นอนที่สุดในการกำหนดจังหวะเวลาสำหรับการทำ Cache Invalidation คือการลบ Key ทันทีหลังจากดำเนินการเขียนข้อมูล เมื่อเรคคอร์ดของ Author หรือ Book ถูกอัปเดตหรือลบ การลบ Key ที่เกี่ยวข้องอย่างชัดเจน เช่น author:123:books จะช่วยหลีกเลี่ยงความเสี่ยงในการส่งค่าเก่ากลับไป

python
1def save(self, *args, **kwargs): 2super().save(*args, **kwargs) 3cache.delete(f"author:{self.author_id}:books")

การลืมทำ Invalidation มักเกิดขึ้นกับกระบวนการที่ไม่ผ่าน Signal หรือการ save ของ ORM เช่น การอัปเดตแบบกลุ่ม (Bulk Update) หรือการรัน SQL โดยตรง สำหรับตารางที่มีช่องทางเหล่านี้อยู่ ไม่ควรพึ่งพาเพียงแค่ Hook สำหรับการลบเท่านั้น แต่ควรตั้งค่าวันหมดอายุของ Key (timeout) ให้สั้นลงเพื่อเป็นการป้องกันสองชั้น โดยทั่วไปสำหรับข้อมูลอ้างอิงที่มีความถี่ในการอัปเดตวันละไม่กี่ครั้ง ควรตั้งค่า timeout ไว้ที่ระดับไม่กี่นาทีถึงไม่กี่สิบนาที เพื่อให้มั่นใจว่าข้อมูลเก่าจะหมดอายุภายในระยะเวลาที่กำหนดแม้ Hook การลบจะไม่ทำงานก็ตาม หากตั้งค่า timeout นานเกินไป ผลกระทบจากการลืมทำ Invalidation จะคงอยู่เป็นเวลานาน ดังนั้นควรปรับลดระยะเวลาให้สั้นลงตามความถี่ในการเขียนข้อมูล

รายการตรวจสอบการแก้ไขปัญหา N+1

รายการตรวจสอบการแก้ไขปัญหา N+1

เพื่อป้องกันการตกหล่นในการตรวจสอบการใช้งาน (Implementation check) การสรุปรายการตามลำดับขั้นตอน ได้แก่ การตรวจพบ การจำแนกประเภท การประยุกต์ใช้ และการตรวจสอบ จะช่วยให้ตัดสินใจได้รวดเร็วยิ่งขึ้น

รายการตรวจสอบวิธีการตรวจสอบเงื่อนไขความสำเร็จ
การจำลอง N+1วัดจำนวน SQL ด้วย Query Log หรือ APMยืนยันความสัมพันธ์เชิงสัดส่วนระหว่างจำนวน Parent Record และจำนวน Query ที่ส่งออกไปแล้ว
การจำแนกรูปแบบความสัมพันธ์ตัดสินว่าเป็น Foreign Key, 1 ต่อ 1, 1 ต่อกลุ่ม หรือกลุ่มต่อกลุ่มระบุในโค้ดแล้วว่าเป็นค่าเดี่ยว (Single value) หรือกลุ่มข้อมูล (Collection)
การประยุกต์ใช้รูปแบบใช้งาน JOIN หรือ Batch Processing อย่างใดอย่างหนึ่งยืนยันจาก Log แล้วว่าจำนวน Query ลดลงหลังการประยุกต์ใช้
การตรวจสอบจำนวนผลลัพธ์ตรวจสอบการขยายตัวของจำนวนแถวหลังใช้ JOINยืนยันแล้วว่าจำนวนแถวที่ซ้ำกันของ Parent x Child อยู่ในระดับที่ไม่กระทบต่อการใช้หน่วยความจำ
การตัดสินใจเรื่อง Cacheตรวจสอบความถี่ในการอัปเดตและอัตราส่วนการอ่านจำกัดเฉพาะข้อมูลฝั่งอ่าน (Read-only) เท่านั้นที่จะนำมาทำ Caching
การออกแบบการยกเลิก (Invalidation)ตั้งค่า save/delete hook หรือ timeoutดำเนินการเขียนโค้ดลบ Key เมื่อมีการเขียนข้อมูลเรียบร้อยแล้ว
การรวมระบบตรวจจับอัตโนมัติติดตั้ง Bullet gem หรือ Debug Toolbar ในสภาพแวดล้อมการพัฒนาอยู่ในสถานะที่สามารถตรวจจับได้อย่างต่อเนื่องผ่าน CI หรือ Workflow การพัฒนา

เมื่อดำเนินการจนถึงขั้นตอนการรวมระบบตรวจจับอัตโนมัติเสร็จสิ้น จะถือเป็นการสร้างระบบที่ป้องกันการเกิด N+1 ในโค้ดใหม่ได้ตั้งแต่ขั้นตอนการพัฒนา หากยังมีความลังเลในการเลือกรูปแบบ ให้ย้อนกลับไปพิจารณาที่ 2 แกนหลัก คือ อัตราส่วนจำนวนความสัมพันธ์และความถี่ในการเข้าถึง แล้วเริ่มตัดสินใจใหม่จากการแยกแยะว่าเป็นค่าเดี่ยวหรือกลุ่มข้อมูล จะช่วยให้การตัดสินใจไม่ไขว้เขว

คำถามที่พบบ่อย: วิธีป้องกันความผิดพลาดในการแก้ปัญหา N+1

คำถามที่พบบ่อย: วิธีป้องกันความผิดพลาดในการแก้ปัญหา N+1

รูปแบบการแก้ไขปัญหาไม่ได้จบลงที่การนำไปใช้เพียงอย่างเดียว แต่ยังมีกรณีที่ต้องนำไปผสมผสานกันตามลำดับชั้นที่เกี่ยวข้องอีกด้วย นอกจากนี้ ในการตรวจสอบการปรับปรุง หากทำการตรวจสอบโดยไม่กำหนดเงื่อนไขการวัดให้ตรงกัน ก็จะไม่สามารถจับการเปลี่ยนแปลงของโหลดในสภาพแวดล้อมการใช้งานจริงได้อย่างถูกต้อง ต่อไปนี้จะขอสรุป 2 ประเด็นที่มักทำให้เกิดความเห็นต่างกันอย่างชัดเจน

กรณีใดที่ควรใช้รูปแบบการแก้ไขหลายวิธีร่วมกัน?

ควรใช้รูปแบบผสมผสานกันในกรณีที่มีลำดับชั้นของความสัมพันธ์ที่แตกต่างกันอยู่ภายในหน้าจอเดียว ตัวอย่างเช่น หน้าจอที่แสดงรายการผู้เขียน ไปจนถึงหนังสือ และประวัติการสั่งซื้อของหนังสือแต่ละเล่ม ความสัมพันธ์ระหว่าง "ผู้เขียน" กับ "หนังสือ" เป็นแบบหนึ่งต่อกลุ่ม (1-to-many) ที่มีจำนวนลูกมาก จึงเลือกใช้วิธี Batch Processing ส่วนความสัมพันธ์ระหว่าง "หนังสือ" กับ "หมวดหมู่" เป็นแบบหนึ่งต่อหนึ่ง (1-to-1) จึงรวมเข้าเป็น 1 คิวรีด้วย JOIN นอกจากนี้ เนื่องจากชื่อหมวดหมู่ของหนังสือมีการอัปเดตไม่บ่อยนัก จึงเลือกใช้การ Caching ผลลัพธ์จาก Batch Processing ในขณะที่ประวัติการสั่งซื้อมีการอัปเดตบ่อย จึงงดเว้นการใช้ Caching

การตัดสินใจควรทำแยกตามความสัมพันธ์ ไม่ใช่ทำทั้งหน้าจอ โดยให้ตรวจสอบแยกกันว่า Foreign Key เป็นค่าเดี่ยวหรือไม่, จำนวนลูกมีมากน้อยเพียงใด และความถี่ในการอัปเดตต่ำหรือไม่ หากใช้รูปแบบเดียวกับทุกลำดับชั้น จะเกิดปัญหาแถวข้อมูลขยายตัว (Row Explosion) จากการ JOIN ในลำดับชั้นที่มีชุดข้อมูลขนาดใหญ่ และจะเกิด Overhead ที่ไม่จำเป็นจาก IN clause ของ Batch Processing ในลำดับชั้นที่เป็นค่าเดี่ยว การเลือกใช้รูปแบบที่เหมาะสมแยกตามความสัมพันธ์ และตรวจสอบผลลัพธ์แยกตามลำดับชั้นในหัวข้อ "การใช้รูปแบบ (Pattern Application)" ของรายการตรวจสอบ (Checklist) ในภายหลัง จะช่วยป้องกันการตกหล่นในการประยุกต์ใช้ได้

จะตรวจสอบประสิทธิภาพหลังการแก้ไขได้อย่างไร?

การตรวจสอบหลังการแก้ไขคือการวัดผลซ้ำด้วยขั้นตอนเดิมภายใต้เงื่อนไขเดียวกับที่วัดไว้ก่อนการแก้ไข และบันทึกการเปลี่ยนแปลงของตัวเลข

  1. วัดจำนวนการออกคิวรีล็อกซ้ำ โดยใช้จำนวนข้อมูลทดสอบและ Endpoint เดียวกับก่อนการแก้ไข
  2. เปลี่ยนจำนวน Parent Record เป็น 2 เท่า และ 5 เท่า เพื่อยืนยันว่าจำนวนคิวรีจะไม่เพิ่มขึ้นตามสัดส่วน (คงที่อยู่ที่ประมาณ 1-2 ครั้ง)
  3. ในส่วนที่มีการใช้ JOIN ให้ตรวจสอบว่าจำนวนแถวของชุดผลลัพธ์อยู่ในขอบเขตที่คาดการณ์ไว้หรือไม่ (การขยายตัวของ Parent × Child ไม่เกินขีดจำกัดที่ยอมรับได้)
  4. ในส่วนที่มีการเพิ่ม Caching ให้วัดเวลาการตอบสนองทั้งในกรณี Cache Hit และ Miss เพื่อยืนยันว่าแม้ในกรณี Miss ก็ยังอยู่ในขอบเขตที่ยอมรับได้
  5. ใช้ APM กับปริมาณข้อมูลที่เทียบเท่ากับระบบจริง เพื่อยืนยันว่าจำนวนการรัน SQL และเวลาการรันรวมภายใน Trace เดียวกันมีการปรับปรุงขึ้น
  6. ในส่วนที่มีการใช้ Batch Processing ให้ตรวจสอบผ่านล็อกว่าจำนวนอาร์กิวเมนต์ใน IN clause ไม่เกินขีดจำกัดของ Batch Size

ตัวอย่างเช่น หากก่อนแก้ไขคิวรีเคยรัน 101 ครั้งสำหรับ Parent 100 รายการ แต่หลังแก้ไขแล้วยังคงที่อยู่ที่ 2-3 ครั้งแม้จะมี Parent 500 รายการ ก็สามารถตัดสินได้ว่าปัญหาการเพิ่มขึ้นตามสัดส่วนได้รับการแก้ไขแล้ว

ผู้เขียน・ผู้ตรวจสอบ

Yusuke Ishihara

Yusuke Ishihara

เริ่มเขียนโปรแกรมตั้งแต่อายุ 13 ปี ด้วย MSX หลังจบการศึกษาจากมหาวิทยาลัย Musashi ได้ทำงานพัฒนาระบบขนาดใหญ่ รวมถึงระบบหลักของสายการบิน และโครงสร้าง Windows Server Hosting/VPS แห่งแรกของญี่ปุ่น ร่วมก่อตั้ง Site Engine Inc. ในปี 2008 ก่อตั้ง Unimon Inc. ในปี 2010 และ Enison Inc. ในปี 2025 นำทีมพัฒนาระบบธุรกิจ การประมวลผลภาษาธรรมชาติ และแพลตฟอร์ม ปัจจุบันมุ่งเน้นการพัฒนาผลิตภัณฑ์และการส่งเสริม AI/DX โดยใช้ generative AI และ Large Language Models (LLM)