这是一个影响 Android 4.1-4.3 版本的 HTTPUrlConnection 库 BUG,但只会在特定条件下触发。
我们有一个 Android App,通过多个并发 POST 连接上传数据到服务器,没有加入单个请求重试机制。在某些 Android 机型上发现一个诡异的 bug,在使用中频繁出现上传失败的情况,但是在其它机型上并不能复现。
经过较长时间的排查,我们找到了上传失败出现的规律,并认为它跟 HTTP Keepalive 持久化连接机制有关。具体的规律是:当 App 上传一轮数据后,等待超过服务端 Nginx keepalive_timeout 时间后,再次尝试上传数据,就会出现上传失败,抛出 EOFException 异常。
更准确的特征可以通过连上手机的 adb shell 观察 netstat:当 App 上传一轮数据后,可以观察到有 N 个到服务器的连接处于 ESTABLISHED 状态;当等待超过服务端 Nginx keepalive_timeout 时间后,可以观察到这 N 个到服务器的连接处于 CLOSE_WAIT 状态;当上传失败后,发现部分 CLOSE_WAIT 状态的连接消失。
Java 的 HTTP Keepalive 机制,一直是由底层实现的,理论上来讲,不需要应用层关心。但从上面的 BUG 来看,对于 stale connection 的复用,在部分 Android 机型上是有问题的。为此我稍微研究了一下 Android 的源代码,的确发现了一些问题。
在 2011年12月15日,Android 开发者提交了这样一个 Commit,Commit Message 这样写到:
Change the way we cope with stale pooled connections. Previously we'd attempt to detect if a connection was stale by probing it. This was expensive (it relied on catching a 1-millisecond read timing out with a SocketTimeoutException), and racy. If the recycled connection was stale, the application would have to catch the failure and retry. The new approach is to try the stale connection directly and to recover if that connection fails. This is simpler and avoids the isStale heuristics. This fixes some flakiness in URLConnectionTest tests like testServerShutdownOutput and testServerClosesOutput. Bug: http://b/2974888 Change-Id: I1f1711c0a6855f99e6ff9c348790740117c7ffb9
简单来说,这次 commit 做了一件事:在修改前,是在 TCP 连接池获取连接时,做 connection isStale 的探测。Android 开发者认为这样会在获取每个 connection 时都有 1ms 的 overhead,所以改成了在应用层发生异常时,再重试请求。 但是这个重试有个前提,就是用户的请求不能是 ChunkedStreamingMode,不能是 FixedLengthStreamingMode,这两种模式下,底层无法重试。很不幸地是,我们正好使用到了 FixedLengthStreamingMode 带来的特性。
// Code snippet of: libcore.net.http.HttpURLConnectionImpl.java
while (true) {
try {
httpEngine.sendRequest();
httpEngine.readResponse();
} catch (IOException e) {
/*
* If the connection was recycled, its staleness may have caused
* the failure. Silently retry with a different connection.
*/
OutputStream requestBody = httpEngine.getRequestBody();
// NOTE: FixedLengthOutputStream 和 ChunkedOutputStream
// 不是 instance of RetryableOutputStream
if (httpEngine.hasRecycledConnection()
&& (requestBody == null || requestBody instanceof RetryableOutputStream)) {
httpEngine.release(false);
httpEngine = newHttpEngine(method, rawRequestHeaders, null,
(RetryableOutputStream) requestBody);
continue;
}
httpEngineFailure = e;
throw e;
}
由于 BUG 的根源在 Android 的核心库 libcore 中。这次改动影响了从 4.1 到 4.3 的所有 Android 版本, Android 4.4 网络库的 HTTP/HTTPS 从 libcore 切换到 okhttp,所以4.4以后的 Android 版本不受影响。
既然底层不重试,那么只有在应用层重试,所以我们在应用层增加了最多『http.maxConnections+1』次重试机制,以修复此问题。在重试的时候,尝试使用一个 stale connection 会导致 EOFException,底层也会自动关闭这个连接。『http.maxConnections+1』次重试保证即使连接池中全都是 stale connection,我们也能获得一个可用的连接。
网上应该也有人遇到过这个 BUG,所以我也在这个 StackOverflow 问题下做了回答。