เอนด์พอยต์แบบ idempotent ใน Go: คีย์ ตารางป้องกันซ้ำ และการลองซ้ำ
ออกแบบเอนด์พอยต์แบบ idempotent ใน Go ด้วยคีย์ idempotency ตาราง dedup ใน PostgreSQL และ handler ที่ทนต่อการลองซ้ำสำหรับการชำระเงิน การนำเข้า และเว็บฮุก

ทำไมการลองซ้ำถึงสร้างข้อมูลซ้ำ (และทำไม idempotency จึงสำคัญ)
การลองซ้ำเกิดขึ้นแม้จะไม่มีอะไรผิดพลาด ไคลเอนต์อาจหมดเวลาในการรอในขณะที่เซิร์ฟเวอร์ยังทำงานอยู่ การเชื่อมต่อมือถือขาดแล้วแอปลองส่งใหม่ ตัวรันงานได้รับ 502 แล้วส่งคำขอซ้ำ กับการส่งแบบ at-least-once (พบได้บ่อยกับคิวและเว็บฮุก) การเกิดคำขอซ้ำถือเป็นเรื่องปกติ
นั่นเป็นเหตุผลว่าทำไม idempotency ถึงสำคัญ: คำขอที่ซ้ำกันควรให้ผลลัพธ์สุดท้ายเหมือนกับการเรียกครั้งเดียว
มีคำบางคำที่มักจะสับสนกันง่าย:
- Safe: การเรียกไม่เปลี่ยนสถานะ (เช่น อ่าน)
- Idempotent: เรียกหลายครั้งแล้วมีผลเท่ากับการเรียกครั้งเดียว
- At-least-once: ผู้ส่งจะลองจนกว่าจะผ่าน ดังนั้นผู้รับต้องจัดการกับคำขอซ้ำ
ถ้าไม่มี idempotency การลองซ้ำอาจก่อความเสียหายได้จริงๆ เอนด์พอยต์การชำระเงินอาจคิดเงินสองครั้งถ้าการชาร์จสำเร็จแต่คำตอบไม่ถึงไคลเอนต์ จุดนำเข้าข้อมูลอาจสร้างแถวซ้ำเมื่อตัวประมวลผลลองซ้ำหลังหมดเวลา เว็บฮุกอาจประมวลผลเหตุการณ์เดิมสองครั้งแล้วส่งอีเมลสองฉบับ
ประเด็นสำคัญ: idempotency เป็นสัญญาของ API ไม่ใช่รายละเอียดการทำงานภายในเฉพาะ ตัวไคลเอนต์ต้องรู้ว่าพวกเขาสามารถลองใหม่ได้เมื่อไร คีย์ใดที่จะส่ง และคาดหวังคำตอบแบบไหนเมื่อตรวจพบคำขอซ้ำ หากคุณเปลี่ยนพฤติกรรมโดยเงียบๆ คุณจะทำลายตรรกะการลองซ้ำและสร้างโหมดความล้มเหลวใหม่
idempotency ก็ไม่ใช่ตัวทดแทนการมอนิเตอร์และการตรวจสอบความต่าง ติดตามอัตราการซ้ำ ลงบันทึกการตัดสินใจ "replay" และเปรียบเทียบเป็นระยะกับระบบภายนอก (เช่น ผู้ให้บริการชำระเงิน) กับฐานข้อมูลของคุณ
เลือกสโคปและกฎ idempotency สำหรับแต่ละเอนด์พอยต์
ก่อนจะเพิ่มตารางหรือมิดเดิลแวร์ ให้ตัดสินใจว่า "คำขอเดียวกัน" หมายถึงอะไรและเซิร์ฟเวอร์สัญญาจะทำอะไรเมื่อไคลเอนต์ลองซ้ำ
ปัญหาส่วนใหญ่เกิดกับ POST เพราะมันมักสร้างสิ่งใหม่หรือกระตุ้นผลข้างเคียง (คิดเงิน ส่งข้อความ เริ่มนำเข้า) PATCH ก็อาจต้องการ idempotency ถ้ามันกระตุ้นผลข้างเคียง ไม่ใช่แค่การอัปเดตฟิลด์อย่างเดียว GET ไม่ควรเปลี่ยนสถานะ
กำหนดสโคป: ที่ซึ่งคีย์ถือว่าเป็นเอกลักษณ์
เลือกสโคปให้ตรงกับกฎธุรกิจของคุณ กว้างเกินไปจะปิดงานที่ถูกต้อง แคบเกินไปจะยอมให้เกิดการซ้ำได้
สโคปที่พบบ่อย:
- ต่อเอนด์พอยต์ + ลูกค้า
- ต่อเอนด์พอยต์ + อ็อบเจ็กต์ภายนอก (เช่น invoice_id หรือ order_id)
- ต่อเอนด์พอยต์ + tenant (สำหรับระบบหลายผู้เช่า)
- ต่อเอนด์พอยต์ + วิธีการชำระเงิน + จำนวน (ทำได้ถ้านโยบายผลิตภัณฑ์คุณอนุญาต)
ตัวอย่าง: สำหรับเอนด์พอยต์ "สร้างการชำระเงิน" ให้คีย์เป็นเอกลักษณ์ต่อผู้ใช้ลูกค้า สำหรับ "รับเหตุการณ์เว็บฮุก" ให้สโคปตาม event ID ของผู้ให้บริการ (ความเป็นเอกลักษณ์ระดับโลกจากผู้ให้บริการ)
ตัดสินใจว่าจะให้ผลลัพธ์อะไรเมื่อพบคำขอซ้ำ
เมื่อคำขอซ้ำมาถึง ให้คืนผลลัพธ์เดิมที่เกิดจากความพยายามแรกที่สำเร็จ ในทางปฏิบัติ นั่นหมายถึงการรีเพลย์สถานะ HTTP เดิมและเนื้อหาตอบกลับเดิม (หรืออย่างน้อยก็ resource ID และสถานะเดียวกัน)
ไคลเอนต์พึ่งพาสิ่งนี้ หากการลองครั้งแรกสำเร็จแต่เครือข่ายหลุด การลองซ้ำไม่ควรสร้างการชาร์จที่สองหรืองานนำเข้าที่สอง
เลือกช่วงเก็บข้อมูล (retention window)
คีย์ควรหมดอายุ เก็บไว้นานพอที่จะครอบคลุมการลองซ้ำที่เกิดขึ้นจริงและงานที่ล่าช้า
- การชำระเงิน: 24 ถึง 72 ชั่วโมงเป็นช่วงที่พบบ่อย
- การนำเข้า: หนึ่งสัปดาห์อาจสมเหตุสมผลถ้าผู้ใช้อาจลองซ้ำในภายหลัง
- เว็บฮุก: ให้ตรงกับนโยบายการลองซ้ำของผู้ให้บริการ
นิยามคำว่า "คำขอเดียวกัน": คีย์ชัดแจ้ง vs แฮชของบอดี
คีย์ idempotency ชัดแจ้ง (header หรือฟิลด์) มักเป็นกฎที่สะอาดที่สุด
การใช้แฮชของบอดีช่วยเป็นแบ็กลักษ์ได้ แต่จะพังได้ง่ายกับการเปลี่ยนแปลงที่ไม่มีผล เช่น ลำดับฟิลด์ เว้นวรรค หรือ timestamp หากใช้การแฮช ให้ทำ normalization อินพุตและระบุชัดว่ารวมฟิลด์ใดบ้าง
คีย์ idempotency: วิธีการใช้งานเชิงปฏิบัติ
คีย์ idempotency เป็นสัญญาง่ายๆ ระหว่างไคลเอนต์และเซิร์ฟเวอร์: "ถ้าคุณเห็นคีย์นี้อีก ให้ถือว่าเป็นคำขอเดียวกัน" มันเป็นเครื่องมือที่ใช้งานได้จริงสำหรับ API ที่รองรับการลองซ้ำ
คีย์อาจมาจากทั้งสองฝ่าย แต่สำหรับ API ส่วนใหญ่ควรให้ไคลเอนต์สร้าง ไคลเอนต์รู้ว่าเมื่อใดกำลังลองซ้ำการกระทำเดิม ดังนั้นจึงสามารถใช้คีย์เดียวกันข้ามการพยายามได้ คีย์ที่เซิร์ฟเวอร์สร้างมีประโยชน์เมื่อคุณสร้างทรัพยากร "ร่าง" (เช่น งานนำเข้า) แล้วให้ไคลเอนต์ลองซ้ำโดยอ้างอิง job ID แต่คีย์เซิร์ฟเวอร์ไม่ช่วยกับคำขอแรกสุด
ใช้สตริงสุ่มที่เดาไม่ได้ พยายามให้มีความสุ่มอย่างน้อย 128 บิต (เช่น 32 ตัวอักษร hex หรือ UUID) อย่าสร้างคีย์จาก timestamp หรือ user ID
บนเซิร์ฟเวอร์ เก็บคีย์พร้อมบริบทพอที่จะตรวจจับการใช้ผิดและรีเพลย์ผลลัพธ์เดิม:
- ใครเป็นผู้เรียก (account หรือ user ID)
- เอนด์พอยต์หรือการดำเนินการที่คีย์นี้ใช้กับมัน
- แฮชของฟิลด์คำขอที่สำคัญ
- สถานะปัจจุบัน (in-progress, succeeded, failed)
- การตอบกลับที่ใช้รีเพลย์ (สถานะ HTTP และบอดี)
คีย์ควรถูกสโคปโดยปกติเป็นต่อผู้ใช้ (หรือ per API token) บวกกับเอนด์พอยต์ หากคีย์เดียวกันถูกใช้กับเพย์โหลดต่างกัน ให้ปฏิเสธด้วยข้อผิดพลาดที่ชัดเจน นั่นป้องกันการชนด้วยคีย์จากไคลเอนต์ที่บั๊กส่งจำนวนเงินใหม่โดยใช้คีย์เก่า
เมื่อรีเพลย์ ให้คืนผลลัพธ์เดียวกับความพยายามแรกที่สำเร็จ นั่นหมายถึงสถานะ HTTP เดิมและบอดีเดิม ไม่ใช่การอ่านใหม่ที่อาจเปลี่ยนไป
ตาราง dedup ใน PostgreSQL: รูปแบบเรียบง่ายและเชื่อถือได้
ตาราง dedup เฉพาะคือวิธีง่ายที่สุดวิธีหนึ่งในการใช้ idempotency คำขอแรกสร้างแถวสำหรับคีย์ idempotency ทุกการลองซ้ำอ่านแถวนั้นและคืนผลลัพธ์ที่เก็บไว้
เก็บอะไรบ้าง
เก็บตารางให้เล็กและตรงจุด โครงสร้างทั่วไป:
key: คีย์ idempotency (text)owner: ผู้เป็นเจ้าของคีย์ (user_id, account_id, หรือ API client ID)request_hash: แฮชของฟิลด์คำขอที่สำคัญresponse: เพย์โหลดตอบกลับสุดท้าย (มักเป็น JSON) หรือ pointer ไปยังผลลัพธ์ที่เก็บไว้created_at: เวลาที่คีย์ถูกเห็นครั้งแรก
ข้อจำกัดแบบ unique เป็นหัวใจของรูปแบบ บังคับความเป็นเอกลักษณ์บน (owner, key) เพื่อให้ลูกค้าหนึ่งคนไม่สามารถสร้างซ้ำได้ และลูกค้าสองคนต่างกันไม่ชนกัน
เก็บ request_hash ด้วยเพื่อให้ตรวจจับการใช้คีย์ผิด หากการลองซ้ำมาพร้อมแฮชต่างกัน ให้คืนข้อผิดพลาดแทนการผสมสองการดำเนินการเข้าด้วยกัน
การเก็บรักษาและการทำดัชนี
แถว dedup ไม่ควรอยู่ตลอดไป เก็บไว้นานพอสำหรับหน้าต่างการลองซ้ำจริง แล้วค่อยทำความสะอาด
เพื่อความเร็วภายใต้ภาระ:
- ดัชนี unique บน
(owner, key)สำหรับการแทรกหรือค้นหาเร็ว - ดัชนีทางเลือกบน
created_atเพื่อให้การลบเก่าเร็วขึ้น
ถ้าการตอบกลับใหญ่ ให้เก็บ pointer (เช่น result ID) แล้วเก็บเพย์โหลดจริงไว้ที่อื่น เพื่อลดการบวมของตารางในขณะที่ยังคงพฤติกรรมการลองซ้ำที่สม่ำเสมอ
ขั้นตอนทีละขั้น: โฟลว์ handler ที่รองรับการลองซ้ำใน Go
handler ที่รองรับการลองซ้ำต้องมีสองสิ่ง: วิธีนิ่งเพื่อระบุว่า "เป็นคำขอเดียวกันอีกครั้ง" และที่เก็บที่ทนทานเพื่อเก็บผลลัพธ์ครั้งแรกเพื่อให้รีเพลย์ได้
โฟลว์ปฏิบัติสำหรับการชำระเงิน การนำเข้า และการรับเว็บฮุก:
-
ตรวจสอบคำขอ แล้วสกัดค่า 3 อย่าง: idempotency key (จาก header หรือฟิลด์ของไคลเอนต์), owner (tenant หรือ user ID), และ request hash (แฮชของฟิลด์สำคัญ)
-
เริ่มทรานแซคชันฐานข้อมูลและพยายามสร้างเรคคอร์ด dedup ให้เป็น unique บน
(owner, key)เก็บrequest_hash, สถานะ (started, completed), และที่ว่างสำหรับการตอบกลับ -
ถ้าแทรกชน ให้โหลดแถวที่มีอยู่ ถ้ามัน completed ให้คืนการตอบกลับที่เก็บไว้ ถ้ามัน started ให้รอสั้นๆ (polling ง่ายๆ) หรือคืน 409/202 เพื่อให้ไคลเอนต์ลองใหม่ภายหลัง
-
ก็ต่อเมื่อคุณ "ครอบครอง" แถว dedup สำเร็จ ให้รันธุรกิจลอจิกเพียงครั้งเดียว ทำ side effects ภายในทรานแซคชันเดียวกันเมื่อเป็นไปได้ บันทึกผลลัพธ์ของธุรกิจพร้อม HTTP response (สถานะและบอดี)
-
คอมมิต และบันทึกล็อกพร้อม idempotency key และ owner เพื่อให้ฝ่ายซัพพอร์ตสามารถติดตามการซ้ำได้
รูปแบบตารางขั้นต่ำ:
create table idempotency_keys (
owner_id text not null,
idem_key text not null,
request_hash text not null,
status text not null,
response_code int,
response_body jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (owner_id, idem_key)
);
ตัวอย่าง: เอนด์พอยต์ "สร้างการจ่ายเงิน" หมดเวลาหลังจากคิดเงินแล้ว ไคลเอนต์ลองซ้ำด้วยคีย์เดียวกัน handler ของคุณจะเจอ conflict เห็นว่า completed แล้วคืน payout ID เดิมโดยไม่คิดเงินซ้ำ
การชำระเงิน: คิดเงินครั้งเดียว แม้จะหมดเวลา
การชำระเงินคือจุดที่ idempotency เป็นสิ่งจำเป็น เครือข่ายล้ม ผู้ใช้มือถือลองซ้ำ และเกตเวย์บางครั้งหมดเวลาแม้จะสร้างการชาร์จแล้วก็ตาม
กฎปฏิบัติ: คีย์ idempotency ป้องกันการสร้างการชาร์จ และ provider ID (charge/intent ID) เป็นแหล่งความจริงหลังจากนั้น เมื่อคุณบันทึก provider ID แล้ว อย่าสร้างการชาร์จใหม่สำหรับคำขอเดียวกัน
รูปแบบที่จัดการการลองซ้ำและความไม่แน่นอนของเกตเวย์:
- อ่านและตรวจสอบ idempotency key
- ในทรานแซคชัน DB สร้างหรือดึงแถวการชำระเงินที่มีคีย์
(merchant_id, idempotency_key)ถ้ามีprovider_idอยู่แล้ว ให้คืนผลลัพธ์ที่บันทึกไว้ - ถ้าไม่มี
provider_idให้เรียกเกตเวย์เพื่อสร้าง PaymentIntent/Charge - ถ้าเกตเวย์สำเร็จ ให้บันทึก
provider_idและมาร์กการชำระเงินเป็น "succeeded" (หรือ "requires_action") - ถ้าเกตเวย์หมดเวลา หรือคืนผลลัพธ์ที่ไม่แน่นอน ให้บันทึกสถานะเป็น "pending" และคืนการตอบกลับที่สอดคล้องเพื่อบอกไคลเอนต์ว่าสามารถลองซ้ำได้อย่างปลอดภัย
รายละเอียดสำคัญคือการจัดการการหมดเวลา: อย่าสมมติว่าล้มเหลว ให้มาร์กการชำระเงินเป็น pending แล้วยืนยันโดยการสอบถามเกตเวย์ทีหลัง (หรือผ่าน webhook) เมื่อคุณมี provider ID
ข้อผิดพลาดที่คืนควรเดาได้ ไคลเอนต์สร้างตรรกะการลองซ้ำรอบรูปแบบการคืนดังนั้นให้คงสถานะโค้ดและรูปแบบข้อผิดพลาดให้เสถียร
นำเข้าและเอนด์พอยต์แบบแบตช์: dedup โดยไม่สูญเสียความคืบหน้า
การนำเข้าคือที่ที่การซ้ำสร้างความเสียหายมากที่สุด ผู้ใช้อัปโหลด CSV เซิร์ฟเวอร์หมดเวลาเมื่อทำงานถึง 95% และพวกเขาลองใหม่ หากไม่มีแผน คุณอาจสร้างแถวซ้ำหรือบังคับให้ผู้ใช้เริ่มใหม่
สำหรับงานแบตช์ คิดเป็นสองชั้น: งานนำเข้า และรายการภายในงาน ระดับงานหยุดการสร้างงานซ้ำ รายการระดับรายการหยุดการนำเข้าแถวซ้ำ
รูปแบบระดับงานคือการบังคับคีย์ idempotency ต่อคำขอนำเข้า (หรือสกัดจาก request hash ที่นิ่งร่วมกับ user ID) เก็บมันกับเรคคอร์ด import_job และคืน job ID เดิมเมื่อลองซ้ำ handler ควรบอกได้ว่า "ฉันเคยเห็นงานนี้ นี่สถานะปัจจุบัน" แทนที่จะบอก "เริ่มใหม่"
สำหรับการ dedup ระดับรายการ ให้พึ่งพา natural key ที่มีอยู่ในข้อมูลแล้ว เช่น แต่ละแถวอาจมี external_id จากระบบต้นทาง หรือคอมโบที่นิ่งเช่น (account_id, email) บังคับด้วย unique constraint ใน PostgreSQL และใช้ upsert เพื่อให้การลองซ้ำไม่สร้างรายการซ้ำ
ก่อนปล่อย ให้ตัดสินใจว่าการรีเพลย์ทำอะไรเมื่อแถวมีอยู่แล้ว ให้ชัดเจน: ข้าม อัปเดตเฉพาะฟิลด์ หรือทำให้ล้มเหลว หลีกเลี่ยงการ "รวม" (merge) เว้นแต่คุณมีกฎที่ชัดเจนมาก
ความสำเร็จแบบบางส่วนเป็นเรื่องปกติ แทนที่จะคืนแค่ว่า "ok" หรือ "failed" เก็บผลลัพธ์ต่อแถวผูกกับงาน: หมายเลขแถว, natural key, สถานะ (created, updated, skipped, error), และข้อความข้อผิดพลาด เมื่อลองซ้ำ คุณสามารถรันซ้ำได้อย่างปลอดภัยในขณะที่คงผลลัพธ์เดิมสำหรับแถวที่เสร็จแล้ว
เพื่อให้การนำเข้าสามารถเริ่มต่อได้ ให้เพิ่ม checkpoint ประมวลผลเป็นหน้า (เช่น 500 แถวต่อหน้า) เก็บเคอร์เซอร์สุดท้าย (ดัชนีแถวหรือ cursor ของต้นทาง) และอัปเดตหลังแต่ละหน้าคอมมิต ถ้ากระบวนการล้มเหลว ความพยายามถัดไปจะเริ่มจากเช็คพอยต์สุดท้าย
การรับเว็บฮุก: dedup, ตรวจสอบความถูกต้อง, แล้วประมวลผลอย่างปลอดภัย
ผู้ส่งเว็บฮุกจะลองซ้ำ และส่งเหตุการณ์นอกลำดับ หาก handler ของคุณอัปเดตสถานะทุกครั้งที่รับ จะเกิดการสร้างซ้ำ สองครั้งส่งอีเมลสองฉบับ หรือคิดเงินสองครั้งในท้ายที่สุด
เริ่มจากเลือกคีย์ dedup ที่ดีที่สุด ถ้าผู้ให้บริการให้ event ID ที่เป็นเอกลักษณ์ ให้ใช้มัน จงถือมันเป็นคีย์ idempotency สำหรับเอนด์พอยต์เว็บฮุก สำรองด้วยแฮชของเพย์โหลดเมื่อไม่มีย่อเหตุการณ์
ความปลอดภัยมาก่อน: ตรวจสอบลายเซ็นก่อนยอมรับ ถ้าลายเซ็นล้มเหลว ให้ปฏิเสธคำขอและอย่าเขียนเรคคอร์ด dedup มิฉะนั้นผู้โจมตีอาจ "จอง" event ID และบล็อกเหตุการณ์จริงในภายหลัง
โฟลว์ที่ปลอดภัยภายใต้การลองซ้ำ:
- ตรวจสอบลายเซ็นและรูปแบบพื้นฐาน (header ที่จำเป็น, event ID)
- แทรก event ID ลงในตาราง dedup พร้อม unique constraint
- ถ้าการแทรกล้มเพราะซ้ำ ให้คืน 200 ทันที
- เก็บเพย์โหลดดิบ (และ header) เมื่อมีประโยชน์สำหรับการตรวจสอบและดีบัก
- เข้าคิวเพื่อประมวลผลและคืน 200 อย่างรวดเร็ว
การตอบรับอย่างรวดเร็วสำคัญเพราะผู้ให้บริการหลายรายมี timeout สั้น ทำงานที่น้อยที่สุดที่เชื่อถือได้ในคำขอ: ตรวจสอบ, dedup, เก็บ แล้วประมวลผลแบบอะซิงโครนัส (worker, queue, background job) หากไม่สามารถทำอะซิงโครนัสได้ ให้รักษาการประมวลผลให้เป็น idempotent โดยใช้ event ID เดียวกันสำหรับผลข้างเคียงภายใน
การส่งนอกลำดับเป็นเรื่องปกติ อย่าสมมติว่า "created" มาก่อน "updated" ให้ใช้ upsert ตาม external object ID และติดตาม timestamp หรือเวอร์ชันของเหตุการณ์ที่ประมวลผลล่าสุด
การเก็บเพย์โหลดดิบช่วยเมื่อผู้ใช้บอกว่า "เราไม่เคยได้อัปเดต" คุณสามารถรันกระบวนการใหม่จากบอดีที่เก็บไว้หลังแก้บั๊ก โดยไม่ต้องขอให้ผู้ให้บริการส่งซ้ำ
ความพร้อมใช้งานพร้อมกัน: รักษาความถูกต้องเมื่อต้องจัดการคำขอขนานกัน
การลองซ้ำยุ่งยากขึ้นเมื่อคำขอสองคำขอที่มีคีย์ idempotency เดียวกันมาถึงพร้อมกัน หากทั้งสอง handler รันขั้นตอน "ทำงาน" ก่อนที่ใครจะบันทึกผล อาจเกิดการคิดเงินซ้ำ การนำเข้าซ้ำ หรือการเข้าคิวซ้ำได้
จุดประสานงานที่เรียบง่ายที่สุดคือตัวจัดการทรานแซคชันของฐานข้อมูล ให้ขั้นตอนแรกเป็น "อ้างสิทธิ์คีย์" แล้วให้ฐานข้อมูลตัดสินว่าใครชนะ ตัวเลือกทั่วไป:
- การแทรกแบบ unique ลงในตาราง dedup (ฐานข้อมูลบังคับให้มีผู้ชนะหนึ่งคน)
SELECT ... FOR UPDATEหลังจากสร้าง (หรือหาก) dedup row- advisory locks ระดับทรานแซคชัน โดยใช้แฮชของ idempotency key
- unique constraints บนเรคคอร์ดทางธุรกิจเป็นแผนสำรองสุดท้าย
สำหรับงานที่ใช้เวลานาน หลีกเลี่ยงการถือ lock ขณะเรียกระบบภายนอกหรือรันการนำเข้านานนาที แทนให้เก็บสถานะเครื่องจักรสั้นๆ ในแถว dedup เพื่อให้คำขออื่นออกได้เร็ว
ชุดสถานะที่ใช้ได้จริง:
in_progressพร้อมstarted_atcompletedพร้อมการตอบกลับที่แคชไว้failedพร้อมรหัสข้อผิดพลาด (เป็นทางเลือก ขึ้นกับนโยบายการลองซ้ำ)expires_at(สำหรับการทำความสะอาด)
ตัวอย่าง: อินสแตนซ์ A และ B รับคำขอการชำระเงินเดียวกัน A แทรกคีย์และมาร์ก in_progress แล้วเรียก provider B เข้ามาเจอทางชน แทนที่จะทำงาน มันอ่านแถว dedup เห็น in_progress แล้วคืนคำตอบว่า "กำลังประมวลผล" อย่างรวดเร็ว (หรือรอแล้วเช็คใหม่) เมื่อ A เสร็จ มันอัปเดตแถวเป็น completed และเก็บบอดีการตอบกลับ เพื่อให้การลองซ้ำภายหลังได้ผลลัพธ์เดิมเป๊ะ
ข้อผิดพลาดทั่วไปที่ทำให้ idempotency พัง
บั๊กส่วนใหญ่เกี่ยวกับ idempotency ไม่ได้มาจากการล็อกที่ซับซ้อน แต่เป็นการเลือกที่ "เกือบถูก" ซึ่งล้มเหลวเมื่อเกิดการลองซ้ำ หมดเวลา หรือลูกค้าสองคนทำงานคล้ายกัน
กับดักทั่วไปคือการถือว่าคีย์ idempotency เป็นเอกลักษณ์ระดับโลก หากคุณไม่สโคปมัน (โดยผู้ใช้ บัญชี หรือเอนด์พอยต์) ลูกค้าต่างคนกันอาจชนกันและคนหนึ่งจะได้ผลลัพธ์ของอีกคน
ปัญหาอีกประการคือรับคีย์เดียวกันกับบอดีคำขอที่ต่างกัน หากการเรียกครั้งแรกเป็น $10 แล้วการเรียกซ้ำเป็น $100 คุณไม่ควรคืนผลแรกโดยเงียบๆ เก็บ request hash หรือฟิลด์สำคัญ เปรียบเทียบเมื่อรีเพลย์ และคืนข้อขัดแย้งที่ชัดเจน
ไคลเอนต์สับสนเมื่อการรีเพลย์คืนรูปแบบตอบกลับหรือตัวสถานะต่างออกไป ถ้าครั้งแรกคืน 201 พร้อม JSON การรีเพลย์ควรคืนบอดีเดิมและสถานะที่สม่ำเสมอ การเปลี่ยนพฤติกรรมการรีเพลย์จะบังคับให้ไคลเอนต์ต้องเดา
ความผิดพลาดที่มักทำให้เกิดการซ้ำ:
- พึ่งพาแผนที่ในหน่วยความจำหรือแคชเพียงอย่างเดียว แล้วสูญเสียสถานะ dedup เมื่อรีสตาร์ท
- ใช้คีย์โดยไม่สโคป (ชนกันข้ามผู้ใช้หรือข้ามเอนด์พอยต์)
- ไม่ตรวจสอบความแตกต่างของเพย์โหลดเมื่อคีย์เหมือนกัน
- ทำ side effect ก่อน (คิดเงิน แทรก เผยแพร่) แล้วเขียนเรคคอร์ด dedup ทีหลัง
- คืน ID ใหม่ทุกครั้งเมื่อลองซ้ำ แทนที่จะรีเพลย์ผลลัพธ์เดิม
แคชอาจทำให้การอ่านเร็วขึ้น แต่แหล่งความจริงควรทนทาน (มักเป็น PostgreSQL) มิฉะนั้นการลองซ้ำหลัง deploy อาจสร้างการซ้ำ
วางแผนการทำความสะอาดด้วย หากคุณเก็บคีย์ทุกอันไว้นิรันดร์ ตารางจะโตและดัชนีช้าลง กำหนดหน้าต่างการเก็บตามพฤติกรรมการลองซ้ำจริง ลบแถวเก่า และเก็บดัชนีให้เล็ก
เช็คลิสต์ด่วนและขั้นตอนต่อไป
ถือว่า idempotency เป็นส่วนหนึ่งของสัญญา API ของคุณ ทุกเอนด์พอยต์ที่อาจถูกลองซ้ำโดยไคลเอนต์ คิว หรือเกตเวย์ ต้องมีนิยามชัดเจนว่า "คำขอเดียวกัน" คืออะไร และ "ผลลัพธ์เดียวกัน" เป็นอย่างไร
เช็คลิสต์ก่อนปล่อย:
- สำหรับแต่ละเอนด์พอยต์ที่อาจลองซ้ำ ได้กำหนดสโคป idempotency (ต่อผู้ใช้ ต่อบัญชี ต่อคำสั่ง ต่อเหตุการณ์ภายนอก) และเขียนไว้หรือไม่?
- การป้องกันซ้ำถูกบังคับโดยฐานข้อมูล (unique constraint บนคีย์และสโคป) ไม่ใช่แค่เช็กในโค้ดหรือไม่?
- เมื่อรีเพลย์ คุณคืนสถานะและบอดีเดิมหรือ subset ที่นิยามไว้ ไม่ใช่อ็อบเจ็กต์ใหม่หรือ timestamp ใหม่หรือไม่?
- สำหรับการชำระเงิน คุณจัดการผลลัพธ์ที่ไม่แน่นอนอย่างปลอดภัย (หมดเวลา หลังส่ง เกตเวย์บอกว่า "processing") โดยไม่คิดเงินซ้ำหรือไม่?
- โลคและเมตริกชัดเจนว่าสามารถเห็นได้เมื่อคำขอถูกเห็นครั้งแรกเทียบกับการรีเพลย์หรือไม่?
ถ้ารายการใดเป็น "อาจจะ" ให้แก้ไขตอนนี้ ความล้มเหลวส่วนใหญ่ปรากฏในภายใต้ความกดดัน: การลองซ้ำแบบขนาน เครือข่ายช้า และการล่มบางส่วน
ถ้าคุณกำลังสร้างเครื่องมือภายในหรือแอปสำหรับลูกค้าบน AppMaster (appmaster.io) จะช่วยได้ถ้าดีไซน์คีย์ idempotency และตาราง dedup ใน PostgreSQL ตั้งแต่ต้น ด้วยวิธีนี้ แม้แพลตฟอร์มจะสร้างโค้ด Go ซ้ำเมื่อความต้องการเปลี่ยน พฤติกรรมการลองซ้ำของคุณจะยังคงสอดคล้อง
คำถามที่พบบ่อย
การลองใหม่เป็นสิ่งปกติเมื่อเครือข่ายและไคลเอนต์ล้มเหลวในวิธีธรรมดา คำขออาจสำเร็จบนเซิร์ฟเวอร์แต่คำตอบไม่ถึงไคลเอนต์ ไคลเอนต์จึงส่งคำขอซ้ำ ทำให้ระบบทำงานซ้ำอีกครั้งถ้าเซิร์ฟเวอร์ไม่รู้จักและเล่นผลลัพธ์เดิมกลับให้
ส่งคีย์เดียวกันในการลองซ้ำของการกระทำเดียวกัน สร้างคีย์ที่ฝั่งไคลเอนต์เป็นสตริงสุ่มที่คาดเดาไม่ได้ (เช่น UUID) และอย่าใช้คีย์นั้นกับการกระทำอื่น
กำหนดสโคปให้ตรงกับกฎทางธุรกิจ มักเป็นต่อเอนด์พอยต์ร่วมกับตัวระบุผู้เรียก เช่น user, account, tenant หรือ API token เพื่อป้องกันไม่ให้ลูกค้าต่างคนกันชนกันที่คีย์เดียวกัน
ส่งผลลัพธ์เดียวกันกับการเรียกที่สำเร็จครั้งแรก ในเชิงปฏิบัติ รีเพลย์สถานะ HTTP และเนื้อหาตอบกลับเดิม หรืออย่างน้อยก็ ID ของทรัพยากรและสถานะเดียวกัน เพื่อให้ไคลเอนต์สามารถลองซ้ำได้อย่างปลอดภัยโดยไม่เกิด side effect ที่สอง
ปฏิเสธด้วยข้อผิดพลาดแบบ conflict ที่ชัดเจน แทนที่จะเดา จัดเก็บและเปรียบเทียบแฮชของฟิลด์สำคัญ หากคีย์ตรงแต่เพย์โหลดต่างกัน ให้ล้มเหลวอย่างรวดเร็วเพื่อไม่ให้ผสมสองการดำเนินการภายใต้คีย์เดียวกัน
เก็บคีย์ไว้นานพอสำหรับการลองซ้ำที่สมเหตุสมผล แล้วค่อยลบ ตัวอย่างมาตรฐานคือ 24–72 ชั่วโมงสำหรับการชำระเงิน, หนึ่งสัปดาห์สำหรับการนำเข้า และสำหรับเว็บฮุกให้ตรงกับนโยบายการลองซ้ำของผู้ส่ง
ตาราง dedup เฉพาะใช้งานได้ดีเพราะฐานข้อมูลบังคับใช้ unique constraint และทนต่อการรีสตาร์ท เก็บสโคปเจ้าของ คีย์ แฮชของคำขอ สถานะ และการตอบกลับเพื่อรีเพลย์ แล้วตั้ง (owner, key) ให้เป็น unique เพื่อให้มีผู้ชนะเพียงคนเดียว
อ้างสิทธิ์คีย์ภายในทรานแซคชันฐานข้อมูลก่อน แล้วค่อยทำ side effect หากคำขออีกอันมาพร้อมกัน ให้มันเจอ unique constraint, เห็น in_progress หรือ completed และคืนคำตอบแบบรอ/รีเพลย์ แทนการรันตรรกะซ้ำ
มองว่าการหมดเวลาของเกตเวย์เป็น “ไม่รู้” มากกว่า “ล้มเหลว” บันทึกสถานะเป็น pending และถ้ามี provider ID ให้ใช้เป็นแหล่งความจริง เพื่อให้การลองซ้ำคืนผลการชำระเงินเดียวกันแทนการสร้างการเรียกเก็บใหม่
ทำ dedup สองชั้น: ระดับงานและระดับรายการ ให้การลองซ้ำคืน job ID เดิม และบังคับใช้ natural key สำหรับแถว เช่น external ID หรือ (account_id, email) ด้วย unique constraint หรือ upsert เพื่อให้การประมวลผลซ้ำไม่สร้างรายการซ้ำ


