こんにちは、BOOTHチームでサーバサイドエンジニアをやっているRNDです。 今回はBOOTHの「あなたにおすすめのショップ」機能を実現するために、 BigQueryを使った話を書きます。
BOOTHの「あなたにおすすめのショップ」について
去年の8月頃、BOOTH では「あなたにおすすめのショップ」という機能をリリースしました。 トップページに、「あなたにおすすめのショップ」としてユーザーごとにレコメンドされたショップが表示されます。
2020年1月現在、「あなたにおすすめのショップ」は ユーザーがイラストコミュニケーションサービス「pixiv」でフォローしているクリエイターのショップを表示する仕様になっています。
この仕様はレコメンドと呼ぶにはあまりにシンプルな仕様ですが、 「あなたにおすすめのショップ」のファーストリリースに際し、 効果測定が難しい高度なレコメンドアルゴリズムを適用するよりも前に まずユーザーが興味を持っていることが明らかな「pixivでフォローしているクリエイターのショップ」を確実に表示することを優先したためこのような仕様になっています。
その代わり、将来的にアルゴリズムの変更に柔軟に対応できるようレコメンドの基盤の構築に主眼をおいて開発を進めました。 今回の記事ではこの「あなたにおすすめショップ」の基盤となる仕組みについて紹介します。
pixivのデータを使ってBOOTHのショップを推薦するには
あるショップがおすすめショップに表示されるかどうかの判定は
- pixivでフォローしているユーザーである
- BOOTHで未フォローのショップである
- BOOTHでショップのpixiv連携を許可している etc...
といったふうに、 BOOTHのデータとpixivのデータの双方に依存しています。 これを実現するにはBOOTHとpixivのサービス間をまたいだデータ連携の仕組みが必要です。
サービス間データ連携
BOOTHでpixivのフォロー情報を使ってレコメンドを行いたい、となったとき pixiv側にフォロー情報を取得するAPIを生やしてそれを利用する方法がまず考えられます。
しかしこの方法は
- BOOTHからpixivへ大量のAPIアクセスが発生するため、pixiv側の負荷を気にする必要がある
- レコメンドの計算をBOOTHのRailsアプリケーション上で行う必要があり負荷を気にしないといけない
- pixiv側の他のデータをおすすめに使いたくなった時はAPIアクセスがどんどん複雑になってしまいレコメンドのアルゴリズムの変更が容易でない
等、保守性について考えることが多く採用を見送りました。
BigQueryを用いたサービス間データ連携
今回、上記の問題を解決してサービス間データ連携を行うためにBigQueryを用いました。
ピクシブではデータ分析基盤としてBigQueryを用いており pixivのデータもBOOTHのデータも BigQuery上のデータセットにアップロードされ同期される仕組みを運用しています。 そのためpixivのデータもBOOTHのデータもBigQuery上からアクセスすることができます。
BigQueryを用いた方法では以下の3つのバッチスクリプトを順に実行することでレコメンドを実現します。
- pixiv, BOOTHのDBに入っているデータを定期的にBigQueryにuploadする
- BigQueryでレコメンド結果を計算し、テーブルに保存するクエリを実行する
- BigQuery上のレコメンド結果のテーブルをBOOTHのDBに読み込む
このうち、1は既に実現できていたので今回のリリースでは2と3を実装しました。
この方法では
- BoothからpixivへのAPIアクセスは発生せず、APIアクセスはBOOTHからGCP(BigQuery)に対して行われるためpixiv側の負荷を気にしなくてもよい
- レコメンドの計算はGCP上で行われるため負荷はそこまで気にしなくてもよい
- レコメンドのアルゴリズムを変更したい時はレコメンドの計算のSQLを書き換えるだけでよい
- BOOTH側ではBigQuery上のおすすめショップを読み込む時の負荷だけを気にすればよい
と、考えることが減ります。
また、BigQueryはpixivでのレコメンドですでに使用されており レコメンドの知見がサービスを横断して流用しやすいこともメリットの一つにありました。
RailsアプリケーションにBigQuery上のテーブルを読み込む
今回、BOOTHのRailsアプリケーションにBigQueryのテーブルを読み込むにあたって、 データのサイズが大きいことが課題になりました。
pixivのユーザー数は4000万以上、 BOOTHのユーザー数もpixivほどではないにしてもかなり多いので、すべてのユーザーに対しておすすめショップを算出・保存するのは経済的ではありません。
それでもなるべく多くのユーザーに対しておすすめショップを表示するため ディスク容量の削減とBigQueryからテーブルを読み込む際の負荷について考えなければいけません。
以下では、BOOTH(Ruby on Rails(5系)、MySQL)で気をつけたことについて書きます。
- ディスク容量を削減する
- 1ユーザーに対しておすすめするショップのIDをバイナリ列で保存する
- テーブルの取り込みの負荷を軽減する
- BigQueryから一度に読み込むレコードの数を制限する
- bulk insertする(rails 6 以降は insert_all, upsert_all でできるようになる)
ディスク容量を考慮したテーブルの設計
データ容量を削減するため おすすめショップのIDのリストをシリアライズして一つのカラムにバイナリ列で保存しました。 RDBのお作法的にはアンチパターンですが、 今回は要件とデータ容量のコストを鑑みてこの方法を採用するのが合理的と判断しました。
わかりやすいように以下にmigrationのサンプルを載せます。 ※サンプルは ruby 2.6.5, rails 5.2.4.1, DBはMySQLで作成しました
1. モデルの作成
% rails g model Recommendation uid:bigint:index recommended_id_list:blob
2. migration書き換え
class CreateRecommendations < ActiveRecord::Migration[5.2] def change - create_table :recommendations do |t| + create_table :recommendations, id: false do |t| # idの代わりにuidを使う - t.bigint :uid + t.bigint :uid, null: false, unique: true - t.blob :recommended_id_list + t.blob :recommended_id_list, null: false - t.timestamps + t.datetime :updated_at, null: false # created_atは不要 end end - add_index :recommendations, :uid + add_index :recommendations, :uid, unique: true + add_index :recommendations, :updated_at, unique: true end
3. migration
% rails db:create % rails db:migrate
4. packとunpackによる書き込みと読み込み
参考:pack テンプレート文字列(Ruby 2.7.0 リファレンスマニュアル)
ids = [1,2,3] #=> [1, 2, 3] #書き込み recommendation = Recommendation.create(uid: 1, recommended_id_list: ids.pack('Q<*')) #=> #<Recommendation uid: 1, recommended_id_list: "\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00", updated_at: "2020-01-15 09:56:20"> #読み込み recommendation.recommended_id_list.unpack('Q<*') #=> [1, 2, 3]
BigQueryから一度に読み込むレコードの数を制限する
前述したように、DBの容量だけでなくBigQueryからデータを読み込む際の負荷も課題となります。
今回はgoogle-cloud-bigqueryを使い BigQueryからレコードを読み込みDBに保存するバッチを書きました。
google-cloud-bigqueryはGoogleが提供している BigQueryを扱うためのライブラリです。
google-cloud-bigqueryを利用して大量のレコードの読み込む時の方法は以下のような感じです。
google-cloud-bigqueryの導入
サービスアカウントの認証キーを発行する
https://cloud.google.com/docs/authentication/getting-started?hl=ja
秘密鍵が入ったjsonが発行されるので大事に保管する
Gemfileにgoogle-cloud-bigqueryを追加する
config/initializersに以下を追加する
require "google/cloud/bigquery" # google-cloud-bigqueryの設定 Google::Cloud::Bigquery.configure do |config| config.project_id = 'プロジェクトID' config.credentials = '認証キー(json)のPATH' end
google-cloud-bigqueryを使ってテーブルを分割して読み込む
以下にサンプルコードを示します。
BQ_DATASET_ID = 読み込むデータセットID BQ_TABLE_ID = 読み込むテーブルID FETCH_RECORDS_PER_REQUEST = 一回のリクエストでBigQueryから取得するレコードの数 bigquery = Google::Cloud::Bigquery.new dataset = bigquery.dataset(BQ_DATASET_ID, skip_lookup: true) table = dataset.table(BQ_TABLE_ID, skip_lookup: true) data = table.data(max: FETCH_RECORDS_PER_REQUEST) loop do data.each do |record| # record= { カラム名: 値, ... }に対する処理 end break unless data.next? data = data.next end
BiqQuery上のテーブルのデータは
dataset = Google::Cloud::Bigquery.dataset(データセットID) table = dataset.table(テーブルID) records = table.data() # BQ上のレコードにアクセスするためのオブジェクト
で取得できます。
この時に
records = table.data(token: ページトークン, max: 1リクエストあたりのレコード数)
で読み込みを開始するページのトークンと一度に読み込むレコードの数を指定できます ここでmaxを指定しないと毎回10万件ずつレコードを読み込んでしまい、メモリを圧迫します。
ページネーションは以下の書き方で行います。
records.each # レコードを順番に読む records.next # 次のページを読み込む records.next? # 次のページが存在するかを返す records.token # 現在のページトークンを取得する
おすすめショップでは、Jobのリトライ時に途中から読み込めるように1ページinsertし終わるごとにページトークンを取得しRedisに保存しています。
書き込み負荷を考慮してinsertする
1レコードのinsertごとにクエリを発行するとユーザー数分の書き込みが発生してしまうので、数百~数千recordを1回のクエリでまとめてinsertします。(bulk insert)
rails 6以降ならinsert_all, upsert_all、rails 5以前ならactive-record-importを使用するなどですね。
Railsアプリケーション側での表示の制御
おすすめショップに表示されるショップはショップがpixiv連携を設定しているか、 ショップが非公開になっていないかなどの条件によってフィルタリングされています。
BigQueryでおすすめショップを計算する際に これらの条件によってフィルタリングする処理を行いますが、 BigQueryとBOOTH側の同期はバッチで行われるので これだけではショップがpixiv連携をオフにしても 対象のショップが除外されるまでに時間差が生じます。
BOOTHのおすすめショップでは ショップのpixiv連携オフなどの情報をリアルタイムに反映するため Railsアプリケーション側でも対象となるショップのフィルタリング処理を行っています。
以下に、モデルのサンプルコードを書いておきます。
class Recommendation < ActiveModel # 例: おすすめショップのモデル名がShopのときの例 def shops @shops ||= Shop.where(id: shop_ids) .order(Arel.sql(shops_order_query)) .where(リアルタイムのフィルタリング条件) end def shops_order_query # おすすめショップをID順に並べる ActiveRecord::Base.sanitize_sql_array(["field(`#{Shop.table_name}`.`id`, ?)", shop_ids]) end def shop_ids # recommended_id_list(おすすめショップのIDのリスト) recommended_id_list.unpack('Q<*') end end
展望と感想
デメリット
pixivのデータを即時に反映することはできない
今回の方法では、 レコメンドはバッチで行われるため pixivのフォロー状況を即座に反映するようなことができません。 そういった用途で使いたい場合は別の仕組みが必要になります。
レコメンドのアルゴリズムをRailsアプリケーションの外で管理することになる
レコメンドのアルゴリズムはBigQueryに投げるので BOOTH側のコードだけを見てもレコメンドがわからん、という感じになります。
また、レコメンドのクエリはpixiv側のテーブル構造(BigQuery上のデータセットの構造)に依存することになるので pixiv側のデータの仕様が変わったりした場合壊れる可能性があります。 この辺は何か上手い運用の仕方を考える必要がありますね。
これらのデメリットがあるため、 即時反映が必要だったり壊れるとすぐに影響が出るような重要な機能では この仕組みを使うのは厳しいです。
今後の展望
「あなたにおすすめショップ」はまだまだ成長途中の機能です。 今はpixivのフォロワーを表示しているだけですが、 今後BOOTH内のデータに基づいた推薦等も反映したいという話が進行しています。
BOOTH内のデータのみに基づいたレコメンドについても 今回の仕組みに載せることで
- BOOTHのRailsアプリケーション/DBの負荷を考えなくてよい
- アルゴリズムの変更が容易
- (BigQueryを用いている)他サービスのレコメンドの知見が流用できる
といったメリットを享受することが出来ます。
チームで価値を最大化
ピクシブは部署を横断して相談できる仕組みや相談しやすい空気があり、 私は業務経験ゼロで負荷対策なんもわからん/BigQueryなんもわからんという状態でBOOTHチームにジョインしましたが様々な部署のメンバーと相談しながら進め、おすすめショップをより良い形で実現することができ、圧倒的成長を得ることができました。
お世話になった人たち
- データ分析わくわくタイム ( ピクシブのアナリストにデータ分析やBigQueryについて何でも相談できる時間 )
- BigQueryについて無限にお世話になった。
- レコメンドわくわくタイム ( ピクシブのレコメンドチームに何でも相談できる時間 )
- BigQueryを使ったレコメンドの仕組みやテーブル構造等の相談で無限にお世話になった。
- Rails系サービス互助会(ピクシブ内のRailsを使っているプロダクトのメンバーが技術について共有・相談する集まり)
- Railsの書き方全般で無限にお世話になった。
- インフラチームとの相談
- 負荷テストやバッチの負荷軽減で無限にお世話になった。
- BOOTHチームメンバーとの相談
- すべてにおいて無限にお世話になった。
フランクに他部署の人に相談できるところもピクシブの魅力です。 という感じに、採用に繋がりそうないい話をして締めとさせていただきます。
ピクシブでは大量のデータをいいかんじにしてサービスを改善する仲間を募集しています!