登壇者の自己紹介と本日のアジェンダ紹介

江頭宏亮氏:それでは、株式会社サイバーエージェント・江頭から「ABEMAのレコメンドへの大規模アクセスを支えるGo製サーバーの裏側」というタイトルで発表いたします。よろしくお願いします。

「レコメンド」と聞くと、パーソナライズされているという印象を持つ方もけっこういると思います。そういったものと、この大規模配信など多くのユーザーが一度に訪れるユースケースの掛け合わせだと、非常に負荷対策などの面で苦労するんじゃないかな? と思う方もいると思います。

そういったところで具体的にどういった工夫をしたのか、どういった実装を入れることによって、パーソナライズを最大限維持したまま大規模負荷や大規模ユーザーアクセス(への対策)を実現したのかを具体的にお話できればと思います。

まず軽く自己紹介をさせてください。2018年にバックエンドエンジニアとして株式会社サイバーエージェントに入社して、最初は競輪とオートレースの投票券の販売を行う「WINTICKET」というサービスの立ち上げに関わっていました。

2020年からは、コロナ禍もありオフラインでのイベントがけっこう難しい状況になってきたので、この期間に芸能人とファンがオンラインでコミュニケーションを取れるサービスを提供するエンタメDX事業に携わっていました。

そして、2021年11月にABEMAに異動して、今はレコメンドシステムの本体を作ることもあるのですが、主にバックエンド寄りの部分と、サービスをグロースさせるために新機能の実装や既存機能の拡張を担うチームを担当しています。よろしくお願いします。

本日は、まず始めにABEMAのレコメンドシステムの概要についてお話できればと思います。そして、今回Go製サーバーを作ることになった背景、また、その具体的な実装についてお話できればと思います。

一方で、いろいろなレコメンドのロジックやエンジンを持っているのですが、その具体的な実装、機械学習のアルゴリズムや手法みたいなところは、Goの範疇を少し超えてしまう都合で割愛させていただければと思います。

「ABEMA」におけるレコメンドの仕組みとは?

まず「ABEMA」についてですが、新しい未来のテレビとして展開する動画配信事業で、主に2つの形態の動画を扱っています。1つは24時間編成で編成されている専門チャンネル。具体的には、釣りチャンネル、麻雀チャンネル、ニュースチャンネルみたいな、いわゆる僕らが「リニア配信」と呼んでいるような配信形態です。もう1つは、NetflixやU-NEXTなどがやっているような、ビデオ・オン・デマンドのサービス。主にこの2つを提供しています。

その中で、レコメンド機能を提供しています。(スライドを示して)具体的にはこの右側のスクショみたいな感じで、いろいろなコンテンツの推薦を行っています。私たちは、この右側の図の横の並びのことを「モジュール」と呼んでいて、さまざまな切り口でユーザーにとって最適と思われるコンテンツのレコメンドを提供しています。

このレコメンドが具体的にどのようになっているかというと、ユーザーの属性や過去の行動に応じてコンテンツを推薦しています。ここで言う「ユーザーの属性」というのは、例えば年齢だったり性別だったり、過去に視聴したコンテンツだったりみたいなもの……あ、そっか。「過去に視聴した」じゃないですね。何日に初めて訪れたとか、あとは課金ステータスみたいな、そういったものをユーザーの属性として扱っています。

そしてもう1つ、過去の行動に応じても推薦をしています。この過去の行動というのが、いつどのページを訪れたとか、PPV(ペイ・パー・ビュー)の販売もやっているのですが、いつどのPPVを買ったか、直近でどの動画を視聴したかとか、そういった情報を基にコンテンツの推薦を行っています。

そしてこのモジュールごとに異なる特徴量。ここで言う特徴量とは、ユーザーの属性や過去の行動量を使用して実現しています。例えば、(スライドを示して)この右側のスクショの上から2番目の「あなたへの関連度が高い作品」に関しては、そのユーザーが直近で視聴したコンテンツに似ているコンテンツを推薦しています。

レコメンドの具体的な処理のフローですが、2つのフロー、ステップに分かれています。1つが「候補生成」と言われる処理で、(スライドを示して)この上から2番目の「あなたへの関連度が高い作品」であれば、まずユーザーが直近で視聴したコンテンツのIDなどを基に、似ているコンテンツを洗い出す作業を私たちは「候補生成作業」と呼んでいます。

