团队的项目正常运行了很久,但近期偶尔会出现BUG。目前观察到的有两种场景:一是大批量提交业务请求,二是生成批量导出文件。出错后,再执行一次就又正常了。
经过跟踪日志,发现是在Server之间进行json格式大数据量传输时会丢失部分字符,造成接收方拿到完整字符串后不能正确解析成json,因此报错。
同其他团队同事们沟通后发现,不仅仅是我们项目有这个问题,我们不是一个人在战斗。
1 问题现象
服务器之间使用http+json的数据传输方案,在传输过程中,一些json数据发生错误,导致数据接收方解析json报错,系统功能因此失败。
下面截取了一小段真实数据错误,在传输的json中,有一个数据项是departmentIdList,其内容时一个长整型数组。
传输之前的数据为:
"departmentIdList" : [ 719, 721, 722, 723, 7367, 7369, 7371, 7373, 7375, 7377 ]
接收到的数据为:
"departmentIdlist" : [ 719, 721'373, 7375, 7377 ]
可以看到,这个错误导致了两个问题:
1、 json解析失败
2、 丢失了一些有效数据
详细检查系统日志之后,这是偶发bug,并且只在传输数据较大时发生。
2 可选的解决方案
2.1 请架构组协助解决
这是最直接的解决方案,因为我们项目使用架构组提供的环境,他们需要提供可靠的底层数据传输机制。
2.2 压缩传输数据
因为数据量大时容易发生,并且传输的都是普通文本,可以考虑对内容进行压缩后传输。普通文件压缩率也很高,压缩后内容长度能做到原数据10%以内,极大减少传输出错的几率。
2.3 对传输数据进行MD5校验
将传输数据作为一个完整数据块,传输之前先做一个md5摘要,并将原数据和摘要一并发送;接收方收到数据后,先进行数据校验工作,校验成功后再进行后续操作流程,如果不成功可以辅助重传或直接报错等机制。
3 方案设计
为了彻底解决这个问题,设计了一个底层方案
3.1 设计原则
1、 适用类型:Spring MVC项目,数据发送方使用RestTemplate工具类,使用fastjson作为json工具类。
2、 数据校验,使用MD5加密,当然也可以配合数据压缩机制,减少传输数据量。
3、 提供底层解决方案,不需要对系统代码做大规模调整。
3.2 核心设计
数据发送方,重载RestTemplate,在数据传输之前对数据进行md5摘要,并将原始数据和 md5摘要一并传输。
数据接收方,重载AbstractHttpMessageConverter,接收到数据后,对数据进行MD5校验。
3.3 DigestRestTemplate关键代码
对原json进行摘要,并同原始数据一起生成一个新的json对象。
private Object digestingJson(JSONObject json) throws Exception { String requestJsonMd5 = JsonDigestUtil.createMD5(json); JSONObject newJson = new JSONObject(); newJson.put("content", json); newJson.put("md5", requestJsonMd5); return newJson; } |
重载的postForEntity函数核心部分,如果传入参数是 JSONObject,则调用方法对数据进行摘要操作,并用新生成的json进行传输。
Object newRequest = null; if (request instanceof JSONObject) { JSONObject json = (JSONObject) request; try { newRequest = digestingJson(json); } catch (Exception e) { } } if (newRequest == null) { newRequest = request; } return super.postForEntity(url, newRequest, responseType); |
3.4 DigestFastJsonHttpMessageConverter 核心代码
首先会判断是否是经过md5摘要的json,是有摘要的数据进行校验,否则直接返回对象。
private JSONObject getDigestedJson(JSONObject json) { if (json.size()==2&&json.containsKey("md5")&&json.containsKey("content")) { String md5 = json.getString("md5"); String content = json.getString("content"); logger.info("degested json : {}", json); try { String newMd5 = JsonDigestUtil.createMD5(content); if (newMd5.equals(md5)) { json = JSON.parseObject(content); } else { logger.error("md5 is not same : {} vs {}", md5, newMd5); throw new RuntimeException("content is modified"); } } catch (Exception e) { } } else { logger.info("may not be digested json"); } return json; } |
原有的处理数据代码增加调用该方法的代码
@Override protected Object readInternal(Class<? extends Object> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { JSONObject json = null; InputStream in = inputMessage.getBody(); Charset jsonCharset = fastJsonConfig.getCharset(); Feature[] jsonFeatures = fastJsonConfig.getFeatures(); json = JSON.parseObject(in, jsonCharset, clazz, jsonFeatures); json = getDigestedJson(json); return json; } |
当前的代码,如果数据校验失败,简单抛出异常。后续可以增加更多的机制,比如在RestTemplate处增加校验,如果发现校验失败,则重传。
3.5 数据发送方项目配置
以Spring Boot项目为例
在Main类中定义 restTemplate
@Bean(name = "restTemplate") public RestTemplate getRestTemplate() { RestTemplate restTemplate = new DigestRestTemplate(); return restTemplate; } |
需要调用RestTemplate的代码,只需要依赖注入RestTemplate
@Autowired RestTemplate restTemplate; |
3.6 数据接收方项目设置
在SpringBootApplication类中定义
@Bean public HttpMessageConverters fastJsonHttpMessageConverters() { DigestFastJsonHttpMessageConverter fastConverter = new DigestFastJsonHttpMessageConverter(); FastJsonConfig fastJsonConfig = new FastJsonConfig(); fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat); fastConverter.setFastJsonConfig(fastJsonConfig); HttpMessageConverter<?> converter = fastConverter; return new HttpMessageConverters(converter); } |
4 出错重传机制
在数据接收端,当数据校验失败时,会抛出一个RuntimeException异常(如果要做到产品,当然应该自定义一个高大上的Exception)。
4.1 服务器端随机模拟传输失败
为了模拟测试,在接收方的代码中,增加随机失败的情况。见下面代码中黑体字部分,大约10%的概率会失败。
private JSONObject getDigestedJson(JSONObject json) { if (json.size()==2&&json.containsKey("md5")&&json.containsKey("content")) { String md5 = json.getString("md5"); String content = json.getString("content"); logger.info("degested json : {}", json); try { String newMd5 = JsonDigestUtil.createMD5(content); if (newMd5.equals(md5)) { json = JSON.parseObject(content); } else { logger.error("md5 is not same : {} vs {}", md5, newMd5); throw new RuntimeException("content is modified"); } } catch (Exception e) { } } else { logger.info("may not be digested json"); } if (random.nextInt(100) < 10) { logger.info("random throw exception"); throw new RuntimeException("content be modified"); } return json; } |
4.2 发送方Catch异常重传
当接收端抛异常后,最终会发送一个500错误到数据发送方。
org.springframework.web.client.HttpServerErrorException: 500 Internal Server Error |
最简单的处理方式,在发送方校验是否发生了 500 错误,如果发生了就重传。这个方案的代码如下:
ResponseEntity<T> responseEntity = null; int times = 0; while (times < 5) { try { responseEntity = super.postForEntity(url, newRequest, responseType, uriVariables); break; } catch (Exception e) { if (e instanceof HttpServerErrorException) { times++; logger.error("post for entity", e); logger.error("resend the {}'st times", times); } else { break; } } } |
当传输错误后,图示代码会最多尝试发送五次。仍然失败后考虑抛异常,由发送端上层代码处理。
但这个代码有一个很明显的问题,接收端的任何错误如数据保存失败,都会导致发送端重传数据。下面读一下Spring的代码,看看是如何处理异常的。
4.3 SpringMVC异常处理
4.3.1 第一层处理
在类AbstractMessageConverterMethodArgumentResolver的readWithMessageConverters()方法中,会Catch IOException,相关代码为
catch (IOException ex) { throw new HttpMessageNotReadableException( "Could not read document: " + ex.getMessage(), ex); } |
HttpMessageNotReadableException是继承自RuntimeException的一个异常。
4.3.2 第二层处理
在类InvocableHandlerMethod的getMethodArgumentValues()方法,Catch Exception打印一下日志,然后继续throw。
try { args[i] = this.argumentResolvers.resolveArgument( parameter, mavContainer, request, this.dataBinderFactory); continue; } catch (Exception ex) { if (logger.isDebugEnabled()) { logger.debug(getArgumentResolutionErrorMessage("Failed to resolve", i) , ex); } throw ex; } |
4.3.3 第三层处理
在类org.springframework.web.servlet.DispatcherServlet.doDispatch()分别捕获了两种异常,代码如下
catch (Exception ex) { dispatchException = ex; } catch (Throwable err) { dispatchException = new NestedServletException( "Handler dispatch failed", err); } processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException); |
可以看到,如果抛出的Exception异常,会将原异常直接处理,如果是Runtime Exception,会转换成继承自ServletException的异常NestedServletException。
4.3.4 处理异常
在 processDispatchResult() 方法中,异常处理核心代码
if (exception instanceof ModelAndViewDefiningException) { logger.debug("ModelAndViewDefiningException encountered", exception); mv = ((ModelAndViewDefiningException) exception).getModelAndView(); } else { Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null); mv = processHandlerException(request, response, handler, exception); errorView = (mv != null); } |
我们抛出的异常,明显不是 ModelAndViewDefiningException,所以会交由processHandlerException处理。看看它的代码
ModelAndView exMv = null; for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) { exMv =resolver.resolveException(request, response, handler, ex); if (exMv != null) { break; } } …(如果exMv不为空,会单独处理) throw ex; |
可以看到,这部分代码如果没有处理,会继续抛出异常,回到 processDispatchResult()
catch (Exception ex) { triggerAfterCompletion(processedRequest, response, mappedHandler, ex); } |
呃,太复杂,先不往下看了。因为我们需要区分是数据传输错误还是其他错误,可以考虑数据出错时抛异常,不抛普通的RuntimeException,而是HttpMessageNotReadableException,看看数据发送端会有什么变化。
4.3.4 数据接收方抛新异常
修改了数据接收方代码中抛出异常HttpMessageNotReadableException
private JSONObject getDigestedJson(JSONObject json) { if (json.size()==2&&json.containsKey("md5")&&json.containsKey("content")) { String md5 = json.getString("md5"); String content = json.getString("content"); logger.info("degested json : {}", json); try { String newMd5 = JsonDigestUtil.createMD5(content); if (newMd5.equals(md5)) { json = JSON.parseObject(content); } else { logger.error("md5 is not same : {} vs {}", md5, newMd5); throw new HttpMessageNotReadableException("content is modified"); } } catch (Exception e) { } } else { logger.info("may not be digested json"); } // 调试用,后续删掉 if (random.nextInt(15) < 10) { logger.info("random throw exception"); throw new HttpMessageNotReadableException("content be modified"); } return json; } |
4.3.5 数据发送端修改代码
RestClientException transferException = null; ResponseEntity<T> responseEntity = null; int times = 0; while (times < 5) { try { responseEntity = super.postForEntity(url, newRequest, responseType, uriVariables); transferException = null; break; } catch (RestClientException e) { transferException = e; boolean transferError = false; if (e instanceof HttpClientErrorException) { HttpClientErrorException clientError = (HttpClientErrorException) e; transferError = clientError.getRawStatusCode() == 400; } if (transferError) { times++; logger.error("post for entity", e); logger.error("resend the {}'st times", times); } else { break; } } } if(transferException != null){ throw transferException; } return responseEntity; |
如果返回的是400错误,发送方会尝试共发送5次;如果是其他异常或5次都不成功,则抛出异常。
5 后记
经过测试,这个方案是可行的。如果为了能够适应更多的项目及更多的Java技术栈,需要对代码进行进一步完善。
补充:第一版发布后,同学们很关心如何重传的问题。对这个也做了一些测试,补充到文档中。如果是数据传输错误,会尝试共传输5次;如果仍然不成功则抛出异常由上层代码处理。