性能文章>记一次由 MQ SDK 太简陋引发的生产事故>

记一次由 MQ SDK 太简陋引发的生产事故原创

https://a.perfma.net/img/2521381
5年前
1080847

本文正在参加「Java应用线上问题排查经验/工具分享」活动

背景

由于公司所有系统都在云上,所以服务器/数据库/中间件啥的用的都是某云服务商的产品,包括 MQ/Redis 等。
​

某周边小系统在流程改造过程中,引入了该云服务商的 MQ 产品,通过 MQ 处理一些异步的场景。
​
经过一阵子的改造后,总于成功上线了。可好景不长,第二天这个系统就出事了……

问题描述

查看日志和监控发现,虽然报错那会有一定的调用量,但是量并不大啊,并发能有10个就了不得了。日志里还有多种错误信息(删除了详细的敏感错误信息)……

  • java.lang.IllegalStateException: Already connected
  • at sun.net.www.protocol.http.HttpURLConnection.setRequestProperty(HttpURLConnection.java:3132)
  • at sun.net.www.protocol.https.HttpsURLConnectionImpl.setRequestProperty(HttpsURLConnectionImpl.java:330)
  •  
  • java.net.ProtocolException: cannot write to a URLConnection if doOutput=false - call setDoOutput(true)
  • at sun.net.www.protocol.http.HttpURLConnection.getOutputStream0(HttpURLConnection.java:1322)
  • at sun.net.www.protocol.http.HttpURLConnection.getOutputStream(HttpURLConnection.java:1315)
  • at sun.net.www.protocol.https.HttpsURLConnectionImpl.getOutputStream(HttpsURLConnectionImpl.java:264)
  •  
  • java.lang.IllegalStateException: connect in progress
  • at sun.net.www.protocol.http.HttpURLConnection.setRequestMethod(HttpURLConnection.java:551)
  • at sun.net.www.protocol.https.HttpsURLConnectionImpl.setRequestMethod(HttpsURLConnectionImpl.java:388)
  •  
  • java.io.IOException: stream is closed
  • at sun.net.www.protocol.http.HttpURLConnection$HttpInputStream.ensureOpen(HttpURLConnection.java:3427)
  • at sun.net.www.protocol.http.HttpURLConnection$HttpInputStream.read(HttpURLConnection.java:3452)
  •  
  •  

我看了眼详细的 StackTrace,发现都是这个 MQ SDK 相关的代码报错,虽然错误信息分了好几种,但结合起来看,都是网络/报文类的错误。

那问题就简单了,肯定是在 和 MQ Server 交互时出现的问题。

不过既然功能测试阶段没有出现这个问题,那么说明单线程下,程序是没问题的,多线程请求时才会报错。

先猜测一下,可能的原因有:

  1. MQ Server 的问题
  2. 客户端 -> MQ 网络的问题
  3. 程序问题

对于原因 1/2 ,可能性不大,因为功能其他环境也在正常使用,而且也联系了网络组的同事,确认网络一切正常,不太可能是这个导致的。所以只剩下原因3 - 程序问题了。
​
既然有了分析的方向,那就先从代码下手,看看和 MQ 交互的代码是怎么写的,有没有什么骚操作。

在这个系统的代码里,封装了一个和 MQ 交互的类,里面有一些基本的操作,发送消息/接收消息之类的方法(伪代码):

  • // MQ SDK API
  • private Account account = new Account(endpoint,secretId, secretKey);
  •  
  • public void sendMsg(String msg){
  • // MQ SDK API
  • Queue queue = account.getQueue("queue-test10");
  •  
  • String msgId = queue.sendMessage("hello world,this is xxxxxmq sdk for java");
  • }
  •  
  •  

看到这个类的时候,我是有点懵逼的,这个 SDK 也太太太太简陋了吧……简陋的就像一个 Demo

image.png

不过简陋,也不见得是坏事嘛,功能好使就行。
​

我接着往下翻代码,既然是交互请求那里的问题,那就找到它发送 HTTP 请求的地方,一探究竟
​

中间的代码比较无聊,这里就不贴了,无非是一些校验/拼报文/签名之类的逻辑。

image.png

