ZOZOTOWN検索マイクロサービスにおけるキャッシュの導入とその効果

header

はじめに

こんにちは。検索基盤部 検索基盤チームの佐藤(@satto_sann)です。検索基盤チームでは、 ZOZOTOWNの検索周りのシステム開発に日々取り組んでいます。

本記事では、ZOZOTOWNの検索マイクロサービスにおけるキャッシュ導入で得られた知見や工夫点について紹介します。検索に限らずマイクロサービスへキャッシュの導入を検討されている方の参考になれば幸いです。

目次

キャッシュの導入背景

なぜ検索マイクロサービスにキャッシュを導入する必要があったのかについて紹介します。

負荷とレイテンシの悪化

現在ZOZOTOWNの検索システムは、既存の肥大化したシステムから検索に関連したAPI(以下、検索APIと呼ぶ)を切り離し、検索機能に特化したマイクロサービスで構成されています。

マイクロサービス化への道のりについては、以下の記事をご参照ください。 techblog.zozo.com

検索APIのマイクロサービス化に伴い、ZOZOTOWN以外のマイクロサービスから直接呼び出される機会が増加していました。

ZOZOTOWNからリクエストされる場合には、ZOZOTOWNを配信しているWebサーバ上にキャッシュする機構が以前からあったため、特に性能的な問題はありませんでした。

一方で他のサービスから直接呼び出された場合、検索APIはキャッシュ処理を行うような機構(以下、キャッシュ機構と呼ぶ)を持っていないため、負荷やレイテンシの悪化が課題となっていました。

検索APIが各サービスからリクエストされる様子

ABテストの仕組みをマイクロサービスへ移設する上での問題

ZOZOTOWNではより良い検索機能を提供するためにABテストを実施しています。以下では、ABテストが振り分けられる様子を表しています。

ABテストが振り分けられる様子

これまでのABテストの仕組みでは、ZOZOTOWN上でABテストを設定していたため、以下のようなロジックの修正や設定の変更が必要でした。

  • ZOZOTOWNのABテストの設定
  • 既存キャッシュ処理のロジック
  • 検索マイクロサービスのパラメータ
  • 検索マイクロサービスの内部ロジック

ABテスト毎にこれらの変更作業が発生するため、短期的にABテストを実施する上で課題となっていました。

そこで、以下の図のように検索マイクロサービス上でABテストを実施する仕組み(以下、ABテスト基盤と呼ぶ)を構築して、ABテストの設定をこの基盤上で行えるよう変更します。

変更後のABテスト振り分けの様子

マイクロサービスに完結したABテストの実施が可能になり、設定や改修は以前と比べ少なくなります。

  • ABテスト基盤の設定
  • 検索マイクロサービスの内部ロジック

検索APIを直接利用していたネイティブアプリについては、これまでABテストを実施出来ていませんでしたが、ABテスト基盤が出来たことでWebと合わせてABテストが実施可能になります。

この仕組を実現するためにも検索APIにキャッシュ機構を導入する必要がありました。

キャッシュ導入の検証

検索APIにキャッシュ機構を導入すると前述した複数の課題が解決できますが、下記のような懸念事項がありました。

  • Cache Stampede(キャッシュスタンピード)
  • 2重キャッシュ
  • キャッシュの有効期限
  • 定められたタイミングでの情報反映

以下では、これらの懸念事項とその対策について詳しく説明します。

Cache Stampede

キャッシュが有効期限切れなどで破棄された際に、データ提供元(以下、オリジンと呼ぶ)へのアクセスが瞬間的に集中することで、APIやデータベースの負荷が高まります。 このような現象をCache Stampede(キャッシュスタンピード)と呼びます。

アクセスが少ない場合は特に問題になりません。しかし、検索APIが提供する検索機能はZOZOTOWNの多くのユーザが利用するため、キャッシュが破棄されたタイミングでCache Stampedeの発生が予想されました。

Cache Stampedeの対策

Cache Stampedeの対策はいくつかあります。

  • 別プロセスで事前にキャッシュを生成する(事前作成)
  • 期限切れ前に一定の確率で期限を更新する(期限更新)
  • 裏側のAPIへアクセスするプロセスを絞る(ロック)

