ป้องกันสองชั้นไม่ให้ส่งการแจ้งเตือน "โอนเงินสำเร็จ" ซ้ำ — Idempotent Consumer กับความละเอียดของคีย์กันซ้ำที่เกือบทำให้ข้อความที่สองที่ถูกต้องหายไป

分散システム Kafka 冪等性 Idempotent Consumer 設計

บทนำ

ฉันมีเซอร์วิสแจ้งเตือนอยู่ในระบบเชื่อมต่อธนาคารที่สร้างเอง เป็นส่วนที่รับอีเวนต์แล้วส่งการแจ้งเตือนแบบ “การโอนเงินเสร็จสมบูรณ์แล้ว” ผ่านอีเมลหรือ push notification

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

เรื่องความสอดคล้องฝั่งผู้ส่ง ไม่ให้เงินหายแม้จะมีบางอย่างล่มระหว่างโอนข้ามบัญชี (การส่งที่เชื่อถือได้จากฝั่งผู้ส่ง) เขียนไว้ใน ไม่ให้เงินหายไปแม้เซอร์วิสจะล่มระหว่างการโอน บทความนี้เป็นด้านตรงข้ามของเรื่องนั้น คือ ไม่ประมวลผลอีเวนต์เดียวกันซ้ำสองครั้งที่ฝั่งผู้รับ


ทำไมการแจ้งเตือนถึงมาถึงซ้ำสองได้

เซอร์วิสแจ้งเตือนรับอีเวนต์ (เช่น “การโอนเสร็จสมบูรณ์”) ที่เซอร์วิสอื่นส่งออกมา ผ่านแพลตฟอร์มข้อความ (Kafka) สิ่งที่สำคัญตรงนี้คือการรับประกันการส่งที่เรียกว่า at-least-once (อย่างน้อยหนึ่งครั้ง)

แพลตฟอร์มข้อความไม่ได้รับประกัน “แค่ครั้งเดียว” แต่รับประกันว่า “ต้องส่งถึงอย่างน้อยหนึ่งครั้งแน่นอน” มองอีกด้านหนึ่งคือ มันยอมให้อีเวนต์เดียวกันมาถึงสองครั้งได้ เนื่องจากการส่งซ้ำของเครือข่ายหรือการ rebalance การไม่ทำอีเวนต์หล่นแม้แต่ครั้งเดียว มีต้นทุนคือบางครั้งจะซ้ำ — นั่นคือ at-least-once

เซอร์วิสผู้ส่ง ──"การโอนเสร็จสมบูรณ์(event_id=A)"──▶ แพลตฟอร์มข้อความ ──▶ เซอร์วิสแจ้งเตือน

                                                          └─ การส่งซ้ำทำให้ event_id=A เดิมมาถึงอีกครั้ง

                                                (ถ้าประมวลผลแบบไร้เดียงสา) การแจ้งเตือนจะถูกส่งสองครั้ง

ดังนั้นฝั่งผู้รับต้องจดจำเองว่า “event_id นี้ประมวลผลไปแล้ว” และปฏิเสธครั้งที่สอง ผู้รับที่มีพฤติกรรมว่า “ไม่ว่าจะได้รับกี่ครั้ง ผลลัพธ์ก็เท่ากับหนึ่งครั้ง” เรียกว่า Idempotent Consumer (ผู้บริโภคที่มีคุณสมบัติidempotent) idempotentคือคุณสมบัติที่ว่าไม่ว่าจะทำการดำเนินการเดิมซ้ำกี่ครั้ง ผลลัพธ์ก็ไม่เปลี่ยนแปลง


การป้องกันสองชั้นของทะเบียนรับ (Inbox) กับข้อจำกัดในฐานข้อมูล

รูปแบบมาตรฐานสำหรับไม่ประมวลผลซ้ำคือ รูปแบบ Inbox (ทะเบียนบันทึกอีเวนต์ที่รับแล้ว เรียกอีกอย่างว่า Transactional Inbox) บันทึก ID ของอีเวนต์ที่ประมวลผลแล้วลงในตารางเฉพาะ แล้วถ้า ID เดียวกันมาอีกครั้งก็ถือว่า “ประมวลผลแล้ว” และทิ้งไป

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

