Open11

Cloudflare D1の性能調査

chimamechimame

前提

https://blog.cloudflare.com/d1-open-beta-is-here/

D1がオープンΒになったが、速度が遅いと言われるので実際どれくらい遅いのか自身で理解しておくための調査記録(2023年10月1日時点

調査方法

  1. Cloudflare Workersからデータベースに対する接続、読み込み、書き込みの時間を測る。
  2. データベースはD1とSupabase(東京リージョンかつPgBouncer)を用意してそれぞれの処理時間を比較する

調査するためのコード

調査するために使用したコード

https://github.com/chimame/check-d1-performance

以下は完成する前のソースをわざと書いてる。完成品はGitHub参照。

schema.sql
DROP TABLE IF EXISTS "Users";

CREATE TABLE "Users" (
    id INTEGER PRIMARY KEY NOT NULL,
    username TEXT NOT NULL,
    email TEXT NOT NULL
);
import { Hono } from "hono"
import { connection } from './database/client'

type Env = {
  DATABASE_URL: string
  DB: D1Database
}

const app = new Hono<{ Bindings: Env }>()

app.get("/clear", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    await db.deleteFrom('Users').execute()
    for (let i = 0; i < 10000; i++) {
      await db.insertInto('Users').values({ id: i + 1, username: `test${i}`, email: `test${i}@example${i}.com` }).execute()
    }

    return c.text(`clear ${performance.now() - start}ms`)
  } catch (e) {
    return c.text((e as Error).message)
  }
})

app.get("/count", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    const data = await db.selectFrom('Users').select(db.fn.count('Users.id').as('count')).executeTakeFirst()

    return c.json({
      time: performance.now() - start,
      data
    })
  } catch (e) {
    return c.text((e as Error).message)
  }
})

app.get("/write", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    const now = new Date().getTime()
    await db.insertInto('Users').values({ username: `test${now}`, email: `test${now}@example${now}.com` }).execute()

    return c.text(`write ${performance.now() - start}ms`)
  } catch (e) {
    return c.text((e as Error).message)
  }
})

app.get("/read", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    const data = await db.selectFrom('Users').selectAll().orderBy('Users.id desc').limit(50).execute()

    return c.json({
      time: performance.now() - start,
      data
    })
  } catch (e) {
    return c.text((e as Error).message)
  }
})

app.get("/read/:name", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    const data = await db.selectFrom('Users').selectAll().where('Users.email', 'like', `%${c.req.param('name')}%`).execute()

    return c.json({
      time: performance.now() - start,
      data
    })
  } catch (e) {
    return c.text((e as Error).message)
  }
})

export default app
chimamechimame

問題発生

発生した問題をここに書いていく

Cloudflare D1には1 worker(もしくは1 request)から呼び出せるAPI回数に制限がある。

上記のコードで /clear はテーブルをすべて消して、初期データを入れ直す処理となっている。ここではわざとINSERT文が複数回分けて発行する処理にしている。しかし、この処理をD1で使用すると動作しない。

エラーとしては Too many API requests by single worker invocation というエラーが返ってくる。要は1 workerからAPIつまりSQLの発行回数は上限が決められている。現に上記のコードを実行した場合は Users テーブルには999行しかデータがインサートされない。つまり1 worker(request?)からはD1に対して最大で1,000回までしかSQLが実行できないということになる。

それはそれで仕方ないので、以下のコードに書き換えた。

app.get("/clear", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    await db.deleteFrom('Users').execute()
-    for (let i = 0; i < 10000; i++) {
-      await db.insertInto('Users').values({ id: i + 1, username: `test${i}`, email: `test${i}@example${i}.com` }).execute()
-    }
+    for (let i = 0; i < 999; i++) {
+      await db.insertInto('Users').values([...Array(101)].map((_, index) => {
+        const id = index + 1 + (i * 101)
+        return { id, username: `test${id}`, email: `test${id}@example${id}.com` }
+      })).execute()
+    }

    return c.text(`clear ${performance.now() - start}ms`)
  } catch (e) {
    return c.text((e as Error).message)
  }
})

つまり1万回のSQL発行を999回に抑えるためにSQLを書き換えた。

SQL実行時のバインド変数の個数に制限がある

具体的には先程のコードでは以下のようなSQLが発行される

insert into "Users" ("id", "username", "email") values (?, ?, ?), (?, ?, ?), ...

? の部分にはいわゆるバインドされる値が渡されるのだが、上記のコードでは1SQLの実行時に 3 * 101 個の値が渡されることになるのだが、これを実行すると too many SQL variables とD1特有のエラーを出力する。つまりバインドする値に上限があるよう。じゃあ上限はいくらなの?と思ったので調査したところ1SQLに設定できるバインドの値の上限は100個が上限のよう。

仕方がないのでバインドしない形のSQLに書き換える

