项目中redis改brpop阻塞模式为订阅模式的实现(二)
更改项目需求以及项目之前阻塞模式问题的叙述已经在上一篇说过了,详情可参考:https://www.cnblogs.com/darope/p/10276213.html文章的介绍。
关于Agent数据采集相关内容介绍可以参考华中科技大学的这篇硕士论文,说的比较详细:http://www.docin.com/p-131767044.html 。
一,关于brpop为什么要更改,这里简单分析一下原版本的阻塞代码。
1 @Override 2 public void readyForControl(Service.ControlRequest request, StreamObserver<Service.ControlResponse> responseObserver) { 3 byte[] uuidByte = request.getH().getId().toByteArray(); 4 JUUID juuid = new JUUID(uuidByte); 5 String uuid = juuid.toString(); 6 logger.info("readyForControl uuid: " + uuid); 7 // agent上线 8 Long onlineTime = System.currentTimeMillis(); 9 redisService.set(ONLINE_PREFIX + uuid, String.valueOf(System.currentTimeMillis())); 10 onlineAgent(uuid); 11 12 while (true) { 13 try { 14 //暂时没有更好的办法处理,降低两个while同时守护任务redis的可能性 15 if (needBreak(uuid, onlineTime)) { 16 break; 17 } 18 List<Task> tasks = taskRedisMap.brpop(uuid); 19 if (Objects.isNull(tasks)) { 20 continue; 21 } 22 for(Task task : tasks) { 23 //agent 重启后丢失一个任务;老的rpc通道收到任务放回机制 24 if (needBreak(uuid, onlineTime)) { 25 taskRedisMap.pushTask(task); 26 continue; 27 } 28 logger.info("task get uuid: " + uuid + " nodeId: " + task.getNodeId()); 29 30 Service.ControlResponse.ControlCmd controlCmd = Service.ControlResponse.ControlCmd.forNumber(task.getTaskType()); 31 Service.ControlResponse response = null; 32 assert controlCmd != null; 33 // 根据任务类型分配任务 34 response = getControlResponseOption(task, controlCmd, null); 35 logger.info("cmd: " + controlCmd + " nodeId " + task.getNodeId()); 36 if (Objects.isNull(response)) { 37 logger.info("empty response. nodeId " + task.getNodeId()); 38 return; 39 } 40 41 // 通知业务调用方 42 readyForControlEvent(task); 43 logger.info("readyForControlEvent"); 44 task.setTaskStatus(TaskStatusEnum.BUSY); 45 task.setStartExecTimeout(System.currentTimeMillis()); 46 task.setReceiveEvent(true); 47 taskRedisMap.update(task); 48 logger.info("onNext ..."); 49 responseObserver.onNext(response); 50 logger.info("onNext OK..."); 51 } 52 } catch (Throwable e) { 53 logger.error("readyForControl异常, uuid={}", e, uuid); 54 } 55 } 56 }
客户端在服务端注册好自己传送过来的数据后,调用readyForControl,请求服务端下发命令,有几个agent客户端主机,就会调用几次。相同agent再次上线这里就会出现一个很大的问题,原来的agent没有下线,相同的agent再次上线,这里会再次调用readyForControl。意味着相同的agent调用了两次,而且新上线的agent后调用readyForControl。如果采用brpop的方式,意味着一开始上线的agent调用readyForControl已经拿走了消息列队的task任务,后来的只能拿不到,空指针异常。这里采用了一个不是办法的办法,就是写一个死循环,监听agent上线动作,比对一下,如果这个agent是后来上线的,就会break掉,杜绝了异常的发生。但是这个操作会显得很臃肿,而且效率不太好。
二,更改为订阅模式或许会解决以上问题,原因如下:
a. readyForControl中,只有一个订阅方法,简洁很多
b. 不需要判断是不是相同agent上线的问题,虽然新上线的agent跟之前的agent是同一个agent,但是跟redis的发布订阅模式不冲突,老的agent也会订阅到消息,新的agent也会订阅到消息。避免了一个大的用于判断agent新旧问题的死循环。
c. 效率更高,redis底层是c语言实现的,借助redis的机制来解决问题,往往比自己实现逻辑来解决问题,从本质上看来要可取。
三,更改的过程中遇到的坑:
很遗憾,很多坑是我想当然的以为造成的,并没有严谨的考虑软件工程的思想以及大型程序运行的理论情形。对此只会让我以为我还有很多的东西要学,现在的出错,只是为了记忆更深刻吧。下面由浅入深做简单总结:
a. 从简单订阅模式,到多线程订阅模式。
订阅模式本身是redis自带的方法,但是订阅模式是恒阻塞的,一旦进入订阅的方法,就会一直监听发布方是否发布了消息,导致监听阻塞,无法使调用方程序顺序执行。虽然订阅方法父类有onMessage方法可以终止订阅,但是不满足需要监听agent上线的逻辑策略。对此需要增加多线程实现,把订阅方法写到线程空间中去。
1 @Override 2 public void readyForControl(Service.ControlRequest request, StreamObserver<Service.ControlResponse> responseObserver) { 3 byte[] uuidByte = request.getH().getId().toByteArray(); 4 JUUID juuid = new JUUID(uuidByte); 5 String uuid = juuid.toString(); 6 Long agentId = taskRedisMap.getIdByUuid(uuid); 7 // 调用订阅者线程 8 SubThread subThread; 9 subThread = new SubThread(redisService.getJedisPool(), agentId, responseObserver, taskRedisMap, applicationContext); 10 subThread.start(); 11 logger.info("readyForControl uuid: " + uuid); 12 13 // agent上线 14 redisService.set(ONLINE_PREFIX + uuid, String.valueOf(System.currentTimeMillis())); 15 onlineAgent(uuid); 16 }
更改之后的代码采用多线程开启订阅方法,删除死循环维护agent上线的问题。当多agent上线时,会为每一个agent客户端开启一个属于自己的订阅方法,由于brpop方式采用的是uuid转化为agentId对比任务agentId的方式,以此来保证任务下发的准确性,我就把频道更改为uuid,保证了任务下发的准确性。
b.从专用频道订阅模式,到通用频道订阅模式。
企业级项目必须考虑到资源的损耗和浪费情况,如果每一个上线agent客户端均使用专用频道,会增加redis的负荷,严重会让redis睡觉。如此看来为每一个agent开一个以agent的id相关的字符串为该agent的通道的话,是绝对不可取的。在师兄的引导下,为此我折腾了一个下午,目的就是不采用专用通道,采用通用通道,即所有任务shell的发布和订阅都在一个频道,是谁的谁自己来领取。但是怎么领取,最后我通过把uuid传到订阅线程中,从onMessage中转化为任务序列对比发布中的任务序列号,取到我需要的task然后return到调用方。看起来还不错,我比较满意。
c.程序运行并不满足预期
如我所想,数据我是拿到了,接着我在readyForControl调用这个线程后,取到agentId对应的所有任务列表,这样我就可以使用这个任务列表onNext到客户端啦,像下面这样:
1 // 通知业务调用方 2 readyForControlEvent(task); 3 logger.info("readyForControlEvent"); 4 task.setTaskStatus(TaskStatusEnum.BUSY); 5 task.setStartExecTimeout(System.currentTimeMillis()); 6 task.setReceiveEvent(true); 7 //不通 8 taskRedisMap.update(task); 9 logger.info("onNext ..."); 10 responseObserver.onNext(response); 11 logger.info("onNext OK...");
但是下面的方法是取不到我的task任务列表的所有数据的,原因是,当我进入到我的线程后,我执行订阅方法,对比我传入的uuid拿到属于该agent的一个task。然后调用这个线程的方法就会顺序执行了。线程仍然存在,只是再也没人调用了,readyForControl代码程序一旦顺序执行,就回不到调用线程的那个代码位置了。尴尬的是,理论上,我的task列表里面只会有一条task。
d.没法在readyForControl中拿到所有task的列表,我必须在线程里面单个处理,仔细想想,效率好像还提升了
逆行思维真的是很好的方式,他会使你在向左走不通的情况下会考虑向右走一走,最终走出这个死胡同。程序封装的目的在于统一处理,正常的方式是我所有task存入到我的list列表中,return到调用方,在readyForControl中统一onNext到agent客户端。线程方式这种走不通,只能把接下来所有操作task的代码传到线程中去,在线程中一个一个onNext到客户端。首先要做的是把需要用到的类实例传到线程中去,该传进去的传进去,该注入的注入到线程空间中去。然后每次收到订阅消息message,我都把这个message转化为对应agent的task最后onNext下发到客户端。看起来还不错,但是即将迎来一个大坑。
e.程序没报错,为什么线程空间中的实例,会频繁的报空指针?
代码看着已经没什么问题,逻辑上也是可行的,但测试的时候,老是空指针。查阅资料,发现Spring为了安全,禁止向线程空间中注入bean。网上的解决办法很多,我需要注入的就是两个操作task任务流的bean,所以就采用了最简单的传递参数的方式,外层先注入我需要的bean,然后当成调用线程的方法的参数。线程方使用私有变量初始化类,不采用注入的方式,然后通过构造方法拿到传进来的类实例。
f.或许你认为最不应该有问题的地方出现了问题
最终代码已经差不多可以使用了,但是偶尔会抛异常,检查了一晚上发现是jdk中操作list的问题。至今不是很明白,也希望有读到的大神给与评论原因。一开始的逻辑,在对比是不是我这个上线agent的task的时候,我采用一个if判断。在任务列表tasks不可能为空的情况下,if( 上线agentId.compareToIgnoreCase(发布方发布的Task中的agentId) != 0 ) 从tasks列表中移除这个不匹配的task,采用tasks.remove(task)的方式,else下发这个任务到客户端 ------------》 更改为if( 上线agentId.compareToIgnoreCase(发布方发布的Task中的agentId) == 0 )下发任务到客户端,else不做处理。就解决了异常问题,看似两个逻辑是一样的,或许是remove操作列表有什么需要注意的吧。
最终所有操作都在线程空间中处理,订阅线程继承的的onMessage方法中,分布对订阅到的task单独处理,肢解了圆来readyForControl的代码:
1 @Override 2 public void onMessage(String channel, String message) { //收到消息会调用 3 logger.info("收到了发布者的消息,频道为: {}, 消息为: {}", channel, message); 4 5 tasks.add(message); 6 7 key = TASK_PENDING_PREFIX + agentId; 8 9 List<Map> taskList = tasks.stream().map(k -> Json2.fromJson(k, Map.class)).collect(Collectors.toList()); 10 if (taskList.size() == 0) { 11 return; 12 } 13 // 筛选出ShellTask 14 List<ShellTask> shellTaskList = taskList.stream().filter(t -> Objects.equals(t.get("execType"), ExecScriptType.SHELL.getCode())).map(t -> Json2.fromJson(Json2.toJson(t), ShellTask.class)).collect(Collectors.toList()); 15 if (shellTaskList.size() == 0) { 16 return; 17 } 18 List<Task> task_ = shellTaskList.stream().filter(t -> Objects.equals(t.getTaskStatus(), TaskStatusEnum.NOT_OPERATED)).collect(Collectors.toList()); 19 logger.info("task list : {}", task_); 20 21 //返回携带特定uuid订阅者agent的task 22 23 for (Task task : task_) { 24 String keyPub = TASK_PENDING_PREFIX + task.getAgentId(); 25 logger.info("keyPub {}", keyPub); 26 if (key.compareToIgnoreCase(keyPub) == 0){ 27 logger.info("task get uuid: " + key + " nodeId: " + task.getNodeId()); 28 29 Service.ControlResponse.ControlCmd controlCmd = Service.ControlResponse.ControlCmd.forNumber(task.getTaskType()); 30 Service.ControlResponse response = null; 31 assert controlCmd != null; 32 // 根据任务类型分配任务 33 response = getControlResponseOption(task, controlCmd, null); 34 logger.info("cmd: " + controlCmd + " nodeId " + task.getNodeId()); 35 if (Objects.isNull(response)) { 36 logger.info("empty response. nodeId " + task.getNodeId()); 37 return; 38 } 39 40 // 通知业务调用方 41 readyForControlEvent(task); 42 logger.info("readyForControlEvent"); 43 task.setTaskStatus(TaskStatusEnum.BUSY); 44 task.setStartExecTimeout(System.currentTimeMillis()); 45 task.setReceiveEvent(true); 46 //不通 47 taskRedisMap.update(task); 48 logger.info("onNext ..."); 49 responseObserver.onNext(response); 50 logger.info("onNext OK..."); 51 } 52 } 53 }
接下来的优化策略是,判断agent上线时间,如果是相同agent再次上线,可以考虑让以前的agent下线,而非继续订阅,虽然继续订阅不会影响程序正常使用,也不需要像brpop的方式来维护消息列队中的task,但是当agent某个客户端反复上线下线,也会造成不必要的订阅资源浪费,所以程序还是需要判断哪些agent需要下线处理。
因为是实习第一阶段,自己还算个小白,很多思考不到的地方,踩了不少坑,特此记录。