Query performance ที่ไม่ทำร้าย domain
จนถึงบทที่แล้ว เราทุ่มพลังไปกับ “ฝั่งเขียน” ทั้งหมด — map Order ทั้งก้อน, migration, optimistic concurrency, transaction boundary — เป้าหมายเดียวคือเซฟ Order ลง PostgreSQL อย่างซื่อสัตย์ต่อ model และปลอดภัยจากการเขียนชนกัน แต่ application จริงอ่านมากกว่าเขียนหลายเท่า และเส้นทางอ่านมีกับดักคนละชุด บทนี้ข้ามมาฝั่ง “อ่าน”: ทำยังไงให้ query หน้ารายการออเดอร์เร็ว โดยไม่ต้องลาก Order aggregate ที่รักษากฎธุรกิจไว้อย่างดีกลับมาให้กลวงหรือช้า
นี่คือรูป model ที่เราจะอ่าน — ตัวเดิมจากคอร์สก่อน ไม่แตะแม้แต่บรรทัดเดียว:
// FoodOrdering.Domain/Orders/ — ทวนจาก Course A บทที่ 2 + Course B บทที่ 2–3// (OrderLine เริ่มเป็น record ใน Course A บท 2 แล้วถูกยกระดับเป็น Entity<OrderLineId> ใน Course B บท 3)public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value); // ประกาศก่อนใช้เป็น key ของ OrderLinepublic sealed record ProductId(Guid Value);public sealed record Quantity(int Value);public sealed record Money(decimal Amount, string Currency){ public static Money Thb(decimal amount) => new(amount, "THB");}
public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
public sealed class OrderLine : Entity<OrderLineId> // entity class — มี identity ของตัวเอง{ public ProductId ProductId { get; } public Quantity Quantity { get; } public Money UnitPrice { get; } // ...}
public sealed class Order{ private readonly List<OrderLine> _lines = new(); private readonly List<IDomainEvent> _domainEvents = new(); public OrderId Id { get; } public OrderStatus Status { get; private set; } public Money Total { get; private set; } public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items) { /* รักษา invariant */ }}code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — query ในบทนี้อยู่ฝั่งอ่านที่ path FoodOrdering.Infrastructure/Queries/ แยกจาก IOrderRepository ฝั่งเขียนโดยตั้งใจ
N+1 แอบเกิดขึ้นได้ยังไง
หัวข้อที่มีชื่อว่า “N+1 แอบเกิดขึ้นได้ยังไง”สมมติงานง่ายที่สุด: แสดงหน้ารายการออเดอร์ที่บอกว่าแต่ละใบมีกี่รายการและยอดรวมเท่าไหร่ ถ้าเขียนแบบซื่อๆ ตามสัญชาตญาณ — โหลดออเดอร์มาก่อน แล้ววน loop หยิบข้อมูลลูกของแต่ละใบ — เราจะเดินเข้ากับดักที่ชื่อ N+1 ProblemN+1 Problembug ด้านประสิทธิภาพที่เกิดเมื่อ query รายการหลัก 1 ครั้ง แล้ววน loop query ข้อมูลที่เกี่ยวข้องของแต่ละแถวแยกอีกทีละครั้ง (N ครั้ง) เช่น โหลด Order มาทีละใบแล้ววน query OrderLine ของแต่ละใบซ้ำ — แก้ด้วย Include/eager loading หรือ projection เดียวArchitecture เต็มๆ:
// db คือ FoodOrderingDbContext (DbContext ก้อนเดียวจาก Course A บท 4)var ids = await db.Orders.Select(o => o.Id).ToListAsync(); // query #1 — ได้ N id
var lines = new List<string>();foreach (var id in ids){ // ทุกรอบของ loop = อีก 1 query เพราะโหลด order ทีละใบพร้อม Include var order = await db.Orders .Include(o => o.Lines) .FirstAsync(o => o.Id == id); // query #2 .. #N+1 lines.Add($"{id.Value}: {order.Lines.Count} รายการ, รวม {order.Total.Amount}");}// ออเดอร์ 500 ใบ = 1 + 500 = 501 query ยิงไปที่ PostgreSQLรูปแบบนี้คือ “1 query หลัก + N query ลูก” — จึงเรียก N+1 ที่ร้ายคือมันไม่ throw ไม่ error ไม่มีอะไรแดง มันแค่ ช้าลงเรื่อยๆ ตามจำนวนข้อมูล เดโมด้วย 5 ใบก็เนียน พอ production มี 5,000 ใบก็ล่ม อีกหน้าตาหนึ่งของ bug เดียวกันคือ lazy loading: ถ้าเปิด lazy-loading proxies ไว้ แค่เขียน foreach (var line in order.Lines) โดยที่ Lines ยังไม่ถูกโหลด EF Core จะแอบยิง query ให้เงียบๆ ทุกครั้งที่แตะ — loop เดียวกัน กับดักเดียวกัน ต่างแค่ไม่เห็น await คาตา
รากของปัญหาไม่ใช่ “query เยอะ” แต่คือ การตัดสินใจว่าจะโหลดข้อมูลลูกตอนไหน ถูกเลื่อนไปเกิดทีละแถวใน loop แทนที่จะบอก EF Core ให้จัดการทั้งหมดในคำสั่งเดียว ทางแก้มีสองสาย และการเลือกสายไหนคือหัวใจของบทนี้
Include: หายจาก N+1 แต่ลาก write model มาทั้งก้อน
หัวข้อที่มีชื่อว่า “Include: หายจาก N+1 แต่ลาก write model มาทั้งก้อน”สายแรกคือบอก EF Core ให้ eager-load ลูกมาพร้อมพ่อในคำสั่งเดียวด้วย Include — ยก loop ทิ้ง เหลือ query เดียว:
var orders = await db.Orders .AsNoTracking() // แค่อ่าน ไม่ต้อง track (เดี๋ยวอธิบายด้านล่าง) .Include(o => o.Lines) // JOIN OrderLines มาในคราวเดียว .ToListAsync(); // query เดียวจบ — N+1 หายไปN+1 หายจริง แต่สังเกตว่าเราได้อะไรกลับมา: List<Order> — write model ทั้งก้อน ทุกใบมี OrderLine entity ครบทุกตัว ทุก column (ProductId, Quantity, UnitPrice, OrderLineId …) ทั้งที่หน้ารายการต้องการแค่ “จำนวนรายการกับยอดรวม” มันคือการรื้อ Order aggregate ที่ออกแบบมาเพื่อรักษา invariant ตอนเขียน ขึ้นมาทั้งหลังเพื่ออ่านเลขสองตัว
มีรายละเอียดของ Include collection ที่ต้องรู้: การ JOIN หนึ่ง Order เข้ากับหลาย OrderLine ในคำสั่งเดียวทำให้ข้อมูลพ่อซ้ำทุกแถวลูก (cartesian) ยิ่ง Include หลาย collection พร้อมกันยิ่งบานเป็นผลคูณ EF Core จึงให้เลือกได้สองแบบ:
var orders = await db.Orders .AsNoTracking() .Include(o => o.Lines) .AsSplitQuery() // ยิงแยก 1 query ต่อ 1 collection แทน JOIN ก้อนเดียว .ToListAsync();- Single query (ค่าเริ่มต้น,
AsSingleQuery()) — round trip เดียวไป PostgreSQL แต่ข้อมูลพ่อซ้ำ เสี่ยง cartesian explosion เมื่อ Include หลาย collection - Split query (
AsSplitQuery()) — Npgsql ยิงหลายคำสั่งแยกกัน ไม่มีข้อมูลซ้ำ แต่มีหลาย round trip และถ้าไม่อยู่ใน transaction เดียว ข้อมูลระหว่างคำสั่งอาจไม่ consistent
ทั้งสองแบบยังตอบโจทย์ผิดข้ออยู่ดี: เรากำลังโหลด aggregate เต็มๆ เพื่องานที่ไม่ต้องการ aggregate เลย Include เหมาะเมื่อ จะแก้ไข aggregate จริงๆ (โหลดมาเพื่อเรียก method แล้วเซฟกลับ) ไม่ใช่เพื่อโชว์บนหน้าจอ
Projection: ยิงตรงเข้า read DTO
หัวข้อที่มีชื่อว่า “Projection: ยิงตรงเข้า read DTO”สายที่สองคือเลิกโหลด entity แล้วบอก EF Core ให้ เลือกเฉพาะ field ที่ต้องใช้ ยิงตรงเข้า DTO นี่คือ ProjectionProjectionการ query โดยเลือกเฉพาะ field ที่ต้องใช้ (Select ไปเป็น DTO) แทนที่จะโหลดทั้ง Aggregate ผ่าน change tracking — เร็วกว่าและไม่ผูกกับ entity graph ทั้งก้อน เหมาะกับ use case ที่แค่ 'อ่าน' เช่นหน้ารายการออเดอร์Architecture — Select(...) ไปเป็น type ที่ออกแบบมาเพื่ออ่านโดยเฉพาะ:
// DTO สำหรับหน้ารายการ — ไม่ใช่ entity ไม่มี identity ไม่มี behaviorpublic sealed record OrderSummaryDto( Guid OrderId, int LineCount, decimal TotalAmount, string Currency, OrderStatus Status);
var summaries = await db.Orders .Select(o => new OrderSummaryDto( o.Id.Value, // แปลงเป็น column Id ตรง ๆ o.Lines.Count, // EF แปลเป็น COUNT ในฐานข้อมูล ไม่โหลด OrderLine มาสักตัว o.Total.Amount, // owned type Money → column TotalAmount o.Total.Currency, // → column TotalCurrency o.Status)) .ToListAsync();EF Core แปล Select ก้อนนี้เป็น SQL ที่หยิบมาเฉพาะ 4-5 column กับ subquery COUNT สำหรับ Lines.Count — ไม่มี OrderLine ถูกโหลดขึ้น memory แม้แต่ตัวเดียว ไม่มี Order object ถูกสร้าง เหลือแค่ List<OrderSummaryDto> แบนๆ ที่ตรงกับสิ่งที่หน้าจอต้องใช้พอดี เทียบกับ Include: query เดียวเหมือนกัน แต่ payload เล็กกว่ามากและไม่ลาก write model มาเกี่ยวเลย
จุดที่คนสับสนบ่อยคือ AsNoTracking(): มันเกี่ยวกับ Change TrackingChange Trackingกลไกของ DbContext ที่จด snapshot ค่าตอน entity ถูกโหลดเข้ามา แล้วเทียบกับค่าปัจจุบันตอน SaveChanges เพื่อสร้าง UPDATE เฉพาะ column ที่เปลี่ยนจริง — มีต้นทุน จึงควรใช้ AsNoTracking() กับ query ที่ไม่ได้ตั้งใจแก้ไขArchitecture — กลไกที่ DbContext snapshot ค่าทุก entity ที่โหลดเข้ามา เพื่อเทียบตอน SaveChanges ว่า column ไหนเปลี่ยนบ้างแล้วค่อยสร้าง UPDATE เส้นทางที่แค่อ่านไม่มีวันเรียก SaveChanges ต้นทุน snapshot นั้นจึงเสียเปล่าล้วนๆ — ทั้งเวลาและ memory AsNoTracking() สั่งปิดมันทิ้งสำหรับ query นั้น
แต่ระวัง: AsNoTracking() มีผลกับ query ที่คืน entity เท่านั้น (เช่นสาย Include ด้านบน — ควรใส่) ส่วน projection ที่ยิงเข้า DTO อยู่แล้ว ผลลัพธ์ไม่ใช่ entity จึงไม่ถูก track ตั้งแต่แรก — ใส่ AsNoTracking() ไปก็ไม่มีผลอะไร อย่าโปรยมันแบบเวทมนตร์ ให้เข้าใจว่ามันปิดอะไร: track ต่อเมื่อคุณตั้งใจจะเซฟกลับ ไม่งั้นปิด
Write model ไม่เท่ากับ Read Model — สะพานไป CQRS
หัวข้อที่มีชื่อว่า “Write model ไม่เท่ากับ Read Model — สะพานไป CQRS”ถอยมามองภาพรวม เราเพิ่งใช้ model สองแบบกับข้อมูลก้อนเดียว:
- ฝั่งเขียน ใช้
Orderaggregate ผ่านIOrderRepository— โหลดทั้งก้อน เรียกOrder.Place(...)ด้วยMoney.Thb(120)ให้ root รักษา invariant แล้วเซฟ model นี้ออกแบบมาเพื่อ ความถูกต้องตอนเปลี่ยนสถานะ - ฝั่งอ่าน ใช้
OrderSummaryDtoที่ได้จาก projection ตรง — ไม่ผ่าน repository ไม่ผ่าน aggregate model นี้ออกแบบมาเพื่อ ความเร็วและรูปร่างที่ตรงกับหน้าจอ
นี่คือแก่นของแนวคิด Read ModelRead Modelโครงสร้างข้อมูลที่ออกแบบมาเพื่ออ่านโดยเฉพาะ ไม่ใช่ Aggregate เดิมที่ออกแบบมาเพื่อรักษา invariant ตอนเขียน — มักเป็นผลของ Projection หรือ view ที่ flatten หลายตารางเข้าด้วยกันเพื่อหน้าจอเดียวArchitecture: โครงสร้างข้อมูลที่ออกแบบมาเพื่ออ่านโดยเฉพาะ ไม่ใช่ aggregate เดิมที่ออกแบบมาเพื่อรักษา invariant ตอนเขียน สองฝั่งนี้ไม่จำเป็นต้องหน้าตาเหมือนกัน และมัก ไม่ควร เหมือนกันด้วยซ้ำ — บังคับให้หน้ารายการวิ่งผ่าน aggregate ทั้งก้อนคือการเอาข้อจำกัดของฝั่งเขียนไปถ่วงฝั่งอ่านโดยไม่ได้อะไรกลับมา
พอแยกความรับผิดชอบสองฝั่งนี้ออกจากกันอย่างเป็นทางการ — คำสั่งที่เปลี่ยนสถานะไปทางหนึ่ง, คำถามที่แค่อ่านไปอีกทางหนึ่ง — เราก็มาถึงประตูของ CQRS (Command Query Responsibility Segregation) พอดี บทนี้ไม่ได้สร้าง CQRS เต็มรูป (ยังใช้ FoodOrderingDbContext ก้อนเดียว ยังตาราง PostgreSQL ชุดเดียว) แต่ IOrderRepository สำหรับเขียน กับ projection สำหรับอ่าน คือ CQRS version เบาที่สุดที่ให้ประโยชน์แล้วจริง โดยไม่ต้องแยกฐานข้อมูลหรือ event sourcing ให้ปวดหัว
ถามตัวเองว่า query นี้จะ เขียนกลับ ไหม — ถ้าจะแก้ aggregate ให้โหลดผ่าน IOrderRepository (tracked, ทั้งก้อน) ถ้าแค่เอาไปแสดง ให้ Select เป็น DTO ตรงๆ อย่าโหลด aggregate มาอ่านเลขไม่กี่ตัว และอย่าวน query ใน loop เด็ดขาด
flowchart TB
subgraph NPLUS1["❌ version ดิบ: N+1 — 1 query หลัก + N query ลูก"]
A["query #1: โหลดออเดอร์ N ใบ"]
A --> B1["query #2: lines ของใบที่ 1"]
A --> B2["query #3: lines ของใบที่ 2"]
A --> B3["... อีก N-2 query ใน loop ..."]
A --> BN["query #N+1: lines ของใบที่ N"]
BN --> COST["รวม 1 + N query<br/>ช้าลงตามจำนวนข้อมูล"]
end
subgraph PROJ["✅ Projection: 1 query เดียว → read DTO"]
C["query เดียว: SELECT Id, COUNT(lines),<br/>TotalAmount, Currency, Status"]
C --> D["List<OrderSummaryDto><br/>ไม่โหลด aggregate • ไม่ผ่าน change tracking"]
end
คำบรรยายภาพ: ฝั่งบนคือ N+1 — query หลักหนึ่งครั้งแล้วแตกเป็น query ลูกทีละใบใน loop รวมเป็น 1 + N คำสั่งที่ยิงไป PostgreSQL และช้าลงเรื่อยๆ ตามจำนวนออเดอร์ ฝั่งล่างคือ projection — คำสั่งเดียวที่ให้ฐานข้อมูลคำนวณ COUNT และหยิบเฉพาะ column ที่ต้องใช้ ยิงตรงเข้า OrderSummaryDto โดยไม่แตะ change tracking และไม่ลาก write model ทั้งก้อนกลับมา
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- CQRS — แยก “ฝั่งเขียน” (aggregate + repository ที่รักษา invariant) ออกจาก “ฝั่งอ่าน” (projection ตรงเข้า read model) อย่างเป็นทางการ — ปลายทางที่บทนี้เปิดประตูให้
- Lazy Loading — การโหลด navigation เมื่อถูกแตะครั้งแรก ต้นตอคลาสสิกของ N+1 เมื่อเกิดขึ้นใน loop
- Repository — ประตูฝั่งเขียนที่โหลด aggregate ทั้งก้อน บทนี้ชี้ว่าเส้นทางที่แค่อ่านไม่ควรวิ่งผ่านมัน
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3อะไรคือสาเหตุของ N+1 problem?