Z-SOFT
アーキテクチャ · パフォーマンス

write-behind:書き込み負荷をデータベースに押し付けない設計

Z-EDU の動画プレーヤーは、受講者ごとに 15 秒おきに進捗 ping を送ります。旧来の処理では 20回の ping に約 80 クエリを要していました。Redis 経由の遅延書き込みに切り替え、Postgres へバッチで書き下ろすようにした結果、同じ 20回の ping が 2回の DB トランザクションだけで済むようになりました。本稿ではその仕組み、進捗を巻き戻させない idempotent な SQL、そして受け入れるべき trade-off を説明します。

Z-SOFT 開発チーム 11分で読めます

データベースを疲弊させやすい負荷があります。小さな書き込みが大量に発生し、その一件ごとが持つ情報はごくわずか、というものです。 注文でも入金でもなく、「この受講者は今 412 秒目を見ている」というだけの情報です。30 秒後にはその数字が 442 になります。 進捗が数秒失われても、体験にはさほど影響しないかもしれません。しかしその数字をすべて Postgres に直接書き込むと、 この副次的な書き込み経路が、もっと重要な業務処理よりも先にシステムを遅くしてしまうことがあります。

Z-EDU ではこの課題を write-behind(遅延書き込み)で解いています。ユーザーの request は Redis に書いて即座に返し、 worker がデータをまとめて周期的に Postgres へ書き下ろします。実測の結果は 20回の ping → 2回の DB トランザクション。同じ 20回の ping に対し、旧経路では約 80 クエリを要していました。

本稿は write-behind をどこでも使える万能の処方箋として売り込むものではありません。これは即時の耐久性を throughput と引き換えにする技術です。 対象とするデータの種類を間違えれば、システムを最適化しているのではなく、静かにデータを失う仕組みを作っていることになります。

1. 課題:書き込みは密、1回あたりの価値は極めて低い

動画プレーヤーは、視聴中の受講者ごとに 15 秒おきに進捗 ping を送ります。これは検討したうえでの数値です。これより間隔を空けると、 受講者がページを離れたときに再生位置を失いすぎる可能性があり、これより詰めると不要にリソースを消費します。ただ、これを書き込み負荷の側から見てみます。

25 分の講義を最後まで視聴する受講者は、約 100回の ping を送ります。旧来の処理経路は、一般的な「きれいな」書き方に従って、 ping ごとに次のすべてを実行していました。現在の進捗を読む、講義時間と完了しきい値を知るためにレッスンの metadata を読む、進捗を更新する、 さらにコース進捗の集計を更新する。4 クエリです。20回の ping を掛ければ約 80 クエリ。さらにピーク時に同時視聴する数百人の受講者を掛ければ、 その数字はもう小さくありません。

要点は、この負荷が業務上の価値に比例して増えないことです。増える要因は主に受講者数と動画の長さです。 ユーザーが普段「続きから学習する」を押したときにしか読み返さない整数のカラムのために、Postgres の connection pool、WAL、vacuum のコストを払っていることになります。

どんな解決策を考えるより先に、やるべきことが一つあります。失っても許される度合いでデータを分類することです。 これは前提条件であり、任意の手順ではありません。

データの種類数秒失われたら?書き込み方法
動画の再生位置、視聴済み秒数 受講者が数秒巻き戻すだけ。誰も文句を言わない。 Redis 経由の write-behind
課題の提出物、テストの解答 受講者が費やした労力が失われる。許容できない。 DB へ直接書き込み、トランザクション内で
点数、採点結果 成績記録が狂う。絶対に不可。 DB へ直接書き込み、トランザクション内で
決済、コース登録 金銭の損失、アクセス権の喪失。 DB へ直接書き込み、トランザクション内で
write-behind を使ってよいのは、上の表の最初の行だけです。設計とコードの両方でその境界をはっきり引けないのであれば、この技術は使わないでください。

2. アーキテクチャ:ping は Redis へ、worker が Postgres へまとめて流す

全体の流れを文章で示します。

動画プレーヤー(15秒ごとに ping)
      |
      v
POST /api/v1/progress/ping   -- NestJS、JWT を検証し、受講者の権限を確認
      |
      v
Redis:  HSET progress:pending <courseId>:<lessonId>:<userId> {watchedSec, completed, ts}
        SADD progress:dirty   <同じキー>
      |
      +--> client に即座に 204 を返す(Postgres には触れない)
      |
      v
Worker(BullMQ、5秒周期)
      |  1. "dirty" キー集合を取得して削除(atomic)
      |  2. 各キーの最新値を読む
      |  3. 1本の INSERT ... ON CONFLICT DO UPDATE にまとめる
      v
