Photo by William Iven on Unsplash

ใครเคยเรียกใช้งานเซอร์วิซครั้งเดียว (เช่น การโอนเงิน สั่งซื้อของ ฯลฯ) แล้วเจอ Transaction ตัดซ้ำสองครั้งไหมครับ?

ถ้าใช่ คุณเจอปัญหาเรื่อง Idempotent เข้าให้แล้วล่ะ

เราเคยคุยกันในเรื่องของRetryไปแล้ว ว่าอาจจะถล่มเซอร์วิซตัวเองได้ หากไม่จัดการให้ดี

มานั่งตรึกตรองดูอีกที จริงๆแล้วผมลืมอีกคอนเซ็บหนึ่งที่สำคัญมากเวลาเราใช้ Retry นั่นก็คือเรื่อง Idempotent ซึ่งเป็นที่มาของ เคส Transaction เกิดขึ้นสองที

บทความนี้จะเริ่มต้นจากต้นตอของปัญหานี้ก่อน เพื่ออธิบายให้เห็นภาพว่าเกิดอะไรขึ้น และเสนอวิธีการแก้ปัญหาในตอนท้ายครับ

ทำไมถึงเกิด Transaction ซ้ำ

ตอนเริ่มเขียนโปรแกรมใหม่ ผมเชื่อมั่นว่าถ้ายิง Request ไป แล้วต้องได้คำตอบกลับมาแน่ๆ

พอเขียนไปสักพัก ถึงค้นพบว่าบางทียิงไปแล้วก็ไม่มีคำตอบกลับ เนื่องด้วยความเปรี้ยว ก็เลยใส่ Loop เข้าไปว่าถ้ายังไม่ได้คำตอบกลับมา ให้ยิง Request ไปอีกจนได้คำตอบ (ก็คือการทำ Retry นั่นเอง)

โดยลืมไปว่า ไอ้การไม่ได้รับคำตอบ มันเป็นไปได้หลายกรณี

  1. Request ไปไม่ถึง Server อีกฝั่ง (ตรงกับที่ผมคิด)
  2. Request ไปถึง, Server ทำการประมวลผลเสร็จแล้ว, แต่ Response ที่ส่งมาหายไประหว่างทาง

ในที่นี้ ผมทึกทักเอาไปเองว่าเป็นข้อ 1. แต่จริงๆอาจเป็นข้อ 2. ก็ได้

สมมติว่าผมส่ง Request ไปทำการโอนเงิน เป็นไปได้ว่า Request แรกเกิดกรณีข้อ 2. ขึ้น แต่ผมดันเข้าใจผิดว่า Request ไปไม่ถึง เลยทะลึ่งส่งไปอีกรอบ ทำให้โอนเงินซ้ำไปสองที

ที่น่ากลัวที่สุด คือผมไม่รู้เรื่องเลยว่าโอนเงินซ้ำ จนกระทั่งหัวหน้าเรียกไปพบ เพราะลูกค้าโทรมาด่า

Network ไม่ Reliable

Network ที่เราใช้ในอินเตอร์เน็ต ไม่ได้ถูกออกแบบมาให้การันตีว่าทุกๆ Request ที่เราส่ง จะไปถึงเป้าหมาย 100% ครับ

การส่ง Request ก็เหมือนกับการส่งจดหมายผ่านไปรษณีย์ไทยนั่นแหละ ไม่รู้หรอกว่าจะถึงรึเปล่า

ถ้า Response ไม่กลับมา เราก็ไม่มีทางรู้เลยว่ามันเกิดกรณี 1 หรือ 2

เพื่อให้เห็นภาพชัด ผมขอเล่าถึงปัญหาคลาสสิคที่ชื่อว่า “Two Generals' Problem”

กล่าวคือ มีขุนพลสองคน ตั้งกำลังพลขนาบข้างศัตรูอยู่หลังภูเขา พร้อมจะจู่โจมได้ทุกเมื่อ ดังรูปข้างล่าง

