《Java 网络编程基础》8.2 keep-alive、chunked 与管线化

HTTP/1.1 的连接复用依赖消息边界。本节实现 ChunkedCodec 的 chunk 解码与响应编码,用 KeepAliveHandler 处理 Connection: keep-alive/close、HTTP/1.0 兼容、分块请求与按序回写,并在 loopback 的真实 SocketChannel 上验证两个请求的响应顺序。

本节目标:让一条 TCP 连接安全地承载多条 HTTP/1.1 请求,能按 Content-Length 或 chunked 判断消息边界,并按请求顺序回写响应。
适用版本:JDK 25(Temurin 25.0.5+8);SocketChannel、ByteBuffer、record。

承接的问题

8.1 的 HttpParser 一次只回答「这一条请求完整了吗」,没有回答「这条连接接下来怎么办」。HTTP/1.1 默认复用连接:curl 连续访问两个接口时,往往只建一次 TCP,第二条请求直接写在同一连接上。如果服务端回完第一条就 close(),客户端会看到 Connection reset 或被迫重连;如果服务端不关连接又不说清 body 长度,客户端会一直等到超时。

chunked 是另一个边界问题。请求头里出现 Transfer-Encoding: chunked 时,body 没有 Content-Length,长度由每个 chunk 的大小行决定。8.1 对这一支返回 501;本节把它补成真正的解码器,并让响应也能用 chunked 分块写出。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。本节用 loopback 上的一对真实 SocketChannel 驱动处理器,输出来自该环境。

核心机制一:keep-alive 的决策只看两件事

HTTP/1.1 的连接默认是持久的,除非请求或响应显式写了 Connection: close。HTTP/1.0 相反:默认关闭,只有请求带 Connection: keep-alive 才复用。服务端做决策时看两个输入:

输入结果
响应头有 Connection: close关闭
请求头有 Connection: close关闭
版本 HTTP/1.0,请求无 keep-alive关闭
版本 HTTP/1.0,请求有 keep-alive保持
版本 HTTP/1.1,没有 close保持

保持连接的前提是响应 body 有明确边界:Content-Length 或 Transfer-Encoding: chunked。两者都没有时,客户端只能靠连接关闭判断 body 结束,这时必须关闭连接,不能复用。8.1 的解析器保证了请求侧有边界,本节保证响应侧也有。

核心机制二:chunked 的格式、解码与编码

chunked body 由若干块组成,每块是「十六进制大小行 + CRLF + 数据 + CRLF」,最后以一个大小为 0 的块结束,后面可以跟 trailer 头部,再以一个空行收尾。大小行可以带 ;ext=value 扩展,解码时忽略扩展,但必须校验大小是合法十六进制且不超过上限。

package com.plume.linkd.http;

