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

Aggregate ควร​ใหญ่​แค่​ไหน — ให้ concurrency เป็น​คน​ตอบ ไม่ใช่​ความ​รู้สึก

สอง DbContext เปิด​ออเดอร์​ใบ​เดียวกัน คน​หนึ่ง​แก้​บรรทัด​แรก อีก​คน​แก้​บรรทัด​ที่​สอง ไม่มี​บรรทัด​ไหน​ซ้ำ​กัน​สัก​บรรทัด

เรียก SaveChanges() ไป 2 ครั้ง ผ่าน 1 ครั้ง และ​ได้ DbUpdateConcurrencyException 1 ครั้ง

ex.Entries ของ exception ก้อน​นั้น​มี 1 รายการ และ entity ที่​อยู่​ใน​นั้น​คือ Order ไม่ใช่ OrderLine ที่​ใคร​สัก​คน​ไป​แก้

ตัวเลข​ชุด​นี้​ไม่​ได้​มา​จาก​การ​นึก​ภาพ มัน​มา​จาก EF Core 10.0.10 บน file SQLite 3.49.1 จริง​บน​ดิสก์ รัน​ด้วย .NET 10.0.10 และ​รัน​ซ้ำ​ได้​ด้วย​คำ​สั่ง​เดียว​ใน repo ของ​ไซต์​นี้

ธีสิส​ของ​หน้า​นี้

ขนาด​ของ aggregate ไม่ใช่​รสนิยม​การ​ออกแบบ แต่​เป็น การ​ตัดสิน​ใจ​เรื่อง contention ที่​นับ​ได้

concurrency token อยู่​บน​แถว​ของ aggregate root ดังนั้น​ทุก​การ​แก้​ที่​ต้อง​เขียน​แถว​นั้น​ใหม่ จะ​แย่ง​กันเอง​ไม่​ว่า​จะ​เกิด​จาก​คนละ use case หรือ​คนละ​บรรทัด​ก็ตาม

คำถาม​ที่​หน้า​นี้​ตอบ​จึง​ไม่ใช่ “aggregate ควร​เล็ก​หรือ​ใหญ่” แต่​คือ:

“ขอบ​ที่​คุณ​วาด​ไว้​ตอน​นี้ อยู่​ตรง​ไหน​เมื่อ​เทียบ​กับ​การ​แก้​ที่​ระบบ​คุณ​ทำ​จริง และ​ถ้า​ย้าย​มัน invariant ข้อ​ไหน​ตาย​ก่อน”

ทั้ง​สอง​ครึ่ง​ของ​คำถาม​ตอบ​ด้วย​จำนวน ไม่ใช่​ด้วย​กฎ

ไซต์​นี้​สอน​เรื่อง​ขนาด​ของ aggregate ไป​แล้ว​หลาย​ที่ และ​สอน​ถูก แต่​ทุก​ที่​จบ​ลง​ด้วย​คำ​สั่ง​ให้​ไป​วัด แล้ว​ไม่มี​ที่ไหน​วัด

ใบ​แรก​อยู่​ใน​คอร์ส Tactical Patterns บท​ที่​ว่าด้วย Aggregate ซึ่ง​เตือน​เอา​ไว้​เอง​ว่า “คุณ​ยัง​ต้อง​วัด round-trip และ database performance ของ​จริง”

ใบ​ที่​สอง​อยู่​ใน DDD Distilled บท Aggregate ที่​เขียน​ว่าการ​จะ​เปลี่ยน​ความคิด​ของ​ทีม “มัก​ต้อง​ใช้​เวลา​พิสูจน์​ให้​เห็น​ว่า transaction จะ​ล้มเหลว​อย่างไร”

หน้า​นี้​คือ​การ​ทำ​ตาม​สัญญา​สอง​ข้อ​นั้น มัน​ไม่​ได้​มา​เสนอ​ข้อ​โต้แย้ง​ใหม่ มัน​มา​เป็น เครื่องมือ ที่​ผลิต​จำนวน​ให้​เอา​ไป​ตัดสิน​ใจ

