Animated Architecture Patterns

Retry with Backoff & Jitter

Retry error ที่เกิดแค่ชั่วคราว โดยรอนานขึ้นเรื่อย ๆ และสุ่มระยะรอ client จะได้ไม่แห่กันกลับมาถล่มพร้อมกัน

หรือที่เรียกกันว่า Retry pattern, Exponential backoff, Jittered backoff

429 wait for Retry-After: 1 s ok on attempt 2 503 wait 100 ms ok on attempt 2 400 not retried blip retry storm: 6 calls in flight, nonstop 100 ms 200 ms 400 ms 800 ms idle between waves every wave overloads it again ok on attempt 3 of 5 ok on attempt 3 of 5 ok on attempt 3 of 5 ok on attempt 3 of 5 ok on attempt 3 of 5 ok on attempt 4 of 5 retries spread out: under capacity attempts per client waiting to retry succeeded failed client 1 client 2 client 3 client 4 client 5 client 6 Service HEALTHY BLIP OVERLOADED calls in flight over capacity: every call in flight times out capacity 4 calls at once 0 0.5 s 1 s 1.5 s 2 s RETRY POLICY What to retry timeout, 503 transient: retry after a wait 429 wait as long as Retry-After says 400, 401 never: a retry can't fix them Retry immediately wait = 0 Every client resends the moment its call fails, again and again. Retries add to the overload that caused the failures. Exponential backoff wait = min(800, 100×2n) ms n = retries so far Waits double: 100, 200, 400, 800 ms, then stay at the cap. Same schedule on every client. Backoff + full jitter backoff = min(800, 100×2n) wait = random(0, backoff) Each client draws its own wait, so retries no longer line up. Max 5 attempts, then fail. RESULT AFTER 2 s attempts succeeded – – 8 5 of 6 the 400 is not retried 120 0 of 6 nothing gets through 30 0 of 6 fewer attempts, no success 19 6 of 6 recovered within 0.75 s

ขั้นที่ 1 · Retry แค่ที่ยังมีโอกาสผ่าน ระบบสะดุดไปช่วงสั้น ๆ (blip) การเรียกครั้งหนึ่งเลย fail ด้วย 503 client รอ 100 ms แล้วลองครั้งที่สองก็ผ่าน ให้ retry แค่ error ที่หายไปเองได้ คือ timeout, 503 และ 429 หลังรอครบตาม Retry-After แล้ว ส่วน 400 หรือ 401 ให้ส่งกลับไปเลย เพราะส่ง request เดิมซ้ำอีกกี่รอบก็ไม่มีทางผ่าน

ขั้นที่ 2 · Retry ทันทีแบบไม่รอ การเรียก 6 ครั้งเข้ามาพร้อมกัน เกินกว่าที่ service จะรับไหวในทีเดียว เลย timeout หมดทุกครั้ง ทุก client retry ทันทีที่การเรียกของตัวเอง fail ก็เลยมีการเรียกค้างอยู่ (in flight) 6 ครั้งตลอดเวลาไม่หยุด: รวม 120 ครั้งในสองวินาที ไม่ผ่านเลยสักครั้ง แถมการ retry ยังทำให้ service รับโหลดเกินอยู่ตลอด แทนที่จะได้ฟื้นตัว

ขั้นที่ 3 · ใช้ exponential backoff แต่ละ client รอนานขึ้นทุกครั้งที่ fail: 100, 200, 400 แล้วก็ 800 ms ที่เป็นเพดาน (min(cap, base × 2ⁿ)) จำนวนครั้งที่เรียกลดจาก 120 เหลือ 30 และ service ได้ว่างระหว่างรอบ แต่ client ทั้งหมด fail พร้อมกันและรอตามตารางเดียวกัน แต่ละรอบเลยมาถึงเป็นคลื่นลูกเดียว แล้วทำให้ service รับโหลดเกินอีกรอบ

ขั้นที่ 4 · ใส่ jitter พอใช้ full jitter ก็จะสุ่มระยะรอทุกครั้ง ให้อยู่ระหว่างศูนย์ถึงค่า backoff (random(0, min(cap, base × 2ⁿ))) retry ชุดแรกยังชนกันอยู่ แต่หลังจากนั้นก็กระจายออก โหลดอยู่ต่ำกว่า capacity และการเรียกทั้ง 6 ครั้งก็ผ่านหมดใน 0.75 s โดยใช้ไปทั้งหมด 19 ครั้ง ส่วนการจำกัดไว้ที่ 5 ครั้งก็กันไม่ให้ client retry ไปเรื่อย ๆ ไม่รู้จบ ถ้า fail นั้นไม่ได้เกิดแค่ชั่วคราว

ปัญหา

