retry ตอนบริการปลายทางช้าทำให้ระบบล่มหนักกว่าเดิมได้ และวิธีตั้ง retry ใน Laravel ให้ปลอดภัย

ตอน API ปลายทางช้า retry ที่ซ้อนกันหลายชั้นจะส่ง request ไปเพิ่มอีกหลายเท่า จนปลายทางฟื้นไม่ได้ วิธีกันคือให้ retry อยู่ที่เดียว ตั้ง deadline ให้ทั้งงาน จำกัดงานที่ทำพร้อมกัน มีโควตา retry รวม และสุ่มเวลารอ

เผยแพร่
เวลาอ่าน
13 นาที
สรุปจาก
Wendell Adriel · wendelladriel.com

บริการปลายทางช้าลงนิดเดียว แอปเราก็ค้างได้ทั้งแอป

ต้นฉบับยกตัวอย่างหน้า checkout ที่ต้องถามค่าส่งจาก API ของขนส่ง ปกติ API ตอบใน 100 ms วันหนึ่งช้าลงเป็น 2 วินาที คำตอบยังถูกต้องทุกอย่าง แค่ช้า

ทีนี้ลองคิดตามด้วยสูตรง่าย ๆ ที่เรียกว่า Little's Law คือ จำนวนงานที่ค้างพร้อมกัน = จำนวน request ต่อวินาที × เวลาที่แต่ละ request ใช้

request ต่อวินาที เวลาตอบ ค้างพร้อมกัน
ปกติ 100 0.1 วินาที ราว 10
ปลายทางช้า 100 2 วินาที ราว 200

ในโลก PHP ตัวเลขนี้คือจำนวน worker ของ PHP-FPM ที่ยืนรอคำตอบอยู่ ถ้าเราตั้ง pm.max_children ไว้ 50 แอปจะรับได้แค่ 50 request พร้อมกัน ที่เหลือต่อคิวรอ หน้าอื่นที่ไม่เกี่ยวกับค่าส่งเลยก็ช้าตามไปด้วย เพราะไม่มี worker ว่าง

ที่น่ากลัวคือ CPU ของ server ยังต่ำอยู่ เพราะ worker แค่นั่งรอ network ถ้าเราดูแต่กราฟ CPU จะไม่เห็นว่ามีปัญหา และถ้าแก้ด้วยการเพิ่ม worker ก็แค่ส่ง request ไปกองที่ปลายทางมากขึ้น ปลายทางไม่ได้เร็วขึ้นเพราะเรามีคนเรียกมากขึ้น

เราเจอแบบนี้กับระบบหลังบ้านตัวหนึ่งที่ต่อกับ SAP ตอนไล่ดู alert เรื่อง CPU บน production เราเปิด slowlog ของ php-fpm ดู แล้วเจอ request จำนวนมากค้างอยู่ใน curl_exec เพื่อรอ SAP ตอบ worker พวกนี้แทบไม่ใช้ CPU แต่จองที่ไว้ครบทั้ง 20 ตัวของ pool ช่วง peak log ของ php-fpm จึงขึ้นว่า seems busy … 0 idle

ถ้าอยากรู้ว่าแอปเราติดอยู่ที่ปลายทางช้าหรือเปล่า ลองดูสามที่นี้แทนกราฟ CPU

  • slowlog ของ php-fpm ตั้ง request_slowlog_timeout ไว้ แล้วดูว่า stack trace จบที่ curl_exec บ่อยแค่ไหน
  • log ของ php-fpm ที่มีคำว่า seems busy แปลว่า worker ว่างไม่พอแล้ว
  • card Slow Outgoing Requests ของ Laravel Pulse ที่บอกว่า URL ไหนช้าบ่อย และช้าสุดเท่าไร

retry ซ้อนกันหลายชั้นคูณจำนวน request ขึ้นไปเรื่อย ๆ

ต้นฉบับแยกสองคำนี้ให้ชัด

  • original request คืองานหนึ่งชิ้นที่ผู้ใช้สั่ง เช่น กดดูค่าส่งหนึ่งครั้ง
  • attempt คือการยิงจริงแต่ละครั้ง รวมครั้งแรกและทุกครั้งที่ retry

ถ้าทุกชั้นตั้ง retry ไว้ 3 ครั้งเหมือนกัน ผลจะคูณกัน เบราว์เซอร์ลองได้ 3 ครั้ง แอป Laravel ลองได้ 3 ครั้ง และ HTTP client ที่ยิงไปขนส่งลองได้อีก 3 ครั้ง การกดปุ่มครั้งเดียวจึงกลายเป็น 3 × 3 × 3 = 27 attempt ที่ขนส่งได้รับ ถ้ามีคนกด 100 ครั้งต่อวินาที แค่ชั้นล่างสุดชั้นเดียวก็ส่งไปถึง 300 attempt ต่อวินาทีแล้ว

