LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

This post is also available in the following languages. English, Korean

req-shieldでキャッシュの厄介者「Thundering Herd問題」を簡単に解決!

こんにちは。LINE Plus CorporationのContents Service Engineering組織でバックエンド開発を担当しているKang Hyun Yangと、Byungchan Leeです。私たちが所属しているContents Server EngineeringではTech Groupという組織を運営しています。Tech Groupでは、さまざまな組織が直面する共通の課題に取り組み、特に大量のトラフィックを効率的に処理する方法を模索してきました。その模索の中で、キャッシュプロセスの高度化を行い、その結果、「req-shield」という内部ライブラリを開発しました。

この記事では、req-shieldを開発した背景と詳細機能を詳しく紹介したいと思います。

一般的にアプリケーションがキャッシュを利用する方法

通常、アプリケーションは以下のようにキャッシュを利用します。

クライアントがリクエストを行うと、そのリクエストに対応するコンテンツがキャッシュにあれば、クライアントはすぐにレスポンスを受け取ります。もしキャッシュに対応するコンテンツがない場合は、バックエンドのデータストア(DB、サーバー、ファイルなど)からデータを取得し、キャッシュに保存します(上の図では便宜上「MySQL」と表現されている)。

このような仕組みで、仮にN個のクライアントのリクエストが入っているとしてみましょう。このとき、キャッシュにN個のリクエストに対応するコンテンツがなければ、N個のリクエストはすべてバックエンドデータストアにデータをリクエストし、N個のリクエストがすべて同じ値を求めていたとしても、N回のリクエストがそのまま行われます。さらに、特に何の措置も取らなければ、バックエンドデータストアから受け取ったデータをキャッシュにN回記録する作業も行われます。これはバックエンドストアとキャッシュの両方に負荷を引き起こす原因となり、「Thundering Herd問題」と呼ばれます。

req-shieldの開発は、その「Thundering Herd問題」を解決する方法を模索することから始まりました。

req-shieldのアイデア

この問題を解決するカギは、クライアントからバックエンドデータストアへのリクエストをどのように減らすかです。私たちはキャッシュにヒットするかどうかによって、2つのアイデアを適用できると考えました。

リクエストされたデータがキャッシュに存在しない場合

リクエストされたデータがキャッシュに存在せず、バックエンドストアから取得してキャッシュにロードする必要がある場合、アプリケーション側でローカルまたはグローバルロック(lock)を使って特定のリクエストだけをバックエンドストアに送ります。他のリクエストは、ロックを取得したリクエストがキャッシュにデータを作成するまで待てば、問題を解決できます。

リクエストされたデータがキャッシュに存在する場合

リクエストされたデータがキャッシュに存在し、クライアントがすぐにレスポンスを受け取る場合にも、将来のための予防措置を取ることができます。例えば、照会しようとするキャッシュのTTL(time to live)が10秒で、クライアントがそのキャッシュを照会した時点でTTLが2~3秒程度残っていれば、キャッシュが期限切れになる前にキャッシュのTTLを更新することです。これにより、一定のトラフィックが入るという前提で、キャッシュが期限切れにならず、まるでキャッシュが継続的に存在しているような効果が期待できます。

上記を時間グラフで比較すると以下のようになります。

アイデアの実装方法

それぞれのアイデアをどのように実装したのか、一つずつ見てみましょう。

リクエストされたデータがキャッシュに存在しない場合

このケースでは、ロック(lock)メカニズムを積極的に利用しました。

ロックには大きく分けて、ローカルロックとグローバルロックがあります。まず、グローバルロックについては、ライブラリ側は開発者がどのような分散(distributed)ロックを使うか分からないため、単純に関数を注入することで実装しました。開発者がreq-shieldのインスタンスを作成するときに注入したグローバルロック関数をアプリケーション側で実行することで、グローバルロックを取得したと認識し、ロックを取得したリクエストが代表としてデータ取得やキャッシュロードを行います。ロックを取得できなかったリクエストは、設定された時間の間、データがキャッシュにロードされるのを待ちます。

