Skip to content

Write-Behindパターン(Write-Back)

⚡ Write-Behindパターン(Write-Back)

Section titled “⚡ Write-Behindパターン(Write-Back)”

Write-Behindパターンは、データの書き込みをキャッシュにのみ行い、後で非同期にデータベースに書き込むキャッシング戦略です。これにより、書き込み性能を大幅に向上させることができます。

Write-Behindパターンでは、データの書き込み時に、まずキャッシュに書き込み、その後非同期でデータベースに書き込みます。これにより、書き込みのレスポンスタイムを短縮できます。

動作フロー:

書き込み:
1. アプリケーションがデータを書き込む
2. キャッシュに書き込み(即座に完了)
3. 非同期でデータベースに書き込み(バックグラウンド)
4. 即座に成功を返す
読み取り:
1. キャッシュから取得を試みる
2. キャッシュにデータがある場合: キャッシュから返す(高速)
3. キャッシュにデータがない場合: データベースから取得してキャッシュに保存
// 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;
}
});
// ✅ 良い例: 複数の書き込みをバッチで処理
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);
});
}
}
}

利点:

  • 高い書き込み性能: キャッシュへの書き込みのみで即座に完了するため、非常に高速
  • スループットの向上: データベースへの負荷を分散できる
  • スケーラビリティ: 書き込みのスループットを大幅に向上できる

欠点:

  • データ損失のリスク: キャッシュが失われると、データベースに書き込まれていないデータが失われる可能性がある
  • データ一貫性: キャッシュとデータベースが一時的に不整合になる可能性がある
  • 実装の複雑さ: キュー管理、リトライ、エラーハンドリングが必要
  • 障害処理: アプリケーションがクラッシュした場合、未書き込みのデータを回復する必要がある
// ✅ 良い例: 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分後に再試行
});
});
// ✅ 良い例: 定期的にキャッシュとデータベースの整合性をチェック
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が適しているケース
// 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が適さないケース
// 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パターンは、書き込み性能が重要で、一時的なデータ損失を許容できる場合に適したキャッシング戦略です。

重要なポイント:

  • 高い書き込み性能: キャッシュへの書き込みのみで即座に完了
  • データ損失のリスク: キャッシュが失われると、データベースに書き込まれていないデータが失われる可能性がある
  • 実装の複雑さ: キュー管理、リトライ、エラーハンドリングが必要
  • 整合性チェック: 定期的にキャッシュとデータベースの整合性をチェックする必要がある

これらの特徴を理解し、適切なユースケースで使用することで、書き込み性能を大幅に向上させることができます。