Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

クライアントサイドでよく使われる Debounce処理 をサーバサイドで3回実装した話

クライアントサイドでよく使われる Debounce処理 をサーバサイドで3回実装した話

Debounce 処理というのはクライアントでよく使われる技術です。 高頻度で呼び出されるイベント(キー入力やマウスの移動、ウインドウのサイズ変更)などを制御するテクニックのひとつです。たとえばJavaScriptのライブラリLodashに実装されていたりそれなりにクライアント側では使われる技術です。
そんなDebounce処理をサーバサイドで実装したので、そのお話をしようと思います。
Debounce処理自体の説明から、 クライアントで処理すべきものをなぜサーバサイドで実装しなくてはいけなかったのかの背景の説明、 実際にどうやって複数のサーバで実装しているのか、なぜ3回も実装したのか、そして4回目の実装の構想などをお話します。

Yoshiori SHOJI

October 27, 2024
Tweet

More Decks by Yoshiori SHOJI

Other Decks in Technology

Transcript

  1. 15 テスト結果を集めてAIを使って様々な処理をしている ◦ 失敗したテストの原因が同じかどうかを判断 ▫ 例えばDBへのコネクションが原因で複数のテストがコケた時に 原因は同じなので1つにまとめる ◦ 同じ原因で失敗したテストが過去にあったか判断 ▫

    新しく発生した失敗なのかよくある失敗なのか ◦ リトライして成功しているかどうか ▫ 不安定なテストは複数回実行して成功していたら成功扱いに ◦ SlackやGithubなど各種通知 ◦ などなど
  2. 一旦初期対応終わったあと どうするのが良いのか? ◦ ❌ APIにRate Limitつける ▫ お客さんのテストデータを受け入れないのはダメ ◦ ❌

    処理を夜間バッチとかにする ▫ テスト結果はすぐに見たい(当たり前) ◦ ❌ スケールアップ/スケールアウト ▫ スパイクなのでauto scale的なものでは間に合わない ◦ ❌ ジョブキューなどにして非同期にする ▫ 700並列で非同期処理が走るようになるだけ 18
  3. 簡単に言うとRedisをロックオブジェクトにしたDebounce処理 21 * 最近のRedis界隈のゴタゴタでRedisと言うのは正確ではないけど この発表ではそこは本質ではないのでRedisで統一します @Async public waitAndClose(int testSessionID){ UUID

    myId = UUID.create(); redis.set(testSessionID, myId); Thread.sleep(5 * 100); UUID latest = redis.get(testSessionID); if(myId == latest){ testSessionsService.close(testSessionID); redis.del(testSessionID); } } ベースはProposalに書いた疑似コード
  4. 26 コードはシンプル @Async public waitAndClose(int testSessionID){ UUID myId = UUID.create();

    <- 呼ばれたら UUID作成 redis.set(testSessionID, myId); <- Redisに保存 Thread.sleep(5 * 100); <- 500ms待つ UUID latest = redis.get(testSessionID); <- Redisから取得 if(myId == latest){ <- 値の照合 testSessionsService.close(testSessionID); <- 実行 redis.del(testSessionID); <- Redisから値消す } }
  5. ここまでのまとめ Server-Side Debounce ◦ サーバ跨いで処理するのでsynchronizedは使えない ▫ というか使っても同期できない ◦ なのでRedisをロック機構に使う ▫

    Redisは基本処理がAtomicなので便利 ◦ サーバ再起動の時に処理が失われる可能性がある ▫ 安定させるには本格的にJobQueueとかの仕組みが必要 ▫ 99%はこれで上手く行くのでもっと余裕が出来たら考える 27
  6. 実際のコード v1 29 疑似コードとの変更点 ◦ 処理を引数にLambdaで渡せるように ▫ 非同期で値は返せないのでRunnable ◦ delayも引数で渡せる

    ▫ ユーザーや処理によって変更できるように ◦ 疑似コードからほとんど変化なし ▫ コード自体は数行でシンプルなもの ◦ エラー処理の追加
  7. Race Conditionがあった 処理の最後にRedisから値を消しているところ ◦ 本質的にはその削除処理はいらない ▫ 次のアクセスがなければRedisのexpireで揮発する ◦ 何故消す処理を入れたのか? ▫

    消すことによって実行されたかどうかの判断が出来る ▫ 残っていたらまだ実行されていないと判断できる ▪ サーバ再起動遅延させたり、再起動後に残ってた実行を復 活させたりの判断が出来きる 31
  8. Race Conditionがあった 対応案 ◦ 処理実行中はロックする ▫ Single Threaded Execution パターン的なやつ

    ▫ サーバ跨いでロックするのは大変 ◦ 削除時Keyだけではなく値も一致してる時だけ消す ▫ 処理実行前に値が同じことを確認しているので終わった後、消 すタイミングでも確認してから消す ▫ こっちが良さそう 34
  9. 削除時、Keyだけではなく値も一致してる時だけ消す やりたいことは ◦ synchronized して値が一致したら削除する。 ▫ コードで書くとこういう感じ ▫ ただし、サーバを跨いで(ry 36

    public boolean deleteIfValueEquals (K key, V value) { synchronized (cache) { if (Objects.equals(cache.get(key), value)) { cache.del(key); return true; } } return false; }
  10. 削除時、Keyだけではなく値も一致してる時だけ消す Lua スクリプト実行するだけ 38 @Language("lua") private static final String ATOMIC_CHECK_AND_DELETE_LUA_SCRIPT

    = """ if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end """; public boolean deleteIfValueEquals(K key, V valueOfKey) { return redis.eval( ATOMIC_CHECK_AND_DELETE_LUA_SCRIPT, ScriptOutputType.BOOLEAN, (K[]) new Object[] {key}, valueOfKey); }
  11. Debounceはdelay時間の調整がキモ delay時間が・・・ ◦ 長過ぎた場合 ▫ 処理が実行されるまで時間がかかる、即時性が失われる ◦ 短すぎた場合 ▫ delayがあまり意味をなさずに全て実行されてしまう

    調整して10秒になってたけど、更に遅延させるの?テスト時間短くした くてLaunchable使ってもらってるのに?1分が長いか短いか論争に。 (そもそも重い処理なので処理を始めてからも時間かかるので) 46
  12. 2個の実現したいこと 今回の件では相反する2個の実現したいことがある ◦ 重い処理なのでなるべくまとめて実行したい ▫ OpenAIのAPI呼び出しなどもあるので逐次実行するとお金もかか りすぎる -> 出来ればdelay5分とかにしたい ▫

    負荷対策など主に運営側の問題 ◦ ユーザーにはなるべく早く届けたい ▫ 通知やテスト結果の色々な情報など ▫ これはサービスの価値やクオリティの話 48
  13. 即時実行 最初のリクエストの直後に アクションが実行され、重 要な更新が遅延なく確実に 実行される。 この即時実行は、迅速な応 答が重要な通知などに V2 案 条件付き遅延

    その後のリクエストからは 5分のdelayのDebounce処 理。 このDebounce処理は、デー タが全て集まった後、分析 や推論などの重い処理をす る 51
  14. 実装はどうなるか ここもRace conditionになりうる ◦ 最初の想定である1秒間に10回以上のスパイク時には簡単に発生しそう ◦ この処理もRedis内でatomicにする必要がある 55 var prev

    = cache.get(key); if (prev == null) { <-この処理の間に cache.set(key, DUMMY); <-別のリクエストがあるとダメ runnable.run() ; return; } . . // 通常の(長いDelayの)Debounce処理
  15. 出来た! 今回の修正もシンプル ◦ コードの変更は3行くらい ▫ しかもRedisにも最初からatomicな処理があった ◦ v1 のちょっとしたリファクタリングも ▫

    Threadを生で触るのではなくSpringのTaskSchedulerを使う ◦ 今度こそ大丈夫なはず! ▫ と思ったけど...この実装はすぐにダメになった 59
  16. 67 今までの問題をまとめると ◦ 短期間に大量に来るスパイクへの対応 ▫ v1 - シンプルなDebounce ◦ 緩やかだが量が多いものをまとめる

    ▫ v2 - 即時実行と遅延実行をするDebounce ▫ もちろんv1の問題も一緒に解決する ◦ スパイクではないが短期間(2分割で送信など) ▫ v3 - イマココ ▫ もちろんv1、v2の問題も一緒に解決する
  17. 実装はどうなるか delay決定部分 86 @VisibleForTesting static CallTrack calculateNextCallTrack(@Nullable CallTrack prev, Instant

    now) { if (prev == null) { return new CallTrack(now, DEFAULT_MINIMUM_DELAY); <- 初回呼び出し時はデフォルト値使う } Duration observedInterval = Duration.between(prev.time(), now); <- 前回から時間 Duration delay = min(max(prev.delay(), observedInterval), MAX_DELAY); ↑ 前回からの時間と前回使ったdelayの長い方、長過ぎたらMAX_DELAYの方を使う return new CallTrack(now, delay); }
  18. 実装はどうなるか debounce 本体実装 87 @Async public void debounce(Key key, Runnable

    runnable) { String keyString = key.toString(); CallTrack prev = synchronizationMap.get(keyString); CallTrack track = calculateNextCallTrack(prev, Instant.now()); synchronizationMap.put(keyString, track); taskScheduler.schedule( () -> { if (!track.equals(synchronizationMap.get(keyString))) { return; } runnable.run(); }, track.scheduledAt()); <- time.plus(delay) を返してるだけ }
  19. 実装はどうなるか 88 @Async public void debounce(Key key, Runnable runnable) {

    String keyString = key.toString(); CallTrack prev = synchronizationMap .get(keyString); CallTrack track = calculateNextCallTrack(prev, Instant. now()); synchronizationMap .put(keyString, track); taskScheduler .schedule( () -> { if (!track.equals(synchronizationMap .get(keyString))) { return; } runnable.run(); }, track.scheduledAt()); } @VisibleForTesting static CallTrack calculateNextCallTrack (@Nullable CallTrack prev, Instant now) { if (prev == null) { return new CallTrack(now, DEFAULT_MINIMUM_DELAY); } Duration observedInterval = Duration. between(prev.time(), now); Duration delay = min(max(prev.delay(), observedInterval), MAX_DELAY); return new CallTrack(now, delay); } public record CallTrack(Instant time, Duration delay) implements Serializable { Instant scheduledAt () { return time.plus(delay); } } 全体のコード量 全体でも30行以下。 言葉で説明するより コードのほうがシンプ ルでしょ? でも理解するの難しい よね><
  20. 94 Server-Side debounce まとめ ◦ サーバを跨いだマルチスレッドプログラミング ▫ atomicな処理が必要だったり楽しい ◦ 現実は複雑だ...

    ▫ 3回書き直すとは...でも解決できてよかった!! ◦ コード自体は至極シンプルに ▫ 30行以下で実装出来てるの自分でもビビる ▫ やっぱりシンプルなコードで複雑な問題に対応できるの楽しい ◦ やっぱりボスがロックスターだと便利w
  21. 96 v4 作るなら ◦ workspace毎にdelayのデータを貯めたい ▫ 今はロックとしても使っているのですぐに上書きされるし、一 日で揮発する ▫ データを元に外れ値弾いたり平均だしたりして初回debounceか

    ら適切な時間を使えるように... ◦ JobQueueにする ▫ 間引く仕組みは出来たのでJobQueueにして完全に実行を切り分 けて再起動などしても大丈夫なようにしたい ▫ ただし今のようにlambdaでさくっと書けたりしなくなるので悩 ましい