Share Notes

chundev

View the Project on GitHub latteouka/share-notes

Inngest 是什麼?為什麼現代 workflow 系統不再自建 Queue 表

日期:2026-04-24 技術棧:Next.js 16、TypeScript、PostgreSQL、k3s


TL;DR

現代 serverless 架構下,void someAsync() 這種 fire-and-forget 可能會靜默丟失(runtime 在 request 結束後被回收,async 工作根本沒跑完)。傳統解法是自建 queue table + cron worker + 失敗重試表,光是「寄個 email、發個 webhook、做個逾時催辦」就要維護一整組基礎設施。Inngest 是一個開源 durable workflow engine,把這些問題外部化到一個獨立服務裡:你只要寫 await inngest.send(...) 和 async function,斷線、重啟、逾時、重試、取消全部引擎幫你管。本文說明 Inngest 是什麼、為什麼需要獨立部署成一個 service、以及跟傳統企業流程引擎的代差。


背景:fire-and-forget 在 serverless 會死得很慘

一個典型的通知服務長這樣:

async sendNotification(userId, title, content) {
  await db.notification.create({ data: { userId, title, content } });

  // Fire-and-forget — 不阻塞 request
  void this.trySendEmail(userId, title, content);
}

這段 code 在傳統 Node.js server(process 永遠跑著)沒問題,但丟到 Next.js API Route / Vercel / Cloudflare Workers / Kubernetes 短生命週期 pod 就會出事:

情境 後果
Request handler return 後 runtime 被回收 trySendEmail 根本沒跑完
SMTP server 暫時掉線,callback 丟出 exception 沒人 catch、沒 log、沒 retry
同時有 50 個 concurrent 通知 SMTP server 被打爆、沒 rate limit
Process 重 deploy 正在排隊的通知全部消失

簡單說:在現代架構下,任何不跟 request 同步等待的工作,都需要額外的 durability guarantee


傳統解法為什麼臃腫

大約 2005–2015 年代設計的企業流程系統,典型解法是在 DB 裡建四組同構的 queue 表,每組三張分別記 Queue / Succeed / Failed

4 × 3 = 12 張表,加上 4 個 cron scanner 每分鐘掃一次,加上鎖機制、失敗重試、dead letter 分流、監控 dashboard⋯⋯ 光是「把非同步工作做可靠」就變成一個子系統。

更痛的是這種架構沒有程式碼層級的 resume — 如果邏輯是「寄信 → 等 24 小時 → 若還沒核可就升級到上級」,你必須:

  1. 寄完信後手動寫一筆 TimeoutQueue 行,含 ExpireDate = now + 24h
  2. 一個 cron 每分鐘掃 TimeoutQueue 找到期的行
  3. 到期後執行「升級」邏輯,刪掉原 queue 行、寫入 TimeoutSucceed
  4. 另外寫 code 監聽「使用者核可」事件,提前刪掉 queue 行避免誤升級
  5. 寫監控驗證 cron 沒卡、沒漏掃

一段業務邏輯要五六個地方同步改,出錯機率很高。


Inngest 是什麼

Inngest 是一個 durable workflow engine — 它不是 message queue(像 BullMQ、RabbitMQ),也不是 cron scheduler,而是把「async function 的執行狀態」本身做成 first-class concept。

核心概念叫 Durable ExecutionTemporal 的說法 / Restate 的說法):

你寫的 async function 可以被「中斷」,引擎保證從斷點繼續執行 — 就算程式重啟、機器換一台、deploy 新版本都一樣。

做法是把 function 的「每一步」視為獨立的 durable unit:結果寫入持久化 log,重跑時從 log replay 到同一個狀態,只執行未完成的部分。

Inngest 提供三個殺手級 primitive:

Primitive 用途 傳統解法
step.run("id", fn) 執行 fn,成功結果寫 log;失敗自動 exponential backoff retry try/catch + 自建 retry table
step.sleep("24h") / step.sleepUntil(date) 持久化 timer(跨重啟) TimeoutQueue + cron scanner
step.waitForEvent("x", {...}) 阻塞等一個外部事件(含 timeout 條件) 自建事件訂閱 + polling

關鍵:這些 primitive 寫起來像同步,但底層全部是 durable。你的 function 停在 await step.sleep("24h") 這行的時候,真的不佔任何記憶體、真的能跨 deploy


三個示範 primitive 的具體例子

例子 1:Email 通知改成 durable

import { inngest } from "../client";
import { EmailRequested } from "../events";

export async function sendEmailHandler({ event, step }) {
  const { userId, subject, content } = event.data;

  // step.run:結果 memoize + 失敗自動 retry
  const user = await step.run("lookup-user", () =>
    db.user.findUnique({ where: { id: userId } })
  );
  if (!user?.email) return { skipped: "no-email" };

  await step.run("send", () =>
    emailService.send({ to: user.email, subject, content })
  );

  return { sent: true };
}

export const sendEmail = inngest.createFunction(
  {
    id: "send-notification-email",
    retries: 3,                     // 30s → 1m → 5m exponential backoff
    concurrency: { limit: 20 },     // 保護 SMTP 不被打爆
    triggers: [{ event: EmailRequested }],
  },
  sendEmailHandler
);

呼叫端改一行:

// 原本:void this.trySendEmail(userId, title, content);  ← 可能靜默丟失
// 改成:
await inngest.send(EmailRequested.create({ userId, subject: title, content }));

inngest.send 把事件寫入 Inngest server 後立刻 return(內網 < 5ms),接下來 server 負責持久化、排程、callback 到你的 /api/inngest endpoint 執行 function。SMTP 掛了就重試,全部都失敗就進 dead letter,每封信都有完整的 step-by-step trace 在 UI 上看得到。

例子 2:24h 催辦 + 48h 自動升級(30 行 code)

這是最能體現 durable execution 威力的場景:

export const taskTimeout = inngest.createFunction(
  {
    id: "task-timeout",
    retries: 2,
    cancelOn: [
      { event: "process/task.completed", match: "data.taskId" },
      { event: "process/task.cancelled", match: "data.taskId" },
    ],
  },
  { event: "task/timeout.schedule" },
  async ({ event, step }) => {
    const { taskId, firstNotifyAt, deadlineAt } = event.data;

    // Stage 1:等到催辦時間點
    await step.sleepUntil("wait-first-notify", firstNotifyAt);
    // 走到這行 = cancelOn 沒觸發 = task 仍未處理
    await step.run("send-reminder", () => sendTaskReminder(taskId));

    // Stage 2:等到 deadline
    await step.sleepUntil("wait-deadline", deadlineAt);
    await step.run("escalate", () => escalateTaskToSupervisor(taskId));

    return { resolved: "escalated" };
  }
);

關鍵是 cancelOn:只要在等待期間 emit 了匹配的事件(match: "data.taskId" 會自動對同一筆 task ID),整個 function 自動取消,不會發催辦、不會升級。沒有任何 polling、沒有任何 DB 掃描、沒有競態條件

傳統解法要做同樣的事:12 張 queue 表、一堆 cron scanner、自己管「提前取消」邏輯、確保 timer 跟使用者動作之間的競態條件都正確。這就是代差


為什麼需要「獨立部署成一個 service」

Inngest 是一個獨立的 binary,你的 app 跟它之間用 HTTP callback 通訊。為什麼不做成 library 塞進你的 app process?

原因 說明
跨 deploy 的持久化 你的 app 重 deploy 時正在跑的 function 要能暫停、等新 deploy 起來後繼續 — 這需要狀態外部化
排程解耦 sleepUntil("3 days later") 的 timer 不能依賴你的 app process 一直活著
事件去中心化 多個 function 訂閱同一個事件時,事件的 fan-out 由引擎做比較乾淨
觀察性獨立 每個 run 的 step-by-step trace 需要獨立 UI,不能跟業務 log 混在一起

架構長這樣:

k3s cluster
├── Deployment: your-app (Next.js)
│   └── /api/inngest              ← Inngest server callback 這裡
│             ▲
│             │ HTTP POST
│             │
├── Deployment: inngest (binary)   ← 自架開源版
│   └── :8288 UI + API
│             ▲
│             │ postgres
│             │
└── StatefulSet: postgres-inngest  ← 專屬 state backend

本地開發更簡單:pnpm dlx inngest-cli@latest dev 一個指令起來,內建 SQLite,不需要 Docker。


跟傳統 BPM 系統對比

一份 2000s 年代企業流程系統的 DDL 大約長這樣(常見模式):

功能 表數量 配套
寄信佇列 MessagesQueue / MessagesSucceed / MessagesFailed + SMTP worker cron
逾時催辦 TimeoutQueue / TimeoutSucceed / TimeoutFailed + timer cron
背景工作 JobQueue / JobSucceed / JobFailed + job worker cron
ESB 整合 ESBQueue / ESBSucceed / ESBFailed + integration cron
合計 12 張表 + 4 個 cron scanner 還要自己寫監控

Inngest 版本:

功能 表數量 配套
全部上述功能 0 張 queue table Inngest server + 你的 function code

具體差異:

⚠️ 要補充的是:傳統 BPM 在 2000s 設計時是合理的 — 那年代 cloud-native / durable execution 還沒變成業界共識,SQL table + cron 是務實選擇。我們不是說舊方案「爛」,而是 2024-2026 年的現代 stack 讓這些複雜度消失了,新系統沒理由再自建。


為什麼自架而不用 Inngest Cloud

Inngest 提供 Cloud 版(managed SaaS),也提供開源自架版(MIT license)。兩者權衡:

項目 Cloud 自架
啟動速度 綁帳號 5 分鐘 寫 helm / k8s yaml 1 天
維護成本 一個 pod + 一個 postgres
免費額度 50k runs/month 無上限
資料主權 Task payload 走公網、存 Cloud 全程內網、資料自管
Vendor lock-in 中(event schema 可搬,但要重部署)
成本預測 Run-based 計費,尖峰不可控 佔用既有 k3s 資源
Latency ~50-100ms 公網往返 < 5ms 內網

我們選自架是因為:


業界有哪些類似服務

Durable execution 是 2023-2026 快速成形的一個 category,主要玩家:

服務 特色 Tech Radar / 成熟度
Temporal 最成熟,業界最大 case study(Uber、Netflix、Coinbase) ThoughtWorks Adopt
Zeebe / Camunda 8 BPMN 導向,BA 能畫流程圖 企業 BPM 首選
Restate 新興、輕量、sidecar 模式 ThoughtWorks Assess
Trigger.dev Next.js 友善、UI 漂亮 活躍新創
Inngest Event-driven + 自架門檻低 活躍新創,社群成長快
Windmill 低代碼 workflow + 自動生 UI 特殊場景適合

選擇建議


學到的事

  1. Serverless ≠ 可以 fire-and-forget — Request handler 結束就可能失去 runtime,所有「不想讓 user 等」的工作都需要 durable guarantee
  2. Durable execution 是一個 category,不是一個工具 — Temporal / Inngest / Restate / Zeebe 都在解同一類問題,差別在編程模型
  3. step.* primitive 讓分散式程式寫起來像同步 — 不用自己管 retry、timer、取消、memoization
  4. cancelOnmatch 比自己寫過濾條件好太多 — 宣告式取消是現代引擎的標配,傳統解法要在 DB 裡 delete row + 處理競態條件
  5. 自架門檻比想像低 — 1 個 deployment + 1 個 statefulset 就能跑,k3s 適合不過
  6. 不要在 function 內呼叫非 idempotent 外部 API 沒包 step.run — retry 會雙發,這是踩過的坑

參考資料