ข้าม​ไป​ยัง​เนื้อหา

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 ของ OrderLine
public 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 ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — query ใน​บท​นี้​อยู่​ฝั่ง​อ่าน​ที่ path FoodOrdering.Infrastructure/Queries/ แยก​จาก IOrderRepository ฝั่ง​เขียน​โดย​ตั้งใจ

สมมติ​งาน​ง่าย​ที่สุด: แสดง​หน้า​รายการออเดอร์​ที่​บอกว่า​แต่ละ​ใบ​มี​กี่​รายการ​และ​ยอด​รวม​เท่า​ไหร่ ถ้า​เขียน​แบบซื่อๆ ตาม​สัญชาตญาณ — โหลด​ออเดอร์​มา​ก่อน แล้ว​วน loop หยิบ​ข้อมูล​ลูก​ของ​แต่ละ​ใบ — เรา​จะ​เดิน​เข้า​กับดัก​ที่​ชื่อ N+1 ProblemN+1 Problembug ด้าน​ประสิทธิภาพ​ที่​เกิด​เมื่อ query รายการ​หลัก 1 ครั้ง แล้ว​วน loop query ข้อมูล​ที่​เกี่ยวข้อง​ของ​แต่ละ​แถว​แยก​อีก​ที​ละ​ครั้ง (N ครั้ง) เช่น โหลด Order มา​ที​ละ​ใบ​แล้ว​วน query OrderLine ของ​แต่ละ​ใบ​ซ้ำ — แก้​ด้วย Include/eager loading หรือ projection เดียวArchitecture เต็มๆ:

❌ version ดิบ: loop ที่​ยิง query ที​ละ​ใบ​จน​กลาย​เป็น N+1
// 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 ให้​จัดการ​ทั้งหมด​ใน​คำ​สั่ง​เดียว ทาง​แก้​มี​สอง​สาย และ​การ​เลือก​สาย​ไหน​คือ​หัวใจ​ของ​บท​นี้

สาย​แรก​คือ​บอก 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 แล้ว​เซฟกลับ) ไม่ใช่​เพื่อ​โชว์​บน​หน้า​จอ

สาย​ที่​สอง​คือ​เลิก​โหลด entity แล้ว​บอก EF Core ให้ เลือก​เฉพาะ field ที่​ต้อง​ใช้ ยิง​ตรง​เข้า DTO นี่​คือ ProjectionProjectionการ query โดย​เลือก​เฉพาะ field ที่​ต้อง​ใช้ (Select ไป​เป็น DTO) แทนที่​จะ​โหลด​ทั้ง Aggregate ผ่าน change tracking — เร็ว​กว่า​และ​ไม่​ผูก​กับ entity graph ทั้ง​ก้อน เหมาะ​กับ use case ที่​แค่ 'อ่าน' เช่น​หน้า​รายการออเดอร์ArchitectureSelect(...) ไป​เป็น type ที่​ออกแบบ​มา​เพื่อ​อ่าน​โดย​เฉพาะ:

// DTO สำหรับหน้ารายการ — ไม่ใช่ entity ไม่มี identity ไม่มี behavior
public 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 ต่อ​เมื่อ​คุณ​ตั้งใจ​จะ​เซฟกลับ ไม่​งั้น​ปิด

ถอย​มา​มอง​ภาพ​รวม เรา​เพิ่ง​ใช้ model สอง​แบบ​กับ​ข้อมูล​ก้อน​เดียว:

  • ฝั่ง​เขียน ใช้ Order aggregate ผ่าน 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

ถาม​ตัวเอง​ว่า 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&lt;OrderSummaryDto&gt;<br/>ไม่โหลด aggregate • ไม่ผ่าน change tracking"]
  end

คำ​บรรยาย​ภาพ: ฝั่ง​บน​คือ N+1 — query หลัก​หนึ่ง​ครั้ง​แล้ว​แตก​เป็น query ลูก​ที​ละ​ใบ​ใน loop รวม​เป็น 1 + N คำ​สั่ง​ที่​ยิง​ไป PostgreSQL และ​ช้า​ลง​เรื่อยๆ ตาม​จำนวนออเดอร์ ฝั่ง​ล่าง​คือ projection — คำ​สั่ง​เดียว​ที่​ให้​ฐาน​ข้อมูล​คำนวณ COUNT และ​หยิบ​เฉพาะ column ที่​ต้อง​ใช้ ยิง​ตรง​เข้า OrderSummaryDto โดย​ไม่​แตะ change tracking และ​ไม่​ลาก write model ทั้ง​ก้อน​กลับ​มา


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • CQRS — แยก “ฝั่ง​เขียน” (aggregate + repository ที่​รักษา invariant) ออก​จาก “ฝั่ง​อ่าน” (projection ตรง​เข้า read model) อย่าง​เป็น​ทางการ — ปลายทาง​ที่​บท​นี้​เปิด​ประตู​ให้
  • Lazy Loading — การ​โหลด navigation เมื่อ​ถูก​แตะ​ครั้ง​แรก ต้นตอ​คลาสสิก​ของ N+1 เมื่อ​เกิด​ขึ้น​ใน loop
  • Repository — ประตู​ฝั่ง​เขียน​ที่​โหลด aggregate ทั้ง​ก้อน บท​นี้​ชี้​ว่า​เส้นทาง​ที่​แค่​อ่าน​ไม่​ควร​วิ่ง​ผ่าน​มัน

เช็กความเข้าใจ — บทที่ 7

ข้อ 1 / 3

อะไรคือสาเหตุของ N+1 problem?