今回は、ロック方式を採用しました。任意のタイミングで更新情報を商品結果に反映する検索要件を実現しやすいことと、他マイクロサービスにて安定して運用している実績があったためです。

ロック方式の利点やその他Cache Stampede対策については以下の記事をご参照ください。

techblog.zozo.com

2重キャッシュ

背景で述べたとおり、現状リクエスト元のWebサーバ上で検索APIのレスポンスがキャッシュされています。検索APIにキャッシュ機構を導入すると、既存のキャッシュ処理を廃止するまで両方でキャッシュ処理される2重キャッシュ状態となり、キャッシュ効率の観点で懸念がありました。

しかし、どちらも一度キャッシュしてしまえば次の更新まで高速にレスポンスを返却できるため、この問題は許容出来ると判断しました。実際にリリース後の計測では2重キャッシュ状態でレイテンシは10ミリ秒増加しましたが、運用上は許容範囲内でした。

リリース後、リクエスト元で行われるキャッシュ処理をエンドポイント単位で無効化していき、段階的に2重キャッシュ状態を解消しました。これによりパフォーマンスとしてはレイテンシが数十ミリ秒減少し、大きな効果が見られました。

分散していたキャッシュの統合

パフォーマンス以外にも、ZOZOTOWNではWebとアプリの両方のリクエスト元でキャッシュが分散管理されている状態でしたが、検索APIに統合したことで運用が容易になるといった副次的効果も得られました。

キャッシュの有効期限

キャッシュの有効期限(以下、TTLと呼ぶ)は長ければ長いほどキャッシュヒット率は向上します。一方で、新たな商品やショップが頻繁に追加されるといったオリジンの更新頻度が高い場合には、APIはキャッシュのTTLが切れるまで更新された新しい情報を返せなくなってしまいます。

そこで、API毎に扱うオリジンのデータ更新頻度を調査し、更新頻度に応じた適切なTTLを検討・設定しました。

定められたタイミングでの情報反映

ZOZOTOWNでは、10時や12時などの特定の時間にセールや商品の販売が開始されます。開始直後にこれらの情報を検索結果に反映させるには、意図的にキャッシュを切り替える必要がありました。

例えば、セールの開始時間が12時でキャッシュの有効期限が5分だった場合、11時59分に新たにキャッシュが作成されると、12時04分までセール情報を含まない結果を返してしまいます。

そこで、キャッシュの有効期限とは別にキャッシュキーに有効期間(以下、タイムセクションと呼ぶ)を加えて意図的に特定の時間で切り替えるように工夫しました。

例えば、現在時刻が0分から14分の間であれば、FROM0TO14を加えたキャッシュキーを生成します。15分から29分の間であればFROM15TO29を加えます。このように15分ごとに異なるキャッシュキーが生成されるようにタイムセクションを付与します。

タイムセクションがあることで11時59分にキャッシュが生成された後、12時に同様のリクエストがあっても生成されるキャッシュキーが異なるためキャッシュミスを誘発できます。結果として開始時刻に合わせて最新の情報を反映したキャッシュに切り替えが可能となります。

キャッシュ導入後の構成

キャッシュ導入後の検索マイクロサービスの構成イメージは下図の通りです。一部詳細は省略しています。 検索マイクロサービス構成イメージ

キャッシュストアは他のマイクロサービスでも導入実績があったAWS ElastiCacheを採用しました。同様の理由で、キャッシュエンジンはRedisを使用します。

キャッシュ制御の設計

検索APIはSpring Bootフレームワークを使用しており、"アプリケーション層"と"ドメイン層"、"インフラ層"からなる3層アーキテクチャで構成されています。

各層について、以下の通り責務を分割しています。 - "アプリケーション層":クライアントとの入出力とビジネスロジックを繋ぐ。 - "ドメイン層":複数のビジネスロジックを集約。 - "インフラ層":Elasticsearchやその他マイクロサービスとのやり取り。

このアーキテクチャ内にキャッシュ制御をどのように取り入れるか検討しました。

  • 【A案】アプリケーション層とドメイン層の間
  • 【B案】ドメイン層とインフラ層の間
  • 【C案】A案とB案の両方を採用する

