由 Transfer-Encoding chunked 引起的 site issue

Transfer-Encoding: chunked 介绍

Transfer-Encoding 是 HTTP 1.x 版本的一个header, 设置 payload 传输时候的一种编码. 可能的编码格式有: chunked, compress, deflate, gzip. 可以同时设置多个兼容的值. 这个header 只适用于 hop to top, 不适用于整个连接. 如果你想在整个连接上使用压缩算法, 应该使用 Content-Encoding header.

使用 Transfer-Encoding 的例子:

Transfer-Encoding: gzip, chunked

为什么要使用 chunked

假如一开始就知道要传输多长的payload 数据, 就可以使用 Content-Length header.
chunked: 一般因为一开始并不知道要传输多长的payload数据, 所以要一块一块传输, 在每一块的头上标注这一块有多长.
例子:

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7\r\n
Mozilla\r\n
11\r\n
Developer Network\r\n
0\r\n
\r\n

payload header

HTTP 1.x 的 header 分为

由 Transfer-Encoding chunked 引起的 site issue

HTTP GET 请求

http GET 请求是最简单的请求类型. 在浏览器输入一个URL, 直接回车, 就是发送一个 http GET 请求. 一个简单的例子:

GET /path/to/resource?query=string HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, sdch
Connection: keep-alive

上面的最后一行header 之后, 会在发送一行只有\r\n 的行, 表示请求结束.

Transfer-Encoding: chunked 介绍

Transfer-Encoding 是 HTTP 1.x 版本的一个header, 设置 payload 传输时候的一种编码. 可能的编码格式有: chunked, compress, deflate, gzip. 可以同时设置多个兼容的值. 这个header 只适用于 hop to top, 不适用于整个连接. 如果你想在整个连接上使用压缩算法, 应该使用 Content-Encoding header.

使用 Transfer-Encoding 的例子:

Transfer-Encoding: gzip, chunked

为什么要使用 chunked

假如一开始就知道要传输多长的payload 数据, 就可以使用 Content-Length header.
chunked: 一般因为一开始并不知道要传输多长的payload数据, 所以要一块一块传输, 在每一块的头上标注这一块有多长.
例子:

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7\r\n
Mozilla\r\n
11\r\n
Developer Network\r\n
0\r\n
\r\n

当 HTTP GET 遇到 Transfer-Encoding: chunked

根据上面的介绍, HTTP GET 请求不应该包含 payload. 所以如果在header 里面误发了Transfer-Encoding: chunked,会发生什么事情呢?

不同的服务器可能有不同的处理方式, 有的快速返回, 有的等待接受payload.

Tomcat 的处理方式

根据作者本地 debug 的实践, 到现在为止(20240713), Tomcat 的最新版本仍然是等待接收 chunked payload, 直到 socket read timeout.

这是某个Tomcat 版本等待读取 payload 的栈:

java.lang.Object.wait(Native Method)
org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.fillReadBuffer(NioEndpoint.java:1333)
org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.read(NioEndpoint.java:1234)
org.apache.coyote.http11.Http11InputBuffer.fill(Http11InputBuffer.java:785)
org.apache.coyote.http11.Http11InputBuffer.access$400(Http11InputBuffer.java:41)
org.apache.coyote.http11.Http11InputBuffer$SocketInputBuffer.doRead(Http11InputBuffer.java:1185)
org.apache.coyote.http11.filters.ChunkedInputFilter.readBytes(ChunkedInputFilter.java:310)
org.apache.coyote.http11.filters.ChunkedInputFilter.parseChunkHeader(ChunkedInputFilter.java:338)
org.apache.coyote.http11.filters.ChunkedInputFilter.doRead(ChunkedInputFilter.java:164)
org.apache.coyote.http11.filters.ChunkedInputFilter.end(ChunkedInputFilter.java:229)
org.apache.coyote.http11.Http11InputBuffer.endRequest(Http11InputBuffer.java:644)
org.apache.coyote.http11.Http11Processor.endRequest(Http11Processor.java:1184)
org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:430)
org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:926)
org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1791)
org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
java.lang.Thread.run(Thread.java:750) 

Tomcat 的逻辑是先逐行读取 header 信息, 直到读到 \r\n 行, 然后根据header设置需要的 InputFilter 列表(虽然是列表, 可能只有一个). 常见的 InputFilter 有:

  1. VoidInputFilter - 当 GET, HEAD 请求时用.
  2. ChunkedInputFilter - 当 chunked 的时候用.

一个请求的例子

下面是使用 python 写的一个发送 GET 请求并且设置 Transfer-Encoding: chunked 的例子:

import socket
from concurrent.futures import ThreadPoolExecutor
 
# Configuration
host = 'www.tianxiaohui.com'
port = 80
buffer_size = 4096
read_timeout = 100000  # Set read timeout to 10 seconds
 
def call():
    # Create a socket object using IPv4 and TCP protocols
    client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 
    # Set the read timeout on the socket
    client_socket.settimeout(read_timeout)
 
    try:
        # Connect to the server
        client_socket.connect((host, port))
 
        # Prepare the HTTP request data
        http_request = ("GET /sell/marketing/v1/ad_campaign?limit=100&offset=0 HTTP/1.1\r\n"
                        f"Host: {host}\r\n"
                        "accept: application/json, text/json, text/x-json, text/javascript\r\n"
                        "accept-encoding: application/gzip, deflate\r\n"
                        "Transfer-Encoding: chunked\r\n"
                        "\r\n")
 
        # Send the HTTP request to the server
        client_socket.sendall(http_request.encode())
 
        # Receive the response from the server
        response = ''
        while True:
            part = client_socket.recv(buffer_size).decode()
            if not part:
                break
            response += part
 
    except socket.timeout:
        print("Read timed out")
        response = None
    finally:
        # Close the socket
        client_socket.close()
 
    # Return the response
    return response
 
# Number of parallel calls
num_calls = 1
 
# Use ThreadPoolExecutor to execute the calls in parallel
with ThreadPoolExecutor(max_workers=num_calls) as executor:
    # Submit all calls to the executor
    future_calls = [executor.submit(call) for _ in range(num_calls)]
     
    # Wait for all futures to complete and print their results
    for future in future_calls:
        response = future.result()
        if response is not None:
            print("Response:")
            print(response)

如果改成对着本地的 tomcat 调用, 可以看到它等在那里20ms, 这20ms 就是读取完 header 之后, 等待读取 chunked payload, 却迟迟等不来的结果, 最后只有等到 read timeout.

这是在最新的 Tomcat 10.1.25 上得到的栈:

java.lang.Thread.State: TIMED_WAITING (on object monitor)
    at java.lang.Object.wait(java.base@17.0.4.1/Native Method)
    - waiting on <0x000000061a3aea90> (a java.util.concurrent.Semaphore)
    at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.fillReadBuffer(NioEndpoint.java:1280)
    - locked <0x000000061a3aea90> (a java.util.concurrent.Semaphore)
    at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.read(NioEndpoint.java:1181)
    at org.apache.coyote.http11.Http11InputBuffer.fill(Http11InputBuffer.java:789)
    at org.apache.coyote.http11.Http11InputBuffer$SocketInputBuffer.doRead(Http11InputBuffer.java:1195)
    at org.apache.coyote.http11.filters.ChunkedInputFilter.readBytes(ChunkedInputFilter.java:254)
    at org.apache.coyote.http11.filters.ChunkedInputFilter.fill(ChunkedInputFilter.java:295)
    at org.apache.coyote.http11.filters.ChunkedInputFilter.parseChunkHeader(ChunkedInputFilter.java:328)
    at org.apache.coyote.http11.filters.ChunkedInputFilter.doRead(ChunkedInputFilter.java:136)
    at org.apache.coyote.http11.filters.ChunkedInputFilter.end(ChunkedInputFilter.java:181)
    at org.apache.coyote.http11.Http11InputBuffer.endRequest(Http11InputBuffer.java:646)
    at org.apache.coyote.http11.Http11Processor.endRequest(Http11Processor.java:1188)
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:429)
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:904)
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1741)
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
    at java.lang.Thread.run(java.base@17.0.4.1/Thread.java:833)

Java 内存分析工具 MAT 使用技巧

本文列出作者在日常使用 Java 内存分析工具 MAT 的过程中用到的一些技巧

导出长字符串

有时候我们经常要从 heap 中复制出某些很长的字符串, 来观察它到底有哪些数据. 通常我们通过: 在某个字符串上点击右键 - Copy -> Value. 但是通常这个复制出的内容都有长度限制.

比如下面的例子, 我想从 HTTP request 的 HeapByteBuffer 复制出它已经读取请求的内容, 但是通过上面的方法只能复制出几千的字符:
copy.png

但是如果其内容远超这个数量, 拿到的就是部分数据, 根据部分数据可能得出错误的结论.
如何完全导出其内容?
在上面的菜单中选择 -> Save Value To File. 就能导出全部内容.

不过对于上面例子中的 HeapByteBuffer 要特别注意, 它是通过当前的位置(pos)来标记那里是有效数据的, pos 位置之后可能还有数据, 只不过是无效数据.

根据字段值分组统计

