【インターンレポート】LIVEBUYにおけるサーバサイドの開発

はじめに

こんにちは。技術職 就業型コースのインターンシップに8/16から9/25までの期間で参加しました森順平です。
私は京都大学大学院情報学研究科の修士1回生で、大学院では離散アルゴリズムや離散構造をメインとする研究をしています。

今回のインターンでは開発Kチームに所属し、宇井敬一朗さんにメンターをしていただきました。開発KチームでLIVEBUYというサービスのサーバサイドの開発をしました。
本記事ではLIVEBUYについて少しご紹介してから、その開発でしたことについてご紹介します。

LIVEBUYとは

はじめに簡単にLIVEBUYについてご紹介します。

LIVEBUYとはライブコマースの一種でLINEが新たに打ち出したサービスです。ライブコマースではタレントやインフルエンサーがライブ形式で商品についての紹介をし、視聴者はそのライブを見ながら欲しいと思った商品をリアルタイムに購入するという新しいオンラインショッピングの形式です。

ライブのような熱量をオンラインで感じ取りながら新しい購入体験をすることができます。

使われている技術について

サーバサイドは主にKotlinで書かれていて、フレームワークはSpringが使われています。サーバ内ではマイクロサービスの構成をとっていて、各サービス間の通信はgRPCが使われています。iOS/Android アプリケーション等、外部との通信もgRPCが使われています。

データベースはMySQLやRedisが使われています。今回取り組んだ課題ではMySQLが主題となるので、MySQLについてはもう少し詳しく説明します。Kotlin側からMySQLを呼び出すAPIとしてR2DBCが使われています。R2DBCは現時点ではSPIが1.0のマイルストーンリリースのみで、Springの対応も2019年12月から始まったばかりというかなり新しい技術です。従来はJDBCが使われていました。R2DBCはJDBCと異なり、ノンブロッキングな入出力を扱うことができます。

今回はR2DBCをリッチにしたAPIをもつSpring Data R2DBCのクライアントを使いました。今回使ったクライアントでは動的にSQL文を発行することができ、このインターンで実施したメインの課題はこの動的に発行するSQL文の内容についてのものでした。

取り組んだ課題について

配信一覧を与えられた条件で絞りこみページングして返すAPIを実装しました。開発途中の画面になりますが次のスクリーンショットのように多くの検索条件があるので、これに合致する配信を返すというものです。

検索条件によっては関係データベース上に保存されている複数のテーブルに跨った検索を行う必要があります。また与えられた文字列に対しての判定も完全一致なのか部分一致なのかなど仕様をしっかり固めるところからの課題でした。

条件が与えられない場合もあるので、SQL文は動的に発行する必要があります。全体の実装自体はそこまで難しくないのですが、SQL文が複数テーブルに跨がっているため、そのようなSQL文を発行するコードの可読性やパフォーマンスが問題になります。このAPIはコンテンツ管理者向けの管理ページから利用するもののため、そこまでパフォーマンスを気にする必要はありませんでした。ただ、いい機会なので今後のために調査しようということになりました。

調査内容

パフォーマンスの低下を招くだろうと考えられる箇所をまず調査しました。パフォーマンスが低くなると思われたのが演者の名前を入力として、対応する配信を出力するクエリでした。配信には複数の演者が紐づけられることがありうるという設定です。

例えば次のテーブルを考えます。broadcastsテーブルは配信の情報を、broadcastersテーブルは演者の情報を、broadcasts_broadcastersテーブルは配信に対する演者の出演情報が書かれています。この例では配信Xを花子さんが、配信Yを太郎さんが、配信Zを太郎さんと花子さんが二人で配信しているという状態です。このとき太郎さんが入力として与えられた場合、右のテーブルから順番に見ていくことで、太郎さんの出演する番組は配信Y、 配信Zの二つであることがわかります。

このクエリが実行したいことを箇条書きにすると以下の通りです。

  1. broadcastersテーブルから入力で与えられた名前に該当する演者のidを取得
  2. broadcasts_broadcastersテーブルから1.で取得した演者のidに対応するbroadcast_hashを取得
  3. broadcastsテーブルから2.で取得したhashをもつ配信を取得

このクエリをINを使った副問合せでSQL文に変換すると次のようになります。入力で与えられる演者の名前を太郎としています。