retry สามชั้น ชั้นละ 3 ครั้ง กลายเป็น 27 attempt

กดปุ่มครั้งเดียว ถ้าทุกชั้น retry 3 ครั้ง ปลายทางโดนได้ถึง 27 ครั้ง

ในแอป Laravel เรื่องนี้เกิดได้ง่ายกว่าที่คิด เช่น Http::retry(3) ที่อยู่ใน queue job ซึ่งตั้ง #[Tries(3)] ไว้ แค่นี้ก็ 9 attempt แล้ว ยังไม่นับผู้ใช้ที่กดปุ่มซ้ำเพราะหน้าค้าง

ต้นฉบับแนะนำให้ มีที่เดียวที่ตัดสินใจ retry คือ class ที่คุยกับ API ปลายทางโดยตรง ชั้นอื่นห้าม retry เอง เราจะได้นับได้ว่าจริง ๆ แล้วยิงไปกี่ครั้ง

ตั้ง deadline ให้ทั้งงาน แทนการตั้งแค่ timeout ต่อครั้ง

timeout จำกัดเวลารอของ attempt ครั้งเดียว ส่วน deadline จำกัดเวลาของงานทั้งก้อน ถ้าตั้ง timeout 500 ms กับ retry 3 ครั้ง งานนี้อาจใช้ 1.5 วินาทีไปกับการรออย่างเดียว ยังไม่นับเวลาพักระหว่างรอบ

ใน Laravel ค่าเริ่มต้นของ HTTP client ยิ่งนานกว่านั้นอีก PendingRequest ตั้ง timeout ไว้ 30 วินาที และ connect_timeout 10 วินาที ถ้าเราไม่ตั้งเอง worker หนึ่งตัวรอได้นานถึงครึ่งนาทีต่อ attempt

class ที่เราเขียนครอบ API ไว้เองอาจตั้งค่ายาวกว่านั้นอีก ระบบที่ต่อ SAP ของเราเคยตั้ง timeout ไว้ 600 วินาที ตัวที่ตัดจริงบนหน้าเว็บจึงเป็น request_terminate_timeout ของ php-fpm ที่ 120 วินาที แปลว่า worker หนึ่งตัวรอได้ถึงสองนาทีต่อ request

ต้นฉบับใช้วิธีแบ่งงบเวลาแบบนี้

  • หน้า checkout มีเวลาทั้งหมด 900 ms
  • กันไว้ 50 ms สำหรับจัดการคำตอบ
  • เหลือ 850 ms ให้การถามค่าส่ง รวมทุก attempt

attempt แรกได้เวลาส่วนหนึ่ง พักแล้ว attempt ที่สองได้แค่เวลาที่เหลือ ไม่ได้เริ่มนับ 850 ms ใหม่ ส่วนการจับเวลาใช้ hrtime(true) ที่ให้ค่าเป็น nanosecond และไม่เพี้ยนตามนาฬิกาของเครื่อง

ควรเช็กด้วยว่า timeout ที่ตั้งครอบคลุมขั้นไหนบ้าง DNS, การต่อ connection, TLS และการอ่านคำตอบ อาจมีตัวตั้งแยกกัน ใน Laravel อย่างน้อยต้องตั้งทั้ง connectTimeout() และ timeout()

ใช้ timeout คนละชุดระหว่างหน้าเว็บกับ queue

class ที่คุยกับ API ตัวเดียวกันถูกเรียกได้ทั้งจากหน้าเว็บและจาก queue job แต่สองที่นี้รอได้ไม่เท่ากัน หน้าเว็บมีคนรออยู่และจอง worker ของ php-fpm ไว้ ส่วน queue job รอได้นานกว่า ระบบที่ต่อ SAP ของเราจึงให้ class นั้นเลือกเพดานเองจากที่ที่โค้ดกำลังรันอยู่

<?php

namespace App\Support\Http;

final class WebBudget
{
    public static function applies(): bool
    {
        return ! app()->runningInConsole();
    }

    public static function timeout(int $seconds): int
    {
        return self::applies() ? min($seconds, 10) : $seconds;
    }

    public static function connectTimeout(int $seconds): int
    {
        return self::applies() ? min($seconds, 3) : $seconds;
    }
}

หน้าเว็บได้เวลาต่อ connection 3 วินาที รอคำตอบ 10 วินาที และไม่มี retry ส่วน queue worker, scheduler และคำสั่ง artisan ยังได้ค่าเต็ม เพราะ runningInConsole() เป็นจริงในทั้งสามที่ job ที่ dispatch_sync จากในหน้าเว็บก็ได้เพดานของหน้าเว็บไปด้วย ซึ่งถูกต้อง เพราะยังจอง worker ของ php-fpm อยู่ ใส่ไว้ใน class ที่คุยกับ SAP ที่เดียว ทุกที่ที่เรียกก็ได้เพดานนี้ รวมถึงที่เพิ่มเข้ามาทีหลัง

