はじめに
はじめまして。音楽ストリーミングサービスAWAでサーバサイドのリードエンジニアをやっている辻(jun06t)です。
以前
といった記事を書きましたが、あれから数年経ち
- サービスの成長
- 課金プラットフォーム側の機能追加・仕様変更
- 課金形式(クレジットカード決済・キャリア決済・年間プランなど)の増加
といった理由から既存のシステムでは拡張が難しいと考え、半年ほど前に大幅刷新をしました。
旧システムと現システムの比較
旧システム
以前の課金システムのアーキテクチャは以下です。
特徴は以下です。
- DBにレシート情報を保存し、データが主体となった構成
- サブスクリプションの更新はcronによるバッチ処理。CrawlingBatchが更新対象を集め、SQSに入れてUpdateBatchで更新する
旧システムの課題
このシステムの問題点としては以下があります。
- データが主体なため、ロジックが複数のサーバに分散して存在する
- サブスクリプションの更新対象を毎回クローリングする必要があり、リアルタイム性に欠ける。またスケール性も悪い
1つ目の問題によって仕様変更(サービス側・プラットフォーム側に関わらず)による改修がしにくくなります。
また2つ目の問題はAppleのサブスクリプションは更新期限の24時間前のどこかで更新する、といった仕様の影響でクローリングタイミングが難しく、クローリングを増やせば負荷が上がるし減らせば更新が遅れたりする可能性が出るなどの問題がありました。
これらの課題を解決すべく課金システムの刷新を行いました。
現システム
※点線はgRPCによる通信
特徴は以下です。
- Payment Micro Serviceを主体としたマイクロサービス構成
- サブスクリプションの更新はプラットフォーム側のWebhookをベースにしたリアルタイム処理
現システムのポイント
Payment Micro Serviceを主体としたマイクロサービス構成
これまではDBのデータを各サーバが直接取得・更新していましたが、それらの操作を全て課金用マイクロサービス(Payment Micro Service)が担当するようにしました。
これによりドメインロジックがこの課金サービスのみに集約され、各サーバにまたがったロジックの散乱を防ぐことができました。
またこのタイミングで課金サービス自体の設計もDDD(ドメイン駆動設計)にし、ドメインレイヤにロジックが集中するようにリファクタを行いました。
このように2段階のドメインロジックの集約を行ったことで仕様変更における改修が非常にしやすいものとなりました。
サブスクリプションの更新はプラットフォーム側のWebhookをベースにしたリアルタイム処理
AWAでは3つの課金プラットフォームを利用しています。
各プラットフォームのWebhookのプロトコルはHTTPであるため、図のようにPayment Micro Serviceの前にPayment Gatewayというサーバを用意しています。
これらWebhookのうちGoogleとStripeの2つは完全な形でディベロッパー向きのWebhookがサポートされています。
一方AppleのWebhookは特定の用途には向きますが自動更新には向いていません。理由は次で説明します。
AppleのWebhook
AppleのサブスクリプションのWebhookはStatus Update Notificationsと呼ばれています。
これの特徴としては
- RENEWALは決済失敗後のリトライで成功したケース
- INTERACTIVE_RENEWALは解約後に再購読した場合に発火
- CANCELはカスタマーサポート経由のキャンセルでのみ発火
※Technical Support Incident (TSI)にて確認
といったように、どちらかというと異常系のユースケースの機能であり、通常の自動更新としては向いていません。
ちなみにAWAでは決済手段に問題が生じた、意図的でない解約ケースに対してこれらの通知を利用しています。
またこれらの通知は同じ内容が複数回届くことがあるため、冪等を意識した設計にしないと大変なことになるので注意が必要です。
iOSユーザのサブスクリプションをリアルタイム更新させる
前述の問題があるため、AWAでは自前でiOSユーザのサブスクリプションをcronのような定期実行でなく、ユーザごとに実行タイミングを予約するような形でリアルタイムに更新できるように実装しました。
図におけるiOS Next Invoice Schedulerがそれを担っています。
この設計の詳細を以下の私の個人ブログで説明しています。
データ毎に実行スケジュールが異なる場合の実装方法を考えてみる
簡単に説明すると以下です。
- RedisのZSET(ソート済みセット型)を使う
- iOSレシートのoriginal transaction idをmemberに、更新時刻(expiresAt)をscoreに
- ZRANGEBYSCOREで短い期間に対象を取得して更新させる
具体的に言うと例えば以下のように更新を迎えるユーザデータが有る時に
transaction id | 更新期限(unixtime) |
---|---|
transaction001 | 1500000000 |
transaction002 | 1500000010 |
transaction003 | 1500000020 |
transaction004 | 1500000030 |
現在時刻が1500000025であるとすると、
127.0.0.1:6379> ZRANGEBYSCORE subscription 0 1500000025
1) "transaction001"
2) "transaction002"
3) "transaction003"
のように更新期限を迎えた順に取得できます。
RedisはインメモリDBで高速なので、ZRANGEBYSCORE自体も10秒毎など短い間隔で実行可能です。
これにより期限を迎えたiOSユーザのステータスをほぼリアルタイムに更新することが可能になりました。
GoogleのWebhook
Googleは去年くらいにReal-time developer notificationsという機能を公開しており、これによりCloud Pub/Sub経由でリアルタイムにユーザのサブスクリプションステータスを受け取ることが可能になっています。
通知の種類はたくさんありますが、よく使うものを以下に載せます。
種類 | 説明 |
---|---|
SUBSCRIPTION_RECOVERED | Account Hold状態から通常の状態に復帰した時 |
SUBSCRIPTION_RENEWED | 通常の自動更新。 またはGrace Periodから復帰した時 |
SUBSCRIPTION_CANCELED | ユーザが意図的にキャンセルした時。 または非意図的でGrace PeriodやAccount Holdの状態で決済手段が改善されず解約となる時 |
SUBSCRIPTION_PURCHASED | 初回購入の時。 またはキャンセル状態でGoogle API経由で再購読した時 |
SUBSCRIPTION_ON_HOLD | 決済手段に問題が有りAccount Holdになった時 |
SUBSCRIPTION_IN_GRACE_PERIOD | 決済手段に問題が有りGrace Periodになった時 |
SUBSCRIPTION_RESTARTED | キャンセル状態のユーザがGoogle Playの設定から再購読した時 |
AWAではこの機能を使うことでリアルタイムにAndroidユーザの課金ステータスを更新しています。
StripeのWebhook
StripeはAWAのPCユーザ向けに利用しているクレジットカード用の決済プラットフォームです。
Stripeでは全イベントを細かくWebhookの対象として設定できるので、エンジニアにとって非常に扱いやすい形で実装できます。
これを用いてリアルタイムにユーザのステータスを更新します。
まとめ
旧システムの課題を解決するために
- 各プラットフォームの仕様の再確認・検証
- マイクロサービス化
- DDD
- RedisのZSETを用いた更新スケジューラ
など、インプットも検証も実装も非常に大変でしたが、結果としてより堅牢なシステムを構築することができました。