PostgreSQL  -- バッチ全体で 1 トランザクション

強調したい点は、ユーザーの request がPostgres に一切触れないことです。Redis の hash に書き込み、そのキーを「dirty」と印を付けるだけです。 同じ受講者が 5 秒周期の中で 5回 ping を送っても、Redis は最後の値だけを保持します。手前の 4回は完全に吸収され、DB への負荷を生みません。 これが「20回の ping → 2回のトランザクション」という数字の出どころです。データはキー単位(同じレッスンに対する複数の ping)でまとめられ、 さらにバッチ単位(同じ周期内の複数の受講者)でまとめられます。

API 側の ping 受け取り部分を、簡略化して示します。

// progress.service.ts
const PENDING = 'progress:pending';
const DIRTY = 'progress:dirty';

async ping(userId: string, lessonId: string, dto: PingDto): Promise<void> {
  const key = `${dto.courseId}:${lessonId}:${userId}`;

  const payload = JSON.stringify({
    watchedSec: dto.watchedSec,
    completed: dto.watchedSec >= dto.durationSec * COMPLETE_RATIO,
    ts: Date.now(),
  });

  try {
    await this.redis
      .multi()
      .hset(PENDING, key, payload)   // 上書き:最新の値だけが生き残る
      .sadd(DIRTY, key)              // DB へ流す必要があると印を付ける
      .exec();
  } catch (err) {
    // Redis がダウン -> ping を黙って握り潰してはいけない。第4節を参照
    this.logger.warn(`redis down, fallback to direct write: ${err.message}`);
    await this.flushOne(key, payload);
  }
}

次が worker です。5 秒ごとに動き、dirty なキー集合を取得して 1 バッチとして書き込みます。5 秒という周期は意図的な選択です。 リスクの窓が数秒で済む程度に短く、同じ受講者の複数の ping が同じバッチに入る程度に長い、という線です。

// progress.worker.ts — 5秒ごとに実行
async flush(): Promise<number> {
  // dirty 集合の取得 & 削除を1ステップで行い、他の worker が重複して拾うのを防ぐ
  const keys: string[] = await this.redis.spop(DIRTY, BATCH_MAX);
  if (keys.length === 0) return 0;

  const raw = await this.redis.hmget(PENDING, ...keys);
  const rows = keys
    .map((k, i) => raw[i] && this.toRow(k, JSON.parse(raw[i])))
    .filter(Boolean);
  if (rows.length === 0) return 0;

  // バッチ全体を 1本の INSERT、1 トランザクションで
  await this.prisma.$executeRaw(this.buildUpsert(rows));

  await this.redis.hdel(PENDING, ...keys);
  return rows.length;
}

順序に注意してください。まず SPOP、次に DB への書き込み、その後にようやく HDEL です。worker が途中で落ちた場合、 キーはすでに dirty 集合から pop されていますが、値は pending hash に残っています。 zedu-maintenance キューのクリーンアップ job がこうした孤立したキーを走査し、dirty 集合へ戻します。 ざっと眺めるだけでは見落としやすい部分です。write-behind は「Redis に書いて flush する」だけのものではありません。小さな状態機械であり、 故障し得るすべての点に対して処理経路が必要です。

3. なぜ集約は必ず idempotent でなければならないのか

ここが最も間違えやすい部分であり、同時に write-behind が安全か危険かを決める部分です。

バッチでまとめるシステムでは、順序を制御できません。ネットワークの timeout の後にバッチが retry されることがあります。 複数の replica に scale すれば、2つの worker が並行して動くこともあります。古く遅れたバッチが、より新しいバッチの後に DB へ届くこともあります。 集約のルールが「後から来た値が勝つ」(last write wins)であれば、次のシナリオは遅かれ早かれ発生します。

t=0s   バッチA: watchedSec = 300   (ネットワークで詰まり、DB に届いていない)
t=5s   バッチB: watchedSec = 340   -> DB への書き込みに成功。DB = 340
t=6s   バッチA が到着(retry)     -> last write wins: DB = 300  ❌ 巻き戻る

受講者が 5 分目まで視聴し、PC を閉じ、再び開くとプレーヤーが 4 分目に戻っています。さらに悪いことに、レッスンが完了と記録済みだった場合、 古いバッチがその状態を消してしまい、コース進捗が 100% から 90% に下がることもあります。この種の不具合はすぐに表面化しないのが普通です。 散発的に現れ、最初の報告が来た頃にはログが原因追跡に足りなくなっていることもあります。