deadline เดียวครอบทุก call ใน job

deadline ใน class ตัวอย่างข้างล่างครอบได้แค่ method เดียว แต่งานจริงหนึ่งงานมักเรียกปลายทางหลายครั้ง อย่างการ query SAP ครั้งหนึ่งของเรามีทั้ง login ขอ token, ลงทะเบียน query, ยิง query จริง และถ้าเจอ 401 ก็ login ใหม่แล้วยิงซ้ำ ถ้าแต่ละ call มี timeout ของตัวเอง เวลารวมจะยาวเท่าผลบวกของทุก call

timeout แยกทุก call รอรวมได้นานมาก ส่วน deadline เดียวคุมเวลาของทั้งงาน

แถวบนแต่ละ call มี timeout ของตัวเอง แถวล่างทุก call ใช้เวลาจากก้อนเดียวกัน call สุดท้ายได้แค่เวลาที่เหลือ

เราจึงเก็บ deadline ไว้ใน container แล้วให้ทุก request ที่ยิงไป SAP ถามก่อนว่าเหลือเวลาเท่าไร

<?php

namespace App\Support\Http;

use Closure;

final readonly class HttpDeadline
{
    public function __construct(private float $at) {}

    public static function within(int $seconds, Closure $call): mixed
    {
        $outer = app()->bound(self::class) ? app(self::class) : null;
        $at = microtime(true) + $seconds;

        app()->instance(self::class, new self($outer ? min($outer->at, $at) : $at));

        try {
            return $call();
        } finally {
            if ($outer) {
                app()->instance(self::class, $outer);
            } else {
                app()->forgetInstance(self::class);
            }
        }
    }

    public static function cap(int $timeout): int
    {
        if (! app()->bound(self::class)) {
            return $timeout;
        }

        $left = app(self::class)->at - microtime(true);

        return max(1, min($timeout, (int) ceil($left)));
    }
}

class ที่คุยกับ SAP แค่ส่ง timeout ผ่าน HttpDeadline::cap() ก่อนใช้ ส่วน job ครอบงานทั้งก้อนด้วย HttpDeadline::within(60, fn () => ...) ถ้า deadline ซ้อนกัน จะใช้ตัวที่หมดก่อน และคืนตัวเดิมเมื่อออกมา เราเก็บไว้ใน container แทน Context เพราะ Context ติดไปกับ job ที่ dispatch ต่อด้วย

timeout ทุกชั้นใน queue ต้องซ้อนกันพอดี

ใน queue มี timeout สามชั้น และต้องเรียงแบบนี้เสมอ

เวลารวมของ HTTP ใน job  <  $timeout ของ job  <  retry_after ของ queue connection

ถ้า HTTP ใช้เวลาเกิน $timeout worker จะ kill job กลางทาง ก่อนได้เขียนผลลงฐานข้อมูล ถ้า $timeout เกิน retry_after queue จะเข้าใจว่า job หายไปแล้ว แล้วส่งให้ worker อีกตัวทำซ้ำทั้งที่ตัวแรกยังทำอยู่

จุดที่เข้าใจผิดกันง่ายคือคิดว่า --timeout ของ worker (หรือ timeout ใน supervisor ของ Horizon) เป็นเพดานของทุก job จริง ๆ แล้ว Worker::timeoutForJob() ใช้ $timeout ของ job ก่อนเสมอ ค่าของ worker เป็นแค่ค่าเริ่มต้นให้ job ที่ไม่ได้ตั้งเอง job ที่ตั้ง $timeout = 600 จึงรันได้ครบ 600 วินาทีแม้ worker ตั้งไว้ 300 เพดานจริงมีแค่ retry_after

ตั้ง timeout จากเวลาตอบที่วัดได้จริง

timeout สั้นไม่ได้ดีกว่าเสมอ ช่วงที่ SAP ช้า เราเปิด log ดูแล้วพบว่า 94% ของ call ยังตอบภายใน 12 วินาที และ 97.8% ตอบภายใน 60 วินาที ตอนนั้น queue ตั้ง timeout ไว้ 12 วินาที ทั้งที่ลองยิงทีละตัวก็ได้คำตอบจริงในราว 40 วินาที พอขยายเป็น 60 วินาทีก็ได้ข้อมูลกลับมา ขยายได้เพราะมี concurrency limit และ deadline ของทั้ง job คุมอยู่แล้ว

กลับกัน อีก endpoint หนึ่งค้างพร้อมกันทั้งชุดทุกเที่ยงโดยไม่ส่งข้อมูลกลับมาสักไบต์ แบบนั้นเพิ่ม timeout เท่าไรก็ไม่ช่วย เรื่องนี้เล่าต่อในหัวข้อสุ่มเวลารอข้างล่าง

จำกัดจำนวนงานที่ยิงไปพร้อมกัน

