Spring boot限流


最近客户现场的实施工程师反馈,系统在某一时间会特别的卡顿,导致医生无法进行操作,特别是在业务系统对接我们自己的接口程序的时候,老是访问超时导致无法进行下一把操作。
经过追踪发现,这一时间段中,接口程序会频繁刷新导致CPU飙升,内存被大批量占用,经过排查日志发现,第三方程序再这一时间段频繁访问同一个接口,几乎是一秒一次,
跟第三方沟通后才知道他们改了接口访问的频次才导致的问题,而我们的接口也没有进行限流的,所以再后续的更新中给接口加上限流。
所以对于此次的处理进行记录。以下是相关的代码:

限流的方式

  1. 固定窗口
  2. 滑动窗口
  3. 漏桶
  4. 令牌桶(此次所展示的算法)

    使用的是Redis + Lua方式

    Lua介绍

    Lua官网

  • 特性:

    轻量级: 它用标准C语言编写并以源代码形式开放,编译后仅仅一百余K,可以很方便的嵌入别的程序里。
    可扩展: Lua提供了非常易于使用的扩展接口和机制:由宿主语言(通常是C或C++)提供这些功能,Lua可以使用它们,就像是本来就内置的功能一样。

  • 其它特性:
  1. 支持面向过程(procedure-oriented)编程和函数式编程(functional programming);
  2. 自动内存管理;只提供了一种通用类型的表(table),用它可以实现数组,哈希表,集合,对象;
  3. 语言内置模式匹配;闭包(closure);函数也可以看做一个值;提供多线程(协同进程,并非操作系统所支持的线程)支持;
  4. 通过闭包和table可以很方便地支持面向对象编程所需要的一些关键机制,比如数据抽象,虚函数,继承和重载等。

    自定义注解:RateLimiter.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface RateLimiter {
    /**
    * 限流key
    */
    String key() default "rate_limit:";

    /**
    * 限流时间,单位秒
    */
    int time() default 60;

    /**
    * 限流次数
    */
    int count() default 60;

    /**
    * 返回类型 0 为xml 1为 json
    * @return
    */
    String resultType() default "0";

    /**
    * 限流类型
    */
    LimitType limitType() default LimitType.DEFAULT;

    /**
    * 厂商返回类型
    * @return
    */
    LimitResultType limitResultType() default LimitResultType.NAME_DEFAULT;
    }

