「ランキング」のマイクロサービスを作った話

qsona
FiNC Tech Blog
Published in
16 min readJun 18, 2017

FiNCではマイクロサービスでの開発をすすめており、バックエンドサービスの数は20を超えています。

今回はFiNCの数あるマイクロサービスのうち、FiNCアプリ内の「ランキング機能」のサービスを作った時の設計思想や実装について書きたいと思います。マイクロサービス化の一ケースとして、参考になる点があれば幸いです。

Summary

アプリケーションはRuby on Rails, データストアにMySQLとRedis (ElastiCache) を利用しています。記事中では以下のような点に言及します。

  • サービス設計 … サービスに持たせる責務、サービスの切り分け方
  • データの同期(サービス間のイベント連携)の方法
  • Redisを使ったランキングの実装
  • RedisとMySQLを併用する設計と実装

ビジネス要件

今回、ビジネスサイドから話があったのは以下の二つでした。

  • 歩数を用いたランキングをやりたい。 (短期目標)
  • 様々な要素でのランキングを行えるようにしたい。(中長期的な視点)

もともとFiNCアプリではライフログの一つとして歩数のデータを取得・利用しており、その歩数を用いたリアルタイムランキングをアプリ内でやりたいというものでした。

また、健康のためにもっとも重要なことは「継続」です。継続につながる要素の一つが競争であり、ランキングのような機能はFiNCの各所で使われていく可能性があるということでした。

今回はこの2点を頭に入れた上で、どのように設計していくかを考えました。

モノリシックな設計と、その問題

FiNCアプリでは、この時点でマイクロサービスがいくつかありましたが、最もメインとなるバックエンドサービス(以下メインサービス)があり、ライフログ(歩数)のデータもこのサービスが持っていました。

そのため、一番単純な方法は、メインサービスにランキング機能を実装することです。

この方法は合理的です。マイクロサービス的にも、「いきなりマイクロサービスとして切り出す」のはアンチパターンとされることが多く(例: マイクロサービスアーキテクチャ 3.3.3節「時期尚早な分解」)、このように一つのサービスに作るところから始め、ビジネスドメインの境界がはっきりしてから分割するほうが良いとされます。

一方で、可能ならば大きめの機能は最初からサービスに切り出したい、という思いもありました。メインサービスは当時でテーブル数が600を越える大規模サービスになっており、技術的負債の解消もすぐには難しい状況でした。とくにCIに時間がかかる(Staging環境に上げるまでに25分程度)のが難点で、大きめの新規機能を作るには足かせとなります。

このような状況を考えて、ランキング機能ははじめからマイクロサービス化して開発したいと思い、設計を始めました。

最初の設計

初めに考えていた設計は、以下のようなものでした。

  • ランキングサービスは、汎用的なランキングとしての機能を持つ。期間や名前などが設定できる。データストアにRedisを利用する。
  • 「歩数ランキング」に関する仕様はメインサービスに持たせる。つまりメインサービスに歩数の更新があったときに、バックエンド間でランキングサービスにPOSTリクエストを発行する。

設計の問題点

この設計について、同僚(現FiNC Appチーム開発マネージャ)の高見に相談したところ、「これだとランキングサービスがただのRedisの薄いラッパーみたいにならへん?」という意見をもらいました。

考えてみると、この指摘は非常に真っ当です。この切り分け方だとランキングサービスは、大したロジックを持たないのです。結局ロジックはRedisを直接利用する代わりにランキングサービスを利用するだけになり、大して負担は減りません。それだけでなく、無駄にAPIアクセスを行うことになり、またサービスが増えることでの管理コストも増えます。

意味があるとすれば、インフラとして共通なものを使えるため、スケーリングなどを各サービスがそれぞれ考えなくてすむことでしょう。ただし、それもいろいろなサービスから使われるようになって初めて意味が生まれるもので、初めから選択するほどのものではありません。また、FiNCではAWSを利用しており、RedisについてもAWSのマネージドなサービス(ElastiCache)を使っているので、そもそもあまりインフラ的な問題は大きくありませんでした。

