commit ID ใน Laravel คืออะไร และวิธีเช็คว่า read replica ตามทันแล้วหรือยัง

Laravel 13.40 ให้ขอตำแหน่งล่าสุดของ replication จากฐานข้อมูลหลักหลังเขียน แล้วถาม replica ว่ามีข้อมูลถึงตำแหน่งนั้นหรือยัง จึงส่งผู้ใช้กลับไปอ่านจาก replica ได้ทันทีที่ตามทัน แทนการเดาเวลาเป็นวินาที ใช้ได้กับ MySQL, MariaDB และ PostgreSQL

เผยแพร่
เวลาอ่าน
4 นาที
สรุปจาก
Paul Redmond · laravel-news.com
ในโพสต์นี้ 7 หัวข้อ
  1. read replica ทำให้ผู้ใช้เห็นข้อมูลเก่าได้
  2. commit ID คือตำแหน่งใน replication log
  3. สามเมธอดที่เพิ่มใน 13.40
  4. ฐานข้อมูลต้องตั้งค่าเพิ่ม
  5. จำ commit ID ไว้ใน session ระหว่าง request
  6. ส่ง commit ID ไปกับ queue job
  7. hasCommitId() อาจไปถามฐานข้อมูลหลัก และกรณีอื่นที่เจอได้

read replica ทำให้ผู้ใช้เห็นข้อมูลเก่าได้

แอปที่คนใช้เยอะมักแยกฐานข้อมูลเป็นตัวหลักที่รับการเขียน กับ replica อีกหนึ่งหรือหลายตัวที่ไว้อ่าน replica ได้ข้อมูลจากตัวหลักผ่าน replication ซึ่งช้ากว่าเสมอ อาจแค่เสี้ยววินาที หรือหลายวินาทีตอนเครื่องหนัก ถ้าผู้ใช้กดบันทึกออร์เดอร์แล้วหน้าถัดไปอ่านจาก replica ที่ยังไม่ได้แถวนั้น ก็จะเห็นว่าออร์เดอร์หายไป

Laravel 13.38 เพิ่ม PreventStaleDatabaseReads ที่ให้ผู้ใช้ที่เพิ่งเขียนอ่านจากฐานข้อมูลหลักต่ออีกสองวินาที เป็นการเดาว่า replica จะตามทันในเวลานั้น ถ้าสั้นไป ผู้ใช้ก็ยังเห็นข้อมูลเก่า ถ้ายาวไป ก็ไปอ่านจากตัวหลักทั้งที่ replica มีข้อมูลแล้ว commit ID ใน 13.40 คือทางที่ไม่ต้องเดา

commit ID คือตำแหน่งใน replication log

ฐานข้อมูลที่ทำ replication มีเลขบอกตำแหน่งว่าเขียนไปถึงไหนแล้ว และ replica ก็รู้ว่าตัวเองรับมาถึงตำแหน่งไหน commit ID ของ Laravel คือเลขนี้ในรูป string

ฐานข้อมูล Laravel อ่านจาก หน้าตา
MySQL GTID set จาก @@global.gtid_executed 3e11fa47-71ca-11e1-9e33-c80aa9429562:1-77
MariaDB GTID จาก @@gtid_binlog_pos 0-1-1052
PostgreSQL ตำแหน่ง WAL จาก pg_current_wal_lsn() 0/16B3748

SQLite กับ SQL Server ไม่รองรับ เรียกเมื่อไหร่ก็โยน exception ใครใช้ PostgreSQL replication อยู่แล้ว ตำแหน่ง WAL ตัวนี้คือเลขเดียวกับที่ใช้เช็คว่า standby ตามทันไหม

สามเมธอดที่เพิ่มใน 13.40

Tobias Petry เพิ่มเมธอดเหล่านี้ใน PR #62198 เรียกผ่าน facade DB

// หลังเขียน ขอตำแหน่งล่าสุดจากฐานข้อมูลหลัก
$commitId = DB::getLastCommitId();

// ถาม replica ว่ามีข้อมูลถึงตำแหน่งนั้นหรือยัง
DB::hasCommitId($commitId);          // เช็คครั้งเดียว ตอบทันที
DB::hasCommitId($commitId, wait: 2); // รอได้ไม่เกินสองวินาที

// เขียนหลายครั้ง รวมเป็นตำแหน่งเดียว
$combined = DB::mergeCommitIds([$firstId, $secondId]);
  • getLastCommitId() ถามฐานข้อมูลหลักเสมอ
  • hasCommitId() ตอบ true เมื่อเครื่องที่ถามมีข้อมูลถึงตำแหน่งนั้นแล้ว ถ้าใส่ wait ฐานข้อมูลจะรอจน replica ตามทันหรือครบเวลา ถ้าครบเวลาก่อนก็ตอบ false
  • mergeCommitIds() บน PostgreSQL เลือกตำแหน่งที่ไกลที่สุด บน MySQL และ MariaDB รวม GTID set เข้าด้วยกัน ส่ง array ว่างจะโยน RuntimeException

ฐานข้อมูลต้องตั้งค่าเพิ่ม

ฐานข้อมูล ต้องเปิด รอด้วย wait
MySQL log_bin, gtid_mode = ON, enforce_gtid_consistency = ON ได้
MariaDB log_bin ได้
PostgreSQL ไม่ต้องตั้งอะไร เฉพาะ PostgreSQL 19 ขึ้นไป

