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

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

https://a.perfma.net/img/2521381
11月前
558123

本文正在参加「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”可不行

分类:
标签:
请先登录,再评论

快乐的是“至此解决”的畅快感!

111月前

可能更多人会关注 “经过一阵子的改造后,总于成功上线了。” 这个交付过程?

111月前

为你推荐

字符串字面量长度是有限制的
前言 偶然在一次单元测试中写了一个非常长的字符串字面量。 正文 在一次单元测试中,我写了一个很长的字符串字面量,大概10万个字符左右,编译时,编译器给出了异常告警 `java: constant
多次字符串相加一定要用StringBuilder而不用-吗?
今天在写一个读取Java class File并进行分析的Demo时,偶然发现了下面这个场景(基于oracle jdk 1.8.0_144): ``` package test; public c
如何通过反射获得方法的真实参数名(以及扩展研究)
前段时间,在做一个小的工程时,遇到了需要通过反射获得方法真实参数名的场景,在这里我遇到了一些小小的问题,后来在部门老大的指导下,我解决了这个问题。通过解决这个问题,附带着我了解到了很多新的知识,我觉得
高吞吐、低延迟 Java 应用的 GC 优化实践
本篇原文作者是 LinkedIn 的 Swapnil Ghike,这篇文章讲述了 LinkedIn 的 Feed 产品的 GC 优化过程,虽然文章写作于 April 8, 2014,但其中的很多内容和
「每日五分钟,玩转 JVM」:久识你名,初居我心
聊聊 JVMJVM,一个熟悉又陌生的名词,从认识Java的第一天起,我们就会听到这个名字,在参加工作的前一两年,面试的时候还会经常被问到JDK,JRE,JVM这三者的区别。JVM可以说和我们是老朋友了
据说99.99%的人都会答错的类加载的问题
概述首先还是把问题抛给大家,这个问题也是我厂同学在做一个性能分析产品的时候碰到的一个问题。 同一个类加载器对象是否可以加载同一个类文件多次并且得到多个Class对象而都可以被java层使用吗请仔细注意
Java多线程——并发测试
编写并发程序时候,可以采取和串行程序相同的编程方式。唯一的难点在于,并发程序存在不确定性,这种不确定性会令程序出错的地方远比串行程序多,出现的方式也没有固定规则。那么如何在测试中,尽可能的暴露出这些问
Java多线程知识小抄集(一)
本文主要整理笔者遇到的Java多线程的相关知识点,适合速记,故命名为“小抄集”。本文没有特别重点,每一项针对一个多线程知识做一个概要性总结,也有一些会带一点例子,习题方便理解和记忆。 1.interr