Carpe Diem

備忘録。https://github.com/jun06t

データ毎に実行スケジュールが異なる場合の実装方法を考えてみる

概要

Stripeの定期購読を使っていると、各ユーザの定期購読の更新タイミングでWebhook eventがちゃんと飛んできます。
ほぼリアルタイムで届くのでこのwebhookを使う側としては非常に助かる機能ですが、提供する立場から考えるとどうやるんだろう?と悩んだので幾つか自分でケースを考えてみました。

課題

今回やりたいことを実現する上で問題になるのは主に以下です。

  • タイムスケジューラをどうするか
  • データ量が増えたときにスケール可能か

cronのようなスケジューラだと、今回のような個々のデータに対するスケジューリングはできません。
またバッチ処理で扱おうとすると、データ量が増えた時に処理対象がどんどん増えるため、対象の抽出や処理自体に時間がかかって実行頻度を上げることが難しくなります。

手段

浮かんだのは以下の4つでした。

  1. 全ユーザの更新期限を毎回チェックしてイベントを飛ばす
  2. 更新日を迎えるユーザをあらかじめ別の場所に保持しておいて、それを定期的に処理
  3. Redisのzsetを使う
  4. Redis Keyspace Notificationsを使う

説明

a. 全ユーザの更新期限を毎回チェックしてイベントを飛ばす

一番シンプルというかそのまんまです。
定期的にDBに対してrangeクエリでユーザを抽出し、更新処理をかけます。
実行タイミングはcronやJenkinsとかに任せることになると思います。

メリット

  • ロジックはシンプル

デメリット

  • cronを使った一括処理のスケジューラなので、ユーザ毎に処理できない
  • 対象ユーザが増えると抽出に時間がかかる。スケールしない。
  • ↑の理由でcronのスケジュール頻度を細かくとれない

結論

1日数回のバッチ処理であったり、即時性が求められなければ良いですが、今回の要件は満たせないです。

b. 更新日を迎えるユーザをあらかじめ別の場所に保持しておいて、それを定期的に処理

次に浮かんだのがこのやり方で、aでの問題として即時性がないのであればあらかじめ対象を抽出しておいて、後からその対象を使って定期的に更新処理すれば良いのでは?という考えです。
SQSなどのメッセージ基盤に対象を詰め込んで置いて、更新時間になったらdequeueして処理する感じです。

メリット

  • 対象抽出と更新処理を分割するので、aよりは即時更新できる。
  • 抽出は事前に行っておくので、対象ユーザが増えても心配ない。スケールできる。

デメリット

  • dequeueするタイミングが難しい
    • ユーザ毎に処理するためにはキューを細かく(1時間毎のグループ?)分ける必要がある。当然細かくすれば管理が大変になり、逆に幅をもたせるとリアルタイム性が無くなる。

結論

aよりは良いですが、今回の要件は満たせなさそうです。

c. Redisのzsetを使う

ランキングなどでよく使われるzset(ソート済みセット型)を使います。
対象ユーザIDをmemberに、更新時刻をscoreとして保持します。
こうすることでZRANGEBYSCOREを使って現在日時を過ぎた更新対象を取得することが可能です。
またインメモリDBで高速なので、ZRANGEBYSCORE自体も10秒毎など短い間隔で実行可能です。

rubyのsidekiqが同様の実装をしています。

github.com

例えば以下のようなケースで

userID 更新期限(unixtime)
user001 1500000000
user002 1500000001
user003 1400000000
user004 1400000001

現在時刻が1500000000であるとすると、

127.0.0.1:6379> zadd subscription 1500000000 user001
(integer) 1
127.0.0.1:6379> zadd subscription 1500000001 user002
(integer) 1
127.0.0.1:6379> zadd subscription 1400000000 user003
(integer) 1
127.0.0.1:6379> zadd subscription 1400000001 user004
(integer) 1

とデータに追加していって、ZRANGEBYSCORE

127.0.0.1:6379> ZRANGEBYSCORE subscription 0 1500000000
1) "user003"
2) "user004"
3) "user001"

といった感じに更新期限を迎えた順に取得できます。

メリット

  • ほぼリアルタイム。ユーザ毎に処理が可能。
  • あらかじめソートされているので高速
  • Redis自体をスケールアップすればシステム的にスケール可能

デメリット

  • インメモリDBなので揮発する可能性
  • 使用済みのmemberを削除するといった後処理が必要

結論

かなり良さそうです。

d. Redis Keyspace Notificationsを使う

v2.8.0から提供されたkeyspace notificationsを使います。
これはRedisに対する各種操作のイベントを、Pub/Subで受け取ることができる機能です。
なのでキーのexpireに更新の時刻をセットすれば、その時刻になった時expireイベントがsubscribeできるのでリアルタイムに処理できるという事になります。

例えば以下のようにEXPIREを設定したところ、

127.0.0.1:6379> SET key1 value1
OK
127.0.0.1:6379> EXPIRE key1 10
(integer) 1

Subscribeしているクライアント側で期限が切れた時にイベントを取得できました。

Reading messages... (press Ctrl-C to quit)
"psubscribe","*",1
"pmessage","*","__keyspace@0__:key1","expired"
"pmessage","*","__keyevent@0__:expired","key1"

メリット

  • リアルタイム。ユーザ毎に処理が可能。
  • 発火タイミングをRedisに任せられるので実装側はシンプルに作れる

デメリット

結論

機能としては良さそうですが、expireのタイミングに左右されたり、Pub/Subの信頼性も影響するのでそこが許容できるかどうかになりそうです。

まとめ

今回挙げた案の中では、Redisのzsetを活用するのが良さそうです。

  • 予約投稿を実現したい
  • Appleの定期購読の更新チェックをリアルタイムにしたい
  • 新作動画や音楽の新譜などを許諾側の指定した時刻に公開したい

と言った感じで色んなところで必要とされそうな今回の要件なのですが、ググっても具体的な手法が見つからなかったのでまとめてみました。

追記:ゆううきさんがちょうど最近発表されてました↓

blog.yuuk.io

ソース