import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public final class ChunkedCodec {

    public static final int MAX_CHUNK = 64 * 1024;
    private static final byte[] CRLF = {'\r', '\n'};

    public record DecodeResult(boolean complete, byte[] body, int consumed, String error) {
        static DecodeResult needMore() { return new DecodeResult(false, null, 0, null); }
        static DecodeResult done(byte[] body, int consumed) { return new DecodeResult(true, body, consumed, null); }
        static DecodeResult failed(String message) { return new DecodeResult(false, null, 0, message); }
    }

    private ChunkedCodec() { }

    public static DecodeResult decode(ByteBuffer in) {
        int start = in.position(), p = start;
        ByteArrayOutputStream body = new ByteArrayOutputStream();
        while (true) {
            int lineEnd = findCrlf(in, p);
            if (lineEnd < 0) return DecodeResult.needMore();
            if (lineEnd - p > 1024) return DecodeResult.failed("chunk line too long");
            String line = ascii(in, p, lineEnd);
            int semi = line.indexOf(';');
            String hex = (semi < 0 ? line : line.substring(0, semi)).trim();
            int size;
            try {
                size = Integer.parseUnsignedInt(hex, 16);
            } catch (NumberFormatException e) {
                return DecodeResult.failed("bad chunk size");
            }
            if (hex.isEmpty() || !hex.chars().allMatch(c -> Character.digit(c, 16) >= 0)
                    || size > MAX_CHUNK) return DecodeResult.failed("bad chunk size");
            p = lineEnd + 2;
            if (size == 0) {
                while (true) {
                    int trailerStart = p;
                    int end = findCrlf(in, p);
                    if (end < 0) return DecodeResult.needMore();
                    p = end + 2;
                    if (end == trailerStart) return DecodeResult.done(body.toByteArray(), p - start);
                }
            }
            if (body.size() + size > MAX_CHUNK) return DecodeResult.failed("body too large");
            if (in.limit() - p < size + 2) return DecodeResult.needMore();
            byte[] chunk = new byte[size];
            in.get(p, chunk);
            p += size;
            if (in.get(p) != '\r' || in.get(p + 1) != '\n')
                return DecodeResult.failed("missing CRLF after chunk");
            p += 2;
            body.writeBytes(chunk);
        }
    }
    public static byte[] encode(byte[] body, int chunkSize) {
        if (chunkSize <= 0) throw new IllegalArgumentException("chunkSize <= 0");
        ByteArrayOutputStream out = new ByteArrayOutputStream(body.length + 32);
        for (int off = 0; off < body.length; off += chunkSize) {
            int len = Math.min(chunkSize, body.length - off);
            out.writeBytes(Integer.toHexString(len).getBytes(StandardCharsets.US_ASCII));
            out.writeBytes(CRLF);
            out.write(body, off, len);
            out.writeBytes(CRLF);
        }
        out.writeBytes("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
        return out.toByteArray();
    }

    private static int findCrlf(ByteBuffer in, int from) {
        for (int i = from; i + 1 < in.limit(); i++)
            if (in.get(i) == '\r' && in.get(i + 1) == '\n') return i;
        return -1;
    }

    private static String ascii(ByteBuffer in, int from, int to) {
        byte[] bytes = new byte[to - from];
        in.get(from, bytes);
        return new String(bytes, StandardCharsets.ISO_8859_1);
    }
}

decode 从 position 开始绝对扫描,不移动位置;返回 complete=false 时调用方保留字节,下一次补齐后从头再扫。consumed 是整段 chunked body 的字节数,解析器把它加到 body 起点,得到下一条请求的位置。encode 按固定块大小切分,最后写 0\r\n\r\n;块大小用十六进制,不补前导零。

把 8.1 里那个返回 501 的分支替换成下面这段,HttpParser 就能同时处理两种 framing:

String transferEncoding = header(headers, "Transfer-Encoding");
if (transferEncoding != null) {
    if (!transferEncoding.equalsIgnoreCase("chunked"))
        return ParseResult.error(501, "unsupported transfer coding");
    int saved = in.position();
    in.position(pos);
    ChunkedCodec.DecodeResult r = ChunkedCodec.decode(in);
    in.position(saved);
    if (r.error() != null) return ParseResult.error(400, r.error());
    if (!r.complete()) { in.compact(); return ParseResult.needMore(); }
    in.position(pos + r.consumed());
    return ParseResult.done(new HttpRequest(method, target, version, headers, r.body()));
}

header(headers, "Transfer-Encoding") 取到的是合并后的值;只有恰好等于 chunked 才进入解码。gzip, chunked 这类组合本节不支持,返回 501,而不是把 gzip 字节当成 chunk 大小行硬解。真实实现要按逗号拆分、逐个判断传输编码,最后一个编码决定 framing。

如果请求同时带 Transfer-Encoding 和 Content-Length,上面这段会走 TE 分支、完全忽略 CL;按 RFC 9112 应把这种请求判为 400 或删除 CL,避免代理与源站对消息边界理解不一致。

核心机制三:管线化的顺序与队头阻塞

管线化(pipelining)是客户端在收到第一个响应之前,就把后续请求写进同一连接。服务端可以逐条处理,但响应必须按请求顺序回写;HTTP/1.1 没有请求 ID,客户端只能靠顺序配对。把两个请求丢给线程池并发处理,谁先完成谁先写,响应就会错配。

顺序的代价是队头阻塞:第一个请求慢,后面已经到达的请求都要等。KeepAliveHandler 因此在 onReadable 的同一个循环里顺序处理,不把单个请求交给别的线程。真要让慢请求不阻塞后续连接,正确做法是限制每条连接的在途请求数,或者升级到 HTTP/2 的多路复用;在 HTTP/1.1 上并发写同一连接只会破坏顺序。

在 linkd 里落地:KeepAliveHandler

处理器把读取、解析、路由、编码、关闭串成一个循环。Connection 和 Handler 来自 com.plume.linkd.core(7.1 到 7.3 已定义),这里不重复实现:

package com.plume.linkd.app;

import com.plume.linkd.core.Connection;
import com.plume.linkd.core.Handler;
import com.plume.linkd.http.ChunkedCodec;
import com.plume.linkd.http.HttpParser;
import com.plume.linkd.http.HttpRequest;
import com.plume.linkd.http.HttpResponse;
import com.plume.linkd.http.HttpRouter;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

public final class KeepAliveHandler implements Handler {

    private static final int INPUT_CAPACITY = 128 * 1024;
    private static final DateTimeFormatter HTTP_DATE = DateTimeFormatter
            .ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US)
            .withZone(ZoneOffset.UTC);

    private final HttpParser parser = new HttpParser();
    private final HttpRouter router;
    private final ByteBuffer in = ByteBuffer.allocate(INPUT_CAPACITY);
    private boolean closing;

    public KeepAliveHandler(HttpRouter router) { this.router = router; }

    @Override
    public void onReadable(Connection c) {
        if (closing) return;
        try {
            int n = c.channel().read(in);
            if (n < 0) {
                if (in.position() > 0) send(c, HttpResponse.text(400, "incomplete request\n"), false, false);
                close(c);
                return;
            }
            if (n == 0) return;
            in.flip();
            while (true) {
                HttpParser.ParseResult result = parser.parse(in);
                if (result.state() == HttpParser.State.NEED_MORE) {
                    in.compact();
                    if (!in.hasRemaining()) {
                        send(c, HttpResponse.text(400, "request too large\n"), false, false);
                        close(c);
                    }
                    return;
                }
                if (result.state() == HttpParser.State.ERROR) {
                    send(c, HttpResponse.text(result.errorStatus(), result.message() + "\n"), false, false);
                    close(c);
                    return;
                }
                HttpRequest request = result.request();
                HttpResponse response;
                try {
                    response = router.route(request);
                } catch (RuntimeException e) {
                    response = HttpResponse.text(500, "internal server error\n");
                }
                boolean keep = keepAlive(request, response);
                send(c, response, keep, request.isHead());
                if (!keep) { close(c); return; }
                if (!in.hasRemaining()) { in.compact(); return; }
            }
        } catch (IOException e) {
            close(c);
        }
    }

    @Override
    public void onWritable(Connection c) { }
    @Override
    public void onClosed(Connection c) { closing = true; }

    private void close(Connection c) { closing = true; c.close(); }

    private static boolean keepAlive(HttpRequest request, HttpResponse response) {
        if (hasToken(response.header("Connection"), "close")) return false;
        String connection = request.header("Connection");
        if (hasToken(connection, "close")) return false;
        if ("HTTP/1.0".equals(request.version())) return hasToken(connection, "keep-alive");
        return true;
    }

    private static boolean hasToken(String value, String token) {
        if (value == null) return false;
        for (String part : value.split(",")) if (part.trim().equalsIgnoreCase(token)) return true;
        return false;
    }

    private static void send(Connection c, HttpResponse response, boolean keep, boolean head) {
        c.enqueue(ByteBuffer.wrap(encode(response, keep, head)));
    }

    private static byte[] encode(HttpResponse response, boolean keep, boolean head) {
        ByteArrayOutputStream out = new ByteArrayOutputStream(response.body().length + 256);
        writeAscii(out, "HTTP/1.1 " + response.status() + " " + reason(response.status()) + "\r\n");
        Map<String, String> headers = new LinkedHashMap<>(response.headers());
        putIfAbsent(headers, "Date", HTTP_DATE.format(ZonedDateTime.now(ZoneOffset.UTC)));
        putIfAbsent(headers, "Server", "linkd/0.1");
        boolean chunked = hasToken(header(headers, "Transfer-Encoding"), "chunked");
        if (chunked) headers.keySet().removeIf(key -> key.equalsIgnoreCase("Content-Length"));
        else putIfAbsent(headers, "Content-Length", Integer.toString(response.body().length));
        putIfAbsent(headers, "Connection", keep ? "keep-alive" : "close");
        for (Map.Entry<String, String> e : headers.entrySet())
            writeAscii(out, e.getKey() + ": " + e.getValue() + "\r\n");
        writeAscii(out, "\r\n");
        if (!head) {
            if (chunked) out.writeBytes(ChunkedCodec.encode(response.body(), 4096));
            else out.writeBytes(response.body());
        }
        return out.toByteArray();
    }

    private static String reason(int status) {
        return switch (status) {
            case 200 -> "OK"; case 400 -> "Bad Request"; case 403 -> "Forbidden";
            case 404 -> "Not Found"; case 405 -> "Method Not Allowed";
            case 413 -> "Content Too Large"; case 414 -> "URI Too Long";
            case 431 -> "Request Header Fields Too Large"; case 505 -> "HTTP Version Not Supported";
            case 500 -> "Internal Server Error"; case 501 -> "Not Implemented";
            default -> "Status";
        };
    }

    private static void writeAscii(ByteArrayOutputStream out, String text) {
        out.writeBytes(text.getBytes(StandardCharsets.US_ASCII));
    }

    private static String header(Map<String, String> headers, String name) {
        for (Map.Entry<String, String> e : headers.entrySet())
            if (e.getKey().equalsIgnoreCase(name)) return e.getValue();
        return null;
    }

    private static void putIfAbsent(Map<String, String> headers, String name, String value) {
        if (header(headers, name) == null) headers.put(name, value);
    }
}

