Lập trình · 22/09/2026

Học Laravel 13 – Bài 14: Transaction, Locking và xử lý cạnh tranh trong Laravel 13

Bài 14 — Transaction, Locking và Concurrency: sau truy vấn danh sách ở bài 13, chúng ta xem chuyện gì xảy ra khi ghi dữ liệu thất bại giữa chừng hoặc hai người cùng sửa một task. Ví dụ thực hành chạy trên SQLite bộ nhớ; phần row lock là thiết kế cần kiểm chứng riêng trên engine production.

Transaction, Locking và xử lý cạnh tranh trong Laravel 13

Bài 14 — Transaction, Locking và Concurrency: sau truy vấn danh sách ở bài 13, chúng ta xem chuyện gì xảy ra khi ghi dữ liệu thất bại giữa chừng hoặc hai người cùng sửa một task. Ví dụ thực hành chạy trên SQLite bộ nhớ; phần row lock là thiết kế cần kiểm chứng riêng trên engine production.

1. Hai vấn đề khác nhau

Transaction giúp một nhóm thao tác database cùng commit hoặc cùng rollback. Nó không tự ngăn mọi lost update: hai request có thể đọc cùng trạng thái rồi ghi đè nhau nếu logic không có điều kiện bảo vệ thích hợp. Isolation level và cách đọc/ghi quyết định hành vi cụ thể.

Trong TaskFlow, tạo một task rồi gặp exception phải không để lại task nửa chừng. Còn chuyển trạng thái chỉ khi task vẫn ở trạng thái người dùng đã thấy là một yêu cầu cạnh tranh riêng. Ta viết hai test thay vì gộp chúng thành câu “dùng transaction là an toàn”.

2. Rollback khi callback ném exception

DB::transaction(function () use ($project) {
    $project->tasks()->create(['title' => 'Must roll back']);
    throw new RuntimeException('Simulated failure');
});

Exception phải thoát khỏi callback để lớp transaction xử lý rollback. Nếu catch bên trong rồi trả về bình thường, bạn có thể vô tình commit phần đã ghi. Project được tạo trước transaction trong test nên vẫn tồn tại sau rollback; ranh giới transaction là một phần của contract, không phải mọi dữ liệu liên quan đều tự quay về quá khứ.

Transaction database không hoàn tác email, HTTP call hoặc file đã gửi ra ngoài. Không đặt network call chậm trong transaction giữ lock. Khi cần đồng bộ sự kiện sau ghi, xem xét after-commit hoặc outbox; không hứa tính nguyên tử giữa database và dịch vụ ngoài chỉ vì callback có tên transaction. Xem database transactions.

3. Cập nhật có điều kiện thay vì đọc rồi ghi mù

$affected = Task::whereKey($taskId)
    ->where('project_id', $projectId)
    ->where('status', 'todo')
    ->update(['status' => 'doing']);

if ($affected !== 1) {
    // Handle missing record or stale expected state deliberately.
}

Điều kiện và cập nhật nằm trong cùng statement. Khi một thao tác khác đã đổi todo thành doing, yêu cầu cũ vẫn đòi todo sẽ cập nhật 0 dòng. Đừng trả thông báo thành công mặc định. Số 0 cũng có thể do record không tồn tại hoặc sai phạm vi; lớp HTTP cần phân biệt phù hợp mà không làm lộ dữ liệu không được phép.

Cách này chỉ bảo vệ điều kiện status, không bảo vệ mọi field. Nếu trạng thái đi todo → doing → todo, request rất cũ lại khớp: đó là giới hạn kiểu ABA. Khi cần phát hiện mọi phiên bản cũ, dùng version tăng đơn điệu và cập nhật WHERE version = expected_version, đồng thời tăng version trong cùng statement. Schema TaskFlow hiện chưa có version; không giả vờ ví dụ status là optimistic locking hoàn chỉnh.

4. Khi nào cần khóa hàng?

DB::transaction(function () use ($projectId, $taskId) {
    $task = Task::where('project_id', $projectId)
        ->whereKey($taskId)->lockForUpdate()->firstOrFail();
    if ($task->status !== 'todo') {
        throw new DomainException('Task is no longer todo');
    }
    $task->status = 'doing';
    $task->save();
}, attempts: 3);

