สั่ง Job ให้รอ Database Transaction commit ก่อนใน Laravel

job, อีเมล หรือ event ที่สั่งใน DB::transaction() อาจออกไปก่อน commit หรือออกไปทั้งที่ rollback แล้ว Laravel ให้เราสั่งรอ commit ได้ตั้งแต่ระดับ queue connection ตัว job ไปจนถึง listener, Observer, mail และ notification

เผยแพร่
อัปเดต
— เพิ่มตารางเลือกวิธีให้รอ commit
เวลาอ่าน
5 นาที
แท็ก
#Laravel#Queue
ในโพสต์นี้ 4 หัวข้อ
  1. Transaction คุมได้แค่ query ใน database
  2. Worker อาจหยิบ job ไปทำก่อน transaction commit
  3. เลือกวิธีให้รอ commit ตามสิ่งที่ต้องรอ
  4. อ่านเพิ่ม

Database transaction ช่วยให้ข้อมูลไม่ค้างครึ่ง ๆ กลาง ๆ ถ้ามี error ตรงไหน query ทั้งชุดจะ rollback กลับไปหมด แต่ transaction คุมได้แค่ query ใน database ถ้าเรา dispatch job ส่งอีเมล หรือยิง event ระหว่างทาง งานพวกนั้นออกไปแล้วก็ดึงกลับไม่ได้ บทความนี้ไล่ดูว่า Laravel 13 ให้เราสั่ง "รอ commit ก่อน" ได้ที่ไหนบ้าง

Transaction คุมได้แค่ query ใน database

ลองนึกถึงหน้าสมัครสมาชิกที่สร้าง User แล้วสร้าง Team ให้ทันที เราห่อทั้งสองคำสั่งไว้ใน DB::transaction() ถ้าสร้าง Team ไม่ผ่าน Laravel จะ rollback User ที่เพิ่งสร้างไปด้วย ทีนี้ถ้ามีบรรทัดส่งอีเมลต้อนรับอยู่ตรงกลาง

DB::transaction(function () use ($data) {
    $user = User::create($data);
    Mail::to($user)->send(new WelcomeEmail($user));
    Team::create(['owner_id' => $user->id]); // ถ้า error ตรงนี้ User หาย แต่อีเมลออกไปแล้ว
});

transaction rollback ได้แค่ข้อมูลใน database อีเมลที่ส่งไปแล้วเรียกคืนไม่ได้ ลูกค้าจะได้อีเมลต้อนรับเข้าบัญชีที่ไม่มีอยู่จริง

ถ้าโค้ดส่งอีเมลอยู่ใน closure ตรง ๆ แบบนี้ ย้ายไปไว้หลัง DB::transaction() ก็จบ เพราะถ้า transaction ล้ม Laravel จะโยน exception ออกมา บรรทัดถัดไปจึงไม่ได้ทำงาน แต่ในงานจริงโค้ดพวกนี้มักซ่อนอยู่ใน listener ของ event UserRegistered หรือใน Observer ที่ดัก created ของ User ซึ่งทำงานตอน User::create() ยังอยู่ใน transaction ย้ายออกมาแบบนั้นไม่ได้

Worker อาจหยิบ job ไปทำก่อน transaction commit

ปัญหาอีกแบบเกิดกับ job ที่ dispatch ลง queue จากใน transaction แล้วยังมีงานต่ออีกพักใหญ่กว่าจะ commit

worker เป็นอีก process ที่ต่อ database แยกกัน ระหว่างที่ transaction ยังไม่ commit worker จะมองไม่เห็นแถว User ใหม่ ถ้าหยิบ job ส่งอีเมลไปทำในช่วงนั้น ตอนโหลด User กลับขึ้นมาจะเจอ ModelNotFoundException และถ้า transaction rollback ภายหลัง job ก็อยู่ใน queue ไปแล้ว ลูกค้าได้อีเมลอยู่ดี

เลือกวิธีให้รอ commit ตามสิ่งที่ต้องรอ

ทุกวิธีข้างล่างทำงานเหมือนกัน คือเก็บงานไว้ก่อน แล้วปล่อยออกไปเมื่อ transaction ชั้นนอกสุด commit ถ้า transaction rollback Laravel ก็ทิ้งงานนั้นไป และถ้าเรียกนอก transaction งานก็ออกไปทันทีเหมือนเดิม ต่างกันแค่ว่าเราสั่งไว้ที่ไหน

สิ่งที่ต้องรอ วิธีสั่ง เหมาะกับ
job ทุกตัวใน connection หนึ่ง 'after_commit' => true ใน config/queue.php แอปที่อยากให้รอเป็นค่าเริ่มต้น
job บางครั้งที่ dispatch ->afterCommit() หรือ ->beforeCommit() ตอน dispatch เคสเฉพาะจุด
job ที่ต้องรอทุกครั้ง implement ShouldQueueAfterCommit แทน ShouldQueue job ส่งอีเมลหรือแจ้งเตือนลูกค้า
โค้ดที่ไม่ใช่ job ห่อด้วย DB::afterCommit() เรียก API ภายนอก ส่งอีเมลตรง
event ทั้งก้อน event implement ShouldDispatchAfterCommit event ที่ทุก listener ต้องรอ
listener หรือ Observer บางตัว ShouldHandleEventsAfterCommit หรือ ShouldQueueAfterCommit ถ้าเข้า queue listener ส่งอีเมลข้าง listener ที่ต้องทำทันที
mail กับ notification ใน queue ->afterCommit() ก่อนส่ง หรือ implement ShouldQueueAfterCommit ส่งจากใน service ที่อยู่ใน transaction