ローカルロックはセマフォ(semaphore)を積極的に使っており、ロック取得がサーバーインスタンスごとに行われるという点以外は上の図のように動作します。

リクエストされたデータがキャッシュに存在する場合

このケースもロックメカニズムを積極的に利用しますが、前述のケースと少し異なる点があります。

キャッシュにデータが存在するため、ロックを取得できなかったリクエストはキャッシュにデータがロードされるのを待つ必要がありません。即座にクライアントにレスポンスできます。ロックを取得したリクエストも即座にクライアントにレスポンスし、非同期でキャッシュの更新を行います。

動作原理のまとめ

req-shieldの動作原理をフローチャートにまとめると以下のようになります。

req-shieldのモジュール構成

req-shieldのモジュールは、大きくコアモジュールとSpringモジュール、サポートモジュールに分けられます。各モジュールを見てみましょう。

コアモジュール

コアモジュールは、各プラットフォームに応じて使い分けられるように、以下の3つで構成されています。各モジュールには、メインメソッドと設定ファイルが含まれています。

  • core:通常のシーケンシャルブロッキング(sequential-blocking)メソッド環境下でのコアロジックが含まれています。
  • core-reactor:Reactorベースのコアロジックが含まれています。
  • core-kotlin-coroutine:Kotlin Coroutineベースのコアロジックが含まれています。

開発者は自分の環境に適したコアモジュールをimportでインポートして設定作業を行った後、インスタンスの作成と同時にメインメソッドを実行することでreq-shieldの機能を使うことができます。

以下はreq-shieldインスタンスを作成し、それを実際のキャッシュデータの取得や保存に使用するサンプルコードです。

//productIdを使ってproductというドメインを照会するメソッドの例です。
fun getProduct(productId: String): Product {
 
    //Req-Shieldインスタンスを作成します。
    val reqShield = ReqShield(
        ReqShieldConfiguration(
            setCacheFunction = { key, data ->
                cacheSpec.put(key, data, 3000)
                true
            }, //cache platformにデータを入力するときに使用される関数です。Req-Shieldで使用されます。
 
            getCacheFunction = { key ->
                cacheSpec.get(key)
            }, //cache platformからデータを照会するときに使用される関数です。Req-Shieldで使用されます。
 
            isLocalLock = true, //ロックメカニズムとしてローカルロックを使用するかどうか。falseの場合、グローバルロックが使用されます。
 
            decisionForUpdate = 70 //TTLの何%が経過した時点でキャッシュを更新するかを判断する設定です。
         )
    )
 
    //getAndSetReqShieldDataはReq-Shieldのメインメソッドで、以下に指定されたReqShieldDataが返されます。
    return reqShield.getAndSetReqShieldData(
        key = productId, //キャッシュのキーも一緒に渡します。
        callable = { productRepository.findById(id) }, //キャッシュが存在しない場合、Req-Shieldがデータをリクエストするcallable関数です。
        timeToLiveMillis = 3000).value as Product
}
 
//Req-ShieldのgetAndSetReqShieldDataを呼び出すと返されるクラスです。valueに実際のキャッシュの値が入ります。
data class ReqShieldData(
    var value: Any?,
    var status: Status,
    val createdAt: Long,
    val timeToLiveMillis: Long,
)

Springフレームワークで以下のようにBeanとして作成して使うこともできます。

@Bean
fun reqShield() = ReqShield(reqShieldConfiguration())
 
private fun reqShieldConfiguration() =
    ReqShieldConfiguration(
        setCacheFunction = { key, data ->
            cacheSpec.put(key, data)
        },
        getCacheFunction = { key ->
            cacheSpec.get(key)
        },
        isLocalLock = true,
        decisionForUpdate = 70
    )

