公開 文責 Webaff 編集部
Netlify BlobsでPVを数える設計とコスト対策
- Netlify
- Astro
- 運用
このブログのトップページには「よく読まれている記事」の並びがあります。その順番は Google アナリティクスではなく、自分で数えた PV で決めています。GA4 の数字はレポートを見るためのもので、サイト側から並び替えに使うには API を叩く必要があり、静的サイトの表示のたびに外部 API を待つ構成にはしたくなかったからです。
そこで Netlify Blobs に閲覧を書き溜めて、毎時まとめる形にしました。素直に作ると「1閲覧1書き込み」がそのままコストになるので、抑えるための判断がいくつも必要でした。その記録です。
数える場所は Edge Function にした
記事ページは静的に配信されるので、ページ側の JavaScript で数えるか、配信経路で数えるかの二択です。後者にしました。netlify/edge-functions/count-article-view.js が /blog/* に入り、レスポンスを返したあとに書き込みます。
export default async (request, context) => {
const response = await context.next();
// 判定して、数える場合だけ
context.waitUntil(recordPageView(articleId, requestId));
return response;
};
context.waitUntil を使うと、レスポンスを返したあとに非同期処理を続けられます。読者の待ち時間に書き込みを挟まないための形です。
数えない条件は4つ置きました。GET 以外、公開デプロイ以外(Deploy Preview を数えたくない)、HTML 以外のレスポンス、それに bot と先読みです。先読みは sec-purpose / purpose ヘッダに prefetch が入るので弾いています。ここを抜くと、ブラウザやリンク先読みのぶんが混ざって順位が歪みます。
1閲覧1キーにした理由
カウンタなので「既存の値を読んで +1 して書く」が最初に思い浮かびますが、これはやめました。Netlify Blobs は同じキーへの同時書き込みであとの書き込みが勝つ仕様で、読んで足して書く形は取りこぼします。公式ドキュメントも「頻繁な読み取りと稀な書き込みに最適化されたデータストア」と説明しています。
なので閲覧ごとに別のキーを作り、あとで数える形にしました。
views/{記事スラッグ}/{YYYY-MM}/{リクエストID}
書き込みは onlyIfNew: true を付けています。同じリクエスト ID で二重に走っても増えません。
書き込みに上限を置く
この形の弱点は、同じ記事を連打されると書き込みだけが伸びることです。読者の待ち時間は増えませんが、ストレージと操作数は増えます。そこで isolate ごとに上限を置きました。
| 設定 | 値 | 意味 |
|---|---|---|
WRITE_WINDOW_MS |
60,000 | 判定の窓は1分 |
MAX_WRITES_PER_ARTICLE |
40 | 1記事あたり1分40件まで |
MAX_TRACKED_ARTICLES |
200 | 記録用 Map の上限 |
上限に当たった閲覧は計上されません。取りこぼしとコストの無制限な増加を比べて、後者を避ける方を選びました。
この40件/分という値は、後述する集約1回の上限50,000件から逆算しています。単一の isolate での最悪値は 記事数 × 40 × 60(件/時)で、50,000 ÷ 40 ÷ 60 ≒ 20.8 なので記事20本までなら1時間分が1回の集約に収まります。公開20本のいまは 20 × 40 × 60 = 48,000件/時で、余裕は2,000件しかありません。
21本目からはこの最悪値が上限を超えますが、壊れません。あふれた分は次回の集約で処理されます。そのとき何が起きるかは「集約が追いつかなくなる」だけで、表示は変わりません。ただしこれは1回で処理する件数の上限という意味で、処理速度の保証ではありません。Edge Function の isolate は状態を共有しないので、複数に分散されれば全体では上限を超えて積み上がります。ここで防いでいるのは、あくまで1つの isolate からの際限のない増え方です。
読み出しは1キーだけにする
トップページは SSR なので、表示のたびに Blobs を読みます。ここで未集約のキーを全部 list して数えると、キーが増えるほどトップページが遅くなります。表示コストがアクセス数に比例して増える形は避けたいので、集約済みの1キーだけを読むようにしました。
const store = getStore({ name: PAGE_VIEW_STORE, consistency: 'strong' });
const aggregated = await store.get(PAGE_VIEW_TOTALS_KEY, { type: 'json' });
consistency: 'strong' は即時性のためです。既定は結果整合で、更新と削除が全エッジに伝わるまで最大60秒かかります。読み取りは遅くなりますが、読むのは1キーなので許容しました。
集約は毎時、実行は Background Function
集約は Scheduled Function に置きましたが、処理そのものは別の関数に投げています。実行時間の上限が違うからです。
| 種類 | 上限 | この構成での役割 |
|---|---|---|
| Scheduled Function | 30秒 | 起動役。毎時20分に走る |
| Background Function | 15分 | 実行役。キーの集約と削除 |
| 同期 Function | 60秒 | 使っていない |
30秒では数万件の削除は終わらないので、起動と実行を分けました。実行側は background: true と method: 'POST' を設定し、想定外のメソッドで叩かれないようにしています。
二重起動の対策には etag の楽観ロックを使いました。ロック用のキーを読んで、その etag を条件に書き戻します。
const existing = await store.getWithMetadata(LOCK_KEY, { type: 'json' });
const result = existing
? await store.setJSON(LOCK_KEY, { startedAt: now }, { onlyIfMatch: existing.etag })
: await store.setJSON(LOCK_KEY, { startedAt: now }, { onlyIfNew: true });
return result.modified;
onlyIfMatch / onlyIfNew を付けた書き込みは modified を返し、条件を満たさなければ false になります。これで「先に取った1回だけが進む」形になります。ロックには20分の期限を持たせて、途中で落ちても次回が動けるようにしました。
順番を間違えると二重に数える
実装していて一番ひっかかったのはここです。集約は「キーを削除してから合計に足す」順番にしてあります。逆にすると、足したあとで削除に失敗した場合に、次の集約で同じキーをもう一度足してしまいます。削除が先なら、落ちたときに失われるのは「その回のぶん」だけで、増えることはありません。PV の用途は並び替えなので、多く出るより少なく出る方を選びました。
止まったことに気づけるようにする
この構成には静かに壊れる道があります。起動役が実行役を叩くときにトークンを使うので、トークンのスコープが足りないと集約だけが止まり、サイトは普通に動いたまま順位が古くなる。ログを見ないと分かりません。
そこで集約は、対象が無かった回も含めて毎回 lastRunAt を書くようにしました。値が変わった時刻(updatedAt)とは別に持たせるのが要点です。読み出し側は lastRunAt が6時間より古ければ警告を出します。
| 状態 | 出る警告 |
|---|---|
| 集約キーがまだ無い | 初回前か、一度も動いていない |
| 時刻が無い | 集約の時刻が記録されていない |
| 6時間以上更新なし | 何時間止まっているかと、順位が古いこと |
「一度も動いていない」と「動いていたが止まった」を区別できないと、後者に気づけません。毎回書く値を分けたのはそのためです。
なおこの仕組みはまだ本番のトラフィックで検証できていません。このサイトの流入自体が少なく、2026-09 の検索指標は全記事あわせて表示54回・クリック5回(GA4とSearch Consoleで毎月追う指標と実測例 と同じ /data/ の値)だからです。
ここで挙げた表示回数はこの記事で数えている PV とは別のものです。表示回数は検索結果にリンクが並んだ回数で、開かれたかどうかは含みません。PV は実際にページが表示された回数なので、検索以外の流入も入る一方、表示されただけの回は入りません。両者を足したり割ったりしても意味のある値にはならないので、突き合わせずに別々に見ています。数字が動いてから、また書き足します。
静的サイトで自前計測を足すときの判断
振り返ると、判断のほとんどは「正確さ」ではなく「コストと壊れ方」でした。
- 加算ではなくキーを分ける(同時書き込みで取りこぼすため)
- 書き込みに上限を置き、取りこぼしを受け入れる
- 読み出しを未集約のキー数から切り離す
- 長い処理は実行時間の上限が長い置き場所へ移す
- 落ちたときに増えない順番にする
- 止まったことが分かる印を毎回残す
無料枠で運用するサイトだと、この種の「増え方の設計」が効いてきます。クレジットの上限とデプロイ回数の関係は ほぼ0円で始める広告収益ブログの設計と検証手順 に実費つきでまとめました。
関連記事
Netlifyのデプロイ失敗と本番チェックの誤検知を切り分けた記録
PRのプレビューと本番デプロイがNetlify側の内部エラーで失敗し、GitHub Actionsのliveチェックまで誤って落ちました。ログの見方、APIでは取れない情報、再試行を入れた修正の記録です。
- Netlify
- 運用
- Astro
日本語Webフォントを自己ホストして転送量を削る手順
Google Fontsから日本語フォントを読むとトップページで52リクエスト553KiBになっていた。サイトで使う文字だけのサブセットを自己ホストして3リクエスト396KiBにした実測と、増えたコストの話。
- Astro
- Netlify
- 表示速度
Astroブログでads.txtを設置して確認する手順
Astroのpublicディレクトリにads.txtを置いて本番で応答を確認した記録です。設置できても審査は別問題という実例と、404やHTMLが返る場合の切り分けをまとめます。
- AdSense
- Astro
- Netlify