สาม​หน้า สาม​คำถาม​คนละ​ข้อ และ​หน้า​นี้​จงใจ​ไม่​ทับ​กับ​อีก​สอง​หน้า

  • บทเรียน จัดการ concurrency ว่าด้วย กลไก​ที่​รายงาน conflict — token ถูก​เทียบ​ตอน​ไหน 0 แถว​แปล​ว่า​อะไร แล้ว​จะ​กู้​อย่างไร
  • บทเรียน Aggregate กับ​การ​รักษา invariant ว่าด้วย กฎ​ที่​บอก​ให้​ทำให้​เล็ก และ​เหตุผล​เชิง​การ​ออกแบบ​ของ​กฎ​นั้น
  • หน้า​นี้​ว่าด้วย ตำแหน่ง​ที่​ขอบ​ของ​คุณ​อยู่​ตอน​นี้ เทียบ​กับ​การ​แก้​ที่​ระบบ​คุณ​ทำ​จริง

ถ้า​คุณ​เรียน​บทเรียน​สอง​บท​นั้น​มา​แล้ว สอง​ฉาก​แรก​ของ​หน้า​นี้​จะ​ไม่​ทำให้​แปลก​ใจ ส่วน​ฉาก​ที่​สาม​ควร​ทำ เพราะ​มัน​บอก​เงื่อนไข​ที่​บทเรียน​เหล่า​นั้น​ไม่​ได้​บอก

สิ่ง​ที่​คุณ​ยัง​ไม่​เคย​เห็น​คือ จำนวน และ​จำนวน​ของ​ขอบ​อีก​แบบ​วาง​เทียบ​ข้าง ๆ กัน

หน้าที่​รายงาน​ตัวเลข​โดย​ไม่​บอกว่า​นับ​อะไร คือ​หน้าที่​เอา​ไป​อ้าง​ต่อ​ไม่​ได้

ชื่อ​ใน​หน้า​นี้นับ​อะไร​จริง ๆ
conflictจำนวน​ครั้ง​ที่ SaveChanges() โยน DbUpdateConcurrencyException
entriesex.Entries.Count ของ exception ก้อน​นั้น
entity ที่​โหลดจำนวน entity ที่​ถูก materialise เข้า ChangeTracker
command ที่​ส่งจำนวน DbCommand ที่​ถูก​ส่ง​ออก​ไป​จริง​ระหว่าง​โหลด นับ​ด้วย DbCommandInterceptor โดย connection ถูก​เปิด​ไว้​ก่อน​เริ่ม​นับ​แล้ว
แถว​ที่ invariant พังจำนวน​แถว Order ที่ Total ไม่​ตรง​กับ​ผล​รวม​ของ OrderLine ใน​แถว​นั้น

ตาราง 1 — หน่วย​นับ​ทั้ง​ห้า​ของ​หน้า​นี้ ทุก​ตัว​เป็น​จำนวน​ของ​สิ่ง​ที่​นับ​ได้ ไม่มี​ตัว​ไหน​เป็น​ตัวเลข​เวลา · วัด​บน SQLite 3.49.1 · .NET 10.0.10 · EF Core 10.0.10

ตั้ง Order เป็น aggregate root เดียว วาง concurrency token ไว้​บน​ราก แล้ว​ให้​สอง​ฝั่ง​แก้​คนละ​บรรทัด

ออเดอร์​ที่​ใช้​ใน​ฉาก​นี้​มี 12 บรรทัด OrderLine เป็น​ลูก​ที่​ไม่มี token ของ​ตัวเอง ตาม​รูปทรง​ที่​ตำรา​สอน​กัน​มา​ตรง​ตัว

ทั้ง​สอง​ฝั่ง​โหลด​ออเดอร์​ใบ​เดียวกัน​พร้อม​บรรทัด​ทั้งหมด แก้​กัน​คนละ OrderLineId แล้ว​ต่าง​คน​ต่าง save

sequenceDiagram
    participant A as context A
    participant DB as file SQLite
    participant B as context B
    A->>DB: โหลด Order ใบเดียวกัน ได้ Version = 1
    B->>DB: โหลด Order ใบเดียวกัน ได้ Version = 1
    A->>DB: แก้บรรทัดแรก แล้ว SaveChanges
    DB-->>A: UPDATE Orders WHERE Version = 1 โดน 1 แถว save ผ่าน
    B->>DB: แก้บรรทัดที่สอง แล้ว SaveChanges
    DB-->>B: UPDATE Orders WHERE Version = 1 โดน 0 แถว จึงโยน DbUpdateConcurrencyException