สองคำนี้ฟังคล้ายกันแต่กันคนละเรื่อง

  • rate limit จำกัดจำนวนครั้งต่อช่วงเวลา เช่น 100 ครั้งต่อวินาที
  • concurrency limit จำกัดจำนวนงานที่ค้างอยู่พร้อมกัน เช่น ไม่เกิน 10 ตัว

ตอนปลายทางช้า rate เท่าเดิมแต่งานค้างพร้อมกันเพิ่มขึ้น 20 เท่า rate limit จึงกันไม่ได้ ต้องใช้ concurrency limit ต้นฉบับแนะนำให้เช็กก่อนยิงว่ายังมีช่องว่างไหม ถ้าไม่มีให้ตอบกลับทันทีด้วยผลที่เราเตรียมไว้ ไม่ต้องต่อคิวรอ

ใน Laravel ถ้ามี Redis ใช้ Redis::funnel() ได้เลย ตั้ง block(0) ให้ลองครั้งเดียวแล้วไปทางสำรองทันทีถ้าเต็ม

Redis::funnel('shipping-quote')
    ->limit(10)
    ->releaseAfter(5)
    ->block(0)
    ->then(
        fn () => $this->callProvider($postcode),
        fn () => throw new ShippingQuoteUnavailable('คิวเรียกขนส่งเต็ม'),
    );

ชื่อ shipping-quote แยกช่องของ API แต่ละเจ้า ต้นฉบับเรียกวิธีนี้ว่า bulkhead คือกั้นห้องไว้ ถ้า API ค่าส่งช้า ก็ไม่ไปแย่งช่องของ API ข้อมูลสินค้า และเพราะ funnel เก็บสถานะไว้ใน Redis เพดาน 10 จึงเป็นของทั้งระบบ ไม่ใช่ของ server แต่ละเครื่อง ต่างจากตัวนับในหน่วยความจำที่ถ้ามี 4 เครื่องก็จะกลายเป็น 40

queue worker ก็คูณแบบเดียวกัน ระบบที่ต่อ SAP ของเราเคยจำกัดไว้แค่ใน job ว่าแต่ละตัวยิงพร้อมกันได้ไม่เกิน 5 request แต่ queue มี worker 10 ตัว รวมแล้วจึงยิงเข้า gateway ตัวเดียวกันได้ราว 50 connection พอ SAP ช้า connection ก็ค้างเต็ม gateway ตอนนี้เราใช้ semaphore บน Redis ที่นับรวมทั้งระบบ 15 ช่อง และเพิ่มอีกสามอย่าง

  • กันช่องไว้ให้งานที่มีคนรอ งาน background ใช้ได้แค่ 12 ช่อง อีก 3 ช่องเก็บไว้ให้หน้าตะกร้าที่ลูกค้ากำลังรอผลเช็กสต็อก งาน background แค่ไม่ยอมให้ตัวนับเกิน 12 จึงไม่ต้องแยก token เป็นสองแบบ
  • เวลารอช่องว่างนับรวมใน deadline ถ้ารอจนหมดเวลาแล้วยังไม่ได้ช่อง request นั้นก็จบเป็น error ทันที ไม่ยิงออกไป
  • token มีอายุสองเท่าของ timeout ถ้า worker ตายกลางทาง ช่องก็คืนเอง เหมือน releaseAfter() ของ funnel
15 ช่องรวมทั้งระบบ งาน background ใช้ได้ 12 ช่อง อีก 3 ช่องเก็บไว้ให้ลูกค้าที่รออยู่

worker ทุกตัวขอช่องจากที่เดียวกัน งาน background ไม่มีวันใช้ 3 ช่องสุดท้าย

ส่วน job ที่ยิงหลาย request พร้อมกัน ถ้าแบ่งเป็น chunk แล้วรอให้ทั้ง chunk เสร็จก่อนค่อยยิง chunk ถัดไป request ที่ค้างตัวเดียวจะทำให้ทั้ง chunk รอ ใน Laravel 13 Http::pool() รับ concurrency ได้ และทำงานแบบ sliding window คือตัวไหนเสร็จก็ปล่อยตัวถัดไปเข้ามาทันที

$responses = Http::pool(fn (Pool $pool) => array_map(
    fn (string $sku) => $pool->as($sku)->timeout(60)->post($url, ['sku' => $sku]),
    $skus,
), concurrency: 5);

อีกเรื่องที่ต้นฉบับเตือนไว้ คือฝั่งเรา timeout แล้วไม่ได้แปลว่าปลายทางหยุดทำงาน request นั้นอาจยังวิ่งอยู่ที่ปลายทาง ตัวเลขช่องว่างของเราจึงดูดีกว่าความจริงได้ retry จึงต้องมีเพดานเสมอ

มีโควตา retry รวมของทั้งระบบ

