需求简述


多项目管理,每个项目在页面自由选择消息中间件类型,将n个定时巡检的数据进行推送。

实现思路


根据选择的消息中间件类型,将配置的中间件服务器数据,生成不同类型消息生产者。

实现方案


  • 方案一

    每个定时任务执行任务完,发数据的时候,直接创建的一个消息生产者,发完消息直接close掉。该方案的特点是, 随用随放,不占用连接资源。但缺点是当定时任务的个数多,频率大起来的时候,会频繁创建和关闭连接,造成很大的资源消耗。

  • 方案二

    针对每个项目下,多个定时任务都用的同一份生产者配置,加上方案一的缺点,该方案采用在工厂类中用ConcurrentHashMap<String, IMQProducer>定义一个生产者池子存放不同项目的消息生产者。每个项目下的定时任务共享一个生产者。 该方案为每个项目维持一个消息生产者连接。可以解决频繁创建和销毁连接带来性能损耗。

    但存在一个安全隐患。即当某个项目删除、或者消息推送的开关被长时间或永久关闭、抑或是服务器发生异常与生产者断开连接。那该map里的实例还是存在的。当时间一久,项目一多,容易引起连接泄露问题。

  • 方案三

    针对第二种方案的缺点。我们可以通过引入google的GuavaCache来解决。利用其失效机制来控制长时间没利用的消费者,自动进行close处理。

下面是常用的其提供的常用方法:

/** 
 * 该接口的实现被认为是线程安全的,即可在多线程中调用 
 * 通过被定义单例使用 
 */  
public interface Cache<K, V> {  
  /** 
   * 通过key获取缓存中的value,若不存在直接返回null 
   */  
  V getIfPresent(Object key);  
  /** 
   * 通过key获取缓存中的value,若不存在就通过valueLoader来加载该value 
   * 整个过程为 "if cached, return; otherwise create, cache and return" 
   * 注意valueLoader要么返回非null值,要么抛出异常,绝对不能返回null 
   */  
  V get(K key, Callable<? extends V> valueLoader) throws ExecutionException;  
  /** 
   * 添加缓存,若key存在,就覆盖旧值 
   */  
  void put(K key, V value);  
  /** 
   * 删除该key关联的缓存 
   */  
  void invalidate(Object key);  
  /** 
   * 删除所有缓存 
   */  
  void invalidateAll();  
  /** 
   * 执行一些维护操作,包括清理失效的缓存 
   */  
  void cleanUp();  
} 

最终工厂类实现

public final class MqProducerFactory {

    private static final Logger logger = LoggerFactory.getLogger(MqProducerFactory.class);

    private static final int CLEAR_PERIOD = 1000;
    private static final String KAFKA_PREFIX = "kafka_";
    private static final String CTG_MQ_PREFIX = "ctgmq_";
    private static final String ROCKET_PREFIX = "rocketmq_";
    

    private static final ScheduledExecutorService PRODUCER_CLEAR_UP_SCHEDULER = new ScheduledThreadPoolExecutor(1,
            new ThreadFactoryBuilder().setNameFormat("mq-producer-clearUp-scheduler-%d").build());

    public static final Cache<String, IMqProducer> PRODUCER_CACHE = CacheBuilder.newBuilder()
        // 入参为距离最近一次访问,实例运行不被失效的最大时间,是因为每次只要距离上一次访问时刻的时间小于该值的情况下进行访问,这个时间就会被重置,重新计时,知道超过该事件位置。简单来说,不超时的情况下,访问会重置过期时间
        .expireAfterAccess(Duration.ofSeconds(20))
        // 注册一个监听,当缓存失效被get的时候,或触发clearUp的时候触发
        .removalListener(r -> {
            	// 对长时间闲置导致失效生产者进行关闭
                IMqProducer producer = (IMqProducer) r.getValue();
                if (Objects.nonNull(producer)) {
                    producer.close();
                }
            })
        .build();

    // 每秒进行一次失效缓存清理,触发remove监听,对生产者连接进行关闭
    static {
        PRODUCER_CLEAR_UP_SCHEDULER.scheduleAtFixedRate(PRODUCER_CACHE::cleanUp, 0, CLEAR_PERIOD, TimeUnit.MILLISECONDS);
    }