ขุนพลสุธีกับขุนพลอรุชพยายามสื่อสารกันเพื่อนัดเวลาจู่โจม
ขุนพลสุธีกับขุนพลอรุชพยายามสื่อสารกันเพื่อนัดเวลาจู่โจม

เนื่องจากขุนพลทั้งสองมีกำลังน้อยกว่า วิธีการเดียวที่จะชนะศัตรูได้ คือต้องจู่โจมขนาบข้างพร้อมกัน

ขุนพลสุธีจึงส่งม้าเร็วไปแจ้งขุนพลอรุชว่า “เก้าโมงเช้าพรุ่งนี้ เราจะเข้าตีศัตรูพร้อมกัน”

ปัญหาคือ ขุนพลสุธีไม่มีทางรู้ได้เลยว่าขุนพลอรุชได้รับสารรึเปล่า ม้าเร็วอาจจะโดนศัตรูตรวจพบและสอยไประหว่างทาง

ดังนั้น จะเป็นไปได้สองกรณี

  1. หากขุนพลอรุชไม่ได้รับสาร ขุนพลสุธีจะเข้าตีคนเดียว แพ้แน่นอน
  2. หากขุนพลอรุชได้รับสาร ทั้งสองขุนพลเข้าตีพร้อมกัน ชนะแน่นอน

เพื่อความปลอดภัย ขุนพลสุธีจึงเขียนไปว่า “เก้าโมงเช้าพรุ่งนี้ เราจะเข้าตีศัตรูพร้อมกัน ถ้าได้รับสารแล้วส่งคำตอบมาด้วย” จะได้ชัวร์ ไม่ไปเก้อคนเดียว

ปรากฏว่า ม้าเร็วไปถึงขุนพลอรุช ส่งสารได้สำเร็จ ขุนพลอรุชส่งสารกลับไป “โอเค เก้าโมงเช้าเจอกัน”

แต่เดี๋ยวก่อน ถ้าหากม้าเร็วโดนสอยไประหว่างขากลับล่ะ กรณีนั้น ขุนพลสุธีก็จะไม่ได้รับสาร และเข้าใจไปว่าสารไปไม่ถึง พรุ่งนี้เก้าโมงเช้า ขุนพลอรุชก็จะยกทัพไปโดนกระทืบคนเดียว ไม่ได้การล่ะ ต้องเขียนไปใหม่ว่า “โอเค เก้าโมงเช้าเจอกัน ได้รับสารแล้วตอบกลับมาด้วย”

ม้าเร็วก็วิ่งกลับไปหาขุนพลสุธี

ขุนพลสุธีได้รับสารกระหยิ่มยิ้มย่อง จะส่งสารกลับไป แต่เอ๊ะ ถ้าเกิดคราวนี้ม้าเร็วพลาดขึ้นมา โดนศัตรูสอยระหว่างทาง ขุนพลอรุชก็จะเข้าใจว่าเราไมไ่ด้รับสาร เราก็จะเข้าตีคนเดียว… ฉิบหายล่ะ อย่างนี้ต้องให้ขุนพลอรุชตอบกลับมาอีกครั้ง

จะเห็นได้ว่า ไม่ว่าขุนพลทั้งสองจะส่งม้าเร็วกลับไปมาอีกกี่สิบชาติ ก็ไม่มีทางที่จะมั่นใจได้ว่าอีกฝั่งนึงได้รับสารชัวร์ๆ

ปัญหาคลาสสิคนี้ถูกเรียกว่า Two Generals' problem

ต้นเหตุของปัญหานี้คือความไม่แน่นอน (Unreliable) ของม้าเร็ว ที่อาจจะส่งสารไปถึงหรือไม่ก็ได้

ซึ่งมันตรงกับปัญหาที่เรากำลังเจออยู่มาก

