😺

Laravelタグ付きキャッシュを利用したキャッシュ機能の実装

2023/12/14に公開

最初に

この記事は、Laravel Advent Calendar 2023 13日目の記事です。

自己紹介

PRONI株式会社というスタートアップ企業でエンジニアしている deliku です。普段はLaravelを触っています。AWS界隈のウォッチは趣味でしていたり、開発者体験を良くすることを通じて、より良いプロダクトをユーザに届けるために頑張ってます。
普段なにしているかは会社テックブログに投稿しているので、興味ある方はぜひ読んでいってください!

https://note.com/deliku0306/

PRONI株式会社紹介

「受発注を変革するインフラを創る」をビジョンに掲げ、発注者と受注企業を適切にマッチングし、企業間取引の利便性向上に貢献する事業を展開しています。当社の事業目的は、経済活動の根幹ともいえる企業間取引に残る「不」を解消し、企業経営の生産性改善、ひいては日本の産業活性化に寄与することです

https://speakerdeck.com/proni/proni-now-hiring

PRONIアイミツ

「最適なプロに、最速で」サービス開始から10年を迎え、BtoBに特化した国内最大級の受発注プラットフォームサービスを提供しています。

Laravelタグ付きキャッシュ機能

タグ付きキャッシュとは

キャッシュタグを使用すると、キャッシュ内の関連アイテムにタグを付けてから、特定のタグが割り当てられているすべてのキャッシュ値を削除できます。

https://readouble.com/laravel/9.x/ja/cache.html#storing-tagged-cache-items

なぜキャッシュを利用したか

課題

PRONIアイミツでは、発注先のサービスを探す(サービス一覧)機能があります。
そこでは、サービス情報をさまざまな切り口から検索ができるのですが、扱うデータ数が増えてきたことで200ms〜400msかかるSQLが発生し始め、レスポンス速度への悪影響がでてくるようになりました。

アプローチ

・マテリアライズドビューへのデータ取得は、QueryServiceを利用して行われる。
・マテリアライズドビューのデータ更新は、バッチ処理により一定期間でデータが更新される。
・マテリアライズドビューのデータ更新後、キャッシュデータは削除する。
・QueryServiceで取得したデータがキャッシュに保存されていない場合、キャッシュデータとして登録する。

データ取得の流れ

図示すると下記のようなイメージになります。

実装

下記は実際のコードを一部抜粋しています。
マテリアライズドビューのタグ付きキャッシュデータを取り扱うCacheClassを作成し、QueryServiceで利用します。

Factory

Cacheキー生成ロジックを共通化させたいので、FactoryClassを作成しています。

<?php

namespace App\Cache\SearchItemAggregateCaches;

class SearchItemAggregateCacheFactory
{
    /**
     *
     * @param string $execute_method `__METHOD__` で指定する
     * @param array $parameters `get_defined_vars()` で指定する
     * @return SearchItemAggregateCache
     */
    public function create(string $execute_method, array $parameters): SearchItemAggregateCache
    {
        return app(
            SearchItemAggregateCache::class,
            ['execute_method' => $execute_method, 'parameters' => $parameters]
        );
    }
}

Cache

マテリアライズドビューへのCache処理はこのClassを通して行います。
登録、取得、削除にキャッシュタグを使用しています。

<?php

namespace App\Cache\SearchItemAggregateCaches;

use ErrorException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;

class SearchItemAggregateCache
{
    private const TAG_NAME = 'search_item_aggregate';

    private const VALID_SECONDS = 60 * 60 * 24; // 24時間

    private string $parameters_string;
    private string $execute_method;

    /**
     * @param string $execute_method
     * @param array $parameters
     * @throws ErrorException
     */
    public function __construct(string $execute_method, array $parameters)
    {
        $this->parameters_string = $this->parametersToString($parameters);
        $this->execute_method = $execute_method;
    }

    /**
     * @return bool
     */
    public function has(): bool
    {
        return Cache::tags(self::TAG_NAME)->has($this->getCacheKey());
    }

    /**
     * @return Collection
     */
    public function get(): Collection
    {
        return Cache::tags(self::TAG_NAME)->get($this->getCacheKey());
    }

    /**
     * @return bool
     */
    public static function flush(): bool
    {
        return Cache::tags(self::TAG_NAME)->flush();
    }

    /**
     * @param Collection $value
     * @return Collection
     */
    public function store(Collection $value): Collection
    {
        Cache::tags(self::TAG_NAME)->put($this->getCacheKey(), $value, self::VALID_SECONDS);

        return $value;
    }

    /**
     * @return string
     */
    private function getCacheKey(): string
    {
        return "$this->execute_method.$this->parameters_string";
    }

    /**
     * @param array $parameters
     * @return string
     * @throws ErrorException
     */
    private function parametersToString(array $parameters): string
    {
	// パラメータ値を文字列に変換    
        return xxx;
    }
}

QueryService

キャッシュの存在チェックを行い、なければキャッシュ登録を行います。

<?php

namespace App\Service\Query\Site\Search;

use App\Cache\SearchItemAggregateCaches\SearchItemAggregateCacheFactory;

class EloquentSearchSiteQueryService implements SearchSiteQueryService
{
    public function __construct(
        private SearchItemAggregateCacheFactory $search_item_aggregate_cache_factory)
    ) {
    }
    
    public function getSearchSpItemsForSearch(
        string $category_id,
        string $prefecture_id,
        string $city_id,
        string $station_id,
    ): Collection {
        $cache = $this->search_item_aggregate_cache_factory->create(__METHOD__, get_defined_vars());
        if ($cache->has()) {
            return $cache->get();
        }
        return $cache->store(
            // QueryServiceによるデータ取得処理
	    // 省略
        );
    }

バッチ処理で使用するUseCase

タグ付きキャッシュデータにしたことで、
バッチ処理で呼び出すキャッシュデータの削除処理がシンプルな実装になっています。

<?php

namespace App\UseCases\Commands\RefreshSearchItemAggregate;

use App\Cache\SearchItemAggregateCaches\SearchItemAggregateCache;
use App\Models\SearchItemAggregate;
use Illuminate\Contracts\Container\BindingResolutionException;

class RefreshSearchItemAggregateUseCase
{
    /**
     * @return void
     * @throws BindingResolutionException
     */
    public function invoke(): void
    {
        app()->make(SearchItemAggregate::class)->refresh();
        SearchItemAggregateCache::flush();
    }
}

最後に

キャッシュを利用するようにしたことで、300ms -> 4msになる改善効果が得ることができました。
また、今回タグ付きキャッシュを利用する際に下記のブログを参考情報にさせていただきました。

https://tec.tecotec.co.jp/entry/2022/12/02/000000

Discussion