というわけで、初めに考えていた設計で進んでいたらほぼ100%失敗していたものと思います。FiNCではこういった設計のレビューや相談をカジュアルに行える文化があり、これはマイクロサービス化を支える重要な文化だと感じています。

さて、今回の目標を再掲すると、以下の2つでした。

  • 歩数を用いたランキングをやりたい。 (短期目標)
  • 様々な要素でのランキングを行えるようにしたい。(中長期的な視点)

私の最初の設計では、後者を見すぎてしまったきらいがありました。ここはYAGNIの精神に戻り、短期の歩数ランキング機能をビジネスとして独立させることに注力することにしました。その上で、中長期的な方は、今回の実装を技術的なナレッジとして共有していくようにしようと考えました。

変更後の設計

今回の歩数ランキングに関する責務は、全て新しいランキングサービスに持たせると決めました。

これにより、マイクロサービスとビジネスの単位が一致し、本来のマイクロサービスの形になったといえます。影響範囲が少ないため、頻繁にデプロイでき、ビジネスのPDCAを高速に回しやすい状態です。

ロジックは新サービスの中に閉じることを決めたので、残るは、どうやってメインサービスからランキングにデータを同期させるかです。

まず考えるべきは論理設計です。特にマイクロサービス設計では、各サービスが「凝集度が高い」状態になるように細心の注意を払う必要があります。ランキングに関する関心事は、ランキングサービス内に閉じるべきです。そのため、メインサービスはランキングのことを意識せず、歩数が更新されたときにイベントの通知をする、というようにしたいと考えました。

イベントの通知方法

上記のようにマイクロサービス間にイベント連携を行いたいケースは非常に多くあります。現在FiNCではこのような場合、Amazon SNSとSQSを利用しています。大まかにイベントの種別ごとにSNSトピックを作成し、イベント通知元がこのトピックにイベントをpublishします。イベントを受ける側は、SQSを利用してこのSNSトピックをsubscribeし、サービスのworkerを立ててSQSにポーリングします。この構成については、別途記事を作成する予定です。

しかし、このサービスを作成した当時は、まだそのような方法が整っていませんでした。そのため、HTTPでのJSON APIを利用しました。その代わりに、「イベント通知」という特性から、以下のようにしました。

  • APIをRESTfulに沿わせず、 POST /v1/events/steps_updateのように「イベント連携」であることを明示したAPIにした
  • イベント通知元は、ユーザの歩数更新リクエストとは非同期的にAPIを叩くようにした(バックグラウンドジョブ。ここでは sidekiq というRubyライブラリを利用した)

データの保持の単位

この時に悩んだポイントがあります。メインサービスでは、歩数のデータを細かい時間の単位で保持しています。一方で、ランキングサービスでは、日単位でデータを持てば十分でした(ここはビジネス側とも合意をとりました)。

ランキングサービスでも同じように、細かい時間の単位で持つのがもっとも単純な方法です。ただし、細かい単位の大量のデータを保持するのは、それだけでそれなりに苦労がいります(パーティションを区切り、定期的にデータを消すなど)。歩数をメインで扱うサービスにとっては必要な苦労ですが、ランキングサービスにとっては必要ないデータで、それを保持するための苦労はしたくないので、これは避けたい方法でした。

ここでは以下の2つの方法で迷いました。

  1. イベント連携のデータに、「日単位の歩数データ」を含め、ランキングサービスはそれを使う。
  2. イベントを受けたランキングサービスから、再度「日単位のデータ」をメインサービスにAPIで問い合わせる。

1の問題点は、ランキングのロジックがイベント通知元のサービスに漏れ出していることです。

すなわち、この「日単位」というのは、ランキングサービス側のロジックなので、例えば、ランキング側の都合で、am3時を基準にするよう変更することもあるかもしれません。国際化すれば、タイムゾーンごとにデータを持つのが必要になるかもしれません。このような変更のときに、イベント通知元のサービスへの変更が必要となってしまい、凝集性が低下してしまっている状態です。

論理設計的には、2が正解といえるでしょう。イベント連携のデータには最低限必要とわかるものだけを詰め、それ以上必要ならばイベントの受けてが能動的に取りに行くということで、単純なオブジェクト指向上の話であれば「正解」であると言えると思います。

