本文将深入探讨几种常见的限流方案,每种方案都具备独特的适用场景。我们将从实现策略、控制粒度以及时间窗口的处理方式等维度进行详细分析。请注意,示例代码可能存在不够规范之处,核心目标是帮助理解设计思路。
1. 基于数据库统计的限流方法
基于数据库实现限流,核心思路非常直观:每次操作时,向数据库写入一条包含时间戳的记录,随后根据设定的时间范围进行统计,判断是否达到限流阈值。下面通过一个具体场景帮助理解。
适用场景:用户密码在1分钟内输入错误次数过多,系统自动冻结账号。
要点:
- 登录失败时,将登录名和登录时间写入数据库记录。
- 登录前,根据登录名查询最近1分钟内的失败次数。
- 判断失败次数是否超过预设阈值,若超过则冻结账号,否则执行正常登录流程(登录失败后再写入记录)。
代码要点:
select count(登录名) from 登录错误记录表 where 登录名='登录名' and 登录时间 > DATE_SUB(NOW(), INTERVAL 1 MINUTE)
分析:
该方案的粒度控制可通过代码逻辑与数据库设计灵活调整,时间窗口设为最近一分钟,属于典型的滑动时间窗口。由于依赖数据库存储,它同样适用于分布式环境。然而,缺点显而易见——随着请求量增加,数据库压力会急剧上升。
2. 基于Redis自增及过期策略的限流方案
基于Redis实现限流,核心思路如下:在Redis中维护一个带有过期时间的键(key),每次操作前先读取该键的值。如果当前值已超过限制,则触发限流;否则通过INCR命令进行自增,若键不存在,则创建并设置过期时间。如此循环往复。
适用场景:对接口实施会话级别的访问频率控制。
要点:
- 读取键值,根据value的不同情况分别处理:若键不存在,则设置键并指定过期时间;若value大于限定值则触发限流;若value未超过限定值则执行自增操作。
代码要点(Redis命令):
## 设置key的值5秒过期
set kkk 1 EX 5
### 获取key的值
get kkk
### 自增长
incr kkk
### 手动设置过期时间
expire kkk 5
代码示例:
private boolean accessCheck(HttpSession session) {
String key = "ACCESS" + session.getId();
long current = 1;
// key不存在,进行设置
if( valueOperations.get(key) == null){
// 一步到位
//valueOperations.set(key,1,5,TimeUnit.SECONDS);
// 也可以分两步设置
valueOperations.increment(key);
valueOperations.getOperations().expire(key,5,TimeUnit.SECONDS);
} else {
// key已经存在了,自增 1
valueOperations.increment(key);
current = Long.parseLong(valueOperations.get(key));
}
if(current > 20){
throw new RuntimeException("访问次数过多,请稍后再试");
}
return true;
}
分析:
Redis方案同样支持分布式环境,时间窗口为固定窗口(取决于设置的过期时间)。凭借Redis的高性能,该方案能够应对较大的流量冲击。控制粒度则取决于具体的设计细节。
3. 基于内存(以LinkedList为例)的限流方案
基于内存的限流策略,实质是在内存中维护一个时间窗口的数据结构,通过统计窗口内的请求数量来做出限流决策。
适用场景:对接口调用频率进行控制。
要点:
- 维护一个按时间升序排列的请求列表(避免每次全量扫描)。
- 移除超出时间窗口的旧数据。
- 在列表末尾添加最新的时间戳。
代码要点(以LinkedList为例):
private boolean accessCheckByLinkedList(){
synchronized(accessList) {
Date data = null;
Date now = new Date();
if(accessList.size() > 0){
data = accessList.getFirst();
while (data != null && now.getTime() - data.getTime() > 5000) {
accessList.removeFirst();
if(accessList.size() > 0){
data = accessList.getFirst();
} else {
data = null;
}
}
if (accessList.size() > 5) {
throw new RuntimeException("访问频率过高");
}
}
accessList.add(new Date());
}
return true;
}
分析:
内存限流的控制粒度完全取决于设计者的实现水平。由于是内存操作,必须妥善处理线程同步(虽然使用ConcurrentHashMap等并发容器可以减少部分同步问题,但排序仍需注意)以及高并发下的竞争条件。其控制粒度为应用级别,能够从应用层面直接拦截流量,避免因负载均衡策略失效或异常情况导致后端服务被冲垮。
4. 基于木桶算法的限流方案
木桶算法的思路更为形象:维护一个固定容量的“池”,每次请求消耗池中的存量,同时通过后台任务持续向池中添加资源,并确保存量始终不超过最大值。
适用场景:应用级别接口请求频率控制。
要点:
- 当增加池中存量时,若达到最大值则暂停新增。
- 当使用池中存量时,若存量降至0以下则触发限流。
- 确保池既不会被撑爆,也不会被透支。
代码要点:
public class BucketAlgorithmTest {
public static void main(String[] args) throws InterruptedException {
// 定时添加资源
new Thread(()-> {
while (true){
Bucket.add();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
Thread.sleep(200);
// 若初始默认值为满则可省略此步骤
// 定时消耗资源,模拟控制逻辑
new Thread(()->{
int errorcout = 0;
while (errorcout < 10) {
try {
Bucket.get();
Thread.sleep(40);
} catch (Exception e) {
errorcout++;
e.printStackTrace();
try {
Thread.sleep(100);
}catch (Exception ee){}
}
}
}).start();
}
}
class Bucket{
/**
* 当前可用量
*/
private static Integer currentSize = 0;
/**
* 最大容量
*/
private final static int totalSize = 20;
/**
* 添加资源
*/
public static void add(){
synchronized (currentSize) {
if(currentSize < totalSize) {
currentSize++;
System.out.println("[添加 ]可用" + Bucket.getCurrentSize());
}
}
}
/**
* 消耗资源
*/
public static void get(){
synchronized (currentSize) {
if(currentSize > 0) {
currentSize--;
System.out.println("[使用---]可用" + Bucket.getCurrentSize());
}else {
throw new RuntimeException("当前无可用资源,请求被限流");
}
}
}
public static Integer getCurrentSize(){
return currentSize;
}
}
为了方便理解,这里贴一张运行结果:

分析:
木桶算法的可调性很强,具体效果取决于对原理的理解深度。在突发大流量冲击下,恢复时间可能相对较长。它本质上是一种“滑动流量”窗口,而非严格的时间窗口。该策略的数据可以存储在内存、数据库或Redis中,应用场景也可灵活定制,既可作为应用级限流,也可作为负载均衡层面的策略补充。
限流方案对比总结
| 方案 | 时间窗口 | 基于 | 分布式环境 | 应用范围 |
|---|---|---|---|---|
| 数据库统计 | 滑动 | 数据库 | 支持 | 登录次数控制 |
| Redis自增 | 固定 | Redis | 支持 | 接口频率控制 |
| 内存列表 | 滑动 | 内存 | 不支持 | 应用级频率控制 |
| 木桶算法 | 滑动 | 内存/Redis/数据库 | 支持 | 灵活配置 |