MySQL 8 เปิด binary log ไว้แล้ว แต่ GTID ยังปิดอยู่ ส่วน MariaDB ปิด binary log ไว้ตั้งแต่ต้น ถ้าค่าไหนขาด getLastCommitId() จะโยน RuntimeException ที่บอกชื่อค่าที่ต้องเปิด บน PostgreSQL 18 หรือเก่ากว่า เรียก hasCommitId() พร้อม wait จะโยน exception แม้จะถามตัวหลักก็ตาม และถ้าใช้ฐานข้อมูลแบบ managed ผู้ให้บริการบางเจ้าอาจไม่ให้เปลี่ยนค่าพวกนี้

จำ commit ID ไว้ใน session ระหว่าง request

ตัวอย่างในบทความเป็น middleware ที่ทำสองอย่าง ก่อนเข้า controller ถ้า session มี commit ID ค้างอยู่ ให้ถาม replica ถ้าตามทันแล้วก็ลบออก ถ้ายังไม่ทันก็ให้ request นี้อ่านจากตัวหลัก หลังจาก controller ทำงานเสร็จ ถ้า request นี้เขียนอะไรลงฐานข้อมูล ก็เก็บ commit ID ใหม่ไว้ใน session

public function handle(Request $request, Closure $next): Response
{
    $pending = $request->session()->get('db_commit_id');

    if ($pending && DB::hasCommitId($pending)) {
        $request->session()->forget('db_commit_id');
        $pending = null;
    }

    if ($pending) {
        DB::useWriteConnectionWhenReading();
    }

    $response = $next($request);

    if (DB::hasModifiedRecords()) {
        $latest = DB::getLastCommitId();
        $request->session()->put('db_commit_id', $pending ? DB::mergeCommitIds([$pending, $latest]) : $latest);
    }

    return $response;
}

ลงทะเบียนด้วย $middleware->web(append: [ReadYourOwnWrites::class]) ใน bootstrap/app.php ให้ทำงานหลัง middleware ของ session ใน request ปกติให้เช็คแบบตอบทันที อย่าใส่ wait เพราะเวลาที่รอ replica ตัวหลักก็ตอบผู้ใช้ไปได้แล้ว

ส่ง commit ID ไปกับ queue job

job ที่ส่งเข้า queue ทันทีหลังบันทึก เช่นส่งอีเมลยืนยันออร์เดอร์ อาจเริ่มทำงานก่อน replica จะได้แถวนั้น ทางที่บทความแนะนำคือส่ง commit ID ไปพร้อม id ของ model แล้วให้ job รอ replica ได้สักพัก ถ้ายังไม่ทันก็อ่านจากตัวหลักแทน

// หลัง transaction commit แล้ว
SendOrderConfirmation::dispatch($order->id, DB::getLastCommitId());

// ใน job
public function handle(): void
{
    if (DB::hasCommitId($this->commitId, wait: 2)) {
        $this->send();

        return;
    }

    DB::usingWritePdo(fn () => $this->send());
}
  • ส่ง id ไม่ใช่ model เพราะ SerializesModels โหลด model จาก replica ก่อนเข้า handle() ถ้า replica ยังไม่มีแถวนั้นก็จะได้ ModelNotFoundException ก่อนจะถึงโค้ดที่เช็ค
  • ใช้ usingWritePdo() ใน worker ไม่ใช่ useWriteConnectionWhenReading() เพราะ worker ใช้ connection เดิมต่อหลาย job และ usingWritePdo() คืนค่าเดิมให้เมื่อ callback จบ job ถัดไปจึงไม่ติดไปอ่านจากตัวหลักด้วย
  • ใน worker การ wait ไม่กระทบผู้ใช้ จึงใช้ได้

hasCommitId() อาจไปถามฐานข้อมูลหลัก และกรณีอื่นที่เจอได้

  • hasCommitId() ถามเครื่องเดียวกับที่ query อื่นใน request ไปอ่าน ถ้าเรียกใน transaction ถ้าเปิด sticky แล้ว request นี้เขียนไปแล้ว หรือถ้าบังคับอ่านจากตัวหลักไว้ คำตอบจะเป็น true เสมอเพราะถามตัวหลัก บทความบอกว่าไม่ผิด เพราะ query ที่ตามมาก็อ่านจากตัวหลักเหมือนกัน
  • เรียก getLastCommitId() หลัง transaction commit ถ้าเรียกข้างใน การเขียนยังไม่ commit ตำแหน่งที่ได้จึงยังไม่รวมข้อมูลนั้น
  • request หนึ่งใช้ replica ตัวเดียว ถ้าตั้ง read ไว้หลาย host Laravel เลือกหนึ่งตัวตอนเปิด connection request ถัดไปอาจได้ตัวอื่นที่ช้ากว่า จึงต้องเช็คใหม่ทุก request อย่า cache ผลไว้
  • ค่าใช้จ่ายมีแค่ query เพิ่มหนึ่งตัว request ที่มี commit ID ค้างถาม replica เพิ่มหนึ่งครั้ง request ที่เขียนถามตัวหลักเพิ่มหนึ่งครั้ง และถ้า replication หยุด ผู้ใช้คนนั้นก็แค่อ่านจากตัวหลักต่อไปเรื่อย ๆ

สำหรับแอปส่วนใหญ่ที่ยังมีฐานข้อมูลตัวเดียว เรื่องนี้ยังไม่ต้องสนใจ แต่ถ้าวันหนึ่งต้องแยก replica เพราะตัวหลักรับไม่ไหว commit ID ช่วยให้ไม่ต้องเลือกระหว่างข้อมูลที่ถูกต้องกับการกระจายโหลด

ตัวอย่างโค้ดเต็มและรายละเอียดของแต่ละฐานข้อมูล อ่านต่อได้ที่บทความของ Paul Redmond

อ่านต่อ

ล่าสุด

ดูทั้งหมด →