本文是根据开涛的博客 聊聊高并发系统之限流特技-1 整理而成,自学笔记第二篇
1.系统限流实践 - 理论篇
2.系统限流实践 - 应用限流
3.系统限流实践 - 分布式限流
4.系统限流实践 - 接入层限流(上)
5.系统限流实践 - 接入层限流(下*完结)
上篇学习了限流的基本知识(传送门),接下来学习一下应用限流的方法
通过对容器进行配置,限制TPS/QPS阀值,防止大量请求涌入击垮系统。
如果你使用过Tomcat,其Connector 其中一种配置有如下几个参数:
acceptCount:如果Tomcat的线程都忙于响应,新来的连接会进入队列排队,如果超出排队大小,则拒绝连接;
maxConnections:瞬时最大连接数,超出的会排队等待;
maxThreads:Tomcat能启动用来处理请求的最大线程数,如果请求处理量一直远远大于最大线程数则可能会僵死
详细的配置请参考官方文档。另外如Mysql(如max_connections)、Redis(如tcp-backlog)都会有类似的限制连接数的配置
如果担心接口某个时刻并发量过大了,可以细粒度地限制每个接口的总并发/请求数
可以使用Java得Atomic来实现
public class SimpleLimit {
private AtomicInteger requestCount;
public void doRequest(String threadName) {
try {
if (requestCount.decrementAndGet() < 0) {
System.out.println(threadName + ":请求过多,请稍后再尝试");
}else {
System.out.println(threadName + ":您的请求已受理");
}
} finally {
requestCount.incrementAndGet();
}
}
......
}
测试代码
public static void main(String[] args) throws InterruptedException, IOException {
final SimpleLimit simpleLimit = new SimpleLimit();
final CountDownLatch latch = new CountDownLatch(1); //保证线程同一时刻start
simpleLimit.requestCount = new AtomicInteger(10);
for (int i = 0; i < 50; i++) {
final int finalI = i;
Thread t = new Thread(new Runnable() {
public void run() {
try {
latch.await();
simpleLimit.doRequest("t-" + finalI);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
latch.countDown();
System.in.read();
}
结果
可以看到,程序通过requestCount
把并发数量控制在10,当有50个线程并发去访问时,就会出现有得线程能竞争资源成功,有的线程竞争资源失败
这种并发控制方式比较简单粗暴,没有平滑处理,应用的时候要根据场景使用
限制住一个窗口时间内接口的请求量,例如某个基础服务调用量很大,怕被突然的大流量打挂,下面是一种实现窗口时间并发控制的方法
我们使用Guava的Cache来存储计数器,利用秒数作为Key,Value代表这一秒有多少个请求,这样就限制了一秒内的并发数。另外过期时间设置为两秒,保证一秒内的数据是存在的。
public class TimeLimit {
private long limit = 5; //限流数
static volatile boolean exit;
private LoadingCache counter =
CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.SECONDS)
.build(new CacheLoader() {
@Override
public AtomicLong load(Long aLong) throws Exception {
return new AtomicLong(0);
}
});
public void doRequest(String threadName) throws ExecutionException {
long currentSecond = System.currentTimeMillis() / 1000;
if (counter.get(currentSecond).incrementAndGet() > limit) {
System.out.println(threadName + ":请求过多,请稍后再尝试");
} else {
System.out.println(threadName + ":您的请求已受理");
}
}
......
}
测试代码
public static void main(String[] args) throws InterruptedException {
//或等当前秒数
final TimeLimit timeLimit = new TimeLimit();
final CountDownLatch latch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
final int finalI = i;
Thread t = new Thread(new Runnable() {
public void run() {
try {
latch.await();
while (!exit) {
timeLimit.doRequest("t-" + finalI);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
});
t.start();
}
latch.countDown();
Thread.sleep(3000);
exit = true;
}
结果
限流数为5,并发数为10,从结果上看,当一秒内请求数到了5之后,接下来的请求都会别拒绝
之前的限流方式都不能很好地应对突发请求,即瞬间请求可能都被允许从而导致一些问题;因此在一些场景中需要对突发请求进行整形,整形为平均速率请求处理(比如5r/s,则每隔200毫秒处理一个请求,平滑了速率)。
这个时候有两种算法满足我们的场景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法实现,可直接拿来使用。
public class BasicUsage {
public static void main(String[] args) {
RateLimiter rateLimiter = RateLimiter.create(5); //令牌桶容量为5,即每200毫秒产生1个令牌
System.out.println(rateLimiter.acquire()); //阻塞获取一个令牌
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
}
}
结果
0.0 //马上得到令牌,所以等待时间为0
0.166525 //因为令牌桶每200毫秒产生1个令牌,所以上面消耗掉令牌后桶里没令牌,需要等待新的令牌产生后才能消费,下面的同理
0.167868
0.195596
0.193168
0.128312
如果令牌桶里有令牌的话,acquire返回0。如果没令牌,则等待一段时间,当有令牌的时候消费令牌并则返回等待令牌所消耗的时间。
public class BasicUsage {
public static void main(String[] args) {
RateLimiter rateLimiter = RateLimiter.create(5); //令牌桶容量为5,即每200毫秒产生1个令牌
System.out.println(rateLimiter.acquire(10)); //透支令牌
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
System.out.println(rateLimiter.acquire());
}
}
结果
0.0
1.996642
0.197655
0.19933
0.200378
0.200474
令牌桶允许一定程度的透支,不过接下来的请求需要等透支完的补充回来后才能继续执行。
public class BasicUsage {
public static void main(String[] args) throws InterruptedException {
RateLimiter rateLimiter = RateLimiter.create(1000); //每秒投放1000个令牌
for (int i = 0; i < 10; i++) {
if (rateLimiter.tryAcquire()) { //tryAcquire检测有没有可用的令牌,结果马上返回
System.out.println("处理请求");
} else {
System.out.println("拒绝请求");
}
}
}
}
结果
处理请求
处理请求
处理请求
拒绝请求
拒绝请求
拒绝请求
拒绝请求
拒绝请求
拒绝请求
拒绝请求
没有获取到令牌的请求将会被拒绝,这里可以添加一些额外的处理,例如增加异步操作,把处理不过来的请求加入队列,待处理完之后发消息通知用户处理结果。
模拟一个TPS为10的接口
public class MockUsage {
RateLimiter rateLimiter = RateLimiter.create(10); //TPS为10
public void doRequest(String threadName) {
boolean isAcquired = rateLimiter.tryAcquire();
if (isAcquired) {
System.out.println(threadName + ":下单成功");
} else {
System.out.println(threadName + ":当前下单人数过多,请稍后再试");
}
}
......
}
测试
public static void main(String[] args) throws InterruptedException, IOException {
final MockUsage mockUsage = new MockUsage();
final CountDownLatch latch = new CountDownLatch(1);//两个工人的协作
final Random random = new Random(10);
for (int i = 0; i < 20; i++) { //模拟一秒内20个并发
final int finalI = i;
Thread t = new Thread(new Runnable() {
public void run() {
try {
latch.await();
int sleepTime = random.nextInt(1000); //随机sleep [0,1000)毫秒
Thread.sleep(sleepTime);
mockUsage.doRequest("t-" + finalI);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
latch.countDown();
System.in.read();
}
结果
t-8:下单成功
t-2:下单成功
t-10:下单成功
t-11:当前下单人数过多,请稍后再试
t-1:当前下单人数过多,请稍后再试
t-3:当前下单人数过多,请稍后再试
t-17:当前下单人数过多,请稍后再试
t-6:当前下单人数过多,请稍后再试
t-5:下单成功
t-0:当前下单人数过多,请稍后再试
t-19:当前下单人数过多,请稍后再试
t-4:下单成功
t-16:当前下单人数过多,请稍后再试
t-18:当前下单人数过多,请稍后再试
t-9:下单成功
t-13:下单成功
t-14:下单成功
t-12:下单成功
t-15:下单成功
t-7:当前下单人数过多,请稍后再试
上面模拟了一个TPS为10的接口,同时一秒内20个并发请求,观察发现一半的请求下单成功,而另一半的需要稍后再试,符合我们的预期
上面的是应用层的限流策略,有基于Atomic的请求数限流,有基于GuavaCache的针对时间窗口请求数限流,也有基于令牌桶的平滑限流。Atomic和GuavaCache这两种方法比较粗粒度,简单的场景下可以使用。
令牌桶的方法比较细粒度,使用上更灵活,适合复杂一点的场景。
不过以上的方式只是对单应用进行了限流,在分布式和流行的微服务的场景下有心无力,接下来将会继续学习在分布式和微服务的场景下如何限流。