從RocketMQ看長輪詢(Long Polling)

ksfzhaohui發表於2019-03-07

前言

訊息佇列一般在消費端都會提供push和pull兩種模式,RocketMQ同樣實現了這兩種模式,分別提供了兩個實現類:DefaultMQPushConsumer和DefaultMQPullConsumer;兩種方式各有優勢:

push模式:推送模式,即服務端有資料之後立馬推送訊息給客戶端,需要客戶端和伺服器建立長連線,實時性很高,對客戶端來說也簡單,接收處理訊息即可;缺點就是服務端不知道客戶端處理訊息的能力,可能會導致資料積壓,同時也增加了服務端的工作量,影響服務端的效能;

pull模式:拉取模式,即客戶端主動去服務端拉取資料,主動權在客戶端,拉取資料,然後處理資料,再拉取資料,一直迴圈下去,具體拉取資料的時間間隔不好設定,太短可能會導致大量的連線拉取不到資料,太長導致資料接收不及時; RocketMQ使用了長輪詢的方式,兼顧了push和pull兩種模式的優點,下面首先對長輪詢做簡單介紹,進而分析RocketMQ內建的長輪詢模式。

長輪詢

長輪詢通過客戶端和服務端的配合,達到主動權在客戶端,同時也能保證資料的實時性;長輪詢本質上也是輪詢,只不過對普通的輪詢做了優化處理,服務端在沒有資料的時候並不是馬上返回資料,會hold住請求,等待服務端有資料,或者一直沒有資料超時處理,然後一直迴圈下去;下面看一下如何簡單實現一個長輪詢;

1.實現步驟

1.1客戶端輪詢傳送請求

客戶端應該存在一個一直迴圈的程式,不停的向服務端傳送獲取訊息請求;

1.2服務端處理資料

伺服器接收到客戶端請求之後,首先檢視是否有資料,如果有資料則直接返回,如果沒有則保持連線,等待獲取資料,服務端獲取資料之後,會通知之前的請求連線來獲取資料,然後返回給客戶端;

1.3客戶端接收資料

正常情況下,客戶端會馬上接收到服務端的資料,或者等待一段時間獲取到資料;如果一直獲取不到資料,會有超時處理;在獲取資料或者超時處理之後會關閉連線,然後再次發起長輪詢請求;

2.實現例項

以下使用netty模擬一個http伺服器,使用HttpURLConnection模擬客戶端傳送請求,使用BlockingQueue存放資料;

服務端程式碼

public class Server {

	public static void start(final int port) throws Exception {
		EventLoopGroup boss = new NioEventLoopGroup();
		EventLoopGroup woker = new NioEventLoopGroup();
		ServerBootstrap serverBootstrap = new ServerBootstrap();

		try {

			serverBootstrap.channel(NioServerSocketChannel.class).group(boss, woker)
					.childOption(ChannelOption.SO_KEEPALIVE, true).option(ChannelOption.SO_BACKLOG, 1024)
					.childHandler(new ChannelInitializer<SocketChannel>() {
						@Override
						protected void initChannel(SocketChannel ch) throws Exception {
							ch.pipeline().addLast("http-decoder", new HttpServerCodec());
							ch.pipeline().addLast(new HttpServerHandler());
						}
					});

			ChannelFuture future = serverBootstrap.bind(port).sync();
			System.out.println("server start ok port is " + port);
			DataCenter.start();
			future.channel().closeFuture().sync();
		} finally {
			boss.shutdownGracefully();
			woker.shutdownGracefully();
		}
	}

	public static void main(String[] args) throws Exception {
		start(8080);
	}
}
複製程式碼

netty預設支援http協議,直接使用即可,啟動埠為8080;同時啟動資料中心服務,相關程式碼如下:

public class DataCenter {

	private static Random random = new Random();
	private static BlockingQueue<String> queue = new LinkedBlockingQueue<>();
	private static AtomicInteger num = new AtomicInteger();

