ch42-cost
在 GitHub 上檢視·6 個檔案·12.7 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch42-cost
cd ch42-cost
npm install可用指令
npm run dev # wrangler dev
npm run model # node scripts/cost-model.mjs說明
ch42-cost — measure the billable units before you deploy
Section titled “ch42-cost — measure the billable units before you deploy”Companion example for chapter 42. Measured with wrangler 4.118.0 on
2026-08-01. Prices in scripts/cost-model.mjs are Cloudflare’s published
rates on that date and live in one PRICES object — edit that, not the
arithmetic.
npm installnpm run devcurl localhost:8787/cost/d1 # seeds 5,000 rows, then measurescurl localhost:8787/cost/kvcurl localhost:8787/cost/r2npm run model # LinkForge at 10k / 1M / 100M clicks per monthFinding 1 — meta.rows_read is the invoice line, and you can read it locally
Section titled “Finding 1 — meta.rows_read is the invoice line, and you can read it locally”Same four queries against a 5,000-row table, before and after adding indexes:
| Query | Rows returned | rows_read without index | rows_read with index |
|---|---|---|---|
WHERE slug = ? | 1 | 5,000 | 2 |
WHERE tenant_id = ? | 100 | 5,000 | 101 |
SELECT COUNT(*) | 1 | 5,000 | 5,000 |
ORDER BY clicks DESC LIMIT 10 | 10 | 10,000 | 10 |
Three things worth stopping on:
- The point lookup is 2,500× cheaper with one index.
ORDER BY ... LIMIT 10without an index reads twice the table — the sort scans everything and then runs it through the sorter. “I only take ten rows” is not a cheap query.COUNT(*)costs the whole table either way. An index does not help; you need a maintained counter.
Write amplification, same example:
| Write | rows_written |
|---|---|
UPDATE links SET clicks = clicks + 1 (clicks is indexed) | 2 |
UPDATE links SET url = ? (url is not) | 1 |
One row to the table, one to each index touching a written column. Rows written cost 1000× more than rows read ($1.00/M vs $0.001/M), so indexes on hot-write columns are a real trade, not a free win.
Finding 2 — KV misses are billed reads
Section titled “Finding 2 — KV misses are billed reads”{ "hit": "v", "miss": null, "missWithMetadata": { "value": null, "metadata": null, "cacheStatus": null } }All three are billed. The docs: “All operations incur charges, including
fetches for non-existent keys that return a null (Workers API) or
HTTP 404 (REST API).”
So a cache-aside miss costs a KV read plus the D1 query, not just the D1 query. And billing is per key — bulk APIs save latency, not money.
Finding 3 — R2 operation classes
Section titled “Finding 3 — R2 operation classes”| Operation | Class | Price |
|---|---|---|
put, list, copy, each multipart part | A | $4.50/M |
get, head | B | $0.36/M |
delete | free | — |
list() costs 12.5× a get(). head() is not free, and a miss costs the
same as a hit. delete is free, so cleanup has no cost pressure — but a
lifecycle storage-class transition is Class A.
The cost model
Section titled “The cost model”=== 1,000,000 clicks / month === usage $0.80 + $5.00 plan minimum = $5.80/month
=== 100,000,000 clicks / month === workers requests $27.60 workers CPU $2.06 kv reads $45.00 kv writes $47.50 queues operations $119.60 DO requests $1.35 workers logs $109.20 usage $352.31 + $5.00 plan minimum = $357.31/month
=== the same 100M-click month with ONE missing index === d1 rows read 5.00e+12 -> $4,975/month (the indexed version above: $0.00)At a million clicks a month the whole thing is $5.80 — do not spend time
optimising there. At a hundred million, the two largest lines are Queues
and Workers Logs, which together exceed requests + CPU + KV combined. Both
are costs nobody thinks to model: Queues bills three operations per message
(write, read, delete) and batching does not reduce it, and logs bill per
event with one invocation log plus every console.log.
And one missing index costs 14× the entire rest of the bill.
原始碼
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch42-cost",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
"d1_databases": [{ "binding": "DB", "database_name": "ch42", "database_id": "ch42-local" }],
"kv_namespaces": [{ "binding": "CACHE", "id": "ch42-cache" }],
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "ch42" }]
}package.json
{
"name": "ch42-cost",
"private": true,
"type": "module",
"scripts": { "dev": "wrangler dev", "model": "node scripts/cost-model.mjs" },
"devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2", "@types/node": "^22.0.0" }
}src/index.ts
/**
* Measures the units Cloudflare actually bills, locally, before you deploy.
*
* The point of the chapter: you do not have to guess. D1 reports rows_read on
* every query, and that number IS the invoice line.
*/
const ROWS = 5000;
async function seed(env: Env): Promise<void> {
const seeded = await env.CACHE.get("seeded");
if (seeded === "1") return;
await env.DB.exec(
"CREATE TABLE IF NOT EXISTS links (id INTEGER PRIMARY KEY, tenant_id TEXT NOT NULL, slug TEXT NOT NULL, url TEXT NOT NULL, clicks INTEGER NOT NULL DEFAULT 0)",
);
const stmt = env.DB.prepare("INSERT OR IGNORE INTO links (id, tenant_id, slug, url, clicks) VALUES (?1, ?2, ?3, ?4, ?5)");
for (let batch = 0; batch < ROWS / 500; batch++) {
await env.DB.batch(
Array.from({ length: 500 }, (_, i) => {
const n = batch * 500 + i;
return stmt.bind(n, `t${n % 50}`, `slug-${n}`, `https://example.test/${n}`, n % 97);
}),
);
}
await env.CACHE.put("seeded", "1");
}
type Measured = { sql: string; rows_read: number; rows_written: number; returned: number };
async function measure(env: Env, sql: string, ...binds: unknown[]): Promise<Measured> {
const res = await env.DB.prepare(sql).bind(...binds).all();
const meta = res.meta as unknown as { rows_read?: number; rows_written?: number };
return {
sql,
rows_read: meta.rows_read ?? -1,
rows_written: meta.rows_written ?? -1,
returned: res.results.length,
};
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
// ---- D1: the index IS the invoice --------------------------------
if (url.pathname === "/cost/d1") {
await seed(env);
const withoutIndex = [
await measure(env, "SELECT slug FROM links WHERE slug = ?1", "slug-4321"),
await measure(env, "SELECT slug FROM links WHERE tenant_id = ?1", "t7"),
await measure(env, "SELECT COUNT(*) AS n FROM links"),
await measure(env, "SELECT slug FROM links ORDER BY clicks DESC LIMIT 10"),
];
await env.DB.exec("CREATE INDEX IF NOT EXISTS links_slug ON links (slug)");
await env.DB.exec("CREATE INDEX IF NOT EXISTS links_tenant ON links (tenant_id)");
await env.DB.exec("CREATE INDEX IF NOT EXISTS links_clicks ON links (clicks DESC)");
const withIndex = [
await measure(env, "SELECT slug FROM links WHERE slug = ?1", "slug-4321"),
await measure(env, "SELECT slug FROM links WHERE tenant_id = ?1", "t7"),
await measure(env, "SELECT COUNT(*) AS n FROM links"),
await measure(env, "SELECT slug FROM links ORDER BY clicks DESC LIMIT 10"),
];
// Index write amplification: a write that touches an indexed column
// writes to the table AND to each index.
const write = await measure(
env,
"UPDATE links SET clicks = clicks + 1 WHERE id = ?1",
1,
);
const writeUnindexedColumn = await measure(
env,
"UPDATE links SET url = ?2 WHERE id = ?1",
2,
"https://example.test/changed",
);
return Response.json({ tableRows: ROWS, withoutIndex, withIndex, write, writeUnindexedColumn });
}
// ---- KV: a miss is a billed read ---------------------------------
if (url.pathname === "/cost/kv") {
await env.CACHE.put("present", "v");
return Response.json({
note: "All of these are billed reads, including the ones returning null.",
hit: await env.CACHE.get("present"),
miss: await env.CACHE.get("definitely-not-here"),
missWithMetadata: await env.CACHE.getWithMetadata("also-not-here"),
// A negative cache entry costs 1 write but converts N misses into
// N reads either way -- it does NOT save the read. It only helps if
// the alternative is hitting D1 or an origin.
});
}
// ---- R2: which call is Class A ------------------------------------
if (url.pathname === "/cost/r2") {
await env.BUCKET.put("a/1.txt", "one");
await env.BUCKET.put("a/2.txt", "two");
const listed = await env.BUCKET.list({ prefix: "a/" });
const got = await env.BUCKET.get("a/1.txt");
const head = await env.BUCKET.head("a/1.txt");
const headMissing = await env.BUCKET.head("a/nope.txt");
return Response.json({
classA: {
"put x2": 2,
"list x1": 1,
note: "list() is Class A at $4.50/million -- 12.5x a Class B get()",
},
classB: {
"get x1": 1,
"head x2": 2,
note: "head() is Class B, NOT free. A miss still costs.",
},
free: { "delete": 0 },
observed: {
listedKeys: listed.objects.map((o) => o.key),
gotSize: got === null ? null : (await got.arrayBuffer()).byteLength,
headSize: head?.size ?? null,
headMissing: headMissing === null ? "null" : "object",
},
});
}
return new Response(
[
"ch42-cost:",
" GET /cost/d1 rows_read with and without indexes (seeds 5000 rows)",
" GET /cost/kv every read is billed, including misses",
" GET /cost/r2 which operations are Class A vs Class B",
].join("\n"),
{ headers: { "content-type": "text/plain" } },
);
},
} satisfies ExportedHandler<Env>;scripts/cost-model.mjs
#!/usr/bin/env node
/**
* A LinkForge cost model at three scales.
*
* Prices are Cloudflare's published rates as of 2026-08-01 and are stated
* per unit so you can re-check each one against its pricing page. They WILL
* change -- edit PRICES, not the arithmetic.
*
* Usage: node scripts/cost-model.mjs [--clicks 1000000] [--json]
*/
// --- published rates, 2026-08-01 -------------------------------------------
const PRICES = {
plan: { minimumMonthly: 5.0 }, // covers Workers, Pages Functions, KV, Hyperdrive, DO
workers: { includedRequests: 10e6, perMillionRequests: 0.3, includedCpuMs: 30e6, perMillionCpuMs: 0.02 },
kv: { includedReads: 10e6, perMillionReads: 0.5, includedWrites: 1e6, perMillionWrites: 5.0, includedStorageGb: 1, perGbMonth: 0.5 },
d1: { includedRowsRead: 25e9, perMillionRowsRead: 0.001, includedRowsWritten: 50e6, perMillionRowsWritten: 1.0, includedStorageGb: 5, perGbMonth: 0.75 },
r2: { includedClassA: 0, perMillionClassA: 4.5, includedClassB: 0, perMillionClassB: 0.36, perGbMonth: 0.015 },
do: { includedRequests: 1e6, perMillionRequests: 0.15, includedGbS: 400e3, perMillionGbS: 12.5, includedRowsRead: 25e9, perMillionRowsRead: 0.001, includedRowsWritten: 50e6, perMillionRowsWritten: 1.0 },
queues: { includedOps: 1e6, perMillionOps: 0.4 },
logs: { includedEvents: 20e6, perMillionEvents: 0.6 },
};
const args = process.argv.slice(2);
const flag = (n, d) => { const i = args.indexOf(n); return i === -1 ? d : args[i + 1]; };
const asJson = args.includes("--json");
/** Per-click work, from the LinkForge design in this series. */
function model(clicksPerMonth) {
const createsPerMonth = Math.round(clicksPerMonth / 200); // 1 create per 200 clicks
const cacheHitRate = 0.9;
// Workers: one request per click, plus creates and the dashboard.
const requests = clicksPerMonth + createsPerMonth * 4;
// ~1 ms CPU on a cache hit, ~4 ms when D1 is involved.
const cpuMs = clicksPerMonth * (cacheHitRate * 1 + (1 - cacheHitRate) * 4) + createsPerMonth * 6;
// KV: one read per click (hit or miss -- misses ARE billed), one write per
// cache fill and per create.
const kvReads = clicksPerMonth;
const kvWrites = clicksPerMonth * (1 - cacheHitRate) + createsPerMonth;
// D1: only on cache miss. WITH the right indexes: ~2 rows read per lookup.
// Without them it is the whole table -- see the `unindexed` scenario below.
const d1RowsRead = clicksPerMonth * (1 - cacheHitRate) * 2;
// One row per create, times two because slug is indexed (write amplification).
const d1RowsWritten = createsPerMonth * 2;
// Queues: one click event per click, 3 operations per message.
const queueOps = clicksPerMonth * 3;
// Durable Objects: one counter per slug, batched from the queue. Assume the
// consumer aggregates a batch of 10 into one DO request, ~5 ms active each.
const doRequests = clicksPerMonth / 10;
const doGbS = (doRequests * 0.005 * 128) / 1024;
const doRowsWritten = doRequests;
// Logs: one invocation log per request plus one structured log per click.
const logEvents = requests + clicksPerMonth;
return { clicksPerMonth, createsPerMonth, requests, cpuMs, kvReads, kvWrites, d1RowsRead, d1RowsWritten, queueOps, doRequests, doGbS, doRowsWritten, logEvents };
}
const over = (used, included, perMillion) => Math.max(0, used - included) / 1e6 * perMillion;
function price(u) {
const lines = {
"workers requests": over(u.requests, PRICES.workers.includedRequests, PRICES.workers.perMillionRequests),
"workers CPU": over(u.cpuMs, PRICES.workers.includedCpuMs, PRICES.workers.perMillionCpuMs),
"kv reads": over(u.kvReads, PRICES.kv.includedReads, PRICES.kv.perMillionReads),
"kv writes": over(u.kvWrites, PRICES.kv.includedWrites, PRICES.kv.perMillionWrites),
"d1 rows read": over(u.d1RowsRead, PRICES.d1.includedRowsRead, PRICES.d1.perMillionRowsRead),
"d1 rows written": over(u.d1RowsWritten, PRICES.d1.includedRowsWritten, PRICES.d1.perMillionRowsWritten),
"queues operations": over(u.queueOps, PRICES.queues.includedOps, PRICES.queues.perMillionOps),
"DO requests": over(u.doRequests, PRICES.do.includedRequests, PRICES.do.perMillionRequests),
"DO duration": over(u.doGbS, PRICES.do.includedGbS, PRICES.do.perMillionGbS),
"DO rows written": over(u.doRowsWritten, PRICES.do.includedRowsWritten, PRICES.do.perMillionRowsWritten),
"workers logs": over(u.logEvents, PRICES.logs.includedEvents, PRICES.logs.perMillionEvents),
};
const usage = Object.values(lines).reduce((a, b) => a + b, 0);
return { lines, usage, total: Math.max(PRICES.plan.minimumMonthly, PRICES.plan.minimumMonthly + usage) };
}
const scales = flag("--clicks") ? [Number(flag("--clicks"))] : [10_000, 1_000_000, 100_000_000];
const out = scales.map((c) => {
const u = model(c);
const p = price(u);
return { clicks: c, usage: u, cost: p };
});
if (asJson) {
console.log(JSON.stringify(out, null, 2));
} else {
for (const { clicks, usage, cost } of out) {
console.log(`\n=== ${clicks.toLocaleString()} clicks / month ===`);
console.log(` requests ${Math.round(usage.requests).toLocaleString()} cpu ${Math.round(usage.cpuMs).toLocaleString()} ms`);
console.log(` kv ${Math.round(usage.kvReads).toLocaleString()} reads / ${Math.round(usage.kvWrites).toLocaleString()} writes`);
console.log(` d1 ${Math.round(usage.d1RowsRead).toLocaleString()} rows read`);
console.log(` queues ${Math.round(usage.queueOps).toLocaleString()} ops DO ${Math.round(usage.doRequests).toLocaleString()} req / ${usage.doGbS.toFixed(1)} GB-s`);
for (const [k, v] of Object.entries(cost.lines)) {
if (v > 0.005) console.log(` ${k.padEnd(20)} $${v.toFixed(2)}`);
}
console.log(` usage $${cost.usage.toFixed(2)} + $${PRICES.plan.minimumMonthly.toFixed(2)} plan minimum = $${cost.total.toFixed(2)}/month`);
}
// The point of the chapter: one missing index dominates everything above.
console.log("\n=== the same 100M-click month with ONE missing index ===");
const u = model(100_000_000);
// Measured in this example: an unindexed slug lookup on a 5,000-row table
// reads 5,000 rows. LinkForge at this scale has ~500,000 links.
const unindexedRowsRead = u.clicksPerMonth * 0.1 * 500_000;
const cost = over(unindexedRowsRead, PRICES.d1.includedRowsRead, PRICES.d1.perMillionRowsRead);
console.log(` d1 rows read ${unindexedRowsRead.toExponential(2)} -> $${cost.toLocaleString(undefined, { maximumFractionDigits: 0 })}/month`);
console.log(` (the indexed version above: $${price(u).lines["d1 rows read"].toFixed(2)})`);
}.gitignore
node_modules/
.wrangler/
worker-configuration.d.tstsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts", "node"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}