Concurrency
บทก่อนๆ เราเก็บ Order ทั้งก้อนลง PostgreSQL ได้อย่างซื่อสัตย์ต่อ model แล้ว — strongly-typed ID กลายเป็น column ผ่าน Value Converter, Money ฝังเป็น owned type, _lines ยังปิดสนิทด้วย backing field, schema วิวัฒน์ได้ด้วย migration และ repository ที่เราใช้มาตลอดก็เป็นแค่ wrapper บางๆ รอบ DbSet<Order> แต่ทุกอย่างที่ผ่านมาสมมติเงียบๆ ว่ามีคนเดียวแก้ออเดอร์ใบหนึ่งในเวลาหนึ่ง โลกจริงไม่เป็นแบบนั้น
พนักงานสองคนเปิดออเดอร์ใบเดียวกันพร้อมกัน สองแท็บของ app เดียวกันกดยืนยันไล่หลังกันเสี้ยววินาที หรือ background worker กับ request ของผู้ใช้ชนกันพอดี คำถามของบทนี้คือ: เมื่อสอง SaveChanges เขียน Order ใบเดียวกัน ใครชนะ และอีกคนรู้ตัวไหมว่าเพิ่งทับงานคนอื่นไป ถ้าตอบว่า “คนเซฟทีหลังชนะ และไม่มีใครรู้เลย” นั่นคือ bug ที่ทำเงินหายได้จริง
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — การตั้ง concurrency token อยู่ใน FoodOrdering.Infrastructure/Persistence/FoodOrderingDbContext.cs และการจับ conflict อยู่ใน EfOrderRepository
สันนิษฐานว่าไม่ค่อยชน แทนที่จะล็อกไว้ก่อน
หัวข้อที่มีชื่อว่า “สันนิษฐานว่าไม่ค่อยชน แทนที่จะล็อกไว้ก่อน”มีสองวิธีหลักในการคุมการเขียนพร้อมกัน วิธีแรกคือ pessimistic concurrency — ล็อกแถวไว้ตั้งแต่ตอนอ่าน ใครจะแตะต้องรอจนกว่าเราจะเซฟเสร็จ ปลอดภัยแต่แพง: แถวถูกล็อกค้างระหว่างที่มนุษย์ยังคิดอยู่หน้าจอ ทำให้คำขออื่นรอเป็นแถวยาวและเสี่ยง deadlock
บทนี้ใช้ทางที่สองซึ่งเหมาะกับเว็บ app ส่วนใหญ่: Optimistic ConcurrencyOptimistic Concurrencyกลยุทธ์คุมการเขียนพร้อมกันที่ 'สันนิษฐานว่าไม่ค่อยชน' — ไม่ล็อกแถวไว้ล่วงหน้า แต่ตรวจตอน SaveChanges ว่าแถวยังเป็นค่าที่อ่านมาหรือไม่ ถ้ามีคนอื่นแก้ไปก่อนแล้วจะโยน DbUpdateConcurrencyException แทนที่จะ silently เขียนทับArchitecture — สันนิษฐานว่าการชนกันเกิดขึ้นไม่บ่อย จึงไม่ล็อกอะไรไว้ล่วงหน้าเลย ปล่อยให้ทุกคนอ่านและแก้ในสำเนาของตัวเองได้อิสระ แล้วค่อยไปตรวจตอนจะเขียนจริงว่าแถวยังเป็นค่าเดียวกับตอนที่เราอ่านมาหรือเปล่า ถ้ายัง — เขียนได้ ถ้าไม่ใช่ — แปลว่ามีคนแก้ตัดหน้าไปแล้ว EF Core จะไม่ยอมเขียนทับเงียบๆ แต่จะโยน exception ออกมาให้เราตัดสินใจแทน กลไกที่ทำให้ “ตรวจตอนเขียน” เป็นไปได้คือ concurrency token
Concurrency token: ตรา version ของแถว
หัวข้อที่มีชื่อว่า “Concurrency token: ตรา version ของแถว”Concurrency TokenConcurrency Tokencolumn (เช่น rowversion หรือ field version) ที่ EF Core ผูกไว้กับ Optimistic Concurrency แล้วใส่ใน WHERE clause ตอน UPDATE/DELETE — ถ้าค่าไม่ตรงกับตอนอ่านมา แปลว่ามีธุรกรรมอื่นแก้ Order ไปแล้วArchitecture คือ column ที่เก็บ “version ปัจจุบันของแถว” และจะเปลี่ยนค่าทุกครั้งที่แถวถูกแก้ EF Core จะจำค่านี้ไว้ตอนอ่าน Order เข้ามา แล้วใส่มันลงในเงื่อนไข WHERE ตอน UPDATE/DELETE ถ้ามีธุรกรรมอื่นแก้แถวไปก่อน ค่า version ในฐานข้อมูลจะไม่ตรงกับค่าที่เราถืออยู่ UPDATE จึงจับได้ 0 แถว — และนั่นคือสัญญาณของการชน
ที่มาของ “ค่า version” ต่างกันตามฐานข้อมูล บนคอร์สนี้ที่ใช้ PostgreSQL + Npgsql เรามีของฟรีที่ Postgres แถมมาให้ทุกแถวอยู่แล้ว: system column ชื่อ xmin ซึ่งเก็บ transaction id ที่แก้แถวนั้นล่าสุด ทุกครั้งที่แถวถูก UPDATE ค่า xmin จะเปลี่ยนเองโดยอัตโนมัติ — เหมาะเป็น concurrency token อย่างยิ่งเพราะไม่ต้องเพิ่ม column ไม่ต้องเขียน MigrationMigrationfile C# ที่ EF Core generate จาก diff ระหว่าง model ปัจจุบันกับ Model Snapshot ก่อนหน้า อธิบายการเปลี่ยน schema เป็น Up/Down ที่ควบคุม version ได้ และ apply กับฐานข้อมูลจริงตามลำดับArchitecture ใหม่ และไม่ต้องแตะ domain แม้แต่บรรทัดเดียว (Order ยังไม่รู้จัก EF Core เหมือนเดิม รักษา Persistence Ignorance ไว้ครบ)
ก่อนไปดู mapping ขอทวน Order รูปสุดท้ายที่เราจะเก็บ — ตัวเดิมจากคอร์สก่อน ไม่มีการ re-model:
// FoodOrdering.Domain/Orders/ (ทวนจาก Course A บทที่ 2 + Course B บทที่ 2–3 —// OrderLine เคยเป็น record ใน Course A แล้วถูกยกระดับเป็น entity ใน Course B; นี่คือรูปสุดท้าย)public sealed record OrderId(Guid Value);public sealed record OrderLineId(Guid Value); // ประกาศก่อนใช้ในบรรทัดถัดไปpublic sealed record ProductId(Guid Value);
public sealed class OrderLine : Entity<OrderLineId> // entity class ไม่ใช่ record{ public ProductId ProductId { get; } public Money UnitPrice { get; } // ...}
public sealed class Order{ public OrderId Id { get; } // ประตูเดียว/identity ของ aggregate public Money Total { get; private set; } // คำนวณจากทุกบรรทัด ไม่ set อิสระ public OrderStatus Status { get; private set; } public IReadOnlyCollection<OrderLine> Lines { get; }
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items); // invariant: ตะกร้าห้ามว่าง public void Confirm(); // Placed -> Confirmed เท่านั้น แล้วปล่อย OrderConfirmed}การตั้ง concurrency token เป็นงานของชั้น mapping ล้วนๆ — เพิ่มบรรทัดเดียวใน OnModelCreating ต่อยอดจากที่ Course A บท4 วาง OwnsOne(o => o.Total) / HasMany(_lines) / private-ctor rehydration ไว้ให้แล้ว (บทนี้ไม่ทวนของเดิม ต่อยอดขึ้นไปเลย):
// FoodOrdering.Infrastructure/Persistence/FoodOrderingDbContext.csprotected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Entity<Order>(order => { // ...OwnsOne(Total) / HasMany(Lines) / backing field _lines จาก Course A บท4 อยู่ตรงนี้แล้ว...
// PostgreSQL: ใช้ system column xmin ที่มีอยู่แล้วทุกแถวเป็น concurrency token // ไม่ต้องเพิ่ม column ไม่ต้องเขียน migration และไม่ต้องแตะ domain เลย order.UseXminAsConcurrencyToken();
// ถ้าใช้ SQL Server แทน Npgsql จะประกาศ column rowversion เป็น shadow property แทน: // order.Property<byte[]>("Version").IsRowVersion(); // กลไกเหมือนกันเป๊ะ ต่างแค่ที่มาของค่า version (xmin ของ Postgres vs rowversion ของ SQL Server) });}UseXminAsConcurrencyToken() ทำให้ทุก UPDATE ต่อแถว Orders ถูกเติมเงื่อนไข AND xmin = @เวอร์ชันตอนอ่าน ให้อัตโนมัติ ความต่างระหว่างมีกับไม่มี token เห็นชัดที่ SQL ที่ Change TrackingChange Trackingกลไกของ DbContext ที่จด snapshot ค่าตอน entity ถูกโหลดเข้ามา แล้วเทียบกับค่าปัจจุบันตอน SaveChanges เพื่อสร้าง UPDATE เฉพาะ column ที่เปลี่ยนจริง — มีต้นทุน จึงควรใช้ AsNoTracking() กับ query ที่ไม่ได้ตั้งใจแก้ไขArchitecture generate ออกมา:
-- ❌ version ดิบ: ไม่มี concurrency token → UPDATE ไม่มีเงื่อนไข versionUPDATE "Orders" SET "Status" = 1 WHERE "Id" = '...';-- ใครเซฟทีหลังก็ทับได้เสมอ ไม่มีใครรู้ว่าเพิ่งเขียนทับงานคนอื่นไป
-- ✅ มี token: xmin ตอนอ่านเข้าไปอยู่ใน WHERE ด้วยUPDATE "Orders" SET "Status" = 1 WHERE "Id" = '...' AND xmin = 100;-- ถ้ามีคนแก้ไปก่อน xmin ไม่ใช่ 100 แล้ว → จับได้ 0 แถว → EF Core โยน DbUpdateConcurrencyExceptionราก Order คือหน่วยของ concurrency
หัวข้อที่มีชื่อว่า “ราก Order คือหน่วยของ concurrency”สังเกตว่าเราตั้ง token ไว้บน ราก Order เท่านั้น ไม่ได้ตั้งบน OrderLine แต่ละบรรทัด — นี่ไม่ใช่ความบังเอิญ แต่เป็นผลโดยตรงจากนิยามของ AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง มี root เดียวเป็นประตูเข้าและเป็นหน่วยของ transaction เช่น Order ที่คุม OrderLine — งานของบทนี้คือ map Aggregate ทั้งก้อนลง SQL โดยไม่ทำลาย invariant ที่ root รักษาไว้Tactical Design: ราก aggregate เป็นทั้งประตูเข้าเดียว เป็นหน่วยของความสอดคล้อง และเป็นหน่วยของธุรกรรม ดังนั้นมันจึงเป็นหน่วยของ concurrency ด้วย token ตัวเดียวบนรากคุ้มครองทั้ง graph เพราะการเปลี่ยนแปลงที่มีความหมายทุกอย่างต้องผ่านราก และรากจะแก้ column ของตัวเอง (Status, Total) เสมอ — เช่น เพิ่มบรรทัดหนึ่งบรรทัด Total ก็ต้องคำนวณใหม่ ทำให้แถว Orders ถูก UPDATE และ xmin ถูกตรวจทุกครั้ง
นี่ยังสอดรับกับหลัก “1 aggregate ต่อหนึ่ง SaveChanges” ที่จะเป็นแกนของ Transaction BoundaryTransaction Boundaryขอบเขตที่กำหนดว่างานชิ้นไหนต้องสำเร็จหรือล้มเหลวพร้อมกันทั้งหมด — ปกติคือ1 Aggregate ต่อ1 SaveChanges เดียว ข้าม Aggregate ให้ใช้ eventual consistency แทนการยัดหลาย aggregate ไว้ใน transaction เดียวกันArchitecture ในบทถัดไป — เมื่อ DbContextDbContextจุดเข้าหลักของ EF Core ที่รวม connection, change tracker และ unit of work ไว้ในตัวเดียว มี DbSet<T> ต่อ Aggregate หนึ่งตัว — อายุสั้น (scoped ต่อ request) ไม่ใช่ singleton ที่ใช้ข้าม requestArchitecture เป็น Unit of WorkUnit of Workpattern ที่รวบรวมการเปลี่ยนแปลงหลาย object ไว้เป็นชุดเดียวแล้ว commit พร้อมกันเป็น transaction เดียว — DbContext ของ EF Core เป็น unit of work อยู่ในตัวแล้ว ไม่ต้องเขียน class ห่อซ้ำArchitecture ที่ commit ทั้งชุดทีเดียว การตรวจ token ก็เกิดที่จุด commit จุดเดียวนั้นพอดี ลองดูสองธุรกรรมที่กดยืนยันออเดอร์ใบเดียวกันพร้อมกัน:
sequenceDiagram participant A as ธุรกรรม A (พนักงาน ก) participant DB as PostgreSQL · แถว Order เลขที่ 42 participant B as ธุรกรรม B (พนักงาน ข) Note over DB: เริ่มต้น xmin = 100 A->>DB: อ่าน Order 42 (จำ xmin = 100) B->>DB: อ่าน Order 42 (จำ xmin = 100) Note over A: order.Confirm() แก้ผ่านราก Note over B: order.Confirm() แก้ผ่านราก (คนละสำเนา แต่ Order ใบเดียวกัน) A->>DB: SaveChanges → UPDATE ... WHERE Id=42 AND xmin=100 DB-->>A: จับได้ 1 แถว → commit สำเร็จ (xmin เลื่อนเป็น 101) B->>DB: SaveChanges → UPDATE ... WHERE Id=42 AND xmin=100 DB-->>B: จับได้ 0 แถว (ตอนนี้ xmin=101 แล้ว) → ชน Note over B: EF Core โยน DbUpdateConcurrencyException<br/>งานของ ข ยังไม่ถูกบันทึก
คำบรรยายภาพ: ทั้งสองธุรกรรมอ่าน Order 42 มาตอน xmin เท่ากับ 100 เหมือนกัน ธุรกรรม A เซฟก่อน UPDATE ที่มีเงื่อนไข xmin = 100 จึงจับแถวได้และ commit สำเร็จ — พร้อมกันนั้น Postgres เลื่อน xmin ของแถวเป็น 101 พอธุรกรรม B เซฟตาม UPDATE ที่ยังถือเงื่อนไข xmin = 100 จับแถวได้ 0 เพราะ version เปลี่ยนไปแล้ว EF Core จึงโยน DbUpdateConcurrencyException แทนที่จะเขียนทับงานของ A เงียบๆ งานของ B ยังไม่ถูกบันทึก และ B รู้ตัว
เมื่อชนกัน: DbUpdateConcurrencyException บอกอะไร
หัวข้อที่มีชื่อว่า “เมื่อชนกัน: DbUpdateConcurrencyException บอกอะไร”DbUpdateConcurrencyException มีความหมายเดียวและชัดเจน: แถวที่เรากำลังจะเขียน ถูกธุรกรรมอื่นแก้ (หรือลบ) ไปแล้วหลังจากที่เราอ่านมัน — SaveChanges คาดว่าจะกระทบ 1 แถว แต่กระทบจริง 0 แถวเพราะ concurrency token ไม่ตรง ผลที่สำคัญที่สุดคือ การเปลี่ยนแปลงของเรายังไม่ถูกบันทึก ฐานข้อมูลยังคงค่าของธุรกรรมที่ชนะไว้ครบ ไม่มีข้อมูลปนกันครึ่งๆ กลางๆ
จุดที่ต้องระวังเชิงสถาปัตยกรรม: DbUpdateConcurrencyException เป็นชนิดของ EF Core ถ้าเราปล่อยให้มันทะลุขึ้นไปถึงชั้น Application ตรงๆ แปลว่า EF Core รั่วออกนอก Infrastructure — ผิดหลัก Persistence Ignorance ที่คอร์สนี้รักษามาตลอด ทางที่สะอาดคือให้ repository ใน Infrastructure จับแล้วแปลมันเป็น exception ภาษา domain ก่อนปล่อยออกไป:
// FoodOrdering.Infrastructure/Persistence/EfOrderRepository.cspublic sealed class EfOrderRepository : IOrderRepository{ private readonly FoodOrderingDbContext _db; public EfOrderRepository(FoodOrderingDbContext db) => _db = db;
public Task<Order?> FindAsync(OrderId id, CancellationToken ct) => _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task SaveAsync(Order order, CancellationToken ct) { try { await _db.SaveChangesAsync(ct); // UPDATE ... WHERE Id = @id AND xmin = @xmin } catch (DbUpdateConcurrencyException ex) { // แปล exception ของ EF Core เป็นภาษา domain ก่อนปล่อยออกจาก Infrastructure // (OrderModifiedConcurrentlyException ประกาศไว้ในชั้น Application) — ชั้นบนจะได้ // ไม่ต้องรู้จัก EF Core เลย รักษา Persistence Ignorance ไว้ throw new OrderModifiedConcurrentlyException(order.Id, ex); } }}แก้ยังไง: reload+retry หรือส่งให้ผู้ใช้ตัดสิน
หัวข้อที่มีชื่อว่า “แก้ยังไง: reload+retry หรือส่งให้ผู้ใช้ตัดสิน”จับ conflict ได้แล้วต้องตัดสินใจ ห้ามกลืน exception ทิ้งเฉยๆ เพราะนั่นเท่ากับยอมให้งานหายไปโดยไม่มีใครรู้ มีสองทางหลัก:
- Reload + retry — เหมาะเมื่อการเปลี่ยนแปลงของเรากับของคนที่ชนะไม่ได้ขัดกันตรงๆ (เช่นต่างคนต่างเพิ่มคนละบรรทัด) ดึงค่าล่าสุดจากฐานข้อมูลมาทับสำเนาเก่า แล้วลองใหม่บนฐานที่สด:
catch (DbUpdateConcurrencyException ex){ var entry = ex.Entries.Single(); // entry ของ Order ที่ชน await entry.ReloadAsync(ct); // ดึงค่าปัจจุบัน + xmin ใหม่จาก DB มาทับ // ...นำการเปลี่ยนแปลงที่ตั้งใจไว้มา apply บนค่าที่สดแล้ว แล้ว SaveChangesAsync อีกครั้ง...}- Surface ให้ผู้ใช้ตัดสิน — เหมาะเมื่อทั้งสองฝ่ายแก้ field เดียวกันจนไม่มีทาง merge อัตโนมัติแบบปลอดภัย ปล่อย
OrderModifiedConcurrentlyExceptionที่แปลไว้ให้ทะลุขึ้นไปถึงชั้น API แล้วตอบ 409 Conflict พร้อมข้อความว่า “ออเดอร์นี้เพิ่งถูกแก้ไป โปรดโหลดใหม่แล้วลองอีกครั้ง” ให้มนุษย์เป็นคนเลือกว่าจะเอา version ไหน
flowchart TD
S["SaveChanges โยน<br/>DbUpdateConcurrencyException"] --> Q{"merge อัตโนมัติ<br/>ได้อย่างปลอดภัยไหม?"}
Q -- "ได้ (แก้คนละส่วน ไม่ขัดกัน)" --> R["reload ค่าล่าสุดจาก DB<br/>แล้วลองใหม่บนฐานที่สด"]
Q -- "ไม่ได้ (แก้ field เดียวกัน)" --> U["ตอบ 409 Conflict<br/>ให้ผู้ใช้ตัดสินใจเอง"]
R --> D["บันทึกสำเร็จ ไม่มีงานของใครหาย"]
U --> D
คำบรรยายภาพ: ทั้งสองทางจบที่จุดเดียวกัน — ไม่มีการเขียนทับเงียบๆ ความต่างอยู่ที่ว่าใครเป็นคนตัดสินใจ merge: code (เมื่อปลอดภัยพอ) หรือมนุษย์ (เมื่อการชนกันต้องใช้วิจารณญาณ)
last-write-wins ทำเงินหายเงียบๆ
หัวข้อที่มีชื่อว่า “last-write-wins ทำเงินหายเงียบๆ”ทำไมต้องลงทุนกับเรื่องนี้? เพราะทางเลือกตรงข้าม — last-write-wins หรือ “คนเซฟทีหลังชนะ” — คือค่า default เมื่อไม่มี concurrency token และมันอันตรายเงียบๆ ลองนึกภาพออเดอร์ใบหนึ่งราคารวม 250 บาท พนักงานสองคนเปิดพร้อมกัน:
- พนักงาน ก เพิ่มผัดไทยหนึ่งจาน
Totalในสำเนาของเขากลายเป็น 310 บาท แล้วเซฟ - พนักงาน ข ทำงานจากสำเนาเก่าที่
Totalยัง 250 เพิ่มชาไทยหนึ่งแก้วTotalของเขากลายเป็น 280 บาท แล้วเซฟตาม
ถ้าไม่มี token คำสั่ง UPDATE ของ ข ไม่มีเงื่อนไข version มันจึงเขียน Total = 280 ทับสำเร็จ — ผัดไทย 60 บาทของ ก หายไปทั้งที่ลูกค้าสั่งจริง ไม่มี error ไม่มี log ไม่มีใครรู้ ครัวไม่เห็นผัดไทย ยอดเงินไม่ตรงกับของที่ส่ง นี่คือ lost update คลาสสิก: การแก้ที่คำนวณจากค่าที่ล้าสมัยไปแล้ว ลบผลของอีกคนทิ้งอย่างเงียบเชียบ
จุดที่เงินเข้ามาเกี่ยวคือจุดที่เดิมพันสูงที่สุด — ยอดออเดอร์ผิด การตัดเงินซ้ำ ยอดคงเหลือที่บวกลบไม่ตรง ล้วนเริ่มจาก lost update แบบนี้ทั้งนั้น concurrency token เปลี่ยน “หายเงียบๆ” ให้กลายเป็น “conflict ที่มองเห็นและจัดการได้” ซึ่งเป็นก้าวแรกของความถูกต้องเรื่องเงิน เรื่องนี้จริงจังพอที่ คอร์สที่ 10 — Payments & Ledger จะยกทั้งคอร์สมาว่าด้วยการทำให้เงินไม่มีวันหายหรือเกิดเองจากอากาศ
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Aggregate — ราก aggregate เป็นหน่วยของความสอดคล้องและของ transaction จึงเป็นที่ที่เราวาง concurrency token ไว้ตัวเดียวเพื่อคุ้มครองทั้ง graph
- Unit of Work —
DbContextเป็น unit of work ในตัว จุดSaveChangesเดียวคือที่ที่ token ถูกตรวจและ conflict ถูกจับ - Payments & Ledger (คอร์สที่ 10) — ที่ที่ปัญหา lost update เรื่องเงินถูกยกมาว่าอย่างจริงจังเต็มคอร์ส
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3concurrency token ทำหน้าที่อะไร?