钉钉机器人限流应对方案--延迟队列的实现和内存泄漏思考(上)

需求

最近做公司的报警系统,需要做钉钉推送报警信息,但是钉钉有限流措施,一分钟内发多了会导致"send too fast"异常,虽然我们可以通过限流工具来拒绝多余的信息,但是我们希望信息不要漏掉.如果推送时间接受可以晚一点的话,我们可以通过延时队列解决。

JDK里的延时队列

其实jdk就有现成的延时队列 DelayQueue。里面存放的元素必须要全部实现 Delayed接口,Delayed接口只有一个方法getDelay,用于自定义计算剩余延迟时间,如果take的时候第一个队列元素没有到到期时间(getDelay>0),那就可能阻塞等待。DelayQueue的队列实现用的是PriorityQueue,PriorityQueue使用Comparable或者Comparator来进行排序。下面是DelayQueue部分重要组成

public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
    implements BlockingQueue<E> {

    private final transient ReentrantLock lock = new ReentrantLock();
    private final PriorityQueue<E> q = new PriorityQueue<E>();

	public boolean offer(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            q.offer(e);
            if (q.peek() == e) {
                leader = null;
                available.signal();
            }
            return true;
        } finally {
            lock.unlock();
        }
	}

	public E take() throws InterruptedException {
	      final ReentrantLock lock = this.lock;
	      lock.lockInterruptibly();
	      try {
	          for (;;) {
	              E first = q.peek();
	              if (first == null)
	                  available.await();
	              else {
	                  long delay = first.getDelay(NANOSECONDS);
	                  if (delay <= 0)
	                      return q.poll();
	                  first = null; // don't retain ref while waiting
	                  if (leader != null)
	                      available.await();
	                  else {
	                      Thread thisThread = Thread.currentThread();
	                      leader = thisThread;
	                      try {
	                          available.awaitNanos(delay);
	                      } finally {
	                          if (leader == thisThread)
	                              leader = null;
	                      }
	                  }
	              }
	          }
	      } finally {
	          if (leader == null && q.peek() != null)
	              available.signal();
	          lock.unlock();
	      }
	  }
}

符合实际需求的实现

上面的jdk实现不能直接套用,因为还要考虑到 按业务主键(key)分类,单周期内最大处理数量,队列最大数量 等因素。实现了一个符合自己实际需求的延迟队列,完整代码如下:

package cn.xxywithpq.delay;

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;

@Slf4j
public class LimitUtil<E> {

    /**
     * 队列最大个数
     */
    final int maxSize;
    /**
     * 每分钟最大处理数
     */
    final int maxSizePerMinutes;
    private ScheduledFuture<?> scheduledFuture;
    private ScheduledExecutorService executorService;

