后端接口防刷与限流的几种实现层次与取舍

2026-09-28 162 浏览 0 评论 0 字

接口防刷与限流要在「分层、精度、成本」之间权衡。本文讲解防刷与限流的区别,梳理网关、应用中间件、业务代码三层防护的取舍,对比固定窗口、滑动窗口、令牌桶、漏桶四种算法,并演示 IP 级限流中间件、express-rate-limit 以及多实例下用 Redis 做共享计数的做法。

问题背景

接口一旦暴露到公网,就会被各种爬虫、恶意脚本盯着打:同一账号反复调验证码接口,营销接口被高频请求刷爆,甚至接口被 DDoS 打垮。防刷和限流不是"要不要做"的问题,而是"在哪一层做、做到多严"的问题。做太宽形同虚设,做太狠又把正常用户给误伤了。

原理说明

防刷和限流是一对概念:

  • 防刷 :识别并拦截"异常的、非人为"的访问,比如同一 IP 一秒发几十次,或同一账号极速操作。
  • 限流 :对流量设置上限,超过阈值的请求直接拒绝或走降级,保护后端不被压垮。

处理层次从外到内通常是:网关/反向代理层(最外层)→ 应用中间件层 → 业务代码层,越外层越轻量、越粗粒度,越内层越精细、越耗资源。常见算法:

  • 固定窗口 :按固定时间窗计数,简单但窗口边界会出现尖峰。
  • 滑动窗口 :用更细的时间片滑动统计,更平滑。
  • 令牌桶 :以恒定速率补充令牌,请求取令牌,平滑突发流量。
  • 漏桶 :请求进桶按固定速率出,天然削峰。

选择原则:简单场景先上固定窗口或令牌桶,追求精度再上滑动窗口;分布式多实例时还要考虑把计数放到 Redis 共享,而不是本地内存各自为政。

示例代码

应用层用中间件做 IP 级限流(一次性计数,固定窗口):

// middlewares/rateLimit.js
const ipCount = new Map();   // 简单内存计数,真实项目可换 Redis

// 每 IP 每秒最多允许 reqPerSecond 次
function rateLimit({ windowMs = 1000, limit = 30 } = {}) {
  return function (req, res, next) {
    const ip = req.ip || req.connection.remoteAddress;
    const now = Date.now();
    const bucket = ipCount.get(ip);

    if (!bucket || now - bucket.start > windowMs) {
      // 时间窗过期,重置计数
      ipCount.set(ip, { start: now, count: 1 });
      return next();
    }

    bucket.count += 1;
    if (bucket.count > limit) {
      return res.status(429).json({
        code: 429, message: '请求过于频繁,请稍后再试', data: null,
      });
    }
    next();
  };
}

module.exports = rateLimit;

如果引入现成库 express-rate-limit 更省心:

const rateLimit = require('express-rate-limit');

// 全局限流:每 IP 每分钟 100 次
const globalLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  message: { code: 429, message: '请求过于频繁', data: null },
});

// 登录接口单独收紧:每 IP 每分钟 5 次
const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 5,
  message: { code: 429, message: '尝试次数过多,请稍后再试', data: null },
});

// 用法
app.use('/api', globalLimiter);
app.post('/api/login', loginLimiter, loginHandler);

多实例场景可把计数放 Redis(以秒作窗口键,用 INCR 命令原子自增):

// 用 IP 加时间窗做 Redis key
async function checkByRedis(client, ip) {
  const key = `rl:${ip}:${Math.floor(Date.now() / 1000)}`;
  const count = await client.incr(key);
  await client.expire(key, 2);       // 让 key 自动过期
  return count;
}

常见坑与解决方案

  • 坑一:只在单机内存计数,掉到多实例/负载均衡后就失效了。解法:分布式场景把计数放到 Redis,保证共享一致。
  • 坑二:限流阈值设得太高形同虚设,或太低误伤正常用户。解法:结合业务压测数据动态调整,并给不同接口差异化配置。
  • 坑三:误用固定窗口导致每秒"边界点"能瞬时冲到两倍量。解法:对精度敏感的场景换滑动窗口或令牌桶。
  • 坑四:限流后直接 500,前端没法区分是崩溃还是被限。解法:统一返回 429 + 清晰 message,前端可据此提示"操作太频繁"。

小结

防刷和限流要在"分层 + 精确度 + 成本"之间权衡:能上 Nginx、中间件的尽量不搁业务代码;追求精度再用滑动窗口;多实例必须上 Redis。给接口留好 429 的语义,前端也就更从容。接口安全理顺了,还得回头管好进程本身的部署与重启。

FAQ(常见问题)

Q:单机内存计数为什么多实例后就失效了?
A:负载均衡下请求会散到不同实例,各自本地计数互不相通,限流就形同虚设。分布式场景要把计数放到 Redis 共享,保证各实例一致。

Q:固定窗口有什么副作用?
A:固定窗口在时间窗边界会出现「秒杀尖峰」,同一刻可瞬间冲到两倍量。对精度敏感的场景应改用滑动窗口或令牌桶平滑突发流量。

Q:限流后直接返回 500 有什么问题?
A:前端无法区分是服务器崩溃还是被限流。应统一返回 429 + 清晰 message(如"请求过于频繁"),前端据此给用户友好提示。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论