SQL 里面有 select * from table_0 group by column_0. 可是 OQL 里面却没有这个语句. 但是MAT 却提供了这样的功能.
根据下面的菜单栏, 就能找到 Group By Value 选项.
groupBy.png

然后填入你想分组的类名字和要分组的字段. 下面以 java.util.regex.PatternnormalizedPattern 来分组:
pattern.png

最终看到每个 normalizedPattern 的统计个数:

p_result.png

http keep-alive 实验

之前一篇讲道客户端和服务端是如何处理 http keep-alive 的, 其中很多都是一笔带过. 本篇补充一些细节.

版本

对于 http keep-alive 的概念, 这里的讨论只局限于 http 1.0, http 1.1. 对于 HTTP/2, HTTP/3 这里的讨论不适用.

http header - Connection & Keep-Alive

http 1.1 里面默认是持久连接. 但是我们可以看到下面的默认情况:
Chrome:

  1. 默认发送 Connection: keep-alive 头, 但是不发 Keep-Alive.

curl:

  1. Ubuntu 上的 curl 7.81.0 默认连 Connection 都没发.
  2. Mac 上的 curl 8.6.0 也没发 Connection header.

python requests:

GET / HTTP/1.1
Host: www.henu.edu.cn
User-Agent: python-requests/2.31.0
Connection: keep-alive

Java:

GET / HTTP/1.1
User-Agent: Java/17.0.4.1
Host: www.henu.edu.cn
Connection: keep-alive

JDK 里面的 Java client 默认的处理细节

Keep-Alive header 里面的 timeout 和 max 分别对应 JDK HttpClient 里面的字段:
timeout - keepAliveTimeout
max - keepAliveConnections.

keepAliveTimeout

首先, 在 HttpClient 里面定义了一个 keepAliveTimeout 字段. JDK 21 到链接:
https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L136C9-L136C25.

这个字段有4种取值可能:

  1. 正值 - timeout 的秒数, 对应 Keep-Alive header 里面的 timeout 值.
  2. 0 - 对方明确不需要 Keep-Alive.
  3. -1: 需要保持连接, http 1.1 设置或者不设 Connection: keep-alive, 但是没有设置 timeout 值.
  4. -2: 明确在 Keep-alive header 里面设置了 timeout: 0 这个值.

如果把我们上面实验的客户端的结果反过来看成对方发来的response 来看, 都属于上面的第3种: -1 类型.

真实的JDK java 客户端的解析过程在这些代码中:
https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L907-L917

真正的使用这个值的地方

真正使用这个值的地方在:
https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/KeepAliveCache.java#L162-L177

keepAliveConnections

这个值全部都是在 HttpClient 里面使用的.

parse:

  1. 若头部带来了 Keep-Alivemax 则使用这个值.
  2. 若没带来 max, 则如果使用代理, 则是50, 否则是5.
    细节代码: https://github.com/openjdk/jdk/blob/jdk-21%2B35/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L902-L904

由http header Keep-Alive 引出的问题

由于建立一个网络连接相对于本地操作十分昂贵, 所以系统应该尽量重用一个已经建立的连接.

因此, 在http 1.0的规范中定义了 Keep-Alive header, 一个典型的例子:

Keep-Alive: timeout=5, max=1000

这里的 timeout 表示如果没有后续的重用, 让这个连接最短逗留(idle) 5秒才能关闭它. 若有重用, 这从最后一个重用重新开始计时.
这里的 max 表示如果这个连接被一直重用, 但是这个连接最多只能发送1000个请求(或响应), 一旦超过这个数目, 即使能还没等idle 5秒, 也要关闭它. 所以它表示最多可以通过这个连接发送多少请求(或响应).

http 1.1

http 1.1 默认是持久化连接, 所以默认暗含是 Keep-Alive 的, 即便不发送这个头字段. 如果不在 http 的请求和响应中发送这个头字段, 那么连接的两端又该如何控制 idle 的timeout 和 一个连接最多可以发送的请求(或响应)的数目呢?

既然是为了重用, 那就最大化重用. 是不是一直重用某个连接, 一直不关闭就好了呢?

timeout 的合理性

  1. 从资源管理的角度. 若服务器端一直保持连接, 那么每个新用户都保持一个连接, 即便用户几分钟没数据传输, 这会导致服务器资源(连接, 内存等)很快被耗尽. 若客户端程序(浏览器, 代码客户端)也会有一样的问题.
  2. 从公平的角度. 若一个连接一直保持, 那么后来的新用户肯定无法再连接上.
  3. 从安全和性能的角度. 若保持很多没有数据的连接, 可能会有潜在的性能降级或者内存泄漏问题.

