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

Retry and Timeout

การเรียก remote ไม่เหมือนการเรียกฟังก์ชันในเครื่อง เพราะต้องวิ่งผ่านเครือข่ายที่ทำ packet หายได้ ผ่าน load balancer ที่บางทีก็ reset connection ไปยัง service ที่อาจกำลัง rolling deploy อยู่ หรือกำลังทำ garbage collection ชั่วครู่ ปัญหาพวกนี้ส่วนใหญ่เป็นแบบ transient คืออยู่แค่ไม่กี่มิลลิวินาที แล้วลองใหม่ครั้งหน้าก็สำเร็จ ทั้ง connection reset, 503 ชั่วขณะตอน pod restart, หรือเครือข่ายสะดุดนิดหน่อย ไม่มีอันไหนแปลว่า dependency พังจริง

มีรูปแบบความล้มเหลวสองแบบซ่อนอยู่ในทุกการเรียก remote แบบแรกคือ การรอแบบไม่มีขอบเขต: การเรียกที่ไม่มีขีดจำกัดเวลาสามารถค้างได้ตลอดระยะเวลาของ TCP timeout — เป็นนาทีในค่าเริ่มต้นบางอย่าง — ขณะที่ thread นั่งว่างถือ connection ไว้ แบบที่สองคือ การสะดุดแบบ transient: ความพยายามครั้งเดียวล้มเหลวด้วยเหตุผลที่ผ่านพ้นไปแล้ว และการยอมแพ้ไปเลยจะเปลี่ยนการสะดุดที่กู้คืนได้ให้กลายเป็น error ที่ผู้ใช้มองเห็น

แต่การแก้แบบง่าย ๆ กลับทำให้แย่ลง การ retry ทันทีโดยไม่หน่วงเวลาคือการไปกระหน่ำ dependency ที่กำลังจะฟื้น การ retry พร้อมกันจาก client หลายพันตัวก็สร้าง thundering herd คือคลื่น retry ที่ซิงค์กันแล้วถล่มเข้ามาพร้อมกัน ล้ม dependency ซ้ำอีกรอบ ส่วนการ retry operation แบบ non-idempotent อย่าง “หักเงินจากบัตรใบนี้” หรือ “จัดส่ง order นี้” ก็เสี่ยงทำให้งานเดียวถูกประมวลผลสองครั้ง

ดังนั้นแรงที่ตึงเครียดกันคือ: คุณอยากกู้คืนจากการสะดุดโดยอัตโนมัติ คุณต้องไม่รอตลอดกาล คุณต้องไม่ขยายภาระให้ dependency ที่กำลังดิ้นรน และคุณต้องไม่ทำให้ side effect ซ้ำซ้อน

ผสานวินัยสองอย่างเข้ากับทุกการเรียก remote

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

Retry ด้วย exponential backoff และ jitter เมื่อเจอความล้มเหลวแบบ transient ให้ลองใหม่ แต่รอนานขึ้นเรื่อย ๆ ในแต่ละครั้ง โดยหน่วงเวลาเพิ่มแบบ exponential ประมาณ base, base × 2, base × 4 ไปเรื่อย ๆ แล้วเติม jitter แบบสุ่มลงในเวลาหน่วงแต่ละครั้ง เพื่อให้ client ที่ทำงานพร้อมกันกระจายตัวออก ไม่ retry พร้อมกันเป็นแถว จำกัดจำนวนครั้งไว้ด้วย ความล้มเหลวจริงจะได้โผล่ออกมาสักที และ retry เฉพาะ operation ที่เป็น idempotent เท่านั้น คือรันสองครั้งแล้วได้ผลเท่ากับรันครั้งเดียว

sequenceDiagram
  participant C as Caller
  participant D as Dependency
  C->>D: attempt 1 (timeout 1s)
  Note over C,D: no response within 1s
  C--xC: timeout fires, attempt cancelled
  Note over C: wait ~0.5s (base + jitter)
  C->>D: attempt 2 (timeout 1s)
  D--xC: 503 transient
  Note over C: wait ~1.0s (base*2 + jitter)
  C->>D: attempt 3 (timeout 1s)
  D-->>C: 200 OK
timeout กำหนดขอบเขตให้แต่ละความพยายาม การ retry รอนานขึ้นเรื่อย ๆ พร้อม jitter จนกว่าจะมีครั้งหนึ่งสำเร็จหรืองบหมด

ตัวอย่างนี้คือ helper ที่รัน operation โดยตั้ง timeout ต่อความพยายามหนึ่งครั้ง แล้ว retry ความล้มเหลวแบบ transient ด้วย exponential backoff บวก jitter โดยตั้งสมมติฐานว่า operation เป็น idempotent แต่ละตัวอย่างรันได้ด้วยตัวเองและเขียนตามสไตล์ของภาษานั้น ๆ

