Upgrade to Pro — share decks privately, control downloads, hide ads and more …

なぜThrottleではなくDebounceだったのか? 700並列リクエストと戦うサーバーサ...

なぜThrottleではなくDebounceだったのか? 700並列リクエストと戦うサーバーサイド実装のすべて

Avatar for Yoshiori SHOJI

Yoshiori SHOJI

November 14, 2025
Tweet

More Decks by Yoshiori SHOJI

Other Decks in Technology

Transcript

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

    新しく発生した失敗なのかよくある失敗なのか ◦ リトライして成功しているかどうか ▫ 不安定なテストは複数回実行して成功していたら成功扱いに ◦ SlackやGithubなど各種通知 ◦ などなど
  2. 8 分散テスト実行 ◦ CIでのテスト実行に時間がかかるので複数台で実行する ▫ テストをある程度のまとまりごとにわけて実行 ◦ 分散するということはテストが多いということ ▫ つまり多いテストを早く終わらしたい

    ◦ 早く終わらせるためには平均的な時間で終わるように揃える ▫ 合計10分かかるテストを2分割するとして9分で終わるテストの 集まりと1分で終わる集まりに分散しても意味があまりない。 ▫ 上記なら5分で終わるテストの集まりを2個作るのが良い ◦ ほぼ同時に終わる = ほぼ同時に結果が送られる ▫ つまり、同時に大量のテスト結果が送られてくる
  3. 10 700並列の分散テスト実行結果 ◦ 非同期にしたりジョブキューに入れてもあまり効果ない ▫ 700個のキューが貯まるだけ ◦ Close処理はまとめられる ▫ 700並列のテスト結果データは保存必須

    ▫ その後のClose処理はある程度まとめて実行して良い ◦ 良い感じにデータ溜まったあとにClose処理したい ▫ ホントは全部終わったよってAPIリクエスト貰うのが一番楽なん だけど色々運用してそれは不可能だとなった ▫ なので良い感じに終わったと判断しなくてはいけない ◦ つまりClose処理部分を間引く必要がある
  4. 一旦初期対応終わったあと どうするのが良いのか? ◦ ❌ APIにRate Limitつける ▫ お客さんのテストデータを受け入れないのはダメ ◦ ❌

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

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

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

    Redisは基本処理がAtomicなので便利 ◦ サーバ再起動の時に処理が失われる可能性がある ▫ 処理自体のシリアライズなどの仕組みが必要 ▫ 99%はこれで上手く行くのでもっと余裕が出来たら考える 42
  8. 実際のコード v1 44 疑似コードとの変更点 ◦ 処理を引数にLambdaで渡せるように ▫ 非同期で値は返せないのでRunnable ◦ delayも引数で渡せる

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

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

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

    public boolean deleteIfValueEquals (K key, V value) { synchronized (cache) { if (Objects.equals(cache.get(key), value)) { cache.del(key); return true; } } return false; }
  12. 削除時、Keyだけではなく値も一致してる時だけ消す Lua スクリプト実行するだけ 53 @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); }
  13. Debounceはdelay時間の調整がキモ delay時間が・・・ ◦ 長過ぎた場合 ▫ 処理が実行されるまで時間がかかる、即時性が失われる ◦ 短すぎた場合 ▫ delayがあまり意味をなさずに全て実行されてしまう

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

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

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

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

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

    ▫ v2 - 即時実行と遅延実行をするDebounce ▫ もちろんv1の問題も一緒に解決する ◦ スパイクではないが短期間(2分割で送信など) ▫ v3 - イマココ ▫ もちろんv1、v2の問題も一緒に解決する
  19. 実装はどうなるか delay決定部分 101 @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); }
  20. 実装はどうなるか debounce 本体実装 102 @Async public void debounce(Key key, Runnable

    runnable) { String keyString = key.toString(); CallTrack prev = cache.get(keyString); CallTrack track = calculateNextCallTrack(prev, Instant.now()); cache.put(keyString, track); taskScheduler.schedule( () -> { if (!track.equals(cache.get(keyString))) { return; } runnable.run(); }, track.scheduledAt()); <- time.plus(delay) を返してるだけ }
  21. 実装はどうなるか 103 @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行以下。 言葉で説明するより コードのほうがシンプ ルでしょ? でも理解するの難しい よね><
  22. 109 Server-Side debounce まとめ ◦ サーバを跨いだマルチスレッドプログラミング ▫ atomicな処理が必要だったり楽しい ◦ 現実は複雑だ...

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

    ら適切な時間を使えるように... ◦ JobQueueにする ▫ 間引く仕組みは出来たのでAWS SQSとか使ってJobQueueにして完 全に処理を移譲したい ▫ ただし今のようにlambdaでさくっと書けたりしなくなるので悩 ましい