1. 问题现场还原与核心症结定位
Spring Cloud Gateway 作为微服务架构里的统一入口,很多团队都会在网关层做鉴权、日志、限流、参数校验这类全局处理。只要涉及 POST 或 PUT 请求,几乎必然会碰到一个让人抓狂的现象:在过滤器里调用exchange.getRequest().getBody()拿到的Flux<DataBuffer>是空的,或者读了一次之后下游服务收到的请求体变成了空字符串,接口直接报 400 或者参数缺失。
这个问题的典型报错形态有两种。第一种是过滤器里读不到任何内容,DataBuffer的 readableByteCount 为 0;第二种更隐蔽,过滤器里读到了内容,但转发给下游的请求体被"消费"掉了,下游控制器收到的是空 body,日志里能看到请求正常路由,但业务参数全是 null。很多人第一反应是"网关有 bug",其实这是 Reactor 响应式编程模型下请求体只能被消费一次的固有特性导致的,跟框架本身没关系。
我先把结论摆在这里:Spring Cloud Gateway 的请求体是一个只能被订阅一次的响应式流,任何一次读取都会消耗掉它,后续再读就是空的。理解这句话,后面所有的解决方案都是围绕"如何在不破坏下游读取的前提下,把 body 缓存下来复用"展开的。
这篇文章面向的是正在用 Spring Cloud Gateway 做网关层开发的同学,尤其是需要在过滤器里读取或修改请求体的场景。不管你是刚接触 WebFlux 的新手,还是已经踩过几次坑的老手,我都会把原理、方案选型、完整代码、参数计算和排查技巧讲透,让你能直接抄作业落地。
1.1 为什么请求体只能读一次
要搞清楚这个问题,得先理解 WebFlux 的数据流模型。在传统的 Servlet 栈里,HttpServletRequest的getInputStream()虽然也不能重复读,但你可以通过包装类把字节数组缓存到内存里,反复读取。到了 WebFlux 这套响应式体系,请求体被抽象成Flux<DataBuffer>,它是一个发布者,数据是"推"给订阅者的,订阅一次数据就流走了,发布者不会为你重放。
DataBuffer本身还涉及内存池管理,Netty 的PooledByteBufAllocator会复用缓冲区,读完必须release(),否则会内存泄漏。这就解释了为什么很多人读完之后下游拿不到数据——不是数据被"删"了,而是缓冲区已经被释放归还给池子了。
提示:如果你在过滤器里直接
getBody()然后subscribe()打印日志,大概率会发现下游请求体变空,这就是典型的"读一次就没了"。
1.2 错误(14)这类报错的常见触发路径
标题里提到的 Error(14),在实际项目里通常对应几种情况。一种是过滤器里读取 body 后没有正确重新封装请求,导致下游解析失败;另一种是在GlobalFilter里做了DataBufferUtils.join之后忘记把新的 body 写回ServerHttpRequest,请求继续往下走时 body 已经空了。还有一种是在GatewayFilter的filter方法里对Mono做了阻塞式操作,触发了 Reactor 的线程模型冲突。
这些路径最终都指向同一个根因:请求体的生命周期管理没做对。下面我会从方案设计开始,一步步把正确的做法拆开讲。
2. 方案选型:缓存请求体的三种主流思路
在动手写代码之前,先想清楚用哪种方案。市面上关于"网关读取 body"的做法大致有三类,各有适用场景和代价,选错了后面维护会很痛苦。
2.1 方案一:在过滤器中缓存并重新封装请求
这是最通用的做法。核心思路是在过滤器里把Flux<DataBuffer>通过DataBufferUtils.join聚合成一个完整的DataBuffer,转成字节数组缓存起来,然后用ServerHttpRequestDecorator包装原始请求,重写getBody()方法返回一个新的Flux,这样下游还能正常读到数据。
这个方案的优点是灵活,你可以在缓存后做任意处理——打印日志、修改内容、做签名校验。缺点是会把整个请求体加载到内存,大文件上传场景要特别小心。适合请求体体积可控的业务接口,比如 JSON 报文为主的 API 网关。
2.2 方案二:用内置的 ModifyRequestBody 过滤器
Spring Cloud Gateway 自带ModifyRequestBodyGatewayFilterFactory,可以在配置里声明式地修改请求体。它的底层其实也是缓存加重新封装,只是把细节封装好了。适合只需要简单改写 body 的场景,比如统一加字段、脱敏。
但这个方案的局限很明显:它主要面向"修改"而非"读取",如果你只是想在过滤器里读一下 body 做鉴权,用它反而绕远了。而且配置式的写法在复杂逻辑下不够灵活。
2.3 方案三:把 body 读取下沉到业务层
还有一种思路是干脆不在网关读 body,把需要 body 的逻辑(比如签名校验)放到下游服务或者独立的鉴权服务里。网关只做路由和轻量级 header 校验。这个方案对性能最友好,但牺牲了网关层的统一处理能力,适合对网关性能要求极高、且业务能接受逻辑分散的团队。
三种方案的对比我整理成表格,方便你按场景选:
| 方案 | 适用场景 | 内存开销 | 灵活性 | 实现复杂度 |
|---|---|---|---|---|
| 过滤器缓存重封装 | 需要读取/修改 body 的通用场景 | 中高 | 高 | 中 |
| ModifyRequestBody | 简单改写 body | 中 | 低 | 低 |
| 下沉业务层 | 高性能、逻辑可分散 | 低 | 低 | 低 |
我个人的经验是,绝大多数中小规模项目直接用方案一就够了,代码可控,排查问题也方便。下面重点讲方案一的完整实现。
3. 核心实现:缓存请求体的完整代码拆解
这一节是全文的重点,我会把每一步的意图、参数、注意事项都讲清楚。代码基于 Spring Cloud Gateway 的GlobalFilter实现,你可以直接复制到项目里改改就能用。
3.1 定义全局过滤器与执行顺序
首先定义一个实现GlobalFilter和Ordered的过滤器。顺序很关键,缓存 body 的过滤器必须排在所有需要读取 body 的过滤器之前。通常我会给它一个比较小的 order 值,比如 -100,确保它最先执行。
@Component public class CacheBodyGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); HttpMethod method = request.getMethod(); // 只处理有请求体的方法 if (method == HttpMethod.POST || method == HttpMethod.PUT || method == HttpMethod.PATCH) { return DataBufferUtils.join(request.getBody()) .flatMap(dataBuffer -> { byte[] bytes = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); // 关键:释放缓冲区,避免内存泄漏 DataBufferUtils.release(dataBuffer); // 把 body 缓存到 exchange 属性里,供后续过滤器使用 exchange.getAttributes().put("cachedRequestBody", bytes); // 重新封装请求,让下游能继续读到 body ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(request) { @Override public Flux<DataBuffer> getBody() { return Flux.defer(() -> { DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes); return Mono.just(buffer); }); } }; return chain.filter(exchange.mutate().request(decorator).build()); }); } return chain.filter(exchange); } @Override public int getOrder() { return -100; } }这段代码有几个点必须强调。第一,DataBufferUtils.join会把整个 body 聚合成一个DataBuffer,如果请求体很大,这里会占用对应大小的堆外内存。第二,dataBuffer.read(bytes)之后一定要release,否则 Netty 的池化缓冲区不会归还,跑一段时间就会 OOM。第三,重新封装时用Flux.defer而不是直接Mono.just,是为了保证每次订阅都生成新的 buffer,避免同一个 buffer 被多次消费。
3.2 用 ServerHttpRequestDecorator 重写 getBody
ServerHttpRequestDecorator是 Spring 提供的装饰器基类,它默认把所有方法委托给被装饰的请求。我们只需要重写getBody(),返回一个基于缓存字节数组的新Flux。这样下游服务读取 body 时,拿到的是我们重新生成的流,跟原始请求体内容完全一致。
这里有个容易踩的坑:如果你重写getBody()时直接返回Mono.just(buffer)而不加Flux.defer,在某些场景下同一个 buffer 会被多次订阅,第二次订阅时 buffer 已经被读空了。用Flux.defer包一层,每次订阅都重新wrap一个新的 buffer,就安全了。
注意:
exchange.getResponse().bufferFactory()拿到的工厂跟请求的 bufferFactory 是同一个,用它来 wrap 字节数组是标准做法,不要自己 new 一个 DefaultDataBufferFactory,否则可能跟 Netty 的内存管理策略不匹配。
3.3 缓存数据的读取与线程安全
缓存到exchange.getAttributes()里的字节数组,后续过滤器可以直接取出来用。比如鉴权过滤器里:
byte[] cachedBody = exchange.getAttribute("cachedRequestBody"); if (cachedBody != null) { String bodyStr = new String(cachedBody, StandardCharsets.UTF_8); // 做签名校验、日志打印等 }exchange.getAttributes()底层是一个ConcurrentHashMap,在同一个请求的处理链路里是线程安全的。但要注意,这个 map 的生命周期跟 exchange 绑定,请求处理完就销毁了,不要试图跨请求共享。
另外,如果你缓存的是大对象,记得评估内存占用。一个 1MB 的请求体,在网关并发 1000 的情况下就是 1GB 的堆外内存,这个账要提前算清楚。
3.4 处理空 body 与异常边界
不是所有 POST 请求都有 body,比如某些心跳接口。DataBufferUtils.join在 body 为空时会返回一个空的Mono,flatMap不会执行,请求会直接往下走,这是符合预期的。但如果你在flatMap里做了非空假设,就会 NPE。
我的做法是在flatMap之前加一个默认值处理:
return DataBufferUtils.join(request.getBody()) .defaultIfEmpty(exchange.getResponse().bufferFactory().wrap(new byte[0])) .flatMap(dataBuffer -> { ... });这样即使 body 为空,也能走统一的处理逻辑,避免分支遗漏。异常边界方面,DataBufferUtils.join可能因为客户端中断连接而抛错,建议用onErrorResume兜底,返回一个友好的错误响应,而不是让异常直接冒到全局异常处理器。
4. 实操过程与关键环节记录
光看代码还不够,实际落地时有很多细节决定成败。这一节我把完整的实操流程和现场记录整理出来,包括参数计算、配置调整和验证方法。
4.1 从零搭建一个可复现的验证环境
要验证 body 缓存是否生效,最直接的办法是搭一个最小的网关加下游服务。网关用 Spring Cloud Gateway,下游用一个简单的 Spring Boot WebFlux 或者 MVC 服务,暴露一个 POST 接口打印收到的 body。
配置上,网关的路由可以这样写:
spring: cloud: gateway: routes: - id: body-test uri: http://localhost:8081 predicates: - Path=/api/test/**下游服务里加一个控制器:
@PostMapping("/api/test/echo") public Mono<String> echo(@RequestBody String body) { return Mono.just("received: " + body); }启动后,用 curl 发一个 POST 请求,观察网关日志和下游返回。如果下游能正确打印出 body,说明缓存重封装生效了。
4.2 参数计算:缓冲区大小与并发量的关系
前面提到内存开销,这里给一个粗略的计算方法。假设单个请求体平均大小为 S 字节,网关并发处理请求数为 C,那么峰值堆外内存占用约为 S × C。比如 S = 10KB,C = 2000,占用约 20MB,这个量级完全可控。但如果 S = 5MB(比如图片上传),C = 500,就是 2.5GB,这就很危险了。
所以我的建议是:对请求体大小做限制。可以在网关层加一个MaxInMemorySize配置,或者用spring.codec.max-in-memory-size控制。超过阈值的请求直接拒绝,返回 413,避免内存被打爆。
spring: codec: max-in-memory-size: 2MB这个值要根据你的业务实际调整,JSON 接口一般 1-2MB 足够,文件上传类接口建议走单独的路径,不要跟普通 API 混在一起。
4.3 验证缓存是否生效的三种手段
第一种是日志验证。在缓存过滤器里打印 body 长度,在鉴权过滤器里打印读取到的内容,对比是否一致。第二种是下游回显验证,就是上面那个 echo 接口,看下游收到的 body 跟客户端发的是否完全相同。第三种是抓包验证,用抓包工具看网关转发出去的请求体是否完整。
我一般三种都用,尤其是第三种,能发现一些代码层面看不出来的问题,比如 Content-Length 不匹配、Transfer-Encoding 异常等。有一次就遇到过重封装后 Content-Length 没更新,导致下游解析截断的问题,抓包一看就定位了。
4.4 修改请求体内容的进阶玩法
缓存 body 之后,除了读取,你还可以修改。比如统一给请求体加一个字段,或者对敏感信息脱敏。做法是在缓存字节数组之后,先做转换,再重新封装:
byte[] bytes = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); // 修改内容 String original = new String(bytes, StandardCharsets.UTF_8); String modified = original.replace("\"phone\":\"", "\"phone\":\"****"); byte[] newBytes = modified.getBytes(StandardCharsets.UTF_8); exchange.getAttributes().put("cachedRequestBody", newBytes);注意修改后要同步更新Content-Length,否则下游可能读不全。可以在装饰器里重写getHeaders(),把Content-Length设置为新字节数组的长度。这一步很多人会漏,导致下游收到的 body 被截断,排查起来很费时间。
5. 常见问题与排查技巧实录
这一节是我这些年踩坑攒下来的经验,基本都是文档里不会写的。整理成速查表,遇到问题直接对号入座。
5.1 典型问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 过滤器读不到 body | 读取时机太晚,body 已被消费 | 检查过滤器 order 值 | 把缓存过滤器 order 调到最小 |
| 下游收到空 body | 读取后未重新封装 | 检查是否用了 Decorator | 重写 getBody 返回新 Flux |
| 内存持续增长 | DataBuffer 未释放 | 检查 release 调用 | 读取后立即 release |
| 大请求体 OOM | 全量加载到内存 | 查看请求体大小分布 | 限制 max-in-memory-size |
| 下游解析截断 | Content-Length 未更新 | 抓包看长度 | 重写 getHeaders 更新长度 |
| 偶发读取为空 | buffer 被多次订阅 | 检查是否用 Flux.defer | 用 defer 包裹 buffer 生成 |
5.2 独家避坑技巧
第一个技巧:不要在过滤器里用 subscribe 手动消费 body。很多人图省事,直接getBody().subscribe(...)打印日志,结果下游拿不到数据。正确做法永远是 join 加重新封装,让框架去管理订阅。
第二个技巧:注意 GET 请求的 body 问题。虽然规范上 GET 不应该有 body,但有些客户端会发。如果你在过滤器里对所有方法都做 join,GET 请求可能会因为 body 为空而走异常分支。建议只对 POST、PUT、PATCH 做处理,GET 直接放行。
第三个技巧:测试时用真实大小的请求体。我见过有人用{"a":1}测试一切正常,上线后遇到几百 KB 的报文就出问题。测试数据要贴近生产,尤其是边界值。
第四个技巧:关注 Reactor 的调试模式。在开发环境开启Hooks.onOperatorDebug(),能打印出响应式链路的订阅堆栈,定位"body 被谁消费了"这类问题特别有用。生产环境不要开,有性能开销。
5.3 性能优化的几个方向
如果网关 QPS 很高,body 缓存这块会成为瓶颈。优化方向有几个:一是尽量缩小缓存范围,只对需要读取 body 的路由启用;二是用对象池复用字节数组,减少 GC 压力;三是把非必要的 body 处理逻辑异步化,不要阻塞主链路。
还有一个容易被忽视的点:Netty 的 direct memory 监控。堆外内存出问题不像堆内存那样容易发现,建议接入 Micrometer 或者类似的监控,把bufferPool的指标暴露出来,设置告警阈值。
6. 我个人在实际项目中的几点体会
最后聊点实在的。这套 body 缓存的方案我在好几个项目里用过,从日请求量几十万到上千万的都有,稳定性没问题,但前提是把细节做对。
最大的体会是:响应式编程里,"数据流只能消费一次"这个心智模型必须刻进骨子里。很多 bug 不是代码写错了,而是思维方式还停留在 Servlet 时代。一旦你习惯了用"发布-订阅"的视角看问题,很多坑自然就避开了。
另外,网关层的每一行代码都要考虑放大效应。你在过滤器里多占 1KB 内存,乘以并发量就是很大的数字。所以能不加的逻辑就不加,能下沉的就下沉,网关保持轻量才是长久之计。
如果后续要扩展,可以考虑把缓存的 body 做成可配置的开关,按路由维度控制,这样不同业务线可以按需启用,既灵活又安全。这个思路我在一个多团队共用的网关里实践过,效果不错,值得一试。