interface RetryOptions {
attempts: number;
timeoutMs: number;
baseDelayMs: number;
isTransient: (err: unknown) => boolean;
}
function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
return fn(ctrl.signal).finally(() => clearTimeout(timer));
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function retry<T>(
op: (signal: AbortSignal) => Promise<T>,
opts: RetryOptions,
): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < opts.attempts; i++) {
try {
return await withTimeout(op, opts.timeoutMs);
} catch (err) {
lastErr = err;
if (!opts.isTransient(err) || i === opts.attempts - 1) throw err;
// Exponential backoff with full jitter.
const ceiling = opts.baseDelayMs * 2 ** i;
await sleep(Math.random() * ceiling);
}
}
throw lastErr;
}

สิ่งที่คุณได้รับ:

  • latency มีเพดานชัดเจน เพราะทุกความพยายามมี timeout จึงไม่มีการเรียกไหนค้างตลอดกาล และผู้เรียกคำนวณเวลากรณีแย่ที่สุดได้ คร่าว ๆ คือจำนวนครั้งคูณ timeout บวกเวลาหน่วงจาก backoff
  • การฟื้นตัวอัตโนมัติจากการสะดุด การ reset แบบ transient, 503 ชั่วขณะ และการสะดุดของเครือข่ายชั่วครู่จะรักษาตัวเองโดยไม่ทำให้ error ปรากฏต่อผู้ใช้
  • ภาระที่กระจายตัว jitter ทำลายการซิงค์ที่สร้าง thundering herd ดังนั้น dependency ที่กำลังฟื้นตัวจึงไม่ถูกล้มด้วยกำแพงของการ retry พร้อมกัน

สิ่งที่คุณต้องจ่าย:

  • ความเสี่ยงต่อ retry storm การ retry ทวีภาระ หาก dependency รับภาระเกินจริง ๆ — ไม่ใช่แค่สะดุด — การ retry แบบไร้เดียงสาคือการเติมเชื้อไฟ นี่คือเหตุผลที่ retry ต้องจับคู่กับ circuit breaker อย่างแน่นอน: เมื่อ breaker trip การ retry จะหยุดโดยสิ้นเชิงจนกว่า dependency จะฟื้นตัว
  • idempotency เป็นเงื่อนไขบังคับ การ retry operation แบบ non-idempotent เสี่ยงทำให้ side effect เกิดขึ้นสองรอบ ทางเลือกคือจำกัด retry ไว้เฉพาะ operation ที่ปลอดภัย เช่น การอ่านและการเขียนแบบ idempotent หรือทำให้การเขียนเป็น idempotent ด้วย idempotency key เพื่อให้ระบบตรวจเจอรายการซ้ำแล้วทิ้งไป
  • การจูนงบประมาณ ความพยายามมากเกินไปหรือ timeout นานเกินไปทำให้การเรียกที่ล้มเหลวมีราคาแพงสำหรับผู้เรียก น้อยเกินไปทำให้การ retry ไร้ความหมาย งบเวลารวมต้องอยู่ภายใน deadline ของผู้เรียกเอง
  • Circuit Breaker — คู่หูสำคัญที่หยุดการ retry ไม่ให้กลายเป็น storm
  • Bulkhead — จำกัดเพดานทรัพยากรที่การ retry จะกินได้ระหว่างที่รออยู่
  • Saga — compensating transaction ต้องเป็น idempotent ด้วยเหตุผลเดียวกัน เพราะมีโอกาสโดน retry
ข้อดีข้อแลกเปลี่ยน
จัดการ transient failure อัตโนมัติ — network blip ไม่กระทบ userretry อาจไปเพิ่ม load ให้ dependency ตอนที่กำลัง overload อยู่
timeout ป้องกัน thread leak จาก slow dependencytimeout สั้นเกินทำให้ fail operation ที่ควรจะสำเร็จ
exponential backoff ลด thundering herdretry storm — หลาย client retry พร้อมกัน อาจ DDoS dependency
jitter กระจาย retry ไม่ให้พร้อมกันnon-idempotent operation ต้องระวัง retry ทำซ้ำ

Retry Non-idempotent Operation — retry POST /payment โดยไม่ตรวจสอบ idempotency อาการ:

  • user ถูกเรียกเก็บเงินสองครั้งจาก retry
  • ต้องใช้ idempotency key สำหรับ operation ที่ side effect

Retry ที่ไม่มี Backoff — retry ทันทีซ้ำ ๆ อาการ:

  • ส่ง 100 request ในวินาทีเดียวไปยัง service ที่กำลัง overload
  • ทำให้ service ฟื้นตัวช้าลงหรือล้มหนักกว่าเดิม
  • ใช้ exponential backoff + jitter เสมอ

💡 ตัวอย่างจากของจริง

AWS SDK:

  • built-in retry ด้วย exponential backoff สำหรับทุก API call
  • default: 3 retry, backoff 100ms → 200ms → 400ms + jitter
  • configurable per-operation timeout

Google Cloud:

  • แนะนำ truncated exponential backoff สำหรับ all API
  • max retry delay 32 วินาที ป้องกัน retry storm
ทำไมทุกการเรียก remote จึงต้องมี timeout ที่เข้มงวด?
การเพิ่ม jitter ให้การหน่วงเวลาในการ retry แก้ปัญหาอะไร?
operation แบบใดที่ปลอดภัยต่อการ retry โดยไม่ต้องระวังเป็นพิเศษ?
ทำไมการ retry จึงควรจับคู่กับ circuit breaker?