几个细节决定它能不能长期运行。read 返回 0 表示当前没有新数据,直接返回,不能当 EOF;返回 -1 才是对端关闭。NEED_MORE 时先 compact() 再检查 hasRemaining():缓冲区满了还解析不完,说明消息超过 128 KiB,回 400 并关闭,不能让连接永远占着内存。ERROR 时先把错误响应 enqueue 再 close,依赖 Connection.close() 的语义是「停止读、排空写队列、关闭通道」,否则 400 可能还没发出去连接就没了。

响应编码先写状态行,再写头部,最后写 body。Date、Server、Content-Length、Connection 只在响应没有显式提供时才补,避免路由层和编码层写出两个 Content-Length。如果响应头声明了 Transfer-Encoding: chunked,编码器删掉 Content-Length,改用 ChunkedCodec.encode;HEAD 请求保留长度头但不写 body。

本机验证:顺序、关闭与 chunked

用一对 loopback SocketChannel 驱动 KeepAliveHandler,客户端一次写入两条请求,服务端在同一个 onReadable 里循环解析。本机输出(Temurin 25.0.5+8,单次运行,未做统计):

pipeline: statuses=[200, 200] closed=false bytes=376
http10: statuses=[200] closed=true bytes=157
connection-close: statuses=[200] closed=true bytes=157
chunked-request: statuses=[200] closed=false bytes=164
head: statuses=[200] closed=false bytes=159
chunked-response: statuses=[200] closed=false bytes=174
bad-request: statuses=[400] closed=true bytes=187

