💎

DynamoDBで実装する月末失効ポイントシステムの設計パターン

に公開

ポイント機能をゼロから実装するとき、データ構造や失効ロジックをどう組むかで迷いがちです。本記事では 「発行月を含め 12 か月目の月末に失効 という最小構成を題材に、DynamoDB 上で高速に回る 月次バケット + ラベル移動方式 を解説します。

代表的な 3 つの設計パターン

ポイントシステムの設計には、主に 3 つのアプローチがあります。それぞれの特徴と適したユースケースを理解することで、最適な設計を選択できます。

パターン 概要 向くケース 具体例と特徴
単一残高カラム 単純な加減算のみで残高を管理。履歴は保持せず、現在の残高のみを記録 小規模 / 履歴不要なストアクレジット 小規模オンラインショップのストアクレジットなど、シンプルな残高管理で十分なケース
Ledger (イベントログ) すべての取引を個別レコードとして記録。付与・利用・失効の履歴を完全に保持 日単位期限・返品多めの EC 詳細な取引履歴が必要な大規模 EC サイトなど、柔軟な期限管理と履歴追跡が重要なケース
月次バケット + ラベル移動 12 ヶ月分の残高を配列で管理し、月ごとの位置を追跡。月末に一括で失効処理 月末失効 & 大量アクセス対応 (→ 本記事) 月単位での失効管理が効率的な大規模ポイントシステムなど、パフォーマンスと保守性を重視するケース

月次バケット + ラベル移動 ― 原理と処理フロー

月次バケット方式は、月末失効のポイントシステムにおいて最も効率的な実装方法の一つです。シンプルなデータ構造と直感的な処理フローで、高いパフォーマンスを実現します。

データ構造(12 バケット版)

月次バケット方式の基本的なデータ構造は、12 個のバケットと 1 つのポインタで構成されます。このシンプルな構造により、ポイントの付与・利用・失効を効率的に処理できます。

フィールド 役割
buckets[0‥11] 当月から 11 か月後までの残高 [100, 50, 0]
currentIdx 今月を指すポインタ (0‥11) 0 (= 1 月)

処理フロー

月次バケット方式の処理フローは、付与・利用・失効の 3 つの基本操作で構成されます。それぞれの操作は独立しており、シンプルなルールに従って実行されます。

  1. 付与 — 発生したポイントをそのまま 当月バケット に加算。
  2. 利用 — 最も古いバケット(currentIdx + 11)から新しいバケットに向かって逆順に差し引く。最大 12 回で終わる。
  3. 月末失効currentIdx = (currentIdx + 1) % 12。外れたバケットをゼロクリアすれば 12 か月前のポイントが失効。

3 か月のタイムライン例

実際の動作を理解するために、1 年が 3 ヶ月であると簡略化して具体的例を見てみます。
各ステップでのデータの変化と、それに伴う操作の結果をトレースしてみます。

Step currentIdx buckets [0,1,2] 合計ポイント 操作・結果
初期 (1 月) 0 0 / 0 / 0 0 残高ゼロでスタート
1 月 +10 pt 0 10 / 0 / 0 10 当月バケットに加算
1 月月次処理 1 10 / 0 / 0 10 ポインタ +1 → 2 月
2 月 +50 pt 1 10 / 50 / 0 60 当月バケットに加算
2 月月次処理 2 10 / 50 / 0 60 ポインタ +1 → 3 月
3 月 +40 pt 2 10 / 50 / 40 100 当月バケットに加算
3 月月次処理 0 0 / 50 / 40 90 ポインタ +1 → 1 月。1 月の 10pt が失効
4 月 +30 pt 0 30 / 50 / 40 120 当月バケットに加算
4 月 -80 pt 0 30 / 0 / 10 40 2 月の 50pt、3 月の 30pt を消費

ポイント消費は FIFO(First In, First Out)方式で行われ、たった 3 バケットで「3 か月後失効」のサイクルが成立します。例えば 4 月の 80pt 消費では、最も古い 2 月バケットから 50pt、次に 3 月バケットから 30pt を消費します。これにより、古いポイントから順に消費されます。すべての操作は O(1) で実行されます。

長所と制約

この設計パターンには明確な利点と制約があります。システムの要件に応じて、これらの特性を適切に評価することが重要です。

  • 高速 — 付与・利用・失効すべてが定数時間。
  • シンプル — ポインタ移動 + ゼロクリアだけ。
  • 制約 — バケット方式は月単位の期限管理に最適化されていますが、日単位の期限管理(例:90 日で失効するポイントなど)には向いていません。そのような場合は、Ledger 方式を使用して各ポイントの正確な期限を管理することをお勧めします。

実装例

実際のコードを見ながら、月次バケット方式の具体的な実装方法を解説します。DynamoDB の特性を活かした効率的な実装方法を紹介します。

テーブル定義

  • Entity - PointLedger
