昨天去巡檢線上環境的時候,偶然發現了某個服務報了一個錯誤,而且是每隔90秒報一次,錯誤信息如下:
意思是內部錯誤,沒有新的messageid可以使用了。
消息隊列就不多說了。正常的情況就是一個消息會有一個消息id,如果不瞭解mqtt的消息id的話,我們正常人的思維就是這個消息id是個隨機數,因爲消息的id是int類型,所以最大值是2^31-1,大概是21億,對於一個龐大的系統而且是持續運行的系統,消息肯定會滿天飛,數量級可能是百億甚至千億,那這個id能裝得下麼。結合着這個疑問和這個報錯,捋了一下這塊的代碼。
對於消息隊列來講,就是生產者、消費者兩個角色。 生產者很簡單,生產消息,簡單的可以理解爲建立連接、發送消息、關閉連接。 消費者是監聽某個隊列,如果有消息就去接收,並做處理,那麼對於消費者來講就是一個持續連接的狀態。
下面先貼下僞代碼:
1、相關依賴:
<!--lmq-mqtt-->
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>com.aliyun.openservices</groupId>
<artifactId>ons-client</artifactId>
<version>1.3.2.Final</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.32</version>
</dependency>
2、消費者,採用的是異步監聽的方式
@Component
public class MQTTAsyncClientFactory implements ApplicationRunner {
private static MqttAsyncClient mqttAsyncClient;
private MQTTAsyncClientFactory() {
}
public static MqttAsyncClient getMqttAsyncProducerClient() throws MqttException {
return mqttAsyncClient;
}
@Override
public void run(ApplicationArguments args) throws Exception {
LMQProperties lmqProperties = LMQProperties.getInstance();
String clientId = lmqProperties.getGroupId() + "@@@" + lmqProperties.getDeviceId() + InetAddress.getLocalHost().getHostAddress().toString().replaceAll("\\.", "");
final MemoryPersistence memoryPersistence = new MemoryPersistence();
mqttAsyncClient = new MqttAsyncClient(lmqProperties.getBrokerUrl(), clientId, memoryPersistence);
MqttConnectOptions connOpts = new MqttConnectOptions();
connOpts.setUserName(lmqProperties.getUserName());
connOpts.setPassword(lmqProperties.getPassWord().toCharArray());
connOpts.setCleanSession(lmqProperties.getCleanSession());
connOpts.setKeepAliveInterval(10);
connOpts.setAutomaticReconnect(true);
connOpts.setMaxInflight(lmqProperties.getMaxInflight());
mqttAsyncClient.setCallback(new MqttCallbackExtended() {
@Override
public void connectComplete(boolean reconnect, String serverURI) {
try {
//訂閱Topic,可以訂閱多個
final String topicFilter[] = {lmqProperties.getTopic() + "/" + lmqProperties.getRecieveTopic2() + "/" + lmqProperties.getTopic3()};
//設置QoS級別
final int[] qos = {lmqProperties.getQos()};
Thread.sleep(2000);
System.out.println("連接成功,訂閱消息");
IMqttToken subscribe = mqttAsyncClient.subscribe(topicFilter, qos);
System.out.println("消息ID:"+subscribe.getMessageId()+" Thread="+Thread.currentThread().getName());
} catch (MqttException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public void connectionLost(Throwable throwable) {
throwable.printStackTrace();
}
@Override
public void messageArrived(String s, MqttMessage mqttMessage) throws Exception {
//處理pos返回消息
rececive(new String(mqttMessage.getPayload()));
}
@Override
public void deliveryComplete(IMqttDeliveryToken iMqttDeliveryToken) {
//this notice make sense when qos >0
int serialid = iMqttDeliveryToken.getMessageId();
System.out.println("消息id是"+serialid+" Thread="+Thread.currentThread().getName());
}
});
mqttAsyncClient.connect(connOpts).waitForCompletion();
System.out.println("mqtt連接成功");
}
public static boolean rececive(String msg){
//TODO 處理pos返回消息
return true;
}
}
消費者其實實現起來也很簡單,就是實現接口
ApplicationRunner,並且把類註冊到spring容器中就行了。
ApplicationRunner接口很簡單:
public interface ApplicationRunner {
/**
* Callback used to run the bean.
* @param args incoming application arguments
* @throws Exception on error
*/
void run(ApplicationArguments args) throws Exception;
}
這個接口的註釋說的也很明確:
Interface used to indicate that a bean should <em>run</em> when it is contained within a {@link SpringApplication}.
Multiple {@link ApplicationRunner} beans can be defined within the same application context and
can be ordered using the {@link Ordered} interface or {@link Order @Order} annotation.
意思是隻要你這個bean實現了這個接口,並且包含在spring容器中,就會執行實現的run方法。多個bean可以在同一個spring上下文中,並且可以通過order接口或註解來進行指定執行順序。
run方法會在spring容器啓動之後進行執行,
這裏面有一個對象MqttAsyncClient,是在spring容器啓動之後進行實例化而且實例化了一次。
正常同步的話,我們可以在
mqttAsyncClient.connect(connOpts)之後,進行訂閱
mqttAsyncClient.subscribe(topicFilter, qos);
但是在這個demo裏面,我們是在連接成功之後,回調確認連接成功的時候進行的監聽,應該是一樣的。因爲確認連接的過程實際上也是發送了一條消息。
LMQProperties這個類是一個單例,封裝了mqtt連接的信息,具體配置的值我已經去掉了。
@Component
public class LMQProperties {
//服務地址
private String brokerUrl;
//一級topic
private String topic;
//二級發送消息topic 商戶ID_標識符 標識符:用1/0標記,1:消息從雲平臺到POS,0:消息從POS到雲平臺
private String sendTopic2;
//二級接收消息topic
private String recieveTopic2;
//三級topic
private String topic3;
//服務系統用戶名
private String userName;
//
private String passWord;
//客戶端配置分組id 在控制檯創建後使用
private String groupId;
//Device ID: 每個設備獨一無二的標識,由業務方自己指定。需要保證全局唯一,例如每個傳感器設備的序列號。
//與groupId 一起生成 clientID = groupId+@@@+deviceId
private String deviceId;
//QoS級別
private Integer qos;
//設置客戶端是否使用持久化模式
private Boolean cleanSession;
//允許未能及時收到broker回覆的ack時的MQ消息最大數量
private int maxInflight;
//老版本隊列的企業號白名單
private String companyIdsWhite;
private LMQProperties(){
brokerUrl = "";
topic = "";
sendTopic2 = "test";
recieveTopic2 = "";
topic3 = "";
userName="";
passWord="";
groupId="";
deviceId="";
qos=1;
cleanSession= false;
maxInflight=100000;
}
private static LMQProperties INSTANCE = new LMQProperties();
public static LMQProperties getInstance(){
return INSTANCE;
}
public String getBrokerUrl() {
return brokerUrl;
}
public void setBrokerUrl(String brokerUrl) {
this.brokerUrl = brokerUrl;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public String getSendTopic2() {
return sendTopic2;
}
public void setSendTopic2(String sendTopic2) {
this.sendTopic2 = sendTopic2;
}
public String getRecieveTopic2() {
return recieveTopic2;
}
public void setRecieveTopic2(String recieveTopic2) {
this.recieveTopic2 = recieveTopic2;
}
public String getTopic3() {
return topic3;
}
public void setTopic3(String topic3) {
this.topic3 = topic3;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassWord() {
return passWord;
}
public void setPassWord(String passWord) {
this.passWord = passWord;
}
public String getGroupId() {
return groupId;
}
public void setGroupId(String groupId) {
this.groupId = groupId;
}
public String getDeviceId() {
return deviceId;
}
public void setDeviceId(String deviceId) {
this.deviceId = deviceId;
}
public Integer getQos() {
return qos;
}
public void setQos(Integer qos) {
this.qos = qos;
}
public Boolean getCleanSession() {
return cleanSession;
}
public void setCleanSession(Boolean cleanSession) {
this.cleanSession = cleanSession;
}
public int getMaxInflight() {
return maxInflight;
}
public void setMaxInflight(int maxInflight) {
this.maxInflight = maxInflight;
}
public String getCompanyIdsWhite() {
return companyIdsWhite;
}
public void setCompanyIdsWhite(String companyIdsWhite) {
this.companyIdsWhite = companyIdsWhite;
}
3、生產者
public static void publishMsg(int cnt) throws Exception {
MqttAsyncClient mqttClient = MQTTAsyncClientFactory.getMqttAsyncProducerClient();
LMQProperties lmqProperties = LMQProperties.getInstance();
//拼接topic
final String mqttSendTopic = lmqProperties.getTopic() + "/" + lmqProperties.getRecieveTopic2() + "/" + lmqProperties.getTopic3();
MqttMessage message = new MqttMessage("我是消息內容".getBytes());
//設置QoS級別
message.setQos(lmqProperties.getQos());
mqttClient.publish(mqttSendTopic, message);
if(cnt%1000==0)
System.out.println("這是第"+cnt+"次發送消息");
}
生產者很簡單,就是執行一下publish就可以了。
消息想產生一定是會在發消息的時候生成,所以接下來就可以跟下publish方法是怎麼生成消息id的。
publish調用的是
ClientComms的sendNoWait,
最終調用的是ClientState的send方法
getNextMessageId()方法就是生成新消息id的方法:
private synchronized int getNextMessageId() throws MqttException {
int startingMessageId = nextMsgId;
// Allow two complete passes of the message ID range. This gives
// any asynchronous releases a chance to occur
int loopCount = 0;
do {
nextMsgId++;
if ( nextMsgId > MAX_MSG_ID ) {
nextMsgId = MIN_MSG_ID;
}
if (nextMsgId == startingMessageId) {
loopCount++;
if (loopCount == 2) {
throw ExceptionHelper.createMqttException(MqttException.REASON_CODE_NO_MESSAGE_IDS_AVAILABLE);
}
}
} while( inUseMsgIds.containsKey( new Integer(nextMsgId) ) );
Integer id = new Integer(nextMsgId);
inUseMsgIds.put(id, id);
return nextMsgId;
}
這個方法是個同步方法,說的就是給消息獲取新的消息id,並且把它存到一個已經在使用的集合裏面作爲標記。
而這個方法裏面的這個異常便是我們的那個異常了
在這個方法裏面,涉及到這幾個變量:
nextMsgId默認是0, 最小消息id是1,最大消息id是65535。inUseMsgIds這個標記已經使用的消息id的集合是個hashtable。
這個方法大致解釋一下,就是進入這個方法之後,會將當前nextMsgId的值存到startingMessageId這個變量上。
定義一個循環數量的變量loopCount,如果循環了達到兩次,就拋出那個異常。
進入循環的判斷條件就是nextMsgId在inUseMsgIds的集合裏面。
循環裏面,如果nextMsgId大於最大消息id的時候,會被設置爲最小消息id。 所以從這裏可以看出,這裏的消息id是循環重複使用的。那麼什麼情況下會出現那個異常呢,就是當從1~65535的消息id都放到了inUseMsgIds裏面,並且nextMsgId達到了最大值65535的時候,nextMsgId會被重置爲1,會重新再跑一遍1~65535的while循環,就會報那個錯了。
inUseMsgIds是記錄已經使用的消息id。那麼他裏面的內容什麼時候被清理的呢,ClientState裏面提供了
clearState(),close()方法,進行inUseMsgIds.clear()。 而clearState()是在
shutdownConnection的時候執行的。 也就是說在mqtt的client失去連接或者關閉的時候,就會清理inUseMsgIds。
如果只有這個機制才能清理,那麼如果一直不失去連接或者不關閉連接,豈不是就會報那個錯誤,設計一定不會這麼傻。所以ClientState還提供了以下方法:
private synchronized void releaseMessageId(int msgId) { inUseMsgIds.remove(new Integer(msgId)); }
這個方法總共有三個調用者:
CommsSender,CommsReceiver,CommsCallback
CommsSender在發送成功之後,會調用ClientState.notifySend()方法,進而調用releaseMssageId()。
CommsReceiver在收到ack的消息的時候,會調用ClientState.notifyReceivedAck()方法,進而調用releaseMssageId()。
CommsCallback當等待者和回調處理完消息後調用,會調用ClientState.notifyComplete()方法,進而調用releaseMssageId()。
所以正常情況下,只要消息發送成功,消息成功消費,基本就不存在報那個錯。因爲消息的id產生了,只要不用了就會被釋放。
所以非常遺憾,線上服務重啓後就沒有再出現這個問題,所以也沒有排查到當時爲啥會出現那個錯誤。