結論として、B案を採用しました。以下では、採用に至った経緯について説明します。

【A案】アプリケーション層とドメイン層の間

A案では、リクエスト毎にキャッシュの有無を問い合わせ、ヒットすればキャッシュからデータを返却できます。一方で、キャッシュが存在しない場合はドメイン層へと処理が移り、その後処理の結果を返却すると同時にキャッシュを保存します。

この案を採用した場合、1リクエストあたり1キャッシュの関係となり設計はシンプルになりそうです。しかし、アプリケーション層が複数のドメイン層でやり取りする場合、複数の処理結果が1つのキャッシュに保存されるため肥大化が懸念されました。

【B案】ドメイン層とインフラ層の間

この案を採用した場合、ドメイン層で処理された結果ごとにキャッシュを保存できます。そのため、A案で問題となっていたキャッシュの肥大化を防ぐことが可能です。

例えば、2つの異なるリクエストがあり、一部を同様のビジネスロジックで処理していたとします。B案であれば、ビジネスロジック毎にキャッシュできるため、共通する処理の結果は1つのキャッシュを流用してそれぞれのリクエストに対して返却できます。このようにキャッシュ対象の粒度が小さくなることでA案の課題を解決すると同時にキャッシュミス減少も期待できます。

【C案】A案とB案の両方を採用する

この案はB案をベースに複数のビジネスロジックを利用する箇所にはA案を採用する良いところ取り設計になります。

この方法であればより効率的にキャッシュを運用できそうです。しかし、3層を横断した煩雑な設計となり、今後の運用コストが高まると考え採用しませんでした。

キャッシュ導入による効果

検索APIにキャッシュ機構を導入したことで、以下の効果が得られました。

  • レイテンシの減少
  • 負荷への耐久性の向上

レイテンシの減少

キャッシュ導入によって、ZOZOTOWNが配信されているWebサーバから検索APIへリクエストされた後、レスポンスが返却されるまでのレイテンシが減少しました。 以下の画像は、リリース前後のレイテンシの様子を表しています。 リリース前後のレイテンシの様子

リリースは15時頃行われ、その後レイテンシはリリース前と比較すると、どのパーセンタイルでも減少が見られました。特に、p99では約30%減少と大きな効果が得られました。

このような効果が得られた要因としては、以下が考えられます。

  • 似たような条件で検索される割合が高い
  • リクエストの量が多い

似たような条件で検索される割合が高いというのは、言い換えるとキャッシュのヒット率が高いことを意味します。実際に、弊チームで利用しているモニタリングツールからヒット率を確認すると高い割合で推移していました。

また、検索APIでは常に膨大なリクエストを受けるため、キャッシュが効果的に働いたことも要因として挙げられます。

APIへのリクエスト数が少ない場合

一方で、APIへのリクエスト数が少ない場合、キャッシュ処理が新たに加わったことで実行時間が増加してレイテンシに影響を与える可能性があります。

この場合、解決策の1つとしてAPI毎にキャッシュ適用の有無を切り替えられるような仕組みを導入する方法が考えられます。幸い検索APIでは今のところこの影響を受けませんが、一部APIでキャッシュ処理に不備があったなどの障害対策としてもこの方法は有効であると考え、導入しました。

負荷への耐久性の向上

検索APIがどの程度の負荷に耐えられるか検証するための負荷試験を実施しました。負荷の規模は、年間で一番大きい正月セールを想定しました。このセールでは、通常時の数倍のリクエストが発生します。

試験の結果、キャッシュ未使用時と比べてレイテンシが大幅に減少し、負荷への耐久性の向上が確認できました。また、一部APIではp50が約60%減少するなど大きな効果が得られました。

工夫した取り組み

キャッシュ機構を導入する上で工夫した取り組みについて、いくつか紹介します。

API毎にTTLを個別設定

キャッシュの有効期限で述べた通り、API毎にオリジンのデータ更新頻度が異なるため、TTLを環境変数上で個別設定できるようにしました。

application.yamlに記述したTTLの例を紹介します。

ttl:
  default-millis: ${REDIS_TTL_DEFAULT_MILLIS:60000}
  api-1-mills: ${REDIS_TTL_API_1_MILLIS:600000}
  api-2-mills: ${REDIS_TTL_API_2_MILLIS:3600000}