การเรียกข้าม network fail ได้ด้วยเหตุผลที่หายไปเอง: connection หลุด, node restart หรือ service รับโหลดเกินไปแป๊บหนึ่ง หรือกำลัง throttle client ที่ยิงหนัก ๆ การ retry ทำให้การสะดุดพวกนี้ส่วนใหญ่กลายเป็นความสำเร็จที่ไม่มีใครสังเกตเห็น แต่ retry ทุกครั้งก็คือ request อีกหนึ่งตัว ที่ส่งไปตอนที่ service รับมันได้น้อยที่สุดพอดี ถ้า service fail เพราะโหลดเกิน ตัว client ที่ retry ทันทีก็จะดันโหลดให้เกิน capacity ต่อไป failure เลยไม่หยุด และจำนวนครั้งที่ลองก็ทวีคูณขึ้น: นี่คือ retry storm ทาง Builders' Library ของ Amazon เตือนว่า retry "อาจทำให้ระบบฟื้นช้าลงด้วยซ้ำ เพราะมันทำให้โหลดสูงค้างไปอีกนานหลังจากต้นเหตุเดิมถูกแก้ไปแล้ว" การรอเป็นเวลาคงที่หรือรอนานขึ้นเท่าตัวช่วยได้น้อยกว่าที่คิด: client ที่ fail พร้อมกันก็รอเท่ากันแล้วกลับมาพร้อมกัน เป็นคลื่น ๆ

ทำงานยัง⁠ไง

retry policy ตอบคำถามสามข้อ

  • failure นี้ควร retry ไหม ให้ retry สิ่งที่อาจผ่านในรอบหน้า: connection fail และ timeout, 503 Service Unavailable กับ 429 Too Many Requests และส่วนใหญ่ก็รวม 502, 504 และ 408 ด้วย (ใน gRPC คือ UNAVAILABLE) อย่าส่ง 400, 401, 403, 404 หรือ 422 ซ้ำไปแบบไม่แก้อะไร: request เดิมก็ fail แบบเดิม และ 401 ต้องใช้ credential ใหม่ ไม่ใช่การรอ (eventual consistency อาจทำให้เส้นแบ่งไม่ชัด: 404 ที่ได้หลัง create ไม่นานอาจกลายเป็นผ่านเมื่อ state ใหม่กระจายไปถึงแล้ว) ส่วน SRE book ของ Google พูดไว้ตรง ๆ ว่าอย่า retry permanent error หรือ request ที่ผิดรูปแบบ "เพราะทั้งสองอย่างไม่มีวันผ่าน"
  • ควรรอนานแค่ไหน ใช้ capped exponential backoff with jitter ค่า backoff โตขึ้นทุกครั้งที่ retry คือ min(cap, base × 2ⁿ) และถ้าใช้ full jitter ตัว client จะ sleep เป็นเวลาสุ่มระหว่างศูนย์ถึงค่า backoff นั้น backoff ทำให้ client แต่ละตัว retry ห่างขึ้น ส่วน jitter ช่วยสลายคลื่น ทำให้ retry ไปถึง service ในอัตราที่ค่อนข้างคงที่แทนที่จะมาพร้อมกันหมด ใน simulation ของบทความปี 2015 ของ Marc Brooker ตัว exponential backoff ที่ไม่มี jitter "แพ้ขาดลอย" ส่วน full jitter กับ decorrelated jitter (สุ่มระยะรอแต่ละครั้งระหว่าง base ถึงสามเท่าของระยะรอครั้งก่อน แล้วตัดที่ cap) ทำได้ดีที่สุด และเมื่อมี client แย่งกัน 100 ตัว jitter ลดจำนวนการเรียกได้มากกว่าครึ่ง ถ้า response มี Retry-After มาด้วย (เป็นจำนวนวินาทีหรือ HTTP date ตาม RFC 9110) ก็ให้รออย่างน้อยเท่านั้น
  • ควรหยุดเมื่อไหร่ หลังลองครบจำนวนครั้งที่กำหนด (default ของ standard retry mode ใน AWS SDK คือรวมสามครั้ง ส่วน Polly คือสี่ครั้ง) เมื่อการลองครั้งถัดไปไม่มีทางเสร็จก่อน deadline ของผู้เรียก หรือเมื่อ retry budget หมด แล้วก็คืน error กลับไป

ตัวเลขใน animation มาจาก model เล็ก ๆ: client หกตัวส่งการเรียกที่ใช้เวลา 100 ms ไปที่ service ที่ทำได้ทีละสี่งาน และระหว่างที่มีงาน in flight เกินสี่ตัว ทุกตัวจะ timeout หมด step 4 คือการรันแบบสุ่มที่กำหนด seed ไว้หนึ่งรอบ ที่มีจำนวนครั้งที่ลองเท่ากับค่า median (19) จากการจำลอง 20,000 รอบ

