一、踩坑现场:限流改完后反而更卡了
上周三晚高峰,我负责的电商秒杀接口突然炸了——本来要扛10万用户的接口,刚改完限流规则,前10分钟还稳,10分钟后直接把整个服务器打崩,监控显示CPU飙到100%,数据库连接池满得溢出,用户端全是“网络错误”。
查了半天才发现问题根源:之前为了扛住秒杀,给接口设的限流规则是“同一IP每分钟最多访问5次”,那天白天业务调整,要求把新用户的限流放宽到10次,老用户还是5次,结果改完规则后,之前已经建立的老连接,还在按旧规则跑——新用户刚进来按新规则走,老连接里的新用户却被卡得要死,而老连接里的老用户又没被限制,导致老连接攒了一堆无效请求,新连接的正常请求又被挤没了,整个限流直接失控。
1.1 先搞懂“限流”和“连接”的基础逻辑
怕刚接触的朋友懵,先把两个核心概念用大白话讲清楚:
- 限流:就像小区门口的闸机,每秒钟只放10个人进去,防止小区被挤爆;
- 连接:就像闸机和每个人的“临时通道”,你进来的时候闸机给你开了个专属通道,这个通道没关,你就可以一直用这个通道进出。
我之前的坑,本质就是:闸机换了新规则(新用户放10个,老用户放5个),但之前已经开的通道,还在按旧规则(不管新老都放5个)走——老通道里的新用户本来能进10个,现在只能进5个,就堵在通道里;老通道里的老用户本来只能进5个,现在没被限制,就一直往小区里冲,小区直接炸了。
二、问题拆解:为什么旧连接不更新规则?
为了把问题说透,我用一个具体的服务代码来还原场景,所有示例用Node.js + Express(选这个是因为简单,大家容易懂)。
2.1 先搭一个简单的限流服务
先写一个基础的Express服务,带限流功能,代码如下:
// 引入依赖
const express = require('express');
const app = express();
const port = 3000;
// 定义旧的限流规则:同一IP每分钟最多访问5次
const oldLimit = {
maxRequests: 5, // 最大请求数
windowMs: 60 * 1000 // 时间窗口:1分钟(毫秒)
};
// 存储每个IP的访问记录:key是IP,value是{count, resetTime}
const ipAccessLog = new Map();
// 限流中间件:每个请求进来先过这个
app.use((req, res, next) => {
const ip = req.ip; // 获取请求的IP
const now = Date.now();
// 检查这个IP有没有访问记录
if (!ipAccessLog.has(ip)) {
// 没有的话,初始化访问记录
ipAccessLog.set(ip, {
count: 1,
resetTime: now + oldLimit.windowMs // 重置时间:当前时间加1分钟
});
return next(); // 放行
}
// 有记录的话,先检查时间窗口有没有过期
const accessInfo = ipAccessLog.get(ip);
if (now > accessInfo.resetTime) {
// 过期了,重置记录
accessInfo.count = 1;
accessInfo.resetTime = now + oldLimit.windowMs;
return next(); // 放行
}
// 时间窗口没过期,检查请求数
if (accessInfo.count < oldLimit.maxRequests) {
// 没超,计数加1,放行
accessInfo.count++;
return next();
}
// 超了,拒绝请求
res.status(429).send('访问太频繁,请稍后再试');
});
// 秒杀接口
app.get('/seckill', (req, res) => {
res.send('秒杀成功');
});
app.listen(port, () => {
console.log(`服务运行在 http://localhost:${port}`);
});
这个代码的逻辑很简单:每个IP每分钟最多访问5次,超过就拒绝。
2.2 改完规则后出问题的代码
后来业务要求改规则:新用户(这里简化成“请求头带isNewUser: true”的用户)每分钟最多访问10次,老用户还是5次。我当时的改法很蠢,只改了限流规则的变量,没改中间件的更新逻辑,代码如下:
// 改后的新限流规则:老用户maxRequests=5,新用户maxRequests=10
const newLimit = {
oldUserMax: 5,
newUserMax: 10,
windowMs: 60 * 1000
};
// 这里是坑!中间件还是用旧的规则逻辑,没有更新
app.use((req, res, next) => {
const ip = req.ip;
const now = Date.now();
const isNewUser = req.headers.isNewUser === 'true'; // 判断是不是新用户
if (!ipAccessLog.has(ip)) {
// 初始化的时候,没有根据新老用户设置不同的最大请求数!
ipAccessLog.set(ip, {
count: 1,
resetTime: now + newLimit.windowMs,
// 这里没加新老用户的标识!
});
return next();
}
const accessInfo = ipAccessLog.get(ip);
if (now > accessInfo.resetTime) {
accessInfo.count = 1;
accessInfo.resetTime = now + newLimit.windowMs;
return next();
}
// 这里的最大请求数,居然还是用旧的5!没有根据新老用户判断!
if (accessInfo.count < oldLimit.maxRequests) {
accessInfo.count++;
return next();
}
res.status(429).send('访问太频繁,请稍后再试');
});
这下问题就出来了:
- 新连接(新的IP访问):初始化的时候,没有记录新老用户,所以最大请求数还是按旧规则的5算,新用户本来能进10次,现在只能进5次;
- 老连接(之前已经有访问记录的IP):中间件还是用旧的最大请求数5,新用户被卡,老用户没被限制,导致老连接攒了一堆无效请求。
三、修复方案:让旧连接也能更新规则
修复的核心逻辑是:每次请求进来,都要重新检查当前的限流规则,不能只在连接初始化的时候检查一次。
3.1 正确的修复代码
我把中间件的逻辑改了,每次请求进来,都重新获取当前的限流规则,并且根据新老用户动态调整最大请求数,代码如下:
// 正确的限流中间件:每次请求都重新检查规则
app.use((req, res, next) => {
const ip = req.ip;
const now = Date.now();
const isNewUser = req.headers.isNewUser === 'true';
// 每次都重新获取当前的限流规则(这里可以从配置中心、数据库等实时获取)
const currentLimit = {
oldUserMax: 5,
newUserMax: 10,
windowMs: 60 * 1000
};
// 计算当前用户的最大请求数:新用户用newUserMax,老用户用oldUserMax
const maxRequests = isNewUser ? currentLimit.newUserMax : currentLimit.oldUserMax;
if (!ipAccessLog.has(ip)) {
// 初始化的时候,记录当前的最大请求数(方便后续判断)
ipAccessLog.set(ip, {
count: 1,
resetTime: now + currentLimit.windowMs,
currentMax: maxRequests // 记录这次初始化时的最大请求数
});
return next();
}
const accessInfo = ipAccessLog.get(ip);
if (now > accessInfo.resetTime) {
// 时间窗口过期,重置的时候,重新计算最大请求数(用最新的规则)
const newMax = isNewUser ? currentLimit.newUserMax : currentLimit.oldUserMax;
accessInfo.count = 1;
accessInfo.resetTime = now + currentLimit.windowMs;
accessInfo.currentMax = newMax; // 更新最大请求数
return next();
}
// 时间窗口没过期,检查是否超过最大请求数(用最新的规则)
if (accessInfo.count < maxRequests) {
accessInfo.count++;
return next();
}
res.status(429).send('访问太频繁,请稍后再试');
});
这个修复的关键有两点:
- 每次请求都重新获取规则:不管是新连接还是老连接,每次进来都用最新的限流规则,不会再用旧规则;
- 时间窗口过期时重置规则:如果一个IP的访问记录已经过期(比如1分钟到了),下次再访问的时候,会重新计算最大请求数,相当于“刷新”了连接的规则。
3.2 适配分布式场景的优化
如果是分布式服务(比如多个服务器节点),上面的代码还不够,因为每个节点的ipAccessLog是独立的,没法同步。这时候可以用Redis来存储访问记录,每次请求都从Redis获取最新的规则,并且更新访问记录,代码如下(简化版):
// 引入Redis客户端
const redis = require('redis');
const client = redis.createClient();
// 限流中间件(分布式版)
app.use(async (req, res, next) => {
const ip = req.ip;
const now = Date.now();
const isNewUser = req.headers.isNewUser === 'true';
const windowMs = 60 * 1000;
// 从Redis获取最新的限流规则(可以在Redis中实时更新)
const currentLimit = await client.get('current_limit');
const limit = JSON.parse(currentLimit); // 格式:{"oldUserMax":5,"newUserMax":10}
const maxRequests = isNewUser ? limit.newUserMax : limit.oldUserMax;
// 从Redis获取该IP的访问记录
const accessKey = `access:${ip}`;
const accessInfo = await client.get(accessKey);
if (!accessInfo) {
// 初始化访问记录,存入Redis,过期时间设为1分钟
await client.setEx(accessKey, windowMs / 1000, JSON.stringify({
count: 1,
resetTime: now + windowMs,
currentMax: maxRequests
}));
return next();
}
const info = JSON.parse(accessInfo);
if (now > info.resetTime) {
// 过期,重置访问记录
const newMax = isNewUser ? limit.newUserMax : limit.oldUserMax;
await client.setEx(accessKey, windowMs / 1000, JSON.stringify({
count: 1,
resetTime: now + windowMs,
currentMax: newMax
}));
return next();
}
if (info.count < maxRequests) {
// 没超,计数加1,更新Redis
info.count++;
await client.setEx(accessKey, windowMs / 1000, JSON.stringify(info));
return next();
}
res.status(429).send('访问太频繁,请稍后再试');
});
四、应用场景、优缺点和注意事项
4.1 应用场景
这个修复方案适合所有需要动态调整限流规则的场景,比如:
- 电商秒杀、大促期间,根据实时流量调整新老用户的限流;
- 接口上线新功能时,给新用户放更宽松的限流,老用户保持稳定;
- 服务器资源紧张时,临时收紧所有用户的限流,资源充足时再放宽。
4.2 技术优缺点
优点
- 规则更新实时:不管是新连接还是老连接,都能及时用上最新的限流规则,不会出现规则“断层”;
- 适配分布式:用Redis存储访问记录,多个节点可以共享规则,不会出现节点间限流不一致的问题;
- 灵活度高:可以根据不同的用户类型、时间、流量情况,动态调整不同的限流规则。
缺点
- 性能开销:每次请求都要获取最新的规则(比如从Redis获取),会增加一点点响应时间,不过现在Redis的速度很快,一般可以忽略;
- 规则冲突:如果规则更新太频繁,可能会出现短时间内规则不一致的情况,比如一个请求在规则更新前进来,按旧规则处理,更新后进来的按新规则处理,不过这个影响很小,因为时间窗口只有1分钟,很快就会统一。
4.3 注意事项
- 规则的实时获取:不要把规则写死在代码里,最好存在配置中心(比如Nacos、Spring Cloud Config)或者Redis里,这样可以在不重启服务的情况下更新规则;
- 时间窗口的设置:时间窗口不能太长也不能太短,太长会导致规则更新的延迟大,太短会导致频繁重置访问记录,增加Redis的压力;
- 新老用户的判断:要保证新老用户的判断逻辑是一致的,比如不能一个节点按请求头判断,另一个节点按用户ID判断,否则会出现限流不一致的问题;
- 监控和告警:要实时监控限流的效果,比如监控拒绝请求的数量、CPU、内存、数据库连接数等,如果出现异常,要及时调整规则。
五、总结
这次踩坑的核心教训是:不能把“连接”和“规则”绑定死,规则更新的时候,要保证所有正在使用的连接都能及时用上新规则。很多人在改限流规则的时候,只考虑新连接的情况,忘了老连接还在按旧规则跑,结果导致限流失控。
修复的思路其实很简单:每次请求进来,都重新检查当前的限流规则,不管是新连接还是老连接,都用最新的规则处理,时间窗口过期的时候,再重新初始化连接的规则。这样就能保证规则更新后,所有连接都能及时适配,不会出现控制失衡的问题。
评论
围绕“限流阈值动态调整后旧连接仍按原规则执行引发控制失衡的修复”参与讨论