บทที่ 12 · Part 3 — Scale & Reliability

Resilience Patterns

Taxonomy ของ failure, timeout/retry/circuit breaker/bulkhead/hedged request พร้อมค่าที่ควรตั้ง และ load shedding

พฤติกรรมเริ่มต้นของทุกระบบตอนโหลดเกินคือพฤติกรรมที่แย่ที่สุดเท่าที่จะเป็นได้ มันจะยุ่งที่สุดและมีประโยชน์น้อยที่สุดพร้อมกัน บทนี้คือวิธีทำให้การปฏิเสธงาน เป็นพฤติกรรมที่ออกแบบไว้ ไม่ใช่ผลข้างเคียง

จบบทนี้คุณจะ

  • เรียกชื่อ failure ทั้ง 9 ชนิดได้ — โดยเฉพาะ gray failure และ metastable failure
  • ตั้งค่า timeout, retry, circuit breaker, bulkhead ด้วยตัวเลขจริง ไม่ใช่ default
  • รู้ว่าทำไม dependency ที่ ช้า แย่กว่าที่ ตาย
  • เข้าใจ goodput collapse และแก้ด้วย bounded queue + load shedding + drop expired work
  • เขียนตาราง degradation และรู้ว่าแถวไหนต้องให้คนที่มีอำนาจตัดสิน

Taxonomy ของ failure — จำชื่อมันไว้

ชนิดหน้าตาเป็นอย่างไรทำไมอันตรายการป้องกัน
Crash / fail-stopprocess หรือ node ตายเคสที่ง่ายที่สุด — health check จับได้redundancy + ตรวจจับเร็ว
Gray failureยังมีชีวิต แต่ช้า, หรือล้ม 5% ของ request, หรือเสียเฉพาะบางเส้นทางhealth check ผ่าน จึงยังส่ง traffic เข้า node ที่พังต่อไป — แย่กว่า crashoutlier detection, ติดตาม error ฝั่ง client, eject ตาม p99
Partial failureเขียน DB สำเร็จ แต่ publish event ไม่สำเร็จข้อมูลไม่ตรงกันแบบเงียบ ค้นพบอีกหลายวันถัดมาโดยฝ่ายบัญชีoutbox pattern, reconciliation, replay ที่ idempotent
Cascading failureการล้มของตัวหนึ่งทำให้ตัวถัดไปโหลดเกิน ต่อกันไปเรื่อยๆtrigger เล็ก แต่ล่มทั้งระบบ; กู้ยากเพราะโหลดพุ่งตอน restarttimeout, circuit breaker, bulkhead, load shedding
Correlated failurereplica "อิสระ" ทั้ง 3 ตัวใช้ rack เดียวกัน, AZ เดียวกัน, config push เดียวกัน, หรือ certificate ใบเดียวกันเลข redundancy ของคุณเป็นนิยายmap failure domain ออกมาให้ชัด; ทยอย rollout config และ certificate
Metastable failureระบบยังพังอยู่หลังจาก trigger หายไปแล้ว — retry และ cache ที่เย็นทำให้มันอิ่มตัวต่อไปการกำจัดสาเหตุไม่ทำให้ service กลับมา — outage ยาวหลายชั่วโมงload shedding, retry budget, แผน warm cache, ความสามารถปิด traffic แล้วค่อยๆ เปิด
Poison / data-triggeredrecord เสียใบเดียวหรือ config เสียชุดเดียวทำให้ consumer ทุกตัวที่อ่านมัน crashcorrelated 100% ข้ามทุก replica — redundancy ไม่ช่วยอะไรเลยvalidate ที่ขอบเขต, DLQ, canary config, kill switch
Split brainnode 2 ตัวเชื่อว่าตัวเองเป็น leaderwrite แยกทางกัน; reconcile ทีหลังยากจริงๆquorum/consensus, fencing token, ห้ามมี "แค่ promote มันเลย" แบบ manual
Human / proceduralคำสั่งผิด, environment ผิด, migration ที่ไม่ผ่าน reviewในเชิงสถิติเป็นสาเหตุอันดับต้นของ incident ใหญ่least privilege, four-eyes กับงาน destructive, โหมด dry-run, ทำ automation แทนคำสั่งเป็นเอกสาร

Postmortem สาธารณะ 4 ฉบับที่คุ้มค่า 1 ชั่วโมงของคุณ

