Idempotency คืออะไร และวิธีทำให้ request กับ job ใน Laravel ส่งซ้ำได้โดยไม่เกิดผลซ้ำ

งานที่ idempotent ทำซ้ำกี่รอบก็ได้ผลเหมือนทำครั้งเดียว เป็นเงื่อนไขก่อน retry งานที่เปลี่ยนข้อมูล ใน Laravel ทำได้ด้วย unique index, การเช็กสถานะก่อนทำ และ Idempotency-Key สำหรับ API

เผยแพร่
เวลาอ่าน
6 นาที

idempotent คือทำซ้ำกี่ครั้งก็ได้ผลเหมือนทำครั้งเดียว

RFC 9110 ซึ่งเป็นมาตรฐานของ HTTP นิยามว่า method หนึ่ง idempotent เมื่อผลที่ตั้งใจให้เกิดบน server จาก request เหมือนกันหลายครั้ง เท่ากับผลจาก request ครั้งเดียว

ลองเทียบสองบรรทัดนี้

// idempotent รันกี่รอบ order ก็อยู่ในสถานะ paid เหมือนเดิม
$order->update(['status' => 'paid']);

// ไม่ idempotent รันสองรอบ ยอดเงินเพิ่มสองรอบ
$wallet->increment('balance', 100);

บรรทัดแรกตั้งค่าให้เป็นค่าที่ต้องการ รันซ้ำก็ได้ค่าเดิม ส่วนบรรทัดที่สองบวกเพิ่มจากค่าปัจจุบัน ทุกรอบที่รันซ้ำ ยอดเงินก็เพิ่มขึ้นอีก

คำตอบที่ได้กลับมาไม่ต้องเหมือนกันทุกรอบ DELETE /orders/5 รอบแรกอาจได้ 204 รอบสองได้ 404 แต่ผลบน server เหมือนกัน คือ order 5 ไม่อยู่แล้ว จึงยังนับว่า idempotent

คำสั่งเดิมมาซ้ำได้จากหลายทาง

ถ้าทุกคำสั่งมาถึงครั้งเดียวเสมอ เรื่องนี้คงไม่สำคัญ แต่ในงานจริงคำสั่งซ้ำเกิดได้แทบทุกชั้น

  • ผู้ใช้กดปุ่มยืนยันสองครั้ง เพราะหน้าเว็บตอบช้า
  • เน็ตมือถือหลุดตอนรอคำตอบ แอปจึงส่ง request เดิมอีกรอบ
  • Http::retry() ยิงซ้ำหลัง timeout
  • queue job พังกลางทางแล้ว Laravel รันใหม่ตาม tries หรือ worker ตายระหว่างทำแล้ว job กลับเข้าคิว
  • webhook จากระบบภายนอกส่ง event เดิมมาอีกรอบ อย่าง LINE ที่ส่ง event ซ้ำได้

payment gateway เป็นตัวอย่างที่ชัดมาก ระบบร้านค้าออนไลน์ที่เราดูแลรับผลการจ่ายเงินจาก 2C2P ได้สามทาง

  • backend callback ที่ 2C2P ยิงตรงมาที่ server ของเรา
  • หน้าที่ 2C2P redirect ลูกค้ากลับมา แล้วเราถามสถานะจริงอีกรอบด้วย Payment Inquiry
  • ปุ่มถามสถานะในหลังบ้าน ที่แอดมินกดเองตอน callback ไม่มา

สองทางแรกมักมาถึงเกือบพร้อมกันตอนลูกค้าจ่ายเสร็จ และทั้งสามทางเรียก method ตัวเดียวกันที่บันทึกการชำระเงิน method นั้นจึงต้องรันซ้ำได้ ไม่อย่างนั้นการจ่ายครั้งเดียวจะถูกบันทึกเป็นหลายครั้ง

อีกเรื่องคือ timeout แปลว่าเราไม่ได้รับคำตอบ ไม่ได้แปลว่างานไม่เกิด request ที่ตัดเงินอาจตัดสำเร็จไปแล้วที่ปลายทาง แค่คำตอบกลับมาไม่ถึง ถ้าเรา retry งานที่ไม่ idempotent ลูกค้าก็โดนตัดเงินสองรอบ เรื่องนี้จึงเป็นเงื่อนไขแรกก่อนจะตั้ง retry ให้งานที่เปลี่ยนข้อมูล ส่วนเรื่องว่าระบบรับ retry ไหวแค่ไหน อ่านต่อได้ที่ retry ตอนบริการปลายทางช้า

HTTP method ไหนเป็น idempotent

