前端 vs 後端:完全不同的問題

前端 debounce 和後端 debounce 名字一樣,但解的是兩個完全不同的問題——前端是「UI 互動節流」,後端是「事件去重與速率控制」。

前端後端
Debounce搜尋框打字,停止 300ms 才送 request50 個商品同時更新都觸發 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 環境失效。


延伸閱讀