GitHub, ตุลาคม 2018 — network partition 43 วินาทีระหว่าง data center 2 แห่ง ทำให้ MySQL failover อัตโนมัติ หลังจากนั้น 2 ฝั่งรับ write ที่แยกทางกัน การซ่อม consistency ใช้เวลากว่า 24 ชั่วโมงในสภาพ degraded บทเรียน: automated failover ข้าม WAN เปลี่ยนสะดุด 43 วินาทีให้เป็น incident เรื่องความถูกต้องของข้อมูลนาน 1 วัน — กลไก failover เองก็เป็นความเสี่ยงที่ต้องออกแบบ (post-incident analysis)

Cloudflare, กรกฎาคม 2019 — WAF rule ที่มี regular expression ซึ่ง backtrack อย่างหายนะถูก deploy ทั่วโลกและกิน CPU ของ edge fleet ทั้งหมด บทเรียน: config และ rule data คือโค้ด ต้อง canary, ต้องมี resource limit, และต้อง revert ได้ทันที (details of the outage)

AWS Kinesis, พฤศจิกายน 2020 — การเพิ่ม capacity ให้ front-end fleet ทำให้จำนวน OS thread เกิน limit ที่ตั้งไว้ ซึ่งทำลาย membership map ภายในของ fleet บทเรียน: การ scale up เองก็เป็น trigger ได้ — รู้ limit ของคุณ (thread count, file descriptor, connection cap, ช่วงของ ID, port exhaustion) และ alarm ตอนที่กำลังใกล้ ไม่ใช่ตอนที่ชนแล้ว (service event summary)

Roblox, ตุลาคม 2021 — outage 73 ชั่วโมง ที่ฟีเจอร์ซึ่งเพิ่งเปิดในชั้น service discovery สร้างรูปแบบโหลดที่ระบบกู้คืนไม่ได้แม้จะเอา traffic ออกไปแล้ว — metastable failure ตามตำรา บทเรียน: การกู้คืนเป็นปัญหา design แยกจาก availability ให้ถามว่า "เราจะกลับขึ้นมาได้อย่างไร" และซ้อมมัน (Roblox return to service)

อ่าน postmortem เป็นนิสัย ไม่ใช่เป็นความบันเทิง — ทุกฉบับให้ถามว่า กลไกนี้เกิดขึ้นในระบบของผมได้ไหม และอะไรจะบอกผม แล้วเขียน alert ที่ขาดไป

Resilience pattern พร้อมค่าที่สำคัญ

