※PostgreSQLを前提としています。
結論(まとめ)
- ユースケースをベースにどこまで一貫性を確保するかを設計しよう
- try/catchをいい感じで使おう
- データ量を考慮しデッドロック・タイムアウトの可能性を検討しよう
1. Prisma.transaction の基本的な使い方
Prisma の transaction は、複数のデータ操作を一つのトランザクション内で実行し、一貫性を保証するためのものです。以下は基本的な使用例です。
import { prisma } from './client';
async function createUser() {
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
where: { id: 1 },
data: { name: '山田太郎' },
});
await tx.userAttribute.create({
data: { userId: user.id, profile: 'こんにちは' },
});
});
}
上記のコードでは、ユーザ情報とユーザーの属性情報の作成が一つのトランザクション内で行われています。
この時点で、テーブル設計において「ユーザー」という概念と「ユーザー属性」という概念を2つに分けて管理するという正規化が行わたということがわかります。
つまりどういうことだってばよ
もっとわかりやすくセキュリティの観点でUserとUserAttributeのテーブル設計について考えてみます。
「ユースケースとしてユーザーの情報とユーザー個人の情報とを紐づけない形で管理する」という意思決定が行われているはずです。
-
分離による情報保護
ユーザの属性情報(例: 趣味、嗜好、その他個人データ)を別テーブルに格納することで、ユーザ情報そのもの(例: 名前やログイン情報)へのアクセス制御を強化 -
最小限の情報漏洩リスク
万が一、User テーブル、UserAttribute テーブルが流出した場合でも、ユーザIDの関連付けがなければ個人を特定できなくする -
柔軟なデータ管理
ユーザ属性情報を独立して操作できるため、将来的なデータ構造の変更や新しい属性の追加の容易性を確保
このように、User と UserAttribute を分けることで、セキュリティとデータ管理の両面でメリットを享受できる設計となります。
特に事業領域が金融業界やプライバシー管理の資格を取得してる企業の場合、個人情報保護の観点でこのような対応はほぼ必須となります。
さらにシステムの都合上 User と UserAttribute は必ず1to1であることが望ましいとされている場合もよくある話です。これらの一貫性を確保するために、Transactionを用いるという状況が生まれるのです。
2. try/catch を用いた例外処理
トランザクション内でエラーが発生した場合、Prisma は自動的にロールバックを行うので、DBとしては一貫性を保った状態で処理されます。
同様にシステム側でも try/catch ブロックを用いてエラーを捕捉し、適切な対応を行う必要があります。最初の例を拡張してみましょう。
import { prisma } from './client';
async function createUser() {
try {
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: { name: '山田太郎' },
});
await tx.userAttribute.create({
data: { userId: user.id, profile: 'こんにちは' },
});
});
} catch (error) {
console.error('Transaction failed:', error.message);
// ここでthrow Error() したり response.send(500)したりエラー通知したりする
}
}
このコードでは、ユーザとその属性情報を一括してトランザクション内で処理しています。エラーが発生した場合、自動的にロールバックされます。
- デッドロックを回避する設計
デッドロックは、複数のトランザクションが相互にリソースを待機し、永続的に終了しない状態です。ただし、Prismaでは行ロックがサポートされていないらしいので、工夫が必要になります。
参考:
データ量の考慮
操作対象のデータ量が多い場合はここまで述べた内容を総合して検討する必要があります。
例えばバッチ処理を流すとき、全データに対して確実に更新したい場面です。
async function updateInBatchesWithTransaction(batchSize: number) {
let offset = 0;
while (true) {
// トランザクション開始
await prisma.$transaction(async (tx) => {
const payments = await tx.$queryRaw<RawPayment[]>(`
SELECT id, field1, field2
FROM payments
ORDER BY id
LIMIT $1 OFFSET $2
FOR UPDATE
`, batchSize, offset);
if (!payments?.length) {
throw new Error("対象のデータがありません");
}
for (const payment of payments) {
try {
// 個別の更新処理
await tx.$executeRawUnsafe(`
UPDATE payments
SET field1 = $1, field2 = $2
WHERE id = $3
`, payment.field1, payment.field2, payment.id);
} catch (error) {
console.error(`データ更新エラー: id=${payment.id}`, error);
// 必要なら失敗データをログに記録するなど
}
}
}).catch((error) => {
console.error("トランザクションエラー", error);
// トランザクション全体のリトライやロールバック後の処理を実装可能
throw error;
});
offset += batchSize;
console.log(`Batch processed: offset ${offset}`);
}
}
上記の実装はあくまで例なのでそのままでは動かないと思います。とりあえずポイントだけ理解してください。(transaction, try/catch, ロック時の処理)
ポイント
- try/catch が
for (const payment of payments)
にあることで、個別のデータでエラーが起きても対応可能 - transactionがあるのでデータが1つでも落ちると元通り。↑の個別データのエラーを1つずつ解消していきつつ何度でも実行可能
- ロールバック、リトライをcatchできるので何かあっても対応可能
- batchSizeで実行しているので処理の負荷を低減できる
- queryRawを使って「SELECT xxx FOR UPDATE」による行ロックを実現