前言
就是在前不久,白天好好地坐在工位上碼代碼,運維那邊的同事打電話和我說我的服務發的消息太多,造成RMQ集羣消息積壓了很多,於是連忙去看日誌和RMQ的管理頁面,好傢伙發了20來W條數據。這是令我沒想到的,因爲以我們產品的用戶數量和這個接口的使用頻率上來說,這是不大可能的。後來調查到是一個同事在批量處理他們系統的數據,需要走我這個接口,總共大約要處理15W條,但是他連續操作了兩次,這樣連續的消息發送並且由於消息消費者處理的速率以及消費者的數量並不多,自然消息就積壓了。其實我在發送的時候是做了簡單的流量控制的,但是還是不夠周全。所以晚上到家我就在想着處理方案。
從消費者還是生產者下手
這個事情,並且就正常情況而言,也就是沒有人像我同事這樣操作或者用戶惡意調用的情況,是不會出現上述的問題的。事實上用戶惡意調用是不成立的,我這裏是基礎服務,上層服務都做了限流或者安全措施,但無法避免公司內部服務來多次調用,比如我同事同步數據的時候調用很多次。所以這裏的問題不在於消費者的消費速度或者消費者的數量,而是應該在我這邊做限流,控制發送的速度。所以需要從生產者下手。
是否需要重發
如果簡單地只做限流,只要用RateLimiter這樣的限流器,便可用令牌桶法來進行限流,沒有獲取到令牌的就丟掉。(令牌桶算法請看->限流算法),但是否需要丟掉消息是需要根據業務來的,經過對業務的考量,不能丟掉,因爲直接影響到客戶。那麼就需要重發機制了。
重發機制
我的想法是在本地新增一個線程安全的隊列,如果某條消息發送的時候顯示太快了,那麼把這個消息發送隊列裏。然後後臺有輪詢的線程不斷從隊列中取消息,取出之後再重新發送。當然,每個消息應該有一個延時的時間,所以這個隊列應當支持延時。關於延時隊列請看->延時隊列,這裏我採用Java裏的DelayQueue來實現。爲了線程安全,隊列分別有一把寫鎖和讀鎖。後臺線程如何創建?採用SpringBoot自帶的@Async來異步啓動。線程內部使用線程池來進行多線程發送。
代碼實現
延時隊列中存放的消息類
public class DelayTask implements Delayed {
/**
* 存放消息的map
* */
private HashMap<String,Object> msg;
/**
* 延時時長+入隊時間的值
* */
private long dealAt;
public HashMap<String, Object> getMsg() {
return msg;
}
public void setMsg(HashMap<String, Object> msg) {
this.msg = msg;
}
public DelayTask(long time, HashMap<String,Object> msg){
this.dealAt = time;
this.msg = msg;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(
dealAt-System.currentTimeMillis(),
TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
if(getDelay(TimeUnit.MILLISECONDS)>o.getDelay(TimeUnit.MILLISECONDS)) {
return 1;
}else {
return -1;
}
}
//getter setter
}
延時隊列管理
public class DelayQueueManager {
/**
* 延時隊列
* */
private DelayQueue<DelayTask> queue = null;
private ReentrantLock readLock;
private ReentrantLock writeLock;
private static DelayQueueManager instance = null;
public static DelayQueueManager getInstance(){
if(instance == null){
synchronized (DelayQueueManager.class){
if(instance == null){
instance = new DelayQueueManager();
}
}
}
return instance;
}
private DelayQueueManager(){
queue = new DelayQueue<>();
readLock = new ReentrantLock();
writeLock = new ReentrantLock();
}
/**
* 消息入隊列
* */
public void saveMsg(long outTime,HashMap<String,Object> msg){
writeLock.lock();
try {
queue.add(new DelayTask(outTime,msg));
}finally {
writeLock.unlock();
}
}
/**
* 消息出隊列
* */
public HashMap<String,Object> getMsg() throws InterruptedException {
DelayTask t = null;
readLock.lock();
try {
//阻塞取消息
t = queue.take();
}finally {
readLock.unlock();
}
return t.getMsg();
}
}
重發器
@Configuration
@Slf4j
@EnableAsync
public class Resender implements DisposableBean{
@Value("${rmq.exchange.name}")
public String exchangeName;
@Value("${rmq.queue.name}")
public String queueName;
@Value("${rmq.binding.name}")
public String bindingName;
/**
* 後臺線程池
* */
private ExecutorService threadPool;
private boolean isStop;
public Resender(){
log.info("構造重發器");
threadPool = new ThreadPoolExecutor(
8,
10,
100L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
new ThreadPoolExecutor.DiscardPolicy());
isStop = false;
}
@Autowired
private MQSender sender;
@Async
public void start() {
log.info("重發器啓動");
while (!isStop){
try {
HashMap<String,Object> msg = DelayQueueManager.getInstance().getMsg();
if(msg!=null){
log.info("重新發送消息:id="+msg.get("msgId"));
String messageId = String.valueOf(msg.get("msgId"));
String createTime = String.valueOf(msg.get("createTime"));
String msgData = String.valueOf(msg.get("msgData"));
threadPool.submit(new Runnable() {
@Override
public void run() {
sender.sendMessage(messageId,msgData,createTime);
}
});
}
}catch (Exception e){
log.info("取消息異常"+e.getMessage());
}
}
}
@Override
public void destroy() throws Exception {
isStop = true;
log.info("重發器停止");
}
}
真正發送MQ消息的發送器
@Component
@Slf4j
public class MQSender {
@Value("${rmq.exchange.name}")
public String exchangeName;
@Value("${rmq.queue.name}")
public String queueName;
@Value("${rmq.binding.name}")
public String bindingName;
@Autowired
private RabbitTemplate rabbitTemplate;
private RateLimiter rateLimiter = RateLimiter.create(100);
/**
* 發送消息
* */
public void sendMessage(String messageId,String msgContent,String createTime){
HashMap<String,Object> map = new HashMap<String,Object>();
map.put("msgId",messageId);
map.put("msgData",msgContent);
map.put("createTime",createTime);
if(rateLimiter.tryAcquire(1,10,TimeUnit.MILLISECONDS))
{
rabbitTemplate.convertAndSend(exchangeName,bindingName,map);
log.info("發送消息:"+map);
}
else
{
log.info("發送速率過快,id="+messageId);
long currentTime = System.currentTimeMillis();
DelayQueueManager.getInstance().saveMsg(currentTime+10L,map);
}
}
}
測試方法
簡單寫一個方法
@RestController
public class FlowController {
@Autowired
private MQSender sender;
@GetMapping("/sendMsg")
public String sendMessage(){
String messageId = String.valueOf(UUID.randomUUID());
String messageData = "Hello world";
String createTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
sender.sendMessage(messageId,messageData,createTime);
return "Send OK";
}
}
壓力測試
使用JMeter來進行測試,因爲我令牌桶沒有設置很大,所以一個線程循環請求1000次接口基本就能看到效果。
觀察日誌:
........
2021-03-30 00:55:48.547 INFO 13132 --- [pool-1-thread-6] cn.izzer.rmq_producer.sender.MQSender : 發送消息:{createTime=2021-03-30 00:55:45, msgId=4c0cb97a-147c-495e-a889-54e4ed0c3070, msgData=Hello world}
2021-03-30 00:55:48.547 INFO 13132 --- [pool-1-thread-7] cn.izzer.rmq_producer.sender.MQSender : 發送速率過快,id=dd94cc70-2dc2-4614-8d08-169b6064f23d
2021-03-30 00:55:48.557 INFO 13132 --- [pool-1-thread-5] cn.izzer.rmq_producer.sender.MQSender : 發送消息:{createTime=2021-03-30 00:55:45, msgId=c4eae4bf-08f5-449c-8f0d-be9de1ab7ec8, msgData=Hello world}
2021-03-30 00:55:48.558 INFO 13132 --- [ task-1] cn.izzer.rmq_producer.common.Resender : 重新發送消息:id=dd94cc70-2dc2-4614-8d08-169b6064f23d
2021-03-30 00:55:48.558 INFO 13132 --- [ task-1] cn.izzer.rmq_producer.common.Resender : 重新發送消息:id=3575c881-62c0-4e72-9d44-4398753eb39e
2021-03-30 00:55:48.558 INFO 13132 --- [ task-1] cn.izzer.rmq_producer.common.Resender : 重新發送消息:id=f0c6eb44-3320-4447-b2fb-356872d11b51
........
但是這樣還是看不出效果,這裏我用編輯器抽取了一條記錄從最開始被限流到最終經過幾次重發的過程,可以看到這條消息是歷經幾次才發送成功的,當然這裏我設計的可能還不大好,因爲這個過程理論上應該要更加平滑纔是合理的,可以適當增加延時時間,或者動態設置延時時間可能效果更好,不過這個方案我可能得再思考一下:
感謝觀看,上面我可能有考慮不好的地方,希望有經驗的大佬可以順便指點指點,謝謝。