1. Timeout — รากฐานที่ทุกอย่างวางอยู่บน

  • ทุกการเรียกผ่าน network ต้องมี timeout ที่ระบุชัด default ของ library มักเป็น "ไม่จำกัด" ซึ่งแปลว่า dependency ที่ช้า 1 ตัว กิน thread ของคุณได้ทั้งหมด (Little's Law อีกครั้ง)
  • ตั้ง timeout จาก p99.9 ของ dependency ตอนสุขภาพดี บวก margin ไม่ใช่จากเลขกลมๆ ที่คุณชอบ — ถ้า p99 ของมันคือ 80 ms timeout 30 วินาทีไม่ใช่ความใจกว้าง มันคือ resource leak
  • จัดงบ timeout ลดหลั่นตามสาย ถ้า client รอ 2 s, gateway ควรรอ 1.8 s, service 1.5 s, database 1 s และ propagate งบที่เหลือ (deadline) ไปใน header เพื่อไม่ให้มี hop ไหนทำงานกับ request ที่ไม่มีใครรอคำตอบแล้ว
  • แยก connect timeout (สั้น ~200 ms — TCP handshake สำเร็จหรือ host หายไปเลย) ออกจาก read timeout
Deadline propagation — ส่งงบที่เหลือไปด้วย

Client  timeout 2000 ms   -> header: X-Request-Deadline: <ts + 2000ms>
Gateway เหลือ 1850 ms     -> ตั้ง timeout ของตัวเอง = min(1800, เหลือ)
Service เหลือ 1600 ms     -> ตั้ง DB query timeout = min(1000, เหลือ - 200)
DB      ถ้าเหลือ < 50 ms  -> ไม่ต้องเรียกเลย ตอบ deadline_exceeded ทันที

--> ประหยัดงานที่ไม่มีใครรอ ซึ่งคือหัวใจของการกู้จาก overload

2. Retry — จำเป็น และเป็นวิธีที่คนทำให้ระบบล่มบ่อยที่สุด

ผิด      retry ทันที 3 ครั้ง
         -> โหลด 4 เท่าบน service ที่กำลังลำบาก ในวินาทีเดียวกัน

ดีขึ้น   exponential backoff: 100ms, 200ms, 400ms
         -> ยัง synchronized กันอยู่ client ทุกตัวที่ล้มพร้อมกัน
            จะ retry พร้อมกัน เกิดเป็นคลื่น

ถูก      exponential backoff + FULL JITTER
         sleep = random_between(0, min(cap, base * 2^attempt))
         -> กระจาย retry อย่างเรียบ มีข้อมูลพิสูจน์ว่าชนกันน้อยลง
            และเสร็จเร็วกว่า exponential backoff เปล่าๆ

กฎที่สำคัญกว่าสูตร:

กฎเหตุผล
Retry เฉพาะ operation ที่ idempotent หรือที่พา idempotency key ไปด้วยห้าม retry POST ที่ไม่ idempotent แบบมืดๆ เด็ดขาด
Retry เฉพาะ error ที่ retryable — 429, 503, timeout, connection resetห้าม retry 400/401/403/422 — คำตอบไม่เปลี่ยน
Retry ที่ชั้นเดียวถ้า SDK + gateway + client retry 3 ครั้งทั้งหมด request เดียวของผู้ใช้กลายเป็น 27 การเรียก backend
ใช้ retry budget — จำกัด retry ที่ ~10% ของ request ทั้งหมดเมื่อ dependency ล้มเป็นวงกว้าง การ retry มากขึ้นทำให้กู้คืนช้าลง — กฎข้อเดียวนี้ป้องกัน outage จาก retry storm
ส่ง Retry-After เมื่อคุณเป็นคนปฏิเสธให้ client รู้ว่าควรรอนานเท่าไหร่ ไม่ต้องเดา

Retry budget คือกฎที่คนข้ามบ่อยที่สุดและมีค่าที่สุด

ถ้า dependency ล้ม 50% การ retry ทำให้โหลดที่มันได้รับเพิ่มขึ้น ตอนที่มันต้องการโหลดน้อยลงที่สุด ตั้งเพดานว่า retry รวมกันต้องไม่เกิน 10% ของ request ปกติ เกินจากนั้นให้ล้มทันทีโดยไม่ retry

อ้างอิงหลัก: Timeouts, retries, and backoff with jitter (Amazon Builders' Library) และ Exponential Backoff And Jitter — ฉบับหลังมีข้อมูล simulation ที่แสดงว่าทำไม full jitter ชนะ

3. Circuit breaker — หยุดเรียกของที่ล่ม

ตั้งค่าด้วยตัวเลขจริง ไม่ใช่ default:

พารามิเตอร์ค่าที่ควรตั้งเหตุผล
ปริมาณ request ขั้นต่ำก่อนที่จะ trip ได้เช่น 20 request ใน 10 sไม่อย่างนั้น error 2 ใบตอน 03:00 จาก health probe เปิด circuit ได้
Failure thresholdเช่น > 50% error ใน 20 request / 10 sต้องเป็นสัดส่วน ไม่ใช่จำนวนดิบ
Open duration5–30 s เป็นปกตินานเกินไป = degrade นานเกินจำเป็น
Half-open trial concurrency1–5 request ไม่ใช่ทั้ง fleetไม่อย่างนั้น half-open กลายเป็น thundering herd

สิ่งที่สำคัญที่สุดและขาดบ่อยที่สุด

ตอน circuit เปิดอยู่ คุณจะ *ทำอะไร*

  • serve ข้อมูลเก่าจาก cache (ดีที่สุด ถ้าโดเมนอนุญาต)
  • คืนค่า default ที่ degrade ("ดึง limit ไม่ได้ ใช้ base limit")
  • เข้าคิวไว้ทำทีหลัง (เฉพาะเมื่อ caller รอได้)
  • ล้มเร็วด้วยข้อความที่ชัดเจนและซื่อสัตย์

Breaker ที่ไม่มี fallback ที่นิยามไว้ แค่เปลี่ยน error ที่ช้าให้เป็น error ที่เร็ว ซึ่งยังเป็นชัยชนะด้าน stability — แต่ต้องพูดออกมาให้ชัด เพราะประสบการณ์ของลูกค้าเหมือนกับตอนระบบล่มเป๊ะ

4. Bulkhead — แยกกันไว้เพื่อให้การล้มจุดเดียวไม่จมเรือ

  • แยก connection pool และ thread pool ต่อ dependency — partner API ที่ค้างจะกิน thread ที่เส้นทาง login ต้องใช้ไม่ได้
  • แยก compute ต่อชั้นของ tenant — traffic batch ของ partner รายใหญ่ที่สุด ต้องไม่อยู่ปนกับ traffic แบบ interactive ของผู้บริโภค
  • แยก critical path ออกจาก nice-to-have path ในทางกายภาพ — การส่งคำสั่งจ่ายเงินกับ marketing analytics ไม่ควรใช้ database, queue หรือ autoscaling group เดียวกัน

Real case — pods ของ Shopify (cell-based architecture)

Shopify แบ่ง platform เป็น "pod" — แต่ละ pod เป็น stack ที่ครบและแยกจากกัน มีฐานข้อมูลของตัวเอง serve ร้านค้าชุดหนึ่ง การล้มใน pod หนึ่งกระทบเฉพาะร้านใน pod นั้น และมันให้หน่วยที่สะอาดสำหรับ capacity planning, การวางตาม region, และการย้ายร้านใหญ่ไปใช้ capacity เฉพาะก่อนวัน flash sale

ทำไมสำคัญกับ fintech: cell ให้ blast radius ที่มีขอบเขต และเป็นคำตอบตามธรรมชาติสำหรับคำถามของ regulator เรื่อง isolation บวกกับโครงสร้าง deployment ring ที่ปลอดภัย (deploy ที่ cell 1, ดูผล, ไปต่อ)

ต้นทุนก็จริง: operation ข้าม cell กลายเป็นเรื่องยาก และต้องมี routing กับ shard-map service — รับมาเมื่อตัวขับคือ blast radius ไม่ใช่ throughput (A pods architecture to allow Shopify to scale)

5. Hedged request — เครื่องมือสำหรับ tail latency

ถ้า p99 แย่กว่า p50 มากเพราะมี node ช้าเป็นครั้งคราว: ส่ง request ไป แล้วถ้าถึงจุด p95 ยังไม่ได้คำตอบ ให้ส่งสำเนาที่ 2 ไปที่ replica อื่น แล้วเอาคำตอบที่มาถึงก่อน

ต้นทุนคือโหลดเพิ่มไม่กี่เปอร์เซ็นต์ แลกกับ p99 ที่ดีขึ้นมาก

เงื่อนไขของ hedged request

  • operation ต้อง idempotent และ read-only
  • ต้องยกเลิกตัวที่แพ้ ไม่อย่างนั้นคุณเพิ่มโหลดเป็น 2 เท่าถาวร

ห้ามใช้กับ write ที่เคลื่อนย้ายเงิน — นั่นคือการสร้างรายการซ้ำ เทคนิคนี้เป็นหนึ่งในแกนของ The Tail at Scale

6. Graceful degradation — ต้องมีขั้นตอน design ของตัวเอง

สำหรับทุก dependency เขียนลงไปว่า product ทำอะไรเมื่อมันใช้ไม่ได้ ใส่ในตารางและให้ product เซ็นรับ

Dependency ล่มพฤติกรรมที่ degradeใครตัดสิน
Recommendation serviceแสดง list ยอดนิยมแบบ staticProduct — ไม่ต้องขออนุมัติ
Notification serviceธุรกรรมยังสำเร็จ; notification เข้าคิวและส่งช้าProduct
Fraud scoring serviceการตัดสินใจเชิงธุรกิจ: บล็อกทุกธุรกรรม (fail closed) หรืออนุญาตต่ำกว่า threshold แล้วรีวิวย้อนหลัง (fail open พร้อม limit)Risk / Compliance — ต้องเป็นการตัดสินใจของคนที่มีอำนาจ และต้องมีเอกสาร
Ledger databaseปฏิเสธ transfer ใหม่ด้วยข้อความที่ชัดเจน — ห้ามรับการเคลื่อนย้ายเงินที่บันทึกไม่ได้กฎวิศวกรรมที่ไม่มีการต่อรอง

Goodput collapse — และวิธีแก้

เมื่อโหลดมาถึงเร็วกว่าที่ serve ได้และไม่มี overload policy: request เข้าคิว → latency โต → client timeout แล้ว retry → คิวโตขึ้นอีก ระบบจึงใช้ capacity เต็มไปกับ response ที่ client เลิกรอแล้ว ขณะที่ goodput — งานที่มีประโยชน์ต่อวินาที — ลดลง

กำลังวาด chart…

ด้านขวาของ peak ระบบยุ่งขึ้นแต่มีประโยชน์น้อยลง นี่คือ collapse ไม่ใช่ saturation และเป็นรูปแบบที่พบได้บ่อยใน outage จาก overload

เครื่องมือ 4 อย่าง ใช้พร้อมกัน:

Bounded queue ทุกที่

Unbounded queue คือระเบิดเวลาของ latency

มันเปลี่ยนภาวะ overload ให้เป็นความช้า, ซ่อนปัญหา, และทำลาย goodput ใส่ขอบเขตให้ทุก queue, thread pool และ buffer เมื่อเต็มแล้วให้ปฏิเสธเร็ว

Load shedding

เหนือ threshold ของ concurrency ให้คืน 503 พร้อม Retry-After ทันที

การปฏิเสธ 20% ของ traffic ใน 1 ms ทำให้ 80% ยังสุขภาพดี; การรับ 100% แบบแย่ๆ ไม่ทำให้ใครได้ประโยชน์เลย

Shed ตามลำดับความสำคัญ:

ลำดับการทิ้ง (ทิ้งจากบนลงล่าง)
  1. batch และ analytics
  2. request ที่ไม่ authenticated
  3. request แบบ read ที่ไม่ critical
  4. read ที่ critical
  X. ห้ามทิ้ง: health check, control plane ของ operator,
     และ (สำหรับ fintech) การอ่านสถานะธุรกรรมที่ค้างอยู่

หมายเหตุ: adaptive concurrency limit (วัด latency แล้วปรับเพดานอัตโนมัติ)
มักดีกว่าตัวเลขที่ตั้งมือ เพราะตัวเลขที่ตั้งมือจะล้าสมัยทันทีที่
โปรไฟล์ของ dependency เปลี่ยน — แต่ต้องมี metric ยืนยันว่ามันปรับถูกจริง

Drop expired work

ก่อนประมวลผลงานในคิว ให้เช็ค deadline ของมัน ถ้า client เลิกรอไปแล้ว 10 วินาที ให้ทิ้ง — ข้อเดียวนี้กู้ระบบที่กำลังพังทลายได้

Fairness / quota ต่อ tenant

script ที่หลุดของ partner รายเดียวต้องไม่กิน capacity ที่ใช้ร่วมกัน Rate limit ต่อ API key และต่อ tenant ไม่ใช่แค่ระดับ global — และให้แต่ละ tenant มีพื้น (floor)

อ้างอิงที่ควรอ่านก่อน capacity review ครั้งถัดไป

  • Using load shedding to avoid overload — Amazon Builders' Library คำอธิบาย goodput collapse ที่ชัดที่สุด
  • Addressing Cascading Failures — Google SRE Book บทที่ 22 มีหัวข้อสำคัญเรื่องวิธี กู้คืน จาก cascading failure (คำใบ้: คุณอาจต้องลด traffic ลงเป็นศูนย์แล้วค่อยๆ เพิ่ม)
  • Handling Overload — Google SRE Book บทที่ 21 เรื่อง criticality level, client-side throttling และ retry budget
  • Performance Under Load — Netflix adaptive concurrency limit ที่ derive มาจาก queueing theory และรันบน production

Checklist resilience

  • ทุกการเรียก network มี timeout ที่ตั้งจาก p99.9 ของ dependency
  • Deadline propagate ไปทุก hop และ hop ที่เหลือเวลาน้อยเกินจะตอบทันที
  • Retry มี full jitter, retry เฉพาะ error ที่ retryable, retry ชั้นเดียว
  • มี retry budget ~10%
  • Circuit breaker มีปริมาณขั้นต่ำก่อน trip และ มี fallback ที่นิยามไว้ชัด
  • Connection/thread pool แยกต่อ dependency
  • Critical path ไม่แชร์ database/queue/ASG กับงาน analytics
  • ทุก queue, pool, buffer มีขอบเขต
  • มี load shedding ที่ shed ตามลำดับความสำคัญ และไม่ shed health check
  • Worker ตรวจ deadline ก่อนทำงาน และทิ้งงานที่หมดอายุ
  • Rate limit ต่อ tenant ไม่ใช่แค่ global
  • มี ตาราง degradation ที่ product/risk เซ็นรับแล้ว
  • มีคำตอบว่า "เราจะกลับขึ้นมาได้อย่างไร" และเคยซ้อมแล้ว

สรุปบทนี้

Gray failure แย่กว่า crash เพราะ health check ผ่าน · metastable failure ไม่หายแม้ trigger จะหายไป — ต้องปิด traffic แล้วค่อยเปิด · timeout จาก p99.9 ไม่ใช่เลขกลม · retry ต้องมี jitter, ชั้นเดียว และมี budget · circuit breaker ต้องมี fallback ไม่ใช่แค่ fail เร็วขึ้น · unbounded queue ทำลาย goodput · และการปฏิเสธงานต้องเป็นสิ่งที่ ออกแบบไว้