เปิดทั้ง queue connection หรือสั่งเป็นราย job

ใน config/queue.php ทุก connection มี 'after_commit' => false เป็นค่าเริ่มต้น เปลี่ยนเป็น true แล้ว job ทุกตัวที่ dispatch ผ่าน connection นั้นจะรอ commit ก่อน ค่านี้เปิดไว้ได้โดยไม่กระทบโค้ดส่วนอื่น เพราะ job ที่ dispatch นอก transaction ยังเข้า queue ทันที connection sync ก็รับค่านี้เหมือนกัน แค่ใน config ไม่ได้เขียนไว้ให้

'redis' => [
    'driver' => 'redis',
    // ...
    'after_commit' => true,
],

ถ้าไม่อยากเปิดทั้ง connection ก็สั่งตอน dispatch ได้ และกลับกัน job ที่ต้องเข้า queue ทันทีแม้เปิด after_commit ไว้ เช่น job เขียน log ที่ไม่สนผลของ transaction ก็สั่ง beforeCommit()

SendWelcomeEmail::dispatch($user)->afterCommit();
WriteAuditLog::dispatch($user)->beforeCommit();

job ที่ต้องรอเสมอใช้ ShouldQueueAfterCommit

job ที่ไม่ควรออกไปก่อน commit เลย เปลี่ยน implements ShouldQueue เป็น implements ShouldQueueAfterCommit แล้วไม่ต้องจำเรียก afterCommit() ทุกที่ที่ dispatch interface นี้สืบทอดมาจาก ShouldQueue job จึงยังเข้า queue ตามปกติ

use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Queue\Queueable;

class SendWelcomeEmail implements ShouldQueueAfterCommit
{
    use Queueable;

    public function __construct(public User $user) {}

    public function handle(): void
    {
        Mail::to($this->user)->send(new WelcomeEmail($this->user));
    }
}

ถ้าสั่งหลายที่พร้อมกัน Laravel ตัดสินตามลำดับนี้ beforeCommit() ชนะทุกอย่าง รองลงมาคือ ShouldQueueAfterCommit แล้วค่าที่ตั้งด้วย afterCommit() สุดท้ายค่อยดู after_commit ของ connection

โค้ดที่ไม่ใช่ job ห่อด้วย DB::afterCommit()

ถ้าสิ่งที่ต้องรอเป็นการเรียก API ภายนอก หรือส่งอีเมลตรงด้วย Mail::send() ให้ห่อด้วย DB::afterCommit() Laravel เก็บ closure ไว้ แล้วเรียกหลัง transaction ชั้นนอกสุด commit ไม่ว่าจะซ้อนกันกี่ชั้น

DB::afterCommit(fn () => Mail::to($user)->send(new WelcomeEmail($user)));

event, listener และ Observer ก็สั่งให้รอได้

event ที่ต้องรอทั้งก้อนหน้าตาแบบนี้ ต่างจาก event ปกติแค่ interface ที่ implement

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UserRegistered implements ShouldDispatchAfterCommit
{
    use Dispatchable, SerializesModels;

    public function __construct(public User $user) {}
}
  • ให้ event รอทั้งก้อน ใส่ ShouldDispatchAfterCommit ที่ตัว event แล้ว UserRegistered::dispatch($user) ที่เรียกใน transaction จะยังไม่เรียก listener ตัวไหนเลยจนกว่าจะ commit
  • ให้ listener บางตัวรอ listener ที่ทำงานทันทีใส่ ShouldHandleEventsAfterCommit หรือประกาศ public $afterCommit = true; ได้ เพราะ listener ไม่ได้ใช้ trait Queueable ส่วน listener ที่เข้า queue ใช้ ShouldQueueAfterCommit เหมือน job
  • Observer ของ model ใส่ ShouldHandleEventsAfterCommit ได้เหมือน listener created() จะรอ commit ก่อน ส่วน method ที่สั่งยกเลิกได้อย่าง creating, saving และ deleting ยังทำงานทันที เพราะต้องทำก่อนบันทึก ตัวอย่างอยู่ในหัวข้อ "ให้ทำงานหลัง transaction commit" ของ Laravel Model Event ใช้ยังไง

mail กับ notification ใน queue

mailable กับ notification ที่สร้างจาก make:mail และ make:notification ใช้ trait Queueable เหมือน job จึงเรียก afterCommit() ก่อนส่งได้ หรือ implement ShouldQueueAfterCommit ถ้าอยากให้รอทุกครั้ง

$user->notify((new InvoicePaid($invoice))->afterCommit());

ตอนรัน test ที่ใช้ RefreshDatabase หรือ DatabaseTransactions Laravel ไม่นับ transaction ที่ห่อ test ไว้ งานที่สั่งให้รอจึงออกไปตอน transaction ในโค้ดของเรา commit เหมือนตอนรันจริง

อ่านเพิ่ม

อ่านต่อ

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

ล่าสุด

ดูทั้งหมด →