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

Học Laravel 13 – Bài 19: Cache, Rate Limit và Atomic Lock trong Laravel 13

Bài 19 — Cache, Rate Limit và Atomic Lock: TaskFlow thêm bộ đếm task có cache và giới hạn tần suất form preview. Chúng ta cũng kiểm thử quyền sở hữu lock, nhưng không dùng cache lock thay database constraint hay tuyên bố đã chống cạnh tranh trên nhiều máy.

Cache, Rate Limit và Atomic Lock trong Laravel 13

Bài 19 — Cache, Rate Limit và Atomic Lock: TaskFlow thêm bộ đếm task có cache và giới hạn tần suất form preview. Chúng ta cũng kiểm thử quyền sở hữu lock, nhưng không dùng cache lock thay database constraint hay tuyên bố đã chống cạnh tranh trên nhiều máy.

1. Ba công cụ giải quyết ba vấn đề khác nhau

Cache tránh tính lại dữ liệu; rate limit hạn chế số lần gọi trong khoảng thời gian; lock phối hợp một công việc giữa các caller dùng cùng nơi lưu khóa. Cả ba có thể dùng cache backend nhưng không thay thế nhau. Cache hit không chứng minh người dùng có quyền đọc project, còn rate limit không bảo đảm chỉ một worker đang chạy.

TaskFlow chấp nhận bộ đếm task trễ tối đa khoảng TTL 30 giây trong luồng thông thường. Không dùng con số này để quyết định quota ghi, thanh toán hoặc quyền truy cập. Các quyết định cần chính xác phải đọc và bảo vệ trạng thái database phù hợp.

2. Cache số nguyên, đặt key theo project

app/Services/ProjectTaskCount.php

<?php

namespace App\Services;

use App\Models\Project;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;

final class ProjectTaskCount
{
    // Caller authorizes the project. Counts may be stale for 30 seconds.
    public function get(Project $project): int
    {
        if (! $project->exists) {
            throw new InvalidArgumentException('A persisted project is required.');
        }

        return Cache::remember('taskflow:project:'.$project->id.':task-count:v1', 30,
            fn () => $project->tasks()->count());
    }
}

Key chứa namespace ứng dụng, project ID, tên dữ liệu và version schema. Nó không dùng title có thể đổi và không chứa input tùy ý của client. Service từ chối project chưa lưu; caller vẫn phải authorize trước khi gọi. Hai project dùng hai key nên không lấy nhầm số đếm của nhau.

Ví dụ cache số nguyên thay vì serialize toàn bộ model hoặc response chứa thông tin người dùng. Giá trị zero cũng được cache. TTL là lựa chọn có chủ đích: sau khi tạo task, số đếm có thể cũ cho đến khi hết hạn. Test bên dưới chứng minh điều đó chứ không giả vờ có cơ chế invalidation ngay lập tức.

Nếu cần cập nhật nhanh hơn, xóa đúng key sau commit của mọi đường ghi liên quan. Không xóa trước commit rồi để reader nạp lại dữ liệu cũ. Ngay cả xóa sau commit vẫn cần phân tích race giữa reader đang tính và writer; hệ thống yêu cầu nhất quán mạnh cần thiết kế version/nguồn dữ liệu khác. Không dùng Cache::flush cho một project vì có thể ảnh hưởng dữ liệu của phạm vi rộng hơn dự định.

3. Giới hạn preview và giữ phản hồi 429

app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use App\Contracts\Clock;
use App\Support\SystemClock;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(Clock::class, SystemClock::class);
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        RateLimiter::for('task-preview', fn (Request $request) => Limit::perMinute(10)
            ->by('task-preview:'.($request->user()?->id ?? $request->ip())));
    }
}

Gắn limiter vào route POST đã có, không chỉ khai báo tên rồi bỏ quên:

Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])
    ->middleware('throttle:task-preview')->name('tasks.preview');

Mỗi user đã đăng nhập hoặc IP khách được 10 request/phút trong ví dụ. Middleware đếm request đi qua nó, kể cả validation thất bại. Lần vượt ngưỡng nhận 429 và Retry-After để client biết khi nào thử lại. Mức 10 chỉ phục vụ bài học, cần đo nhu cầu thật trước khi áp dụng cho nghiệp vụ khác.

Nhiều người có thể chung IP sau NAT; proxy cấu hình sai cũng làm mọi người chung một key hoặc cho phép giả mạo địa chỉ. Khi thêm authentication, ưu tiên user ID ổn định cho quota cá nhân và cấu hình trusted proxy theo hạ tầng. Limiter ứng dụng không phải lớp bảo vệ DDoS hoàn chỉnh.

4. Atomic lock và thời hạn sở hữu

$completed = Cache::lock('taskflow:project:'.$projectId.':export', 10)
    ->get(function () use ($projectId) {
        // Perform a bounded export operation here.
        return true;
    });
