主流算法
固定窗口(Fixed Window)
这是最直观的逻辑,就像限制每分钟只能有 60 个人通过检票口。
核心:
- 划定窗口:以每分钟或者每秒为一个窗口
- 计算:在这个窗口内,每接收一个请求,计数器就+1
- 判断:如果计数器超过60,就拒绝请求
- 重置:当始时间从 00:00 走到 00:01 则 计数器清零,表示新的窗口开始。
问题:
- 如果在上一个窗口的最后一秒进来60个请求及当前窗口的第一秒也进来60个请求,那么就是两秒钟接收了120个请求。并且是没有超时的。
- 这就是固定窗口的缺点,可能短暂的两秒突发请求就会把后端服务打挂
滑动窗口 (Sliding Window)
固定窗口算法的"进化版"。
它的核心目的就是解决固定窗口临界值的问题。
核心:
- 一个可移动的窗口,根据传入的时间来确定窗口的范围。
- 如果你请求的时间是
12:00:30那么窗口就是11:59:30到12:00:30。 - 系统只统计当前时刻往前推 1 分钟这个区间内的请求总数。
- 它是如何消除临界突发的?
- 假设限制是 100/分。
12:00:59来了 100 个请求。12:01:00又来了 1 个请求。固定窗口 会重置,允许通过。滑动窗口 会看12:00:00到12:01:00这区间。它发现里面已经有 100 个请求了(刚才那波),所以第 101 个请求会被拒绝。完美解决!
- 假设限制是 100/分。
使用场景
漏桶算法 (Leaky Bucket)
想象一个底部有漏洞的水桶 🪣。不管上面的水(请求)倒进来的速度有多快,水都会以恒定的速度流出。这种算法非常适合用于平滑流量。
核心:
- 固定的处理速度,强制要求削峰填谷
- 超出桶容量的请求,会直接拒绝, 强行平滑突发流量
- 绝对不允许突发流量。就算桶里有水,他也是以固定流速处理
适用场景:
- 需要严格保护下游服务,防止其不稳定的场景(比如写数据库,或者调用一个极其脆弱的第三方API)
令牌桶算法 (Token Bucket)
系统以恒定速度像桶里发放"令牌" 🪙,请求只有拿到令牌才能通过。这个算法最有趣的地方在于它允许一定程度的"突发流量"。
核心:
- 系统以恒定的速度往桶里放令牌
- 桶有容量上限,满了之后新的令牌就丢掉
- 每个请求必须带走桶里的一个令牌
- 如果桶积攒了10个令牌,瞬间来了10个请求,它们可以同时拿走令牌
适用场景:
- 大部分场景都适用。因为它即限制了平均速度,又允许用户偶尔快一下,也就是允许突发流量
总结一张图
| 维度 | 1. 固定窗口 (Fixed Window) | 2. 滑动窗口 (Sliding Window - ZSET) | 3. 漏桶 (Leaky Bucket) | 4. 令牌桶 (Token Bucket) |
|---|---|---|---|---|
| 核心逻辑 | 切豆腐 。 按时间段(如1分钟)一刀切,计数器清零重置。 | 移动相框 。 记录每个请求的时间点,实时统计"当前时刻前N秒"内的总数。 | 漏斗 。 不管进水(请求)多快,出水(处理)永远是匀速的。 | 存钱罐 。 按固定速度发钱(令牌),有钱才能消费(处理请求),钱能攒。 |
| Redis结构 | String= (INCR + EXPIRE) |
ZSET (存 UUID + Timestamp) |
String (存水位 + 上次时间) |
String (存令牌数 + 上次时间) |
| 内存开销 | ⭐ 极低 (O(1), 仅存1个计数) | 💥 极高 (O(N), 存N个请求记录) | ⭐ 低 (O(1), 存2个数字) | ⭐ 低 (O(1), 存2个数字) |
| 流量特征 | 锯齿状 临界点允许双倍流量突发。 | 平滑 极其精准,无临界突发。 | 直线 强行削平波峰,绝对匀速。 | 平稳+突发 限制平均速率,但允许短时间突发。 |
| 优点 | 实现最简单,性能最高。 | 精度最高,符合直觉。 | 保护能力最强,防止下游崩溃。 | 用户体验最好,能应对瞬间高并发。 |
| 缺点 | 有临界突发隐患(防不住跨窗口攻击)。 | 随着QPS升高,内存和CPU消耗爆炸。 | 无法应对突发流量(严格模式下),用户需排队。 | 参数配置稍微复杂一点点。 |
| 适用场景 | 粗粒度限制 防爬虫、防恶意刷接口、短信频次限制。 | 低频高精限制 1分钟限5次短信、1小时限5次登录失败。 | 保护脆弱下游 调用银行接口、写老旧数据库、秒杀削峰。 | API网关通用限流 绝大多数互联网高并发业务。 |
| 推荐指数 | ⭐⭐ | ⭐ (仅限低频) | ⭐⭐⭐ (特定场景) | ⭐⭐⭐⭐⭐ (通用推荐) |
实现方式
Redis+lua脚本实现
固定窗口
java
/**
* -- KEYS[1]: 限流的 Key,
* -- ARGV[1]: 窗口大小(秒),例如 60
* -- ARGV[2]: 限制次数,例如 10
*/
private final static String FIXED_WINDOW_SCRIPT =
"local key = KEYS[1] " +
"local window = tonumber(ARGV[1]) " +
"local limit = tonumber(ARGV[2]) " +
"local current = redis.call('INCR', key) " +
"if current == 1 then " + // 窗口内 第一次访问时 添加过期时间
" redis.call('EXPIRE', key, window) " +
"end " +
"if current > limit then " + // 窗口内访问次数超过限制则返回0 反之返回1
" return false " +
"else " +
" return true " +
"end";
滑动窗口(ZSET)
java
/**
* -- KEYS[1]: 限流 Key (例如 limit:sliding:user_1)
* -- ARGV[1]: 窗口时间 (毫秒),例如 60000 (1分钟)
* -- ARGV[2]: 限流阈值,例如 100
* -- ARGV[3]: 本次请求的唯一ID (防止高并发下member冲突)
*/
private final static String SLIDING_WINDOW_SCRIPT =
"local key = KEYS[1] " +
"local timeWindowSecond = tonumber(ARGV[1]) " +
"local maxCount = tonumber(ARGV[2]) " +
"local uuid = ARGV[3] " +
// "-- 1.获取获取时间戳信息 time_info是一个数组 [0] 是unix时间 10位时间戳 [1] 是微秒 " +
"local time_info = redis.call('TIME') " +
"local now = tonumber(time_info[1]) * 1000 + math.floor(tonumber(time_info[2]) / 1000) " +
// "-- 2.计算以当前时间计算最开始的窗口的时间 " +
"local startWindowsTime = now - (timeWindowSecond * 1000) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: startWindowsTime=\" .. tostring(startWindowsTime)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: now=\" .. tostring(now)) " +
// "-- 3.删除0-窗口开始时间的所有元素 " +
"local remove = redis.call('ZREMRANGEBYSCORE', key, 0 , '(' .. startWindowsTime) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: remove=\" .. tostring(remove)) " +
// "-- 4.计算元素内的请求数量 " +
"local count = redis.call('ZCARD', key); " +
// "redis.log(redis.LOG_NOTICE, \"Debug: count=\" .. tostring(count)) " +
// "-- 5.判断窗口内是否以超限 " +
"if count < maxCount then " +
// " -- 5.1未超限:记录本次请求 " +
" redis.call('ZADD', key, now , uuid) " +
// " -- 5.2 设置超时时间 窗口大小 +5 秒冗余 " +
" redis.call('EXPIRE',key, timeWindowSecond + 5) " +
" return true " +
"end " +
// "-- 6. 超限:拒绝访问 " +
"return false";
漏桶
java
/**
* -- KEYS[1]: 漏桶的 Key (上次漏水时间的水位)
* -- KEYS[2]: 上次漏水时间的 Key
* -- ARGV[1]: 漏水速率 (leaks/sec),例如 2
* -- ARGV[2]: 桶容量 (capacity),例如 10
* -- ARGV[3]: 本次请求加水量,通常 1
*/
private final static String LEAKY_BUCKET_SCRIPT =
"local bucket_key = KEYS[1] " +
"local timestamp_key = KEYS[2] " +
"local rate = tonumber(ARGV[1]) " +
"local capacity = tonumber(ARGV[2]) " +
"local water_add = tonumber(ARGV[3]) " +
"local rate_per_ms = rate / 1000 " +
// 获取获取时间戳信息 time_info是一个数组 [0] 是unix时间 10位时间戳 [1] 是微秒
"local time_info = redis.call('TIME') " +
"local now = tonumber(time_info[1]) * 1000 + math.floor(tonumber(time_info[2]) / 1000) " +
// 1. 获取上次水位信息 (默认为 0)
"local last_water = tonumber(redis.call('GET', bucket_key) or 0) " +
"local last_time = tonumber(redis.call('GET', timestamp_key) or now) " +
// 2.计算上次漏水时间与当前时间的间隔 单位 毫秒
"local interval = math.max(0, (now - last_time)) " +
// 3.计算流水速率 * 间隔时间 判断间隔时间内桶内流出
"local interval_out_water = interval * rate_per_ms " +
// 4.计算当前水位
"local current_water = math.max(0, last_water - interval_out_water) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: interval=\" .. tostring(interval)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: last_water=\" .. tostring(last_water)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: 当前水位=\" .. tostring(current_water)) " +
// 5.当前水位+本次入水量是否小于桶内容量,小于则入桶,并记录当前水位及漏水时间
"if current_water + water_add > capacity then " +
" return false " +
"end " +
"current_water = current_water + water_add " +
"redis.call('SET', bucket_key, current_water) " +
"redis.call('SET', timestamp_key, now) " +
// 6.计算满桶水流完所需秒数 * 2 是为了防止临界点的问题
"local ttl = math.ceil((capacity / rate) * 2) " +
"redis.call('EXPIRE', bucket_key, ttl) " +
"redis.call('EXPIRE', timestamp_key, ttl) " +
"return true";
令牌桶
java
/**
* -- KEYS[1]: 令牌桶的 Key (存当前令牌数)
* -- KEYS[2]: 上次刷新时间的 Key (存时间戳)
* -- ARGV[1]: 生成速率 (rate),例如 2 (个/秒)
* -- ARGV[2]: 桶容量 (capacity),例如 10
* -- ARGV[3]: 本次请求消耗令牌数,通常是 1
*/
private final static String TOKEN_BUCKET_SCRIPT =
"local token_key = KEYS[1] " +
"local timestamp_key = KEYS[2] " +
"local rate = tonumber(ARGV[1]) " +
"local capacity = tonumber(ARGV[2]) " +
"local reduce_token = tonumber(ARGV[3]) " +
// 获取获取时间戳信息 time_info是一个数组 [0] 是unix时间 10位时间戳 [1] 是微秒
"local time_info = redis.call('TIME') " +
"local now = tonumber(time_info[1]) * 1000 + math.floor(tonumber(time_info[2]) / 1000) " +
// "-- 1. 获取桶的"每毫秒"生成速率 (为了计算方便) " +
"local rate_per_ms = rate / 1000 " +
// "-- 2. 获取上次剩余令牌数 (如果没有,默认满桶) " +
"local last_tokens = tonumber(redis.call('GET', token_key) or capacity) " +
// "-- 3. 获取上次刷新时间 (如果没有,默认就是现在) " +
"local last_time = tonumber(redis.call('GET', timestamp_key) or now) " +
// "-- 4. 计算时间差 " +
"local interval = math.max(0, now - last_time) " +
// "-- 5.计算这段时间内生成的令牌 不能大于桶的容量 " +
"local current_tokens = math.min(capacity, last_tokens + (interval * rate_per_ms)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: interval=\" .. tostring(interval)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: last_tokens=\" .. tostring(last_tokens)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: current_tokens=\" .. tostring(current_tokens)) " +
// "-- 6.令牌不够直接拒绝 " +
"if current_tokens < reduce_token then " +
" return false " +
"end " +
// "-- 7.消耗令牌 " +
"current_tokens = current_tokens - reduce_token " +
// "redis.log(redis.LOG_NOTICE, \"Debug: reduce_after_token=\" .. tostring(current_tokens)) " +
// "-- 8.更新上次的刷新时间为当前时间及上次剩余令牌为当前令牌数 " +
"redis.call('SET', token_key, current_tokens) " +
"redis.call('SET', timestamp_key, now) " +
// "-- 9.防止零界点问题 设置填满桶时间的2倍 " +
"local ttl = math.ceil(capacity / rate * 2) " +
"redis.call('EXPIRE', token_key, ttl) " +
"redis.call('EXPIRE', timestamp_key, ttl) " +
"return true ";
原生Java实现
固定窗口
java
package com.mfyuan.rate.jvm;
import com.mfyuan.rate.RateLimitStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/19 21:32
*/
@RequiredArgsConstructor
@Slf4j
public class JvmFixedWindowRateLimitStrategy implements RateLimitStrategy {
// 窗口大小 单位秒
private final Long timeWindowSecond;
// 窗口内最大访问次数
private final Long maxCount;
private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();
@Override
public boolean tryAcquire(String ident) {
Lock lock = CACHE.get(ident);
if (lock == null) {
synchronized (CACHE) {
long now = System.currentTimeMillis();
Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());
lock = finalLock;
// 利用锁 CACHE 的时机 删除过期的锁
CACHE.entrySet()
.stream()
// 不删除 当前新建出来的lock
.filter(entry -> !Objects.equals(entry.getValue(), finalLock))
// 过期去已过期的锁 给一个3秒的冗余
.filter(entry -> entry.getValue().lastTime < now + 3000)
.map(Map.Entry::getKey)
.forEach(CACHE::remove);
}
}
synchronized (lock) {
long now = System.currentTimeMillis();
if (lock.lastTime == null || lock.lastTime < now) {
lock.lastTime = now + (timeWindowSecond * 1000);
lock.count = 0L;
}
boolean flag = false;
if (lock.count < maxCount) {
flag = true;
}
lock.count++;
log.info("lock hashcode:{} lastTime:{},count:{},flag:{}", lock, lock.lastTime, lock.count, flag);
return flag;
}
}
static class Lock {
private Long lastTime;
private Long count;
}
}
滑动窗口
java
、package com.mfyuan.rate.jvm;
import com.mfyuan.rate.RateLimitStrategy;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/21 17:19
*/
@RequiredArgsConstructor
@Slf4j
public class JvmSlidingWindowRateLimitStrategy implements RateLimitStrategy {
private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();
// 窗口大小 单位秒
private final Long timeWindowSecond;
// 窗口内最大访问次数
private final Long maxCount;
@Override
public boolean tryAcquire(String ident) {
Lock lock = CACHE.get(ident);
if (lock == null) {
synchronized (CACHE) {
long now = System.currentTimeMillis();
Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());
lock = finalLock;
// 利用锁 CACHE 的时机 删除过期的锁
CACHE.entrySet()
.stream()
// 不删除 当前新建出来的lock
.filter(entry!Objects.equals(entry.getValue(), finalLock))
// 删除过期的
.filter(entry ->
// 窗口内部已经没有请求了,则认为是过期的
CollectionUtils.isEmpty(entry.getValue().requestTreeSet)
// 当前时间大于最后请求到窗口内的时间戳 为窗口时间的2倍时则认为是过期的
|| (now - entry.getValue().requestTreeSet.last().timestamp > timeWindowSecond * 1000 * 2))
.map(Map.Entry::getKey)
.forEach(CACHE::remove);
}
}
synchronized (lock) {
long now = System.currentTimeMillis();
if (lock.requestTreeSet == null) {
lock.requestTreeSet = new TreeSet<>();
} else {
long startTime = now - timeWindowSecond * 1000;
Set<Lock.Request> removeSet = lock.requestTreeSet
.stream()
.filter(request -> request.timestamp < startTime)
.collect(Collectors.toSet());
lock.requestTreeSet.removeAll(removeSet);
log.info("now:{},startTime:{},removeSize:{},currentSize:{},removeProfile:{}"
, now, startTime
, removeSet.size()
, lock.requestTreeSet.size()
, removeSet.stream()
.map(Lock.Request::getTimestamp)
.map(Objects::toString)
.collect(Collectors.joining(",")));
}
if (lock.requestTreeSet.size() < maxCount) {
lock.requestTreeSet.add(new Lock.Request(now, UUID.randomUUID().toString()));
return true;
}
return false;
}
}
static class Lock {
TreeSet<Request> requestTreeSet = null;
@AllArgsConstructor
@Getter
static class Request implements Comparable<Request> {
private long timestamp;
private String uuid;
@Override
public int compareTo(Request o) {
return Long.compare(timestamp, o.timestamp);
}
}
}
}
漏桶
java
package com.mfyuan.rate.jvm;
import com.mfyuan.rate.RateLimitStrategy;
import lombok.RequiredArgsConstructor;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/19 22:01
*/
@RequiredArgsConstructor
public class JvmLeakyBucketRateLimitStrategy implements RateLimitStrategy {
// 每秒流出速率
private final Long rate;
// 桶大小
private final Long capacity;
private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();
@Override
public boolean tryAcquire(String ident) {
Lock lock = CACHE.get(ident);
if (lock == null) {
synchronized (CACHE) {
long now = System.currentTimeMillis();
Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());
lock = finalLock;
// 利用锁 CACHE 的时机 删除过期的锁
CACHE.entrySet()
.stream()
// 不删除 当前新建出来的lock
.filter(entry -> !Objects.equals(entry.getValue(), finalLock))
// 过期去已过期的锁 capacity / rate 为桶里面的水留空的时间 冗余个二倍
.filter(entry -> entry.getValue().lastTime < now + (capacity / rate * 2))
.map(Map.Entry::getKey)
.forEach(CACHE::remove);
}
}
synchronized (lock) {
long now = System.currentTimeMillis();
if (lock.lastTime == null) {
lock.lastTime = now;
lock.lastWater = 0D;
} else {
// 计算出当前的水位
long interval = now - lock.lastTime;
double flowOutWater = rate / 1000.0 * interval;
lock.lastWater = Math.max(0D, lock.lastWater - flowOutWater);
lock.lastTime = now;
}
if (lock.lastWater + 1 <= capacity) {
lock.lastWater = Math.min(capacity, lock.lastWater + 1);
return true;
}
return false;
}
}
static class Lock {
private Long lastTime;
// 因为每次并不是完全流出一个 用 double 防止精度丢失
private Double lastWater;
}
}
令牌桶
java
package com.mfyuan.rate.jvm;
import com.mfyuan.rate.RateLimitStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/20 10:35
*/
@RequiredArgsConstructor
@Slf4j
public class JvmTokenBucketRateLimitStrategy implements RateLimitStrategy {
// 每秒生成令牌的数量
private final Long rate;
// 桶大小
private final Long capacity;
private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();
@Override
public boolean tryAcquire(String ident) {
long now = System.currentTimeMillis();
Lock lock = CACHE.get(ident);
if (lock == null) {
synchronized (CACHE) {
Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());
lock = finalLock;
// 利用锁 CACHE 的时机 删除过期的锁
CACHE.entrySet()
.stream()
// 不删除 当前新建出来的lock
.filter(entry -> !Objects.equals(entry.getValue(), finalLock))
// 过期去已过期的锁 capacity / rate 为桶里面的水留空的时间 冗余个二倍
.filter(entry -> entry.getValue().lastTime < now + (capacity / rate * 2))
.map(Map.Entry::getKey)
.forEach(CACHE::remove);
}
}
synchronized (lock) {
log.info("lock hashcode:{} lastTime:{},lastTokens:{}", lock, lock.lastTime, lock.lastTokens);
if (lock.lastTime == null) {
lock.lastTime = now;
lock.lastTokens = capacity * 1.0;
} else {
// 计算出当前的水位
long interval = now - lock.lastTime;
double flowInTokens = rate / 1000.0 * interval;
lock.lastTokens = Math.min(capacity, lock.lastTokens + flowInTokens);
lock.lastTime = now;
}
if (lock.lastTokens > 1) {
lock.lastTokens = Math.max(0, lock.lastTokens - 1);
return true;
}
return false;
}
}
static class Lock {
private Long lastTime;
// 因为每次并不是完全流出一个 用 double 防止精度丢失
private Double lastTokens;
}
}
配置建议
| 场景 | 推荐算法 | Rate/ (速率) | Capacity** /MaxCount(容量)** | 备注 |
|---|---|---|---|---|
| 高并发读接口 (首页/商品详情) | 令牌桶 | 压测极限的 80% | Rate 的 2 倍 | 允许突发,体验优先 |
| 高并发写接口 (下单/抢购) | 令牌桶 | 数据库TPS的 80% | Rate 的 1.0 ~ 1.2 倍 | 写操作要谨慎突发 |
| 调用第三方API (支付/短信) | 漏桶 | 第三方限制值的 90% | Rate × 1秒 | 宁可排队,不能报错 |
| 防恶意刷单 (验证码/登录) | 固定窗口 | N/A | 正常频率的 5-10 倍 | 窗口通常设为 60s+ |
| 低频高精限制 (短信频次/密码试错) | 滑动窗口 | N/A | 业务严格规定的次数 | 精度最高 ,无临界突发。 窗口通常设为 60s 不能过大,(不要限制QPS超过100的 Redis 内存会瞬间爆炸) |