ภาพ 1 — ของ​ที่​ถูก​เทียบ​ไม่ใช่​บรรทัด​ที่​ใคร​แก้ แต่​เป็น​แถว​ของ​ราก · ฝั่ง​ที่ save ทีหลัง​จึง​ถูก​ปฏิเสธ ทั้ง​ที่​ไม่มี​ใคร​แตะ​ของ​ชิ้น​เดียวกัน​เลย · วัด​บน SQLite 3.49.1 · .NET 10.0.10 · EF Core 10.0.10

ตัว​นับค่าที่​ได้
SaveChanges() ที่​เรียก2
save ที่​ผ่าน1
conflict1
รายการ​ใน ex.Entries1
ชนิด​ของ entity ใน ex.EntriesOrder

ตาราง 2 — ฉาก​ขอบ​อ้วน สอง context แก้​คนละ​บรรทัด​ของออเดอร์ 12 บรรทัด · วัด​บน SQLite 3.49.1 · .NET 10.0.10 · EF Core 10.0.10

สี่​แถว​แรก​ของ​ตาราง​นี้​ถูก​กำหนด​โดย​การ​ออกแบบ​ฉาก ไม่ใช่​โดย​ผล​การ​วัด — ฉาก​มี​สอง context ก็​ต้อง​ได้ save 2 ครั้ง แถว​สุดท้าย​ต่างหาก​ที่​เป็น​สิ่ง​ที่​เครื่อง​เป็น​คน​ตอบ: ชนิด​ของ entity ใน ex.Entries คือ Order ทั้ง​ที่​ไม่มี​ใคร​สั่ง​แก้ Order เลย​สัก​คน

จำนวน​นี้​คือ​ประโยค​ที่​สั้น​ที่สุด​ที่​บอกว่า token อยู่​ตรง​ไหน

บทเรียน EF Core ใช้ ex.Entries.Single() แล้ว​อ่าน​ค่า​จาก​ตัว​เดียว​นั้น แต่​ไม่​ได้​บอกว่า​ทำไม​มัน​ถึง​มี​ตัว​เดียว

คำ​ตอบ​คือ exception รายงาน แถว​ที่​ถูก​เทียบ​แล้ว​ไม่​โดน ไม่ใช่​ของ​ที่​คุณ​แก้ และ​แถว​ที่​ถูก​เทียบ​มี​แถว​เดียว​เสมอ คือ​แถว​ของ​ราก

รัน​ซ้ำ​โดย​เปลี่ยน​จำนวน​บรรทัด​ลูก​ที่​ฝั่ง​ที่​แพ้​แตะ ตั้งแต่ 1 ถึง 10 บรรทัด แล้ว​ตัวเลข​ไม่​ขยับ

บรรทัด​ลูก​ที่​แตะconflictรายการ​ใน ex.Entriesชนิด​ของ entity
111Order
211Order
511Order
1011Order

ตาราง 3 — แตะ​ลูก​กี่​ตัว​ก็ตาม exception ยัง​ชี้​ไป​ที่​ราก​ตัว​เดียว · วัด​บน SQLite 3.49.1 · .NET 10.0.10 · EF Core 10.0.10

ประโยค​ที่​เอา​ไป​ใช้ได้​เลย​คือ exception บอก​ชื่อ entity ที่​ถือ token ไม่ใช่​ชื่อ​ของ​ที่​คุณ​แก้

conflict ที่​ไม่​เกิด — เงื่อนไข​ที่​ทำให้​ฉาก​แรก​เป็น​จริง

หัวข้อ​ที่​มีชื่อ​ว่า “conflict ที่​ไม่​เกิด — เงื่อนไข​ที่​ทำให้​ฉาก​แรก​เป็น​จริง”

ถ้า​อ่าน​ฉาก​แรกว่า “ขอบ​อ้วน​ทำให้การ​แก้​ลูก​ที่​ไม่​เกี่ยว​กันชน​กัน​เสมอ” จะ​ผิด และ​ตัว​วัด​เดียวกัน​นี้​เป็น​คน​บอกว่า​ผิด

รัน​ซ้ำ​อีกรอบ​ด้วย mapping ชุด​เดิม ออเดอร์​ใบ​เดิม สอง context เหมือน​เดิม แก้​คนละ​บรรทัด​เหมือน​เดิม เปลี่ยน​แค่ ชนิด​ของ​การ​แก้

