要点: 動画プレイヤーが送るすべての進捗通知を、個別のDBトランザクションにする必要はありません。
この記事の用語: Write-behind:変更をまとめて後からDBへ保存する方式。冪等:繰り返しても結果が変わらない性質。
現場の課題
15秒ごとの小さな更新も、利用者が数千人になればロック、インデックス、レプリケーションへ大きな負荷を与えます。一方で、業務上必要なのは多くの場合、最新の安全な進捗だけです。
単純な対策だけでは不十分な理由
学習進捗は更新頻度が高い一方、価値は各書き込みではなく安全な最新状態にあります。複数端末、オフライン、ワーカー停止を含めても進捗が戻らないマージ規則が中心です。
処理フロー
進捗の定期送信→Redis
最新進捗への集約→バッチワーカー
定期フラッシュ処理→データベース
永続チェックポイント
アーキテクチャ上の判断
進捗モデルを先に定義する
単調に増える完了状態と、現在の再開位置を分離します。完了はMAXで扱えます。再開位置にはSequence、またはClock Skewを制限したクライアント イベント Timeを使います。
Redisで原子的にマージする
1つのLua Scriptまたはトランザクションで、Sequenceの比較、集約状態の更新、Dirty登録を一操作にまとめます。こうすれば、複数端末からの更新でも採用済みの新しいイベントを失いません。
冪等なUpsertでFlushする
ワーカーは制限付きバッチを取得し、バージョン条件付きでCheckpointを書き、Redis バージョンが一致する項目だけPendingを解除します。Flush中に届いた新しいイベントは、次回用にそのまま残ります。
さらに深く考える
書き込み最適化の前に順序をモデル化する
クライアント Sequenceは、一つのセッション内でのみ意味を持ちます。再Install、別端末、オフライン キューの再生では、Counterが重複しがちです。そこでPlayback セッションごとにサーバー発行のIDを付け、セッション Sequenceとサーバー Acceptance バージョンの両方を保存します。マージ関数は決定的にします。Completionは単調増加、Watched RangeはUnion、Resume Positionは最大値ではなく最後に採用したイベントに従います。概念を分けておくことで、以前の章を見直した利用者が意図せず先へ飛ばされる問題を防げます。
Flush プロトコルを状態機械として設計する
ワーカーはLease付きで有限個のDirty キーを取得し、現在のバージョンを読み、条件付きUpsertを実行します。Commit後、RedisがFlush済みバージョンと一致する場合だけキーを解除し、新しいイベントがあればDirtyのまま残します。バッチ SizeはDBのParameter上限、Lock時間、レプリカ Lagを考慮します。ScheduleにJitterを入れ、複数ワーカーが同じ境界で一斉にFlushしないようにします。Graceful Shutdownでは、新しいバッチの取得を止め、実行中のトランザクションを完了し、Leaseを返す(または期限切れにする)ことで他のワーカーへ引き継ぎます。
実装手順
- 進捗をRedisへ安全にマージできた時点でリクエストへ応答します。
- 再送されても進捗が戻らない、決定的なマージ規則を使います。
- 一定間隔でバッチ保存し、失敗したレコードだけを再試行します。
コード例: バージョン付きマージと永続Checkpoint
incoming = { userId, lessonId, position, completed, seq }
state = redis.HGET(progressKey)
if incoming.seq <= state.seq then return state end
next.position = incoming.position
next.completed = state.completed OR incoming.completed
next.seq = incoming.seq
redis.HSET(progressKey, next)
redis.ZADD(dirtyKey, now(), progressKey)
DB UPSERT ... WHERE stored_seq < next.seqDirty SetのScoreを未保存の開始時刻にすると、バッチ取得と滞留時間のアラートの両方に使えます。それでも、Pendingを解除する前には必ずバージョンを再確認します。
想定しておく障害
- Redisがイベントを受け付けた後、レプリカまたはAOFへの反映前に再起動します。RPOを明示し、コース完了などの重要な節目は即時Flushします。
- スキーマやForeign キーの不整合により、Poison レコードが繰り返し失敗します。有限回の再試行の後にDead-letter Streamへ移し、バッチ全体を止めないようにします。
- 複数端末のCounterが比較できません。サーバーが発行するセッション IDとSequenceを使い、セッション間のマージ規則を明文化します。
監視すべきこと
| 指標 | 何が分かるか |
|---|---|
| 最古Dirty項目の滞留時間 | 学習者が受けている最悪の永続化遅延を直接示します。 |
| Dirty Set件数とIngest Rate | ワーカーが処理に追従できるかを示し、処理能力 Planningに使えます。 |
| Flush Conflictと再試行 | 同時更新、DB競合、誤ったバージョン条件を検出します。 |
| Redis永続化・レプリカ遅延 | インフラ障害時に失う可能性がある進捗の時間幅を示します。 |
設計の検証方法
- 2つのセッションから順序違反・重複のHeartbeatを生成し、最終状態を定義済みのマージ モデルと比較します。
- DB Commit後・Dirty Marker削除前にワーカーを停止し、再Flushで同じCheckpointになることを確認します。
- 受付済みイベントの永続化前にRedisを停止し、損失が宣言したRPO内に収まるか測定します。
- バッチへ不正なレコードを1件混ぜ、正常な利用者の処理が続くこととDead-letterへの移動を確認します。
- 定常Throughputだけでなく、ピーク Heartbeatと障害復旧後の再送トラフィックを同時に負荷試験します。
本番導入の進め方
既存の書き込み経路を正として残し、Write-behindを並行実行して講座、セッション、端末ごとの差分を比較します。差分の理由と復旧手順を確認してから切り替えます。
本番前チェックリスト
- Redisの永続化設定が、障害時に損失する可能性のある直近進捗の範囲を決定します。
- フラッシュ間隔は、DB書き込み削減量と復旧精度とのトレードオフです。
- バッファの滞留時間、未書き込みユーザー数、失敗数を継続的に監視します。
まとめ
Write-behindは、マージ結果が決定的で、進捗が後退せず、保存遅延を測れる場合に有効です。
