pixivでキャッシュサーバを作るインターンに行ってきた

2025年2月18日から2月28日にかけて開催された ピクシブ株式会社 のインターンに参加しました。

今回はその様子を成果発表の資料を交えつつお伝えします。

pixivのインターンについて

インターンはプロダクトごとにコースが分かれており、各コースごとの課題に10日間で取り組み最終日にその成果を発表するというものでした。

私はピクシブとさくらインターネットが共同で提供しているクラウドサービス ImageFlux のチームに配属されました。

10日間は、基本的に毎日千駄ヶ谷にあるオフィスへ出社し課題を進めます。オフィスへ出社するのは負担ではありますが1、課題以外の会話 2 をしたり配属コース以外の社員の方と会話できたのはとても楽しかったです。

ホワイトボードを使い会話する様子

ImageFluxとは

まず初めに私が配属されたImageFluxというプロダクトについて説明します。

ImageFluxには「画像変換・配信エンジン」と「ライブ配信エンジン」の2つのサービスが存在します。私は主に画像変換配信エンジンのほうに関する課題に取り組みました。

画像変換・配信エンジンでは、リクエストに応じてオンデマンドで画像の変換・キャッシュをクラウド上で実行するサービスとなっています。

ImageFluxの画像変換機能は、ストレージから画像を取得し、拡大・縮小、切り抜き、回転、フォーマット変換といった画像処理を行った上で、キャッシュして配信するサービスです。ImageFluxドキュメントより

以下でImageFluxと言及した場合は基本的に画像変換・配信エンジンを指します。

取り組んだ課題

私はImageFlux内で利用されている、変換後の画像をキャッシュするキャッシュサーバのプロトタイプを作る課題に取り組みました。

このキャッシュサーバが変換済みの画像をキャッシュ(一時的に保存)することで、時間や負荷のかかる画像変換処理の実行回数を減らすことができます。

ImageFluxでは現在サードパーティ製のキャッシュサーバミドルウェアを利用しています。しかし当該ミドルウェアの制約により、現状URLの一括削除・一括検索ができないという課題がありました。

そこで今回のインターンの課題として次のようなものを作成することになりました 3

  1. キャッシュ付きのプロキシサーバを作る
  2. キャッシュの前方一致による一括削除・一括検索機能を作る

キャッシュ付きプロキシサーバの開発

キャッシュ付きプロキシサーバについて、初期段階ということでストレージへのIOは考慮せずオンメモリでよいということになりました。そこでひとまずマルチスレッドで対応するためのMutexとキーとレスポンスを置くためのMapだけを持つ単純な構造体をキャッシュとして利用することにしました。

Cache-Controlヘッダーをパースする

今回作成するようなキャッシュ機能付きのプロキシの仕様についてはRFC9111 HTTP Cachingにて定められています。

ここではRFC9111を読み込んでやステータスコードやCache-Controlヘッダーの内容をパースして、レスポンスをキャッシュするかどうかを決定するコードを書きました。

また、キャッシュを利用してレスポンスを返すか、オリジンに問い合わせするかどうかについてもRFC9111にて定められています。これは、キャッシュした内容が変化した場合は新しい内容をキャッシュし直すための仕様です。

もしキャッシュが期限切れとなった場合にはETagIf-Matchヘッダー付きのHEADリクエストを使い検証することで、コンテンツを更新するかどうかを効率的に決定するようにしました。

キャッシュが溢れないようにする

次にキャッシュできるレスポンスの量に制限を設けるよう実装しました。

キャッシュのロテーションでは、よく使うレスポンスをキャッシュし続け使わないコンテンツのキャッシュを破棄したいです。このような検討はウェブのキャッシュだけでなくCPUのキャッシュなどでも似たようなことが考えられています 4

今回はWebでのキャッシュで効率的とされているSIEVEアルゴリズムを利用することにしました。

https://cachemon.github.io/SIEVE-website/assets/images/illustrations/sieve_diagram.gif https://cachemon.github.io/SIEVE-website/より

SIEVEアルゴリズムは非常に単純なので実装が簡単でした。

配列をやめて連結リストを使うようにする

キャッシュが溢れないようにするSIEVEアルゴリズムを実装するとキャッシュの保存に明らかに時間がかかるようになりました。そこで原因を調べるとキャッシュ済みのキーを保持する配列から要素を削除する際に巨大なメモリの再配置が起こっていることがわかりました。

そこで削除・挿入が早い連結リストに実装を書き換えたところ、20倍の速度向上を得ることができました。

キャッシュの削除の実装

次にキャッシュを前方一致で削除するタスクに取り組みました。

例えば DELETE /a/b とリクエストすると次のキーに一致するキャッシュがすべて削除されるというものです。

  • /a/b
  • /a/b1
  • /a/b/c

まず初めに愚直な方法としてMapのキーから一致するものを列挙して、それらをすべて削除するように実装しました。

しかしこの方法だと、削除するためにforループを回している間はMutex.Lockしているため、他のリクエストによるキャッシュの読み書きが待たされてしまうという問題がありました。

この問題を根本的に解決するためには、そもそも一つのMapに保存することをやめる必要があると考えました。5 キャッシュを保存するMapを分割することで削除の際にロックする必要がある範囲を減らすことができると考えました。

今回はトライ木(正確には基数木)というデータ構造を使い、Mapを分割することにしました。これは検索・挿入・削除がO(k)(kはキーの長さ)で実行できることが知られています。

ベンチマーク

ここまで実装してきたキャッシュの作成・取得のベンチマークは愚直に6取ることができたのですが、今回実装した削除については先程も紹介したように他のリクエストとの競合を測る必要がありました。

