📲
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人、3人以上で異なるメッセージ
- 非同期処理: レスポンス速度を犠牲にしない
- 自動クリーンアップ: 古いデータは自動削除
アーキテクチャ概要
全体構成
[API/Lambda] → [DynamoDB Buffer] → [EventBridge] → [Batch Processor] → [Push Notification]
↑
[SQS Queue] (高速化が必要な場合)
各コンポーネントの役割
- DynamoDB Buffer Table: 通知イベントの一時保存
- add-notification-buffer: 新しい通知イベントをバッファに追加
- process-notification-buffer: 定期的にバッファを処理・集約
- EventBridge Scheduler: 一定時間ごとに処理をトリガー
- 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のフォローもぜひ!
Discussion