今回は論理設計を重視し、2を選択しました。これにより、高凝集な状態を保つことができました。

一方で、論理設計に寄せたツケはあり、毎度イベント連携のAPIが叩かれ、それに対してAPIで聞き返しているため、その分通信や処理を占有しています。現在、ランキングサービスの処理時間の95%以上をこのイベント連携が占めています。いくらWriteが多いのがランキングの特徴とはいえ、物理的には無駄をしている感は否めません。

今考え直すなら、もう少し違う方法を取るかもしれません。例えば、細かい単位のデータをランキングサービス側で寿命付きでキャッシュし利用しつつ、適度な頻度で問い合わせ直してデータ不整合を防ぐ、などの方法が考えられます。

Redisを使ったランキングの実装

ランキング機能には、Redisの Sorted Set が有効です。

ランキングの特徴として、ReadよりもWriteの割合が非常に多いことが挙げられます。例えばMySQLを利用し、スコアのカラムにindexを張れば、Readは十分速くすることができます。しかし、一般的にはWrite時のindexのツリーを更新するのに時間がかかるため、このように圧倒的にWriteが多い要件には若干適さないという問題があります。

Redisの Sorted Set では、Skip Listというデータ構造を利用しています。この構造では、挿入時に乱択アルゴリズムを使います。Read, Writeともに計算量はO(log(n)) であり、これ自体はMySQLで (B-tree) indexをはった状態と変わりませんが、B-treeのようにインデックスツリーのバランシングをする必要がないため平均的に高速になるようです。

さらに、ランキングは、「ユーザ + スコア」という簡単なデータの集合なので、KVSであるRedisがまさに適していると言えます。

Rubyでの実装について簡単に触れておきます。redisのクライアントには redis/redis-rb を利用しています。なお、RubyでRedisを利用する際には nateware/redis-objects というライブラリもポピュラーですが、個人的にredisのコマンドについては自分で書き、好きな形にラップさせたかったため、高レベルなラッパーの利用は見送っています。

RedisとMySQLを併用する

FiNCでのRedisの利用の方針は、あくまでキャッシュとしての利用にし、一次的なデータソースとしては利用しないことです。永続的なデータベースは、主にMySQL (Amazon Aurora)を利用しています。

今回は一次的なデータは別のマイクロサービス (ライフログを扱っているメインサービス) に保存されているので、ランキング側にはRedisだけもつという選択肢もありました。しかし、

  • 歩数ランキングは1日単位だったので、JSTで1日ごとにデータを持つ。メインサービスではもっと細かい時間の単位で保持している。
  • 数万人が参加する全体ランキングの他に、数十人程度に分かれたグループ内でのランキングも行う。グループの方は、Redisを利用せずにMySQLの利用でパフォーマンス的にも十分。

といった面を考慮し、ユーザの日ごとの歩数データをランキングサービスのMySQLにもたせ、その上で全体ランキングのためにRedisも利用する、という構成にすることにしました。

もっとも、提供するAPIには、裏側のデータストアがなんであるかは隠蔽するに越したことはありません。そのため、全体ランキング(Redisを利用)と、グループランキング(MySQLを利用) でなるべく同じインターフェイスを持たせるようにしました。

具体的(Ruby的)には、 module DbRankingmodule RedisRanking を用意し、共通のメソッドを持たせ、それぞれに includeするような実装にしました。

Web APIの設計

FiNCアプリはモバイルアプリなので、サーバ側はWeb APIを公開します。ランキングのAPI設計は少し悩むところがありました。例えば、「自分」「Top100」「自分と前後の順位の3人」などの要件がありました。これを、ランキングの参加者全体の集合から、部分集合を取るものと捉え、その取り方をクエリパラメータで指定するようにしました。例えば以下のような感じです。

  • ?ranking_users_type=me 自分のみを取得
  • ?ranking_users_type=top&limit=100 Top100を取得
  • ?ranking_users_type=around_me&limit=3 自分と前後の順位の3人を取得