设置一个合理的 timeout 值, 保证一旦一段时间没数据传输, 就关闭这个连接.

max 请求的合理性

既然有了 timeout 可以保证 idle 了就关闭, 同时为了最大化重用, 那么是不是不需要 max 了呢?
考虑一种情况: timeout 是5秒, 但是每当 idle 4秒的时候, 就有一个新的请求(或响应), 那么这个连接将一直会被重用. 那么一直重用又有什么不好呢?

在理想的情况一下, 一直有数据固定频率的传输, 永久的使用这个连接其实是非常美好的一种状态.

但是 max 也有其它合理的地方:

  1. 避免某些写的不好的代码把某些请求(request)资源绑定在这个连接之上, 导致内存泄漏, 若关掉连接则全部释放.
  2. 公平性. 比如某些抢票的网站, 避免某些人一直先保持一个连接, 导致后面的人根本连不上. max 保证多少次请求之后, 一定关闭.
  3. 对于某些负载均衡器(Loader Balancer), 如果某个特定IP上一直保持特别多的连接, 会导致不再均衡.

服务端的 timeout 和 max

虽然 http 1.1 不再设置 Keep-Alive 头字段, 默认是持久连接. 但是连接的2端仍然要对合理的这2种机制做处理. 下面列举一些常见的服务端的处理和配置.

Tomcat web 服务器

Tomcat 是一个常用的 Java web 服务器. 在今天(20240605)最新的 10.1 版本里面的 http Connector 的配置里, 就能看到关于 timeout 和 max 的配置:
keepAliveTimeout: 默认60秒.
maxKeepAliveRequests : 默认100.

nginx 服务器

nginx 的配置分别是:
keepalive_timeout: 默认75秒.
keepalive_requests: 默认 1000.
不过, nginx 还有一个 keepalive_time: 类似max, 它从时间角度约束一个连接从开始建立最长存活多久, 区别于idle timeout.

envoy proxy

对于 Envoy proxy 没有对应的完全一致的概念, 不过它有另外2个参数:
common_http_protocol_options.idle_timeout:
stream_idle_timeout:
更多内容参考: https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts
https://github.com/envoyproxy/envoy/issues/8652

客户端的 timeout 和 max

既然对于 http 1.1 默认是持久的, 那么客户端也是暗含的, 不发送 Keep-Alive 的, 那么客户端是怎么处理的呢?

chrome 浏览器

没有找到相关文档.

Apache httpClient

在 4.5 版本 2.6 节 连接管理部份(https://hc.apache.org/httpcomponents-client-4.5.x/current/tutorial/html/connmgmt.html), 有下面一段描述:
“If the Keep-Alive header is not present in the response, HttpClient assumes the connection can be kept alive indefinitely”

JDK HttpClient

对于取值的大概处理:
https://github.com/openjdk/jdk/blob/e1870d360e05c372e672b519d7de2a60c333675b/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L892-L937

max 的取值:

https://github.com/openjdk/jdk/blob/e1870d360e05c372e672b519d7de2a60c333675b/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L902C25-L902C82

keepAliveConnections = p.findInt("max", usingProxy?50:5);

timeout 的取值

https://github.com/openjdk/jdk/blob/e1870d360e05c372e672b519d7de2a60c333675b/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L906C37-L918

    /*
     * The timeout if specified by the server. Following values possible
     *  0: the server specified no keep alive headers
     * -1: the server provided "Connection: keep-alive" but did not specify a
     *     a particular time in a "Keep-Alive:" headers
     * -2: the server provided "Connection: keep-alive" and timeout=0
     * Positive values are the number of seconds specified by the server
     * in a "Keep-Alive" header
     */
OptionalInt timeout = p.findInt("timeout");
if (timeout.isEmpty()) {
    keepAliveTimeout = -1;
} else {
    keepAliveTimeout = timeout.getAsInt();
    if (keepAliveTimeout < 0) {
        // if the server specified a negative (invalid) value
        // then we set to -1, which is equivalent to no value
        keepAliveTimeout = -1;
    } else if (keepAliveTimeout == 0) {
        // handled specially to mean close connection immediately
        keepAliveTimeout = -2;
    }
}                          

max 用完之后, 关闭连接:

https://github.com/openjdk/jdk/blob/e1870d360e05c372e672b519d7de2a60c333675b/src/java.base/share/classes/sun/net/www/http/HttpClient.java#L443-L453

客户端总结

客户端也会考虑 连接idle 的timeout 和 max 请求数, 但是很多情况下, 它没有服务端那么紧迫, 但是客户端也有这些机制, 只是没有那么透明.