รอบ​นี้​เปลี่ยน​แค่​ข้อความ​กำกับ​บรรทัด ซึ่ง​ไม่มี invariant ข้อ​ไหน​ของ​ราก​ผูก​อยู่​ด้วย ราก​จึง​ไม่​ถูก​เขียน​ใหม่

ผล​คือ conflict 0 ครั้ง และ save ผ่าน​ทั้ง 2 ครั้ง

EF Core เทียบ token เฉพาะ​บน​แถว​ที่​มัน​สั่ง UPDATE จริง​เท่านั้น เมื่อ​ไม่มี UPDATE บน​แถว​ของ​ราก ก็​ไม่มี​อะไร​ถูก​เทียบ และ​ไม่มี​ใคร​ถูก​รายงาน

อ่าน​สอง​ฉาก​นี้​คู่​กัน ไม่ใช่​แยก​กัน

conflict ใน​ฉาก​แรก​เกิด​ขึ้น เพราะ invariant ข้อ ② บังคับ​ให้​ราก​ถูก​เขียน​ใหม่Order.ChangeQuantity(...) คำนวณ Total ใหม่​ทุก​ครั้ง​ตาม​ที่​บทเรียน Aggregate กับ​การ​รักษา invariant กำหนด

contention กับ invariant จึง​ไม่ใช่​สอง​เรื่อง มัน​คือ​เรื่อง​เดียวกัน​ที่​มอง​จาก​คนละ​ด้าน

การ​แก้​ที่​ไม่​แตะ​กฎ​ของ​ราก ก็​ไม่​แย่ง​อะไร​กับ​ใคร​แม้ aggregate จะ​อ้วน​แค่​ไหน ส่วน​การ​แก้​ที่​แตะ​กฎ​ของ​ราก จะ​แย่ง​กัน​เสมอ​แม้​จะ​อยู่​คนละ​บรรทัด

จำนวน​ชุด​นี้​คือ​จุด​ที่​หน้า​นี้​แก้​บทเรียน​ของ​ไซต์​เอง บทเรียน จัดการ concurrency เขียน​ว่า token ตัว​เดียว​บน​ราก​คุ้มครอง​ทั้ง graph เพราะ​ราก​จะ​แก้ column ของ​ตัวเอง​เสมอ — 0 conflict กับ 2 save ที่​ผ่าน​ใน​ฉาก​นี้​บอก​เงื่อนไข​ที่​ประโยค​นั้น​เป็น​จริง คือ​เมื่อ​การ​แก้​บังคับ​ให้ column ของ​ราก​เปลี่ยน ไม่ใช่​ทุก​การ​แก้​ที่​อยู่​ใน​ขอบ

เส้น​แบ่ง​นี้​สำคัญ เพราะ​ตำรา​ให้​คุณ เลือก ว่า​จะ​ให้​ราก​ขยับ version ตาม​การ​แก้​ลูก​หรือ​ไม่ แต่ EF Core ตัดสิน​ให้​จาก​สิ่ง​ที่ column เปลี่ยน​จริง โดย​ไม่​ถาม​ว่า​คุณ​ตั้งใจ​แบบ​ไหน คน​ที่​วาง token ไว้​บน​ราก​ตาม​กฎ จึง​ไม่​ได้​ความ​คุ้มครอง​ทั้ง aggregate อย่าง​ที่​คิด​ว่า​ซื้อ​มา

ให้ OrderLine เป็น root ของ​ตัวเอง ถือ token ของ​ตัวเอง แล้ว​รัน​การ​แก้​ชุด​เดิม​ซ้ำ

การ​แก้​ชุด​เดิม​เป๊ะ ๆ ที่​เคย​ได้ conflict 1 ครั้ง คราว​นี้​เรียก save ไป 2 ครั้ง ผ่าน 2 ครั้ง และ​ได้ conflict 0 ครั้ง

ถ้า​หน้า​นี้​จบ​ตรง​นี้ มัน​ก็​คือ​ใบปลิว จึง​มี​ฉาก​คุม​อีก​ฉาก​ที่​ห้าม​ตัด​ทิ้ง คือ​ให้​สอง​ฝั่ง​แก้ บรรทัด​เดียวกัน

ฉาก​บน​ขอบ​ที่​แยก​แล้วsave ที่​ผ่านconflictรายการ​ใน ex.Entries
แก้​คนละ​บรรทัด20
แก้​บรรทัด​เดียวกัน111

