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
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ตัวอย่างนี้คือ 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;}import asyncioimport randomfrom typing import Awaitable, Callable, TypeVar
T = TypeVar("T")
async def retry( op: Callable[[], Awaitable[T]], *, attempts: int, timeout_s: float, base_delay_s: float, is_transient: Callable[[Exception], bool],) -> T: last_err: Exception | None = None for i in range(attempts): try: return await asyncio.wait_for(op(), timeout=timeout_s) except (asyncio.TimeoutError, Exception) as err: last_err = err transient = isinstance(err, asyncio.TimeoutError) or is_transient(err) if not transient or i == attempts - 1: raise # Exponential backoff with full jitter. ceiling = base_delay_s * (2 ** i) await asyncio.sleep(random.uniform(0, ceiling)) assert last_err is not None raise last_errtype RetryOptions struct { Attempts int Timeout time.Duration BaseDelay time.Duration IsTransient func(error) bool}
func Retry[T any]( ctx context.Context, op func(context.Context) (T, error), opts RetryOptions,) (T, error) { var zero T var lastErr error for i := 0; i < opts.Attempts; i++ { attemptCtx, cancel := context.WithTimeout(ctx, opts.Timeout) v, err := op(attemptCtx) cancel() if err == nil { return v, nil } lastErr = err if !opts.IsTransient(err) || i == opts.Attempts-1 { return zero, err } // Exponential backoff with full jitter. ceiling := opts.BaseDelay * (1 << i) jitter := time.Duration(rand.Int63n(int64(ceiling))) select { case <-time.After(jitter): case <-ctx.Done(): return zero, ctx.Err() } } return zero, lastErr}use std::future::Future;use std::time::Duration;use rand::Rng;use tokio::time::{sleep, timeout};
pub struct RetryOptions { pub attempts: u32, pub timeout: Duration, pub base_delay: Duration,}
pub async fn retry<T, E, F, Fut>( opts: &RetryOptions, mut op: F, is_transient: impl Fn(&E) -> bool,) -> Result<T, E>where F: FnMut() -> Fut, Fut: Future<Output = Result<T, E>>, E: From<&'static str>,{ let mut last_err: Option<E> = None; for i in 0..opts.attempts { match timeout(opts.timeout, op()).await { Ok(Ok(v)) => return Ok(v), Ok(Err(e)) => { let transient = is_transient(&e); last_err = Some(e); if !transient || i == opts.attempts - 1 { return Err(last_err.unwrap()); } } Err(_) => last_err = Some(E::from("attempt timed out")), } // Exponential backoff with full jitter. let ceiling = opts.base_delay * 2u32.pow(i); let jitter = rand::thread_rng().gen_range(0..=ceiling.as_millis() as u64); sleep(Duration::from_millis(jitter)).await; } Err(last_err.unwrap())}ผลลัพธ์ที่ตามมา
หัวข้อที่มีชื่อว่า “ผลลัพธ์ที่ตามมา”สิ่งที่คุณได้รับ:
- 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 ไม่กระทบ user | retry อาจไปเพิ่ม load ให้ dependency ตอนที่กำลัง overload อยู่ |
| timeout ป้องกัน thread leak จาก slow dependency | timeout สั้นเกินทำให้ fail operation ที่ควรจะสำเร็จ |
| exponential backoff ลด thundering herd | retry 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