パフォーマンス上の問題をもつSQL文

SELECT *
FROM broadcasts
WHERE id IN (
SELECT broadcast_id
FROM broadcasts_broadcasters
WHERE broadcaster_id IN (
SELECT name FROM broadcasters WHERE name = "太郎"
))

INを使った副問合せが二重の入れ子になっていて、実行のされ方次第ではパフォーマンスに影響がありそうです。今回のケースに限れば上記の1.における与えられた名前に対応する演者はそこまで多くないことが予想されるので二重だからパフォーマンスが悪いというわけではないかもしれないですが、複雑でパフォーマンスに問題を持ちうるSQL文であることは確かです。

今回のINを使った副問合せは他にもEXISTSを使った相関副問合せや、 JOINを使って書くこともできます。それぞれ論理的には同じ書き換えができます。EXISTSは存在するかどうかの判定では明示的にテーブルを作る必要がないという利点が、JOINは先に結合してから処理をするので他のテーブルを見る回数が少ないという利点があると書かれていることがあります。

しかしこれらがどのように内部で実行されるかはRDBMSの最適化次第になります。最適化がどのようにされるかはドキュメントを読むのもいいのですが、SQLではSELECT文の前にEXPLAINをつけることで実際の実行計画を出力することができます。これを見てパフォーマンスを判断するのがよさそうです。

調査結果と結論

EXPLAINとその実行結果は内容は以下のような内容でした。今回の実験に使うテーブルはmy_dbという名前のデータベースの上に定義しています。

INを使った副問合せの実行計画 

mysql> EXPLAIN SELECT * FROM broadcasts WHERE id IN ( SELECT broadcast_id FROM broadcasts_broadcasters WHERE broadcaster_id IN ( SELECT id FROM broadcasters WHERE name = 'name_10' )) \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: <subquery2>
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: NULL
filtered: 100.00
Extra: NULL
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: broadcasts
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: <subquery2>.broadcast_id
rows: 1
filtered: 100.00
Extra: NULL
*************************** 3. row ***************************
id: 2
select_type: MATERIALIZED
table: broadcasts_broadcasters
partitions: NULL
type: ALL
possible_keys: fk_broadcasts_broadcasters_for_broadcast_id,fk_broadcasts_broadcasters_for_broadcaster_id
key: NULL
key_len: NULL
ref: NULL
rows: 100125
filtered: 100.00
Extra: NULL
*************************** 4. row ***************************
id: 2
select_type: MATERIALIZED
table: broadcasters
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: my_db.broadcasts_broadcasters.broadcaster_id
rows: 1
filtered: 10.00
Extra: Using where
4 rows in set, 1 warning (0.00 sec)
 
mysql> show warnings \G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `my_db`.`broadcasts`.`id` AS `id`,`my_db`.`broadcasts`.`title` AS `title` from `my_db`.`broadcasts` semi join (`my_db`.`broadcasts_broadcasters` join `my_db`.`broadcasters`) where ((`my_db`.`broadcasts`.`id` = `<subquery2>`.`broadcast_id`) and (`my_db`.`broadcasters`.`id` = `my_db`.`broadcasts_broadcasters`.`broadcaster_id`) and (`my_db`.`broadcasters`.`name` = 'name_10'))
1 row in set (0.01 sec)

warningsのMessageを読むとsemi joinというキーワードが書かれていてJOINのような最適化がされていることが確認できます。MySQLのドキュメントを読むと今回使用しているバージョンではINやEXISTSは特定の条件下で準結合を用いて最適化されると書かれています。準結合によるサブクエリ最適化にはいくつかの戦略があり、今回はMaterializationと呼ばれる戦略が使われています。

JOINを使って書き換えたものについても実行計画を表示すると次のようになりました。

JOINで書き換えた場合の実行計画 

