Những cạm bẫy với Laravel mà chắc người mới sẽ gặp phải

Dưới đây là vài sai lầm mà mình đã gặp khi code với Laravel trong dự án vừa rồi, viết lại ra đây để cho khỏi quên mất 🙄.

Dùng hasOne để lấy bản ghi đầu tiên/cuối cùng của relation hasMany

Ví dụ trong dự án mình có 2 model SeriesEpisode với quan hệ hasMany kiểu như này.

class Series extends Model
{
    public function episodes()
    {
        return $this->hasMany(Episode::class);
    }
}

Và mình có yêu cầu tìm tất cả tập mới nhất của một series. Đây cũng là một vấn đề khá phổ biến. Trên stackoverflow có hẳn một tag greatest-n-per-group cho mấy câu hỏi về chủ đề này.

Google thử thì có khá nhiều câu trả lời trên cả Stackoverflow, Reddit, Laracast sẽ chỉ bạn thêm một relation như thế này.

public function latestEpisode()
{
    return $this->hasOne(Episode::class)->latest();
}

Và sau đó mình có thể dùng eager loading như này.

Series::with('latestEpisode')->get()

Thậm chí mình còn thêm được cả điều kiện cho latestEpisode như với các relation khác nữa. Ví dụ ở đây mình muốn tìm tất cả series có tập mới nhất vừa phát hành tháng này.

Series::whereHas([
    'latestEpisode' => function ($query) {
        $query->whereBetween('published_date', ['2020-12-01', '2020-12-31']);
    },
]);

Kết quả lúc mình test thì ra đúng như mong đợi luôn 😃.

Nhưng chỉ vài ngày sau mình đã nhận được quả bug to tướng. Mình mới gọi bạn hàng xóm sang hỏi thì mới biết là sai hết, mình đã bị lừa 🤦‍♀️.

Khi đứa nào nói nó chỉ quan tâm mình bạn thì có nghĩa là trong khi đang nói mắt nó vẫn còn đong đưa khối đứa ngoài kia nữa. Cả HasOne cũng như vậy luôn 🙂.

Chắc bạn cũng biết cấu trúc bảng của 2 relation HasOneHasMany giống hệt nhau rồi. Nếu đọc qua code của relation HasOne bạn cũng sẽ thấy là cả nó và HasMany đều extend HasOneOrMany. Nghĩa là query của bọn nó giống hệt nhau các bạn ạ 😔. Khác biệt ở chỗ HasOne chỉ lấy kết quả đầu tiên với first() còn HasMany thì lấy tất cả kết quả thôi.

Khi mình eager load như trên thì query nó sẽ ra kiểu như này.

SELECT * FROM episodes WHERE episodes.id IN (1, 2, 3)

Nghĩa là lấy tất cả bản ghi luôn thay vì chỉ 1 cái cuối cùng như mình tưởng. Tất nhiên nếu relation của mình thật sự là HasOne thì không vấn đề gì. Nhưng mà thật ra nó lại là HasMany. Thế là API của mình chạy mất mấy chục giây vì phải load đống dữ liệu khổng lồ.

Và khi mình thêm điều kiện vào như trên thì query nó sẽ kiểu này.

SELECT *
FROM episodes
WHERE episodes.id IN (1, 2, 3)
AND published_date BETWEEN '2020-12-01' AND '2020-12-31'

Nghĩa là chỉ cần có 1 tập phát hành trong tháng 12 là thỏa mãn điều kiện, thay vì chỉ tập cuối như mình nghĩ.

Cách sửa thì cũng tùy vào yêu cầu vấn đề của bạn, và phụ thuộc vào cả DB bạn đang dùng nữa. Đây là cách mình dùng. Chắc cũng chưa phải là xịn nhất nhưng mà nhìn nó dễ hiểu 😃.

Series::with([
    'episodes' => function ($query) {
        $query->where('published_date', function ($query) {
            return $query->selectRaw('MAX(published_date)')
                ->from('episodes')
                ->whereRaw('series.id = episodes.series_id');
        });
    },
]);

Nhân tiện, sau cái này thì mình còn gặp vài bug nữa liên quan đến việc viết thêm điều kiện vào relation như ở trên nữa. Nên mình thấy tốt nhất relation thì chỉ nên return relation thôi, đừng thêm điều kiện gì vào đằng sau nữa nhé. Nếu điều kiện đó là mặc định thì hãy thử thêm vào global scope xem.

Race condition với database

Race condition là tình trạng nhiều process cùng thay đổi một tài nguyên nào đó cùng một lúc. Với Laravel thì chủ yếu là các hành động update/insert data mà phải dựa vào kết quả của 1 query trước đó. Cái này đặc biệt hay sảy ra với transaction vì bạn thường có nhiều query trong đó. Mình có vài ví dụ dưới đây.

