做后端开发这些年,我几乎每天都要和 HTTP 请求/响应数据打交道。排查线上问题时,想还原一次完整的调用过程;联调接口时,想确认客户端到底发来了什么;做安全审计时,又需要保留关键报文。一开始我也只是打几行日志,后来发现这种朴素方案根本不够用——日志散落在业务代码里、输入输出不配对、敏感字段全裸奔。折腾过几套方案后,我总结出一些实打实的经验,这里就聊聊怎么优雅地把 HTTP 请求/响应数据记录下来。不管你是写 Java、Go 还是 Python,只要服务对外提供接口,下面这些思路基本都能直接套用。
1. 先想清楚:需求决定采集方案,不要一上来就写过滤器
1.1 四种常见场景,对应的策略完全不同
很多人一听到“记录 HTTP 请求/响应数据”,第一反应就是写一个 Filter,然后把 request 和 response 的 body 全部打出来。这个做法不是不行,但很容易做成一坨“看似完整、实则没人看”的日志。先说需求,我见过的真实诉求大概分成四类。
第一类是联调定位。前后端对不上参数、第三方回调报错,这时候需要看到“客户端到底发了什么、服务端到底回了什么”,越完整越好。第二类是线上故障排查。接口突然超时、状态码异常,此时更关注耗时、traceId、客户端 IP、是否走缓存,body 反而不是每次都要。第三类是安全审计。需要留存关键报文明细,比如登录、支付、管理后台操作,但敏感字段必须脱敏。第四类是流量分析。团队想知道接口调用量、成功率、Top 慢接口,这种情况下根本不用存 body,只要把摘要字段聚合上报就行。
你看,同一个需求,四类场景的“优雅”定义完全不同。联调要完整,线上要抓重点,安全要合规,分析要轻量。如果你一上来就全量记录 body,单机 QPS 稍微上来一点,磁盘和 GC 压力就会很难看。所以我建议先问自己一句:我记录这些数据是为了什么?想清楚了,后面所有的选型才有落点。
1.2 拦截位置怎么选:Nginx、网关、Filter、AOP
确定了目的,接下来要决定在哪个位置采集。这个位置选错了,后面会非常别扭。我整理了几个常见落点,特点差异很大:
| 采集位置 | 优势 | 坑点 | 适合场景 |
|---|---|---|---|
| Nginx 层 | 最靠前,能拿到真实客户端 IP 和 TCP 连接信息 | 要读 body 需要 Lua 或流量镜像,跨团队维护成本高 | 基础 access log、连接层排障 |
| API 网关层 | 统一入口,适合多服务架构,一条链路只记录一次 | 网关到后端依然有一段是黑盒,部分二进制的 body 不好处理 | 多服务统一规范、全局限流和审计 |
| 框架 Filter/Interceptor | 能拿到原始请求和响应流,业务代码零侵入 | 每个服务都要部署,耦合在应用里 | 单服务或微服务每个服务单独控制 |
| 业务 AOP | 能直接拿到反序列化后的参数对象和返回值 | 看不到原始报文、HTTP headers,异常返回值也容易漏 | 只想记录业务入参出参,不在乎 HTTP 细节 |
我的个人倾向是:如果只是单个 Spring Boot 应用,先做 OncePerRequestFilter,这是成本和收益最平衡的方案。如果是多服务,优先把能力做成一个公共 SDK 放进网关和核心服务,再在各服务预留开关。越靠前越方便统一,但也越难拿到业务语义;越靠后越容易做业务化处理,但也越难保证全链路覆盖。没有万能答案,只能按团队情况取舍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 日志字段设计:少一个字段,排查时就多折一次返工
2.1 一份可以直接抄作业的字段清单
很多初版实现只记了 url 和 body,真出问题时才发现缺了一堆关键信息:没有 traceId,请求和响应对不上;没有 durationMs,不知道慢在哪;没有 clientIp,无法按照端到端维度筛选。这里我列一份自己一直在用的字段表,覆盖大部分场景:
| 字段 | 说明 | 是否建议 |
|---|---|---|
| traceId | 贯穿整条调用链的唯一标识 | 强烈建议 |
| timestamp | 请求开始或结束时间 | 必须 |
| method | GET/POST/PUT/DELETE 等 | 必须 |
| uri | 请求路径,包含 query string | 必须 |
| protocol | HTTP/1.1、HTTP/2 | 可选 |
| clientIp | 客户端真实 IP,注意处理代理头 | 建议 |
| userAgent | 客户端类型 | 可选 |
| status | 响应状态码 | 必须 |
| durationMs | 处理耗时 | 必须 |
| requestHeaders | 关键请求头,如 Content-Type | 建议 |
| requestBody | 请求体 | 按场景 |
| responseHeaders | 响应头,如 Content-Encoding | 建议 |
| responseBody | 响应体 | 按场景 |
| errorMessage | 捕获到的异常信息 | 建议 |
字段不是越多越好。headers 里其实有大量无意义的信息,比如 Cookie 里的各种埋点参数,全量记录只会让日志膨胀。我一般会做成一个“白名单”,只保留关键头;body 则是按开关控制是否采集。宁可一开始字段少点,把结构定清楚,也不要想到一个加一个,因为日志字段一旦变更,下游的存储索引和报表都要跟着改。
2.2 用 traceId 把请求和响应串联起来
记录 HTTP 请求/响应数据,最核心的一点就是“必须能配对上”。如果没有统一的 traceId,一次请求的 request 和 response 分散在几万条日志里,几乎没法用。我常用的做法是在过滤器入口检查请求头里的 X-Request-Id,有就沿用,没有就生成一个 UUID,然后放进日志框架的 MDC 里,同时把它写回响应头。
java复制String traceId = request.getHeader("X-Request-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put("traceId", traceId);
response.setHeader("X-Request-Id", traceId);
存放 traceId 之后,日志里所有行都会自动带上这个标识。如果是跨服务调用,上游服务需要把这个 header 透传给下游;如果是异步线程,则要把 MDC 上下文传递到子线程,否则会出现“请求结束了,但 traceId 还在”或者“子线程拿到别人的 traceId”这类怪问题。我的习惯是在 finally 里调用 MDC.remove("traceId"),避免线程池里的线程复用时把上下文带到下一个请求。
2.3 选择单行 JSON 输出,别用分散多行
记录格式上,我强烈建议用单行 JSON。不是说人眼读取不友好,而是单行 JSON 在采集、传输、解析三个环节都很省事。你可以用 logstash-logback-encoder 的 JsonLayout,也可以自己拼 JSON,但千万不要把请求和响应拆成两行 5、6 条日志。那种多行日志虽然打开 txt 看着清楚,但到了 ES 里面就是灾难,关联查询要费很大劲。
json复制{
"timestamp": "2025-03-21T10:15:30.123Z",
"traceId": "3f2a9c1e8b6d4a7f",
"method": "POST",
"uri": "/api/order",
"status": 200,
"durationMs": 36,
"requestBody": "{\"userId\":1001,\"amount\":99.5}",
"responseBody": "{\"orderId\":\"20250321101530001\"}"
}
序列化的时候有两个小技巧:一是对字符串字段做长度截断,超过阈值加 "[truncated]" 后缀;二是空值不输出,避免日志里全是 null。这些都可以在对象序列化阶段统一处理,不要散落在业务代码里。
3. 实操:用 Java Filter 把请求和响应都缓存下来
3.1 先搞懂“流只能读一次”的坑
刚接触的人最容易栽的跟头就是:在过滤器里读了一次 request body,结果 Controller 里 @RequestBody 拿到的却是空。原因很简单,ServletInputStream 和 ServletOutputStream 本质上都是一次性流,已经被读过就没法再从头读。你提前消费了 body,后面对接的组件只能干瞪眼。
Spring 为此提供了缓存包装器,比如 ContentCachingRequestWrapper 和 ContentCachingResponseWrapper。它们会把流内容缓存到内存里,让你既能记录日志,又不影响后续业务方消费。但要明确记住,缓存不等于自动记录,你需要在过滤器 finally 里主动取 getContentAsByteArray()。另外缓存也是有内存代价的,不要拿它去包一个超大请求体,否则可能 OOM。
3.2 完整实现:OncePerRequestFilter + 包装器
下面这个案例是我的常用模板,可以直接抄去改。核心思路是:用 ContentCachingRequestWrapper 包住原始 request,用 ContentCachingResponseWrapper 包住原始 response,然后继续执行过滤器链;等接口处理完了,在 finally 里统一读取缓存内容,拼成一条日志。
java复制@Component
public class HttpLogFilter extends OncePerRequestFilter {
private static final int MAX_BODY_LENGTH = 1024 * 1024;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
ContentCachingRequestWrapper req = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper resp = new ContentCachingResponseWrapper(response);
long start = System.currentTimeMillis();
try {
chain.doFilter(req, resp);
} catch (Exception e) {
log.error("request failed", e);
throw e;
} finally {
long cost = System.currentTimeMillis() - start;
String reqBody = readBody(req.getContentAsByteArray(), request.getCharacterEncoding());
String respBody = readBody(resp.getContentAsByteArray(), response.getCharacterEncoding());
String safeReqBody = maskSensitive(reqBody);
String safeRespBody = maskSensitive(respBody);
log.info("httpLog traceId={} method={} uri={} status={} cost={}ms req={} resp={}",
MDC.get("traceId"), request.getMethod(), request.getRequestURI(),
resp.getStatus(), cost, safeReqBody, safeRespBody);
resp.copyBodyToResponse();
}
}
private String readBody(byte[] bytes, String encoding) {
if (bytes == null || bytes.length == 0) {
return "";
}
if (bytes.length > MAX_BODY_LENGTH) {
return "[body too large, size=" + bytes.length + "]";
}
try {
return new String(bytes, encoding == null ? "UTF-8" : encoding);
} catch (UnsupportedEncodingException e) {
return new String(bytes);
}
}
}
这段代码里最容易被忽略的是最后一行 resp.copyBodyToResponse()。ContentCachingResponseWrapper 会把响应先写到自己的 buffer,如果不调用这个方法,客户端就拿不到真实响应体。我第一次上线时就漏了这一行,结果前端反馈接口一直在转圈,排查了半天才发现响应被吞了。
3.3 脱敏规则不要等日志落地了再处理
日志系统里存了明文密码,是最低级也最致命的错误。我见过有人刚开始只做了登录接口的脱敏,后来支付回调、修改资料这些接口又漏了。最稳妥的办法是在日志输出的最前端统一做一次脱敏,而不是靠业务代码自觉。可以用一个工具方法,基于 JSON key 名做正则替换:
java复制private static final Pattern SENSITIVE_FIELD =
Pattern.compile("(\"(?:password|passwd|pwd|token|secret|authorization)\"\\s*:\\s*)\"([^\"]+)\"");
private String maskSensitive(String json) {
if (json == null || json.isEmpty()) {
return json;
}
return SENSITIVE_FIELD.matcher(json)
.replaceAll("$1\"***\"");
}
这个方案只对 JSON 文本有效,如果你想对所有序列化格式都生效,最好在接入层统一做“先序列化为 JSON,再脱敏,再输出”。另外,脱敏不等于丢掉分析价值。对于手机号、邮箱这类字段,可以把中间几位用星号代替,保留首尾数字,这样既能排查问题,又不会把隐私漏出去。
3.4 性能控制:采样率、大小上限和异步写
全量记录 body 是非常危险的做法,尤其是遇到大 JSON、文件上传、报表导出时。我的默认规则是:正常接口按 10% 采样记录;凡是 4xx、5xx 或耗时超过 500ms 的请求,强制记录完整链路;body 大于 1MB 的不存原始内容,只记录大小和 SHA-256 摘要。
java复制boolean needFull = resp.getStatus() >= 400 || cost > 500;
boolean needSample = ThreadLocalRandom.current().nextInt(100) < 10;
if (!needFull && !needSample) {
return;
}
上面这个逻辑看起来简单,但能让你在长期运行中少踩大部分容量坑。日志写入同样不能阻塞业务线程,用 Logback 的 AsyncAppender 把 IO 丢到后台线程池,避免同步刷盘拖慢接口响应。后面我会专门讲异步日志配置。
4. 存储和采集:日志写得好,还要传得快、存得稳
4.1 用 Logback 的 AsyncAppender 把 IO 从业务线程里分离
在 Web 请求线程里直接写大型 JSON 日志,是很明显的反模式。磁盘 IO 慢的时候,接口响应时间会被日志拖长好几个数量级。Logback 的 AsyncAppender 能解决这个问题:业务线程只要把日志事件放进有界队列,立刻返回;后台线程再慢慢写文件。
xml复制<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<appender-ref ref="FILE"/>
</appender>
queueSize 控制队列容量,discardingThreshold 默认情况下,当队列剩余容量低于阈值时,会直接丢弃 INFO 以下日志;HTTP 请求摘要属于关键日志,建议把它设为 0,保证不丢。neverBlock 设为 true 后,即使队列满了也不会阻塞业务线程,而是直接丢弃日志,这是一种“丢日志也不要拖垮接口”的取舍。真实场景里,日志丢失一小部分是可以接受的,接口卡死是不能接受的。
4.2 采集到 ELK 或自建日志服务的方案
本地文件只是中间形态,真正好用的还是集中采集。常规做法是 logback 按天滚动生成日志文件,再用 Filebeat 采集到 Elasticsearch,Kibana 里做检索。Filebeat 配置不算复杂,核心就是把日志路径指过去、JSON 格式直接解析:
yaml复制filebeat.inputs:
- type: log
paths:
- /data/logs/http/*.log
output.elasticsearch:
hosts: ["http://es:9200"]
如果公司已经有日志平台,也可以直接用 HTTP 上报,但务必在采集端做异步批量发送,不要在每个请求里同步调一次上报接口。我见过一个团队把日志采集做成同步 HttpClient.post,QPS 一上去整个服务直接被日志上报拖垮,这就是典型的“要优雅没优雅到,要稳定也没稳定住”。
4.3 定期清理和容量规划
很多新项目上线时都没算过日志量的账。我给你一个参考:一条包含 request/response body 的完整 JSON 日志,少则 800 字节,多则 2KB。假设单机 QPS 是 500,平均每条 1KB,一天下来就是 500 * 1KB * 86400 = 43.2GB。三台机器跑一个月,就是将近 4TB。如果不做采样、不限制 body 大小、不设置保留周期,再大的磁盘也不够用。
所以我在容量规划时一般按三个指标估算:峰值 QPS、单条日志平均大小、日志保留天数。先算出单日容量,再决定 Sampled 比例和磁盘水位。ES 索引一般按天分,保留 7 天是一个常见起点。明文日志不要无期限保存,尤其涉及用户隐私的,该清理就清理,这也是合规要求之一。
5. 多技术栈和多协议下的优雅姿势
5.1 Nginx 层怎么记录:Access Log 够用,body 用 Lua 或流量镜像
既然题目是“记录 HTTP 请求/响应数据”,就不能只看 Java 一亩三分地。如果你的服务前面有 Nginx,而且需求只是基础访问信息,那么 Nginx 的 access log 其实是更轻的方案。自定义 log_format 可以得到客户端 IP、响应字节数、upstream 耗时等。
nginx复制log_format main '$remote_addr [$time_local] "$request" '
'status=$status body_bytes_sent=$body_bytes_sent '
'request_time=$request_time '
'upstream_response_time=$upstream_response_time '
'host=$host user_agent="$http_user_agent"';
access_log /var/log/nginx/main.log main;
但 Nginx 默认的 access log 拿不到响应 body。如果真要在 Nginx 层记录 body,可以用 Lua 的 ngx.req.get_body_data() 读取请求 body,响应 body 则需要把整个 body 缓存进 Lua 内存,成本不低。更推荐的做法是流量镜像:把请求复制一份到分析节点,由分析节点负责记录和解析。这样不影响主链路,只是需要额外一套基础设施。
5.2 Go、Python 怎么用类似思路做
Go 里最常见的做法是包装 http.RoundTripper,这样能拦到所有经由 HTTP 客户端发出的请求和响应。在中间层里拿到 Request 和 Response 之后,用 io.ReadAll 读取 body,但要记得读完后把 resp.Body 重新包装回去,否则上游业务会读不到数据。Python 则可以在 requests 库的 Session 层注册 hook,或者写一个 Django Middleware,核心套路和 Java 一模一样:临时缓冲数据,最后统一输出,脱敏逻辑复用同一个函数。
技术栈虽然不同,但本质上都在解决同样三件事:一是不要提前消费 body,二是用 buffer 把内容缓存起来供后续记录,三是输出之前先脱敏。代码语言可以变,理念不变。
5.3 HTTP/2 会影响服务端日志记录吗
服务端在应用层记录 HTTP 请求/响应数据,其实不关心底层是 HTTP/1.1 还是 HTTP/2。到了应用层,你拿到的还是 method、uri、header、body 这些抽象概念。但如果你是用 Wireshark、Fiddler 这类客户端抓包工具调试 HTTP/2 流量,就需要额外处理 TLS 解密、多路复用带来的乱序问题。这也是为什么我强调,线上排障不能只依赖抓包,应用层日志才是你能掌控的“地面部队”。抓包适合本地联调,服务端日志适合生产环境,两者互补,不能互相替代。
6. 常见问题快查与避坑技巧
6.1 响应体是 gzip,记录出来乱码怎么办
如果服务开了 Content-Encoding: gzip,你用 ContentCachingResponseWrapper 拿到的字节其实是压缩后的数据,直接转字符串就是乱码。记录前要先判断响应头里的 Content-Encoding,如果是 gzip,就用 GZIPInputStream 解压后再记。但要注意,解压大响应体很耗 CPU,建议只对压缩后大小小于 1MB 的响应做解压,或者干脆不记录这种 body,只记录压缩后的大小和耗时。排查问题时,状态码和耗时往往比 body 里的具体字节更有用。
6.2 multipart/form-data 和文件上传直接读会内存爆炸
文件上传接口的请求体很特殊,里面可能塞了几十 MB 的二进制流。你要是拿 ContentCachingRequestWrapper 全量缓存,内存直接被打爆。针对这种请求,我一般会在 readBody 时先判断 Content-Type,如果是 multipart/form-data 或者 application/octet-stream,就不读 body 内容,只记录文件字段名、文件大小、文件名。
java复制if (contentType != null && (contentType.startsWith("multipart/")
|| contentType.startsWith("application/octet-stream"))) {
return "[binary content, size=" + bytes.length + "]";
}
这样既保留了“这次请求上传了一个多少字节的文件”这个信息,又不会把二进制内容写进日志。否则日志文件会变得无比庞大,ES 索引也会被高基数文本拖垮。
6.3 同一个请求被记录两次,怎么排查
过滤器写好后,可能发现自己记录了两条几乎一样的日志。常见原因有三个:一是过滤器被注册了多次,比如同时用了 @WebFilter 和 FilterRegistrationBean;二是 Nginx 重试或客户端重试带来两次真实请求;三是某些框架内部会对请求做 forward,过滤器链执行了不止一次。最简单的方法是用请求属性加一个标记:
java复制if (request.getAttribute("HTTP_LOG_ALREADY") != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute("HTTP_LOG_ALREADY", Boolean.TRUE);
不过也不要急着过滤所有重复日志,有些重复是合理的:比如跨服务转发时,网关层记了一条,业务层又记了一条,这两条 traceId 相同,但出现在不同服务。这种信息对全链路排查反而很重要,正确的做法是通过 traceId 区分层级,而不是粗暴去重。
6.4 异步线程和虚拟线程下,traceId 出现串线
用了线程池之后,MDC 里的 traceId 可能被线程复用带到下一个任务。这是个非常隐蔽的问题。解决办法有两个:一是用 TaskDecorator 包装提交到线程池的任务,在任务执行前复制 MDC,执行后清理;二是 Java 21 的虚拟线程没有这个问题,因为每个虚拟线程独享独立上下文,但日志框架的MDC支持还得看具体版本。排查串线问题时,可以先看日志里是否连续出现同一条 traceId 且 URI 不同,如果是,基本就是线程池没有隔离上下文。
java复制public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
MDC.setContextMap(contextMap);
try {
runnable.run();
} finally {
MDC.clear();
}
};
}
}
这段代码看起来简单,但能避免非常头痛的“跨请求串日志”问题。我在一个高并发项目中,因为漏了这一步,排查耗时分布时发现某些请求的耗时数据被串到别的请求上,白白浪费了两天时间。
6.5 大促前准备:开关、压测和灰度
最后提一个平时容易被忽略的点:记录 HTTP 请求/响应数据的能力最好做成动态开关。有开关,你才能在大促前临时提高采样率,也能在磁盘告警时第一时间把完整 body 关闭。开关可以做在配置中心,也可以做在数据库里,核心是不要为了改采样率而重新发版。上线前的压测要故意把开关调到“全量记录 + 完整 body”,看看接口的 TP99 会不会明显恶化。如果会,说明异步日志或者缓存策略还没做到位,这时候调整比大促当天手忙脚乱强得多。
我在实际项目里把这些东西全部跑通之后,最大的体会是:记录 HTTP 请求/响应数据,最难的从来不是 Filter 怎么写,而是“流只能读一次”这个认知、脱敏规则的完整性、以及日志容量和业务稳定性之间的平衡。先把 traceId 和异常状态日志做起来,再逐步增加 body 记录,整个排查效率会有很明显的提升。希望这篇实操笔记也能让你少踩几个坑。
