架構藍圖:圖片上傳與處理流水線
處理使用者圖片的參考架構:上傳到 R2、把工作丟進佇列,讓消費者 Worker 用 Images 縮放轉檔、把變體寫回 R2、在 D1 記錄中繼資料,最後送出又快又最佳化的圖片。
我們要蓋什麼?
圖片處理很慢——把一張大照片縮放、轉檔可能要好幾秒。如果你讓使用者乾等著做完,上傳就會卡卡的,而且一出錯整個工作就沒了。這份藍圖把工作切成兩半:流水線的前半段收下檔案、立刻回應,後半段則在背景默默做粗重的事。
把兩半黏起來的關鍵是「佇列(queue)」。上傳 Worker 把原圖存進 R2(物件儲存),然後把一則很小的工作訊息丟進 Cloudflare Queues。另一個獨立的消費者 Worker 之後再把那則工作取出來,用 Cloudflare Images 把圖片最佳化、把變體寫回 R2,並把結果記在 D1(一個 SQL 資料庫)裡。這就是經典的「生產者—消費者(producer–consumer)」模式。
把它想成…
乾洗店。你把襯衫交出去,馬上拿到一張號碼牌(HTTP 202 +『pending 處理中』)——你不會站在那裡等它洗好。襯衫們掛在吊桿上排隊(佇列),店員在後場成批處理;等你的洗好,號碼牌就翻成『ready 完成』,你就能來取件。
每個角色負責什麼?
流水線裡每個產品都只負責一件清楚的事。把職責切開正是系統可靠的關鍵:任何一塊變慢、或短暫出錯,都不會把其他部分一起拖垮。
瀏覽器(用戶端)
把原始檔案送給上傳 Worker,之後再依需要的尺寸請求最佳化後的圖片。
上傳 Worker(生產者)
把原圖存進 R2、在 D1 新增一筆 pending 紀錄、把工作排進佇列,然後立刻回應 202。
R2(物件儲存)
存放原始位元組與每一個產生的變體。CDN 讀取時不收流出(egress)費用。
Queues(緩衝佇列)
把『接下工作』和『執行工作』拆開。緩衝工作、成批投遞,並在失敗時自動重試。
消費者 Worker
每來一批就被喚醒,從 R2 讀原圖、操作 Images、寫入變體、更新 D1。
Images(影像處理)
縮放、裁切並轉成 WebP/AVIF 等現代格式——真正做最佳化的那一步。
D1(中繼資料)
狀態的真實來源:有哪些圖、是否已完成、各有幾個變體。
解耦的那條界線
佇列是生產者與消費者之間的單向閥。生產者永遠不等消費者;消費者忙碌或失敗時,工作就乖乖排隊、稍後重試。兩邊都不需要知道對方是否健在。
資料模型
D1 追蹤每一張圖與它的變體。images 表是主要紀錄(id、R2 key、status、尺寸、變體數);variants 表則每個產生的尺寸放一列,用 image_id 連回去。
status 是心跳
因為工作是稍後才做的,前端不能假設一上傳圖片就好了。status 欄位(pending → ready → failed)讓 UI 可以輪詢、或先顯示轉圈圈,直到變體真的生出來。這就是『最終一致性(eventual consistency)』在實務上的樣子。
CREATE TABLE images (
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
width INTEGER,
height INTEGER,
variants INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE variants (
id TEXT PRIMARY KEY,
image_id TEXT NOT NULL REFERENCES images(id),
label TEXT NOT NULL,
key TEXT NOT NULL,
width INTEGER NOT NULL
);
-- Fast lookups of jobs still waiting to be processed
CREATE INDEX idx_images_status ON images (status);隨時間推進的流程
由上往下看。關鍵時刻是那個 202 回應:上傳在任何處理發生之前就先返回了。界線以下的所有步驟,都是在背景非同步進行的。
動手蓋出來
你會建立三個資源(一個 R2 bucket、一個佇列、一個 D1 資料庫),把它們設成 binding(綁定),再寫一個生產者、一個消費者和一個小前端。Images binding 不需要建立資源——它是執行期就有的能力。
建立資源
一個 R2 bucket 同時放原圖與變體;一個佇列載送工作;一個 D1 資料庫存中繼資料。
npx wrangler r2 bucket create user-images npx wrangler queues create image-jobs npx wrangler queues create image-jobs-dlq npx wrangler d1 create image-meta設定綁定
在 wrangler.jsonc 裡宣告 R2、佇列(同時當生產者與消費者)、D1 與 Images binding。死信佇列會接住一直失敗的工作。
{ "name": "image-pipeline", "main": "src/index.js", "compatibility_date": "2025-01-01", "r2_buckets": [ { "binding": "IMAGES_BUCKET", "bucket_name": "user-images" } ], "queues": { "producers": [ { "queue": "image-jobs", "binding": "IMAGE_QUEUE" } ], "consumers": [ { "queue": "image-jobs", "max_batch_size": 10, "max_batch_timeout": 5, "dead_letter_queue": "image-jobs-dlq" } ] }, "d1_databases": [ { "binding": "DB", "database_name": "image-meta", "database_id": "<your-d1-id>" } ], "images": { "binding": "IMAGES" } }建立資料表
把 schema 套用到你的 D1 資料庫。
npx wrangler d1 execute image-meta --remote --file=./schema.sql
前端 — 上傳檔案
<input type="file" id="file" accept="image/*" />
<button id="send">Upload</button>
<script>
document.getElementById("send").onclick = async () => {
const file = document.getElementById("file").files[0];
// Stream the raw bytes straight to the Upload Worker
const res = await fetch("/upload", {
method: "POST",
headers: { "content-type": file.type },
body: file,
});
const job = await res.json(); // { id, status: "pending" }
console.log("Queued job", job.id, "->", job.status);
};
</script>生產者 — 收下並排隊
export default {
// PRODUCER: store the upload, then enqueue a background job
async fetch(request, env, ctx) {
if (request.method !== "POST") {
return new Response("Use POST to upload", { status: 405 });
}
const id = crypto.randomUUID();
const key = `originals/${id}`;
const bytes = await request.arrayBuffer();
const contentType = request.headers.get("content-type") || "image/jpeg";
// 1) Save the ORIGINAL image to R2
await env.IMAGES_BUCKET.put(key, bytes, {
httpMetadata: { contentType },
});
// 2) Record metadata in D1 with status = pending
await env.DB.prepare(
"INSERT INTO images (id, key, status) VALUES (?, ?, 'pending')"
).bind(id, key).run();
// 3) Enqueue a job and reply instantly (do NOT wait for processing)
await env.IMAGE_QUEUE.send({ id, key });
return Response.json({ id, status: "pending" }, { status: 202 });
},
};訊息要小
注意訊息只有 { id, key }——絕不放圖片位元組。佇列訊息上限是 128 KB,而位元組已經在 R2 裡了。工作只需要說明『要處理哪個物件』就好。
消費者 — 處理整批
export default {
// CONSUMER: Cloudflare invokes this with a batch of queued jobs
async queue(batch, env, ctx) {
for (const message of batch.messages) {
try {
const { id, key } = message.body;
// 1) Read the original back from R2
const original = await env.IMAGES_BUCKET.get(key);
if (!original) {
message.ack(); // nothing to do
continue;
}
// 2) Build optimized variants with the Images binding
const sizes = [256, 1024];
const variantKeys = [];
for (const width of sizes) {
const result = await env.IMAGES
.input(original.body)
.transform({ width })
.output({ format: "image/webp" });
const variantKey = `variants/${id}/w${width}.webp`;
await env.IMAGES_BUCKET.put(variantKey, result.image());
variantKeys.push(variantKey);
}
// 3) Mark the row ready and record how many variants exist
await env.DB.prepare(
"UPDATE images SET status = 'ready', variants = ? WHERE id = ?"
).bind(variantKeys.length, id).run();
message.ack(); // success — never deliver again
} catch (err) {
message.retry(); // failure — Queues will redeliver later
}
}
},
};送出 — Images 轉換網址
你也可以用 Cloudflare Images 的轉換路徑,直接從網址即時縮放。瀏覽器只請求它需要的尺寸,CDN 再把結果快取在邊緣。
<!-- Resize + convert on the fly with a transformation URL -->
<!-- /cdn-cgi/image/<options>/<source-path> -->
<img
src="https://media.example.com/cdn-cgi/image/width=512,quality=80,format=auto/variants/abc123/w1024.webp"
width="512"
alt="optimized photo"
/>重點概念
非同步解耦
『接下工作』與『執行工作』被佇列隔開,所以後端再慢也不會拖慢上傳。
生產者與消費者
生產者用 env.IMAGE_QUEUE.send() 送工作;消費者的 queue() 處理函式成批處理。
冪等性
一則工作可能被送來不只一次,所以重跑必須安全——覆寫同樣的變體 key 即可。
變體(variants)
同一張圖預先產生的多種尺寸/格式(例如 256px WebP、1024px WebP),與原圖放在一起。
死信佇列(DLQ)
工作用完重試後落腳的另一個佇列,讓失敗可被檢查,而不是無聲消失。
最終一致性
剛上傳時圖還沒好;status 欄位會告訴用戶端變體何時生出來。
小提示、陷阱與計費
讓消費者保持冪等
Queues 保證『至少一次(at-least-once)』投遞,也就是同一則工作可能來兩次(例如重試之後)。請永遠寫入像 variants/<id>/w256.webp 這種固定的 key,這樣重跑只會覆寫——不會重複堆疊。
值得記住的事
- 一定要設死信佇列(DLQ),讓一直失敗的工作落到你能檢查的地方,而不是憑空消失。
- 存好 status(pending / ready / failed),讓前端可以輪詢、或先顯示轉圈圈,直到變體生出來。
- 佇列訊息要小——只送 id 與 R2 key,絕不送圖片位元組(128 KB 訊息上限)。
- R2 與 Queues 都不收流出(egress)費用;把產生的變體存起來,而不是每次請求都重新轉檔。
- 流量尖峰時,調整 max_batch_size 與 max_batch_timeout,在延遲與吞吐量之間取捨。
成本花在哪
R2 收儲存空間加上操作次數(不收流出費)。Queues 以每百萬次操作計費。Images 以每次轉換或每張儲存圖片計費。這個設計最大的好處就是『快取變體』:你只付一次轉換的錢,之後就從 R2 經由 CDN 免費送出已存好的結果。