ตาราง 4 — ฉาก​คุม​บรรทัด​ที่​สอง​รายงาน entity ชื่อ OrderLine ไม่ใช่ Order · วัด​บน SQLite 3.49.1 · .NET 10.0.10 · EF Core 10.0.10

ข้อ​สรุป​ที่​ตัวเลข​รองรับ​จึง​ไม่ใช่ “แยก​แล้ว conflict หาย​ไป” แต่​เป็น conflict ย้าย​ที่​ไป​อยู่​บน​ของ​ที่​ชน​กัน​จริง

การ​ชน​ที่​เหลือ​อยู่​คือ​การ​ชน​ที่​ธุรกิจ​ต้องการ​ให้​ชน ส่วน​การ​ชน​ที่​หาย​ไป​คือ​ผล​ข้าง​เคียง​ของ​ขอบ ไม่ใช่​กฎ​ของ​ธุรกิจ

ราคา​ของ​การ​ย้าย​ขอบ​ไม่​ได้​จ่าย​เป็น​ความ​ยุ่งยาก​ของ code แต่​จ่าย​เป็น​กฎ​ธุรกิจ​ที่​หาย​ไป​หนึ่ง​ข้อ

Order ใน​คอร์ส DDD ใน code จริง ถือ invariant ไว้​สี่​ข้อ ข้อ ② คือ Total ต้อง​ตรง​กับ​ผล​รวม​ของ​ทุก​บรรทัด​เสมอ

ฉาก​นี้​แก้ Quantity หนึ่ง​ครั้ง​บน​ขอบ​ทั้ง​สอง​แบบ แล้ว​อ่าน​กลับ​จาก file SQLite เพื่อ​นับ​แถว​ที่ Total ไม่​ตรง​กับ​ผล​รวม​ของ​บรรทัด

รูปทรง​ของ​ขอบแถว​ที่ Total ไม่​ตรง
ราก Order ถือ​ทั้ง​กอง0
OrderLine เป็น root ของ​ตัวเอง1

ตาราง 5 — การ​แก้​จำนวน​หนึ่ง​ครั้ง บน​ขอบ​สอง​แบบ นับ​จาก​ค่าที่​อ่าน​กลับ​จาก file · วัด​บน SQLite 3.49.1 · .NET 10.0.10 · EF Core 10.0.10

บน​รูปทรง​ที่​แยก​แล้ว Order.Total ไม่มี​ใคร​แตะ เพราะ Order เป็น​คนละ aggregate และ​ไม่มี​ใคร​เรียก​มัน

ทาง​กู้​มี​สอง​ทาง และ​ทั้ง​สอง​ทาง​มี​ราคา

  • เขียน​สอง root ให้​จบ — ตัว​วัด​เดินทาง​นี้​จริง ต้อง​แตะ 2 root และ​เรียก save 2 ครั้ง​ตาม​กฎ 1 aggregate ต่อ 1 transaction หลัง​กู้​เสร็จ​เหลือ​แถว​ที่​พัง 0 แถว
  • ยอมรับ​หน้าต่าง​ที่ Total ยัง​ไม่​ตรง แล้ว​ปิด​ทีหลัง​ด้วย domain event ตาม​แนวทาง eventual consistency

ทาง​แรก​ดึง​ทั้ง​สอง root กลับ​มา​อยู่​ใน​การ​ตัดสิน​ใจ​เดียวกัน​อีกรอบ ซึ่ง​ก็​คือ​การ​เอา contention ที่​เพิ่ง​ย้าย​ออก​ไป​กลับ​เข้า​มา

ทาง​ที่​สอง​ไม่​ได้​แพง​ที่ code แต่​แพง​ที่​สัญญา เพราะ​มัน​เปลี่ยน​คำ​ตอบ​ของ​คำถาม​ว่า “ระบบ​ยอม​ให้ Total ผิด​ได้​นาน​แค่​ไหน” ซึ่ง​เป็น​คำถาม​ของ​ธุรกิจ ไม่ใช่​ของ​ทีม​พัฒนา

นี่​คือ​จุด​ที่​การ​ย้าย​ขอบ​เลิก​เป็น​เรื่อง​เทคนิค

ตาราง​นี้​มี​ไว้​เพื่อ​กัน​การ​สรุป​ว่า “แยก​แล้ว​โหลด​น้อย​ลง” ซึ่ง​จริง​แค่​กับ use case แบบ​เดียว