ใช้ตอนไหนดี

  • การเรียกอะไรก็ตามที่ข้าม network: API ระหว่าง service, database, message broker และ cloud API ทั้งนี้ cloud SDK ส่วนใหญ่ retry ให้อยู่แล้ว และ service mesh อย่าง Istio ก็เช่นกัน (default คือสองครั้งสำหรับ HTTP request) เลยควรดูก่อนว่าของคุณทำอะไรอยู่แล้ว ก่อนจะเพิ่มอีกชั้น
  • งาน background และ batch ที่ลองได้หลายครั้งกว่าและรอได้นานกว่า request ที่มี user รออยู่
  • ไม่ใช่สำหรับ permanent error และไม่ใช่การนั่งรอ dependency ที่ล่มไปหลายนาที: เลิกเรียกมันแล้ว fail fast ด้วย Circuit Breaker
  • ไม่ใช่สำหรับ operation ที่มี side effect เว้นแต่มันจะ idempotent (ดูด้านล่าง)

ได้อะไร เสียอะไร

  • retry คือโหลดที่เพิ่มขึ้น แต่ละครั้งคือการขอให้ service ที่กำลังแย่ทำงานเพิ่ม Builders' Library เลยเรียก retry ว่า "เห็นแก่ตัว" จำนวนครั้งสูงสุดกับ budget ช่วยคุมโหลดส่วนเกินให้มีขอบเขตตอนเกิด outage จริง
  • latency หรือความสำเร็จ ทุกการรอเพิ่ม latency ให้ผู้เรียก แนวทางของ Azure คือในแอปแบบ interactive ให้ fail หลัง retry จำนวนน้อยและสั้น ๆ แล้วแสดงข้อความ "ลองใหม่อีกครั้งภายหลัง" ส่วนตาราง exponential ที่ยาวกว่าให้เก็บไว้ใช้กับ batch job
  • การทำซ้ำ timeout ไม่ได้บอกว่า server ทำงานไปแล้วหรือยัง การ retry การจ่ายเงินที่จริง ๆ ผ่านไปแล้วจะตัดเงิน customer สองรอบ เว้นแต่ operation นั้นจะ idempotent
  • การขยายตัว retry ในหลายชั้นของ call chain จะคูณกัน (ดูด้านล่าง)
  • backoff อย่างเดียวไม่พอ ถ้าไม่มี jitter ตัว client ที่ fail พร้อมกันก็จะเดินจังหวะเดียวกันต่อไป และพอระยะรอแตะ cap ก็ retry พร้อมกันหมดในอัตราที่ cap กำหนด ต้องจำกัดจำนวนครั้งด้วย