pipeline 的两个 200 顺序固定:第一个是 /healthz 的 ok\n,第二个是 /books/7 的 JSON,bytes=376 是两条响应拼接后的总字节数。http10 和 connection-close 都只回一条并置 closed=true,区别在触发条件:前者是 HTTP/1.0 默认关闭,后者是请求显式 Connection: close。chunked-request 把 hello 分成一个 5 字节块发送,处理器解码后交给 /echo,响应仍是 Content-Length: 5 且连接保持。

分块响应的原始字节如下(\r\n 为转义显示):

HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/plain\r\n
Date: Wed, 28 Oct 2026 09:43:02 GMT\r\nServer: linkd/0.1\r\n
Connection: keep-alive\r\n\r\nb\r\nhello world\r\n0\r\n\r\n

b 是 11 的十六进制,后面跟 hello world,再跟结束块 0\r\n\r\n。响应没有 Content-Length,客户端靠 chunked 语法判断结束;连接因此可以继续复用。bad-request 送的是缺少版本的请求行,处理器回 400 并关闭,closed=true。

常见错误与边界

错误一:HTTP/1.1 回完就关。 这是把「短连接」当默认。客户端第二次请求会复用已经收到 FIN 的连接,表现为 Connection reset 或 Broken pipe。除非响应没有长度边界、请求写了 close,或者服务端正在停机,否则不要主动关。

错误二:chunked 响应同时写 Content-Length。 两个 framing 冲突,不同客户端和代理会选择不同的解释,可能把 chunk 大小行当成 body 的一部分。编码器必须在 chunked 分支删除 Content-Length,而不是两个都发。

错误三:把管线化请求并发处理。 HTTP/1.1 没有请求 ID,响应顺序就是配对依据。两个请求交给线程池、谁先完成谁先写,客户端会把第二个响应配给第一个请求。要并发就升级协议,或者限制每条连接同时只有一个在途请求。

边界:本节的 chunked 解码器把整个 body 收进内存。 上限 64 KiB,适合表单和 JSON;大文件上传应该边解码边写临时文件,不能等 0\r\n\r\n 再分配。decode 每次 NEED_MORE 都从头重扫,和 8.1 的解析器一样是教学实现,生产版应保存块状态。

小结

  • HTTP/1.1 默认 keep-alive,Connection: close 或 HTTP/1.0 无 keep-alive 才关闭;响应必须有 Content-Length 或 chunked 边界。
  • chunked 的块是「十六进制大小 + CRLF + 数据 + CRLF」,以 0\r\n\r\n 结束,扩展和 trailer 要能跳过。
  • ChunkedCodec.decode 不移动缓冲区位置,返回 consumed 让解析器推进;encode 按固定块大小切分。
  • KeepAliveHandler 在同一个 onReadable 循环里顺序处理,响应入队顺序就是请求顺序。
  • NEED_MORE 要 compact() 后等下一次读;缓冲区满仍不完整,回 400 并关闭,避免内存被一条连接占死。
  • 关闭前必须让写队列排空,否则错误响应可能丢在 close() 里。

下一节把 HttpRequest 交给路由:注册 /healthz 和 /books/{id},区分 404、405、500,并在静态文件服务里处理路径穿越。

阅读导航:上一节:8.1 请求行、头部与消息体 · 下一节:8.3 路由、静态文件与错误响应 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发