后端接口防刷与限流的几种实现层次与取舍
接口防刷与限流要在「分层、精度、成本」之间权衡。本文讲解防刷与限流的区别,梳理网关、应用中间件、业务代码三层防护的取舍,对比固定窗口、滑动窗口、令牌桶、漏桶四种算法,并演示 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(如"请求过于频繁"),前端据此给用户友好提示。