method safe (ไม่แก้ข้อมูล) idempotent
GET, HEAD, OPTIONS ใช่ ใช่
PUT ไม่ ใช่
DELETE ไม่ ใช่
POST ไม่ ไม่
PATCH ไม่ ไม่

GET ถึง DELETE มาจาก RFC 9110 ส่วน PATCH มาจาก RFC 5789 ที่บอกว่า PATCH ไม่ใช่ทั้ง safe และ idempotent ส่วน method ใหม่อย่าง QUERY เป็นทั้ง safe และ idempotent อ่านรายละเอียดได้ใน HTTP QUERY method คืออะไร

ตารางนี้คือสิ่งที่ method สัญญาไว้ โค้ดของเราต้องทำตามสัญญานั้นเอง ถ้าเราเขียน route PUT ที่ไปบวกยอดเงินเพิ่ม method ก็ไม่ได้ช่วยอะไร

ปัญหาจริงจึงอยู่ที่ POST ซึ่งเราใช้สร้าง order สร้างการชำระเงิน หรือส่งข้อความ ทั้งหมดนี้ส่งซ้ำแล้วเกิดของซ้ำ

ทำ POST ให้ส่งซ้ำได้ด้วย Idempotency-Key

วิธีที่ API ของ payment gateway อย่าง Stripe ใช้ คือให้ผู้เรียกสร้าง key ที่ไม่ซ้ำกันมากับ request แล้วส่งใน header Idempotency-Key ถ้า request ที่มี key เดิมมาอีก server จะไม่ทำงานซ้ำ แต่ส่งคำตอบเดิมกลับไป Stripe รับ key นี้กับทุก POST แนะนำให้ใช้ UUID v4 และอาจล้าง key ที่อายุเกิน 24 ชั่วโมงแล้ว

ทาง IETF มี draft มาตรฐานของ header นี้ ซึ่งยังไม่ออกเป็น RFC แต่กติกาในนั้นใช้เป็นแนวทางได้

  • key เดิม ข้อมูลเดิม ให้ตอบผลของครั้งแรก ไม่ว่าครั้งแรกสำเร็จหรือ error
  • key เดิมแต่ข้อมูลไม่เหมือนเดิม ตอบ 422
  • key เดิมมาตอนที่ครั้งแรกยังทำไม่เสร็จ ตอบ 409
  • endpoint ที่บังคับใช้ key แต่ไม่ได้ส่งมา ตอบ 400
  • server กำหนดได้ว่าเก็บ key ไว้นานแค่ไหน แต่ควรเขียนบอกไว้ในเอกสาร

ฝั่งเราเป็นคนเรียก gateway ก็ส่ง key ที่ผูกกับงานชิ้นนั้นไป เช่น สร้างจาก ID ของ order แล้วเก็บไว้กับ order เลย job ที่ retry จะได้ส่ง key เดิม ไม่ได้สุ่มใหม่ทุกรอบ ถ้าสุ่มใหม่ทุกรอบ gateway ก็มองว่าเป็นคำสั่งใหม่

payment gateway ในไทยก็มี key แบบเดียวกัน

2C2P ไม่ได้ใช้ header Idempotency-Key แบบ Stripe แต่มี field ที่ทำหน้าที่เดียวกันสองตัว

  • invoiceNo ทุกครั้งที่ขอ payment token ต้องส่งเลข order ที่ไม่ซ้ำมาด้วย (Payment Token API) ถ้าขอด้วยเลขที่จ่ายไปแล้ว 2C2P จะตอบรหัส 9015 Existing Invoice Number แทนการเปิดรายการใหม่ เลข order จึงเป็น key ของการจ่ายเงินไปในตัว
  • idempotencyID ใช้กับคำสั่งหลังจ่ายแล้ว อย่างคืนเงิน ยกเลิก หรือ settle เอกสาร Payment Process API บอกว่าส่ง idempotencyID เดิมซ้ำจะได้คำตอบเดิมของครั้งแรก รับยาวได้ 100 ตัวอักษร แต่ไม่ได้บอกว่า 2C2P เก็บไว้นานแค่ไหน

ระบบที่เราดูแลใช้รหัส 9015 ตอบผลเดิมให้ลูกค้า บางครั้งลูกค้าจ่ายไปแล้วแต่ callback ยังมาไม่ถึง order ในระบบเราจึงยังรอชำระ ลูกค้าเลยกดจ่ายอีกรอบ พอขอ token แล้วได้ 9015 เราจะไม่เปิดรายการใหม่ แต่ถาม Payment Inquiry แล้วอัปเดต order ตามผลจริง

$result = $this->client->requestPaymentToken($order);