	public static void start() {
		while (true) {
			try {
				Thread.sleep(random.nextInt(5) * 1000);
				String data = "hello world" + num.incrementAndGet();
				queue.put(data);
				System.out.println("store data:" + data);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}

	public static String getData() throws InterruptedException {
		return queue.take();
	}

}
複製程式碼

為了模擬服務端沒有資料,需要等待的情況,這裡使用BlockingQueue來模擬,不定期的往佇列裡面插入資料,同時對外提供獲取資料的方法,使用的是take方法,沒有資料會阻塞知道有資料為止;getData在類HttpServerHandler中使用,此類也很簡單,如下:

public class HttpServerHandler extends ChannelInboundHandlerAdapter {

	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		if (msg instanceof HttpRequest) {
			FullHttpResponse httpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
			httpResponse.content().writeBytes(DataCenter.getData().getBytes());
			httpResponse.headers().set(HttpHeaders.Names.CONTENT_TYPE, "text/plain; charset=UTF-8");
			httpResponse.headers().set(HttpHeaders.Names.CONTENT_LENGTH, httpResponse.content().readableBytes());
			ctx.writeAndFlush(httpResponse);
		}
	}
}
複製程式碼

獲取到客戶端的請求之後,從資料中心獲取一條訊息,如果沒有資料,會進行等待,直到有資料為止;然後使用FullHttpResponse返回給客戶端;客戶端使用HttpURLConnection來和服務端建立連線,不停的拉取資料,程式碼如下:

public class Client {

	public static void main(String[] args) {
		while (true) {
			HttpURLConnection connection = null;
			try {
				URL url = new URL("http://localhost:8080");
				connection = (HttpURLConnection) url.openConnection();
				connection.setReadTimeout(10000);
				connection.setConnectTimeout(3000);
				connection.setRequestMethod("GET");
				connection.connect();
				if (200 == connection.getResponseCode()) {
					BufferedReader reader = null;
					try {
						reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));
						StringBuffer result = new StringBuffer();
						String line = null;
						while ((line = reader.readLine()) != null) {
							result.append(line);
						}
						System.out.println("時間:" + new Date().toString() + "result =  " + result);
					} finally {
						if (reader != null) {
							reader.close();
						}
					}
				}
			} catch (IOException e) {
				e.printStackTrace();
			} finally {
				if (connection != null) {
					connection.disconnect();
				}
			}
		}
	}
}
複製程式碼

以上只是簡單的模擬了長輪詢的方式,下面重點來看看RocketMQ是如何實現長輪詢的;

RocketMQ長輪詢

RocketMQ的消費端提供了兩種消費模式分別是:DefaultMQPushConsumer和DefaultMQPullConsumer,其中DefaultMQPushConsumer就是使用的長輪詢,所以下面重點分析此類;

1.PullMessage服務

從名字可以看出來就是客戶端從服務端拉取資料的服務,看裡面的一個核心方法:

@Override
    public void run() {
        log.info(this.getServiceName() + " service started");

        while (!this.isStopped()) {
            try {
                PullRequest pullRequest = this.pullRequestQueue.take();
                this.pullMessage(pullRequest);
            } catch (InterruptedException ignored) {
            } catch (Exception e) {
                log.error("Pull Message Service Run Method exception", e);
            }
        }

        log.info(this.getServiceName() + " service end");
    }
複製程式碼

服務啟動之後,會一直不停的迴圈呼叫拉取資料,PullRequest可以看作是拉取資料需要的引數,部分程式碼如下:

public class PullRequest {
    private String consumerGroup;
    private MessageQueue messageQueue;
    private ProcessQueue processQueue;
    private long nextOffset;
    private boolean lockedFirst = false;
    ...省略...
}
複製程式碼

每個MessageQueue 對應了封裝成了一個PullRequest,因為拉取資料是以每個Broker下面的Queue為單位,同時裡面還一個ProcessQueue,每個MessageQueue也同樣對應一個ProcessQueue,儲存了這個MessageQueue訊息處理狀態的快照;還有nextOffset用來標識讀取的位置;繼續看一段pullMessage中的內容,給服務端傳送請求的頭內容:

PullMessageRequestHeader requestHeader = new PullMessageRequestHeader();
requestHeader.setConsumerGroup(this.consumerGroup);
requestHeader.setTopic(mq.getTopic());
requestHeader.setQueueId(mq.getQueueId());
requestHeader.setQueueOffset(offset);
requestHeader.setMaxMsgNums(maxNums);
requestHeader.setSysFlag(sysFlagInner);
requestHeader.setCommitOffset(commitOffset);
requestHeader.setSuspendTimeoutMillis(brokerSuspendMaxTimeMillis);
requestHeader.setSubscription(subExpression);
requestHeader.setSubVersion(subVersion);
requestHeader.setExpressionType(expressionType);

String brokerAddr = findBrokerResult.getBrokerAddr();
if (PullSysFlag.hasClassFilterFlag(sysFlagInner)) {
      brokerAddr = computPullFromWhichFilterServer(mq.getTopic(), brokerAddr);
}

PullResult pullResult = this.mQClientFactory.getMQClientAPIImpl().pullMessage(
                brokerAddr,
                requestHeader,
                timeoutMillis,
                communicationMode,
                pullCallback);

            return pullResult;
複製程式碼

其中有一個引數是SuspendTimeoutMillis,作用是設定Broker的最長阻塞時間,預設為15秒,前提是沒有訊息的情況下,有訊息會立刻返回;

2.PullMessageProcessor服務