(スライドを示して)この上から2番目のモジュール2はユーザーに視聴を促す、ユーザーの視聴につなげたいモジュールなので、候補生成が完了したあと、候補生成したものからそのユーザーが一番視聴する確率が高いものを機械学習などを用いてスコアを出しして、そのスコアの高い順に左から並べる並び替え作業をしています。この2つの処理を行うことでレコメンドを実現しています。

レコメンドシステムとして「Dragon」と「Yatagarasu」を活用

ABEMAのレコメンドの仕組みは、大きく2つのシステムがあります。1つが左手に表示されている「Dragon」と呼ばれているものです。これは広告配信のような仕組みになっていて、手動でコンテンツの配信設定を行います。それによって、けっこうきめ細かなターゲティングが可能になっています。

例えば、「直近でこのPPVを買った人にこのPPVを推薦したい」とか、「直近でこのコンテンツを観た人にこのコンテンツを出したい」とか。逆に「これを観た人には、もうこれは推薦しない」とか、けっこうきめ細かいターゲティング設定ができるようになっています。

また、Frequency Controlで「1日2回まで表示されるように」など、制限もできるようになっています。このDragonに関しては、並び替えだけで機械学習を用いているため、計算量が比較的少ない範囲で済んでいます。そしてGoで実装しています。

次に(スライドを示して)この右側の「Yatagarasu」と呼ばれるところですが、これは、候補生成から並び替えまで基本的に機械学習をすべて利用しているため、計算量が非常に多くなっています。また、基本的にML・DSエンジニア、マシンラーニング、データサイエンティストチームが中心となって作っているため、Pythonで書かれています。今回このYatagarasuのシステムについて説明しますが、自分たちが作ったGo製サーバー以外の部分は、すべてPythonで書かれています。

Goでプロキシサーバーを開発することになった背景

今回、Go製サーバーを開発することになった背景です。もともとGo製サーバーを使う予定はなかったのですが、リリース前の負荷試験により、想像以上に計算量が多く、非常に多くの計算リソースが必要だということが判明しました。

また、それによってリソースが不足したケースに障害が発生するリスクが高かったり、たくさんサーバーを置くことで障害の発生のリスクは軽減できるのですが、インフラコストが増大してしまうという問題が出てきました。ほかにも、Pythonで作っているので、並行処理をより最適化したいというニーズも出てきたため、今回Goでプロキシサーバーを開発することになりました。

リクエストをまとめたり、キャッシュを導入したりすることでオリジンへのアクセスを削減

具体的にYatagarasuシステムの全体像は(スライドを示して)このようになっています。この右側に見えるところがYatagarasu、マイクロサービスとなっていて、Pythonで書かれています。このマイクロサービスは、基本的にはモジュール、先ほどのレコメンドの目的や種類ごとにマイクロサービスが切られているマイクロサービスアーキテクチャになっています。

今回は、このYatagarasuマイクロサービスへの負荷やリクエストをより削減するために、フロント側にGoでプロキシサーバーを作り、そこでリクエストをまとめたり、キャッシュを導入することにより、オリジンへのアクセスを削減しました。

具体的な処理の流れを説明

ここからは具体的に処理の流れについて説明したいと思います。今回は、キャッシュ処理が入っているので、キャッシュキーの生成を最初にやります。

今回キャッシュは、GoのインメモリキャッシュとRedisを使ったインメモリキャッシュの2層構造にしているので、まずインメモリにリクエストを行って、インメモリになかった場合は、Redisにリクエストを行う。そして、Redisにもなかった場合、初めてオリジンのYatagarasuのシステムへリクエストに行く。こういった構造になっています。そして、Yatagarasuからレスポンスが来たら、それをキャッシュへ保存するという処理になっています。

キャッシュキーの生成

ここからは、より一つひとつの処理について具体的に説明したいと思います。

まず、キャッシュキーの生成ですが、モジュールが使用する特徴量からハッシュ値を生成しています。具体的にこの特徴量は、ユーザーの視聴履歴だったり属性情報だったりするので、簡単にキャッシュのキーを生成することが難しく、ハッシュ値から生成するアプローチを取っています。

そして、キャッシュキーの生成にはxxHashというアルゴリズムを採用しています。これを採用した理由は、下に図を載せたのですが、MD5やFNVと比較しても20〜30倍ほどパフォーマンスが良いので、このxxHashというアルゴリズムを採用しました。

