Laravel 13.38 มีอะไรใหม่ กันผู้ใช้เห็นข้อมูลเก่าจาก read replica และ DB::usingWritePdo()

Laravel 13.38 เพิ่ม middleware PreventStaleDatabaseReads ให้ผู้ใช้ที่เพิ่งบันทึกข้อมูลอ่านจากฐานข้อมูลหลักต่ออีกสองวินาที ข้าม request ได้ผ่าน session มี DB::usingWritePdo() ที่ครอบ relationship ด้วย และแก้เรื่อง queue กับ lazy() อีกหลายจุด

เผยแพร่
เวลาอ่าน
3 นาที
สรุปจาก
Paul Redmond · laravel-news.com

แอป Laravel ทั่วไปใช้ฐานข้อมูลเครื่องเดียว ถ้ายังเป็นแบบนั้นอยู่ ฟีเจอร์หลักของรุ่นนี้ยังไม่ต้องใช้ ข้ามไปอ่านเรื่องเล็ก ๆ ท้ายโพสต์ได้เลย แต่ถ้าเริ่มทำสำเนาฐานข้อมูลไว้อีกเครื่อง อย่างที่เล่าไว้ในโพสต์เรื่อง PostgreSQL replication แล้วคิดจะให้แอปอ่านจากเครื่องนั้นด้วย เรื่องนี้จะเจอแน่

ผู้ใช้เห็นข้อมูลเก่าเพราะ replica ตามหลังเครื่องหลักอยู่เสมอ

replica รับการเปลี่ยนแปลงจากเครื่องหลักแบบ async ปกติตามหลังไม่ถึงวินาที แต่ช่วงที่เครื่องหลักยุ่งหรือมี query หนักรันบน replica ก็อาจห่างเป็นหลายวินาที ลองนึกถึงฟอร์มแก้โปรไฟล์

  1. ผู้ใช้กดบันทึก request นี้ UPDATE ลงเครื่องหลัก แล้ว redirect กลับไปหน้าโปรไฟล์
  2. request ถัดมาอ่านโปรไฟล์จาก replica ที่ยังไม่ได้รับ UPDATE นั้น
  3. ผู้ใช้เห็นชื่อเดิม คิดว่าบันทึกไม่ติด แล้วกดบันทึกซ้ำ

ตัวเลือก sticky ใน config/database.php ที่มีมานานช่วยได้แค่ใน request เดียวกัน หลังเขียนครั้งแรก การอ่านที่เหลือใน request นั้นจะไปเครื่องหลัก แต่ข้อ 2 เป็น request ใหม่ sticky จึงไม่ช่วย (ส่วนเรื่อง sticky ใน queue worker เขียนไว้ในLaravel 13.36)

'mysql' => [
    'driver' => 'mysql',
    'read' => [
        'host' => [env('DB_READ_HOST', '10.0.0.3')],
    ],
    'write' => [
        'host' => [env('DB_HOST', '10.0.0.2')],
    ],
    'sticky' => true,
    // ...
],

ที่ผ่านมาเราก็มักแก้ด้วยการส่งข้อมูลที่เพิ่งบันทึกไปกับ flash session หรือสั่งให้หน้านั้นอ่านจากเครื่องหลักเองทีละ query ก็ใช้ได้นะ แต่ต้องตามใส่ทุกหน้าที่มีฟอร์ม

PreventStaleDatabaseReads จำไว้ใน session ว่าผู้ใช้เพิ่งเขียนข้อมูล

Jack Bayliss เพิ่ม middleware ที่ทำงานแบบนี้

  • จบ request ไหนที่มีการเขียนลง connection หลักของแอป middleware จะเก็บเวลาไว้ใน session
  • request ถัดไปของ session เดียวกัน ถ้ายังอยู่ในสองวินาทีนับจากเวลานั้น การอ่านทั้งหมดจะไปเครื่องหลัก
  • พ้นสองวินาทีแล้ว ก็กลับไปอ่านจาก replica ตามปกติ

เปิดใช้ด้วยการต่อท้ายกลุ่ม web ใน bootstrap/app.php middleware นี้ต้องรันหลัง StartSession เพราะอ่านเขียน session ซึ่งต่อท้ายกลุ่ม web ก็อยู่หลังอยู่แล้ว

use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        PreventStaleDatabaseReads::class,
    ]);
})

ถ้า replica ของเราตามหลังนานกว่าสองวินาทีบ่อย ๆ ขยายเวลาได้ด้วย for() จะใส่ทั้งแอปหรือเฉพาะกลุ่ม route ก็ได้