retry ไม่เกิน 3 ครั้งต่องานฟังดูปลอดภัย แต่ตอนปลายทางล่มจริง ทุกงานจะ retry ครบ 3 ครั้ง ปลายทางก็โดน request เพิ่มเป็นสามเท่าในจังหวะที่แย่ที่สุด

ต้นฉบับจึงใช้ retry budget คือโควตา retry ที่นับรวมทุก request ทุก original request เติมโควตาให้ 0.1 ครั้ง ทุก retry ใช้โควตา 1 ครั้ง ถ้ามีงานเข้ามา 100 ครั้งต่อวินาที ก็ retry ได้ไม่เกินราว 10 ครั้งต่อวินาที

class ตัวอย่างในต้นฉบับเก็บตัวนับไว้ใน object ซึ่งต้องอยู่ข้าม request ได้ ต้นฉบับบอกไว้เองว่าเป็นแค่ตัวอย่างนโยบาย ไม่ใช่ตัวนับที่แชร์ข้ามเครื่อง ส่วน PHP-FPM เริ่ม object ใหม่ทุก request เราจึงต้องเก็บตัวนับไว้ใน cache แทน แบบนี้

<?php

namespace App\Support;

use Illuminate\Support\Facades\Cache;

final class RetryBudget
{
    public function __construct(
        private string $name,
        private float $ratio = 0.1,
        private int $floor = 5,
    ) {}

    public function recordOriginal(): void
    {
        Cache::add($this->key('original'), 0, 120);
        Cache::increment($this->key('original'));
    }

    public function tryConsume(): bool
    {
        Cache::add($this->key('retry'), 0, 120);
        $retries = Cache::increment($this->key('retry'));
        $originals = (int) Cache::get($this->key('original'), 0);

        return $retries <= $this->floor + $originals * $this->ratio;
    }

    private function key(string $type): string
    {
        return "retry-budget:{$this->name}:{$type}:".now()->format('YmdHi');
    }
}

ตัวนับนี้เริ่มใหม่ทุกนาที และให้ retry ได้ 5 ครั้งเสมอ บวกอีก 10% ของงานในนาทีนั้น ถ้ามีงาน 100 ชิ้นในนาทีเดียว จะ retry ได้ไม่เกิน 15 ครั้ง ที่เหลือจบที่ผลสำรองเลย ค่า floor ช่วยให้ช่วงที่คนใช้น้อย ๆ ยัง retry ได้บ้าง

ถ้างานเป็น batch ใน queue job อย่างการยิงเช็กสต็อกทีละหลายสิบ SKU ใช้นโยบายเดียวกันแบบง่ายกว่านี้ได้โดยไม่ต้องมีตัวนับใน cache เลย ระบบที่ต่อ SAP ของเราเคย retry ทุกตัวที่ล้มแบบชั่วคราวอีกหนึ่งรอบ ช่วงที่ SAP ช้าและล้มเกือบหมด gateway จึงโดนยิงเพิ่มอีกเกือบเท่าตัว ทางแก้คือดูสัดส่วนก่อน ถ้าล้มไม่กี่ตัวก็ retry ตัวที่ล้ม แต่ถ้าล้มเกินครึ่ง แปลว่าปลายทางมีปัญหาจริง ไม่ใช่สะดุดชั่วคราว ก็ข้ามรอบ retry ไปเลย

สุ่มเวลารอก่อน retry และคืนช่องก่อนพัก

backoff คือรอก่อนลองใหม่ ส่วน jitter คือสุ่มเวลารอนั้น ไม่ให้ทุกงานที่พังพร้อมกันกลับมายิงพร้อมกันอีกรอบ ต้นฉบับใช้แบบ full jitter คือสุ่มระหว่าง 0 ถึงเพดานที่โตขึ้นเรื่อย ๆ เริ่มที่ 25 ms คูณสองทุกรอบ แต่ไม่เกิน 200 ms

$delayMs = random_int(0, min(200, 25 * 2 ** ($attempt - 1)));

Http::retry() ของ Laravel รับ closure เป็นเวลารอได้ closure ได้เลขรอบ ($attempt) กับ exception มา และรับ closure อีกตัวที่บอกว่าเคสไหนควร retry

Http::connectTimeout(0.3)
    ->timeout(0.3)
    ->retry(
        2,
        fn (int $attempt) => random_int(0, min(200, 25 * 2 ** ($attempt - 1))),
        fn (Throwable $e) => $e instanceof ConnectionException
            || ($e instanceof RequestException && in_array($e->response->status(), [429, 503])),
    )
    ->get('https://api.shipping.example/quote', ['postcode' => $postcode]);

ส่วน queue job ที่ใช้ #[Backoff(60, 300)] แบบเลขตายตัว ถ้ามี job พังพร้อมกันเป็นร้อยตัว ทุกตัวจะกลับมาพร้อมกันอีกในอีก 60 วินาที เราเปลี่ยนเป็น method backoff() ที่สุ่มค่าได้ Laravel อ่าน method นี้ตอน dispatch แต่ละ job จึงได้ชุดเวลาของตัวเอง