具体的にGoでは、cespare/xxhashというライブラリが存在しており、今回このライブラリを活用して実際に実装に落とし込んでいます。

(スライドの)下にちょっとサンプルコードを書いてみました。実際に利用しているコードとは少し違うのですが、だいたいこのような処理になっています。まず引数にモジュールの名前、attribute、ユーザー属性を受け取り、最初のswitch文でモジュールごとに使用する特徴量を取り出すという作業をしています。

例えばモジュールAであれば、年齢と性別を、モジュールBであれば最後に視聴したエピソードIDを、モジュールCであれば支払いステータスを使用するという感じで、モジュールごとに使用する特徴量が違うので、まずはその特徴量を取り出す作業をします。

これを下のほうで、for文を回してxxHashに入れる。そして最後にhexでエンコードして、文字列として出てきたものをキャッシュのキーとして利用しています。

インメモリキャッシュとRedisキャッシュを導入

次に具体的なキャッシュの部分ですが、オリジンへのリクエストをより減らすために2層構造になっています。もともとはGoのインメモリキャッシュだけを使っていたのですが、どうしてもデプロイ時、ロールアウト時に揮発してしまうため、一時的にオリジンへの負荷が上がってしまうという課題がありました。そこを改善するためにRedisも導入して2層構造になっています。

Goのインメモリキャッシュは、DgraphのRistrettoというライブラリを活用しています。もともとはgo-cacheという、LRUがなく基本的にはTTLだけを指定できるシンプルなキャッシュを利用していました。

しかし、パーソナライズされたレコメンドのものをキャッシュする都合上、かなりバリエーションや種類があり、単純にTTLだけで載せてしまうとメモリが何ギガあっても足りないみたいな、OOMになるリスクもあったので、途中からLRUのDgraphのRistrettoのライブラリを使うように変更しました。Go製のLRUは調べるとけっこういろいろあったのですが、TTLも使う必要があったので、今回はこのライブラリを選定するに至りました。

次に、Redisへのリクエストにはライブラリとしてgo-redisを利用しています。RedisはRedis ClusterとかRedis Sentinelとか、いろいろなクラスタリングや構成があると思いますが、今回はConsistent Hash Ring構成を採用したかったのでこのgo-redisを採用しました。

go-redisはRingクライアントというクライアントが実装されており、このRingクライアントはRedisのノードへ定期的にヘルスチェックを行い、アンヘルシーな場合はRingから勝手に抜くこともきちんとやってくれる実装になっています。具体的に、リリース前に障害試験をやったところ、万が一Redisが1台ダウンしてもユーザーに影響がなくレスポンスをきちんと返せることが確認できています。

また、インフラの話になってしまうのですが、ABEMAの本体のプログラムはほとんどGoogle Cloudで動いているので、RedisはMemorystore for Redisを利用しています。今回は単純なキャッシュを載せるだけだったので、冗長構成ではなくBasic Tierを採用しています。これによりコスト的なメリットも生まれています。

そして、Redisへ書き込む時のエンコーディングのフォーマットですが、Goの標準パッケージのgobを利用しています。Redisとかに載せるデータのエンコーディングの手法としては、JSONやProtocol Bufferなどさまざまありますが、今回はシンプルなものを採用したかったので、標準パッケージのgobを利用しています。

この下の部分に、具体的なgobの実装のサンプルのコードを書いてみたのですが、エンコードの部分は、bytes.bufferを定義・作成して、それをgobのNewEncoderに入れて、Encodeメソッドにmoduleのstructをそのまま渡すだけでエンコードが完了します。このようにむちゃくちゃシンプルな実装ができるので、今回はgobを採用しました。

下にデコードの例も書いているのですが、byte配列、byteのスライスがあった時に、それをReaderにしてデコーダーに入れて、Decodeにdestinationとなるstructを入れればデコードもできます。こんな感じでめちゃくちゃシンプルなので、これを採用しました。

2層のキャッシュの具体的な実装例

そして2層のキャッシュの具体的な実装例がこのようになっています。まずinmemoryからGetして、あればそれを利用して、なければRedisを見に行って、RedisにあればそれをinmemoryにSetして、それを返す。Redisにもなければキャッシュミスというかたちで処理をするという、実装になっています。

(次回へつづく)