从请求到响应
用户点击一次“提交”,后端看到的并不是一个直接落到业务方法上的函数调用。域名要先解析为地址,客户端要找到可用连接,HTTPS 要建立安全通道,请求可能穿过代理和负载均衡,服务器要把网络字节解析成协议对象,再由执行线程调用业务代码。业务代码如果访问数据库,还要借用另一条连接并建立事务边界,最后把结果编码成响应字节写回。
把这些步骤压缩成“前端调用后端”会造成大量误判:连接失败被当成接口异常,线程池排队被当成数据库慢,客户端超时被误解为服务端已经取消,事务提交成功后的响应断连又被当成操作失败。
这次请求一直追到响应被客户端读取
我们从客户端准备目标地址开始,经过 DNS、传输连接、TLS、HTTP、代理、服务端监听、请求解析、执行线程、业务调用、数据库连接与本地事务,一直追到响应被客户端读取。
先把通用请求模型走通;HTTP/2 帧格式、QUIC 实现、Servlet 与 Spring MVC 源码、数据库锁算法、分布式事务和具体网关产品,各自交给后续专题深入。
开始追踪前准备本地网络工具
读者应先理解“后端能力地图”中的进程、端口、连接、请求、线程、事务和依赖边界。实验需要 curl 和 Python 3;若有 nslookup、dig、ss 或 PowerShell 网络命令,可以观察更多证据。
实验只使用 127.0.0.1,适用于中国大陆公网受限、企业内网和离线环境。生产抓包、进程检查或日志读取必须遵守权限与数据安全规范,不要把认证头、Cookie、请求正文和个人数据直接复制到共享记录。
这条请求链在知识树中的位置
这条链承接能力地图,并为网络与 Web、Servlet、Spring MVC、数据访问、性能、可靠性和可观测专题提供共同请求模型。
图中的每支箭头都有独立失败方式和时间预算。“请求耗时 800 ms”只是这些阶段之和,不能直接说明业务方法执行了 800 ms。
第一条边界:名称不是地址
客户端拿到 https://api.example.com/orders 后,先解析 URI:方案决定安全与默认端口,主机名用于定位目标,路径和查询参数表达资源。若没有可复用的解析结果,系统通过名称解析获得一个或多个地址。
可以先观察名称解析:
nslookup example.comLinux 或 macOS 常用:
dig example.com A
dig example.com AAAA解析成功不代表服务可用。它只证明“名称到候选地址”的步骤有结果。多地址、IPv4/IPv6、客户端缓存、递归解析器缓存和负载均衡策略都可能让不同客户端走向不同实例。
反向验证很简单:请求一个不存在的保留域名。
curl -v --connect-timeout 2 https://does-not-exist.invalid/.invalid 是为无效名称保留的顶级域。预期错误发生在连接之前;服务端不会有访问日志。若排障时入口完全没有记录,名称解析和路由应排在业务代码之前检查。
第二条边界:请求不是连接
解析出地址后,客户端需要传输通道。HTTP/1.1 和 HTTP/2 常运行于 TCP 连接之上,HTTPS 再在其上建立 TLS;HTTP/3 使用基于 UDP 的 QUIC。RFC 9110 定义跨版本共享的 HTTP 语义,传输和消息格式由对应版本规范定义。因此“HTTP 是 TCP 协议”并不严谨,正确说法是 HTTP 语义可以由不同传输承载。
一次 TCP 建连需要往返,TLS 协商也会消耗时间。连接池通过复用已建立连接减少这些成本。Java 官方 HttpClient 文档也明确指出,一个客户端实例通常管理自己的连接池并在需要时复用连接。
所以不要在每次调用时无条件创建新的 HTTP 客户端,也不要把“一个请求”等同于“一条新 TCP 连接”。HTTP/2 还可以在一条连接上复用多个并发流;这降低连接数量,却可能让单连接故障影响多个请求。
第三条边界:TLS 成功才有可信的 HTTPS 通道
HTTPS 连接建立后还要完成 TLS 协商:选择协议参数、验证服务器证书和主机名、协商密钥。证书过期、信任链缺失、主机名不匹配、系统时间错误或中间设备干预,都会让请求在 HTTP 消息发出前失败。
用 curl -v 请求 HTTPS 地址时,可以看到连接与 TLS 的阶段信息。排障中不应通过长期关闭证书校验来“修复”问题。curl -k 只能作为隔离验证,而且会放弃身份校验,不能进入脚本或生产配置。
TLS 终止点也是安全边界。如果代理终止 TLS 再转发到应用,团队必须明确代理到应用之间是否仍加密、客户端真实地址怎样可信传递、哪些请求头只能由受信代理覆盖。
第四条边界:HTTP 消息表达意图,不保证业务完成
RFC 9110 把 HTTP 定义为无状态的应用层请求/响应协议。请求由方法、目标、字段和可选内容组成;响应由状态码、字段和可选内容组成。无状态表示每个请求的语义可以独立理解,不表示应用不能通过 Cookie、令牌或数据库维护会话状态。
可以直接观察 HTTP/1.1 形式的消息:
curl -v --http1.1 --max-time 3 http://127.0.0.1:8080/请求方法表达意图,目标定位资源,字段携带条件、媒体类型、认证和追踪上下文,内容携带表示。状态码说明服务端对这次请求的协议结果,但 200 不自动等于业务正确,500 也不说明失败一定发生在业务代码内部。
对写操作尤其要区分“安全”“幂等”和“可重试”。网络断开时,客户端可能不知道服务端是否已经提交。只有业务操作具备幂等键、唯一约束或可查询结果,重试才可能安全收敛;单看客户端没有收到响应,不能断言服务端没有执行。
第五条边界:代理看到请求,应用未必看到
真实流量通常先经过 CDN、WAF、反向代理、入口网关或负载均衡。中间节点可能完成 TLS、认证、路由、限流、缓存、压缩和协议转换,也可能因为上游连接池耗尽、路由错误或超时而直接返回响应。
排障必须区分至少三个时间点:客户端发出、入口收到、应用收到。入口 502 常表示它没有得到有效上游响应,504 常表示等待上游超时,但具体语义仍要结合产品日志和配置;不能看到 5xx 就直接搜索应用异常栈。
代理转发的 Forwarded 或 X-Forwarded-* 信息只能在受信代理边界内采用。应用若直接信任任意客户端传入的“真实 IP”头,会造成审计与访问控制绕过。最外层入口应覆盖或清理这些字段,应用只信任明确的代理链。
第六条边界:端口接收字节,容器把它变成请求对象
应用进程启动后,服务器在某个地址和端口监听。连接到达后,操作系统和服务器网络层接收字节,协议解析器识别请求行、字段和内容,再构造框架能够处理的请求对象。
这一阶段存在三类有限资源:连接和文件描述符、网络缓冲区、请求执行资源。慢客户端、大请求体、字段过多或连接数突增,即使业务方法很快,也可能耗尽入口资源。因此需要限制请求头和正文大小、连接与读取超时,并为异常输入保留可观测证据。
传统 Servlet 应用常把一个正在处理的同步请求关联到一个容器工作线程;事件循环或异步 Servlet 会改变等待模型,但不会消除 CPU、内存、连接和下游容量约束。不要把“一个连接固定占一个线程”当成所有服务器和所有协议的通用事实。
第七条边界:线程调度决定业务何时真正开始
请求被解析后,任务可能先进入队列,直到工作线程可用才执行业务代码。此时客户端已经完成连接并发送请求,但应用业务日志可能尚未出现。
假设服务器只有 20 个工作线程,而 20 个请求都同步等待一个慢下游,新请求就会排队。CPU 可能很低,因为线程在等待;请求延迟却持续上升。只看 CPU 利用率会得出错误结论。
一次请求的上下文通常包括关联标识、认证信息、租户和截止时间。使用线程池异步执行时,基于线程本地变量的上下文不会自动安全传播;即使传播了,也必须在任务结束后清理,避免线程复用造成串数据。
第八条边界:数据库连接与 HTTP 连接不是同一条连接
业务访问数据库时,会向数据源借用数据库连接。HTTP 连接负责客户端和服务端通信,数据库连接负责应用和数据库通信,两者有独立连接池、超时和容量。
请求线程可能在等待数据库连接时卡住,尚未执行 SQL。若数据库池上限为 10,而服务器允许 200 个并发请求,数据库等待队列可能成为真正瓶颈。盲目扩大服务器线程数只会积累更多等待和内存占用。
本地事务通常绑定数据库连接:开始事务后执行多条语句,最后提交或回滚。框架可能用代理隐藏这些动作,但物理边界仍存在。远程 HTTP 调用不会因为写在同一个业务方法里,就自动加入本地数据库事务。
第九条边界:事务提交与响应送达是两个事件
最危险的窗口之一发生在“数据库提交成功之后、客户端收到响应之前”。服务端可能已经创建订单,随后连接断开;客户端只看到超时。如果客户端直接重试,而服务端没有幂等保护,就可能重复创建。
正确设计不是假设网络可靠,而是让业务结果可识别:客户端生成稳定幂等键,服务端用唯一约束或幂等记录原子保护,并允许按键查询结果。幂等记录的生命周期、请求参数冲突和并发竞争仍需在可靠性专题深入。
第十条边界:生成响应不等于客户端已经读取
业务方法返回对象后,框架还要选择媒体类型、序列化、生成状态与字段,服务器再把字节写入网络缓冲区。大响应的序列化、压缩和慢客户端读取都可能延迟线程或占用连接。
服务端日志记录“响应完成”也要明确口径:是业务方法返回、响应头提交、字节交给操作系统,还是客户端确认收到?多数应用无法证明客户端已成功消费全部响应。协议层成功、业务提交成功和用户界面展示成功是不同事实。
最小实验:亲手观察正常路径
先在一个终端启动本地服务:
python -m http.server 8080 --bind 127.0.0.1另一个终端执行:
curl -v --http1.1 \
--connect-timeout 1 \
--max-time 3 \
-H "X-Request-Id: req-demo-001" \
http://127.0.0.1:8080/--connect-timeout 只约束建立连接阶段,--max-time 约束整次传输的总时间。预期 curl 输出连接目标、请求行、请求字段、响应状态和响应字段,服务端输出访问记录。
再查看监听状态。Linux:
ss -lntp | grep ':8080'Windows PowerShell:
Get-NetTCPConnection -LocalPort 8080 -State Listen这组证据只能证明本地 HTTP 正常路径,不证明 DNS、TLS、代理、数据库或事务,因为实验根本没有跨过这些边界。验证结论必须与实验覆盖范围一致。
反向实验:让失败发生在不同层
没有监听者
停止服务后重复请求。通常会快速得到 connection refused。它表示目标地址可达,但该端口没有接受连接的监听者或被主动拒绝;请求还没进入 HTTP 处理。
名称无法解析
curl -v --connect-timeout 2 https://does-not-exist.invalid/预期名称解析失败。若服务端没有访问日志是正常的,因为客户端没有得到目标地址。
总超时过短
下面启动一个故意延迟两秒的本地服务:
python -c "import time; from http.server import BaseHTTPRequestHandler,HTTPServer; H=type('H',(BaseHTTPRequestHandler,),{'do_GET':lambda s:(time.sleep(2),s.send_response(200),s.end_headers(),s.wfile.write(b'ok'))}); HTTPServer(('127.0.0.1',8081),H).serve_forever()"然后请求:
curl -v --max-time 1 http://127.0.0.1:8081/客户端会先超时,但服务端任务未必同步取消;稍后写响应还可能出现断连错误。这个实验直接证明:客户端放弃等待,不等于服务端停止执行。因此写操作必须考虑取消语义和幂等,而不能只加重试。
用分段时间而不是一个总耗时排障
curl 可以输出阶段时间:
curl -sS -o /dev/null \
-w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first_byte=%{time_starttransfer} total=%{time_total}\n' \
https://example.com/time_namelookup 接近名称解析完成,time_connect 接近传输连接完成,time_appconnect 接近 TLS 完成,time_starttransfer 是收到首字节的时间,time_total 是总时间。它们是累计时间点,不应直接互相当成独立阶段;阶段耗时需要做差。
客户端阶段时间还不足以定位服务器内部。应用应记录入口时间、排队或处理时间、下游调用、连接池等待、SQL、事务结果和响应状态,并用关联标识连接起来。分布式系统则用 Trace 和 Span 表达跨服务因果,但采样、敏感字段与成本需要治理。
故障模型:按现象寻找证据
名称解析错误通常表现为无法解析主机,检查客户端使用的名称、解析器和缓存。连接拒绝通常指向端口监听、地址绑定或防火墙。连接超时可能来自路由、丢包、防火墙静默丢弃或目标过载。TLS 错误先检查证书有效期、主机名、信任链、协议能力和系统时间。
入口返回 502/504 时,先查入口的上游选择、连接与超时日志,再查应用是否收到。应用收到但业务未开始时,检查工作队列、线程池和入口限流。业务开始后卡住,检查下游时间、连接池等待、锁和事务。服务端记录成功但客户端超时,则重点检查响应写出、网络中断、客户端截止时间与提交后断连窗口。
最有效的排查顺序不是“从最熟悉的组件开始”,而是先用证据缩小请求最后到达的边界。
性能取舍:每一层都可能排队
连接复用减少握手成本,但连接池太小会排队,太大又会压垮下游。线程池隔离可以限制故障传播,但队列无界会把过载隐藏成越来越长的延迟。数据库池保护数据库,却必须与应用并发和事务时长共同设计。
超时应形成端到端预算:外层截止时间要覆盖内部处理和网络余量,内层依赖超时必须小于剩余预算。若三个下游调用都配置成与入口相同的 3 秒超时,串行最坏时间会远超客户端耐心;重试又可能继续放大流量。
压缩能减少网络字节,却增加 CPU 和首字节等待;大响应流式写出降低峰值内存,却延长连接占用。所有优化都要用分段时间、资源利用率和失败率验证,不能只看平均耗时。
安全边界:不要让便利字段越权
客户端输入在进入业务前始终不可信,包括路径、查询、字段、内容类型、长度、认证信息和转发头。入口应限制请求大小、字段数量和读取时间;应用应做类型、范围、权限与对象级校验;数据库账户使用最小权限。
日志和 Trace 不应默认记录 Authorization、Cookie、令牌、密码、完整身份证件或请求正文。关联标识要可检索,但不能承担认证功能。外部传入的 request id 应限制长度和字符,必要时由入口重建,避免日志注入和高基数污染。
TLS 终止、真实客户端地址、跨域、会话与签名属于明确的信任边界。团队必须记录谁可以创建这些信息、谁可以覆盖、应用信任哪一跳,而不是在每个服务里随意读取请求头。
架构取舍:同步链越长,失败组合越多
同步 HTTP 调用适合需要即时结果、调用关系清晰且延迟可控的交互。链路越长,总延迟越高,任一依赖失败都可能向上传播。异步消息可以削峰并解耦时间,却引入投递、重复、顺序、积压和最终一致问题。
单体内函数调用没有网络分区,事务边界更容易维护;微服务获得独立交付和隔离,同时把线程内错误变成跨进程不确定性。选择架构形态时,应先画出请求与状态边界,再比较收益,不能只看服务数量。
反向代理可以统一 TLS、路由和防护,但也成为容量与配置风险集中点。连接复用、超时、重试、限流和灰度都应在明确责任层实施,避免客户端、代理和服务端各自重试,形成乘法放大。
团队治理:为请求链建立统一证据
团队应统一关联标识传播规则、结构化访问日志字段、超时单位与默认值、错误分类、敏感字段清单和真实客户端地址信任链。每个外部依赖都要声明连接上限、请求超时、重试条件、幂等要求和降级行为。
上线评审至少验证四条路径:正常响应、输入被拒绝、依赖超时、提交后响应中断。压测不仅看吞吐,也看线程队列、连接池等待、数据库活动连接、首字节和尾延迟。回滚必须考虑协议与数据兼容,不能只保证旧进程能重新启动。
发生事故后,把“请求最后到达哪里、哪一层证据缺失、哪个超时先触发、是否发生重复执行”写入复盘,并把结论转成测试、指标或配置门禁。这样下一次故障才不会重新靠猜。
上线前确认域名、证书、监听地址、端口和代理路由与目标环境一致;明确请求大小、连接、线程和数据库池上限;为每个下游分配小于入口截止时间的预算;为写操作设计幂等或结果查询;对转发头、认证和敏感日志建立信任规则。
上线后按客户端、入口、应用、依赖和数据库五层观察请求量、错误率、分位延迟和资源等待。故障时先固定时间窗与关联标识,判断最后一份可靠证据在哪一层,再做限流、摘流、降级或回滚。恢复后用原请求路径和反向实验验证,而不是只看进程已启动。
RFC 9110:HTTP Semantics。MDN:Overview of HTTP。MDN:HTTP Messages