public function backoff(): array
{
    return [random_int(30, 90), random_int(240, 360)];
}

backoff() ใช้กับ job ที่ throw แล้วปล่อยให้ queue retry เอง ถ้า job เรียก release() เอง ต้องสุ่มเวลาที่ส่งให้ release() ด้วย ระบบที่ต่อ SAP ของเรามี endpoint ดึงสต็อกสำหรับจัดสินค้าที่ค้างพร้อมกันราว 10 call ในรอบดึงข้อมูลตอนเที่ยงแทบทุกวัน ขณะที่ endpoint อื่นบน host เดียวกันยังตอบปกติ และรอบถัดไปก็ตอบได้ตามเดิม timeout ที่ยาวขึ้นไม่ช่วยเพราะ call ที่ค้างไม่ตอบเลย และ retry ทันทีใน process ก็จะเจอช่วงที่ค้างอยู่เหมือนเดิม เราจึงให้ job release() กลับเข้าคิวไปรอราว 5 นาที แล้วราว 10 นาที ถ้าใช้เลขตายตัว job ทั้งชุดที่ค้างพร้อมกันก็จะกลับมายิงพร้อมกันอีก จึงสุ่มเป็นช่วงไว้

job ที่ค้างพร้อมกัน ถ้ารอเท่ากันก็กลับมาพร้อมกันอีก ถ้าสุ่มเวลาก็ทยอยกลับมา

job ที่ค้างพร้อมกัน ถ้ารอเท่ากันก็กลับมาเป็นชุดเดิม ถ้าสุ่มช่วงก็ทยอยกลับมา

private const array RETRY_DELAYS = [[240, 360], [540, 660]];

// ใน handle()
} catch (ConnectionException $e) {
    $range = self::RETRY_DELAYS[$this->attempts() - 1] ?? throw $e;

    $this->release(random_int(...$range));
}

ถ้าปลายทางส่ง header Retry-After มาให้ ให้รอตามนั้น และถ้าเวลาใน deadline เหลือไม่พอให้รอ ก็หยุดเลย อย่าแอบลดเวลารอให้สั้นกว่าที่ปลายทางขอ

อีกจุดที่ต้นฉบับย้ำ คือคืนช่องใน concurrency limit ก่อนพัก ถ้าถือช่องไว้ระหว่างรอ งานอื่นก็ใช้ช่องนั้นไม่ได้ ข้อนี้ทำให้ Http::retry() ที่อยู่ใน Redis::funnel() ยังไม่พอ เพราะ funnel ถือช่องไว้ตลอดการ retry ทุกรอบ ถ้าอยากได้ครบทุกข้อต้องเขียน loop เอง ซึ่งเป็นหัวข้อถัดไป

สุดท้าย retry ในหน้าเว็บควรสั้นและมีเพดาน งานที่ต้องรอนานกว่านั้นเพื่อให้ปลายทางฟื้น ย้ายไปเป็น queue job ดีกว่า

รวมทุกข้อไว้ใน class เดียวที่คุยกับขนส่ง

ต้นฉบับรวมทุกอย่างไว้ในฟังก์ชันเดียว ทีนี้ลองแปลงเป็น class ใน Laravel ที่เป็นเจ้าของ retry คนเดียว

<?php

namespace App\Services;

use App\Exceptions\ShippingQuoteBusy;
use App\Exceptions\ShippingQuoteUnavailable;
use App\Support\RetryBudget;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Sleep;
use LogicException;

final class ShippingQuoteClient
{
    private const MAX_ATTEMPTS = 3;

    public function __construct(private RetryBudget $budget) {}

    public function quote(string $postcode): array
    {
        $deadline = hrtime(true) + 850 * 1_000_000;
        $this->budget->recordOriginal();

        for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
            if ($this->remainingMs($deadline) < 50) {
                throw new ShippingQuoteUnavailable('หมดเวลาของงานนี้แล้ว');
            }

            if ($attempt > 1 && ! $this->budget->tryConsume()) {
                throw new ShippingQuoteUnavailable('ใช้โควตา retry หมดแล้ว');
            }

            try {
                return $this->attempt($postcode, min(300, $this->remainingMs($deadline)));
            } catch (ConnectionException|ShippingQuoteBusy $e) {
                $delayMs = random_int(0, min(200, 25 * 2 ** ($attempt - 1)));

                if ($attempt === self::MAX_ATTEMPTS || $this->remainingMs($deadline) < $delayMs + 50) {
                    throw new ShippingQuoteUnavailable('ขนส่งยังไม่ตอบ', previous: $e);
                }

                Sleep::usleep($delayMs * 1000);
            }
        }