รูปทรง​ที่​วัด​มี​สาม​แบบ วัด​ที่​ออเดอร์​สี่​ขนาด — 1, 10, 100 และ 1000 บรรทัด

  • fat-full — โหลด Order พร้อม​บรรทัด​ทั้งหมด เพื่อ​แก้​บรรทัด​เดียว
  • split-line-only — โหลด​เฉพาะ OrderLine ที่​จะ​แก้
  • split-whole-order — โหลด​ทั้ง​ใบ​บน​รูปทรง​ที่​แยก​แล้ว สำหรับ use case ที่​ต้องการ​ทั้ง​ใบ​จริง ๆ
รูปทรงบรรทัด​ใน​ออเดอร์entity ที่​โหลดcommand ที่​ส่ง
fat-full121
fat-full10111
fat-full1001011
fat-full100010011
split-line-only111
split-line-only1011
split-line-only10011
split-line-only100011
split-whole-order122
split-whole-order10112
split-whole-order1001012
split-whole-order100010012

ตาราง 6 — entity ที่​ถูก materialise เข้า ChangeTracker และ​จำนวน DbCommand ที่​ถูก​ส่ง​จริง · วัด​บน SQLite 3.49.1 · .NET 10.0.10 · EF Core 10.0.10

สาม​อย่าง​ที่​ตาราง​นี้​บอก และ​ไม่มี​อัน​ไหน​เป็น​ข่าวดี​ล้วน ๆ

  • split-line-only โหลด 1 entity ใน​ทุกขนาด เพราะ​มัน​ไม่​ต้อง​รู้จัก​บรรทัด​อื่น​เลย
  • split-whole-order โหลด 1001 entity ที่ 1000 บรรทัด ซึ่ง​เป็น​จำนวน​เดียว​กับ fat-full แต่​ส่ง command 2 ครั้ง​แทนที่​จะ​เป็น 1 ครั้ง เพราะ​ไม่มี navigation ให้ EF join ให้ ตัว​วัด​จึง​ถาม​สอง​คำถาม​แยก​กัน (เขียน join ข้าม DbSet เอง​ก็ได้ แต่​ต้อง​เขียน​เอง)
  • fat-full ส่ง command 1 ครั้ง​ใน​ทุกขนาด — EF Core ไม่​ได้​เลือก​แตก query ใน​ฉาก​นี้

ข้อ​สรุป​ที่​ซื่อสัตย์​คือ คุณ​จ่าย​ตาม​รูปทรง​ที่ use case ส่วน​ใหญ่​ของ​คุณ​ต้องการ ไม่ใช่​ตาม​ขนาด​ของ aggregate

นี่​คือ​จุด​ที่​ตาราง​นี้​แก้​อีก​ประโยค​หนึ่ง​ของ​ไซต์​เอง บทเรียน Aggregate เขียน​ว่า aggregate ที่​เล็ก​กว่า​ถ่าย​โอน​ข้อมูล​น้อย​ลง — จริง​เฉพาะ​กับ split-line-only เท่านั้น ส่วน split-whole-order ซึ่ง​เป็น​ขอบ​ที่​เล็ก​กว่า​เหมือน​กัน โหลด entity เท่ากับ fat-full ทุกขนาด และ​ส่ง command มากกว่าด้วย​ซ้ำ ขนาด​ของ​ขอบ​จึง​ไม่​ได้​กำหนด​ปริมาณ​ที่​ถ่าย​โอน รูปทรง​ของ​การ​อ่าน​ต่างหาก​ที่​กำหนด

อีก​จำนวน​หนึ่ง​ที่​ควร​รู้​ก่อน​ตัดสิน​ใจ คือ​ของ​ที่​ฝั่ง​แพ้​จ่าย​ไป​แล้ว — บน​ออเดอร์ 1000 บรรทัด context ฝั่ง​ที่​แพ้ materialise ไว้​แล้ว 1001 entity ซึ่ง​ยัง​ค้าง​อยู่​ใน ChangeTracker หลัง exception ถ้า​เลือก​ทาง reload + retry ตาม บทเรียน​จัดการ concurrency คุณ​ใช้​มัน​ต่อ​ได้ ถ้า​ไม่ ทั้ง​กอง​นั้น​คือ​ของ​ที่​จ่าย​ไป​ฟรี

