😢

Cloudflare D1でトランザクションが使えない問題

2024/06/09に公開

今さらですが、Cloudflare D1がトランザクションをサポートしていないことに気づいて困っているので、調べたこと・考えたことをまとめておきます。

なお、本記事でのトランザクションはSQL文 BEGIN TRANSACTION; で実行されるアトミック操作のためのテーブルのロックやロールバック処理の仕組みのことを指しています。

結論

現時点(2024年6月6日)では、以下のようなケースではプライマリDBとしてCloudflare D1を利用するのは厳しそう(そんなことないよ、というご意見があればぜひ教えてください)。

  • ReadとWriteを1トランザクション内で扱いたい(Readした内容に応じて分岐したりUpdateの値を変えたりしたい)(これが一番でかい)
  • 部分的なロールバックやリトライなど、複雑な制御がしたい

Cloudflare D1は2024年4月にGAされていますが、特に1点目ができないのは痛手で、本当にシンプルなサービス以外は難しくない?と感じています。

D1におけるトランザクション

現時点(2024年6月頭)では、D1はトランザクションをサポートしていません
これ、Cloudflareの公式ドキュメントにはほとんど明記されておらず(見落としているだけだったら本当にごめんなさい)、なんならPricingの部分では以下のようになっており、むしろトランザクションがあるようにすら読み取れます。

Usage: Queries you issue against D1 will count as rows read, rows written, or both (for transactions or batches).

トランザクションはRDBを使う上で非常に重要な仕組みだと思うので、個人的にはLimitsに明記してほしいなと思いました。

トランザクション

トランザクションの重要性はCloudflareも理解しており、こちらの記事ではストアドプロシージャをベースにアトミックな操作を検討している、といった記載があります。

上記記事では、D1においてトランザクションを実現できない理由として以下2点が挙げられています。

  • バグによってROLLBACKが呼び出される前にワーカーがクラッシュした場合、プライマリDBがブロックされる
  • JavaScriptとSQL間で複数のラウンドトリップが発生するトランザクションはシステム全体を数秒ブロックする可能性があり、Cloudflare WorkersとD1の拡張性の足枷となる

記事は2022年の内容ですが、先のIssueにあるとおり、未だにストアドプロシージャの機能は公開されていません。

batch API

トランザクションっぽくアトミックにWriteできる手段として、batch APIが提供されています。

Batched statements are SQL transactions. If a statement in the sequence fails, then an error is returned for that specific statement, and it aborts or rolls back the entire sequence.

使い方は以下のようになります。

await db.batch([
    db.prepare("UPDATE users SET name = ?1 WHERE id = ?2").bind( "John", 17 ),
    db.prepare("UPDATE users SET age = ?1 WHERE id = ?2").bind( 35, 19 ),
]);

batch API は複数のSQL文をまとめて投げる仕組みなので、パフォーマンス面でのメリットはありますが、ロジック(アプリケーションサーバー側で実行する処理)を挟むような使い方はできません。

何が問題になるか?

具体的には、以下のような問題が生じます。

PrismaのNestedWriteが使えない(ORMの機能が使えないケース)

いきなりORM(Prisma)に話がとびますが、他のORMでも同様の課題がありそうなので書きます。

たとえば以下のようなDBモデルがあるとします(テーブル同士がM:N関係のPrismaスキーマ)。

model Team {
  id      Int    @id @default(autoincrement())
  name    String
  members User[] // Many team members
}

model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  teams Team[] // Many teams
}

Prismaでは以下のような書き方で依存関係のあるネストしたテーブルへのWriteを実現できます(teamを作成し、所属するmemberを作成する)。

const team = await prisma.team.create({
  data: {
    name: 'Aurora Adventures',
    members: {
      create: {
        email: 'alice@prisma.io',
      },
    },
  },
})

しかし、実際に発行されるSQLにはトランザクションが利用されるので、D1では使えません(実行はできるが、トランザクションは機能しない)。

つまり、ORMを利用する場合は、ORMの吐くSQLを意識しないと知らないうちにデータ不整合が起きていた、といった事態が発生しかねません。

あるクエリの結果を別のクエリに使えない

先の例はロジックのないただの複数テーブルへのINSERTなので、batchで対応できるかと思いきや、中間テーブルはTeam, Userに依存(Team, UserへINSERTした結果得られたIDをINSERT)するので、これを batchで書くことはできません。

今回の例はIDとしてDBのautoincrementを使っている点が問題なので、以下のようにアプリケーション側で算出できるIDに変更し、IDを値として batch を呼び出すことで回避できます。

しかし、読んだ値に応じてアプリケーション側でロジックを入れたり、ロールバックの制御をしたり、といったこと(要はアプリケーションサーバーとDBサーバーを複数回行き来する処理)はbatchでは実現できません。そして、こういった操作はわりと当たり前に要求されるような気がします。

model Team {
  id      String @id @default(uuid())
  name    String
  members User[]
}

model User {
  id    String @id @default(uuid())
  email String @unique
  teams Team[]
}

まとめ

サブクエリをうまく使ったり、テーブル設計を工夫したりすれば batchでなんとかなるケースも多いとは思います。

しかし、D1にはクエリのサイズやパラメータの数にも制限があるので、個人的にはそのあたりまで気にかけるのはかなり苦痛で、現時点ではD1を使うのは苦しいな、と思いました。

D1自体は非常に魅力的なコンセプトだと思うので、引き続きウォッチしていきたいです。

Discussion