        throw new LogicException('loop ต้อง return หรือ throw ก่อนถึงตรงนี้');
    }

    private function attempt(string $postcode, int $timeoutMs): array
    {
        return Redis::funnel('shipping-quote')
            ->limit(10)
            ->releaseAfter(5)
            ->block(0)
            ->then(
                function () use ($postcode, $timeoutMs) {
                    $response = Http::connectTimeout($timeoutMs / 1000)
                        ->timeout($timeoutMs / 1000)
                        ->get('https://api.shipping.example/quote', ['postcode' => $postcode]);

                    if (in_array($response->status(), [429, 503])) {
                        throw new ShippingQuoteBusy;
                    }

                    return $response->throw()->json();
                },
                fn () => throw new ShippingQuoteUnavailable('คิวเรียกขนส่งเต็ม'),
            );
    }

    private function remainingMs(int $deadline): int
    {
        return (int) floor(($deadline - hrtime(true)) / 1_000_000);
    }
}

ไล่ดูทีละจุด

  • deadline ตั้งครั้งเดียวตอนเริ่ม แล้วทุก attempt ใช้เวลาที่เหลือจากก้อนเดิม
  • แต่ละ attempt ได้ไม่เกิน 300 ms หรือเท่าที่เหลือ แล้วแต่อย่างไหนน้อยกว่า
  • Http ตัวในไม่มี retry() เพราะ class นี้เป็นคนตัดสินใจ retry คนเดียว
  • funnel คืนช่องทันทีที่ attempt จบ ไม่ว่าสำเร็จหรือ throw การพักจึงเกิดนอก funnel
  • retry เฉพาะ error ที่รู้ว่าเป็นแค่ชั่วคราว (ต่อไม่ติด, 429, 503) error อื่นโยนออกไปเลย
  • ถ้าเวลาที่เหลือไม่พอสำหรับพักแล้วลองอีกรอบ ก็หยุดตรงนั้น

ตัดสินใจไว้ก่อนว่าลูกค้าจะเห็นอะไรเมื่อระบบไม่รับงาน

ทุกข้อข้างบนทำให้บางงานจบด้วย "ไม่ทำ" ต้นฉบับเรียกการปฏิเสธงานส่วนเกินแบบนี้ว่า load shedding และบอกว่าต้องคุยกับฝ่ายธุรกิจไว้ก่อนว่าจะตอบลูกค้ายังไง อย่าไปคิดเอาตอนระบบล่ม ตัวอย่างทางเลือกของหน้า checkout

  • แสดงค่าส่งโดยประมาณ และเขียนไว้ชัดว่าเป็นราคาประมาณ
  • ใช้ค่าส่งที่เพิ่งถามได้ของพัสดุแบบเดียวกัน
  • บอกให้ลองใหม่อีกครั้งในอีกสักครู่
  • ห้ามแสดงค่าส่ง 0 บาท อันนี้คือบั๊ก

ระบบที่ต่อ SAP ของเราตัดสินใจไว้ทีละหน้า เมื่อหน้าเว็บรอ SAP ไม่ทันภายใน 10 วินาที

  • เช็กสต็อก ตอบด้วยยอดล่าสุดที่เก็บไว้ และส่ง is_live: false ไปด้วย หน้าจอจะได้บอกลูกค้าว่าเป็นยอดล่าสุดที่มี ไม่ใช่ยอดตอนนี้ และไม่แสดงสต็อกเป็น 0
  • วงเงินเครดิต ตอบด้วยค่าที่เก็บไว้ พร้อม refresh_failed: true
  • ยืนยันตะกร้า ข้ามการดึงสัญญาใหม่ แล้วตรวจกับข้อมูลที่เก็บไว้ พร้อมขึ้นคำเตือน
  • สร้างใบเสนอราคา ไม่ retry เลย และย้ายไปทำใน queue เพราะการสร้างซ้ำจะได้ใบเสนอราคาสองใบ

ถ้าเราเป็นฝั่ง API ให้คนอื่นเรียก 503 หมายถึงตอนนี้ระบบรับไม่ไหว ส่วน 429 หมายถึงผู้เรียกรายนี้ยิงเกินโควตา ทั้งสองแบบไม่ได้ชวนให้ยิงซ้ำทันที ควรส่ง Retry-After ไปด้วย ฝั่งคนเรียกที่ดีจะลดจังหวะการยิงตามสัญญาณนี้ ต้นฉบับเรียกพฤติกรรมนี้ว่า backpressure ถ้าคนเรียกได้ 503 แล้วยิงกลับมาทันที การปฏิเสธของเราก็ไม่ได้ช่วยอะไร