if ($result->respCode === '9015') { // Existing Invoice Number
    $this->paymentInquiry($order); // ดึงผลจริงจาก 2C2P มาอัปเดต order

    throw ValidationException::withMessages([
        'payment' => 'order นี้ชำระเงินแล้ว',
    ]);
}

วิธีทำให้งานส่งซ้ำได้ใน Laravel

ให้ฐานข้อมูลกันข้อมูลซ้ำด้วย unique index

ถ้างานมี key ธรรมชาติอยู่แล้ว เช่น เลขที่ order จากระบบภายนอก หรือ ID ของ event จาก webhook ให้ตั้ง unique index ไว้ที่คอลัมน์นั้น แล้วใช้ createOrFirst()

Schema::table('payments', function (Blueprint $table) {
    $table->string('gateway_charge_id')->unique();
});

Payment::createOrFirst(
    ['gateway_charge_id' => $charge['id']],
    ['order_id' => $order->id, 'amount' => $charge['amount']],
);

createOrFirst() ลอง insert ก่อน ถ้าชน unique index ก็ไปหาแถวเดิมมาให้ ต่างจาก firstOrCreate() ที่หาก่อนแล้วค่อยสร้าง ซึ่งถ้ามีสอง request มาพร้อมกัน ทั้งคู่อาจหาไม่เจอแล้วสร้างซ้ำได้ ถ้างานเป็นการนำเข้าทีละมาก ๆ ใช้ upsert() ที่อาศัย unique index แบบเดียวกัน

เช็กสถานะก่อนทำงานใน queue job

job ที่อาจรันซ้ำควรถามก่อนว่างานนี้ทำไปแล้วหรือยัง และล็อกแถวไว้ระหว่างเช็ก ไม่ให้ worker สองตัวทำพร้อมกัน

public function handle(): void
{
    DB::transaction(function () {
        $order = Order::lockForUpdate()->find($this->orderId);

        if ($order->paid_at !== null) {
            return;
        }

        $order->update(['paid_at' => now()]);
        $order->customer->increment('points', $order->points);
    });
}

การบวกแต้มไม่ idempotent ในตัวเอง แต่พอห่อไว้หลังการเช็ก paid_at ใน transaction เดียวกัน ทั้งก้อนก็กลายเป็นงานที่รันซ้ำได้

จุดที่พลาดง่ายคือเช็กสถานะจาก model ที่โหลดไว้ก่อนเข้า transaction ตอนเขียนโพสต์นี้ เราไล่โค้ดบันทึกการชำระเงินของระบบร้านค้าที่เราดูแล ซึ่งรับผลจาก 2C2P ได้สามทางอย่างที่เล่าไว้ข้างบน แล้วเจอว่าการเช็กอยู่ใน transaction ก็จริง แต่เช็กจาก $order ที่ผู้เรียกแต่ละทางโหลดมาตั้งแต่ตอนยังรอชำระ และไม่ได้ล็อกแถว

// เดิม เช็กจากค่าใน memory ที่ผู้เรียกโหลดมา
DB::transaction(function () use ($order) {
    if ($order->status !== OrderStatus::PendingPayment) {
        return;
    }

    // บวกแต้ม เปลี่ยนสถานะ ...
});

// แก้แล้ว อ่านแถวใหม่พร้อมล็อก แล้วเช็กจากแถวนั้น
DB::transaction(function () use ($order) {
    $locked = Order::lockForUpdate()->findOrFail($order->id);

    if ($locked->status !== OrderStatus::PendingPayment) {
        return;
    }

    // บวกแต้ม เปลี่ยนสถานะ ...
});

ถ้า backend callback กับหน้าที่ redirect ลูกค้ากลับมาถึงพร้อมกัน ทั้งสองทางจะเห็นว่ายังรอชำระ แต้มของลูกค้าถูกบวกสองรอบ และอีเมลยืนยันออกไปสองฉบับ เราเขียน test ที่โหลด order เดียวกันมาสองก้อนแยกกัน แล้วส่งให้ action ทีละก้อน โค้ดเดิมได้แถวแต้ม 4 แถวแทนที่จะเป็น 2 แถว พอเปลี่ยนเป็นอ่านแถวใหม่ด้วย lockForUpdate() ตัวที่สองจะรอจนตัวแรก commit แล้วเห็นว่าจ่ายไปแล้ว จึงไม่บวกแต้มซ้ำ

test เดิมที่เรียก action ซ้ำสองครั้งจับเรื่องนี้ไม่ได้ เพราะโหลด order ใหม่ระหว่างสองครั้ง test ว่างานรันซ้ำได้ควรจำลองผู้เรียกที่ถือข้อมูลเก่าคนละก้อน ไม่ใช่แค่เรียกซ้ำด้วยข้อมูลล่าสุด