default-millisは、TTLの初期値を表しています。単位はミリ秒なので60000は1分を意味します。次に、api-1-millsapi-2-millsはAPI毎のTTLの設定になります。

これらの値は、キャッシュ機構を導入した後に延長対応するなど、キャッシュストアの状況やヒット率を鑑みて最適化を図っています。

キャッシュキーのハッシュ化

キャッシュのキーが長すぎると、メモリ消費やキャッシュ検索の観点で良くないとされています。詳しくは、Redis公式ドキュメントをご参照ください。

そこで以下の実装の通り、MD5を利用してキャッシュキーのハッシュ化を行い、常に固定長となるようにしました。

import org.springframework.util.DigestUtils;
/*
中略
*/
String beforeHsahedKey = "/v1/search/sample_param1&param2_FROM0TO29";
String hashedKey = DigestUtils.md5DigestAsHex(beforeHsahedKey.getBytes(StandardCharsets.UTF_8));
// hashedKey = ea7da06d698ebb8beb84c230e84d698f

実装では、DigestUtilsのクラスのmd5DigestAsHexメソッドを利用してハッシュ値を16進数で表現しています。

この例では、beforeHsahedKeyの値をハッシュ化してea7da06d698ebb8beb84c230e84d698fが得られました。beforeHsahedKeyの値が大きくなっても常に32文字に抑えられます。

キャッシュの圧縮

検索APIは数百件の商品結果やショップ情報など大きなデータを頻繁に扱います。また、リクエスト量も常に多いため、必然的にキャッシュのメモリ消費が問題となりえます。そこで、キャッシュをgzipによって圧縮する仕組みを構築して、この問題を解決しました。

キャッシュ圧縮や解凍の実現方法

次に、実現方法について説明します。通常、Spring Bootではキャッシュを扱う場合、データをシリアライズ化した後にキャッシュとして保存します。一方で、キャッシュからデータを返却する場合、保存されたデータに対してデシリアライズ化します。この中でキャッシュの圧縮や解凍を実現するには、データをシリアライズ化した後に圧縮し、デシリアライズ化する前に解凍処理を行う必要があります。

まとめると、キャッシュ圧縮までの大まかな流れは以下の通りです。

  • 対象オブジェクトをシリアライズ化
  • gzipを使ってシリアライズ化されたデータを圧縮
  • 圧縮したデータを返却

解凍については、圧縮の反対の流れになります。

  • 圧縮されたデータを解凍
  • 解凍したデータをデシリアライズ化
  • デシリアライズ化したデータを返却

実装

これらを実装すると以下のようになります。また、実装はこちらのサイトを参考にしました。

import org.apache.commons.io.IOUtils;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

import javax.annotation.Nonnull;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

@Component
public class RedisCacheGzipSerializer extends JdkSerializationRedisSerializer {
  private final CacheParameter cacheParameter;

  public RedisCacheGzipSerializer(CacheParameter cacheParameter) {
    this.cacheParameter = cacheParameter;
  }

  @Override
  public Object deserialize(byte[] bytes) {
    if (cacheParameter.isGzipEnabled()) {
      return super.deserialize(decompress(bytes));
    } else {
      return super.deserialize(bytes);
    }
  }

  @Override
  public byte[] serialize(Object object) {
    if (cacheParameter.isGzipEnabled()) {
      return compress(super.serialize(object));
    } else {
      return super.serialize(object);
    }
  }

  @Nonnull
  private byte[] compress(@Nullable byte[] content) {
    if (content == null) {
      return new byte[0];
    }

    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) {
      gzipOutputStream.write(content);
    } catch (IOException ex) {
      throw new SerializationException("Unable to compress data", ex);
    }
    return byteArrayOutputStream.toByteArray();
  }

  @Nullable
  private byte[] decompress(@Nullable byte[] contentBytes) {
    if (contentBytes == null || contentBytes.length == 0) {
      return null;
    }

    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    try {
      IOUtils.copy(new GZIPInputStream(new ByteArrayInputStream(contentBytes)),
        byteArrayOutputStream);
    } catch (IOException ex) {
      throw new SerializationException("Unable to decompress data", ex);
    }
    return byteArrayOutputStream.toByteArray();
  }
}

