高并发应对浅谈

高并发系统保护策略

  1. 缓存
  2. 降级熔断
  3. 限流

缓存策略

类似于硬件中的L1,L2,L3缓存分级策略,通常软件系统也使用相应的缓存分级来保证其速度,如使用第三方的缓存服务(redis,monogo等)作为基本缓存系统,可以保证业务服务在总体上来讲是无状态的,可方便拓展,同时在业务系统中可采用本地Cache,增加请求的访问相应速度。

通常使用缓存的请求流程:

缓存存在的问题及解决方案

数据一致性

当库中数据发生改变时,缓存数据未及时清理,造成数据不一致。

处理方式:

  • 通常将缓存层放置在DAO层前一层,操作DAO需经过缓存层,所以发生更新删除时可直接操作相应缓存。本地缓存设置有效时间长度较短即可。
  • 更新数据时,进行缓存双删,即更新数据库前删除,更新后再删除一次。
缓存穿透。

缓存和库中数据都为空,空数据请求量过多也会造成系统宕机。

处理方式:

  • 记录请求key的特征值,并做相应的次数限制,或将某为空的数据也做缓存,直接缓存其值为空。
缓存击穿。

缓存中无数据,库中有数据,这里通常是指多个请求同时请求同一个资源,请求同时到达缓存,缓存中无数据,这时请求大部分都转到数据库中。

处理方式:

  • 热点数据不过期
  • 根据特征值加锁
缓存雪崩。

缓存中数据短时间内大批量过期。

处理方式:

  • 热点数据不过期
  • 数据均匀分布于不同缓存服务中。
  • 提前缓存数据,并将不同数据的过期时间设置不同的过期长度。防止短时间内失效缓存量过多。

降级熔断

什么是降级熔断

服务较多且,服务间调用复杂时,服务间的依赖变得明显,当某个服务层的某个副本发生异常时,若请求方未作请求限制容易将整个服务拖垮。如A,B两个服务,A调用B,当B服务的某个副本产生异常如阻塞等,若A服务的调用方未做处理,当请求是同步时,A的调用线程此时也会产生阻塞,则整个A的线程池使用都将受到影响,严重时也将造成A的阻塞,严重时将造成整个服务不可用。

什么时候熔断降级

  1. 请求调用过慢。
  2. 请求调用线程数过多。
  3. 异常比例超限。
  4. 异常数过多。
    只有非核心服务才能进行熔断降级,不能影响主流程的进行。

服务间调用的方式

RPC

RPC 全称是Remote Procedure Cell 即远程过程调用,本身的目标是在分布式系统中,调用者不必显式的区分本地调用或远程调用。

相对于本地调用来讲,远程调用一个方法的必要信息都有哪些(可看作找一个人去帮自己做某件事情)?

  1. 作为调用方需要知晓哪些服务提供了这些方法(翻通讯录查查谁可以帮我做,注册中心)。
  2. 这些方法的入参参数要求是什么(找他是要钱还是要情,请求内容)。
  3. 怎么让被调用方知道调用方调用(打电话还是发邮件,请求协议)。

比喻可能不够恰当,通常来讲需要知道哪个服务可以帮我去做(在dubbo中是这样,spring-cloud中的openfeign因其本质是通过http协议传输,所以其实是直接由请求方指定,所以通常在SpringCloud中这种包是由服务提供方以jar包方法发布直接提供出去,而不是以URL方法提供。),然后通过通讯协议找到对方,接收方将请求数据剔除协议数据,按序列化方式将二进制数据序列化并调用对应的方法。

RPC的关键点:

  1. 协议(主要用来解决网络寻址传输问题,常用的协议通常基于TCP,UDP然后在其上进行内部二进制的数据解析定义,或者直接使用http协议)
  2. 序列化(接收方解析参数前将数据序列化)
  3. Call ID映射(被调用方接收到数据后调用指定方法,http中是通过其路径和方法的映射关系进行调用,其他的类似于dubbo应该是在调用协议中保持了该方法在zk的节点ID,被调用方通过节点ID获取其jvm方法调用(猜的))。

另外插一句,经常被问RPC和RestFul的区别,就跟被问Dubbo和SpringCloud的区别一样,这两根本不是一个维度的东西。

服务间调用的隔离

因为服务间调用是通过网络协议进行的,必定存在网络问题。请求发生时,若当前请求的主线程直接去请求远程服务,当远程服务发生抖动时容易影响当前服务,且无法很方便的做到对该抖动远程服务的隔离。所以通常在调用远程服务时使用线程池来做。

线程池隔离

线程池隔离是指对于不同的服务或不同的接口组使用不同的线程池。这样对于不同的服务组来讲,即便某个服务挂掉也只影响到当前线程池中线程使用,而不会影响到其他服务组,进而扩散致整个服务。代表是Hystrix

优点:资源之间做到了最彻底的隔离。

缺点:增加了线程切换的成本,还需要预先给各个资源做线程池大小的分配。

信号量隔离