対策はアプリケーション層にはありません(ロック、バージョン、TypeScript 上での比較 — いずれもそれ自体に race condition を抱えています)。 対策は集約のルールを SQL そのものへ落とし込むことです。そうすれば Postgres が、一つのトランザクションの中で monotonic であることを保証してくれます。

INSERT INTO "LessonProgress"
  ("userId", "lessonId", "courseId", "watchedSec", "completedAt", "updatedAt")
VALUES
  ($1, $2, $3, $4, $5, NOW()),
  ($6, $7, $8, $9, $10, NOW())
  -- ... バッチの残りも、同じ1本の文に含める
ON CONFLICT ("userId", "lessonId") DO UPDATE SET
  -- 進捗は上がることだけが許され、決して下がらない
  "watchedSec" = GREATEST(
      "LessonProgress"."watchedSec",
      EXCLUDED."watchedSec"
  ),
  -- 最初の完了時刻を保持する。遅れて届いたバッチは古い時刻を上書きせず、
  -- 既存の時刻を消すこともできない(COALESCE が先に古い値を取る)
  "completedAt" = COALESCE(
      "LessonProgress"."completedAt",
      EXCLUDED."completedAt"
  ),
  "updatedAt" = NOW();

2つの式をよく読んでください。GREATEST は更新を monotonic な集約に変えます — 値は上がるだけで、決して引き下げられません。バッチがどの順序で届こうと、 何回実行し直そうと、最終結果は必ず「これまでに見た最大値」になります。COALESCE は完了時刻について同じことを行います。 一度時刻が入れば、どのバッチもそれを上書きできません。

帰結は非常に強力です。この文は idempotent です。同じデータで 1回実行しても 5回実行しても、結果は同じになります。つまり retry は完全に安全になり、分散ロックのコードを 1 行も書かずに worker を複数の replica で並行実行できます。 並行性に関する複雑さのすべてが 1本の SQL に圧縮されます — システムの中でそれを本当に保証できる唯一の場所です。

ルール:バッチでまとめるすべてのシステムにおいて、集約は commutative かつ monotonic でなければなりません。「どちらのバッチが先に届いたか?」と問う必要があるなら、その設計はすでに誤っています。

これは、はっきり述べておくべき限界も課します。この種の write-behind が適するのは、自然な集約を持つデータ — max、min、 加算、集合の和 — だけです。そうした集約を持たないデータ(たとえば「注文の最新ステータス」)では idempotent な 式を書くことができません。それは、この技術がそのデータ向きではないという明確なシグナルです。

4. Redis が落ちたとき

write-behind を production に載せる前に必ず答えるべき問いがあります。Redis が落ちたらどうなるのか? 答えが「システムが ping を受け付けなくなる」であれば、そのキャッシュは single point of failure になっています。

私たちの原則はデータの重要度に応じた fail-safe です。各層はそれぞれ異なるかたちで縮退し、 その縮退のかたちは、その層でデータを失ったときのコストの大きさに基づいて選びます。

コンポーネントRedis が落ちると...なぜそう選ぶのか
読み取りキャッシュ そのまま DB へ行く。遅いが、正しい。 遅いほうが、誤っているよりも、500 よりもましだから。
動画の進捗(write-behind) ping ごとに DB へ直接書く。遅く、コストもかかるが、失われない。 障害はまれ。その間に負荷が悪化するほうが、受講者の進捗を失うよりましだから。
点数、提出物 無関係 — もともと常に DB へ直接書いている。 write-behind を通すことは一度も許していないから。
rate limit fail-open:request を通す。 一つのカウンターに API 全体を落とさせないため。

rate limit の行に注目してください。こちらは fail-open ですが、進捗のほうは直接書き込み経路へ切り替えます。正反対の選択であり、しかも両方 正しい — 誤ったときのコストが違うからです。10 分の障害中に rate limit のブロックを数回取りこぼすのは許容できますが、 その 10 分の間に全受講者の学習進捗を失うのは許容できません。縮退モードを習慣で選んではいけません。 「これを失うといくら損をするのか」と問うて選んでください。

Redis が落ちたときの最悪ケースは、まだ flush されず hash に残っていた ping の分、動画の再生位置が数秒失われることです。 点数は失われず、提出物も失われません — それらはこの経路を一度も通っていないからです。

5. BullMQ のキュー:進捗だけではない

request 経路の外へ仕事を追い出す基盤ができると、非同期で処理できる箇所がほかにも見えてきます。Z-EDU では 2つの BullMQ キューを動かしています。

