はじめに

はじめまして。音楽ストリーミングサービスAWAでサーバサイドのリードエンジニアをやっている辻(jun06t)です。
以前

といった記事を書きましたが、あれから数年経ち

  • サービスの成長
  • 課金プラットフォーム側の機能追加・仕様変更
  • 課金形式(クレジットカード決済・キャリア決済・年間プランなど)の増加

といった理由から既存のシステムでは拡張が難しいと考え、半年ほど前に大幅刷新をしました。

旧システムと現システムの比較

旧システム

以前の課金システムのアーキテクチャは以下です。

Before

特徴は以下です。

  • DBにレシート情報を保存し、データが主体となった構成
  • サブスクリプションの更新はcronによるバッチ処理。CrawlingBatchが更新対象を集め、SQSに入れてUpdateBatchで更新する

旧システムの課題

このシステムの問題点としては以下があります。

  • データが主体なため、ロジックが複数のサーバに分散して存在する
  • サブスクリプションの更新対象を毎回クローリングする必要があり、リアルタイム性に欠ける。またスケール性も悪い

1つ目の問題によって仕様変更(サービス側・プラットフォーム側に関わらず)による改修がしにくくなります。
また2つ目の問題はAppleのサブスクリプションは更新期限の24時間前のどこかで更新する、といった仕様の影響でクローリングタイミングが難しく、クローリングを増やせば負荷が上がるし減らせば更新が遅れたりする可能性が出るなどの問題がありました。

これらの課題を解決すべく課金システムの刷新を行いました。

現システム

After

※点線は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 idmemberに、更新時刻(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を用いた更新スケジューラ

など、インプットも検証も実装も非常に大変でしたが、結果としてより堅牢なシステムを構築することができました。

jun06t
音楽配信サービスAWAのサーバ・インフラのリードエンジニア