event_id=A ได้รับ


 A มีอยู่ใน Inbox แล้วหรือไม่?
   ├─ มี ─▶ ครั้งที่สอง → ทิ้ง (ไม่ส่งอีก)
   └─ ไม่มี ─▶ ┌─ ทรานแซกชันเดียวกัน ──────────────┐
              │  ① สร้างการแจ้งเตือน                │
              │  ② บันทึก A ลงใน Inbox              │
              └─ สำเร็จทั้งคู่ หรือ ล้มเหลวทั้งคู่ ──┘

นอกจากนั้น ฉันยังไม่พึ่งพาชั้นเดียวนั้น ในฐานะแนวป้องกันสุดท้ายกรณีที่หลุดผ่าน Inbox ไปได้ ฉันยังใส่ข้อจำกัดไว้ในฐานข้อมูลโดยตรงที่ตารางการแจ้งเตือนเองด้วยว่า “ห้ามการแจ้งเตือนซ้ำจากอีเวนต์เดียวกัน” แม้การตรวจสอบของแอปพลิเคชันจะหลุดไปจุดหนึ่ง สุดท้ายฐานข้อมูลก็จะจับได้ — การป้องกันหลายชั้น

-- ตารางการแจ้งเตือนเอง ใส่ UNIQUE constraint ที่ event_id เป็นแนวป้องกันสุดท้ายกรณีหลุดผ่าน Inbox
CONSTRAINT notifications_event_id_uk UNIQUE (event_id)

มาถึงตรงนี้ทุกอย่างตรงไปตรงมา ปัญหาอยู่ที่วิธีเลือกคีย์สำหรับแนวป้องกันสุดท้ายนี้


กับดัก: อีเวนต์เดียวกลายเป็นสองข้อความอย่างถูกต้อง

การแจ้งเตือนมีการออกแบบที่ ขยายอีเวนต์เดียวให้ไปหลายช่องทาง การส่ง “การโอนเสร็จสมบูรณ์” ทั้งทางอีเมลและ push — เรียกว่า fan-out (ขยายหนึ่งเป็นหลาย) ในกรณีนี้การแจ้งเตือนคือ “1 รายการ = 1 การส่งไปยัง 1 ช่องทาง” ดังนั้น event_id เดียวกันจึงสร้าง สองแถวอย่างถูกต้อง คือแถวสำหรับอีเมลและแถวสำหรับ push

ลองนึกถึง UNIQUE (event_id) ที่กล่าวไปก่อนหน้า มันคือข้อจำกัดที่ว่า “event_id เดียวกันมีการแจ้งเตือนได้แค่แถวเดียว” ทันทีที่แถวที่สอง (สำหรับ push) ถูก INSERT ด้วย fan-out มันจะถูกปฏิเสธด้วยข้อจำกัดที่ตัวเองตั้งไว้ เพราะ event_id เหมือนกัน คีย์ที่ควรจะป้องกันการซ้ำ กลับฆ่าข้อความที่สองที่ถูกต้อง

event_id=A (การโอนเสร็จสมบูรณ์)
   ├─ การแจ้งเตือนสำหรับอีเมล (event_id=A, channel=EMAIL) … INSERT สำเร็จ
   └─ การแจ้งเตือนสำหรับ push  (event_id=A, channel=PUSH)  … ✗ ล้มเหลวเพราะละเมิด UNIQUE(event_id)
                                                                  └─ ถูกปฏิเสธทั้งที่ไม่ใช่การซ้ำ

สิ่งนี้จะ ไม่ปรากฏออกมา เลยในช่วงที่ยังไม่ได้ implement fan-out (implement แบบง่ายที่ 1 อีเวนต์ต่อ 1 ช่องทาง) มันจะแสดงเขี้ยวเล็บออกมาทันทีที่เพิ่ม multi-channel เข้าไป — มันคือ ระเบิดเวลา โชคดีที่จับได้ตอนรีวิวการออกแบบ ก่อนที่จะปรากฏในการ implement

วิธีแก้คือเปลี่ยน ความละเอียด ของคีย์ ลดหน่วยของการตัดสินการซ้ำจาก “ต่ออีเวนต์” ไปเป็น “ต่ออีเวนต์ × ช่องทาง”

-- แก้ไข: จาก event_id เดี่ยว → คีย์รวม (event_id, channel)
ALTER TABLE notifications DROP CONSTRAINT notifications_event_id_uk;
ALTER TABLE notifications ADD  CONSTRAINT notifications_event_id_channel_uk
    UNIQUE (event_id, channel);

