Write-Behindパターン(Write-Back)
⚡ Write-Behindパターン(Write-Back)
Section titled “⚡ Write-Behindパターン(Write-Back)”Write-Behindパターンは、データの書き込みをキャッシュにのみ行い、後で非同期にデータベースに書き込むキャッシング戦略です。これにより、書き込み性能を大幅に向上させることができます。
📋 Write-Behindパターンとは
Section titled “📋 Write-Behindパターンとは”Write-Behindパターンでは、データの書き込み時に、まずキャッシュに書き込み、その後非同期でデータベースに書き込みます。これにより、書き込みのレスポンスタイムを短縮できます。
動作フロー:
書き込み:1. アプリケーションがデータを書き込む2. キャッシュに書き込み(即座に完了)3. 非同期でデータベースに書き込み(バックグラウンド)4. 即座に成功を返す
読み取り:1. キャッシュから取得を試みる2. キャッシュにデータがある場合: キャッシュから返す(高速)3. キャッシュにデータがない場合: データベースから取得してキャッシュに保存基本的な実装
Section titled “基本的な実装”// Node.js + Redisの実装例import Redis from 'ioredis';import { Queue } from 'bull';
const redis = new Redis({ host: 'localhost', port: 6379,});
// 書き込みキューconst writeQueue = new Queue('write-queue', { redis: { host: 'localhost', port: 6379 },});
class UserService { // Write-Behindパターン: キャッシュに書き込み、非同期でDBに書き込み async createUser(userData: UserData) { const userId = generateUserId(); const user = { id: userId, ...userData };
// 1. キャッシュに書き込み(即座に完了) await redis.setex(`user:${userId}`, 3600, JSON.stringify(user));
// 2. データベースへの書き込みをキューに追加(非同期) await writeQueue.add('create-user', { userId, userData, });
// 3. 即座に成功を返す return user; }
async updateUser(userId: string, userData: UserData) { // 1. 現在のデータを取得(キャッシュまたはDB) const currentUser = await this.getUser(userId);
// 2. 更新されたデータを作成 const updatedUser = { ...currentUser, ...userData };
// 3. キャッシュを更新(即座に完了) await redis.setex(`user:${userId}`, 3600, JSON.stringify(updatedUser));
// 4. データベースへの書き込みをキューに追加(非同期) await writeQueue.add('update-user', { userId, userData: updatedUser, });
return updatedUser; }
// 読み取り: Cache-Asideパターンと同様 async getUser(userId: string) { const cached = await redis.get(`user:${userId}`); if (cached) { return JSON.parse(cached); }
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
if (!user) { throw new Error('User not found'); }
await redis.setex(`user:${userId}`, 3600, JSON.stringify(user)); return user; }}
// キューワーカー: バックグラウンドでデータベースに書き込みwriteQueue.process('create-user', async (job) => { const { userId, userData } = job.data;
try { await db.query('INSERT INTO users SET ?', [userData]); console.log(`User ${userId} created in database`); } catch (error) { console.error(`Failed to create user ${userId}:`, error); // リトライロジックを実装 throw error; }});
writeQueue.process('update-user', async (job) => { const { userId, userData } = job.data;
try { await db.query('UPDATE users SET ? WHERE id = ?', [userData, userId]); console.log(`User ${userId} updated in database`); } catch (error) { console.error(`Failed to update user ${userId}:`, error); // リトライロジックを実装 throw error; }});バッチ書き込みの実装
Section titled “バッチ書き込みの実装”// ✅ 良い例: 複数の書き込みをバッチで処理class WriteBehindCache { private writeQueue: Map<string, any> = new Map(); private flushInterval: number = 5000; // 5秒ごとにフラッシュ
constructor() { // 定期的にキューをフラッシュ setInterval(() => { this.flush(); }, this.flushInterval); }
async set(key: string, value: any, ttl: number = 3600) { // 1. キャッシュに書き込み await redis.setex(key, ttl, JSON.stringify(value));
// 2. 書き込みキューに追加 this.writeQueue.set(key, value); }
private async flush() { if (this.writeQueue.size === 0) { return; }
const entries = Array.from(this.writeQueue.entries()); this.writeQueue.clear();
// バッチでデータベースに書き込み const pipeline = db.pipeline();
for (const [key, value] of entries) { const userId = key.replace('user:', ''); pipeline.query('UPDATE users SET ? WHERE id = ?', [value, userId]); }
try { await pipeline.exec(); console.log(`Flushed ${entries.length} entries to database`); } catch (error) { console.error('Flush error:', error); // エラーが発生した場合、キューに戻す entries.forEach(([key, value]) => { this.writeQueue.set(key, value); }); } }}利点:
- 高い書き込み性能: キャッシュへの書き込みのみで即座に完了するため、非常に高速
- スループットの向上: データベースへの負荷を分散できる
- スケーラビリティ: 書き込みのスループットを大幅に向上できる
欠点:
- データ損失のリスク: キャッシュが失われると、データベースに書き込まれていないデータが失われる可能性がある
- データ一貫性: キャッシュとデータベースが一時的に不整合になる可能性がある
- 実装の複雑さ: キュー管理、リトライ、エラーハンドリングが必要
- 障害処理: アプリケーションがクラッシュした場合、未書き込みのデータを回復する必要がある
実践的な実装パターン
Section titled “実践的な実装パターン”1. 永続化キュー
Section titled “1. 永続化キュー”// ✅ 良い例: Redisをキューとして使用(永続化)import Bull from 'bull';
const writeQueue = new Bull('write-queue', { redis: { host: 'localhost', port: 6379, }, settings: { stalledInterval: 30000, // 30秒ごとにスタールしたジョブをチェック maxStalledCount: 1, // 最大1回までリトライ },});
// ジョブの設定writeQueue.add('create-user', { userId, userData }, { attempts: 3, // 最大3回までリトライ backoff: { type: 'exponential', delay: 2000, // 2秒から開始 }, removeOnComplete: true, // 成功したジョブを削除 removeOnFail: false, // 失敗したジョブは保持(手動で確認可能)});2. エラーハンドリングとリトライ
Section titled “2. エラーハンドリングとリトライ”// ✅ 良い例: エラーハンドリングとリトライを含むwriteQueue.process('create-user', async (job) => { const { userId, userData } = job.data;
try { await db.query('INSERT INTO users SET ?', [userData]); return { success: true }; } catch (error) { console.error(`Failed to create user ${userId}:`, error);
// データベースエラーの場合、リトライ if (error.code === 'ER_LOCK_WAIT_TIMEOUT') { throw new Error('Database lock timeout, retry'); }
// その他のエラーは再スロー throw error; }});
// 失敗したジョブの処理writeQueue.on('failed', async (job, error) => { console.error(`Job ${job.id} failed:`, error);
// 失敗したジョブを別のキューに移動(手動で確認可能) await writeQueue.add('failed-write', job.data, { delay: 60000, // 1分後に再試行 });});3. データの整合性チェック
Section titled “3. データの整合性チェック”// ✅ 良い例: 定期的にキャッシュとデータベースの整合性をチェックclass ConsistencyChecker { async checkConsistency() { // 1. キャッシュ内のすべてのキーを取得 const keys = await redis.keys('user:*');
for (const key of keys) { const userId = key.replace('user:', '');
// 2. データベースに存在するか確認 const dbUser = await db.query('SELECT * FROM users WHERE id = ?', [userId]); const cacheUser = JSON.parse(await redis.get(key));
// 3. 不整合を検出 if (!dbUser) { console.warn(`User ${userId} exists in cache but not in database`); // オプション: データベースに書き込む await this.syncToDatabase(userId, cacheUser); } else if (JSON.stringify(dbUser) !== JSON.stringify(cacheUser)) { console.warn(`User ${userId} data mismatch between cache and database`); // オプション: データベースのデータでキャッシュを更新 await redis.setex(key, 3600, JSON.stringify(dbUser)); } } }
private async syncToDatabase(userId: string, userData: any) { try { await db.query('INSERT INTO users SET ?', [userData]); console.log(`Synced user ${userId} to database`); } catch (error) { console.error(`Failed to sync user ${userId}:`, error); } }}
// 定期的に整合性をチェックconst checker = new ConsistencyChecker();setInterval(() => { checker.checkConsistency();}, 60000); // 1分ごとWrite-Behindが適しているケース
Section titled “Write-Behindが適しているケース”// ✅ Write-Behindが適しているケース
// 1. 書き込みが非常に多い場合// ログ、イベント、メトリクスなど、書き込みが多く読み取りが少ないデータasync function logEvent(event: Event) { await redis.setex(`event:${event.id}`, 3600, JSON.stringify(event)); await writeQueue.add('log-event', event); return event;}
// 2. 書き込み性能が最重要の場合// リアルタイムなデータ収集など、書き込みのスループットが重要な場合async function recordMetric(metric: Metric) { await redis.setex(`metric:${metric.id}`, 3600, JSON.stringify(metric)); await writeQueue.add('record-metric', metric); return metric;}
// 3. 一時的なデータ損失を許容できる場合// 分析データなど、一部のデータが失われても問題ない場合async function recordAnalytics(data: AnalyticsData) { await redis.setex(`analytics:${data.id}`, 3600, JSON.stringify(data)); await writeQueue.add('record-analytics', data); return data;}Write-Behindが適さないケース
Section titled “Write-Behindが適さないケース”// ❌ Write-Behindが適さないケース
// 1. データの一貫性が最重要の場合// 金融取引など、データの一貫性が絶対に必要な場合async function transferMoney(fromId: string, toId: string, amount: number) { // Write-Behindは使用しない(Write-Throughまたはトランザクションを使用) await db.transaction(async (tx) => { await tx.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]); await tx.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]); });}
// 2. 即座にデータを読み取る必要がある場合// 書き込み後に即座に読み取る場合、Write-Throughの方が適しているasync function createOrder(orderData: OrderData) { // Write-Behindは使用しない(Write-Throughを使用) const order = await db.query('INSERT INTO orders SET ?', [orderData]); await redis.setex(`order:${order.id}`, 3600, JSON.stringify(order)); return order;}Write-Behindパターンは、書き込み性能が重要で、一時的なデータ損失を許容できる場合に適したキャッシング戦略です。
重要なポイント:
- 高い書き込み性能: キャッシュへの書き込みのみで即座に完了
- データ損失のリスク: キャッシュが失われると、データベースに書き込まれていないデータが失われる可能性がある
- 実装の複雑さ: キュー管理、リトライ、エラーハンドリングが必要
- 整合性チェック: 定期的にキャッシュとデータベースの整合性をチェックする必要がある
これらの特徴を理解し、適切なユースケースで使用することで、書き込み性能を大幅に向上させることができます。