一、故障過程回顧
2020年1月3日早上7:30收到pay-xx服務連接超時告警,發現問題後馬上進行排查,根據鏈路日誌發現,我們請求有通過http發送給第三方,將請求日誌發給第三方,但第三方反饋未收到該請求。瞬間懵逼了, 我們有發送請求,但第三方沒有收到,請求中途掉包了 ?網絡出現了問題?但是我們部分請求是可以送達第三方的,開始懷疑是第三方對我們出口的ip做了白名單限制 ? 7:50 左右我們一個服務節點開始出現波測告警,該告警的意思是一個服務節點已經死機。於是趕快聯繫運維重新部署,pay-xx服務。 8:30重新部署服務後,整個請求恢復正常。
爲了排查問題,我們然運維保留了死機節點,並導出了jvm堆棧。
二、問題排查
1)我將出問題的節點出口ip給第三方排查是否由於網關ip白名單訪問限制導致問題,第三運維反饋ip白名單正常。
2)我開始找出問題時候http訪問的狀態,發現在服務告警前,http請求第三方返回了很多504以後, 我們服務開始出現異常;
3)第三方排查確實7:29至7:30 一段時間沒有收到我們的請求,那這個http 504狀態表示的超時,是什麼地方返回的呢?看第三方網關監控7:30分他們可以正常收到請求,但爲什麼我們的一個節點會出現死機呢?
4)第三方用的是阿里雲cdn加速,進過與阿里一起排查發現7:29至7:30,我們收到的504狀態碼,是阿里網關內部超時返回的
5)第三方優化了cdn 中tcp 超時時間
6)問題的根源算是找到了,是由於阿里網關內部超時導致,我們服務7:29至7:30大量請求超時。
7)問題起因算是找到了, 但我們的服務爲什麼沒有,沒有對連接做超時斷開呢 ?於是我開始看項目代碼。
public Map<String, String> send(HttpRequest request) throws ClientException, BusinessException {
Stopwatch stopwatch = Stopwatch.createStarted();
logger.info("Start send http request...");
//設置超時時間,如果request沒設置超時時間,取profile裏的
int socketTimeout = request.getSocketTimeout() > 0 ? request.getSocketTimeout() :10000;
int connectTimeout = request.getConnectTimeout() > 0 ? request.getConnectTimeout() : 10000;
RequestConfig config = RequestConfig.custom().setSocketTimeout(socketTimeout).setConnectTimeout(connectTimeout)
.build();
logger.info("Http request params: uri={}, headers={}, params={} ", request.getUri(),
JSONArray.toJSONString(request.getHeaders()), SensitiveUtils.toJson(request.getParamsMap()));
if (logger.isDebugEnabled() && Strings.nullToEmpty(request.getBodyText()).length() > 0) {
logger.debug("Http request body is {}", request.getBodyText());
}
//請求調用
CloseableHttpResponse httpResponse;
if (request.getMethodType() == MethodType.GET) {
httpResponse = sendGet(request, config);
} else if (request.getMethodType() == MethodType.POST) {
httpResponse = sendPost(request, config);
} else {
throw new ClientException(ClientErrorCode.METHOD_TYPE_NOT_SUPPORT);
}
//校驗http狀態碼
checkHttpStatus(httpResponse);
Map<String, String> response = new HashMap<>();
} catch (Exception e) {
logger.error("Close the response inputStream error, error={}", e);
throw new ServerException(ClientErrorCode.CLOSE_RESPONSE_CONTENT_ERROR);
} finally {
try {
httpResponse.close();
} catch (IOException e) {
logger.error("Close the response error, error={}", e);
}
logger.info("Http request response: uri={}, response={}, time={} ", request.getUri(),
SensitiveUtils.toJson(response), stopwatch.elapsed(TimeUnit.MILLISECONDS));
}
return response;
}
從上面代碼看來都有設置SocketTimeout、ConnectTimeout的超時時間,按理我們請求第三方如果超時,就會出現超時。在代碼裏面我們有打印error日誌,但是我在日誌系統中查了並無明顯的error日誌。那說明我們http請求是沒有出現異常。
8)開始排查爲什麼我們服務沒有異常的原因,我們在日誌中看到一直在發請求,但一直沒有http返回
9) 讓運維查看了故障節點日誌, 也未發現異常,我們用的是docker容器,如果宿主機出現問題,其它服務器也會有異常告警, 說明容器是沒有問題的。
10)通過故障節點堆棧信息分析,http請求導致Tomcat線程池出現了問題。
11)上分析已經明顯知道,是由於http請求堆積太多導致,服務線程池飆升,導致節點死機。那問題又回到爲爲什麼大量http請求,堆積後沒有超時失敗呢 ?然後開始google 發現 apache httpclient 4.5 用連接池技術,新增了一個超時時間設置,默認設置的是-1 ;
setConnectionRequestTimeout:設置從connect Manager獲取Connection 超時時間,單位毫秒。這個屬性是新加的屬性,因爲目前版本是可以共享連接池的。
緊接去看httpclient的源碼發現了坑。 獲取Connection有判斷ConnectionRequestTimeout時間,默認值-1會被設置爲0 ,0表示永久不失效。
final HttpClientConnection managedConn;
try {
final int timeout = config.getConnectionRequestTimeout();
managedConn = connRequest.get(timeout > 0 ? timeout : 0, TimeUnit.MILLISECONDS);
} catch(final InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new RequestAbortedException("Request aborted", interrupted);
}
尷尬,永久不失效,代表的就是:如果沒有獲取到Connection,線程會一直等待,一直未發送到第三方。這就是我們看到爲什麼發送日誌,是正常的但是,沒有返回的Response並且第三方也沒有收到請求。
三、優化方案
1) 上面已經找到問題, 我開始驗證,我的想法。 我先寫了一個server端, 並且讓所有請求都暫停1分鐘,模擬第三方返回延遲 。
AtomicInteger count = new AtomicInteger();
@RequestMapping(path = "/{id}", method = RequestMethod.GET)
@ResponseBody
public Coffee getById(@PathVariable Long id) {
int counts = count.incrementAndGet();
log.info("counts {}:", counts);
log.info("request {}:", id);
try {
Thread.sleep(60000);
}catch (Exception e){
e.printStackTrace();
}
Coffee coffee = coffeeService.getCoffee(id);
return coffee;
}
2)我用故障服務代碼寫了一個http請求
public class HttpTimeOutCase {
// 用3000個線程模擬併發
public static void main(String args[]) {
for (int i = 0; i < 3000; i++) {
try {
R r = new R();
System.err.println(r.getName());
r.start();
} catch (Exception e) {
e.printStackTrace();
}
}
System.err.println("finish");
}
protected static class R extends Thread {
@Override
public void run() {
try {
Map<String, String> map = new HashMap<>();
String url = "http://localhost:8080/coffee/" + 1;
HttpRequest request = new HttpRequest(url, MethodType.GET, map);
request.setParamType(ParamType.X_WWW_FORM_URLENCODED);
Map<String, String> rspData = HttpClientsInstance.INSTANCE.getInstance().send(request);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
3)我們先試一下沒有設置ConnectionRequestTimeout的情況,沒有獲取到connection的線程一直等待中
每個請求,都是一個線程,所以看到線程數量最高的時候是2200個,如果是生產環境節點,這個線程數容器肯定死機了。
最後我們消費端將3000個請求都消費掉
4)我們試一下設置ConnectionRequestTimeout的情況,沒有獲取到connection的線程20s後超時
處理了400個請求, 其它併發的2600線程在客戶端已經超時,現在最大連接數是200,連接超時時間是10s,服務端延遲時間是10s,所以處理了 2*200條請求。設置獲取連接超時時間,可以避免服務死機,但可能導致部分請求失敗。
5)在我們容器中Tomcat線程數默認是1000,所以將ConnectionRequestTimeout設置爲50s
四、總結
1)整個故障排查的思路不夠清晰,導致排查了很久
2)如果現在重新來排查
- 查看日誌,是否有正常發送http請求
- 聯繫第三方排查是否接收到請求
- 故障節點出現死機的時候,應該聯繫運維馬上重新發布一組服務,優先恢復業務
- 應該在故障節點上ping /wget 第三方域名,檢查網絡
- 用jps 和 jstack 命令查看線程情況
- 用jmap 導出堆棧信息