chundev
日期:2026-04-24 技術棧:Next.js 16、TypeScript、PostgreSQL、k3s
現代 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、以及跟傳統企業流程引擎的代差。
一個典型的通知服務長這樣:
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 小時 → 若還沒核可就升級到上級」,你必須:
TimeoutQueue 行,含 ExpireDate = now + 24hTimeoutQueue 找到期的行TimeoutSucceed這一段業務邏輯要五六個地方同步改,出錯機率很高。
Inngest 是一個 durable workflow engine — 它不是 message queue(像 BullMQ、RabbitMQ),也不是 cron scheduler,而是把「async function 的執行狀態」本身做成 first-class concept。
核心概念叫 Durable Execution(Temporal 的說法 / 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。
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 上看得到。
這是最能體現 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 跟使用者動作之間的競態條件都正確。這就是代差。
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。
一份 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 |
具體差異:
TimeoutScanner.ts / RetryHandler.ts / DeadLetterProcessor.ts)sleepUntil + cancelOn 直接宣告式寫完整條 timeout 流程,不用跨多個檔案拼湊⚠️ 要補充的是:傳統 BPM 在 2000s 設計時是合理的 — 那年代 cloud-native / durable execution 還沒變成業界共識,SQL table + cron 是務實選擇。我們不是說舊方案「爛」,而是 2024-2026 年的現代 stack 讓這些複雜度消失了,新系統沒理由再自建。
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 | 特殊場景適合 |
選擇建議:
step.* primitive 讓分散式程式寫起來像同步 — 不用自己管 retry、timer、取消、memoizationcancelOn 的 match 比自己寫過濾條件好太多 — 宣告式取消是現代引擎的標配,傳統解法要在 DB 裡 delete row + 處理競態條件step.run — retry 會雙發,這是踩過的坑