從名字可以看出,服務端用來處理pullMessage的服務,下面重點看一下processRequest方法,其中包括對獲取不同結果做的處理:

 switch (response.getCode()) {
                case ResponseCode.SUCCESS:

                    ...省略...
                    break;
                case ResponseCode.PULL_NOT_FOUND:

                    if (brokerAllowSuspend && hasSuspendFlag) {
                        long pollingTimeMills = suspendTimeoutMillisLong;
                        if (!this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                            pollingTimeMills = this.brokerController.getBrokerConfig().getShortPollingTimeMills();
                        }

                        String topic = requestHeader.getTopic();
                        long offset = requestHeader.getQueueOffset();
                        int queueId = requestHeader.getQueueId();
                        PullRequest pullRequest = new PullRequest(request, channel, pollingTimeMills,
                            this.brokerController.getMessageStore().now(), offset, subscriptionData);
                        this.brokerController.getPullRequestHoldService().suspendPullRequest(topic, queueId, pullRequest);
                        response = null;
                        break;
                    }

                case ResponseCode.PULL_RETRY_IMMEDIATELY:
                    break;
                case ResponseCode.PULL_OFFSET_MOVED:
                    ...省略...

                    break;
                default:
                    assert false;
複製程式碼

一共處理了四個型別,我們關心的是在沒有獲取到資料的情況下是如何處理的,可以重點看一下ResponseCode.PULL_NOT_FOUND,表示沒有拉取到資料,此時會呼叫PullRequestHoldService服務,從名字可以看出此服務用來hold住請求,不會立馬返回,response被至為了null,不給客戶端響應;下面重點看一下PullRequestHoldService:

@Override
    public void run() {
        log.info("{} service started", this.getServiceName());
        while (!this.isStopped()) {
            try {
                if (this.brokerController.getBrokerConfig().isLongPollingEnable()) {
                    this.waitForRunning(5 * 1000);
                } else {
                    this.waitForRunning(this.brokerController.getBrokerConfig().getShortPollingTimeMills());
                }

                long beginLockTimestamp = this.systemClock.now();
                this.checkHoldRequest();
                long costTime = this.systemClock.now() - beginLockTimestamp;
                if (costTime > 5 * 1000) {
                    log.info("[NOTIFYME] check hold request cost {} ms.", costTime);
                }
            } catch (Throwable e) {
                log.warn(this.getServiceName() + " service has exception. ", e);
            }
        }

        log.info("{} service end", this.getServiceName());
    }
複製程式碼

此方法主要就是通過不停的檢查被hold住的請求,檢查是否已經有資料了,具體檢查哪些就是在ResponseCode.PULL_NOT_FOUND中呼叫的suspendPullRequest方法:

private ConcurrentHashMap<String/* topic@queueId */, ManyPullRequest> pullRequestTable =
        new ConcurrentHashMap<String, ManyPullRequest>(1024);
        
 public void suspendPullRequest(final String topic, final int queueId, final PullRequest pullRequest) {
        String key = this.buildKey(topic, queueId);
        ManyPullRequest mpr = this.pullRequestTable.get(key);
        if (null == mpr) {
            mpr = new ManyPullRequest();
            ManyPullRequest prev = this.pullRequestTable.putIfAbsent(key, mpr);
            if (prev != null) {
                mpr = prev;
            }
        }

        mpr.addPullRequest(pullRequest);
    }
複製程式碼

將需要hold處理的PullRequest放入到一個ConcurrentHashMap中,等待被檢查;具體的檢查程式碼在checkHoldRequest中:

private void checkHoldRequest() {
        for (String key : this.pullRequestTable.keySet()) {
            String[] kArray = key.split(TOPIC_QUEUEID_SEPARATOR);
            if (2 == kArray.length) {
                String topic = kArray[0];
                int queueId = Integer.parseInt(kArray[1]);
                final long offset = this.brokerController.getMessageStore().getMaxOffsetInQuque(topic, queueId);
                try {
                    this.notifyMessageArriving(topic, queueId, offset);
                } catch (Throwable e) {
                    log.error("check hold request failed. topic={}, queueId={}", topic, queueId, e);
                }
            }
        }
    }
複製程式碼

此方法用來獲取指定messageQueue下最大的offset,然後用來和當前的offset來比較,來確定是否有新的訊息到來;往下看notifyMessageArriving方法:

public void notifyMessageArriving(final String topic, final int queueId, final long maxOffset, final Long tagsCode) {
        String key = this.buildKey(topic, queueId);
        ManyPullRequest mpr = this.pullRequestTable.get(key);
        if (mpr != null) {
            List<PullRequest> requestList = mpr.cloneListAndClear();
            if (requestList != null) {
                List<PullRequest> replayList = new ArrayList<PullRequest>();

                for (PullRequest request : requestList) {
                    long newestOffset = maxOffset;
                    if (newestOffset <= request.getPullFromThisOffset()) {
                        newestOffset = this.brokerController.getMessageStore().getMaxOffsetInQuque(topic, queueId);
                    }

                    if (newestOffset > request.getPullFromThisOffset()) {
                        if (this.messageFilter.isMessageMatched(request.getSubscriptionData(), tagsCode)) {
                            try {
                                this.brokerController.getPullMessageProcessor().executeRequestWhenWakeup(request.getClientChannel(),
                                    request.getRequestCommand());
                            } catch (Throwable e) {
                                log.error("execute request when wakeup failed.", e);
                            }
                            continue;
                        }
                    }

                    if (System.currentTimeMillis() >= (request.getSuspendTimestamp() + request.getTimeoutMillis())) {
                        try {
                            this.brokerController.getPullMessageProcessor().executeRequestWhenWakeup(request.getClientChannel(),
                                request.getRequestCommand());
                        } catch (Throwable e) {
                            log.error("execute request when wakeup failed.", e);
                        }
                        continue;
                    }

                    replayList.add(request);
                }

                if (!replayList.isEmpty()) {
                    mpr.addPullRequest(replayList);
                }
            }
        }
    }
複製程式碼

方法中兩個重要的判定就是:比較當前的offset和maxoffset,看是否有新的訊息到來,有新的訊息返回客戶端;另外一個就是比較當前的時間和阻塞的時間,看是否超過了最大的阻塞時間,超過也同樣返回; 此方法不光在PullRequestHoldService服務類中迴圈呼叫檢查,同時在DefaultMessageStore中訊息被儲存的時候呼叫;其實就是主動檢查和被動通知兩種方式。

3.PullCallback回撥

服務端處理完之後,給客戶端響應,回撥其中的PullCallback,其中在處理完訊息之後,重要的一步就是再次把pullRequest放到PullMessageService服務中,等待下一次的輪詢;

總結

本文首先介紹了兩種消費訊息的模式,介紹了其中的優缺點,然後引出了長輪詢,並且在本地簡單模擬了長輪詢,最後重點介紹了RocketMQ中是如何實現的長輪詢。

示例程式碼地址

Github Gitee

相關文章