ปัญหา
การเรียกข้าม 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