使用new ClassPathXmlApplicationContext()获取上下文,导致onApplicationEvent()多次执行

记一次生产中的aliyun-iot连接中断的故障问题

1. 业务场景描述

我们做的业务是一套软硬件结合的通信光缆智能化巡检方案。无人机设备在空中自主巡航作业时,会将飞控中采集的心跳信息实时推送到IOT平台,然后服务端与IOT平台建立连接,订阅无人机推送的心跳数据,并转发到客户端(APP、WEB巡检平台)在地图界面动态呈现。

以此实现通过软件平台监控作业的目的。

当然,巡检平台还会展示一些无人机巡检作业途中采集的其他数据,这里就不详细介绍了。

无人机心跳的通信流程图如下:
image

2. 故障描述

某一天,运营团队反馈在APP和WEB平台的地图界面上,很长一段时间看不到正在执行作业的无人机图标。通过查阅线上后台日志发现,打印了如下报错信息:
image

这个故障,导致无人机巡检超出可视范围或地面站监控范围,除了机身上额外安装的GPS装置,基本无法监控无人机在空中的位置和状态,十分危险。虽然无人机正常情况下会按照飞前下发的航线自主飞行。我们一个架次会在空中巡航180KM左右,大致花费时间1小时40分钟左右。

在排查故障的过程中,联系到现场,无人机已经结束作业,正常降落。

3. 故障排查,分析

1. 查看,分析报错日志 👩‍💻

由于服务端对接阿里云IOT平台,代码是基于官网提供的Java SDK接入示例代码编写的。根据日志,定位到程序逻辑进入了JMS连接监听器的 连接中断 方法中了。除此之外,再看不到任何有效的报错信息。

image

基于之前的经验,现场作业过程中,有时也会有连接中断的问题,但是一般都是出在设备端。我们在无人机机舱中装载了自主研发的网联通讯模组,提供4G网络,实现无人机与IOT平台的网络连接,并将飞控中的实时心跳数据通过4G网络推送到IOT平台。但是,由于空中作业,网络信号有时会很不稳定,导致短时间的断联。网络信号恢复后,也会及时重连上,并补传心跳消息。

这种由于设备端网络抖动引起的断联,会导致IOT平台和服务端都接收不到心跳消息。

而目前的故障是:无人机正常将心跳消息推送到IOT平台上了,但是服务端未订阅到心跳消息。也就是说,下游IOT与服务端的连接断开了。而我们的服务端项目是部署在稳定的机房服务器中的,网络也是通畅的,与IOT平台的网络连接应该是十分稳定的。所以跟研发同事一起分析了很久,没找到原因所在。

2. 提工单,咨询阿里云技术人员 📧

实在摸不出问题原因,只能提工单,问技术人员了。不过,对于我这种小菜鸡,详细地描述出自己的问题,让阿里工程师理解,还真不容易。通过咨询,确认导致进入连接中断的原因,百分之八九十是 AMQP消费客户端的连接数超过了IOT平台设置的上限64。技术人员说,如果连接数超过64了,就会自动中断连接。
image

之后,我们进入了IOT实例,查看对应的消费组中在线客户端数,确实是到上限了:
image

技术工程师回复的一句话点到我了:
image

3. 继续研究代码,排查问题 👩‍💻

于是赶紧去看代码,看初始化消费组,创建AMQP消费端的逻辑。核心代码片段如下:

// 指定单个进程启动的连接数
// 单个连接消费速率有限,请参考使用限制,最大64个连接
// 连接数和消费速率及rebalance相关,建议每500QPS增加一个连接
private static int connectionCount = 1;

