Laravel

Dynamic relationship trong Laravel với subqueries

Giới thiệu

Hai mục tiêu bạn cần phải cân nhắc khi xây dựng ứng dụng web mà có kết nối đến database, đó là:

  1. Sử dụng một số lượng tối thiểu query
  2. Sử dụng bộ nhớ ít nhất có thể

Những mục tiêu này có thể có ảnh hưởng mạnh đến hiệu năng ứng dụng của bạn.

Chúng ta thường khá tốt về mục tiêu thứ nhất, chúng ta nhận thức được về vấn đề N+1 và sử dụng kỹ thuật gọi là eager-loading để giảm thiểu số lượng database query. Nhưng với mục tiêu thứ hai thì không phải ai cũng nhận thức được. Thực tế, trong một số trường hợp việc giảm số lượng query có thể làm tốn thêm rất nhiều bộ nhớ.

Vấn đề

Cho ví dụ sau. Bạn có một trang danh sách users trong web app, thể hiện thông tin user bao gồm ngày login cuối cùng của user. Nghe có vẻ đơn giản nhưng thực tế thì nó có một số sự phức tạp thú vị.

Name Email Last Login
Adam Campbell [email protected] Nov 10, 2018 at 12:01pm
Taylor Otwell [email protected] Never
Jonathan Reinink [email protected] Jun 2, 2018 at 5:30am
Adam Wathan [email protected] Nov 20, 2018 at 7:49pm

Việc login của user sẽ được theo dõi và lưu vào database table logins:

Schema::create('users', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('email');
    $table->timestamps();
});

Schema::create('logins', function (Blueprint $table) {
    $table->increments('id');
    $table->integer('user_id');
    $table->string('ip_address');
    $table->timestamp('created_at');
});

Và đây là model tương ứng và relations:

class User extends Model
{
    public function logins()
    {
        return $this->hasMany(Login::class);
    }
}

class Login extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Vậy làm thế nào đế chúng ta hiển thị trang users như yêu cầu ở trên? Hay cụ thể là làm thế nào để chúng ta lấy ra last login date? Một cách đơn giản nhất có thể là:

$users = User::all();

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($lastLogin = $user->logins()->latest()->first())
                {{ $lastLogin->created_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

Oh, nhưng bạn có thể thấy ngay vấn đề ở đây đó là N+1, với mỗi user được hiển thị chúng ta cần thêm 1 câu query để lấy ra last login date của user đó. Nếu trang của chúng ta có 50 users thì số lượng query sẽ là 51.

select * from "users";
select * from "logins" where "logins"."user_id" = 1 and "logins"."user_id" is not null order by "created_at" desc limit 1;
select * from "logins" where "logins"."user_id" = 2 and "logins"."user_id" is not null order by "created_at" desc limit 1;
# ...
select * from "logins" where "logins"."user_id" = 49 and "logins"."user_id" is not null order by "created_at" desc limit 1;
select * from "logins" where "logins"."user_id" = 50 and "logins"."user_id" is not null order by "created_at" desc limit 1;

Bắt đầu tối ưu lại. Cách đầu tiên đơn giản đó là eager load all Login records:

$users = User::with('logins')->get();

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($user->logins->isNotEmpty())
                {{ $user->logins->sortByDesc('created_at')->first()->created_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

Giải pháp này chỉ cần sử dụng 2 câu query, 1 để lấy ra danh sách users và 1 để lấy ra tất cả login records của danh sách users. Tuy nhiên, vấn đề về memory có thể xuất hiện ở đây. Chắc chắn là chúng ta đã tránh được vấn đề N+1, nhưng chúng ta lại tạo ra 1 vấn đề lớn hơn đó là big memory:

Users per page 50 users
Average logins per user 250 logins
Total login records loaded 12,500 records

Hiện giờ thì chúng ta đang load 12500 login records chỉ để lấy ra last login date cho mỗi user. Điều này không chỉ tiêu tốn bộ nhớ mà còn tốn thời gian tính toán, vì mỗi record sẽ được convert thành Eloquent Model.

Caching

Bạn có thể suy nghĩ, "Không có vấn đề gì lớn cả, tôi sẽ cache lại last login":

Schema::create('users', function (Blueprint $table) {
   $table->integer('last_login_id');
});

Khi user logged in, chúng ta sẽ tạo mới một bản ghi Login và update khóa ngoại last_login_id trên bảng users. Chúng ta sẽ tạo 1 relationship gọi là lastLogin và eager load nó:

$users = User::with('lastLogin')->get();

Đây là một giải pháp đúng đắn. Nhưng thực tế caching thường không đơn giản như thế này.

Subquery

Có một cách giải quyết khác đó là sử dụng subquery. Subquery cho phép chúng ta select thêm các extra columns ngay trong câu query chính. Laravel hỗ trợ subquery qua method selectSub:

$lastLogin = Login::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
    ->limit(1)
    ->getQuery();

$users = User::select('users.*')
    ->selectSub($lastLogin, 'last_login_at')
    ->get();

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($user->last_login_at)
                {{ $user->last_login_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

Trong ví dụ này chúng ta thực tế chưa sử dụng relationship. Ở đây chúng ta mới chỉ sử dụng subquery để lấy last login date của mỗi user như một thuộc tính của user, hãy xem câu lệnh SQL thực tế được chạy:

select
    "users".*,
    (
        select "created_at" from "logins"
        where "user_id" = "users"."id"
        order by "created_at" desc
        limit 1
    ) as "last_login_at"
from "users"

Kỹ thuật này giúp cải thiện đáng kể hiệu năng khi chúng ta đã đạt được cả 2 mục tiêu đó là giảm số lượng query và memory, cộng thêm là chúng ta không cần dùng đến cache.

Macro

Trước khi đi xa hơn, tôi muốn show cho bạn một macro tôi thường sử dụng để làm cho việc sử dụng subquery ngắn gọn hơn. Macro được khai báo trong [email protected]:

use Illuminate\Database\Query\Builder;

Builder::macro('addSubSelect', function ($column, $query) {
    if (is_null($this->columns)) {
        $this->select($this->from.'.*');
    }

    return $this->selectSub($query->limit(1), $column);
});

Và bây giờ chúng ta có thể sử dụng macro:

$users = User::addSubSelect('last_login_at', Login::select('created_at')
    ->whereColumn('user_id', 'users.id')
    ->latest()
)->get();

Scopes

Thêm một bước nữa là đóng gói subquery trong User model scope để đơn giản hóa controller và có thể tái sử dụng:

class User extends Model
{
    public function scopeWithLastLoginDate($query)
    {
        $query->addSubSelect('last_login_at', Login::select('created_at')
            ->whereColumn('user_id', 'users.id')
            ->latest()
        );
    }
}

$users = User::withLastLoginDate()->get();

Dynamic relationship

Vâng, bây giờ là đến phần chúng ta đang xây dựng. Sử dụng subquery để lấy last login date thì ok rồi nhưng nếu chúng ta muốn thêm một số thông tin khác về login thì sao? Ví dụ, có thể chúng ta sẽ muốn hiện thông tin IP của login. Làm thế nào đây?

Một lựa chọn đó là viết thêm 1 model scope nữa:

$users = User::withLastLoginDate()->withLastLoginIpAddress()->get();

{{ $user->last_login_at->format('M j, Y \a\t g:i a') }} ({{ $user->last_login_ip_address }})

Và dĩ nhiên cách này hoạt động được, nhưng có lẽ là tốt hơn nếu có cách nào để lấy trực tiếp từ model Login, đặc biệt là khi model có sử dụng các accessors hay relationship.

$users = User::withLastLogin()->get();

{{ $user->lastLogin->created_at->format('M j, Y \a\t g:i a') }} ({{ $user->lastLogin->ip_address }})

Chúng ta sẽ bắt đầu định nghĩa một relationship mới lastLogin belongs to relationship. Thông thường để khai báo relationship, table cần có 1 khóa ngoại, ví dụ ở đây là last_login_id như trong solution sử dụng cache ở trên. Nhưng ở đây, chúng ta không sử dụng cache, thay vào đó sẽ sử dụng subquery.

class User extends Model
{
    public function lastLogin()
    {
        return $this->belongsTo(Login::class);
    }

    public function scopeWithLastLogin($query)
    {
        $query->addSubSelect('last_login_id', Login::select('id')
            ->whereColumn('user_id', 'users.id')
            ->latest()
        )->with('lastLogin');
    }
}

$users = User::withLastLogin()->get();

<table>
    <tr>
        <th>Name</th>
        <th>Email</th>
        <th>Last Login</th>
    </tr>
    @foreach ($users as $user)
        <tr>
            <td>{{ $user->name }}</td>
            <td>{{ $user->email }}</td>
            <td>
                @if ($user->lastLogin)
                    {{ $user->lastLogin->created_at->format('M j, Y \a\t g:i a') }}
                @else
                    Never
                @endif
            </td>
        </tr>
    @endforeach
</table>

Và kết quả là có hai query được thực thi, đầu tiên đó là query lấy users:

select
    "users".*,
    (
        select "id" from "logins"
        where "user_id" = "users"."id"
        order by "created_at" desc
        limit 1
    ) as "last_login_id"
from "users"

Nó khá giống với subquery select last login date, chỉ thay login date bằng login id. Từ đó chúng ta có column last_login_id mà không cần sử dụng cache.

Và query thứ hai sẽ tự đông được chạy khi chúng ta eager load with('lastLogin'):

select * from "logins" where "logins"."id" in (1, 3, 5, 13, 20 ... 676, 686)

Subquery cho phép chúng ta select duy nhất 1 bảng ghi last login của user và sử dụng như model Login.

Lazy-loading dynamic relationship

Một điều cần biết đó là bạn không thể sử dụng lazy-loading với kỹ thuật này bởi vì scope withLastLogin không phải là mặc định.

$lastLogin = User::first()->lastLogin; // will return null

Nếu bạn muốn sử dụng lazy loading thì bạn vẫn có thể sử global model scope:

class User extends Model
{
    protected static function boot()
    {
        parent::boot();

        static::addGlobalScope(function ($query) {
            $query->withLastLogin();
        });
    }
}

Cách này không được tối ưu, cá nhân tối thích eager load dynamic relationship một cách rõ ràng khi cần.

Tham khảo

https://reinink.ca/articles/dynamic-relationships-in-laravel-using-subqueries

Registration Login
Sign in with social account
or
Lost your Password?
Registration Login
Sign in with social account
or
A password will be send on your post
Registration Login
Registration