ส่วน circuit breaker ที่หยุดเรียกปลายทางไปพักหนึ่งหลังพังติดกันหลายครั้ง ต้นฉบับมองว่ามีประโยชน์ แต่เป็นสถานะอีกชุดที่ต้องดูแล และจังหวะที่ปล่อยให้ลองเรียกใหม่ก็จุดไฟให้ปลายทางล่มซ้ำได้ ควรทำ deadline, concurrency limit และ retry budget ให้ได้ก่อน ใน queue job ของ Laravel มี middleware ThrottlesExceptions ที่ทำงานคล้าย circuit breaker อยู่แล้ว ถ้า job พังเกินจำนวนที่ตั้งไว้ จะ release job กลับเข้าคิวไปรอโดยไม่เรียกปลายทาง

ลองทำให้ปลายทางช้า แล้วดูว่าระบบฟื้นไหม

test ที่ให้ปลายทางพังหนึ่งครั้งแล้วรอบสองผ่าน บอกได้แค่ว่า retry ทำงาน ไม่ได้บอกว่าระบบรอดตอนโหลดเกิน ต้นฉบับออกแบบการทดลองไว้แบบนี้

  • ทำ API ปลอมที่รับงานพร้อมกันได้ 4 ตัว ปกติตอบใน 20 ms ตอนช้าตอบใน 120 ms
  • ยิงงาน 50 ครั้งต่อวินาที ตอนช้า 4 ช่องทำได้แค่ราว 33 ครั้งต่อวินาที จึงเกินความสามารถตั้งแต่ยังไม่มี retry
  • เทียบสองแบบ แบบแรกคือ retry 3 ครั้งโดยไม่มีเพดานอื่น แบบที่สองมี concurrency limit, retry budget และ jitter ทั้งสองแบบใช้ deadline เท่ากัน
  • เริ่มจากปกติ ทำให้ช้า แล้วทำให้กลับมาปกติโดยไม่หยุดยิง ดูว่างานที่ค้างระบายออกได้ไหม

จุดที่พลาดกันง่ายคือตัวยิงโหลดต้องยิง 50 ครั้งต่อวินาทีตลอด ไม่ใช่รอคำตอบก่อนแล้วค่อยยิงตัวถัดไป ไม่อย่างนั้นพอระบบช้าลง ตัวยิงก็ยิงช้าลงตาม เราจะไม่เห็นภาพตอนโหลดเกินเลย ถ้าใช้ k6 ให้เลือก executor แบบ constant-arrival-rate ซึ่งยิงตามอัตราที่ตั้งไว้โดยไม่สนว่าคำตอบกลับมาหรือยัง

ค่าที่ควรเก็บไว้ดูตอนระบบมีปัญหา

นับ error อย่างเดียวไม่พอจะบอกว่า retry ช่วยหรือซ้ำเติม ต้นฉบับแนะนำให้เก็บแยกกันดังนี้

  • จำนวน original request กับจำนวน attempt ที่ยิงจริง เอา attempt หาร original จะเห็นว่า retry คูณงานขึ้นกี่เท่า
  • งานที่สำเร็จภายใน deadline
  • retry ที่ยิงไป retry ที่ช่วยให้งานสำเร็จ และ retry ที่โควตาไม่ให้ยิง
  • งานที่ค้างอยู่ตอนนี้ และงานที่ concurrency limit ปฏิเสธ
  • เวลาตอบของปลายทาง แยกเวลาของงานที่สำเร็จออกจากงานที่เราปฏิเสธไปเร็ว ๆ เพราะถ้ารวมกัน ค่าเฉลี่ยจะดูดีทั้งที่ปฏิเสธงานไปเยอะ

จะนับได้ทุกค่า ทุก call ที่ออกไปต้องผ่านจุดบันทึกจุดเดียวก่อน ระบบที่ต่อ SAP ของเราให้ทุก class ที่คุยกับปลายทางเรียกตัวบันทึกตัวเดียวกัน แล้วเก็บทุก attempt เป็นหนึ่งแถว พร้อม status จริงและเวลาของ call นั้น ไม่ว่าจะสำเร็จ ได้ 4xx หรือ timeout

แยกการปฏิเสธของเราออกจาก error ของปลายทางด้วย คนที่ดูกราฟจะได้ไม่เข้าใจผิดว่าการป้องกันคือ error อีกตัว ส่วน request ID ให้เก็บใน log ไม่ใช่ใส่เป็น label ของ metric เพราะทุกค่าใหม่จะกลายเป็นเส้นข้อมูลใหม่ ถ้าอยากให้ทุกบรรทัดของ log มี request ID ลองดูวิธีใช้ Laravel Context ได้

ต้นฉบับปิดท้ายด้วยการชวนให้เลือก API ปลายทางสักตัวที่เรายังนับไม่ได้ว่ายิงไปจริงกี่ครั้ง แล้วลองทำให้มันช้าดู ใครอยากอ่านตัวอย่าง PHP ฉบับเต็มและรายละเอียดการทดลอง ตามไปอ่านที่ บทความของ Wendell Adriel ได้ครับ

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →