キャッシュのメリットとデメリット

藤井力哉氏(以下、藤井):続いて、キャッシュの説明と今回の実装で利用した簡単な例を説明します。(スライドを示して)まずはキャッシュに関してです。キャッシュは取得に時間がかかるデータを繰り返し使い回す手法になっています。メリットとしては、レスポンスを高速に返せたり、データベースやサーバーの負荷を減らせることです。

デメリットもあります。デメリットは、キャッシュされるデータが古いデータになるので、長時間キャッシュを行うと不整合が発生してしまう可能性もあります。なので、システムにキャッシュを導入する際には、システム要件やデータの特性などに応じて、キャッシュの使い分けやキャッシュの有効期限の設定を行う必要があります。

キャッシュにはさまざまな種類がある

(スライドを示して)また、キャッシュにはさまざまな種類があります。クライアントアプリにキャッシュするローカルキャッシュ、あとはFastlyやCloudFrontなどを用いたユーザーに一番近いエッジサーバーにキャッシュを行うCDN。Webサーバー内のメモリにキャッシュするインメモリキャッシュ。今回のTwitter動画シェアでは、動画データをインメモリにキャッシュしました。簡単な実装例などは後ほど説明します。

その他に、Redisなど外部の高速なデータベースにキャッシュする手法である外部キャッシュもあります。さまざまなキャッシュの種類・戦略がありますが、これらは目的に応じて使い分ける必要があります。例えばCDNは、パーソナライズされたデータを介する時は相性が悪かったりします。また、パフォーマンス向上のためにCDN、インメモリキャッシュの併用など、多段キャッシュを行う場合もあります。

今回Twitter連携の動画部分でインメモリキャッシュの実装をしましたが、その際に利用したライブラリはgo-cacheです。go-cacheは、memcachedに似たインメモリのkey-valueストアです。有効期限付きのスレッドセーフなkeyがstring、valueがインターフェイスのmapになっています。インメモリキャッシュを導入することで、オリジンであるデータベースのアクセスを減らすことができ、高パフォーマンスなアプリケーションの構築が実現できます。

インメモリキャッシュを用いた実装例

ここからは、インメモリキャッシュを用いた簡単な実装例をお見せしながら活用方法を紹介します。今回示す例では、KVSというインターフェイスを用意しており、SetとGetのメソッドを定義しています。実装は、文字列をキャッシュするインターフェイスになっていますが、Twitter動画切り出しの実装の際には、動画を保存するようにしていました。SetとGetの実装例は、次のスライドでお見せします。

この実装では、NewKVSという関数で初期化処理を行っています。初期化処理を行う際にgo-cacheで提供されているNew関数を使用しています。cache.New()を呼び出す際には、第1引数でデフォルトの有効期限を指定して、第2引数でキャッシュがパージされる間隔を指定しています。

続いて、SetとGetの実装です。go-cacheから提供されているSet関数を利用してインメモリキャッシュを行っています。Set関数を利用する場合、自分でインメモリキャッシュに保持する期間を指定できます。また、下にコメントも書いてあるのですが、SetDefaultという関数も提供されていて、これを利用する場合、New関数で設定したDefaultExpirationの期間、インメモリキャッシュに保持できます。

インメモリキャッシュから取得する際には、go-cacheから提供されているGet関数をkeyを指定して呼び出しています。Getからは第1引数でinterface型、第2引数でbool型が返ってきます。キャッシュから取得できた場合、この例だとokという変数にtrueが入り、取得できなかった場合はfalseが入ります。取得ができた場合は、第1引数はinterface型になっているので、保存している型でキャストをして結果を返す必要があります。

go-cacheを利用することで、このように非常にシンプルなインメモリキャッシュの実装が実現できます。

実際によく使われる手法を、シーケンスを用いて簡単に説明します。まず、クライアントからリクエストが来た際には、キャッシュからの取得を試みます。キャッシュから取得できた場合はデータベースを参照する必要がないので、取得したデータをクライアントへ返却します。キャッシュから取得できなかった場合は、オリジンであるデータベースからの取得をしに行きます。

データベースから取得できた際には、次回以降キャッシュから取得できるようにキャッシュへ保存します。このような実装を行うことで、オリジンであるデータベースへのアクセスを減らすことが可能です。

syncパッケージで提供されているsync.Pool

続いて、sync.Poolについて説明します。(スライドを示して)sync.Poolは、Goの標準パッケージであるsyncパッケージで提供されている機能の1つです。sync.Poolを利用することで、すでに割り当てられたメモリを解放せずに保持しておけます。メモリが必要な時、保持しているメモリを使い回すことが可能です。sync.Poolを使用することでGCやメモリのアロケーションコストを削減できます。

ここからは、実際のGoのコードを用いて説明していきます。(スライドを示して)sync.Poolを使って初期化する際は、このようなかたちで初期化処理をします。pool.Get()で保持したメモリから取得を試み、なければ新規作成されます。そしてpool.Put()で割り当てられたメモリを保持できます。メモリに保持する前にデータの中身をリセットする必要があるので、メモリを保持する前にリセットをします。

この例ではstringのsliceを例にお見せしましたが、今回は動画を扱っていたのでbytes.Buffer()を利用しました。

実際にsync.Poolを使った時と使わない時でのパフォーマンスを比較するために、簡単にベンチマークを取ってみました。(スライドの)左側にsync.Poolを使わないBenchmarkWithoutPoolと、(スライドの)右側にsync.Poolを利用したBenchmarkWithPoolというベンチマークを用意しました。実行すると、このような結果が得られました。

実行した結果、アロケートした数は変わらなかったのですが、sync.Poolを利用したほうが1回あたりの実行時間のパフォーマンスが、sync.Poolを利用しない時に比べてかなり良いという結果を得ることができました。また、メモリの割り当てに関してもsync.Poolを利用したほうが少なく済んでいるという結果を得られました。

まとめ

最後にまとめです。今回、コメント機能におけるTwitter連携機能、動画シェアの動画切り出し部分は、HLSのパースや生成処理、ffmpegを用いて実現しました。また、インメモリキャッシュを利用することでオリジンのデータベースへの負荷を削減したり、sync.Poolを利用することでGCとかメモリのアロケーションコストを削減してパフォーマンスの向上を図りました。

インメモリキャッシュやsync.Poolはシンプルな実装で、比較的簡単に導入ができると思うので、興味がある方はぜひ使ってみてください。今回の僕の発表は以上です。ご清聴ありがとうございました。

(会場拍手)

司会者:藤井さん、発表ありがとうございました。

藤井:ありがとうございました。

失敗した時のハンドリングにおける工夫とは?

司会者:Twitterでも、「この動画の話が聞きたかった」や「やはり動画の話はおもしろい」という反応もすごくあって、僕自身も聞いていてすごくおもしろかったです。ありがとうございます。今回も質問がたくさん来ているので拾っていこうかなと思います。

1個目です。先ほど見せていただいたフローチャートは、動画をキャッシュしてツイートを投稿するフローだと思いますが、例えば途中でキャッシュを作成するのを失敗しちゃったり、ネットワーク的なエラーでこけちゃったりするケースも実際に運用していると当然あると思います。失敗した時のハンドリングもけっこう工夫しているんでしょうか?

藤井:そうですね。シーケンスでお見せしたように、キャッシュから取得できなかった場合はオリジンであるデータベースからの取得を試みているので、失敗した時もオリジンのデータベースを見に行くようにしています。TwitterのAPIに関しても必ず成功するわけではないので、リトライ処理を入れたり、そういうケアなどは行っています。

司会者:なるほど。ありがとうございます。

ユーザーのリクエストには依存していない処理になっている

司会者:セッション中、何回か「ffmpeg」というキーワードが出てきました。Twitterや質問フォームでもffmpegに関する質問が来ているので拾います。ffmpegをos/execのライブラリでコマンドとして実行していると思うのですが、あれはリクエストごとにまずプロセスというか実行しているという認識で合っていますか? ユーザーが1回ツイートするという単位で。

藤井:動画の切り出しに関しては非同期なワーカーで行っているので、ユーザーのリクエストには依存していない処理になっています。

司会者:なるほど。ありがとうございます。リクエストごとに生成されていたら負荷がやばいんじゃないかなという質問が来ていたので、お聞きしました。

藤井:なるほど。

司会者:ということはパフォーマンス的には裏でWorkerとして非同期で回しているので、大丈夫という感じですかね?

藤井:そうですね。切り出した動画をツイートする際に取得しに行っているだけになっています。

司会者:なるほど。ありがとうございます。

自分でコマンドラインを生成して、os/execで実行したほうが利便性が高い

司会者:僕はffmpegに詳しくないのですが、その他にも来ています。バインディングしたライブラリみたいなものはそもそもあるんですか? ないので今回os/execでコマンドをして実行した感じなんですかね?

藤井:確か……。

司会者:調べた当時はなかったみたいな?

藤井:そうですね。ライブラリはあったという記憶はあるんですけど、ちょっと名前は覚えていないです。でも、そのいろいろなオプションを使いたいとなった時に自分でコマンドラインを生成して、os/execで実行したほうが利便性が高いかなと思っているので、今回この方法を使った感じです。

司会者:なるほど。ちなみに僕、「Goのライブラリってないのかな?」と気になって、先ほどの発表中に調べたんですよ。そのライブラリはあったんですけど、スター数もけっこうあって。あったんですが、結局それは内部ではos/execを使っている感じだった。ラッパーしたライブラリという感じだったので、やはりそういったフォーマットになるのかという感じですかね。

藤井:そうですね。自分でやったほうが確証性が高いかもしれないです。

キャッシュヒット率を高くするために工夫していること

司会者:あとは、これは個人的な意見ですが、動画処理や画像処理に関しては、ImageMagickをバインディングしたライブラリもGoにはありますが、それは結局cgoがネイティブで使うために必要だったり、素でラップしないで使うとなるとcgoが必要だったりするかもしれなくて、ライブラリ作成者も普通にexecでラップしたケースもあるかもしれないですね。

(質問を見て)ありがとうございます。先ほどの江頭さん(江頭宏亮氏)のセッションでも似たような質問があったのですが、ツイート用動画の取得時にキャッシュから取得するというお話が先ほどあったと思いますが、そのヒット率を上げる工夫をなにかされていますか?

藤井:そうですね。今回、動画切り出しのツイート自体はその投稿時間、任意の動画の再生時間でファイル名を切り出しています。例えば同じ時間に同じユーザーが投稿してツイートする際には、そのタイムスタンプで動画を取得しているので、その際にはキャッシュヒット率が高くなるような工夫をしています。

司会者:なるほど。

負荷的な問題を解決するために非同期で動画を切り出した

司会者:ちょっと時間が押してきているので、もう1、2個拾って終わりにしようかなと思います。(質問を見て)ABEMAの普通のユーザーですかね。コメントをTwitterに動画付きで投稿する機能は前からあったと思いますが、今回発表していただいた内容は新しい機能として作られているんですか?

藤井:今回はまったく新しいものになっています。これまであったものは、コメントを投稿する際に同期的に動画を切り出してツイートをしており、負荷的な問題がけっこうあったので、今回のイベントでは利用ができませんでした。その課題を解決するために非同期で動画を切り出したという感じですね。

司会者:なるほど。じゃあ1個目の質問の「リクエストごとにexecする」みたいなのがフォーマットだったみたいな。

藤井:そうですね。

司会者:なるほど。ありがとうございます。それで今回は先ほどのフローで新しく作り直した?

藤井:そうです。

司会者:今回はまさか「2日で作った」とかではないですよね?

藤井:今回はそうですね。

(一同笑)

司会者:なるほど! ありがとうございます。きちんと実装の時間は取ったと。

藤井:はい(笑)。そうです(笑)。

司会者:ありがとうございます。というわけでお時間も来たので、すみません。たくさん質問をいただいていますが、いったんここで切らせていただこうかなと思います。それでは藤井さん、あらためて発表ありがとうございました。

藤井:ありがとうございました。

(会場拍手)