ก่อน​เอา​ตัวเลข​ใน​หน้า​นี้​ไป​อ้าง​กับ provider ของ​คุณ ให้​ดู​ก่อน​ว่า​ข้อ​ไหน​เป็น​ของ​ใคร

สาม​ข้อ​นี้​เป็น​พฤติกรรม​ของ​ชั้น optimistic concurrency ที่ EF Core เป็น​เจ้าของ​เอง จึง​เหมือน​กัน​ข้าม provider

  • token ถูก​เทียบ​ตอน SaveChanges ใน​รูป WHERE ของ​คำ​สั่ง UPDATE ไม่ใช่​ตอน​โหลด
  • UPDATE ที่​โดน 0 แถว กลาย​เป็น DbUpdateConcurrencyException บน relational provider ทุก​ตัว เพราะ EF Core นับ​แถว​ที่​ได้​รับ​ผลกระทบ​แล้ว​เทียบ​กับ​ที่​คาด​ไว้
  • ex.Entries ชี้​ไป​ที่ entity ที่​ถือ token ไม่ใช่ entity ที่​คุณ​แก้
  • ไม่มี rowversion และ​ไม่มี xmin ตัว​วัด​นี้​จึง​ประกาศ property ของ​ตัวเอง​แล้ว​มาร์กด้วย IsConcurrencyToken() และ​เพิ่ม​ค่า​เอง​ใน code ตาม​ทาง​ที่ Microsoft Learn ระบุ​ไว้​สำหรับ​ฐาน​ข้อมูล​ที่​ไม่มี token อัตโนมัติ
  • หมายเลข​รุ่น​ของ​เอนจิน ที่​กำกับ​ทุก​ตาราง​ใน​หน้า​นี้​เป็น​ของ​เครื่อง​ที่​รัน ไม่ใช่​ค่า​คงที่​ของ​บทความ

ส่วน​พฤติกรรมการ​ล็อก​ของ​แต่ละ​เอนจิน หน้า​นี้ ไม่​อ้าง​อะไร​เลย ทั้ง row lock, isolation level และ MVCC อยู่​นอก​สิ่ง​ที่​ตัว​วัด​นี้​แตะ

กติกา​ของ​ตัวเลข​ใน​หน้า​นี้

ตัวเลข​ทุก​ตัว​ข้าง​บน​มา​จาก harness ที่​อยู่​ใน repo ของ​ไซต์​นี้​ที่ scripts/aggregate-size/ และ​รัน​ซ้ำ​ได้​ด้วย npm run gen:aggregate-size

provider คือ file SQLite 3.49.1 จริง​บน​ดิสก์ ไม่ใช่ in-memory provider เพราะ in-memory provider ไม่​บังคับ transaction จริง ทั้ง​ฉาก​แรก​และ​ฉาก invariant จะ​ไม่มี​ความหมาย​บน​มัน

ข้อมูล​ตั้งต้น​ไม่มี​การ​สุ่ม รัน​กี่​รอบ​ก็ได้​ผล​ชุด​เดิม และ​มี​เทส​ที่​รัน harness ซ้ำ​แล้ว​เทียบ file ข้อมูล​ที​ละ byte

ยกเว้น​สาม​บรรทัด​ที่​เป็น​ป้าย​ของ​เครื่อง — SQLite 3.49.1 .NET 10.0.10 EF Core 10.0.10 — ซึ่ง​ถูก​ยกเว้น​จาก​การ​เทียบ​ที​ละ byte แล้ว​ตรวจ​แยก​ว่า​รูปร่าง​ถูก เพราะ​เครื่อง​ที่​รัน​ต่าง​กัน​ย่อม​รายงาน​รุ่น​ต่าง​กัน และ​นั่น​ไม่ใช่​ข้อมูล

ถ้า​ใคร​แก้ตัวเลข​ใน​หน้า​นี้​ด้วย​มือ เทส​จะ​แดง​ทันที

เพราะ​สิ่ง​ที่​ขอบ​เป็น​เจ้าของ​คือ conflict จะ​เกิด​หรือ​ไม่​เกิด ไม่ใช่​ว่า​มัน​เกิด​ถี่​แค่​ไหน