key type memo
eventId string(UUID) EVENT#xxxxxxx
datetime datetime 記録日
userId number ユーザー ID
point number ポイント。自然数
type string (”issued" | "used" | "expired") 付与 or 利用 or 失効
expirationDatetime datetime | undefined 失効日
  • Entity - PointBuckets
key type
userId number USER#123456
bucket map 月毎のポイント配列を追加
  • DynamoDB - point テーブル
PK,GSI-1-SK (S) SK (S) GSI-1-PK (S) bucket(M) 備考
{EventID} datetime {datetime}
{EventID} userId {userId}
{EventID} point {point}
{EventID} type {type}
{EventID} expirationDatetime {expirationDatetime}
{userId} pointView {bucket} 集計した結果をユーザー ID 毎に置いておくもの

Entity を見ればイメージがつきやすいと思いますが、これを各種検索が行いやすくかつ GSI を貼りすぎないように、DynamoDB のテーブル定義に落とすときはGSI Overloadingという手法を使っています。
詳しくはこちらの記事がわかりやすいです。

https://qiita.com/_kensh/items/2351096e6c3bf431ff6f

コード例

月次バケット方式の本質は、利用時の FIFO 処理月末の失効処理 にあります。それぞれの処理の流れを見ていきましょう。

利用 (usePoints.ts)

export const usePoints = async (userId: string, spend: number) => {
  // 1. バケットの取得
  const { currentIdx, buckets } = await getBuckets(userId);
  let remain = spend;
  let consumed = 0;

  // 2. ポイント消費処理
  for (let i = 11; i >= 0 && remain > 0; i--) {
    const idx = (currentIdx + i) % 12;
    const available = buckets[idx] ?? 0;
    const take = Math.min(available, remain);
    if (take === 0) continue;

    await updateBucket(userId, idx, -take);
    remain -= take;
    consumed += take;
  }

  // 3. イベントログの記録
  if (consumed > 0) {
    await createPointHistory({
      id: `EVENT#${crypto.randomUUID()}`,
      myPageId: userId,
      point: -consumed,
      type: "used",
      datetime: new Date().toISOString(),
    });
  }

  return { consumed, remaining: remain };
};

ポイント利用の処理は、FIFO(First In, First Out)方式で行われます。最も古いバケット(currentIdx + 11)から順に消費していき、必要なポイント数に達するまで新しいバケットに向かって進みます。

処理の流れは以下の通りです:

  1. まず、現在のバケット状態を取得します。currentIdx は今月を指すポインタで、buckets は 12 ヶ月分のポイント残高を保持する配列です。

  2. 消費処理では、最も古いバケットから順に処理を行います:

    • i = 11 から開始し、i = 0 まで逆順に処理
    • 各バケットの利用可能ポイントと消費したいポイント数の小さい方を採用
    • バケットの残高を更新し、消費したポイント数を記録
  3. 最後に、消費が発生した場合のみイベントログを記録します。これにより、ポイントの利用履歴を追跡できます。

この実装の特徴は、最大 12 回のループで処理が完了することです。各バケットは 1 回だけ参照され、更新されるため、処理時間は常に一定(O(1))となります。

月次失効 (expireMonth.ts)

export const expireMonth = async (userId: string) => {
  // 1. バケットの更新
  const { currentIdx, buckets } = await getBuckets(userId);
  const expiredPoints = buckets[currentIdx] ?? 0;

  // 2. ポインタを進めて古いバケットをクリア
  const nextIdx = (currentIdx + 1) % 12;
  await updateBucketPointer(userId, nextIdx, currentIdx);

  // 3. 失効イベントの記録
  if (expiredPoints > 0) {
    await createPointHistory({
      id: `EVENT#${crypto.randomUUID()}`,
      myPageId: userId,
      point: -expiredPoints,
      type: "expired",
      datetime: new Date().toISOString(),
    });
  }
};

月末の失効処理は、シンプルなポインタ移動とバケットのクリアで実現されます。この処理により、12 ヶ月前のポイントが自動的に失効します。

処理の流れは以下の通りです:

  1. まず、現在のバケット状態を取得します。currentIdx が指すバケットには、現在の月のポイントが格納されています。

  2. 失効処理では、以下の 2 つの操作を行います:

    • ポインタを 1 つ進める(currentIdx + 1
    • 外れたバケット(currentIdx が指していたバケット)をクリア
  3. 失効したポイントがある場合のみ、イベントログを記録します。これにより、失効履歴を追跡できます。

この実装の特徴は、単純なポインタ移動だけで失効処理が完了することです。バケットの位置関係が固定されているため、複雑な日付計算や条件分岐が不要です。また、失効処理は定数時間(O(1))で完了するため、大量のユーザーに対する一括処理にも適しています。

実装時の考慮点

月次バケット方式を実装する際の重要な考慮点と対処方法について説明します。

日単位の期限管理

バケット方式は月単位の期限管理に最適化されていますが、日単位の期限管理(例:90 日で失効するポイントなど)には向いていません。そのような場合は、Ledger 方式を使用して各ポイントの正確な期限を管理することをお勧めします。

有効期間の拡張

システムの要件変更により有効期間を延長する必要が生じた場合は、バケット配列の長さを増やすことで対応できます。例えば、12 ヶ月から 24 ヶ月、36 ヶ月と延長する場合、配列の長さを増やし、モジュロ計算の除数を変更するだけで実現可能です。この柔軟性により、ビジネス要件の変化に柔軟に対応できます。

エラーリカバリー

月次ジョブの実行に失敗した場合のリカバリー方法として、Ledger のデータをリプレイしてバケットを再生成するバッチ処理を実装する方法があります。このアプローチにより、データの整合性を保ちながら、確実なリカバリーが可能になります。

まとめ

本記事では、月末失効のポイントシステムを効率的に実装するための「月次バケット + ラベル移動方式」について解説しました。DynamoDB を活用した実装例とともに、この設計パターンの主な特徴をまとめます:

12 個のバケットと 1 つのポインタによる直感的なデータ構造で、付与・利用・失効すべての操作が定数時間(O(1))で実行可能です。月末の失効処理は単純なポインタ移動で実現され、FIFO 方式によるポイント消費とバッチ処理による月次失効の自動化が特徴です。データ量が増えても性能が劣化せず、バックアップとリカバリーも容易です。

この設計パターンは、月末失効のポイントシステムにおいて、シンプルさと保守性を両立させることができます。DynamoDB の特性を活かすことで、スケーラブルで保守性の高いシステムを実現できます。

Discussion