if ($completed === false) {
    // Another owner holds the lock; return a controlled busy response.
}

Đây là khung minh họa, chưa có chức năng export thật. Callback trả true để phân biệt hoàn tất với không lấy được lock. Laravel giải phóng lock sau callback, kể cả callback ném exception. Không dùng forceRelease tùy tiện vì có thể xóa lock của owner khác.

TTL 10 giây không tự dừng công việc khi hết hạn. Nếu job chạy quá TTL, caller khác có thể lấy lock trong lúc job cũ còn chạy. Vì vậy lock này không bảo đảm exactly-once. Cần giới hạn thời gian, đánh giá gia hạn và kiểm soát ghi cuối cùng bằng constraint, version hoặc fencing khi yêu cầu. Không giữ lock ngắn quanh thao tác gọi mạng không có timeout.

5. Backend thật quyết định phạm vi phối hợp

TaskFlow mặc định cấu hình database cache; migrations nền đã có cache và cache_locks. Test dùng array để cô lập. Khi triển khai nhiều process/máy, tất cả phải cùng truy cập backend và namespace cần phối hợp. Array trong process hoặc file riêng từng máy không tạo khóa phân tán giữa các máy.

Nếu chọn Redis, kiểm tra extension/client, connection, prefix và quyền truy cập. Không tự chuyển lock hoặc quota sang array khi Redis lỗi rồi vẫn tuyên bố bảo đảm toàn cụm. Cache đọc tối ưu hiệu năng có thể có chiến lược fallback; khóa bảo vệ tính đúng đắn cần quyết định lỗi khác, được kiểm thử rõ ràng.

6. Kiểm thử và giới hạn bằng chứng

tests/Feature/TaskCacheTest.php

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use App\Services\ProjectTaskCount;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class TaskCacheTest extends TestCase
{
    public function test_counts_are_project_scoped_and_refresh_after_ttl(): void
    {
        config(['cache.default' => 'array', 'database.default' => 'sqlite',
            'database.connections.sqlite.database' => ':memory:', 'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        $first = Project::factory()->create();
        $second = Project::factory()->create();
        $counts = app(ProjectTaskCount::class);
        $this->assertSame(0, $counts->get($first));
        Task::factory()->for($first)->create();
        $this->assertSame(0, $counts->get($first));
        $this->assertSame(0, $counts->get($second));
        $this->travel(31)->seconds();
        $this->assertSame(1, $counts->get($first));
        $this->travelBack();
    }

    public function test_preview_rate_limit_returns_retry_after(): void
    {
        config(['cache.default' => 'array']);
        $this->withoutVite();
        for ($i = 0; $i < 10; $i++) {
            $this->post('/tasks/preview', ['title' => 'Preview', 'priority' => 'normal'])->assertOk();
        }
        $response = $this->postJson('/tasks/preview', ['title' => 'Preview', 'priority' => 'normal']);
        $response->assertStatus(429)->assertHeader('Retry-After');
        $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After'));
        $this->travel(61)->seconds();
        $this->post('/tasks/preview', ['title' => 'Preview', 'priority' => 'normal'])->assertOk();
        $this->travelBack();
    }

    public function test_lock_rejects_second_owner_and_releases_after_exception(): void
    {
        $store = Cache::store('array');
        $first = $store->lock('taskflow:test:export:1', 10);
        $second = $store->lock('taskflow:test:export:1', 10);
        $this->assertTrue($first->get());
        $this->assertFalse($second->get());
        $first->release();
        $this->assertTrue($second->get());
        $second->release();
        try {
            $store->lock('taskflow:test:export:1', 10)->get(function () {
                throw new \RuntimeException('Export failed');
            });
        } catch (\RuntimeException $exception) {
            $this->assertSame('Export failed', $exception->getMessage());
        }
        $last = $store->lock('taskflow:test:export:1', 10);
        $this->assertTrue($last->get());
        $last->release();
    }
}
php artisan test --filter=TaskCacheTest
php artisan test

Test xác minh key theo project, dữ liệu cũ trước TTL và mới sau TTL; HTTP preview trả 429 rồi phục hồi; lock owner thứ hai bị từ chối và callback lỗi vẫn nhả khóa. Bộ suite pass 51 test, 182 assertions. Đây là kiểm thử tuần tự trên array store, không chứng minh failover Redis hay cạnh tranh đa process. Phần đó cần integration test riêng trên backend deploy.

Bài tập: đo query trên cache hit, thử hai IP độc lập, thêm test lock hết hạn khi owner cũ vẫn còn công việc và giải thích rủi ro. Tham khảo Laravel CacheRate Limiting.

Điều hướng: Bài 18 · Lộ trình. Tiếp theo là authentication và starter kit.

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

Bài trước (18) · Bài sau (20) · 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.