    private MqProducerFactory() {

    }

    public static IMqProducer getProducer(MqTypeEnum mqType,  Properties properties, String key) {

        switch (mqType) {
            case KAFKA: {
                IMqProducer kafkaProducer = PRODUCER_CACHE.getIfPresent(KAFKA_PREFIX + key);
                if (Objects.nonNull(kafkaProducer)) {
                    return kafkaProducer;
                }
                Producer<String, String> producer = new KafkaProducer<>(properties);
                kafkaProducer = new KafkaMqProducer(producer);
                PRODUCER_CACHE.put(KAFKA_PREFIX + key, kafkaProducer);
                return new KafkaMqProducer(producer);
            }
            case CTG_MQ: {
                IMqProducer kafkaProducer = PRODUCER_CACHE.getIfPresent(CTG_MQ_PREFIX + key);
                if (Objects.nonNull(kafkaProducer)) {
                    return kafkaProducer;
                }
                // todo ctgmq 
                return null;
            }
            case ROCKET_MQ: {
                IMqProducer kafkaProducer = PRODUCER_CACHE.getIfPresent(ROCKET_PREFIX + key);
                if (Objects.nonNull(kafkaProducer)) {
                    return kafkaProducer;
                }
                // todo rocketmq
                return null;
            }
            default: {
                return null;
            }
        }
    }
}

注意:

当前场景下的,有两种情况触发remove监听,如下:

  1. 缓存已经失效,然后去取缓存的时候
  2. claerUp时候,本案例用定时任务取触发clearUp

之所以用定时任务来触发remove事件,而不用单单用情况1,是因为情况1还是只能解决缓存失效问题。而不能立马close掉消费者连接释放资源。得等到下一次使用时候才取close,而既然都等到下一次要用的时候还没关闭了。那还不如不关闭,而不是关闭老的连接,建立新的连接。

另外,一般消息中间件的生产者都有一个最大空闲时间的配置,虽然的它能够实现超时自动关闭连接的功能的,但它没有能够自动续费的一个场景,因此也不能满足我们多个定时任务不同时间段任务执行共享一个生产者的需求

指定一提的是,remove监听虽然定义的方法看似清除了一个。但实验证明,其会同一时间清除掉所有失效的缓存。

image

另外,remove监听其实有很多场景,可以说,只要原来的那份缓存不在了,不管什么原因,都会触发。以下是触发时机

  /**
   * The entry was manually removed by the user. This can result from the user invoking {@link
   * Cache#invalidate}, {@link Cache#invalidateAll(Iterable)}, {@link Cache#invalidateAll()}, {@link
   * Map#remove}, {@link ConcurrentMap#remove}, or {@link Iterator#remove}.
   */
  EXPLICIT {
    @Override
    boolean wasEvicted() {
      return false;
    }
  },

  /**
   * The entry itself was not actually removed, but its value was replaced by the user. This can
   * result from the user invoking {@link Cache#put}, {@link LoadingCache#refresh}, {@link Map#put},
   * {@link Map#putAll}, {@link ConcurrentMap#replace(Object, Object)}, or {@link
   * ConcurrentMap#replace(Object, Object, Object)}.
   */
  REPLACED {
    @Override
    boolean wasEvicted() {
      return false;
    }
  },

  /**
   * The entry was removed automatically because its key or value was garbage-collected. This can
   * occur when using {@link CacheBuilder#weakKeys}, {@link CacheBuilder#weakValues}, or {@link
   * CacheBuilder#softValues}.
   */
  COLLECTED {
    @Override
    boolean wasEvicted() {
      return true;
    }
  },

  /**
   * The entry's expiration timestamp has passed. This can occur when using {@link
   * CacheBuilder#expireAfterWrite} or {@link CacheBuilder#expireAfterAccess}.
   */
  EXPIRED {
    @Override
    boolean wasEvicted() {
      return true;
    }
  },

  /**
   * The entry was evicted due to size constraints. This can occur when using {@link
   * CacheBuilder#maximumSize} or {@link CacheBuilder#maximumWeight}.
   */
  SIZE {
    @Override
    boolean wasEvicted() {
      return true;
    }
  };

可以看出上面提到的两种场景分别对应的实际是EXPIREDEXPLICIT