Route::middleware(PreventStaleDatabaseReads::for(10))->group(function () {
    Route::get('/posts/{post}', [PostController::class, 'show']);
    Route::put('/posts/{post}', [PostController::class, 'update']);
});

ตัวเลขนี้ควรมาจากที่ replica ตามหลังจริง ไม่ใช่เดา บน PostgreSQL ดูได้จาก replay_lag ใน pg_stat_replication ตามที่จดไว้ในคำสั่ง PostgreSQL replication ที่ใช้บ่อย ส่วน MySQL ดูจาก Seconds_Behind_Source ใน SHOW REPLICA STATUS

middleware นี้กันได้เฉพาะคนที่เขียนเอง

docs ของ Laravel กับ PR บอกขอบเขตไว้ชัด

  • กันให้เฉพาะ session ที่เขียน แอดมินกดอนุมัติคำสั่งซื้อ ตัวแอดมินเห็นสถานะใหม่ทันที แต่ลูกค้าที่เปิดหน้าคำสั่งซื้ออยู่ในวินาทีเดียวกันยังอาจเห็นสถานะเดิมจาก replica
  • ต้องมี session API ที่ใช้ token ล้วนและไม่มี session จึงใช้ไม่ได้
  • ดูแค่ connection หลัก ถ้าแอปมีหลาย connection ตัวอื่นไม่ได้รับผล
  • request ที่ยิงพร้อมกันอาจเขียนเวลาทับกันใน session PR ยอมรับไว้ว่าเกิดได้แต่ไม่บ่อย

ข้อแรกสำคัญที่สุด หน้าที่ต้องถูกต้องสำหรับทุกคน อย่างสต็อกสินค้าตอนจะตัดยอด หรือยอดเงินคงเหลือ ไม่ควรอ่านจาก replica ตั้งแต่ต้น ใช้ DB::usingWritePdo() ในหัวข้อถัดไปกับโค้ดส่วนนั้นดีกว่า

DB::usingWritePdo() บังคับให้ทุก query ใน callback อ่านจากเครื่องหลัก

เดิมเรามี onWriteConnection() สั่งให้ query ตัวเดียวอ่านจากเครื่องหลัก แต่ relationship ที่โหลดต่อจาก model นั้นกลับไปอ่านจาก replica ตามเดิม usingWritePdo() จาก PR #62099 ครอบทุก query ใน callback ทั้ง query builder, Eloquent และ relationship

// เดิม ตัว user อ่านจากเครื่องหลัก แต่ posts ยังอ่านจาก replica
$user = User::onWriteConnection()->where('email', $email)->first();
$drafts = $user?->posts()->whereNull('published_at')->get();

// 13.38 ทุก query ใน callback อ่านจากเครื่องหลัก
$drafts = DB::usingWritePdo(fn () => User::query()
    ->where('email', $email)
    ->first()
    ?->posts()
    ->whereNull('published_at')
    ->get());

callback คืนค่าที่ return ออกมาให้เลย และพอ callback จบ connection ก็กลับไปอ่านจาก replica เหมือนเดิม แม้ข้างในจะโยน exception ก็ตาม

ของที่แก้เพิ่มใน 13.38

  • #[CountCrashesAsExceptions] ใช้กับ listener, mailable และ notification ที่เข้า queue ได้แล้ว เดิมใช้ได้แค่กับ job ถ้า worker ตายกลางทาง (หน่วยความจำหมดหรือโดน kill) Laravel จะนับครั้งนั้นรวมกับ maxExceptions ด้วย (PR #62048)
  • job จาก Schedule::job() ที่ใช้ unique lock ไม่ค้างอีก เดิมถ้า push เข้า queue ไม่สำเร็จ lock ยังค้างอยู่ รอบถัดไป scheduler จึงข้าม job นั้นไปเรื่อย ๆ จนกว่า lock จะหมดอายุ (PR #62083)
  • lazy() กับ lazyById() คืนหน่วยความจำของก้อนก่อนหน้าก่อนดึงก้อนใหม่ คำสั่งที่วนข้อมูลเป็นแสนแถวจึงใช้หน่วยความจำสูงสุดน้อยลง (PR #62086)
  • child model ที่ใส่ #[Connection] ใช้ connection ของตัวเอง ไม่โดน $connection ของ parent class ทับอีก (PR #62065)
  • unique broadcast event ที่โดนข้ามจะยิง event UniqueJobSkipped เหมือน unique job ทั่วไป (PR #62077)

รายการ PR ทั้งหมดกว่า 30 ตัวอยู่ในโพสต์ต้นฉบับของ Laravel News ครับ

อ่านต่อ

ล่าสุด

ดูทั้งหมด →