ข้อควรรู้ตอนลงมือทำ

  • ทำให้ operation ที่ retry เป็น idempotent ใน RFC 9110 กำหนดให้ GET, HEAD, OPTIONS, TRACE, PUT และ DELETE เป็น idempotent และบอกว่า client "SHOULD NOT retry request ที่ใช้ method ที่ไม่ idempotent โดยอัตโนมัติ" เว้นแต่จะรู้ว่า request นั้น idempotent หรือยังไม่เคยถูกทำ สำหรับ POST และ operation อื่นที่ไม่ safe ให้ส่ง idempotency key: ID ที่ unique ต่อหนึ่ง operation เชิงตรรกะ และคงเดิมทุกครั้งที่ retry ฝั่ง server เก็บผลลัพธ์แรกของ key นั้นไว้แล้วคืนผลนั้นให้ทุก retry ส่วน header Idempotency-Key ของ Stripe ก็ทำงานแบบนี้ โดยเก็บ status code และ body ของ request แรกไว้ ฝั่ง message consumer ก็ต้องมีการป้องกันแบบเดียวกันเรื่องการส่งซ้ำ (Idempotent Consumer)
  • retry แค่ชั้นเดียว การ retry จะคูณกันไปตาม call chain ในตัวอย่างของ SRE book ของ Google ชั้น JavaScript, frontend และ backend ต่างก็ลอง 4 ครั้ง การกระทำครั้งเดียวของ user เลยส่งการลองไปที่ database ที่โหลดเกินอยู่ได้ถึง 64 ครั้ง (4³) ส่วน Builders' Library เล่าถึง stack ลึกห้าชั้นที่ retry ทุกชั้น และคูณโหลดที่ database ขึ้นไป 243 เท่า (3⁵) ชั้นที่มองไม่เห็นก็นับด้วย: แอปพลิเคชันที่ลองสามครั้งอยู่หลัง Istio sidecar ที่ retry สองครั้ง ก็ส่งได้ถึงเก้าครั้งแล้ว ให้ retry ที่จุดเดียว ตรงที่เข้าใจ operation ทั้งหมด แล้วให้ชั้นข้างล่าง fail fast และรายงานว่าเพราะอะไร
  • ตั้ง budget ให้ retry ไม่ใช่แค่จำนวนครั้ง การจำกัดจำนวนครั้งคุมได้แค่ request เดียว ส่วน budget คุมทั้ง client หรือทั้ง process ทำให้ outage ที่กินวงกว้างไม่ทำให้ทุก request กลายเป็นหลาย request ส่วน SRE book อธิบายการจำกัดสามครั้งต่อ request บวกกับ budget ต่อ client ที่ยอมให้ retry ได้เฉพาะตอนที่ยังต่ำกว่า 10% ของ request วิธีนี้ลดการโตของ traffic ในกรณีแย่ที่สุดจากเกือบ 3× เหลือราว 1.1× และยังแนะนำ limit ระดับทั้ง server เช่น 60 retry ต่อนาที token bucket เป็นวิธีที่ใช้กันบ่อยในการสร้าง budget อย่าง standard retry mode ของ AWS SDK ก็ใช้ token ทุกครั้งที่ retry คืน token เมื่อ request สำเร็จ และหยุด retry เมื่อ bucket ว่าง ส่วน retryThrottling ของ gRPC หัก token หนึ่งตัวต่อการ fail หนึ่งครั้ง เพิ่ม tokenRatio ต่อความสำเร็จหนึ่งครั้ง และพัก retry ไว้ตอนที่ token เหลือไม่ถึงครึ่ง และ retry_budget ของ Envoy จำกัด retry ที่เกิดพร้อมกันไว้เป็นเปอร์เซ็นต์ของ request ที่ active และ pending อยู่ (default 20%)
  • ตั้ง timeout และ deadline ให้การลองทุกครั้งมี timeout และให้ operation ทั้งหมดมี deadline ที่รวมเวลารอไว้แล้ว อย่าเริ่มการลองที่ไม่มีทางเสร็จก่อน deadline และส่งเวลาที่เหลือต่อไปให้การเรียกปลายน้ำ (deadline propagation ใน SRE book) เพื่อไม่ให้ชั้นไหนทำงานกับ request ที่ผู้เรียกทิ้งไปแล้วต่อ สำหรับ timeout ต่อการลองแต่ละครั้ง Builders' Library เริ่มจาก latency ของ dependency: เลือกอัตรา false timeout ที่ยอมรับได้ เช่น 0.1% แล้วใช้ percentile ที่ตรงกัน (p99.9)
  • ทำตาม Retry-After และส่งมันด้วย ใน 503 ตัว Retry-After บอกว่า service คาดว่าจะใช้งานไม่ได้นานแค่ไหน และ 429 (RFC 6585) ก็อาจมีมันติดมาด้วย ฝั่ง server ให้คืน status ที่บอกชัดว่าโหลดเกิน แทนที่จะปล่อยให้การเรียกค้าง client จะได้ถอยออกไป SRE book ก็แนะนำแบบนี้เป๊ะ ๆ และให้สุ่มระยะเวลาที่บอกออกไปด้วย ไม่อย่างนั้น client ทุกตัวที่โดน throttle จะกลับมาในวินาทีเดียวกัน
  • ใช้คู่กับ circuit breaker ตัว retry ช่วยผ่านการสะดุดสั้น ๆ ส่วน Circuit Breaker รับมือ failure ที่ยืดเยื้อเกินการลองไม่กี่ครั้ง ให้มอง "breaker open" ว่าเป็นกรณีที่ไม่ควร retry เพื่อไม่ให้ retry คอยไปเคาะ dependency ที่ breaker หยุดเรียกไปแล้ว Retry pattern ของ Azure ก็พูดไว้แบบเดียวกัน: หลัง fail ซ้ำหลายครั้ง ให้หยุดส่ง request แล้ว fail ทันที ส่วน bulkhead จำกัดจำนวน thread และ connection ที่การเรียก dependency ตัวเดียวถือไว้ได้ รวมถึง retry ด้วย (Bulkhead)
  • Library ทั้ง Polly (.NET), Resilience4j (Java, IntervalFunction.ofExponentialRandomBackoff), tenacity (Python, wait_random_exponential) และ AWS SDK ต่างก็รองรับ exponential backoff ที่สุ่มระยะรอ แต่ต้องเช็กค่า default: retry strategy ของ Polly รอคงที่ 2 วินาทีและปิด jitter ไว้ จนกว่าคุณจะตั้ง BackoffType = DelayBackoffType.Exponential และ UseJitter = true
  • คอยสังเกตมัน นับการลองครั้งแรก การ retry และการยอมแพ้แยกกัน แล้วทำกราฟ retry rate: SRE book บอกว่ามันช่วยเผยพฤติกรรม retry ที่ไม่ดีระหว่างเกิด incident ได้ Azure แนะนำให้ log การลองที่ fail แล้วถูก retry ต่อเป็น informational และ log แค่ failure ครั้งสุดท้ายเป็น error