sell整合實戰
image

架構藍圖:圖片上傳與處理流水線

處理使用者圖片的參考架構:上傳到 R2、把工作丟進佇列,讓消費者 Worker 用 Images 縮放轉檔、把變體寫回 R2、在 D1 記錄中繼資料,最後送出又快又最佳化的圖片。

202即時收下
async非同步處理
5串接產品
$0R2 / Queues 流出費
insights

我們要蓋什麼?

圖片處理很慢——把一張大照片縮放、轉檔可能要好幾秒。如果你讓使用者乾等著做完,上傳就會卡卡的,而且一出錯整個工作就沒了。這份藍圖把工作切成兩半:流水線的前半段收下檔案、立刻回應,後半段則在背景默默做粗重的事。

把兩半黏起來的關鍵是「佇列(queue)」。上傳 Worker 把原圖存進 R2(物件儲存),然後把一則很小的工作訊息丟進 Cloudflare Queues。另一個獨立的消費者 Worker 之後再把那則工作取出來,用 Cloudflare Images 把圖片最佳化、把變體寫回 R2,並把結果記在 D1(一個 SQL 資料庫)裡。這就是經典的「生產者—消費者(producer–consumer)」模式。

dry_cleaning

把它想成…

乾洗店。你把襯衫交出去,馬上拿到一張號碼牌(HTTP 202 +『pending 處理中』)——你不會站在那裡等它洗好。襯衫們掛在吊桿上排隊(佇列),店員在後場成批處理;等你的洗好,號碼牌就翻成『ready 完成』,你就能來取件。

schema完整流水線

上傳

存原圖

送工作

投遞批次

轉檔

變體

寫入一列

送出

最佳化圖片

瀏覽器

上傳 Worker

R2 原圖

Queues 佇列

消費者 Worker

Images 影像處理

R2 變體圖

D1 中繼資料

CDN 邊緣

account_tree

每個角色負責什麼?

流水線裡每個產品都只負責一件清楚的事。把職責切開正是系統可靠的關鍵:任何一塊變慢、或短暫出錯,都不會把其他部分一起拖垮。

devices

瀏覽器(用戶端)

把原始檔案送給上傳 Worker,之後再依需要的尺寸請求最佳化後的圖片。

cloud_upload

上傳 Worker(生產者)

把原圖存進 R2、在 D1 新增一筆 pending 紀錄、把工作排進佇列,然後立刻回應 202。

database

R2(物件儲存)

存放原始位元組與每一個產生的變體。CDN 讀取時不收流出(egress)費用。

queue

Queues(緩衝佇列)

把『接下工作』和『執行工作』拆開。緩衝工作、成批投遞,並在失敗時自動重試。

settings_suggest

消費者 Worker

每來一批就被喚醒,從 R2 讀原圖、操作 Images、寫入變體、更新 D1。

auto_fix_high

Images(影像處理)

縮放、裁切並轉成 WebP/AVIF 等現代格式——真正做最佳化的那一步。

schema

D1(中繼資料)

狀態的真實來源:有哪些圖、是否已完成、各有幾個變體。

解耦的那條界線

佇列是生產者與消費者之間的單向閥。生產者永遠不等消費者;消費者忙碌或失敗時,工作就乖乖排隊、稍後重試。兩邊都不需要知道對方是否健在。

schema生產者—消費者解耦

送工作

投遞批次

成功確認

失敗重試

上傳 Worker

Queues 緩衝

消費者 Worker

schema

資料模型

D1 追蹤每一張圖與它的變體。images 表是主要紀錄(id、R2 key、status、尺寸、變體數);variants 表則每個產生的尺寸放一列,用 image_id 連回去。

schemaimages 與 variants 表

產生

IMAGES

text

id

PK

text

key

text

status

int

width

int

height

int

variants

VARIANTS

text

id

PK

text

image_id

FK

text

label

text

key

int

width

info

status 是心跳

因為工作是稍後才做的,前端不能假設一上傳圖片就好了。status 欄位(pending → ready → failed)讓 UI 可以輪詢、或先顯示轉圈圈,直到變體真的生出來。這就是『最終一致性(eventual consistency)』在實務上的樣子。

sqlschema.sql
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);
swap_vert

隨時間推進的流程

由上往下看。關鍵時刻是那個 202 回應:上傳在任何處理發生之前就先返回了。界線以下的所有步驟,都是在背景非同步進行的。

schema上傳 → 排隊 → 處理 → 完成
"D1""Images""消費者 Worker""Queues""R2""上傳 Worker""瀏覽器""D1""Images""消費者 Worker""Queues""R2""上傳 Worker""瀏覽器"背景非同步稍後上傳檔案存入原始物件新增一列 狀態 pending送出工作訊息202 已排入佇列投遞批次取出原圖縮放並轉檔寫入變體更新狀態 ready用 id 取圖讀取狀態與變體最佳化圖片
construction

動手蓋出來

你會建立三個資源(一個 R2 bucket、一個佇列、一個 D1 資料庫),把它們設成 binding(綁定),再寫一個生產者、一個消費者和一個小前端。Images binding 不需要建立資源——它是執行期就有的能力。

  1. 建立資源

    一個 R2 bucket 同時放原圖與變體;一個佇列載送工作;一個 D1 資料庫存中繼資料。

    bash
    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
  2. 設定綁定

    在 wrangler.jsonc 裡宣告 R2、佇列(同時當生產者與消費者)、D1 與 Images binding。死信佇列會接住一直失敗的工作。

    jsonc
    {
      "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" }
    }
  3. 建立資料表

    把 schema 套用到你的 D1 資料庫。

    bash
    npx wrangler d1 execute image-meta --remote --file=./schema.sql

前端 — 上傳檔案

htmlupload.html
<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>

生產者 — 收下並排隊

jssrc/producer.js
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 });
  },
};
fast_forward

訊息要小

注意訊息只有 { id, key }——絕不放圖片位元組。佇列訊息上限是 128 KB,而位元組已經在 R2 裡了。工作只需要說明『要處理哪個物件』就好。

消費者 — 處理整批

jssrc/consumer.js
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 再把結果快取在邊緣。

htmlserve.html
<!-- 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"
/>
school

重點概念

call_split

非同步解耦

『接下工作』與『執行工作』被佇列隔開,所以後端再慢也不會拖慢上傳。

sync_alt

生產者與消費者

生產者用 env.IMAGE_QUEUE.send() 送工作;消費者的 queue() 處理函式成批處理。

restart_alt

冪等性

一則工作可能被送來不只一次,所以重跑必須安全——覆寫同樣的變體 key 即可。

photo_library

變體(variants)

同一張圖預先產生的多種尺寸/格式(例如 256px WebP、1024px WebP),與原圖放在一起。

report

死信佇列(DLQ)

工作用完重試後落腳的另一個佇列,讓失敗可被檢查,而不是無聲消失。

hourglass_top

最終一致性

剛上傳時圖還沒好;status 欄位會告訴用戶端變體何時生出來。

tips_and_updates

小提示、陷阱與計費

warning

讓消費者保持冪等

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,在延遲與吞吐量之間取捨。
savings

成本花在哪

R2 收儲存空間加上操作次數(不收流出費)。Queues 以每百萬次操作計費。Images 以每次轉換或每張儲存圖片計費。這個設計最大的好處就是『快取變體』:你只付一次轉換的錢,之後就從 R2 經由 CDN 免費送出已存好的結果。