app.get("/clear", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    await db.deleteFrom('Users').execute()
    for (let i = 0; i < 999; i++) {
-      await db.insertInto('Users').values([...Array(101)].map((_, index) => {
-        const id = index + 1 + (i * 101)
-        return { id, username: `test${id}`, email: `test${id}@example${id}.com` }
-      })).execute()
+      const data = [...Array(101)].map((_, index) => {
+        const id = index + 1 + (i * 101)
+        return `(${id}, 'test${id}', 'test${id}@example${id}.com')`
+      })
+      await sql.raw(`INSERT INTO "Users" ("id", "username", "email") VALUES ${data.join(',')}`).execute(db)
    }

    return c.text(`clear ${performance.now() - start}ms`)
  } catch (e) {
    return c.text((e as Error).message)
  }
})

SQL文自体を組み立て、SQLを実行するように修正したコードではKyselyの sql.raw(...).execute() を使っているので本番などで使用する場合はSQLインジェクションに気をつける必要がある。

chimamechimame

処理比較

上記の問題に対応したところ、それぞれ問題なく動作はしたので実際に計測していく。1処理に付きそれぞれ5回計測した結果を以下に記載していく。

エンドポイント Cloudflare D1 Supabase(東京リージョン)
/clear 120,444 ms 13,084 ms
134,501 ms 12,098 ms
128,865 ms 12,119 ms
120,971 ms 11,642 ms
-ms 11,479 ms
/count 58 ms 59 ms
49 ms 58 ms
49 ms 58 ms
44 ms 71 ms
51 ms 59 ms
/write 109 ms 103 ms
105 ms 103 ms
106 ms 102 ms
105 ms 100 ms
94 ms 103 ms
/read 46 ms 101 ms
37 ms 101 ms
63 ms 99 ms
46 ms 94 ms
80 ms 98 ms
/read/:name(33) 257 ms 116 ms
124 ms 117 ms
103 ms 126 ms
106 ms 124 ms
122 ms 126 ms
chimamechimame

読み込み側の追加調査

上記を見る限りWriteはSupabaseの方が早く、ReadはD1の方が早いように見える。
そこでReadをSQL1,000回を実行して、速度差が出るように更にコードに変更を加える。

app.get("/read/100", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    for(let i = 0; i < 999; i++) {
      await db.selectFrom('Users').selectAll().orderBy('Users.id desc').limit(50).execute()
    }
    const data = await db.selectFrom('Users').selectAll().orderBy('Users.id desc').limit(50).execute()

    return c.json({
      type: 'read 100',
      time: performance.now() - start,
      data
    })
  } catch (e) {
    return c.text((e as Error).message)
  }
})

...

app.get("/read/:name/100", async (c) => {
  try {
    const start = performance.now()
    const db = connection(c.req.query('type') === 'pg' ? c.env.DATABASE_URL : c.env.DB)

    for(let i = 0; i < 999; i++) {
      await db.selectFrom('Users').selectAll().where('Users.email', 'like', `%${c.req.param('name')}%`).execute()
    }
    const data = await db.selectFrom('Users').selectAll().where('Users.email', 'like', `%${c.req.param('name')}%`).execute()

    return c.json({
      type: `read name: ${c.req.param('name')} 100`,
      time: performance.now() - start,
      data
    })
  } catch (e) {
    return c.text((e as Error).message)
  }
})
エンドポイント Cloudflare D1 Supabase(東京リージョン)
/read/100 49,980 ms 60,921 ms
47,468 ms 64,169 ms
38,874 ms 60,608 ms
39,029 ms 60,309 ms
39,603 ms 60,230 ms
42,225 ms 60,524 ms
/read/:name(33)/100 81,287 ms 81,348 ms
81,106 ms 81,046 ms
81,795 ms 81,877 ms
82,079 ms 81,093 ms
75,248 ms 81,086 ms
chimamechimame

コールドスタンバイ?

長い時間D1(もしくはWorkers)が起動されない場合に初回だけ接続が遅い。これはSupabaseのPgBouncerも少しあるのだが、D1の場合は顕著に数字として出る。具体的には以下。

  • D1: 初回接続に約700ms程度のコールドスタートの時間を要する
  • Supabase: 初回接続に約50ms程度のコールドスタートの時間を要する

数値だけ見ると結構な差がある。あくまで予想だがD1への接続はプロキシ的なものが立ち上がる必要があって、それの起動時間が必要なのかと考えている。

codehexcodehex

調査ありがとうございます!
他にも制限がないかドキュメントを読んだところちゃんと記載がありました。
https://developers.cloudflare.com/d1/platform/limits/

バインド数は 100 と記載がありました

Maximum bound parameters per query

Plan によって変わるらしいです。50 (Bundled) / 1000 (Unbound)

Queries per Worker invocation

個人的に、地味にしんどいなと思ったのはこの二つの制約でした...

Maximum string, BLOB or table row size 1,000,000 bytes (1 MB)

Maximum SQL statement length 100,000 bytes (100 KB)

chimamechimame

Maximum SQL statement length 100,000 bytes