信号量个隔离是指对于在对外远程请求中使用同一个线程池,但是在该线程池中通过信号量来做不同服务组的线程使用量分配。这样的好处是控制更为简单,并且当某个服务宕掉,该服务占用的线程量可通过信号量动态调整,调整出来的空余线程可被其他请求使用,该部分线程不至于被闲置。代表是Sentiel

限流策略

  • stop-and-wait
  • 滑动窗口
  • 漏桶
  • 令牌桶

stop-and-wait

等待停止,通常在链路传输中作为拥塞控制的一种方法。即发送方同接收方约定一个标志位,当发送方的标志位和接收方的标志位同时为可发送可接收时,消息才被允许进行传输。

程序如下:

public static void main(String[] args) throws InterruptedException {
    AtomicBoolean flag = new AtomicBoolean(true);
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            try {
                if (flag.compareAndSet(true, false)){
                    Thread.sleep(ThreadLocalRandom.current().nextInt(100));
                    System.out.println(Thread.currentThread().getName() + " do something...");
                    flag.set(true);
                }else {
                    System.out.println("fail...");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
        Thread.sleep(10);
    }
    Thread.currentThread().join();
}

计数器

可以使用Semphore(信号量控制)或AtomicLong等计数器控制。设置初始量或者增长量。实际上在Sential中就是使用标志信号量做流量控制的。

Semphore使用

 public static void main(String[] args) throws InterruptedException {
    Semaphore semaphore = new Semaphore(10);
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            try {
                if (semaphore.tryAcquire()) {
                    System.out.println(Thread.currentThread().getName() + " do something...");
                    semaphore.release();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
    Thread.currentThread().join();
}

计数器方式

public static void main(String[] args) throws InterruptedException {
    AtomicLong count = new AtomicLong(10);
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            try {
                if (count.decrementAndGet() > 0) {
                    Thread.sleep(ThreadLocalRandom.current().nextInt(100));
                    System.out.println(Thread.currentThread().getName() + " do something...");
                    count.incrementAndGet();
                }else {
                    System.out.println("fail...");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }
    Thread.currentThread().join();
}

这两种方式本质上都是类似于AQS的计数器方式。缺点在于速率无法进行控制,不够平滑,若服务器可处理请求数为100,设置计数器初始值为100,存在100个令牌第一时间被发放完毕,获取到令牌的线程处理时间过长,无法快速返还令牌,则接下来的请求都会失败,且当100个请求时间极限集中,服务器仍会存在处理失败宕机风险。不过优点在于其存在令牌返还情况,所以总体程序的当前处理请求数量是可控的。

滑动窗口

这里指拥塞控制的一种方式(通常也在字符串的控制中使用),相对于stop-and-wait(停止等待协议)来讲,该算法允许发送方在停止等待并确认前发送多个信息,发送方不必每发送一组信息就停下来等待确认。
即由原来的单个控制,变为段控制,这个段控制可以是时间段或者数据量段。通常在高并发系统中是使用时间段来做控制(通常是QPS)。

可使用链表来做,以时间为维度进行。示例如下:

class SlidWindow{

    //时间长度
    private final Integer date;
    
    //可发放令牌数
    private final int canPermits;
    
    private ConcurrentLinkedQueue<Long> tickers;
    
    public SlidWindow(Integer times, Integer date){
        tickers = new ConcurrentLinkedQueue<>();
        canPermits = times;
        this.date = date;
    }

    /**
     *  每次获取时窗口需要进行移动
     * @param times
     * @throws InterruptedException
     */
    public void aquire(Integer times) throws InterruptedException {
        synchronized (this){
            if (tickers.size() > 0){
                while (true){
                    long now = System.currentTimeMillis();
                    tickers.removeIf(t -> now - t > 1000 * date);
                    if (canPermits - tickers.size() >= times){
//                            System.out.println( Thread.currentThread().getName() + " canPermits = " + canPermits  +
//                                    " || tickers size = " + tickers.size() + " || times =  " + times);
                        break;
                    }
                }
            }
            long now = System.currentTimeMillis();
            tickers.add(now);
            return;
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        SlidWindow slidWindow = new SlidWindow(10, 1);
        for (int i = 0; i < 12; i++) {
            new Thread(() -> {
                try {
                    slidWindow.aquire(1);
                    Thread.sleep(ThreadLocalRandom.current().nextInt(100));
                    System.out.println(Thread.currentThread().getName() + " do something...");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
            Thread.sleep(10);
        }
        Thread.currentThread().join();
    }

}

存在的问题:该方式下流量控制仍不够平滑,只有时间长度够短才能保证其平滑。

漏桶

将请求令牌放置在缓存池中,令牌以一定速率给出,只有拥有该令牌的请求才会被允许进行后续操作。
如Sentiel中的匀速排队方式
demo

令牌桶

相较于漏桶而言,令牌桶则是请求到达,直接去令牌桶中获取token,若请求。同时令牌桶中可以设置令牌的生成速率,这个时候令牌桶则变为漏桶。

限流工具包

google的Guava中的RateLimiter类,该类基于令牌桶算法进行限流。

该类在令牌发放完毕后,会根据QPS设定(Rate)以微秒为单位生成令牌。
在阻塞获取方法中若剩余令牌不足会,根据差值进行令牌生成时间计算,并通过其内部的stopwatch阻塞生成所需时间,然后返回。
在try acquire 方法中在剩余令牌不足的情况下,则先判定所需令牌生成时长是否大于等待时长,若大于直接返回失败,否则通过其内部的stopwatch阻塞生成所需时间,然后返回成功。

//核心代码-创建
 public static RateLimiter create(double permitsPerSecond) {
    return create(SleepingStopwatch.createFromSystemTimer(), permitsPerSecond);
  }

 
  @VisibleForTesting
  static RateLimiter create(SleepingStopwatch stopwatch, double permitsPerSecond) {
    // 创建平滑突发型
    RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
    rateLimiter.setRate(permitsPerSecond);
    return rateLimiter;
  }
  
   // warmupPeriod到达其稳定速率之前提高其速率的时间段。
  public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) {
    checkArgument(warmupPeriod >= 0, "warmupPeriod must not be negative: %s", warmupPeriod);
    return create(
        SleepingStopwatch.createFromSystemTimer(), permitsPerSecond, warmupPeriod, unit, 3.0);
  }

  @VisibleForTesting
  static RateLimiter create(
      SleepingStopwatch stopwatch,
      double permitsPerSecond,
      long warmupPeriod,
      TimeUnit unit,
      double coldFactor) {
    //创建慢启动限流型
    RateLimiter rateLimiter = new SmoothWarmingUp(stopwatch, warmupPeriod, unit, coldFactor);
    rateLimiter.setRate(permitsPerSecond);
    return rateLimiter;
  }

获取令牌的关键代码:

  1. 阻塞式获取

public double acquire(int permits) {
    //获取资源可被获取的等待时间(从不为负)
    long microsToWait = reserve(permits);
    //线程休眠致等待时间,这里应该是同步代码块。
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    //返回执行速率
    return 1.0 * microsToWait / SECONDS.toMicros(1L);
}

/**
mutex()获取一个互斥对象作为锁对象,不存在则创建。
reserveAndGetWaitLength()则是进行令牌量修改并计算下次令牌获取时长。
*/
final long reserve(int permits) {
    checkPermits(permits);
    synchronized (mutex()) {
      return reserveAndGetWaitLength(permits, stopwatch.readMicros());
    }
}
  1. 非阻塞式获取

public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
    //获取超时时长
    long timeoutMicros = max(unit.toMicros(timeout), 0);
    //校验请求数据
    checkPermits(permits);
    //定义等待时长
    long microsToWait;
    // mutex()获取互斥锁对象
    synchronized (mutex()) {
      //获取当期micros
      long nowMicros = stopwatch.readMicros();
      // 判定是否可获取
      if (!canAcquire(nowMicros, timeoutMicros)) {
        return false;
      } else {
        // 重设permit量并获取等待时长。
        microsToWait = reserveAndGetWaitLength(permits, nowMicros);
      }
    }
    //休眠致最短等待时长并返回true
    stopwatch.sleepMicrosUninterruptibly(microsToWait);
    return true;
}

/**
判定permit最早可用时间长度同需等待时间长度大小,若其值差大于当前时间表明即使等待指定时间长度后该线程仍无法获取到令牌
*/
private boolean canAcquire(long nowMicros, long timeoutMicros) {
    //queryEarliestAvailable()方法返回许可证可用的最早时间
    return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
}

核心代码

/**
保留可许可的请求数量,并返回这些请求可被许可的时间长度。
*/
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    //基于当前时间更新storedPermits和nextFreeTicketMicros
    resync(nowMicros);
    
    long returnValue = nextFreeTicketMicros;
    //获取当前可申请到的Permits量
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    //计算申请量和可申请量之间的差值。
    double freshPermits = requiredPermits - storedPermitsToSpend;
    //获取Permits超出部分需要的等待时长。SmoothBursty中为0L
    long waitMicros =
        storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
            + (long) (freshPermits * stableIntervalMicros);
    //nextFreeTicketMicros更新为加上超出部分等待时长。
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
    //更新storedPermits,该值不可能为负。
    this.storedPermits -= storedPermitsToSpend;
    //返回该值无论在acquire()或者tryAcquire()中都会被stopwatch阻塞致该时间段结束。
    return returnValue;
}

void resync(long nowMicros) {
    // 如果nextFreeTicketMicros 已经过去, 则将其更新致当前时间。
    if (nowMicros > nextFreeTicketMicros) {
      //当前微秒-下个微秒时长/ticket产生速率 = 可产生的新的许可量
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      //更新许可量
      storedPermits = min(maxPermits, storedPermits + newPermits);
      nextFreeTicketMicros = nowMicros;
    }
}
posted @ 2021-02-24 18:57  西罗(斗筲小人)  阅读(65)  评论(0编辑  收藏  举报