流量守门员:接口限流艺术

在用户使用服务的过程中,可能会出现一些缺乏实质意义或违反服务条款的行为,包括但不限于以下几种情况:

  1. 以异常高的频率发送短信;
  2. 不断地更改个人资料信息;
  3. 过度频繁地进行点赞操作;
  4. 频繁提交评论内容。

针对上述行为实施流量控制措施是必要的,其主要目的如下:

  1. 防止恶意攻击:通过限制访问频率有效抵御机器人或自动化脚本对验证码等敏感接口的高频次调用尝试。
  2. 确保服务稳定性与可用性:避免由于短时间内涌入大量请求而造成的服务过载甚至崩溃,从而保障平台能够持续稳定地为用户提供服务。
  3. 维护良好用户体验:合理设置限流规则,在不影响正常用户正常使用体验的前提下,对异常行为加以限制。这样既保护了广大用户的权益,也维护了一个健康、积极的网络环境。

常见的方案

算法特点适用场景
固定窗口简单易实现,但存在窗口切换时的流量突增问题。低并发场景
滑动窗口更平滑的流量控制,但实现复杂度较高。高精度限流需求
令牌桶允许突发流量,通过令牌生成速率控制平均流量。需要容忍突发的场景
漏桶严格限制流量速率,输出流量恒定。需要稳定输出的场景

限流的维度:

  1. 按 IP 限流:防止单一 IP 高频攻击。
  2. 按用户 ID 限流:针对登录用户,防止账号被恶意攻击。
  3. 按手机号/邮箱限流:防止短信/邮件验证码被滥发。
  4. 组合维度:例如 IP + 用户ID,提高安全性。

时间滑动窗口

这里我们本系统设计了一个“时间滑动窗口”限流算法,它将时间划分为若干个固定大小的窗口,每个窗口内记录了该时间段内的请求次数。通过动态地滑动窗口,可以动态调整限流的速率,以应对不同的流量变化。

整个限流可以概括为两个主要步骤:

  1. 统计窗口内的请求数量
  2. 应用限流规则

限流的组合维度:IP+邮箱,利用 Redis 的有序集合 zset 实现思路如下:

  1. 允许用于 3 分钟内,只能发送 3 个验证码,或者 10 分钟内只能发送 8 个验证码
  2. 于是将用户发送邮箱验证码行为设计为 key= 场景 : 行为 : md5(ip+邮箱)
  3. score = 时间戳
  4. value = 时间戳


描述文字

实现思路

假设,现在接口只允许同一个用户 3 分钟内只能发送 3 次邮箱验证码。

  1. 记录窗口的请求数量

  1. 此时,计算“当前时间”- “3 分钟”内请求的次数 = 2 次 < 3 次,所以可以发送新请求。

  1. 如果,此时在 “3 分钟”时,又新增了请求,由于 3 分钟内请求次数已经超过了 3 次,拒绝请求。

  1. 此时,等待了 1 分钟后,再次发送请求
    1. 首先,剔除窗口外的请求(00 分钟)
    2. 计算,“当前时间”- “3 分钟”内请求的次数 = 2 次,新增请求。

后续的业务,就时不同场景中,根据不同的需求,进行修改校验就行了,比如 5 分钟限流 3 次,10 分钟限流 8 次等。

代码实现

代码实现方面的思路:

  1. 基于 Redis 的有序集合 Zset 实现滑动窗口限流(List 集合可以实现滑动窗口)。
  2. 目标对象时接口,此时我们就可以使用 AOP 拦截请求,达到限流的目的。
描述文字
统计窗口内容请求次数
private boolean isAllow(String key, LimitFlowAnno limitFlowAnno) {
	String luaScript = """
	local key = KEYS[1]
	local current_time = tonumber(ARGV[1])
	local window_size = tonumber(ARGV[2])
	local threshold = tonumber(ARGV[3])

	-- Remove outdated entries
	redis.call('ZREMRANGEBYSCORE', key, 0, current_time - window_size)

	-- Check current count
	local count = redis.call('ZCARD', key)
	if count >= threshold then
	return "0"  -- reject
	end

	-- Allow and record this request
	redis.call('ZADD', key, current_time, current_time)
	redis.call('PEXPIRE', key, window_size)  -- auto-expire the key
	return "1"  -- allow
	""";

	long windowSizeMillis = limitFlowAnno.windowSize() * UNIT;
	long requestLimit = limitFlowAnno.requestLimit();
	long currentTimestamp = System.currentTimeMillis();

	String result = stringRedisTemplate.execute(
		new DefaultRedisScript<>(luaScript, String.class),
		Collections.singletonList(key),
	String.valueOf(currentTimestamp),
	String.valueOf(windowSizeMillis),
	String.valueOf(requestLimit)
	);

	return "1".equals(result);
}
AOP 限流拦截
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface LimitFlowAnno {
    String behavior() default "";

    /**
     * Time scope, the unit is minute.
     */
    long windowSize() default 1;


    /**
     * Limitation times.
     */
    long requestLimit() default 3;
}
@Component
@Aspect
@Slf4j
@Order(2)
public class LimitFlowAop {

	private final long UNIT = 60 * 1000;

	@Resource
	private StringRedisTemplate stringRedisTemplate;

	@Before("@annotation(limitFlowAnno)")
	private void handleBefore(JoinPoint joinPoint, LimitFlowAnno limitFlowAnno) {
		String argJsonStr = JSON.toJSONString(joinPoint.getArgs()[0]);
		HashMap<String, String> requestMap = JsonUtil.jsonStrToObj(argJsonStr, HashMap.class);

		String email = requestMap.get("to");
		String behavior = limitFlowAnno.behavior();
		String key = String.format(RedisKey.LIMIT_FLOW_KEY, behavior, email);

		if (isAllow(key, limitFlowAnno)) {
			log.info("请求通过");
		} else {
			throw new BizException(BizCode.CONTROL_FLOW);
		}
	}
}

最后,看Redis中的数据结构


Reference

  1. 基于Redis有序集合实现滑动窗口限流
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值