そこで削除については次の割合でリクエストを並列処理するベンチマークを作成しました。

  • キャッシュの取得: 70%
  • キャッシュの保存: 20%
  • キャッシュの削除: 10%

実際に実行すると単純なMapでキャッシュを保存した場合と比較して、トライ木を利用すると50倍ほどの速度が得られることがわかりました。

キャッシュの一括検索

最後にキャッシュの一括検索を実装しました。これはほとんどキャッシュの削除と同じような実装でできることが想像つくかと思います。

しかし、実装は削除とほとんど同じにもかかわらず先ほどと同様のベンチマークを実行すると明らかに検索のほうが遅いことがわかりました。

そこでどの処理が遅いのかを特定するためにプロファイリングを実行しました。その結果memmoveが遅いということがわかりました。

これは見つかったキーを保存する際に、配列を拡張して挿入する操作を繰り返していることが原因でした。そこで、取得できる件数に上限を設けて最初から上限分メモリを確保し配列の拡張が必要ないように実装を変更しました。

しかし、この変更を加えてもあまりパフォーマンスは改善しませんでした。

詳しくプロファイルを見てみると、使用済みのメモリを削除する処理(GC)に時間が取られていることがわかりました。そこで、そもそもベンチマークのコード自体が正しいのかどうかを調べることにしました。

ベンチマーク内の処理として時間がかかる可能性が否定できない箇所として「キャッシュサーバに問い合わせるための検索クエリを生成する部分」と「Go言語が自動的に行う不要になったメモリを削除(GC)する処理」の2箇所が考えられました。

そこで、それぞれの処理をベンチマークの測定対象に含めたり外したりすることでどの程度ベンチマーク結果に変化があるのかを調べることにしました。しかし、ベンチマークコードの違いによるベンチマーク結果の差は小さいことがわかりました。

次に遅いことが予想された箇所として一括検索時に再帰的にキャッシュを検索していることが原因ではないかという仮説を立てました。再帰的に検索すると、その間検索している木の部分の書き込みをブロックしていることでその他の書き込みが必要な処理を待たせていることが原因ではないかと考えました。

Go言語のプロファイリングツールpprofには非同期処理における待ち時間を分析する機能があります。これを使い非同期処理によるパフォーマンスへの影響を調査することにしました。

すると、キャッシュの保存処理がキャッシュの削除処理を累計で4.8sも待たせていることがわかりました。そこでロックの仕方を工夫し、待ち時間を減らす工夫をしてみました。

しかし、この変更を加えるとさらに待ち時間が増えることがわかりました。一方ベンチマークの方を見ると一回あたりの実行時間は増えており良くわからない状態となっていました。

改めてベンチマークの仕様について考えてみるとこの謎は解けました。Go言語のベンチマーク(testing.B)は1秒間で可能な限り処理を実行しその実行回数を記録します。ここまで見てきた処理の実行時間は、つまり 1/(1秒間の実行回数) となっています。

一方でpprofで得られる待ち時間はベンチマーク実行中の待ち時間をすべて合計したものとなっていました。つまり、待ち時間が改善されるとその分実行可能回数が増えるため、合計待ち時間は更に増えてしまうため悪化したように見えているのでした。

もう一度、プロファイルを見直すとメモリの確保部分が遅くなっていることがわかりました。メモリの確保に関しては、上限を設けることで先ほど改善したはずでした。しかし、このときに確保しているメモリの量が大きく確保に時間がかかっていることが時間がかかっている原因と思われました。

そもそも、検索処理でははじめに検索結果(見つかったキャッシュのキーの一覧)を一時的に保管するためのメモリを確保し、検索が完了したらレスポンスとして返すという仕様となっていました。

しかし、よく考えると別に検索結果を一時的に保管する必要はないのではないかと思いました。つまり、検索中に見つかったものから順に返していけばよいのではないかとか考えました。7

これを実際に実装してみると、大幅にパフォーマンスが改善することがわかりました。

まとめと振り返り

最終的な各操作のパフォーマンスは次のようになりました。

正直自分はパフォーマンス計測を通じた性能の改善といったタスクははじめてだったのですが、親切なメンターの方々のおかげで、楽しくこなすことができました。

また、キャッシュサーバを自作するというテーマ自体も自分にとてもあっており8、比較的スピード感を持って取り組むことができたと思います。

インターン全体を通じて課題に関すること・課題と関係ないこと含めて幅広い人とお話9することができたことで自分自身成長することができたと思います。

pixivでは長期のインターンも募集しているそうなのでもし興味がある方がいればぜひ応募してみてはいかがでしょうか?

www.pixiv.co.jp


  1. pixivには住宅手当制度がありこれを利用してオフィスの近くに住んでいる方が多い印象でした 福利厚生 - ピクシブ株式会社
  2. 課題と関係なく技術の話をすることができたのが個人的には一番楽しかったです。
  3. インターン開始時点ではほとんどコードが置かれていないリポジトリを渡されて面白かったです。
  4. Cache replacement policies - Wikipedia
  5. 分散ハッシュテーブルなどを使うことでも解決することができるかもしれないがこれもある意味でMapを分割していると言える気がする
  6. Go言語には標準でtesting.Bというベンチマークツールがあるのでこれを使います
  7. 見つけたら順番にコネクションに対して書き込み続ける
  8. 昨年はメールサーバを作っていてその経験が割と活きたと思いました
  9. pixiv本体のインフラの話や、情報科学全般に関わる話が非常に楽しく終業後も残ってお喋りしていました