実装については、最も単純な方法を選びました。worker を API と同一プロセスで動かすことです。専用サービスも、 専用イメージも、専用パイプラインもありません。

// app.module.ts
const WORKER_ENABLED = process.env.WORKER_ENABLED !== '0';

@Module({
  imports: [
    // ...
    ...(WORKER_ENABLED ? [WorkerModule] : []),
  ],
})
export class AppModule {}

ただし逃げ道は用意してあります。WORKER_ENABLED=0 というフラグで、あるプロセスの worker を無効にできます。worker の負荷が 分離を要する規模になったときにやるべきことは、まったく同じイメージを使う service を Swarm にもう一つ立て、 そちらに WORKER_ENABLED=1、API の replica には WORKER_ENABLED=0 を設定するだけです。コードは 1 行も変えません。

これは私たちが優先するタイプのアーキテクチャ上の判断です。今は単純な案を選び、しかし将来のより複雑な案が書き直しを必要としないよう、 環境変数をあらかじめ用意しておく、というものです。

6. 可観測性:必ず持つべき指標

write-behind には警戒すべき性質が一つあります。壊れたとき、システムが必ずしもエラーを出さないことです。worker が死んでも ping は受け付けられ、 API は 204 を返し続け、ユーザーは普通に動画を見られ、500 も出ず、request ログに exception も出ません。 データはただ Redis に積み上がり、Postgres には決して届きません。それに気づくのは数時間後 — あるいは数日後 —、 ある受講者が「なぜ自分の進捗が記録されていないのか」と尋ねてきたときです。

その状況に陥らない唯一の方法は、キューのサイズを一級の指標として扱うことです。Z-EDU の /health endpoint は pendingProgress — 書き込み待ちの進捗レコード数 — を返します。

// health.controller.ts
@Get('/health')
@Public()
async health() {
  const pendingProgress = await this.redis.hlen('progress:pending');

  return {
    status: 'ok',
    pendingProgress,   // 通常は ≈ 0
  };
}

この指標の読み方です。

キューの深さの指標がないなら、write-behind を導入してはいけません。あなたが作っているのは、より速いシステムではなく — 誰にも気づかれずに データを失うシステムです。

実測の結果

評価軸旧経路(直接書き込み)write-behind
20回の ping に対する DB 負荷 約 80 クエリ(4 クエリ × 20回の ping) 2回の DB トランザクション
ユーザーの request 経路上の DB あり — ping ごとに Postgres を待つ なし — Redis にしか触れない
動画の再生位置の耐久性 書き込んだ時点で耐久性がある リスクの窓は最大で約 5 秒
retry / 並行実行時の安全性 到着順に依存する SQL 内の GREATEST により idempotent
運用の複雑さ 低い より高い:worker、キュー、アラートが増える

最後の 2 行が代償であり、80 → 2 という数字の陰に隠すつもりはありません。即時の耐久性を throughput と引き換えにし、同時に 単純さを性能と引き換えにしています。動画の再生位置についてはそれは妥当な取引です。点数については、それは重大な誤りです。

7. write-behind を使うべきでないとき

次のチェックリストは、write-behind を使うべきかどうかの判断に役立ちます。どれか 1 行でも「はい」なら、そこで立ち止まるべきです。

まとめ

write-behind は単純な高速化の小技ではありません。それは意識的な決断です。特定の一層のデータについて即時の耐久性を犠牲にし、 その代わりにデータベースが無意味な書き込みを背負わずに済むようにする、という決断です。正しく機能するのは 3つの条件が同時に満たされるときだけです。データが 数秒失われることを許容できること、集約を SQL 内で idempotent な式として書けること、そして壊れたときにそれを知る指標があることです。

Z-EDU では、この 3つの条件がいずれも当てはまります。動画の再生位置は数秒の欠落を許容でき、GREATEST が idempotent の部分を担い、 /health 上の pendingProgress がアラートの部分を受け持ちます。その代わりに、20回の ping は約 80 クエリではなく 2回の DB トランザクションだけで済むようになりました。

この技術を自分のシステムに適用するつもりなら、第2節のコードからではなく、第1節のデータ分類の表から始めてください。 難しいのは、どのデータが数秒の遅延や欠落を許されるのかを決めることであって、Redis と会話するコードのほうではありません。

Z-SOFT はこのようなシステムを開発しています

本記事で紹介した技術は、すべて自社プロダクトの本番環境で実際に稼働しているものです。高負荷に耐え、セキュアで、 長期にわたって運用できるシステムをお考えでしたら、Z-SOFT の開発チームにぜひご相談ください。

関連記事