conflict จะ​เกิด​หรือ​ไม่ เป็น​คุณสมบัติ​ของ ขอบ​ที่​คุณ​วาด เทียบ​กับ การ​แก้​ที่​ระบบ​คุณ​ทำ สอง​อย่าง​นี้​ตัว​วัด​นี้​ยืนยัน​ได้

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

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

ตัวเลข​ของ​ระบบ​คุณ ให้​วัด​บน​เครื่อง​คุณ ด้วย traffic ของ​คุณ แล้ว​ติด​ป้าย​กำกับ​ไว้​ว่า​วัด​ที่ไหน​เมื่อไร

สี่​ข้อ​นี้​ตอบ​ได้​ด้วย​ของ​ที่​คุณ​มี​อยู่​แล้ว ไม่​ต้อง​รื้อ model ก่อน

  1. หา entity ที่​ถือ token ให้​เจอ​ก่อน ไล่​ดู​ว่า​มี IsConcurrencyToken(), IsRowVersion() หรือ token ของ provider อยู่​บน entity ตัว​ไหน นั่น​คือ​หน่วย​ของ contention จริง​ของ​คุณ ไม่ใช่​ตัว​ที่​ผัง​ใน​หัว​บอก
  2. เขียน​รายการ​การ​แก้​ที่​ระบบ​คุณ​ทำ​จริง แล้ว​ขีด​ว่า​แต่ละ​อัน​ต้อง​เขียน​แถว​ของ​ราก​ใหม่​หรือ​ไม่ — อัน​ที่​ต้อง​เขียน จะ​แย่ง​กันเอง​แม้​จะ​เป็น​คนละ use case
  3. เปิด ex.Entries ตอน​เจอ conflict แล้ว​อ่าน​ชื่อ entity ถ้า​ชื่อ​ที่​ได้​ไม่ใช่​ของ​ที่​ผู้​ใช้​กำลัง​แก้ แปล​ว่า​ขอบ​ของ​คุณ​กว้าง​กว่า​เหตุผล​ทาง​ธุรกิจ​ที่​รองรับ​มัน
  4. ก่อน​จะ​แยก ให้​ถาม​ว่า invariant ข้อ​ไหน​จะ​ตาย แล้ว​ถาม​ต่อว่า​ธุรกิจ​ยอม​ให้​มัน​ผิด​ชั่วคราว​ได้​นาน​แค่​ไหน ถ้า​คำ​ตอบ​คือ​ไม่​ยอม​เลย ขอบ​นั้น​ห้าม​แยก และ conflict ที่​เจอ​คือ conflict ที่​ถูกต้อง

ข้อ 4 คือ​ข้อ​ที่​ตัดสิน​จริง ๆ เพราะ​สาม​ข้อ​แรก​ให้​จำนวน แต่​ข้อ 4 ให้​คำถาม​ที่​เจ้าของ​ธุรกิจ​ต้อง​เป็น​คน​ตอบ

🔗 แหล่ง​อ้างอิง
  • Vaughn Vernon — Effective Aggregate Design, Part I: Modeling a Single Aggregate (2011) — PDF ที่มา​ของ​กฎ “ทำให้​เล็ก” ที่​หน้า​นี้​เอา​มา​วัด
  • Vaughn Vernon — Effective Aggregate Design, Part II: Making Aggregates Work Together (2011) — PDF การ​อ้างอิง​ด้วย id และ eventual consistency ข้าม aggregate
  • Vaughn Vernon — Effective Aggregate Design, Part III: Gaining Insight Through Discovery (2011) — PDF และ หน้า​รวม​ของ​ชุด​บทความ
  • Eric Evans — Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) — นิยาม​ของ aggregate ใน​ฐานะ consistency boundary (หนังสือ ไม่มี​ฉบับ​ออนไลน์​ทางการ)
  • Eric Evans — Domain-Driven Design Reference (ฉบับ 2015-03) — PDF
  • Microsoft Learn — Handling Concurrency Conflicts — EF Core (เผยแพร่ 2022-10-19 ปรับปรุง​ล่าสุด 2025-10-30) — เอกสาร แหล่ง​หลัก​ของ​เส้น​แบ่ง​ใน​หน้า​นี้ และ​เป็น​ที่มา​ของ​ทาง IsConcurrencyToken() ที่ harness ใช้บน SQLite
  • Microsoft Learn — DbUpdateConcurrencyException — นิยาม​ของ Entries ที่​หน้า​นี้​รายงาน​เป็น​จำนวน

อ่าน​ต่อ