mysql> EXPLAIN SELECT DISTINCT b.* FROM broadcasts AS b INNER JOIN broadcasts_broadcasters AS bb ON b.id = bb.broadcast_id INNER JOIN broadcasters AS c ON bb.broadcaster_id = c.id WHERE c.name = "name_1000" \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: bb
partitions: NULL
type: ALL
possible_keys: fk_broadcasts_broadcasters_for_broadcast_id,fk_broadcasts_broadcasters_for_broadcaster_id
key: NULL
key_len: NULL
ref: NULL
rows: 100125
filtered: 100.00
Extra: Using temporary
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: c
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: my_db.bb.broadcaster_id
rows: 1
filtered: 10.00
Extra: Using where
*************************** 3. row ***************************
id: 1
select_type: SIMPLE
table: b
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: my_db.bb.broadcast_id
rows: 1
filtered: 100.00
Extra: NULL
3 rows in set, 1 warning (0.01 sec)
 
mysql> show warnings \G
*************************** 1. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select distinct `my_db`.`b`.`id` AS `id`,`my_db`.`b`.`title` AS `title` from `my_db`.`broadcasts` `b` join `my_db`.`broadcasts_broadcasters` `bb` join `my_db`.`broadcasters` `c` where ((`my_db`.`b`.`id` = `my_db`.`bb`.`broadcast_id`) and (`my_db`.`c`.`id` = `my_db`.`bb`.`broadcaster_id`) and (`my_db`.`c`.`name` = 'name_1000'))
1 row in set (0.01 sec)

同じようにwarningsのMessageを読むと、そのままJOINのまま処理されていることが確認できます。

今回のケースではレコード数が非常に多い場合semi joinの戦略の選択は工夫しないと最適にならず、INやEXISTSの書き換えはJOINに比べて低速になります。ただ今回の場合は元も子もないのですがbroadcastersテーブルで名前を全件検索するために比較的大きな時間をつかっており、これらの書き換えにはあまり差異がないことが予想されます。
開発においては今後の変更にも備えて可読性を意識することが大切になります。今回の場合INを使って書いたクエリは条件をまとめやすく、クエリを構成するコードがコンパクトになりました。

今回の実装ではパフォーマンス面はある程度保証されていればそれほど気にする必要がないこともあり、可読性を考えてINを使うのがよいだろうということになりました。

他にインターン中にしたこと

簡単なCRUDアプリケーションの作成

インターンに来た時点ではSpringもgRPCも知らなかったので、チュートリアルとしてデータベースにCRUD操作をgRPCで行えるAPIをもつSpringのアプリケーションを作成しました。同じツールセットで開発しているような記事はほとんどないため、ドキュメントを読んで実装する練習をしました。

郵便番号から住所を検索するAPIのサーバサイド側の実装

LINE社内に郵便番号から住所を検索するAPIが提供されているので、それを利用して住所を検索し整形してからアプリ側に渡すAPIを作成しました。

ブロッキングな操作をしているRedis入出力のノンブロッキング化

元々の実装はRedisとの通信のブロッキングな入出力にDispatchers.IOを使って Kotlin Coroutine 上で利用していました。これをより効率の良く処理をするためにノンブロッキングな入出力による通信が可能なクライアントに書き換えました。
Spring Data RedisではRedisとノンブロッキングな入出力を行うクライアントとしてReactiveRedisTemplateが用意されています。ReactiveRedisTemplateの非同期処理のインターフェースはReactor(https://projectreactor.io/)のインターフェースが利用されています。またこれらはKotlinに向けた拡張がされており、suspend functionを用いて自然に書けるようになっています。

勉強会(Code Readability Sessionや文章の書き方講座)への参加

Code Readability Sessionでは命名の仕方からクラスの依存関係についてまで、かなり広い意味での可読性について勉強することができました。実際開発中にも改善すべき部分があり、Code Readability Sessionで勉強したことを生かして改善するということもしました。
LINEにはテクニカルライティングを専門にしている部署があり、その部署の方が文章の書き方講座を講義を開いてくださいました。テクニカルな文章の書き方の講座は珍しく感じて、あまり聞いたことのないアイデアもあり面白かったです。

おわりに

他にもいくつかの機能の実装やバグの修正をしたのですが、大きなプロジェクトのコードを触る経験が今まで少なかったのでとても楽しかったですし、勉強になりました。
リモートのインターンということもあって最初はどうなるか不安だったのですが、メンターの方にやさしく面倒見ていただき充実したインターン生活を送ることができて満足しています。

LINEのインターンはやはり成果を出さないといけないというプレッシャーもありますが、その分楽しいことや学びも大きいのでぜひ興味がある人はチャレンジしてみてください!

Related Post