Cloudflare D1の性能調査
前提
D1がオープンΒになったが、速度が遅いと言われるので実際どれくらい遅いのか自身で理解しておくための調査記録(2023年10月1日時点)
調査方法
- Cloudflare Workersからデータベースに対する接続、読み込み、書き込みの時間を測る。
- データベースはD1とSupabase(東京リージョンかつPgBouncer)を用意してそれぞれの処理時間を比較する
調査するためのコード
調査するために使用したコード
以下は完成する前のソースをわざと書いてる。完成品はGitHub参照。
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
問題発生
発生した問題をここに書いていく
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インジェクションに気をつける必要がある。
処理比較
上記の問題に対応したところ、それぞれ問題なく動作はしたので実際に計測していく。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 |
読み込み側の追加調査
上記を見る限り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 |
コールドスタンバイ?
長い時間D1(もしくはWorkers)が起動されない場合に初回だけ接続が遅い。これはSupabaseのPgBouncerも少しあるのだが、D1の場合は顕著に数字として出る。具体的には以下。
- D1: 初回接続に約700ms程度のコールドスタートの時間を要する
- Supabase: 初回接続に約50ms程度のコールドスタートの時間を要する
数値だけ見ると結構な差がある。あくまで予想だがD1への接続はプロキシ的なものが立ち上がる必要があって、それの起動時間が必要なのかと考えている。
調査ありがとうございます!
他にも制限がないかドキュメントを読んだところちゃんと記載がありました。
バインド数は 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)
Maximum SQL statement length 100,000 bytes
これな... wrangler d1 execute <DATABASE_NAME> --file=<FILE_PATH>
でも発生しますね。例えば既存DBからデータ移行するのに1つのSQLファイルじゃなくて複数に分ける必要があって困ったなと。
追加調査
なるほど、バッチ処理でデータ更新するようなことを考えてるのかな?ということで同じように制限があるかチェックする
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レコードしか存在しなかった。
ここまでの纏め
- ReadはD1でも十分な性能が出る。
- Writeは速度がもう少しあると嬉しい。またトランザクションもまだ使えないのでWrite処理は課題がある。
- 速度もそうだが、それ以外にも制限があるのでその辺を踏まえて使用するか決定する必要がある。
これからD1のアップデートがある度に追記していくようにしたい。
再測定
Cloudflare D1がGAしたので、再度同じコードで計測する。
【前提条件】
- D1およびSupabaseは現時点(2024/04/02)でのDBとなるように再度作成しなおしたものを使用する。
- Cloudflare Workersの
compatibility_date
は最新の2024-03-26
とする(前回は2023-09-22
)
エンドポイント | 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倍)程度早くなっている
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の性能向上が大きく貢献していると思われる