การที่ม้าเร็วอาจโดนศัตรูสอยระหว่างทาง ก็เหมือนกับการที่ Network ไม่ Reliable นั่นแหละ

ไม่ว่าเราจะให้มี Request(สารจากขุนพลสุธี), Response (สารจากขุนพลอรุช) ทั้งหมดกี่รอบ ยังไงอีกฝั่งก็ไม่มีทางรู้ว่าอีกฝั่งได้รับข้อมูลจริงๆหรือเปล่า?

นิยามและความสำคัญ

มาคิดในมุมของขุนพลสุธีกัน ว่าจะจัดการกับปัญหานี้อย่างไร

ถ้าเราส่งม้าเร็วหลายๆตัวไปพร้อมกันล่ะ?

สมมติว่าโอกาสที่ม้าเร็วจะถูกสอยโดยกองทัพศัตรูคือ 25% (และ independent ต่อกัน)

ถ้าขุนพลสุธีส่งม้าเร็วไปสองตัว แทนที่จะเป็นตัวเดียว อัตราการโดนสอยทั้งคู่ (ขุนพลอรุชไม่ได้รับสารเลย) จะเหลือแค่ 0.25 * 0.25 = 0.0625 (~6.25%)

เพื่อความชัวร์ ขุนพลสุธีสามารถส่งม้าเร็วไปสิบตัว โอกาสที่ม้าเร็วจะโดนเก็บหมด คือ 0.25 ยก 10 = 0.00000095367431640625 (ประมาณ 9 ใน ล้าน)

ถ้าขุนพลสุธียอมรับความเสี่ยงได้ เราก็ตัดปัญหาเรื่องการรอ Response จากขุนพลอรุชได้

วิธีนี้คล้ายกับการ Retry ในการส่ง Request ซึ่งเราสามารถใช้เทคนิคนี้ได้อย่างปลอดภัยหาก Request ของเราไม่จำเป็นต้องไปถึงแค่หนึ่งครั้งพอดี (Exactly Once) อันได้แก่:

  1. Request นั้นไม่มีการเปลี่ยนแปลงข้อมูล เช่น เราต้องการดึงข้อมูล (GET ใน REST API) เราจะส่ง Request ไปกี่ครั้งก็ได้
  2. Request นั้นมีการเปลี่ยนแปลงข้อมูล แต่ไม่ว่าการเปลี่ยนแปลงข้อมูลนั้นจะถูกทำซ้ำกี่ครั้ง ผลลัพธ์ก็จะเหมือนกันเสมอ เช่น ผมต้องการแก้ที่อยู่ของลูกค้าในระบบ (PUT ใน REST API) ต่อให้ผมส่งคำสั่งแก้ข้อมูลสิบครั้ง แล้วเซอร์เวอร์ได้รับหมด ผลลัพธ์เหมือนกัน คือข้อมูลถูกเปลี่ยนเป็นที่อยู่ใหม่

คุณสมบัติที่ “ไม่ว่าจะทำกี่ครั้ง ผลลัพธ์ก็จะเหมือนกันเสมอ” คือคุณสมบัติที่ถูกเรียกว่า Idempotent

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

ในทางตรงกันข้าม หาก Request นั้นไม่มีคุณสมบัติ Idempotent ก็จะเกิดกรณีที่ Transaction เกิดขึ้นสองครั้ง เช่น

  1. การโอนเงิน สั่งให้โอนเงิน 100 บาทจากบัญชีสุธี ไปยังบัญชีอรุช หากสั่งสองที ก็กลายเป็นโอนทั้งหมด 200
  2. การสั่งของ สั่งให้ซื้อกล้วย 10 หวี หากสั่งสองที สุธีก็จะได้กล้วย 20 หวี

Request จำพวกนี้ จำเป็นต้องเกิดขึ้นครั้งเดียวเป๊ะ (Exactly Once) หากถูกสั่งมากกว่าหนึ่งครั้ง

ใครที่ใช้ REST API สามารถมองได้ว่าพวกนี้คือคำสั่งประเภท POST นั่นเอง