    ReentrantLock putLock = new ReentrantLock();
    ReentrantLock takeLock = new ReentrantLock();
    Condition notEmpty = takeLock.newCondition();
    private ExecutorService service = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new ThreadFactory() {
        protected final AtomicInteger threadNumber = new AtomicInteger(1);

        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, "pool-limit-util-take-thread-" + toString() + "-" + this.threadNumber.getAndIncrement());
            t.setDaemon(true);
            return t;
        }
    });
    private volatile transient Node<E> head;
    private volatile transient Node<E> tail;


    /**
     * 链表个数
     */
    private volatile transient AtomicInteger count = new AtomicInteger(0);
    /**
     * 每分钟处理个数限制
     */
    private volatile transient AtomicInteger limitCount = new AtomicInteger(0);

    /**
     * @param seconds      周期时间
     * @param maxHandleNum 队列最大个数
     * @param maxQueueSize 每周期时间内最大处理数
     */
    public LimitUtil(int seconds, int maxHandleNum, int maxQueueSize, Consumer<E> consumer) {
        synchronized (this) {
            head = tail = new Node(null);
            maxSizePerMinutes = maxHandleNum;
            maxSize = maxQueueSize;
            executorService = new ScheduledThreadPoolExecutor(1, new ThreadFactory() {
                protected final AtomicInteger threadNumber = new AtomicInteger(1);

                @Override
                public Thread newThread(Runnable r) {
                    Thread t = new Thread(r, "pool-limit-util-timekeeper-thread-" + toString() + "-" + this.threadNumber.getAndIncrement());
                    t.setDaemon(true);
                    return t;
                }
            });
            scheduledFuture = executorService.scheduleAtFixedRate(() -> limitCount.set(0), seconds, seconds, TimeUnit.SECONDS);

            for (int i = 0; i < 1; i++) {
                service.submit(() -> {
                    while (true) {
                        try {
                            E take = take();
                            log.info("LimitUtil Thread: {} ;result {}", Thread.currentThread().getName(), take);
                            consumer.accept(take);
                        } catch (InterruptedException e) {
                            log.warn("LimitUtil stop take");
                            return;
                        } catch (Exception e) {
                            log.error("LimitUtil error {}");
                        }
                    }
                });
            }
        }
    }

    public void put(E e) {
        final AtomicInteger count = this.count;
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        int size;
        try {
//            队列已满,不再加入等待队列
            while (count.get() == maxSize) {
                log.warn("LimitUtil funnelRate full {}", e);
                return;
            }
            enqueue(e);
            size = count.incrementAndGet();
            log.info("LimitUtil Thread {} add  {} size {}", Thread.currentThread().getId(), e, size);
            if ((size > 0 && limitCount.get() < maxSizePerMinutes)) {
                signalNotEmpty();
            }
        } finally {
            putLock.unlock();
        }
    }

    public E take() throws InterruptedException {
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        int size;
        try {
            while (limitCount.get() >= maxSizePerMinutes) {
                try {
                    long delay = scheduledFuture.getDelay(TimeUnit.NANOSECONDS);
                    if (delay > 0) {
                        notEmpty.awaitNanos(delay);
                    }
                } catch (InterruptedException e) {
                    throw e;
                }
            }

            while (count.get() == 0) {
                if (Thread.currentThread().isInterrupted()) {
                    throw new InterruptedException();
                }
                notEmpty.await();
            }
            E result = dequeue();
            size = count.decrementAndGet();
            if (size > 0) {
                notEmpty.signal();
            }
            limitCount.incrementAndGet();
            return result;
        } catch (InterruptedException e) {
            throw e;
        } finally {
            takeLock.unlock();
        }
    }

    private void signalNotEmpty() {
        takeLock.lock();
        this.notEmpty.signal();
        takeLock.unlock();
    }

//    private void signalNotFull() {
//        putLock.lock();
//        this.notFull.signal();
//        putLock.unlock();
//    }

    private void enqueue(E e) {
        Node<E> node = new Node(e);
        tail.next = node;
        tail = tail.next;
    }

    private E dequeue() {
        Node<E> h = head;
        Node<E> first = h.next;
//            help gc
        h.next = h;

        head = first;
        E x = first.item;
        first.item = null;
        return x;
    }

    private class Node<E> {
        volatile E item;
        volatile Node<E> next;

        Node(E x) {
            item = x;
        }
    }

//    @Override
//    protected void finalize() {
//        log.info("finalize start");
//        this.close();
//    }

    public void close() {
        service.shutdownNow();
        executorService.shutdownNow();
        service = null;
        scheduledFuture = null;
        executorService = null;
        head = tail = null;
    }
}

其实出入队的逻辑直接用的LinkedBlockingQueue的源码,所以逻辑上不会出什么问题,而且你可以在源码上加上自己需要的东西。

延时如何实现

在这个队列里,延时的实现依靠一个定时器ScheduledExecutorService,针对钉钉机器人通知,假如我们希望一分钟只发10次。 那我们就可以设一个变量limitCount记录一个周期内的消费次数,ScheduledExecutorService每60秒置零一次limitCount。如果单个周期超过 最大处理量,就通过ScheduledFuture的getDelay方法获取下次执行的剩余时间用于take线程休眠。休眠时间到了,就开始下一个周期消费。

private volatile transient AtomicInteger limitCount = new AtomicInteger(0);


private ScheduledFuture<?> scheduledFuture = executorService.scheduleAtFixedRate(() -> limitCount.set(0), seconds, seconds, TimeUnit.SECONDS);


    public E take() throws InterruptedException {
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        int size;
        try {
        // 当前周期内消费次数大于最大处理数,获取下次执行时间,这个时间并用于当前take线程休眠。
            while (limitCount.get() >= maxSizePerMinutes) {
                try {
                    long delay = scheduledFuture.getDelay(TimeUnit.NANOSECONDS);
                    if (delay > 0) {
                        notEmpty.awaitNanos(delay);
                    }
                } catch (InterruptedException e) {
                    throw e;
                }
            }

            while (count.get() == 0) {
                if (Thread.currentThread().isInterrupted()) {
                    throw new InterruptedException();
                }
                notEmpty.await();
            }
            E result = dequeue();
            size = count.decrementAndGet();
            if (size > 0) {
                notEmpty.signal();
            }
            limitCount.incrementAndGet();
            return result;
        } catch (InterruptedException e) {
            throw e;
        } finally {
            takeLock.unlock();
        }
    }