@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
	...
		for (int i = 0; i < connectionCount; i++) {
			long timeStamp = System.currentTimeMillis();
			//签名方法:支持hmacmd5,hmacsha1和hmacsha256
			String signMethod = "hmacsha1";
			//控制台服务端订阅中消费组状态页客户端ID一栏将显示clientId参数。
			//建议使用机器UUID、MAC地址、IP等唯一标识等作为clientId。便于您区分识别不同的客户端。
			String clientId = WebSocketIP;
			//UserName组装
			String userName = clientId + "-" + i + "|authMode=aksign"
				+ ",signMethod=" + signMethod
				+ ",timestamp=" + timeStamp
				+ ",authId=" + accessKey
				+ ",iotInstanceId=" + iotInstanceId
				+ ",consumerGroupId=" + consumerGroupId
				+ "|";
			//password组装
			String signContent = "authId=" + accessKey + "&timestamp=" + timeStamp;
			String password = doSign(signContent, accessSecret, signMethod);
			//按照qpid-jms的规范,组装连接URL。
			String connectionUrl = "failover:(amqps://" + uid + ".iot-amqp." + regionId + ".aliyuncs.com:5671?amqp.idleTimeout=80000)"
				+ "?failover.reconnectDelay=30";
			Hashtable<String, String> hashtable = new Hashtable<>();
			hashtable.put("connectionfactory.SBCF", connectionUrl);
			hashtable.put("queue.QUEUE", "default");
			hashtable.put(Context.INITIAL_CONTEXT_FACTORY, "org.apache.qpid.jms.jndi.JmsInitialContextFactory");
			Context context = new InitialContext(hashtable);
			ConnectionFactory cf = (ConnectionFactory) context.lookup("SBCF");
			Destination queue = (Destination) context.lookup("QUEUE");
			// Create Connection
			Connection connection = cf.createConnection(userName, password);
			connections.add(connection);
			((JmsConnection) connection).addConnectionListener(myJmsConnectionListener);
			// Create Session
			// Session.CLIENT_ACKNOWLEDGE: 收到消息后,需要手动调用message.acknowledge()
			// Session.AUTO_ACKNOWLEDGE: SDK自动ACK(推荐)
			Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
			connection.start();
			// 创建接收消息的连接
			MessageConsumer consumer = session.createConsumer(queue);
			consumer.setMessageListener(messageListener);
		}
	...
	}

根据代码逻辑分析,初始化消费组实例后,只会初始化一个AMQP客户端,但是IOT管理平台上却看到了60多个AMQP客户端,确实很奇怪。之后分析到这段初始化的逻辑是在 onApplicationEvent() 方法中执行的,这个东西有点眼熟,百度了一下,opApplicationEvent()方法是一般在Spring容器启动后触发,而且经常会有执行两次的情况。一般在业务开发中,创建一个自定义的监听器,实现了Spring的事件监听器ApplicationListener< ContextRefreshedEvent >,重写onApplicationEvent()方法,在里面编写一些Spring容器初始化完毕后执行的操作。

当然,在我们的业务场景中期望是只在容器启动完毕后,执行一次 “初始化消费组实例后,初始化一个AMQP客户端” 的操作。但是,很不幸,它执行了很多次。之后继续查阅线上的后台日志,在报错信息的上下仔细分析,最终是发现了问题所在。

服务端创建的这个无人机心跳监听器的类,会连接IOT,并监听当前服务器上的多个网联设备,也就是说,如果同时有多架无人机执行作业,那么监听器会通过线程池处理多个设备的心跳接收,以及后续业务。其中有一处业务逻辑是:当无人机执行完任务,降落后,会将缓存中任务相关的心跳数据一次性写入到DB中。而这里需要获取dao层的bean,同事采用new ClassPathXmlApplicationContext()的方式获取上下文来加载bean。

image

这种方式获取bean会带来一个问题:会重新加载配置文件,而applicationContext.xml中又加载了application.properties配置文件,当前监听器类中正好通过@Value注解读取了application.properties配置文件中的某几项数据。这样会导致线程每执行一次加载上下文的代码,都会触发onApplicationEvent()的执行,进而导致消费组中新增一个AMQP客户端连接。现场作业频次高的话,一天可能有3架无人机同时作业,每架无人机平均一天作业3个架次。一天就差不多会增加9个客户端连接,差不多作业一个星期就会到达64的上限,我丢!!!😂。通过运营人员对前期故障出现频次的统计,基本吻合,最后对代码进行了调整。重新上线后,再也没出现之前的故障了。

代码中两处地方做了调整:

// 1. onApplicationEvent()中执行初始化消费组和客户端连接的代码加了一层判断
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
	if (null == event.getApplicationContext().getParent()) {
		// 初始化消费组和客户端连接的逻辑
	}
}

// 2.获取dao层bean的位置做了调整,直接在写入DB逻辑的外层通过@AutoWired注入
@Autowired
private TaskMapper taskMapper;

4. 总结

这个故障的排查时间算是比较长的,故障原因是代码本身的问题,而且是一个比较基础的框架上的问题,最后还好及时修复了故障,在排查期间,靠重启web服务临时对付你敢信🤭

也总结了一些分析问题,解决问题的思路:

  • 基于运营经验,对常见的诱因进行过滤
  • 分析后台日志中的报错信息,定位bug
  • 梳理业务逻辑,分析可能导致bug的上游逻辑
  • 自己死磕无效时,及时与团队成员沟通,或提工单咨询(对于应用到了第三方服务的)
  • 多记录,多复盘

这次排错的过程中,也体会到一点:日常工作中,除了对付工作,也要巩固基础,深究原理,要持续学习。

5. 附录

对于onApplicationEvent()和new ClassPathXmlApplicationContext()的问题,提供两篇博文,供大家参考:

posted @ 2022-07-28 17:51  paopaolx  阅读(350)  评论(0编辑  收藏  举报