ストーリー
「送金処理で片方だけ引き落とされて、もう片方に入金されなかった…」
障害報告を受けた高橋アーキテクトは冷静に言う。
「トランザクションが正しく設計されていないとこうなる。ACID特性を理解しているか? そしてNoSQLの世界ではBASEという別のモデルがある。どちらも理解した上で、システムに求められる整合性レベルを設計するんだ」
ACID特性
RDBのトランザクションが保証する4つの特性。
Atomicity(原子性)
トランザクション内のすべての操作が「完全に成功」か「完全に失敗」のどちらか。
-- 送金処理: 原子性の例
BEGIN;
UPDATE accounts SET balance = balance - 10000 WHERE id = 1; -- 引き落とし
UPDATE accounts SET balance = balance + 10000 WHERE id = 2; -- 入金
COMMIT;
-- どちらかが失敗したら自動的にROLLBACK
// Prismaでのトランザクション
await prisma.$transaction(async (tx) => {
// この中のすべての操作がアトミックに実行される
await tx.account.update({
where: { id: 1 },
data: { balance: { decrement: 10000 } },
});
await tx.account.update({
where: { id: 2 },
data: { balance: { increment: 10000 } },
});
// ここで例外が発生すると、すべてロールバック
});
Consistency(一貫性)
トランザクション前後でデータの整合性制約が保たれる。
-- 残高が負にならない制約
ALTER TABLE accounts ADD CONSTRAINT check_balance CHECK (balance >= 0);
-- この制約により、残高不足の引き落としは自動的に失敗する
BEGIN;
UPDATE accounts SET balance = balance - 1000000 WHERE id = 1;
-- ERROR: new row for relation "accounts" violates check constraint
ROLLBACK;
Isolation(分離性)
同時に実行される複数のトランザクションが互いに干渉しない。
トランザクションA: トランザクションB:
BEGIN; BEGIN;
READ balance → 10000 READ balance → 10000
balance - 5000 balance - 3000
WRITE balance = 5000 WRITE balance = 7000
COMMIT; COMMIT;
-- 分離性がないと: 最終残高が7000(Aの変更が消失)
-- 分離性があると: 正しく2000になる
Durability(永続性)
コミットされたデータは、システム障害が発生しても失われない。
COMMIT 完了
↓
WAL(Write-Ahead Log)に書き込み
↓
ディスクに永続化
↓
たとえサーバーが落ちても、WALからデータを復旧可能
BASE特性
分散NoSQLシステムが採用するモデル。ACIDの対極。
Basically Available(基本的に利用可能)
システムは常にレスポンスを返す(ただし最新データとは限らない)。
Soft State(柔軟な状態)
データの状態は時間とともに変化しうる(外部入力なしでも)。
Eventually Consistent(結果整合性)
時間が経てば最終的にすべてのレプリカが同じ状態に収束する。
Write → Node A: value = 100 ✓(即座に反映)
Node B: value = 50 (まだ古い値)
Node C: value = 50 (まだ古い値)
... 数ミリ秒~数秒後 ...
Node A: value = 100 ✓
Node B: value = 100 ✓(レプリケーション完了)
Node C: value = 100 ✓(レプリケーション完了)
ACID vs BASE
| 特性 | ACID | BASE |
|---|---|---|
| 整合性 | 即座に一貫 | 結果的に一貫 |
| 可用性 | 制限あり | 高い |
| パフォーマンス | オーバーヘッドあり | 高い |
| スケーラビリティ | 垂直スケール | 水平スケール |
| 適用場面 | 金融、在庫管理 | SNS、ログ、キャッシュ |
結果整合性のパターン
Read-Your-Writes Consistency
自分が書き込んだデータは即座に読める。
// プロフィール更新後、更新結果を表示
async function updateAndShowProfile(userId: string, data: ProfileData) {
// 書き込み: プライマリノードに
await primaryDb.updateProfile(userId, data);
// 読み取り: プライマリから読む(レプリカは遅延がある)
return await primaryDb.getProfile(userId);
}
Monotonic Read Consistency
一度読んだ値より古い値を読むことがない。
// セッションスティッキー: 同じレプリカから読む
async function getProfile(userId: string, sessionId: string) {
const replicaId = hash(sessionId) % replicaCount;
return await replicas[replicaId].getProfile(userId);
}
Causal Consistency
因果関係のある操作は順序が保たれる。
ユーザーA: 投稿「質問です」 → timestamp: T1
ユーザーB: 返信「回答です」 → timestamp: T2 (T2 > T1)
ユーザーC: T2の返信は見えるがT1の質問が見えない → 因果整合性違反
TypeScriptでの整合性レベルの設計
// 整合性レベルを明示的に設計
interface DataAccessConfig {
consistencyLevel: 'strong' | 'eventual' | 'session';
}
class UserRepository {
// 強い整合性が必要な操作
async getBalance(userId: string): Promise<number> {
// プライマリDBから読む
return await this.primaryDb.query(
'SELECT balance FROM accounts WHERE user_id = $1',
[userId]
);
}
// 結果整合性で十分な操作
async getFollowerCount(userId: string): Promise<number> {
// キャッシュから読む(数秒の遅延は許容)
const cached = await this.redis.get(`followers:${userId}`);
if (cached) return parseInt(cached);
const count = await this.replicaDb.query(
'SELECT COUNT(*) FROM follows WHERE following_id = $1',
[userId]
);
await this.redis.set(`followers:${userId}`, count.toString(), 'EX', 30);
return count;
}
}
整合性レベルの設計判断
| データ種別 | 必要な整合性 | 理由 |
|---|---|---|
| 残高・在庫 | 強い整合性 | 金銭的損失、売り越し防止 |
| ユーザープロフィール | Read-Your-Writes | 自分の更新は即座に見えるべき |
| タイムライン | 結果整合性 | 数秒の遅延は許容 |
| いいね数 | 結果整合性 | 近似値で十分 |
| セッション | セッション整合性 | 同一セッション内で一貫 |
まとめ
| ポイント | 内容 |
|---|---|
| ACID | 原子性・一貫性・分離性・永続性 |
| BASE | 基本的可用性・柔軟な状態・結果整合性 |
| 選択基準 | データの重要度と許容できる遅延で判断 |
| 結果整合性 | Read-Your-Writes, Monotonic Read, Causal |
| 設計の鍵 | データ種別ごとに適切な整合性レベルを選択 |
理解度チェックリスト
- ACID の4特性をそれぞれ説明できる
- BASE モデルの特徴を説明できる
- 結果整合性のパターン(Read-Your-Writes等)を理解している
- データ種別に応じた整合性レベルを判断できる
次のステップ
次のレッスンではロック戦略と分離レベルを深掘りする。楽観的ロック・悲観的ロック、そして4つの分離レベルを理解しよう。
推定読了時間: 40分