找了一会,终于到了最关键的地方,一个 HTTP 工具类,这也是这个 MQ SDK 发送 HTTP 请求的唯一类:

  • private URLConnection connection;
  • private String url ;
  •  
  • private void newHttpConnection(String url) throws Exception {
  •  
  • // 这里如果 url 相同,每次还使用相同连接,默认 POST 请求方式下,全局都会用同一个连接,永远不会新建
  • if(this.url != url)
  • {
  • URL realUrl = new URL(url);
  • if(url.toLowerCase().startsWith("https")){
  • HttpsURLConnection httpsConn = (HttpsURLConnection)realUrl.openConnection();
  • httpsConn.setHostnameVerifier(new HostnameVerifier(){
  • public boolean verify(String hostname, SSLSession session){
  • return true;
  • }
  • });
  • connection = httpsConn;
  • }
  • else{
  • connection = realUrl.openConnection();
  • }
  • this.connection.setRequestProperty("Accept", "*/*");
  • if(this.isKeepAlive)
  • this.connection.setRequestProperty("Connection", "Keep-Alive");
  • this.connection.setRequestProperty("User-Agent",
  • "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)");
  •  
  • this.url = url ;
  • }
  • }
  •  
  • // 发送请求
  • public String request(String method, String url, String req,
  • int userTimeout) throws Exception {
  • String result = "";
  • BufferedReader in = null;
  • try{
  • // if (!this.url.equals(url))
  • this.newHttpConnection(url);
  •  
  • this.connection.setConnectTimeout(timeout+userTimeout);
  • this.connection.setReadTimeout(timeout+userTimeout);
  •  
  • if (method.equals("POST")) {
  • ((HttpURLConnection)this.connection).setRequestMethod("POST");
  •  
  • this.connection.setDoOutput(true);
  • this.connection.setDoInput(true);
  • DataOutputStream out = new DataOutputStream(this.connection.getOutputStream());
  • out.writeBytes(req);
  • out.flush();
  • out.close();
  • }
  •  
  • this.connection.connect();
  • int status = ((HttpURLConnection)this.connection).getResponseCode();
  • if(status != 200)
  • throw new ServerException(status);
  •  
  • in = new BufferedReader(new InputStreamReader(connection.getInputStream(),"utf-8"));
  •  
  • String line;
  • while ((line = in.readLine()) != null) {
  • result += line;
  • }
  • }catch(Exception e){
  • throw e;
  • }finally{
  • try {
  • if (in != null)
  • in.close();
  • } catch (Exception e2) {
  • throw e2;
  • }
  • }
  •  
  • return result;
  • }
  •  

我本以为入口设计就够简单(陋)了,没想到这个 Http 工具类竟然更简陋……直接拿 JDK HTTP API 就开始用了。
​

而且!这个工具类是单例的,全局一份,也就是说所有的线程都会用这个类来发送请求,但这个类里把URLConnection 维护成一个成员变量,而且每次请求都会使用这个 URLConnection,永远不会更换……

image.png

这个设计也太秀了,写出这个代码的人可以被拉出去打了,这可不是不小心留下的 Bug,完全是设计错误。
​

URLConnection 背后的 Stream ,它还是 JDK 的 Socket Stream,多线程读写一个 Stream,肯定报错啊。抛开其他的不谈,就光这个 DataOutputStream 的 write 方法,还是按字节 write……

  • public final void writeBytes(String s) throws IOException {
  • int len = s.length();
  • for (int i = 0 ; i < len ; i++) {
  • out.write((byte)s.charAt(i));
  • }
  • incCount(len);
  • }
  •  

多线程发送请求时,这个 for 循环可就有意思了,直接导致多线程交替 write ,这个时候报文肯定全乱套了……
​

可能有些读者(大佬)们会说,你 New 一个不就没这问题了吗?
​

这个……结合上面 Account 那个模型,每次还 New 一个 Accnout,然后 getQueue,再 sendMsg……这一套写下来会不会被人拖出去打?
​

而且,这套简陋的 HTTP client,它就能发送个请求,连基本的故障恢复都做不到,万一连接断了,整个服务的 MQ 交互可就都挂了……
​

终于吐槽完了,既然已经发现了问题所在,下面来说说解决方案。

解决方案

由于用的是云服务商提供的产品,SDK 也是人家的。出现问题后,第一时间肯定是先和云服务商沟通,看他们有没有现成的解决方案。
​

在和云服务上沟通后得知,目前这个 SDK 并没有新版本……
​

那没办法,既然人家不改,只有自己解决了,MQ 还是用人家的 MQ 服务,只要把 这个 SDK 的问题修复一下就行。

image.png

这个问题解决也很简单,问题的本质是多线程共享了 URLConnection 对象导致的数据混乱问题,那我让他线程安全就好了嘛,多简单的事。
​

解决线程安全问题一般也就几个思路:

  1. 不共享,每个线程独享一份(比如 ThreadLocal,或者每次都 New)
  2. 加锁,排队处理
  3. 维护个资源池,保证一个资源在释放前不会被其他线程获取

但这里并不需要自己在来重新整一套线程安全的 HttpClient,Java 生态这么好,我直接用 Apache HttpClient 它不香么?
​

Apache Http Client 算是 Java (服务端)里功能最强大的 Http 库了,基本上我们能想到的功能,它都有完整的实现,而且非常灵活,所有功能都可以定制。
​

像上面提到的线程安全的资源池啊,故障恢复啊,包括 KeepAlive 连接复用啥的它都有,而且基本可以开箱即用。
​

说干就干,直接把这个类的实现稍加改动,换成 Apache HttpClient 来处理 Http 请求。

打完收工,改完之后,先是自己本地跑了点并发测试,没有任何报错,已发送的消息数量也一切正常。
​
至此,问题解决。

总结

虽然问题解决了,但还是忍不住吐槽一下这个 SDK。作为一个商业产品来说,SDK 写成这个样子实在说不过去了。不过好在这个 MQ 服务他足够便宜,功能上也还可以,这种小细节也就不太计较了……

想编写一个合格的 SDK ,并不是一件很容易的事。需要良好的抽象能力,将功能/业务模型高度抽象出来,然后用代码实现。而且 SDK 是给别人用的,在代码质量、风格上也有较高的要求,随便弄个 “Demo”可不行

点赞收藏
分类:标签:
空无

坚持原创,专注分享 JAVA、网络、IO、JVM、GC 等技术干货

请先登录,查看4条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
7
4