一、踩坑现场:限流改完后反而更卡了

上周三晚高峰,我负责的电商秒杀接口突然炸了——本来要扛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('访问太频繁,请稍后再试');
});

这下问题就出来了:

  1. 新连接(新的IP访问):初始化的时候,没有记录新老用户,所以最大请求数还是按旧规则的5算,新用户本来能进10次,现在只能进5次;
  2. 老连接(之前已经有访问记录的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('访问太频繁,请稍后再试');
});

这个修复的关键有两点:

  1. 每次请求都重新获取规则:不管是新连接还是老连接,每次进来都用最新的限流规则,不会再用旧规则;
  2. 时间窗口过期时重置规则:如果一个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 技术优缺点

优点

  1. 规则更新实时:不管是新连接还是老连接,都能及时用上最新的限流规则,不会出现规则“断层”;
  2. 适配分布式:用Redis存储访问记录,多个节点可以共享规则,不会出现节点间限流不一致的问题;
  3. 灵活度高:可以根据不同的用户类型、时间、流量情况,动态调整不同的限流规则。

缺点

  1. 性能开销:每次请求都要获取最新的规则(比如从Redis获取),会增加一点点响应时间,不过现在Redis的速度很快,一般可以忽略;
  2. 规则冲突:如果规则更新太频繁,可能会出现短时间内规则不一致的情况,比如一个请求在规则更新前进来,按旧规则处理,更新后进来的按新规则处理,不过这个影响很小,因为时间窗口只有1分钟,很快就会统一。

4.3 注意事项

  1. 规则的实时获取:不要把规则写死在代码里,最好存在配置中心(比如Nacos、Spring Cloud Config)或者Redis里,这样可以在不重启服务的情况下更新规则;
  2. 时间窗口的设置:时间窗口不能太长也不能太短,太长会导致规则更新的延迟大,太短会导致频繁重置访问记录,增加Redis的压力;
  3. 新老用户的判断:要保证新老用户的判断逻辑是一致的,比如不能一个节点按请求头判断,另一个节点按用户ID判断,否则会出现限流不一致的问题;
  4. 监控和告警:要实时监控限流的效果,比如监控拒绝请求的数量、CPU、内存、数据库连接数等,如果出现异常,要及时调整规则。

五、总结

这次踩坑的核心教训是:不能把“连接”和“规则”绑定死,规则更新的时候,要保证所有正在使用的连接都能及时用上新规则。很多人在改限流规则的时候,只考虑新连接的情况,忘了老连接还在按旧规则跑,结果导致限流失控。

修复的思路其实很简单:每次请求进来,都重新检查当前的限流规则,不管是新连接还是老连接,都用最新的规则处理,时间窗口过期的时候,再重新初始化连接的规则。这样就能保证规则更新后,所有连接都能及时适配,不会出现控制失衡的问题。