Springモジュール

コアモジュール以外にも、req-shieldをSpringフレームワークでよく使われる@Cacheable@Transactionalといったアノテーション(annotation)のように、便利に使用できるSpringベースのモジュールも構成しました。

Springベースのモジュールは、前述のコアモジュールをベースに@ReqShieldCacheable@ReqShieldCacheEvictのようなアノテーションとして、req-shieldを提供しています。また、このモジュールは、私たちの開発チームで主に使う技術スタックに合わせて、MVCとWebFlux、WebFlux Kotlin Coroutineの3つのモジュールで構成されています。

  • core-spring-mvc
  • core-spring-webflux
  • core-spring-webflux-kotlin-coroutine

詳細な実装はSpring AOPを使用しており、以下のようにアノテーションにいくつかの要素値を追加することでreq-shieldの機能が利用できます。

//productIdを使ってproductというドメインを照会するメソッドの例です。
//上記の設定に入力する値をアノテーションをつけて指定できます。
@ReqShieldCacheable(key = "product_cacheKey", decisionForUpdate = 70, timeToLiveMillis = 6000)
fun getProduct(productId: String): Product {
    log.info("get product (Simulate db request) / productId : $productId")
 
    //TODO : develop..
 
    return Product(productId, "product_$productId")
}

サポートモジュール

共通で使われるデータクラスとユーティリティ、テストパッケージを含むサポート(support)モジュールも存在します。サポートモジュールには、テストコードを実行する際に使用するテストコンテナ(testContainer)関連のコードが含まれており、各モジュールで簡単にRedisなどのプラットフォームを使ってテストできる環境を提供しています。

負荷テストの実施方法と結果

req-shieldのパフォーマンスを確認するため、以下のテスト環境で負荷テストを実施しました。

  • アプリケーション
    • Spring Boot 2.7.17
    • Redis(ローカル環境のDocker):6.2.7-alpine
    • RedisキャッシュTTL:20秒
    • バックエンドクエリシミュレーション:sleep 3秒(クエリ実行時にかかる時間をシミュレートすることで、明確な違いを確認するために遅延をやや長く設定)
    • req-shieldの設定
      • decisionForUpdate:70%(キャッシュのTTLが70%残っている時点で更新)
      • TTL:20秒
  • NGrinderの設定
    • vUser:100(プロセス数4 * スレッド数25)
    • ランプアップ(ramp-up):しない
    • テスト時間:5分
    • Redisキー:10個のキーをランダムに抽出して使用

テストの結果、req-shieldのキャッシュ更新ロジックがパフォーマンスに大きく影響することが分かりました。環境ごとに一つずつ見てみましょう。

Spring MVC環境での負荷テスト方法と結果

Spring MVC環境では3つのケースに分けて負荷テストを行いました。まず、Spring Cacheableのsync属性をそれぞれfalsetrueに設定した状態で負荷テストを行い、次にreq-shieldを使った場合のテストを実施してその結果を比較しました(Spring Cacheableのsync属性に関しては、後述の「開発中で発生した問題」セクションで詳しく説明します)。では、負荷テストの結果を見てみましょう。

@Cacheable & sync = falseと設定した場合

@Cacheable & sync = falseと設定した場合、平均TPSは約10,815でした。

@Cacheable & sync = trueと設定した場合

@Cacheable & sync = trueと設定した場合、平均TPSは約 8,280でした。

req-shieldを使用した場合

req-shieldを使用した場合、平均TPSは約16,799でした。

負荷テスト結果の比較

負荷テストの結果、req-shieldを使用した場合、平均TPS基準で@Cacheable & sync = false設定に対して55.31%、@Cacheable & sync = true設定に対しては102.88%パフォーマンスが向上しました。

Spring WebFlux環境での負荷テスト方法と結果

Spring WebFluxでは、ReactiveRedisOperatorを使って負荷テストを行いました。

