API接口一旦对外暴露,就难免遇到请求频率失控的情况。有的用户会写脚本轮询,有的业务高峰期流量天然集中,还有恶意爬虫不断试探接口边界。如果所有用户共用同一套限流阈值,要么普通用户体验太差,要么高级用户额度不够用。基于用户等级做流量控制是更合理的方案:系统根据用户身份动态确定限流策略,让不同等级的用户享受不同的请求配额,整个过程对业务代码透明,实现真正的自动化管控。

一、为什么限流要区分用户等级
统一限流最大的问题是资源分配不合理。假设整个API全局限流是每分钟1000次请求,一个免费用户写了个死循环脚本,几分钟就能把额度吃光,付费企业用户反而被拒之门外。这种情况下限流保护了服务器,却伤害了最该保护的客户。
按等级限流的核心思想是把限流粒度从接口级别细化到用户级别,再结合用户身份赋予不同配额。常见的等级划分包括:免费用户每分钟60次、基础付费用户300次、企业用户3000次、内部服务账号基本不限。这样一来,单个用户的异常行为只会影响自己,不会扩散到整个系统。
从工程角度看,这套机制还需要满足两个要求:一是自动化,限流逻辑应该通过中间件统一挂载,业务代码不需要感知限流的存在;二是可配置,等级和配额的对应关系最好放在配置或数据库里,运营侧能随时调整而不用改代码发版。
二、令牌桶算法:等级限流的数学基础
固定窗口计数是最简单的限流算法,实现容易但有个明显缺陷:在窗口边界处可能出现双倍流量。比如限流是每分钟60次,用户在第59秒发60次、第61秒再发60次,两秒内实际通过了120次请求,服务瞬间压力翻倍。
令牌桶算法解决了这个问题。它的原理是系统以固定速率往桶里放令牌,桶有容量上限,放满了就不再放。每个请求进来时先从桶里取一个令牌,取到了就放行,取不到就拒绝。这样既限制了平均速率(令牌生成速率),又允许一定程度的突发流量(桶内积攒的令牌),比固定窗口平滑得多。
在Node.js中实现一个简单的内存版令牌桶并不复杂,核心是记录上次补充令牌的时间戳,每次请求时根据时间差计算应该补充多少令牌:
class TokenBucket {
constructor(capacity, refillRatePerSec) {
this.capacity = capacity; // 桶容量,允许的最大突发量
this.tokens = capacity; // 当前令牌数
this.refillRate = refillRatePerSec; // 每秒补充的令牌数
this.lastRefill = Date.now();
}
tryTake(tokens = 1) {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true;
}
return false;
}
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
module.exports = TokenBucket;这个类的用法很直接:new TokenBucket(100, 1)表示桶最多存100个令牌、每秒补充1个,对应平均每秒1次请求、允许瞬间爆发100次的策略。不同用户等级只需要实例化不同参数的桶即可。
三、基于用户等级的限流中间件实现
有了令牌桶,下一步是把等级和配额关联起来,并封装成Express中间件。关键点在于如何识别用户等级:一般从请求头中的token解析,或者查询用户表。这里为了演示清晰,假设有一个getUserLevel函数已经完成了身份识别,返回值是free、pro、enterprise之一。
每个用户需要独立的令牌桶,所以用一个Map按用户ID维度存储桶实例, Map中不存在的用户首次请求时懒加载创建。完整中间件实现如下:
const express = require('express');
const TokenBucket = require('./token-bucket');
// 用户等级对应的限流配置:容量 和 每秒补充速率
const LEVEL_CONFIG = {
free: { capacity: 10, refillRate: 10 / 60 }, // 每分钟10次
pro: { capacity: 60, refillRate: 60 / 60 }, // 每分钟60次
enterprise: { capacity: 600, refillRate: 600 / 60 } // 每分钟600次
};
// 用户ID -> 令牌桶 的映射
const buckets = new Map();
function getBucket(userId, level) {
if (!buckets.has(userId)) {
const cfg = LEVEL_CONFIG[level] || LEVEL_CONFIG.free;
buckets.set(userId, new TokenBucket(cfg.capacity, cfg.refillRate));
}
return buckets.get(userId);
}
function rateLimitByLevel(req, res, next) {
const userId = req.headers['x-user-id'];
if (!userId) {
return res.status(401).json({ error: '缺少用户标识' });
}
const level = getUserLevel(userId); // 业务侧自行实现
const bucket = getBucket(userId, level);
if (bucket.tryTake()) {
next();
} else {
res.setHeader('Retry-After', 60);
res.status(429).json({ error: '请求过于频繁,请稍后再试' });
}
}
const app = express();
app.use(rateLimitByLevel);
app.get('/api/data', (req, res) => {
res.json({ data: 'ok' });
});
app.listen(3000, () => console.log('服务已启动'));被限流时返回429状态码是HTTP语义上的标准做法,同时带上Retry-After响应头告诉客户端多久后可以重试,这对编写规范SDK的用户非常友好。业务路由代码完全不用改动,限流逻辑全部收敛在中间件里,这就是自动化控制的体现。
还有一个细节值得注意:内存中的Map会随用户量增长而膨胀,长期运行的服务需要定期清理不活跃的桶,可以给每个桶记录最后访问时间,用定时任务每十分钟清理一次超过一小时未活动的条目,避免内存缓慢泄漏。
四、多实例部署下用Redis共享限流状态
上面的方案在单机场景完全够用,但一旦服务部署了多个实例、前面挂了负载均衡,问题就来了:用户的请求被分发到不同实例,每个实例各自维护一份令牌桶,实际限流阈值变成了配置值乘以实例数,管控形同虚设。
解决思路是把限流状态集中存到Redis。利用Redis的Lua脚本保证原子性,把补充令牌、判断余量、扣减令牌这几步在一个脚本内完成,避免并发请求导致的竞态条件。脚本逻辑如下:
const redis = require('redis');
const client = redis.createClient({ url: 'redis://127.0.0.1:6379' });
const LUA_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(data[1]) or capacity
local last = tonumber(data[2]) or now
-- 按时间差补充令牌
tokens = math.min(capacity, tokens + (now - last) / 1000 * rate)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, 3600)
return allowed
`;
async function redisRateLimit(userId, level) {
const cfg = LEVEL_CONFIG[level] || LEVEL_CONFIG.free;
const allowed = await client.eval(
LUA_SCRIPT, 1,
`ratelimit:${userId}`,
cfg.capacity, cfg.refillRate, Date.now()
);
return allowed === 1;
}Redis方案把桶的状态持久化在ratelimit:用户ID这个key中,并设置了过期时间自动清理,天然解决了内存泄漏问题。代价是每次请求多了一次网络往返,高并发下Redis可能成为新的瓶颈,这时可以按用户ID做哈希分片,或者引入本机缓存做一层前置拦截,挡掉明显超限的请求再查Redis。
选型上给一个简单建议:单体应用或实例数很少的服务,直接用内存版令牌桶,性能最好、零依赖;容器化弹性伸缩、多可用区部署的服务,必须上Redis集中存储,必要时也可以直接使用rate-limiter-flexible这类成熟库,它对内存和Redis两种后端做了统一封装,切换成本低。
五、落地时的几个优化建议
首先是等级配置外部化。把LEVEL_CONFIG这类映射关系放到数据库或配置中心,并提供管理后台修改入口,运营调整配额后立即生效,不需要重启服务。其次是限流维度组合,真实业务中除了按用户等级,还常常叠加接口维度,比如导出接口对free用户限每小时5次、对enterprise用户限每小时100次,只需要把限流key从纯用户ID改成用户ID加接口名拼接即可。
其次要重视限流的可观测性。记录每次拒绝事件,统计各等级的拒绝率,如果发现某等级拒绝率长期超过百分之十,说明配额设置与真实使用模式不匹配,需要调整。这些指标接上监控告警后,限流策略就从静态配置进化成了可运营的动态体系。
最后给客户端提供自查接口也是好习惯。暴露一个/api/quota端点返回当前用户剩余次数和重置时间,让客户端能主动避让,比被动收到429再退避的体验好得多。等级限流的最终目标不是拒绝请求,而是引导流量在合理区间内平滑运行。