需求
最近做公司的报警系统,需要做钉钉推送报警信息,但是钉钉有限流措施,一分钟内发多了会导致"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是测试类。