📲

DynamoDB + Lambda で実現する通知バッファシステム

に公開

みんなでつくるデジタルスクラップブックアプリを開発しているSenspace CTOのりょーまです。
ソーシャルアプリにおいて「通知が多すぎる」という問題はクリティカルで、開発者的にもユーザー体験的にも、多すぎるのは悩ましいポイントです。例えば、30人が参加しているグループで、みんなが次々とコメントや写真を追加すると、「○○さんがコメントしました」の通知が連続で届いてしまいます。
これを解決するために、AWS DynamoDB、Lambda、EventBridge、SQSを組み合わせた通知バッファシステムを構築しました。

解決したい課題:通知スパム

従来の問題

リアルタイム共同編集では、こんな通知地獄が想像できます。

📱 田中さんがコメントしました (10:00)
📱 佐藤さんがコメントしました (10:01)
📱 山田さんがコメントしました (10:02)
📱 田中さんがコメントしました (10:03)
📱 佐藤さんがコメントしました (10:04)
  • 各アクションが発生するたびに即座に通知を送信
  • 同じ種類の通知でも個別に配信
  • ユーザーの集中を妨げる頻度で通知が届く

理想的な解決策

これを以下のように集約したい:

📱 田中さんと2人の他のメンバーがコメントしました (10:05)

設計方針:

  1. 時間的集約: 一定時間内の類似通知をまとめる
  2. テンプレート: 1人、2人、3人以上で異なるメッセージ
  3. 非同期処理: レスポンス速度を犠牲にしない
  4. 自動クリーンアップ: 古いデータは自動削除

アーキテクチャ概要

全体構成

[API/Lambda] → [DynamoDB Buffer] → [EventBridge] → [Batch Processor] → [Push Notification]
                      ↑
                   [SQS Queue] (高速化が必要な場合)

各コンポーネントの役割

  1. DynamoDB Buffer Table: 通知イベントの一時保存
  2. add-notification-buffer: 新しい通知イベントをバッファに追加
  3. process-notification-buffer: 定期的にバッファを処理・集約
  4. EventBridge Scheduler: 一定時間ごとに処理をトリガー
  5. SQS Queue: コメント通知など高速処理が必要な場合のキュー

DynamoDBテーブル設計の工夫

複合キー設計の背景

なぜこの設計にしたか?
通知を効率的に集約するには、「同じユーザーに対する同じ種類の通知」をグループ化する必要があります。

// 設計例(実際のコードを簡略化)
const notificationKey = {
  pk: `USER#${userId}#${eventType}`,  // パーティションキー
  sk: timestamp,                       // ソートキー
  ttl: threedays_from_now             // 自動削除
}

// 例: USER#user123#COMMENT#2024-01-01T10:00:00Z

この設計の利点:

  • 同じユーザー・同じ種類の通知が物理的に近くに配置される
  • タイムスタンプでソートされるため、時系列処理が高速
  • TTL(Time To Live)で古いデータが自動的に削除される

GSI(Global Secondary Index)の活用

背景: 未処理の通知を効率的に検索したい

// ProcessedIndex GSI の設計
{
  gsi_pk: "processed",     // "false" または "true"
  gsi_sk: "createdAt",     // 作成日時
}

なぜこの設計?

  • 未処理通知(processed=false)を一括で取得可能
  • 古い順に処理できるため、公平性が保たれる
  • バッチ処理のパフォーマンスが向上

Lambda関数の実装パターン

1. 通知バッファ追加(add-notification-buffer)

async function bufferNotification(event) {
  const notificationData = {
    pk: `USER#${event.userId}#${event.type}`,
    sk: new Date().toISOString(),
    processed: "false",
    actorName: event.actorName,
    groupTitle: event.groupTitle,
    ttl: Date.now() + (3 * 24 * 60 * 60) // 3日後に削除
  };
  
  await dynamodb.put(notificationData);
}

設計の背景:

  • シンプルなPUTオペレーションで高速書き込み
  • 同期処理により確実にバッファに保存
  • TTLで運用負荷を軽減

2. バッチ処理(process-notification-buffer)

// 簡略化されたコード例
async function processBatch() {
  // 1. 未処理通知を取得
  const unprocessed = await getUnprocessedNotifications();
  
  // 2. 通知を集約
  const aggregated = aggregateByUserAndType(unprocessed);
  
  // 3. 集約された通知を送信
  for (const notification of aggregated) {
    await sendAggregatedNotification(notification);
  }
  
  // 4. 処理済みマークを付与
  await markAsProcessed(unprocessed);
}

集約ロジックの設計背景:
通知の種類によって異なる待機時間を設定しています。

// 通知種類別の待機時間設定
const WAIT_TIME_CONFIG = {
  COMMENT: 10,      // コメント: 10分待機
  GROUP_JOIN: 60,   // グループ参加: 1時間待機
  OBJECT_ADDED: 60  // オブジェクト追加: 1時間待機
};

SQSを使った高速化戦略

なぜSQSを併用するか?

背景: コメント通知では追加のAPI呼び出しが必要

// 問題のあるパターン
async function addComment(commentData) {
  const comment = await saveComment(commentData);
  
  // ここで重い処理!
  const userInfo = await fetchUserInfo(comment.userId);
  const groupInfo = await fetchGroupInfo(comment.groupId);
  
  await bufferNotification({
    actorName: userInfo.name,
    groupTitle: groupInfo.title,
    // ...
  });
  
  return comment; // ユーザーが待たされる
}

SQSによる解決

// 改善されたパターン
async function addComment(commentData) {
  const comment = await saveComment(commentData);
  
  // 軽量なSQSメッセージを送信(数ms)
  await sqs.sendMessage({
    userId: comment.userId,
    groupId: comment.groupId,
    commentId: comment.id
  });
  
  return comment; // ユーザーの体感速度が向上
}

// 別のLambda関数で非同期処理
async function processNotificationQueue(sqsEvent) {
  for (const message of sqsEvent.Records) {
    const data = JSON.parse(message.body);
    
    // 重い処理を非同期で実行
    const [userInfo, groupInfo] = await Promise.all([
      fetchUserInfo(data.userId),
      fetchGroupInfo(data.groupId)
    ]);
    
    await bufferNotification({
      actorName: userInfo.name,
      groupTitle: groupInfo.title,
      // ...
    });
  }
}

この設計の利点:

  • API レスポンス時間が短縮
  • 外部API障害時の影響を局所化
  • SQSの自動リトライ機能で信頼性向上

賢い通知テンプレート設計

人数別メッセージ生成

// 簡略化された例
const templates = {
  COMMENT: {
    single: (actor, space) => 
      `${actor}さんがコメントしました`,
    
    double: (actor1, actor2, space) => 
      `${actor1}さんと${actor2}さんがコメントしました`,
    
    multiple: (firstActor, otherCount, space) => 
      `${firstActor}さんと${otherCount}人がコメントしました`
  }
};

EventBridge Schedulerの活用

定期処理の設計

// EventBridge Schedule Rule
{
  ScheduleExpression: "rate(2 hours)",
  Target: "process-notification-buffer-lambda"
}

まとめ

通知スパム問題を解決するために、DynamoDB + Lambda + EventBridge + SQSを組み合わせた通知バッファシステムを構築しました。

リアルタイム共同編集アプリでは、「即座に伝える」ことと「邪魔にならない」ことのバランスが重要です。AWS のサーバーレス技術を活用することで、スケーラブルで運用負荷の少ないソリューションを実現できました。

同様の課題に直面している開発者の参考になれば幸いです。Xのフォローもぜひ!
https://x.com/k_0214

Senspace

Discussion