ReactiveRedisOperatorを使用した場合

ReactiveRedisOperatorを使用した場合、平均TPSは約11,404でした。

req-shieldを使用した場合

req-shieldを使用した場合、平均TPSは約17,257でした。

負荷テスト結果の比較

負荷テストの結果を比較すると、req-shieldを使用した場合、平均TPS基準でパフォーマンスが約51.34%向上しました。

Spring WebFluxとKotlin Coroutine環境での負荷テスト方法と結果

Spring WebFluxとKotlin Coroutineを使用した環境では、ReactiveRedisOperatorを使用して負荷テストを行いました。

ReactiveRedisOperatorを使用した場合

ReactiveRedisOperatorを使用した場合、平均TPSは約12,059でした。

req-shieldを使用した場合

req-shieldを使用した場合、平均TPSは約16,335でした。

負荷テストの結果

負荷テストの結果を比較すると、平均TPS基準でreq-shieldを使用した場合、パフォーマンスが約35.45%向上しました。

開発中に発生した問題

Spring Cacheableのsync属性のサポート有無と、その属性の使用によるパフォーマンス低下の問題

Springフレームワークの@Cacheableにはsyncという属性があります。デフォルト値はfalseですが、trueに設定すると、同じ引数(argument)で同時にリクエストが来たとき、バックエンドデータストアへのリクエストをsynchronizedのように順次変更する役割をします。ただし、その属性には以下のような欠点が2つあります。

まず、キャッシュプロバイダ(provider)によってsyncオプションがサポートされる場合と、サポートされない場合があります。Springフレームワークで基本的にサポートされているCacheManagerとRedisキャッシュではsync属性をサポートしていますが、syncオプションを使う前に、それを使おうとしているキャッシュプロバイダがsyncオプションをサポートしているかどうかを確認する必要があります。

次に、一般に使われているRedisCacheManagerを基準にした場合、syncの設定を使用すると多少の性能低下は避けられません。私たちが開発時に参考にしたspring-data-redis 2.7.17を基準に、バックエンドデータストアへ1つのリクエストのみを送るためにsynchronizedを使うためです(spring-data-redis 3.0以上からReentrantLockに実装が変更されましたが、事象は同じです)。キャッシュヒットされた場合はsync属性の有無による性能差は大きくありませんが、キャッシュミスの場合はキャッシュが更新される前に入ってくるリクエストはすべて以下のコードのようにsynchronizedでロジックを実行するため、必然的に性能低下が発生します。

前述の負荷テストのデータにもあるように、実際sync=trueに設定すると、sync=falseの場合に対して約20%の性能低下が発生しました。

おわりに

どのライブラリもそうだと思いますが、req-shieldは万能薬ではありません。req-shieldが必要な場合もあれば、不要な場合もあります。開発者の視点から見てreq-shieldが輝くケースをまとめると以下のとおりです。

  • 大量のトラフィックが発生するシステム
  • クライアントが共通の(パーソナライズされていない)データを取得するシステム
  • キャッシュTTLのサイクルが短期または中期のシステム
  • バックエンドデータストアの負荷が重いか、応答がやや遅いシステム

req-shieldライブラリを開発し、「Thundering Herd問題」の解決策を模索する過程で、技術的な挑戦を通じて成長する貴重な機会を得られました。ロックメカニズムの慎重な適用、キャッシュTTLの更新ロジックの実装、Springフレームワークとの統合など、問題解決に重要な役割を果たした各技術要素に触れて使用する経験ができました。また、負荷テストを通じてreq-shieldが従来の方法より性能が優れていることを確認し、私たちが選択したアプローチが効果的であることを証明できました。

開発への努力が実を結んだことを嬉しく思い、このプロジェクトから得た大切な教訓と経験は、今後直面する新たな挑戦を乗り越えるための貴重な資産となると思います。この記事が他の開発者に少しでもインスピレーションを与えることを願って、終わりにします。