まえがき
JavaScript / TypeScript で複数の非同期処理をまとめて実行したいとき、Promise.all はよく使われます。
たとえば、複数件の update や upsert をまとめて実行する場合、以下のように書きたくなります。
await Promise.all(
updates.map((update) =>
prisma.xxx.update({
where: { id: update.id },
data: update.data,
})
)
)
一見すると、複数のDB更新を並列実行できて効率がよさそうに見えます。
しかし、Prisma のトランザクション処理と組み合わせる場合は注意が必要です。
この記事では、以下の2点を整理します。
- トランザクション内で
Promise.allを使っても、DBクエリの並列化メリットは期待しにくい - トランザクション外で
Promise.allを使うと、途中失敗時に一部だけDBへ反映される可能性がある
実例
たとえば、ユーザーごとの複数の設定値を upsert する処理があるとします。
async function updateUserSettings(
updates: {
userId: string
settingKey: string
enabled: boolean
}[],
client = db
) {
return Promise.all(
updates.map(({ userId, settingKey, enabled }) =>
client.userSetting.upsert({
where: {
userId_settingKey: {
userId,
settingKey,
},
},
create: {
userId,
settingKey,
enabled,
},
update: {
enabled,
},
})
)
)
}
このコードは、Promise.all によって複数の upsert を並列実行しているように見えます。
ただし、この関数は client に何が渡されるかによって挙動が変わります。
ケース1: TransactionClient が渡される場合
呼び出し側で、以下のようにトランザクションを開始しているケースです。
await db.$transaction(async (tx) => {
await updateUserSettings(updates, tx)
})
この場合、updateUserSettings の client には tx が渡されます。
つまり、内部では以下のような処理になります。
await Promise.all(
updates.map((update) =>
tx.userSetting.upsert(...)
)
)
Promise.all を使っているため、複数の upsert が並列実行されそうに見えます。
しかし、Prisma のトランザクション内のクエリは、同じDBコネクション上で実行されます。
1つのDBコネクションでは、複数のクエリを同時に処理できません。
そのため、トランザクション内で Promise.all を使っても、DBクエリは実質的に1つずつ実行されます。
イメージとしては、以下のような動きです。
await tx.userSetting.upsert(...)
await tx.userSetting.upsert(...)
await tx.userSetting.upsert(...)
つまり、トランザクション内では Promise.all によるDB更新の高速化は期待しにくいです。
この場合は、意図が伝わりやすいように for...of で順次実行したほうがわかりやすいです。
async function updateUserSettings(
updates: {
userId: string
settingKey: string
enabled: boolean
}[],
tx: Prisma.TransactionClient
) {
for (const { userId, settingKey, enabled } of updates) {
await tx.userSetting.upsert({
where: {
userId_settingKey: {
userId,
settingKey,
},
},
create: {
userId,
settingKey,
enabled,
},
update: {
enabled,
},
})
}
}
呼び出し側は以下のようになります。
await db.$transaction(async (tx) => {
await updateUserSettings(updates, tx)
})
この形にすると、以下の意図が明確になります。
- この処理はトランザクション内で実行する
- 複数の
upsertは順次実行する - 途中で失敗した場合はトランザクション全体をロールバックする
ケース2: 通常の PrismaClient が使われる場合
次に、呼び出し側が tx を渡さないケースです。
await updateUserSettings(updates)
この場合、関数のデフォルト引数により client = db が使われます。
async function updateUserSettings(updates, client = db) {
return Promise.all(...)
}
つまり、内部では通常の PrismaClient を使って複数の upsert が実行されます。
await Promise.all([
db.userSetting.upsert(...),
db.userSetting.upsert(...),
db.userSetting.upsert(...),
])
この場合は、複数の更新が1つのトランザクションとして扱われていません。
そのため、たとえば3件の更新がある場合に、以下のような状態になる可能性があります。
- 1件目の
upsertが成功 - 2件目の
upsertが成功 - 3件目の
upsertが失敗
この場合、1件目と2件目だけDBに反映された状態が残る可能性があります。
つまり、途中まで成功した中途半端な状態になります。
複数の更新を「全部成功するか、全部失敗するか」の単位で扱いたい場合、この書き方は危険です。
その場合は、明示的に $transaction を使います。
await db.$transaction(
updates.map(({ userId, settingKey, enabled }) =>
db.userSetting.upsert({
where: {
userId_settingKey: {
userId,
settingKey,
},
},
create: {
userId,
settingKey,
enabled,
},
update: {
enabled,
},
})
)
)
または、他の処理も含めて1つのトランザクションにしたい場合は、以下のように書きます。
await db.$transaction(async (tx) => {
for (const { userId, settingKey, enabled } of updates) {
await tx.userSetting.upsert({
where: {
userId_settingKey: {
userId,
settingKey,
},
},
create: {
userId,
settingKey,
enabled,
},
update: {
enabled,
},
})
}
})
関数設計としての注意点
今回の例で注意したいのは、以下のような関数設計です。
async function updateUserSettings(updates, client = db) {
return Promise.all(...)
}
このように client = db をデフォルト引数にしていると、呼び出し側によって挙動が変わります。
-
txを渡した場合: トランザクション内で実行されるが、Promise.allによる並列化メリットは期待しにくい -
txを渡さない場合: トランザクション外で実行され、途中失敗時に一部だけ反映される可能性がある
そのため、複数更新をまとめて扱う関数では、方針を明確にしたほうが安全です。
方針1: TransactionClient 専用の関数にする
async function updateUserSettings(
updates: UpdateUserSettingInput[],
tx: Prisma.TransactionClient
) {
for (const update of updates) {
await tx.userSetting.upsert(...)
}
}
呼び出し側では、必ずトランザクションを開始してから関数を呼びます。
await db.$transaction(async (tx) => {
await updateUserSettings(updates, tx)
})
この場面では、外側で他の処理も含めてトランザクション管理したい時に向いています。
方針2: 関数内で transaction を開始する
async function updateUserSettings(updates: UpdateUserSettingInput[]) {
await db.$transaction(
updates.map((update) =>
db.userSetting.upsert(...)
)
)
}
この形は、関数単体で「全部成功 or 全部失敗」を保証したい場合に向いています。
まとめ
Prisma で複数の update や upsert を実行するとき、安易に Promise.all を使うと意図しない挙動になることがあります。
特に重要なのは以下です。
- Prisma のトランザクション内で
Promise.allを使っても、DBクエリは基本的に直列実行される - そのため、
TransactionClientを使っている場合、Promise.allによる高速化は期待しにくい - トランザクション外で
Promise.allを使うと、途中失敗時に一部だけDBへ反映される可能性がある - 複数更新を「全部成功 or 全部失敗」にしたい場合は、
$transactionを使う
Promise.all は便利ですが、DB更新処理では「並列化できるか」だけでなく、「失敗時にどこまでロールバックされるべきか」を先に考えることが重要です。
Prisma のトランザクション内では、Promise.all による高速化を期待するよりも、「どの処理をまとめて成功・失敗させるべきか」を明確にすることを優先したほうがよいです。