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 ตามทันหรือครบเวลา ถ้าครบเวลาก่อนก็ตอบfalsemergeCommitIds()บน 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