難しかったのは「1位+自分の周囲3人。ただし自分が1位の時は自分+2–4位、…」のような少し複雑な要件です。あまりUIに引きづられたWeb APIにしたくない反面、このロジックをクライアントに実装させるのも、なんども無駄にAPIリクエストが走るなどの問題が生じてしまいます。
この場合は、この「取り方」を top_and_around_me と定義し、
?ranking_users_type=top_and_around_me&limit=4というクエリで取れるようにしました。このように、仕様の捉え方を定義・明文化してAPI設計に生かす手法は、しばしば利用しています。Domain-Driven Design的な開発を取り入れているとも言えると思います。

併用の難しさ

上記のように、Web APIの設計としてはもちろん、裏側のランキングがRedisかMySQLかは意識しないようにしています。上記の中で言えば、 metop についてはどちらでも実装も簡単ですが、 top_and_around_meのようなものは一筋縄ではいきません。

理想を言えば、より細かいメソッドをRedis版とMySQL版に生やし、それを利用して top_and_around_me の取り方を実装したいところです。しかし、どうしてもRedisとMySQLのデータストアの特性が違うため、そのメソッドの粒度が双方で一致するとは限りません。

両対応しなければいけない取り方の場合、DRYに保ちつつパフォーマンスも悪化させないような実装が難しいところでした。
また、そもそも片方しか利用しない場合は、YAGNIの精神で片方だけ対応し、もう片方は対応していないので400エラーというようにしました。ただ、その中で「このメソッドはRedis版では使えるがMySQL版では使えない」のようなことが生じてくるので、プログラム上の面倒や、それをきちんとAPI仕様やControllerのバリデーションに反映させること (対応していない方を無防備に叩かせると NoMethodError が発生してしまうため) あたりでの面倒がやはり生じました。

実際、RedisのSorted Setを利用する実装は思った以上に一瞬で完成して、ずいぶんとスケジュールを巻いてホクホクしていたところ、Redis/MySQL両対応のあたりで思ったより実装に時間がかかり、その中でクラス設計ミスが露呈してリリース前にリファクタリングせざるを得なくなるなど、反省点も多い開発になりました。

振り返り

開発を振り返ってみて、もちろん反省はありますが、マイクロサービス化して良かったと思える事例になりました。

まずは、初めの設計レビューなどを通して、論理設計を失敗しないですんだことが大きなポイントでした。もっとも重要なのは「サービスの高凝集性」で、これに忠実に設計することができました。

さらに、技術的改善やチャレンジを多く取り入れることができました。

  • 時期的にRails 5が出る頃で、社内では Rails 5 ( & API mode )で開発・実運用した初めてのサービスになりました。
  • API作成のためのライブラリを、今の状況にあったものに刷新しました。
  • その他、メインサービスとはライブラリ構成を変えました。特に、メインサービスに入っていて捨てられなくなったライブラリ (例えばデータベース上の論理削除)を導入せずに済んでいるのが大きなポイントです。
  • インフラ的には、Docker + Amazon ECSで運用されるようになったのもこのサービスからです。

FiNCでは最近、小さいサービスで試したものを、後から大きなサービスに導入するパターンが多いですが、それの皮切りとなったものです。

このように1つ1つは大きくないものの、少しずつ新しいことを試していけるのもマイクロサービスの大きな特徴で、その恩恵を実際に受けることが出来ました。総じて、良い開発になったと思います。

宣伝

冒頭に、FiNCアプリを支えるバックエンドサービスの数は20を超えていると書きました。最新のサービスは、今年2017年7月から始まる FiNCウォーク というアプリ内イベントのためのものです。

この記事を読んだ皆さまはぜひ、FiNCアプリをダウンロードして、期間内にたくさん歩いて健康になって下さいね。

さて、FiNCではエンジニアを募集しています。

  • マイクロサービス的な設計
  • マイクロサービスでの高速な実装・改善、技術チャレンジ
  • 健康のための「継続」を実現するサービス開発

この記事を読んで、例えば上記のようなことに興味を持っていただいた方、ぜひカジュアルにお話させて下さい。ご連絡お待ちしています!

--

--

qsona
FiNC Tech Blog

株式会社FiNC所属, WebエンジニアNode.js/Ruby, Rails