実装では、対象オブジェクトをシリアライズ化解凍したデータをデシリアライズ化などの圧縮解凍に関係ない処理については、既存のキャッシュ処理で用いられていたものを流用します。つまり、既存のクラスJdkSerializationRedisSerializerが提供するserializedeserializeメソッドを利用しています。今回作成したRedisCacheGzipSerializerクラスでは、これらのメソッドをラップして圧縮処理を行うcompressや解凍処理を行うdecompressメソッドを追加しています。

注意点として、参考にしたサイトのコードではdecompressメソッドの引数contentBytesがNullだった場合に問題が起きました。

具体的には、new ByteArrayInputStream(contentBytes)NullPointExceptionが発生したので、これを回避する処理を追加しています。

if (contentBytes == null || contentBytes.length == 0) {
  return null;
}

また、decompressメソッドでは圧縮されたデータの解凍と、解凍されたデータをバイト配列へ書き込む処理をIOUtilsクラスが提供するcopyメソッドで実装しています。このメソッドを利用することで、煩雑化しやすいInputStreamから読み込んだデータをOutputStreamに書き込む一連の処理を簡略化できます。

このクラスを利用するために、以下の依存関係を追加しています。

<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
  <groupId>commons-io</groupId>
  <artifactId>commons-io</artifactId>
  <version>2.11.0</version>
</dependency>

今回、作成したRedisCacheGzipSerializerRedisTemplateで呼び出しました。

@Bean
public RedisTemplate<String, Object> redisTemplate(
  RedisCacheGzipSerializer redisCacheGzipSerializer) {
  RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
  /* 中略 */
  redisTemplate.setValueSerializer(redisCacheGzipSerializer);
  /* 中略 */
  return redisTemplate;
}

使用するSpring BootのRedisプロバイダーによって呼び出し元は異なるので、適宜JdkSerializationRedisSerializerから置き換えください。

キャッシュ圧縮による効果

キャッシュ圧縮によって、以下の効果が得られました。

  • メモリ使用率の減少
  • ネットワーク通信量の減少

gzipの圧縮率は、データや使用するアルゴリズムにより異なりますが60%から80%ほどです。メモリ使用率も同程度の減少が期待出来ます。事実、リリース後のメモリ使用率は以下の通り大幅な減少が見られました。

また、キャッシュの保存や参照時に発生するネットワーク通信量もキャッシュ圧縮に伴って、メモリ使用率と同程度の減少が見られました。

一方で、キャッシュ圧縮処理が加わったことによるレイテンシやCPU使用率の増加を懸念していました。レイテンシについては、前述したネットワーク通信量の減少によって相殺されるため問題になりませんでした。CPU使用率についても同様にリリース前後と比較して変化は見られませんでした。

まとめ

これまでZOZOTOWNの検索機能を提供するマイクロサービスでは、ABテストの実施や負荷の面で課題がありました。本記事では、これらの課題を解決する方法の内の1つとして、マイクロサービスへのキャッシュ導入事例を紹介しました。キャッシュの導入により、課題が解決されただけではなく、レイテンシが大幅に減少するなど大きな改善が得られました。

また、「キャッシュの圧縮」や「API毎にTTLを設定」などの工夫もいくつか紹介しました。これらを取り入れることで、効率的なキャッシュの運用が可能になりました。

その他、効果としてAPIのレイテンシをより意識するようになりました。数ミリ秒を改善するため想定したレイテンシに至らなかった場合はTTLを見直して、最も効果が得られそうな値を模索します。その過程で、レイテンシやヒット率といった指標も以前に比べて確認する機会が増えました。

ZOZOTOWNの検索機能はユーザにとって「求める商品を見つける」ための重要な機能です。日々多くのユーザが利用するため、リクエスト数は膨大です。ほんの少しの改善により大きな効果を得られる可能性があります。キャッシュの導入によって大きな効果が得られましたが、まだまだ工夫の余地は残っています。今後も、より高速な検索をユーザへ提供するために改善を続けたいと思います。

おわりに

ZOZOでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください!

hrmos.co

カテゴリー