ก่อน deploy เราเช็กข้อมูลจริงย้อนหลังแล้ว ยังไม่มี order ไหนได้แต้มซ้ำ จังหวะที่สองทางมาถึงพร้อมกันพอดีอาจยังไม่เคยเกิด แต่เกิดได้ทุกวันที่มีคนจ่ายเงิน และวันที่เกิด ลูกค้าก็ได้แต้มเกินไปโดยไม่มีใครรู้ การรู้ตัวก่อนจากการอ่านโค้ดจึงถูกกว่าไปไล่แก้แต้มทีหลัง

รับ Idempotency-Key ใน API ของเรา

ถ้าเราทำ API ให้แอปมือถือหรือระบบอื่นเรียก ใส่ middleware ที่จำคำตอบตาม key ได้ ตัวอย่างนี้ทำตามกติกาของ draft ข้างบน

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

class EnsureIdempotency
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $request->header('Idempotency-Key');

        if ($key === null) {
            return $next($request);
        }

        $cacheKey = 'idempotency:'.$request->user()?->id.':'.$key;
        $fingerprint = hash('sha256', $request->method().$request->path().$request->getContent());

        $lock = Cache::lock($cacheKey.':lock', 30);

        if (! $lock->get()) {
            return response()->json(['message' => 'request เดิมยังทำงานอยู่'], 409);
        }

        try {
            $saved = Cache::get($cacheKey);

            if ($saved !== null) {
                return $saved['fingerprint'] === $fingerprint
                    ? response($saved['body'], $saved['status'], ['Content-Type' => $saved['type']])
                    : response()->json(['message' => 'ใช้ key นี้กับข้อมูลชุดอื่นไปแล้ว'], 422);
            }

            $response = $next($request);

            if ($response->getStatusCode() < 500) {
                Cache::put($cacheKey, [
                    'fingerprint' => $fingerprint,
                    'status' => $response->getStatusCode(),
                    'type' => $response->headers->get('Content-Type'),
                    'body' => $response->getContent(),
                ], now()->addDay());
            }

            return $response;
        } finally {
            $lock->release();
        }
    }
}
  • key ผูกกับ user ที่ login อยู่ คนอื่นใช้ key ซ้ำกับเราก็ไม่ได้คำตอบของเราไป
  • fingerprint คือ hash ของ method, path และ body ใช้เช็กว่า key เดิมมากับข้อมูลเดิมหรือไม่
  • Cache::lock() กันสอง request ที่ key เดียวกันมาพร้อมกัน ตัวที่สองได้ 409 ไป
  • คำตอบ 5xx ไม่เก็บ ผู้เรียกจะได้ retry แล้วทำงานใหม่ได้ ข้อนี้ต่างจาก Stripe ที่เก็บคำตอบ 500 ไว้ด้วย เลือกตามว่างานของเราพังกลางทางแล้วปลอดภัยไหมที่จะทำใหม่
  • เก็บไว้หนึ่งวัน แล้วเขียนบอกในเอกสารของ API

Cache::lock() ต้องใช้ cache driver ที่รองรับ lock อย่าง database, redis หรือ file ถ้าแอปรันหลายเครื่อง ใช้ database หรือ redis เพื่อให้ทุกเครื่องเห็น lock ตัวเดียวกัน

เลือก key ให้ไม่ซ้ำและไม่มีข้อมูลส่วนตัว

  • key ฝั่งผู้เรียกใช้ UUID ไปเลย Laravel มี Str::uuid() ให้แล้ว
  • อย่าใช้อีเมลหรือเลขบัตรประชาชนเป็น key Stripe ก็เตือนเรื่องนี้ไว้ในเอกสาร
  • ตั้ง key ครั้งเดียวต่องานหนึ่งชิ้น แล้วใช้ key เดิมทุกครั้งที่ retry งานชิ้นนั้น
  • งานที่ key ธรรมชาติมีอยู่แล้ว (เลขที่ order, ID ของ event) ใช้ unique index ง่ายกว่าเก็บ key แยก
  • key ธรรมชาติใช้กับระบบภายนอกได้ด้วย ระบบร้านค้าที่เราดูแลส่ง purchase event ให้ Meta Conversions API โดยใช้เลขที่ order เป็น event_id ถ้า event เดิมถูกส่งซ้ำ Meta จะตัดตัวซ้ำทิ้งเอง ยอดขายในรายงานโฆษณาจึงไม่เบิ้ล

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →