前端 vs 後端:完全不同的問題
前端 debounce 和後端 debounce 名字一樣,但解的是兩個完全不同的問題——前端是「UI 互動節流」,後端是「事件去重與速率控制」。
| 前端 | 後端 | |
|---|---|---|
| Debounce | 搜尋框打字,停止 300ms 才送 request | 50 個商品同時更新都觸發 rebuild-search-index,只需跑一次 |
| Throttle | 滾動事件每 100ms 最多觸發一次 handler | 你的系統呼叫 Google Maps API,每秒不能超過 50 次 |
| Rate Limit | — | 外部打你的 API,每 IP 每分鐘最多 100 次 |
前端的實作在 Throttle 實作 有完整說明。這篇專注後端的三個應用場景。
實際案例對照
在說實作前,先確認你的問題屬於哪一類:
後端 Debounce 的場景:
- 後台批次更新 50 個商品分類 → 每個都觸發
rebuild-search-index→ 合併成一次 - 訂單狀態每秒更新 20 次 → 每次都觸發發 push notification → 3 秒內只發一次
- 用戶快速連點「儲存」3 次 → 3 個 DB write 只要最後一個生效
- Webhook 收到大量
product.updated事件 → 同一個商品的事件合併,避免 race condition
後端 Throttle 的場景:
- 批次發 1000 封 email,SendGrid 限制 100 RPS → Worker 控制發送速率
- 批次 geocode 地址,Google Maps 限制 50 QPS → 控制 outbound 速率
- 呼叫 OpenAI API,有 Token Per Minute 限制 → 控制請求速率避免 429
- 資料匯入時呼叫第三方驗證 API → 不能一次打爆對方
容易混淆的案例:
- 用戶重複下單(連點)→ 這是 Idempotency 問題,不是 debounce(見 Idempotency 設計)
- 外部 bot 刷你的 API → 這是 Rate Limiting 問題(見 Rate Limiting)
後端 Debounce:事件去重
場景:批次操作觸發的重複事件
電商後台更新 50 個商品的分類,每個商品更新都觸發 search-index-rebuild——但你只需要重建一次。
做法:時間窗口內去重(Redis SET NX + TTL)
async function debounce(
redis: Redis,
key: string,
fn: () => Promise<void>,
delayMs = 5000
): Promise<void> {
const lockKey = `debounce:${key}`;
// 如果這個 key 在 TTL 內已經有排程,直接跳過
const acquired = await redis.set(lockKey, '1', 'NX', 'PX', delayMs);
if (!acquired) return;
// TTL 到了才執行
await searchQueue.add(
'rebuild-index',
{ key },
{ delay: delayMs, jobId: `debounce:${key}` }
);
}
// 批次更新時,50 次呼叫只會排一個 job
for (const product of updatedProducts) {
await debounce(redis, `search:${product.category}`, async () => {
await searchIndexService.rebuild(product.category);
}, 3000);
}場景:事件風暴(Event Storm)
Webhook 觸發系統在短時間內收到大量事件(例如訂單批次匯入),每個事件都會觸發庫存重算:
// BullMQ 的 jobId 去重:相同 jobId 的 job 只保留一個
async function debounceJob(queue: Queue, jobName: string, data: unknown, key: string, delayMs: number) {
await queue.add(jobName, data, {
jobId: `debounced:${key}`, // 相同 key 的 job 只有一個在 queue 裡
delay: delayMs,
});
// BullMQ 的行為:如果 queue 裡已有相同 jobId 且還沒開始執行,新的 add 會被忽略
// delay 重置不會發生——先排進去的那個會跑
}後端 Throttle:控制對外呼叫速率
場景:遵守第三方 API 的 Rate Limit
你呼叫 Google Maps API,限制每秒 50 次。你有 1000 個地址要 geocode:
import Bottleneck from 'bottleneck';
// Bottleneck:控制 concurrent 數和間隔
const limiter = new Bottleneck({
maxConcurrent: 5, // 同時最多 5 個 request
minTime: 20, // 每個 request 至少間隔 20ms(= 最多 50 RPS)
reservoir: 50, // 每秒最多 50 次(sliding window)
reservoirRefreshAmount: 50,
reservoirRefreshInterval: 1000,
});
// 把每個呼叫包進 limiter.schedule
async function geocodeAddresses(addresses: string[]) {
return Promise.all(
addresses.map(address =>
limiter.schedule(() => googleMapsClient.geocode(address))
)
);
}場景:Queue Worker 的 Throttle(BullMQ)
Queue 裡有 1000 個 email 要發,但 SendGrid 限制每秒 100 封:
const emailWorker = new Worker('send-email', async (job) => {
await emailService.send(job.data);
}, {
connection: redis,
concurrency: 10, // 同時處理 10 個 job
limiter: {
max: 100, // 每個時間窗口最多 100 個 job
duration: 1000, // 時間窗口:1 秒
},
});這樣即使 queue 裡有 10000 個 job,worker 也不會超過 100 RPS 去打 SendGrid。
Rate Limiting 三個層次(不要搞混)
| 層次 | 解決的問題 | 實作位置 |
|---|---|---|
| Inbound rate limit | 別人打你的 API 不能太快 | middleware(express-rate-limit) |
| Outbound throttle | 你打別人的 API 不能太快 | Bottleneck / Queue limiter |
| Internal debounce | 內部事件重複觸發只處理一次 | Redis NX + Queue jobId |
這三個經常被混在一起討論,但它們方向完全不同。Inbound 是保護你,Outbound 是尊重對方,Internal 是優化自己。
指數退避(Exponential Backoff)
Throttle 被觸發時(429 Too Many Requests),正確的 retry 策略:
async function callWithBackoff<T>(
fn: () => Promise<T>,
options = { maxRetries: 5, baseDelay: 1000 }
): Promise<T> {
for (let attempt = 0; attempt < options.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error.status !== 429 || attempt === options.maxRetries - 1) {
throw error;
}
// Retry-After header 優先
const retryAfter = parseInt(error.headers?.['retry-after'] ?? '0') * 1000;
const backoffDelay = options.baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000; // 避免所有 retry 同時打
const delay = Math.max(retryAfter, backoffDelay) + jitter;
await sleep(delay);
}
}
throw new Error('Max retries exceeded');
}Retry-After header 告訴你多久之後可以重試——優先用這個值,比自己算的 backoff 更準確。
防重複送出(UI 層的後端保護)
前端 debounce 不可靠(用戶可以手動呼叫 API、JS 被繞過),後端要有自己的保護:
// 用 Idempotency-Key 防止重複送出
// 詳見 40-idempotency,這裡補充 throttle 的角度:
// 同一個用戶在 3 秒內不能送超過 1 次相同操作
const submitLimiter = new Map<string, number>();
const preventDoubleSubmit = (req: AuthRequest, res: Response, next: NextFunction) => {
const key = `${req.user.id}:${req.path}`;
const lastSubmit = submitLimiter.get(key) ?? 0;
const now = Date.now();
if (now - lastSubmit < 3000) {
return res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil((lastSubmit + 3000 - now) / 1000),
});
}
submitLimiter.set(key, now);
next();
};生產環境用 Redis 存這個狀態(不是 in-memory Map),否則多 pod 環境失效。