根据业务key进行分类

在我们的业务中,钉钉一个机器人就有一个url,每个url应该独立有一套配置,那我们的LimitUtil实例应该不止一个,怎么管理,我们可以想到map,但是系统的内存资源是宝贵的,对于用很少的,或者后期干脆不用的,我们应该及时释放资源。在这个方案中,我们用实现了LRU淘汰算法的LinkedHashMap作为我们的LimitUtil容器,你继承LinkedHashMap后,可以选择覆写removeEldestEntry方法,它会自动把最老的元素(最早以前使用过的)作为方法的参数,这个方法默认返回false,如果你返回true,这个最老的元素就会被map自动删除。大众化的实现是根据map的容量是否超过最大值来决定是否删除。

在LinkedHashMap的构造方法中,有一个accessOrder参数,我们默认设为true,表示被访问的元素会自动移到head位置。

    class LRU<K, V> extends LinkedHashMap<K, V> {

        // 保存缓存的容量
        private int capacity;

        public LRU(int capacity, float loadFactor) {
            super(capacity, loadFactor, true);
            this.capacity = capacity;
        }

        /**
         * 重写removeEldestEntry()方法设置何时移除旧元素
         *
         * @param eldest
         * @return
         */
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            // 当元素个数大于了缓存的容量, 就移除元素
            if (size() > this.capacity) {
                // LimitUtil value = (LimitUtil) eldest.getValue();
                // value.close();
            }
            return size() > this.capacity;
        }
    }

有了容器之后,我们简单写个工厂根据getInstance方法通过key取value,这样就便于管理,下面是工厂完整代码(跟spring集成了):

@Slf4j
@Component
public class DingDingLimitUtilFactory<E> implements InitializingBean {

    @Autowired
    CustomProperties customProperties;

    LRU<String, LimitUtil> map;

    public synchronized LimitUtil getInstance(String key, Consumer<E> consumer) {
        LimitUtil limitUtil;
        if (null != (limitUtil = map.get(key))) {
            return limitUtil;
        } else {
            CustomProperties.DingDingLimit dingDingLimit = customProperties.getDingDingLimit();
            limitUtil = new LimitUtil(dingDingLimit.getSeconds(), dingDingLimit.getMaxHandleNum(), dingDingLimit.getMaxQueueSize(), consumer);
            map.put(key, limitUtil);
            return limitUtil;
        }

    }

    @Override
    public void afterPropertiesSet() {
        map = new LRU<>(customProperties.getDingDingLimit().getLruCapacity(), 0.75f);
    }

    class LRU<K, V> extends LinkedHashMap<K, V> {

        // 保存缓存的容量
        private int capacity;

        public LRU(int capacity, float loadFactor) {
            super(capacity, loadFactor, true);
            this.capacity = capacity;
        }

        /**
         * 重写removeEldestEntry()方法设置何时移除旧元素
         *
         * @param eldest
         * @return
         */
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            // 当元素个数大于了缓存的容量, 就移除元素
            if (size() > this.capacity) {
                LimitUtil value = (LimitUtil) eldest.getValue();
                value.close();
            }
            return size() > this.capacity;
        }
    }
}

在getInstance方法中,有一个consumer参数,决定了LimitUtil 的take方法取到元素后 怎么处理的逻辑,这是lamdba语法,这就让这个工具更加通用,不同的业务也能写出不同的处理方式。

不足

上线之后,工具可以正常用。但这个工具类完全是单机的产物,不适用于分布式环境,就当学习一些map,blockqueue的源码。而且钉钉这种业务也没有一定要用上中间件,可以满足业务需求。

前面虽然有LinkedHashMap作为lru淘汰来节省资源,但是这种代码会导致内存泄漏,具体还看下一篇文章。

完整代码上传到 simplify-lock-spring-boot-starter,LimitUtilTest是测试类。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章