แต่ทุกปัญหาย่อมมีทางออก เราสามารถใช้เทคนิคต่อไปนี้ เพื่อทำให้ Request ข้างต้นกลายเป็น Idempotent ได้ !!

วิธี 1: ระบุเงื่อนไขตั้งต้นในการเปลี่ยนแปลง

ลองย้อนกลับไปยังเรื่องของการโอนเงิน คำสั่งคือ

“โอนเงิน 100 บาทจากบัญชีสุธี ไปยังบัญชีอรุช”

สมมติว่า สุธีมีเงินในบัญชี 1,000 บาท เราสามารถแก้คำสั่งนี้ให้เป็น Idempotent ได้ โดยการระบุเงื่อนไขตั้งต้น

“หากสุธีมีเงินในบัญชี 1,000 บาท ให้เปลี่ยนจำนวนเงินเป็น 900 บาท แล้วเพิ่มเงินในบัญชีอรุช 100 บาท”

ต่อให้เรายิง Request นี้สองครั้ง Request ที่สองจะล้มเหลว เพราะเงินตั้งต้นเป็น 900 เรียบร้อยแล้ว

วิธีนี้มีช่องโหว่ตรงที่

  1. หากสุธีต้องการจ่ายเงินพร้อมๆกัน (เช่น จ่ายให้อรุช 1,000->900 และจ่ายซื้อดอกไม้ให้สาว 1,000->500) คำสั่งที่ไปถึงทีหลังจะไม่สามารถทำงานได้ เพราะจำนวนเงินตั้งต้นในบัญชีไม่ใช่ 1,000 แล้ว ต้องส่ง Request ด้วยค่าใหม่อีกรอบ
  2. หากสุธีซวยจริงๆ ดันมีคนอื่นโอนเงินเข้ามา 100 บาทระหว่าง 2 Request ก็จะโดนตัดเงินสองรอบ
  3. หากสุธีไม่ได้ Response กลับมาจากครั้งแรก แต่ได้ Response ครั้งที่สองออกมาว่าโอนเงินไม่สำเร็จ เพราะเงินเหลือแค่ 900 บาท สุธีจะไม่มีทางรู้เลยว่าเงินที่หายไป 100 บาทนั้นเกิดจาก Request แรก หรือบังเอิญมีการตัดเงิน 100 บาทจากที่อื่นพอดี
  4. หากสุธีต้องการจะซื้อกล้วย จะไม่มี State ให้สุธีใช้เป็นเงื่อนไขตั้งต้น

