Cloudflare D1 の読み取りが 1 日 2,100 万行になった原因
Cloudflare 上で Astro 製の CMS「EmDash」 のサイトを 8 つ運用しています。1 つのアカウントの上に全部を載せているので、データベースの使用量はアカウント単位で合算されます。その合計を確認したら、1 日あたり 2,100 万行を読んでいました。
内訳を見ると、そのほとんどが画面に出ない数字を計算するために読まれた行でした。
使用量は「返した行数」ではなく「スキャンした行数」
Cloudflare D1 の使用量は読み取り行数で数えられます。ドキュメントの定義はこうです。
Rows read measure how many rows a query reads (scans), regardless of the size of each row.
「読む(スキャンする)」であって「返す」ではありません。1 行のサイズも関係しないので、10 バイトでも 10 KB でも 1 行と数えます。
分かりやすい例が、テーブルの件数を確認するクエリです。
select (select count(*) from ec_posts) as posts,
(select count(*) from content_taxonomies) as links,
(select count(*) from taxonomies) as terms
-- → posts 259 / links 256 / terms 25
-- rows_read: 540返ってきたのは数字 3 つ、読んだのは 540 行です。259 + 256 + 25 = 540。COUNT(*) はインデックスが使えなければ全行を数えるので、テーブルの中身をまるごとなぞった行数が使用量に乗ります。
ここが効いてくるのは、アプリケーションのコストがページの表示回数ではなく、1 表示あたりのスキャン行数で決まるという点です。表示回数が同じでも、テンプレートに 1 つクエリを足しただけで倍になります。
自分が書いたクエリではなかった
D1 の読み取りを減らす方法は既にいくつも書かれています。ただ、その多くは「インデックスを貼る」「1 クエリでまとめて取る」といった、自分でクエリを書いている人向けの対処でした。
こちらは EmDash を使っています。クエリを書いているのは EmDash で、こちらはページのテンプレートを書いているだけです。EmDash という Astro 向けの CMS を Cloudflare Workers 上で動かしていて、コンテンツのページはサーバーレンダリングされ、リクエストごとにデータベースを読みます。
読み取りをクエリ単位で分解すると、上位はこうなりました。
- タクソノミーの件数集計: 1 回あたり約 800〜1,000 行
- 404 のアクセスログの件数確認: 1 回あたりテーブル全行(約 2,700 行)
- 記事一覧の取得: 1 回あたり数百から 2,000 行
1 ページ表示するたびに数千行。この数字を使って逆算すると、1 ページ 3,000 行なら 1 日 1,700 ページ表示で 500 万行に届きます。
アクセスが多いから増えたのではない
意外だったのはここでした。
サイト別に見ると、更新をやめて放置しているサイトほど読み取りが多い。8 サイトのうち、もう記事を追加していない 2 サイトだけで、全体の約 6 割を占めていました。
人が読みに来ているからではありません。機械的なアクセスが原因でした。404 のログを見ると、実際に叩かれていたのはこういう URL が大半です。
/.git/config(476 回)/.aws/credentials(259 回)/wp-json(132 回)/wp-content/uploads(119 回)
WordPress から移行したサイトには旧 URL を探すアクセスも来ます。これらは存在しないので 404 を返しますが、404 ページもサーバーレンダリングされるので、そのたびにデータベースを読みます。しかも 404 はキャッシュに載りません。
つまり「アクセスが少ないから使用量も少ない」という見積もりが成り立っていませんでした。人が来なくても機械は来ます。そしてサイトを増やすほど、この機械的なアクセスの総量は積み上がります。
手を入れた順に、効果を測った
ログの上限チェックを間引きました。 404 のアクセスログには行数の上限があり、1 件記録するたびにテーブル全体を COUNT(*) して超過を確認していました。書き込み自体は 1〜2 行なのに、確認だけで数千行を読む構造です。確認の頻度を下げた結果、読み取りに占める 404 ログの割合は 62% から 3% に落ちました。
エッジキャッシュの保持時間を 5 分から 30 日へ延ばしました。 5 分だと、キャッシュが切れるたびに Worker が起動してデータベースを読み直します。エッジキャッシュは Cloudflare のデータセンターごとに独立しているので、再検証の回数は「ページ数 × アクセスのあるデータセンター数 × (1 日 ÷ 保持時間)」に比例します。世界中から機械的なアクセスが来るぶん、データセンターの数が効いてきます。ここで読み取りが 55.6% 減りました。
延ばして困る場面がないことは先に確かめました。コンテンツを編集すれば EmDash がキャッシュを失効させますし、コードを変更してデプロイした場合は、Cloudflare の Purge Everything で Workers Cache が消えない理由 で確かめたとおり、Workers Caching が Worker のバージョンをキャッシュキーに含むので自動的に空から始まります。
タクソノミーの件数集計をやめました。 ここが本丸で、いちばん遠回りをした部分でもあります。
キャッシュを効かせたあとに残った読み取りは、7 割がサイドバーの「(12)」を数えるクエリでした。まず 404 ページのサイドバーが原因だと考えて外しましたが、外したサイトより外していないサイトのほうが数字が動いてしまい、見立てが外れていたことが分かりました。次にサイドバーそのものを自前実装に置き換えると、あるサイトでは 1 時間あたり 102,738 行が 1,209 行になりました。
それでも全体では下げ止まりました。クエリの実行回数がリクエスト数の 2 倍あり、タクソノミーの数と一致していたのが手がかりで、EmDash のミドルウェアが全ページでターム一覧を先読みしていると分かりました。サイドバーを外した時点で、そのデータを読むテンプレートはもう無くなっています。誰も読まないキャッシュを温めるために、毎ページ数百行を読んでいたことになります。
経緯と切り分け方は キャッシュを 30 日にしても D1 の読み取りが減らない原因 に分けました。
結果(1 日ぶんの速報値)
対策を全て入れた翌日、1 日を通して測りました。
- 読み取り 1,883,379 行(対策前 21,102,647 行 → 91.1% 減)
- 1 起動あたり 97 行(対策前 536 行)
- Worker 起動 19,339 回(この数はほぼ変わっていない)
アクセスを減らしたのではなく、1 ページ表示あたりのコストを下げた結果です。起動数が変わらないまま読み取りだけが 10 分の 1 以下になっています。
数字の扱いには注意が必要で、これは 1 日ぶんの測定です。機械的なアクセスの量は日によって数倍動きますし、この日は途中でデプロイを 1 回挟んでキャッシュが空になっているので、その再構築ぶんも乗っています。傾向としては十分ですが、確定した平常値と呼ぶには日数が足りません。しばらく観測を続けて、月単位で見直す予定です。
有料プランとの関係
Cloudflare Workers の有料プラン(月 5 ドル)に移ると、含まれる枠は月 250 億行になります。1 日あたり 8 億行相当なので、今回のような使用量なら金額としてはまず問題になりません。支払って解決するのは、選択肢として十分に妥当です。
それでも読み取りの効率を直す価値があると考えたのは、金額とは別の理由からでした。
- アカウント単位で合算される。 サイトを 1 つ足すたびに使用量も積み上がる。1 サイトあたりのコストを下げておくと、載せられるサイト数がそのまま増える
- 記事が増えるほど悪化する構造だった。 件数集計は記事とタグの紐付け数に比例するので、運用を続けるほど重くなる。これは課金の有無と関係なく残る
- 応答時間にも効く。 スキャン行数が減れば、1 リクエストの往復とデータベースの処理時間も減る
つまり今回やったのは「支払いを避ける工夫」ではなく、1 リクエストあたりのコストを下げる作業でした。1 アカウントに複数サイトを載せる構成では、この単価がそのままスケールの上限を決めます。
この構成で気をつけること
- 使用量は返した行数ではなくスキャンした行数で決まる。
COUNT(*)はテーブル全体が乗る - CMS を使っている場合、クエリは自分で書いていない。減らす手段はキャッシュと、テンプレートが何を呼んでいるかの見直しになる
- アクセス数と使用量は比例しない。更新をやめたサイトでも機械的なアクセスは来続ける
- 404 はキャッシュに載らない。エラーページの描画コストがそのまま読み取りに乗る
- 効果を測るときは、対策を入れた日のデータを使わない。入れる前と後が混ざる
- 1 日だけの数字で結論を出さない。機械的なアクセスの量は日によって動く
移行したとき、動くことは確認していました。確認していなかったのは、1 ページ表示するたびに何行スキャンするかでした。リクエスト数でもデータ量でもなく、そこが運用コストを決めていました。