定时任务执行缓存clearUp


简单回顾下定时任务的三种实现方式

Quartz定时任务框架

要引入框架,比较重,针对有需要手动自主触发定时任务的需求,可以对定时任务进行自主管理

@Scheduled

轻量,依赖于Spring框架,但该场景下要工厂类的缓存变量暴露出来,不太合理,不推荐该场景下使用

ScheduledExecutorService

提供以下api

/**
 * 创建并执行在给定延迟后启用的【一次性】操作。
 * 参数:
 * 	command - 要执行的任务
 * 	delay – 从现在开始延迟执行的时间
 *  	unit – 延迟参数的时间单位
 * 返回:
 *  一个 ScheduledFuture 表示任务的挂起完成,其get()方法将在完成时返回null
 * 抛出:
 *  	RejectedExecutionException – 如果无法安排任务执行
 *  	NullPointerException – 如果命令为空
 */
public ScheduledFuture<?> schedule(Runnable command,
                                   long delay, TimeUnit unit);

/**
 * 创建并执行在给定延迟后启用的 ScheduledFuture。也是【一次性】操作,能够获取执行结果
 * 参数:
 * 	callable – 要执行的函数
 * 	delay – 从现在开始延迟执行的时间
 * 	unit – 延迟参数的时间单位
 * 类型参数:
 * 	<V> – 可调用结果的类型
 * 返回:
 * 	可用于提取结果或取消的 ScheduledFuture
 * 抛出:
 * 	RejectedExecutionException – 如果无法安排任务执行
 * 	NullPointerException – 如果 callable 为 null
 */
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
                                       long delay, TimeUnit unit);

/**
 * 创建并执行一个周期性动作,该动作在给定的初始延迟后首先启用,随后在给定的周期内启用;也就是说,执行将在initialDelay之后开始,
 * 然后是initialDelay+period ,然后是initialDelay + 2 * period ,依此类推。如果任务的任何执行遇到异常,则后续执行将被
 * 抑制。否则,任务只会通过取消或终止执行者来终止。如果此任务的任何执行时间超过其周期,则后续执行可能会延迟开始,但不会同时执行。
 * 参数:
 * 	command - 要执行的任务
 * 	initialDelay – 延迟首次执行的时间
 * 	period – 连续执行之间的时间段
 * 	unit – initialDelay 和 period 参数的时间单位
 * 返回:
 * 	表示待完成任务的 ScheduledFuture,其get()方法将在取消时抛出异常
 * 抛出:
 * 	RejectedExecutionException – 如果无法安排任务执行
 * 	NullPointerException – 如果命令为空
 * 	IllegalArgumentException – 如果 period 小于或等于零
 */
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                              long initialDelay,
                                              long period,
                                              TimeUnit unit);

/**
 * 创建并执行一个周期性操作,该操作首先在给定的初始延迟之后启用,随后在一个执行的终止和下一个执行的开始之间具有给定的延迟。如果任
 * 务的任何执行遇到异常,则后续执行将被抑制。否则,任务只会通过取消或终止执行者来终止。
 * 参数:
 * 	command - 要执行的任务
 * 	initialDelay – 延迟首次执行的时间
 * 	delay - 一个执行的终止和下一个执行的开始之间的延迟
 *  unit – initialDelay 和 delay 参数的时间单位
 * 返回:
 * 	表示待完成任务的 ScheduledFuture,其get()方法将在取消时抛出异常
 * 抛出:
 * 	RejectedExecutionException – 如果无法安排任务执行
 * 	NullPointerException – 如果命令为空
 * 	IllegalArgumentException – 如果延迟小于或等于零
 */
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                 long initialDelay,
                                                 long delay,
                                                 TimeUnit unit);

scheduleAtFixedRate 与 scheduleWithFixedDelay 的区别

从他们的名字可以看出

scheduleAtFixedRate是以固定的速率取执行任务,即表明它不会收到任务执行的影响,是以任务开始为准(当然是在任务执行时间小于间隔时间的前提下,如过大于时间间隔,那么下一次任务就在该次任务结束后开始,这种情况会有影响)

scheduleWithFixedDelay是以固定的延迟进行,也就是上一次任务结束到下一次任务开始时间间隔是固定的,也就是以任务结束时间为准