ด้วยวิธีนี้ “การซ้ำของอีเวนต์เดียวกัน ช่องทางเดียวกัน” จะยังถูกฐานข้อมูลปฏิเสธเหมือนเดิม ส่วน “อีเวนต์เดียวกันแต่ช่องทางต่างกัน” (fan-out) จะได้รับอนุญาตเป็นแถวแยกต่างหาก ป้องกันเฉพาะการซ้ำที่ต้องการป้องกันจริงๆ และปล่อยให้ข้อความที่สองที่ถูกต้องผ่านไปได้ เจตนาของการป้องกันสองชั้นยังคงเหมือนเดิม เพียงแค่แก้ไขความละเอียดของคีย์เท่านั้น

      ต้องการป้องกัน: การซ้ำครั้งที่สองของ event เดียวกัน × channel เดียวกัน   → ปฏิเสธ (ซ้ำ)
      ต้องการอนุญาต:  event เดียวกันที่ขยายไปยัง channel ต่างกัน            → อนุญาต (fan-out)

   UNIQUE(event_id)          … ปฏิเสธทั้งสองแบบรวมกัน (ฆ่าแม้แต่ข้อความที่สองที่ถูกต้อง)
   UNIQUE(event_id, channel) … ปฏิเสธเฉพาะแบบแรก อนุญาตแบบหลัง  ◀ นี่คือความละเอียดที่ถูกต้อง

idempotentไม่ใช่ “แค่ครั้งเดียว” แต่คือ “ครั้งเดียวต่อหน่วยอะไร”

สิ่งที่ฉันซึมซับได้จากการสร้างสิ่งนี้คือ ความยากที่แท้จริงของidempotentไม่ใช่การ “ไม่ทำซ้ำสอง” ในตัวมันเอง สิ่งที่ยากคือ การตัดสินว่าอะไรคือ “สิ่งเดียวกัน” — การเลือกหน่วยของการตัดสินการซ้ำ (ความละเอียดของคีย์) ให้สอดคล้องกับความเป็นจริงทางธุรกิจ

  • ถ้าความละเอียด หยาบเกินไป (event_id อย่างเดียว) fan-out ซึ่งควรถือเป็นคนละสิ่งจะถูกตัดสินผิดว่า “เหมือนกัน” แล้วแม้แต่การประมวลผลที่ถูกต้องก็จะถูกทำลายไปด้วย
  • ถ้าความละเอียด ละเอียดเกินไป การซ้ำที่ต้องการป้องกันจริงๆ จะหลุดผ่านไปได้

“ประมวลผลอีเวนต์แค่ครั้งเดียว” นั้นถูกต้อง แต่พอนำมาใช้ implement จริง ต้องตัดสินใจว่า “ครั้งเดียวของอะไรในอีเวนต์นั้น” ครั้งนี้คือ (event_id, channel) ในสถานการณ์อื่นก็จะเป็นความละเอียดอื่น คีย์กันซ้ำไม่ใช่สิ่งที่เลือกแบบไม่คิดว่าจะใช้ primary key หรือ event ID มันคือเส้นแบ่งระหว่างการซ้ำที่ต้องการป้องกันกับการทำซ้ำที่ถูกต้องซึ่งต้องการอนุญาตให้ผ่าน

และอีกอย่างหนึ่ง ความคลาดเคลื่อนของเส้นแบ่งนี้ หากเกิดขึ้นหลังจาก implement fan-out ไปแล้ว จะปรากฏออกมาเป็นเหตุการณ์ในโปรดักชันแบบ “ทำไมข้อความที่สองถึงล้มเหลว” การที่จับได้ก่อนหน้านั้น ตอนที่กลับไปอ่านการออกแบบใหม่ เป็นเพราะฉันถามคำถาม ไม่ใช่ “ตอนนี้มันทำงานได้ไหม” แต่เป็น “ถ้าเพิ่มสิ่งนี้เข้าไปต่อไป อะไรจะพัง” ยิ่งโค้ดที่ใช้งานได้อยู่แล้ว ยิ่งลืมถามคำถามนี้ได้ง่าย


บทความที่เกี่ยวข้อง

ส่งข้อความได้ตามสบาย

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