Update cột tăng dần

Vấn với 2 model như phần trước. Với Episode mình có cột number là số thứ tự của tập. Mỗi lần thêm tập mới mình sẽ phải set số tập là tập mới nhất hiện tại + 1.

Mình sẽ cần ít nhất 2 query. Đầu tiên là tìm xem tập mới nhất hiện tại là tập bao nhiêu. Sau đó mới tạo tập mới với số tập tiếp theo được. Code của mình kiểu như này.

DB::transaction(function () {
    $currentEpisode = $series->episodes()->max('number');
    $nextEpisode = $series->episodes()->create([
        'number' => $currentEpisode + 1,
    ]);
    // Làm gì đó ở đây nữa
    sleep(5);
});

Nếu có 2 request tới cùng một lúc thì cả 2 sẽ chỉ thấy số tập hiện tại bằng nhau. Và thế là 2 tập mới được thêm vào sẽ có cùng số thứ tự như nhau.

Cái này thì chưa liên quan đến Laravel lắm nhưng cái sau sẽ có nè.

sync nhưng không phải là sync

Ý mình là method sync (synchronize) của relation BelongsToMany ấy.

Method này cần được thực hiện synchronously. Nghĩa là cùng một lúc với một record chỉ được gọi sync một lần thôi.

Method này dùng để update bản ghi của relation many-to-many. Nó được thực hiện bằng ít nhất 2 query. Đầu tiên phải kiểm tra trạng thái hiện tại xem có bản ghi mới cần attach/detach hay không. Nếu có thì mới thực hiện insert hoặc delete.

Nghĩa là trong khoảng thời gian bé tí tẹo giữa những query ấy, nếu có một method sync khác được thực hiện thì kết quả query đầu tiên để kiểm tra trạng thái cần insert/delete sẽ cho kết quả sai. Và sau đó có thể insert thừa dòng, tạo ra bản ghi duplicate không mong muốn.

Cách tái hiện dễ nhất là dùng transaction như này.

DB::transaction(function () {
    $user->roles()->sync([1, 2, 3]);
    // Làm gì đó ở đây nữa
    sleep(5);
});

Giả sử sẽ có role 2 và 3 được thêm vào. Nếu bạn vào tinker và chạy đoạn code này 2 lần liền cùng lúc sẽ thấy có đến 4 bản ghi được thêm vào thay vì 2 cái.

Cách giải quyết

Tất nhiên với các ví dụ trên bạn đều có thể thêm UNIQUE constraint trong DB. Nhưng dù sao bạn vẫn phải handle lỗi khi insert bị trùng. Hơn nữa sẽ có trường hợp bạn không thể thêm constraint tùy theo yêu cầu dự án.

Cách tốt nhất để tránh khỏi vấn đề này là chỉ cho phép những transaction như trên chạy mỗi lần 1 cái thôi. Laravel cung cấp sẵn hẳn 1 cơ chế lock để thực hiện điều này. (DB cũng có sẵn cơ chế lock nhưng mà mình thấy giải thích cái kia dễ hơn 😃).

Mỗi khi một command/request cần thực hiện một transaction như trên sẽ cần tạo một khóa. Các command/request khác nếu cũng thực hiện một transaction tương tự (lên cùng một bản ghi) sẽ không thể tạo được khóa nếu nó đã tồn tại mà phải đợi command/process đang chạy chạy xong đã. Như vậy sẽ đảm bảo cùng lúc chỉ có một command/request update bản ghi đó thôi và tránh được race condition.

Document cho tính năng Atomic Lock của Laravel ở đây nhé.

Mình sẽ viết một cái helper như này.

function lock($name, $callback)
{
    $lock = Cache::lock($name);

    while (!$lock->get()) {
        usleep(500);
    }

    try {
        return $callback();
    } finally {
        $lock->release();
    }
}

Bạn có thể test trong tinker như này. Khi chạy 2 cái cùng lúc bạn sẽ thấy command sau sẽ phải đợi hết 5 giây cho command trước đó chạy xong mới chạy tiếp được.

lock('test', function () {
    dump('start');
    sleep(5);
});

Và trong trường hợp đầu tiên thì code của mình trông như thế này

lock("$series_{series->id}_new_episode", function () {
    DB::transaction(function () {
        $currentEpisode = $series->episodes()->max('number');
        $nextEpisode = $series->episodes()->create([
            'number' => $currentEpisode + 1,
        ]);
    });
})

All Rights Reserved