これな... wrangler d1 execute <DATABASE_NAME> --file=<FILE_PATH> でも発生しますね。例えば既存DBからデータ移行するのに1つのSQLファイルじゃなくて複数に分ける必要があって困ったなと。

chimamechimame

追加調査

https://twitter.com/naporin24690/status/1708440839058538788

なるほど、バッチ処理でデータ更新するようなことを考えてるのかな?ということで同じように制限があるかチェックする

  async scheduled(
    event: ScheduledEvent,
    env: Env,
    ctx: EventContext<Env, any, any>
  ) {
    try {
      const db = connection(env.DB)

      await db.deleteFrom('Users').execute()
      for (let i = 0; i < 1200; i++) {
        await sql.raw(`INSERT INTO "Users" ("id", "username", "email") VALUES (${i}, 'test${i}', 'test${i}@example${i}.com')`).execute(db)
      }
    } catch (e) {
      console.log((e as Error).message)
    }
  }

追加調査中に発生した問題

Cloudflare WorkersをCronで動かした場合にKysely経由でD1の処理を書いてもSQL結果が反映されないバグを踏んだ。仕方ないので上記のようにAPI直接叩いて1,000回しか実行できないかを調査するコードに書き換えた。
ただの私のミスでした。正常に動作します。

結果

通常のリクエスト処理を行うWorkers同様に1,000回しか最大でSQLを実行できない。上記のコードを動かした場合には本来であれば1,200レコードが登録されるはずが、999レコードしか存在しなかった。

chimamechimame

ここまでの纏め

  1. ReadはD1でも十分な性能が出る。
  2. Writeは速度がもう少しあると嬉しい。またトランザクションもまだ使えないのでWrite処理は課題がある。
  3. 速度もそうだが、それ以外にも制限があるのでその辺を踏まえて使用するか決定する必要がある。

これからD1のアップデートがある度に追記していくようにしたい。

chimamechimame

再測定

Cloudflare D1がGAしたので、再度同じコードで計測する。

【前提条件】

  • D1およびSupabaseは現時点(2024/04/02)でのDBとなるように再度作成しなおしたものを使用する。
  • Cloudflare Workersのcompatibility_dateは最新の2024-03-26とする(前回は 2023-09-22)

https://blog.cloudflare.com/making-full-stack-easier-d1-ga-hyperdrive-queues

エンドポイント D1(before) after Supabase(before) after
/clear 120,444 ms 85,631 ms 13,084 ms 56,782 ms
134,501 ms 66,485 ms 12,098 ms 55,952 ms
128,865 ms 63,621 ms 12,119 ms 56,463 ms
120,971 ms 63,250 ms 11,642 ms 55,252 ms
-ms 63,938 ms 11,479 ms 56,787 ms
/count 58 ms 31 ms 59 ms 59 ms
49 ms 30 ms 58 ms 70 ms
49 ms 40 ms 58 ms 61 ms
44 ms 23 ms 71 ms 72 ms
51 ms 27 ms 59 ms 62 ms
/write 109 ms 63 ms 103 ms 104 ms
105 ms 58 ms 103 ms 116 ms
106 ms 57 ms 102 ms 101 ms
105 ms 57 ms 100 ms 100 ms
94 ms 58 ms 103 ms 98 ms
/read/100 49,980 ms 14,077 ms 60,921 ms 64,659 ms
47,468 ms 13,334 ms 64,169 ms 65,002 ms
38,874 ms 13,437 ms 60,608 ms 64,977 ms
39,029 ms 13,107 ms 60,309 ms 61,399 ms
39,603 ms 13,002 ms 60,230 ms 64,756 ms
/read/:name(33)/100 81,287 ms 43,423 ms 81,348 ms 85,401 ms
81,106 ms 42,790 ms 81,046 ms 81,049 ms
81,795 ms 43,316 ms 81,877 ms 82,864 ms
82,079 ms 42,982 ms 81,093 ms 85,890 ms
75,248 ms 42,475 ms 81,086 ms 79,228 ms

何も考えずに数値だけ見ると

  • Supabaseは一部遅くなっているが、全体的にはそこまで変化無し
  • D1はRead,Writeともに2倍(条件によっては3倍)程度早くなっている
chimamechimame

compatibility_date を前回のバージョンにして再測定

プログラムは変えてないが、compatibility_date を変えることによって変わってしまったかもと思って一部だけ速度を取り直し

エンドポイント D1(before) after2024-03-26 after2023-09-22 Supabase(before) after
/clear 120,444 ms 85,631 ms 75,573 ms 13,084 ms 56,782 ms
134,501 ms 66,485 ms 75,146 ms 12,098 ms 55,952 ms
128,865 ms 63,621 ms 75,272 ms 12,119 ms 56,463 ms
120,971 ms 63,250 ms 75,445 ms 11,642 ms 55,252 ms
-ms 63,938 ms 75,147 ms 11,479 ms 56,787 ms

という感じなのでWorkersの処理速度自体も多少は上がっているが、これだけ見るとD1の性能向上が大きく貢献していると思われる