ซึ่งถ้ากรณีพวกนี้อาจเกิดขึ้นได้ถ้าระบบเรามีการเปลี่ยนแปลง Record เดียวกันบ่อยๆ (กรณี#1-2) หรือมีการสร้าง Record ใหม่พร้อมกันเยอะๆ (กรณี#4) หากระบบที่ออกแบบอยู่มีแนวโน้มที่จะเป็นแบบนั้น เราอาจจะต้องพิจารณาวิธีถัดไป

วิธีที่ 2: จำ UUID ของแต่ละ Request

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

ดังนั้น แทนที่จะไปใช้สถานะของข้อมูล เรามาจัดการกับ Request แทนที่จะรอ

โดยฝั่งผู้ใช้จะต้องใส่ Universally Unique ID (UUID) ให้กับแต่ละ Request ส่วนฝั่งเซอร์เวอร์ก็จะต้องทำการจำว่า Request ไหน (UUID ไหน) ได้ประมวลผลไปแล้วบ้าง จะได้ไม่ทำซ้ำ

จากตัวอย่างข้างต้น เราสามารถใส่ UUID=189a8538-75a9-404f-a9db-197f4bb704f1 ให้กับทั้ง Request แรก และ Request ที่สองที่ Retry

ฝั่งเซอร์เวอร์ หากจัดการประมวลผล Request แรกไปแล้ว และยังได้ Request ที่ 2 อีก ก็สามารถเช็คจาก UUID ได้ว่าเป็น Request เดียวกัน และสามารถส่งคำตอบเดิมกลับไปได้

ฟังดูแล้ววิธีนี้ดูสะดวกกว่ามาก แต่จะท้าทายเมื่อระบบต้อง Scale หรือเป็น Distributed System

ลองนึกภาพว่าเรามี Request 1 ล้าน Request ต่อวินาที แปลว่าใน:

  1. 1 นาที ต้องจำ 60 ล้าน UUID
  2. 1 ชั่วโมง ต้องจำ 3,600 ล้าน UUID
  3. 1 วัน ต้องจำ 86,400 ล้าน UUID

บางคนอาจจะแย้งว่า ถ้าเราต้องการเช็คแค่กรณี Retry เราจำแค่ 1 นาทีก็พอ แต่หาก Request พวกนี้ไม่ใช่ HTTP Request ที่มี Timeout สั้นๆล่ะ? Request บางอันอาจจะถูกส่งมาเก็บไว้ใน Queue เพื่อรอการประมวลผลแบบ Asynchronous ก็ได้ ซึ่งแต่ละ Request อาจจะค้างอยู่เป็นวันก่อนได้ประมวลผล กรณีนี้ได้จำกันขี้แตกแน่นอน

ยิ่งหากเซอร์วิซของเราต้องมี Server หลายๆตัว แปลว่า UUID นี้เก็บในเซอร์เวอร์แต่ละตัวแยกกันไม่ได้ (Retry Request อาจจะถูกส่งไปอีกเซอร์เวอร์) เราจึงต้องแยกเก็บใน In-Memory Database ต่างหาก ทำให้ต้องเสีย Latency ในการทำ Network Call อีกรอบ เพื่อเช็คว่า UUID นี้มีเซอร์เวอร์ไหนประมวลผลไปแล้วหรือยัง

เทียบกับวิธีแรก เราไม่จำเป็นต้องจำอะไร หรือเก็บอะไรไว้ที่อื่นเลย นอกจากข้อมูลที่เราต้องใช้อยู่แล้ว

สรุป

เน็ตเวิร์คไม่เที่ยง (ไม่ Reliable)

Request ที่ไม่มี Response กลับมา อาจจะไปถึงผู้รับหรือไม่ก็ได้ โดยเราไม่มีทางรู้เลย ปัญหานี้เหมือนกับสองขุนพล ที่ต่อให้ส่ง Request กับ Response กี่ครั้ง ก็ไม่สามารถมั่นใจได้ว่าอีกฝ่ายจะได้รับหรือเปล่า

เราแก้ปัญหาข้างต้นได้ด้วยการ Retry (ส่ง Request ซ้ำ) แต่หาก Service API ของเราไม่เป็น Idempotent อาจจะทำให้เกิดอาการ “ตัด Transaction ซ้ำสองรอบ” ขึ้นมาได้

เพื่อแก้ปัญหานี้ เราสามารถกำหนดเงื่อนไขตั้งต้นใน Request (วิธีที่ 1) หรือการจำ UUID ของแต่ละ Request (วิธีที่ 2) ซึ่งทั้งสองวิธีมีข้อจำกัดที่แตกต่างกัน

วิธีที่ 1 ใช้ทรัพยากรในการคำนวนน้อยกว่า แต่มีโอกาสเกิดปัญหาหากมีการเปลี่ยนข้อมูลใน Record เดียวกันเยอะๆ หรือมีการสร้าง Record ใหม่บ่อยๆ

วิธีที่ 2 มีความยืดหยุ่นและหลอดภัยกว่า แต่ก็ต้องแลกมาด้วยการจำข้อมูลที่เยอะขึ้น และ Latency ในการเช็ค UUID เพราะต้องเก็บลิสต์ของ UUID ไว้ใน In-memory Database กลาง