Đây là mẫu thiết kế cho engine có hỗ trợ khóa phù hợp, không phải kết quả kiểm thử concurrency của bài. Khi có nhiều phép đọc/quyết định/ghi cần phối hợp, row lock trong transaction có thể cần thiết. Giữ transaction ngắn, tìm bằng index thích hợp và khóa nhiều record theo thứ tự nhất quán để giảm deadlock.

Retry có thể chạy callback lại. Callback phải chịu được việc thực thi nhiều lần và không gửi email hay thu tiền trực tiếp mỗi lần thử. Không retry vô hạn và không retry mọi exception nghiệp vụ như deadlock. Tham khảo pessimistic locking.

5. Bằng chứng thực hành và giới hạn

Tạo tests/Feature/TaskFlowTransactionTest.php:

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Tests\TestCase;

class TaskFlowTransactionTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
    }

    public function test_failed_transaction_does_not_leave_a_partial_task(): void
    {
        $project = Project::factory()->create();
        try {
            DB::transaction(function () use ($project) {
                $project->tasks()->create(['title' => 'Must roll back']);
                throw new RuntimeException('Simulated failure');
            });
            $this->fail('Expected transaction failure');
        } catch (RuntimeException $exception) {
            $this->assertSame('Simulated failure', $exception->getMessage());
        }
        $this->assertDatabaseCount('tasks', 0);
        $this->assertDatabaseHas('projects', ['id' => $project->id]);
    }

    public function test_conditional_transition_rejects_a_stale_expected_status(): void
    {
        $task = Task::factory()->create(['status' => 'todo']);
        $first = Task::whereKey($task->id)->where('project_id', $task->project_id)
            ->where('status', 'todo')->update(['status' => 'doing']);
        $stale = Task::whereKey($task->id)->where('project_id', $task->project_id)
            ->where('status', 'todo')->update(['status' => 'done']);
        $this->assertSame(1, $first);
        $this->assertSame(0, $stale);
        $this->assertSame('doing', $task->fresh()->status);
    }
}
php artisan test --filter=TaskFlowTransactionTest
php artisan test

Toàn suite pass 35 test, 120 assertions. Test thứ hai mô phỏng request cũ bằng hai statement tuần tự; nó chứng minh điều kiện trạng thái hoạt động, không tạo hai client đồng thời. SQLite bộ nhớ cũng không chứng minh SELECT FOR UPDATE trên MySQL/PostgreSQL. Bài không tuyên bố đã đo lock wait hay deadlock production.

6. Cách kiểm chứng concurrency trên môi trường staging

Dùng database riêng cùng engine/version với production và hai connection độc lập. Connection A bắt đầu transaction, khóa task rồi giữ tại điểm đồng bộ kiểm soát được. B bắt đầu thao tác tương ứng; ghi nhận chờ khóa hoặc timeout theo cấu hình. Cho A commit, kiểm tra B đọc trạng thái mới và không ghi chuyển trạng thái không hợp lệ. Lặp lại với A rollback.

Đo thời gian chờ, số lần retry và kết quả cuối; không chỉ assert không có exception. Tắt hoặc giới hạn side effect ngoài database trong bài thử. Với optimistic version, cho hai writer cùng expected_version và assert chỉ một writer thành công. Không chạy thí nghiệm giữ lock trên dữ liệu khách hàng.

7. Bài tập

Thêm test commit thành công và test exception trước insert. Tự dựng tình huống ABA để thấy giới hạn status. Viết ra ranh giới nào cần transaction, dữ liệu nào phải authorize trước thao tác và side effect nào cần trì hoãn sau commit. Bulk update trong ví dụ không gọi model event giống save từng instance; nhớ điều đó khi học observer.

Điều hướng: Bài 13 · Lộ trình. Bài tiếp theo giới thiệu Service Container và Dependency Injection.

Điều hướng khóa học Laravel 13

Bài trước (13) · Bài sau (15) · Mục lục trọn bộ 37 bài

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.