AOP切面实现:RateLimiterAspect.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/**
* 限流
*/
@Aspect
@Component
@Slf4j
public class RateLimiterAspect {
@Autowired
private RedisTemplate<Object, Object> redisTemplate;

@Before("@annotation(rateLimiter)")
public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {
//限流时间,单位秒
int time = rateLimiter.time();
//限流次数
int count = rateLimiter.count();
//厂商信息
LimitResultType limitResultType=rateLimiter.limitResultType();
//返回值类型
String resultType = rateLimiter.resultType();
String luaScript = buildScript();
//获得key
String combineKey = getCombineKey(rateLimiter, point);
List<Object> keys = Collections.singletonList(combineKey);
RedisScript<Number> redisScript = new DefaultRedisScript<>(luaScript, Number.class);
//牌产生是速率(个数/秒)数量/时间-向上取整
int createRate = (int) Math.ceil( count / time);
Number number = redisTemplate.execute(redisScript, keys, count,createRate,currentMillis,60000);
try {
if (number != null && number.intValue() == 0) {
//Xml格式返回
if("0".equals(resultType)){
log.warn("访问过于频繁,请稍候再试");
//院感
if(limitResultType.getCode().equals(LimitResultType.NAME.getCode())){
throw new XmlRRException(LimitResultType.NAME,"访问过于频繁,请稍候再试");
}
}
}

} catch (XmlRRException e) {
throw e;
}catch (Exception e) {
log.error("服务器限流异常,请稍候再试");
throw new RuntimeException("服务器限流异常,请稍候再试");
}

}


/**
* 参数构建
* @return
*/
public String getCombineKey(RateLimiter rateLimiter, JoinPoint point) {
StringBuffer stringBuffer = new StringBuffer(rateLimiter.key());
if (rateLimiter.limitType() == LimitType.IP) {
stringBuffer.append(IpUtils.getIpAddr(((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest())).append("-");
}
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
stringBuffer.append(targetClass.getName()).append("-").append(method.getName());
return stringBuffer.toString();
}


/**
* redis+Lua 限流脚本(令牌桶算法)
* @return
*/
public String buildScript() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("\n local capacity = tonumber(ARGV[1])");
stringBuilder.append("\n local rate = tonumber(ARGV[2])");
stringBuilder.append("\n local lastTime = tonumber(ARGV[3]) ");
stringBuilder.append("\n local residueTokenNum= 0");
stringBuilder.append("\n local consume=1");
stringBuilder.append("\n local resetToken = tonumber(ARGV[4]) ");
stringBuilder.append("\nlocal token = redis.call('hgetall', KEYS[1])");
stringBuilder.append("\n if table.maxn(token) == 0 then");
stringBuilder.append("\n local residueTokenNum= tonumber(ARGV[1])");
stringBuilder.append("\n redis.call('HMSET', KEYS[1], 'capacity', capacity, 'rate', rate, 'lastTime', lastTime,'residueTokenNum', residueTokenNum) ");
stringBuilder.append("\n redis.call('pexpire', KEYS[1], tonumber(ARGV[4]))");
stringBuilder.append("\nelse");
stringBuilder.append("\nlocal bucket = redis.call('HMGET', KEYS[1], 'capacity', 'rate', 'lastTime','residueTokenNum') ");
stringBuilder.append("\ncapacity = bucket[1]");
stringBuilder.append("\nrate =bucket[2] ");
stringBuilder.append("\nlastTime = bucket[3] ");
stringBuilder.append("\nresidueTokenNum= bucket[4] ");
stringBuilder.append("\nlocal nowTime = tonumber(ARGV[3]) ");
stringBuilder.append("\nlocal generateTokenNum = tonumber(((nowTime - lastTime)/1000)* rate)");
stringBuilder.append("\nresidueTokenNum = generateTokenNum + residueTokenNum ");
stringBuilder.append("\nresidueTokenNum = math.min(capacity, residueTokenNum)");
stringBuilder.append("\nif residueTokenNum >= consume then ");
stringBuilder.append("\n residueTokenNum = residueTokenNum - consume");
stringBuilder.append("\n redis.call('hmset', KEYS[1], 'capacity', capacity, 'rate', rate, 'lastTime', nowTime,'residueTokenNum', residueTokenNum) ");
stringBuilder.append("\nreturn residueTokenNum");
stringBuilder.append("\nend");
stringBuilder.append("\nreturn 0 ");
stringBuilder.append("\nend ");
return stringBuilder.toString();
}


}

以上就是防止重复提交的AOP方法,具体使用如图所示:
img_2.png

运行

单次访问

img_3.png

缓存显示如图:
img_4.png
后端打印出来的令牌桶总数量:
img_5.png

模拟多次访问:

接口我次数设置的是5次时间是2秒
img_9.png

接口访问的结果
img_6.png
成功详情:
img_10.png
失败详情:
img_7.png
后台显示:
img_8.png

综合结果得知成功了5次,失败了1次,所以限流成功了,因为我还设置了缓存key的过期为一分钟,下次再次访问的话会重新构建的,具体的话根据实际业务进行配置;

结束

此次只展示了限流的令牌桶的算法,这只是其中解决方法的其中之一,本次使用到的是Redis缓存,其他方式如:JUC中的原子类用自旋+cas实现、guava的限流器(synchronized实现),
具体使用那种算法需要根据自身的业务进行选择,还有其他的算法后面有时间了再进行展示。

-------------本文结束感谢您的阅读-------------