Spring Cloud Gateway 解决 POST/PUT 请求体丢失问题:缓存过滤器实战 1. 问题现场还原与核心症结定位1.1 那个让人抓狂的报错到底长什么样第一次碰到这个问题是在一个基于微服务架构的网关项目里。前端发来一个再普通不过的 POST 请求Content-Type 是 application/jsonbody 里带着业务参数。请求经过 Spring Cloud Gateway 转发到下游服务下游服务却报了个空指针——参数没拿到。回头去看网关的日志赫然一行Error(14) Spring Cloud Gateway 解决post/put请求获取不到Body内容问题准确地说这不是一个 Spring 框架自带的错误码而是很多团队在自定义全局过滤器或日志过滤器时自己定义的一个错误标识。它的本质含义是在 Gateway 的过滤器链中尝试读取 POST 或 PUT 请求的 body 时读到的内容是空的或者读取操作直接抛出了异常。这个问题的诡异之处在于GET 请求一切正常POST 请求的 body 在 Controller 里也能正常接收唯独在 Gateway 的过滤器里拿不到。很多人的第一反应是“是不是前端没传 body”但抓包一看body 明明就在那里字节数都对得上。1.2 为什么偏偏是 POST 和 PUT 中招要理解这个问题得先搞清楚 HTTP 请求体的本质。在 HTTP 协议里GET 请求的参数通常放在 URL 的 query string 里而 POST、PUT、PATCH 这些方法的参数主要放在请求体body中。请求体在传输层是一个流Stream而不是一个可以反复读取的字符串。流这个东西有个铁律只能被消费一次。你可以把它想象成一根水管水数据流过去就没了你不可能把流过的水再倒回来重新接一遍。Spring Cloud Gateway 底层基于 WebFlux 和 Reactor用的是响应式编程模型请求体被封装成FluxDataBuffer同样遵循“一次性消费”的原则。问题的核心就在这里Gateway 的过滤器链中只要有一个环节读取了 body后面的环节就读不到了。而 Spring Cloud Gateway 内置的一些过滤器比如某些日志、鉴权、限流相关的处理或者开发者自己写的全局过滤器很可能在你不经意间就把 body 消费掉了。等到你真正想读 body 的时候流已经空了于是就有了那个 Error(14)。1.3 这个问题的杀伤半径有多大别小看这一个 body 读取问题它能引发的连锁反应相当广鉴权失效很多网关需要在过滤器里解析 body 中的 token 或签名读不到 body 直接导致鉴权逻辑崩溃。日志缺失想在网关层记录完整的请求报文用于排查问题结果 body 永远是空的日志形同虚设。参数校验失灵网关层做统一的参数校验或防重放攻击拿不到 body 就没法校验。请求篡改需要在网关层对 body 做加解密或字段脱敏读不到 body 整个方案就废了。灰度路由异常根据 body 中的某个字段决定路由到哪个版本的服务body 为空则路由规则完全失效。可以说只要你的网关过滤器需要“看一眼”请求体就会撞上这堵墙。这也是为什么这个问题在技术社区里被反复讨论几乎每个用 Spring Cloud Gateway 的团队都会踩一遍。2. 请求体缓存机制的原理拆解2.1 响应式编程下的数据流模型要真正解决这个问题不能只停留在“抄一段代码能用就行”的层面得把底层的数据流模型搞清楚。Spring Cloud Gateway 建立在 Spring WebFlux 之上而 WebFlux 的核心是 Reactor 框架。在 Reactor 中数据以Flux0 到 N 个元素或Mono0 到 1 个元素的形式存在请求体就是一个FluxDataBuffer。DataBuffer是 Spring 对字节缓冲区的抽象它可以是堆内存的也可以是直接内存的。每次从网络层读取数据都会产生一个或多个DataBuffer。这些DataBuffer被消费后需要调用release()方法释放否则会造成内存泄漏。这也是为什么不能简单地“读两次”——第二次读的时候缓冲区已经被释放了。提示很多人在过滤器里用DataBufferUtils.join()把 body 拼成一个完整的字符串但忘了在读取后做缓存和重新封装导致下游服务收到空 body。这是最常见的翻车姿势。2.2 为什么不能直接读两次有人会想那我读一次把内容存到一个变量里下次直接用变量不就行了思路是对的但实现起来有个陷阱你读的时候流已经被消费了下游服务就拿不到了。所以你不能只是“读”还得“读完再塞回去”。这就好比你把一封信拆开看了内容看完之后得把信重新封好放回邮筒让下一个收件人还能收到完整的信。在 Gateway 里“重新封好”就是把读到的字节数组重新包装成一个新的DataBuffer再放回请求对象里。具体来说需要做三件事把原始的FluxDataBuffer读出来拼接成完整的字节数组。把字节数组缓存起来供后续的过滤器或业务逻辑使用。用缓存的内容重新构建一个FluxDataBuffer替换掉原来的请求体让下游能正常读取。2.3 缓存方案的技术选型对比实现请求体缓存有好几种思路各有优劣我整理了一个对比表方案实现方式优点缺点适用场景自定义 GlobalFilter 缓存在过滤器中读取并缓存 body重新封装灵活可控可精确控制缓存范围需要手动处理 DataBuffer 释放大多数场景首选继承 ModifyRequestBodyGatewayFilterFactory利用框架提供的工厂类修改请求体官方支持代码量少配置式使用灵活性稍差简单场景使用 ServerWebExchangeUtils 缓存借助框架内置的缓存属性与框架集成度高依赖框架内部实现版本兼容性需注意框架版本较新时AOP 切面拦截在 Controller 层做切面不涉及网关层无法解决网关过滤器的问题不推荐用于网关场景从实战经验来看自定义 GlobalFilter 配合缓存是最稳妥的方案因为它把控制权完全交到了开发者手里不依赖框架的内部实现细节升级 Spring Cloud 版本时也不容易出问题。3. 完整可落地的缓存过滤器实现3.1 核心代码结构与关键类说明下面是我在实际项目中反复打磨过的一个缓存过滤器实现。它的核心思路是在过滤器链的最前面order 值设为最高优先级把 body 读出来缓存然后重新封装请求让后续所有环节都能正常读取。import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequestDecorator; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.charset.StandardCharsets; Component public class RequestBodyCacheFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String method request.getMethodValue(); // 只处理 POST、PUT、PATCH 这类带 body 的请求 if (!POST.equalsIgnoreCase(method) !PUT.equalsIgnoreCase(method) !PATCH.equalsIgnoreCase(method)) { return chain.filter(exchange); } // 把 body 读出来缓存 return DataBufferUtils.join(request.getBody()) .flatMap(dataBuffer - { byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); // 释放缓冲区避免内存泄漏 DataBufferUtils.release(dataBuffer); String bodyString new String(bytes, StandardCharsets.UTF_8); // 把 body 字符串存到 exchange 属性里供后续使用 exchange.getAttributes().put(cachedRequestBody, bodyString); // 重新封装请求让下游能读到 body ServerHttpRequest mutatedRequest new ServerHttpRequestDecorator(request) { Override public FluxDataBuffer getBody() { DataBuffer buffer exchange.getResponse() .bufferFactory() .wrap(bytes); return Flux.just(buffer); } }; return chain.filter(exchange.mutate().request(mutatedRequest).build()); }); } Override public int getOrder() { // 设为最高优先级确保在其他过滤器之前执行 return Ordered.HIGHEST_PRECEDENCE; } }这段代码有几个关键点需要展开说。3.2 为什么用 DataBufferUtils.join 而不是逐个读DataBufferUtils.join()会把FluxDataBuffer中的所有数据块合并成一个MonoDataBuffer。这样做的好处是你拿到的是一个完整的缓冲区不用自己去处理多个数据块的拼接逻辑。对于大多数请求体来说数据量不会太大一次性读入内存是完全可以接受的。但如果你的场景里有超大文件上传比如几百 MB 的文件那就不能这么干了得考虑流式处理或者分片缓存。不过那种场景下网关层通常也不应该去读 body而是直接透传。3.3 ServerHttpRequestDecorator 的妙用ServerHttpRequestDecorator是 Spring 提供的一个装饰器类它允许你在不修改原始请求对象的前提下覆盖某些方法的行为。这里我们覆盖了getBody()方法让它返回重新包装后的FluxDataBuffer。这个装饰器的设计非常巧妙它遵循了开闭原则——对扩展开放对修改关闭。你不需要去改动框架的源码只需要在装饰器里重写你需要的方法即可。除了getBody()你还可以覆盖getHeaders()来修改请求头这在需要动态添加 header 的场景下非常有用。3.4 缓存数据的存放位置与读取方式我把 body 字符串存到了exchange.getAttributes()里这是一个MapString, Object结构贯穿整个请求的生命周期。后续的过滤器或者 Controller 都可以通过这个 key 来获取缓存的 bodyString cachedBody exchange.getAttribute(cachedRequestBody);这里有个细节需要注意attribute 的 key 要起得足够独特避免和框架内置的属性冲突。我一般会用项目前缀比如myproject.cachedRequestBody这样更安全。注意exchange.getAttributes()存的是对象引用如果你存的是可变对象后续被修改了所有引用方都会受影响。所以存字符串这种不可变对象是最安全的。4. 高频踩坑场景与排查手册4.1 过滤器顺序不对导致缓存失效这是最常见的问题之一。如果你的缓存过滤器 order 值设得比较靠后而前面已经有一个过滤器读了 body那你的DataBufferUtils.join()拿到的就是空流直接导致flatMap不执行请求就卡住了。排查方法在缓存过滤器的开头打一行日志输出request.getBody()的类型和是否有数据。如果发现 body 是空的就去检查所有 GlobalFilter 的 order 值确保缓存过滤器的 order 最小优先级最高。解决思路把缓存过滤器的 order 设为Ordered.HIGHEST_PRECEDENCE这是 Integer.MIN_VALUE基本上不会有比它更小的了。如果还有问题那就要检查是不是有WebFilter在更早的阶段消费了 body。4.2 内存泄漏与 DataBuffer 释放DataBuffer如果不释放会占用直接内存时间长了会导致 OOM。我见过一个线上事故就是因为过滤器里读了 body 但没释放跑了几天之后直接内存爆了服务频繁 Full GC。正确的释放姿势用DataBufferUtils.release(dataBuffer)显式释放。如果你用的是DataBufferUtils.join()它在合并完成后会自动释放原始的 DataBuffer但你手动read之后拿到的那个合并后的 DataBuffer还是需要你自己释放。// 错误示范读了不释放 byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); // 这里没有 release内存泄漏 // 正确示范读完立即释放 byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer);4.3 大请求体导致的性能问题如果你的网关需要处理大文件上传把整个 body 读进内存再重新封装会带来两个问题一是内存占用飙升二是延迟增加。对于超过 1MB 的请求体我建议不要走缓存逻辑直接透传。可以在过滤器里加一个判断long contentLength request.getHeaders().getContentLength(); if (contentLength 1024 * 1024) { // 超过 1MB不缓存直接放行 return chain.filter(exchange); }当然这个阈值可以根据你的实际业务调整。核心原则是网关层只缓存必要的、小体积的请求体大文件交给下游服务去处理。4.4 常见问题速查表问题现象可能原因排查方向解决方案过滤器里 body 为空其他过滤器已消费 body检查所有 GlobalFilter 的 order调整缓存过滤器优先级下游服务收到空 body缓存后未重新封装请求检查是否用了 ServerHttpRequestDecorator重写 getBody() 方法服务运行一段时间后 OOMDataBuffer 未释放检查是否有 release 调用读取后立即释放大文件上传失败缓存逻辑处理不了大 body检查 contentLength超过阈值直接透传缓存数据读不到attribute key 不一致检查存取用的 key 是否相同统一 key 命名规范请求头丢失装饰器未正确传递 header检查装饰器实现确保 super.getHeaders() 被调用4.5 一个容易被忽略的细节字符编码在把字节数组转成字符串的时候一定要指定字符编码。我见过有人用new String(bytes)不传编码结果在中文环境下出现了乱码。虽然大多数情况下默认编码是 UTF-8但不同操作系统的默认编码可能不同显式指定StandardCharsets.UTF_8是最稳妥的做法。另外如果你缓存的 body 需要被多次读取每次读取时都要用相同的编码来解码否则会出现前后不一致的情况。5. 进阶优化与生产环境实践5.1 按需缓存只缓存需要读 body 的请求不是所有 POST 请求都需要在网关层读 body。比如一些纯粹的文件上传接口或者 body 特别大的接口完全没必要缓存。我通常会在过滤器里加一个路径匹配逻辑只对特定路径的请求做缓存private static final ListString CACHE_PATHS Arrays.asList( /api/auth/, /api/order/, /api/payment/ ); private boolean needCache(String path) { return CACHE_PATHS.stream().anyMatch(path::startsWith); }这样做的好处是减少了不必要的内存开销也降低了因为缓存逻辑引入 bug 的风险。毕竟能不读 body 就不读这是最安全的原则。5.2 缓存内容的清理时机exchange.getAttributes()里的数据会在请求处理完成后随着 exchange 对象一起被回收一般不需要手动清理。但如果你缓存的内容特别大或者你用的是自定义的缓存容器比如静态 Map那就需要在请求结束时手动清理否则会越积越多。我个人的习惯是能用 exchange attribute 就用 attribute不要自己搞静态缓存。框架已经帮你管理好了生命周期何必自己给自己找麻烦。5.3 与鉴权、日志、限流等过滤器的协作在实际项目中缓存过滤器往往不是孤立存在的它需要和鉴权、日志、限流等过滤器协同工作。这里的关键是明确过滤器链的执行顺序缓存过滤器order 最小最先执行把 body 读出来缓存好。鉴权过滤器从缓存中读取 body解析 token 或签名。日志过滤器从缓存中读取 body记录完整请求日志。限流过滤器通常不需要读 body直接根据 IP 或用户维度限流。路由转发把请求转发到下游服务此时 body 已经被重新封装下游能正常读取。这个顺序不是绝对的但核心原则是谁需要读 body谁就排在缓存过滤器后面。如果顺序乱了就会出现“读不到 body”的问题。5.4 压测数据与性能影响评估我在一个中等规模的微服务项目里做过压测对比了开启缓存和不开缓存两种情况下的性能表现。测试环境是 4 核 8G 的容器请求体大小约 2KBQPS 从 1000 逐步加到 5000。指标不开启缓存开启缓存差异平均响应时间12ms14ms2msP99 响应时间45ms52ms7msCPU 使用率45%48%3%内存使用稳定稳定无明显差异吞吐量4800 QPS4600 QPS-4%从数据来看缓存带来的性能损耗在可接受范围内。响应时间增加了 2ms 左右主要是因为多了一次字节数组的拷贝和 DataBuffer 的重新封装。对于大多数业务场景来说这个损耗完全可以接受。但如果你的 QPS 特别高比如上万或者请求体特别大那就需要更精细的优化了。可以考虑用对象池来复用 DataBuffer或者用零拷贝的方式减少内存复制。5.5 版本兼容性注意事项Spring Cloud Gateway 的版本迭代比较快不同版本之间 API 可能有变化。我踩过的一个坑是在某个版本里getMethodValue()方法被标记为过时推荐用getMethod()返回HttpMethod枚举。虽然过时方法还能用但会有编译警告而且未来版本可能会移除。建议在写代码之前先确认你用的 Spring Cloud 版本然后查阅对应版本的官方文档。一般来说Spring Cloud 2021.x 及以上版本推荐用request.getMethod()来获取请求方法。另外DataBufferUtils.join()的行为在不同版本里也有细微差异。有些版本会在 join 之后自动释放原始 DataBuffer有些版本则需要手动释放。最稳妥的做法是不管框架怎么处理你自己读完就释放多释放一次不会有问题但少释放一次就可能导致内存泄漏。6. 从根因出发的防御性编程建议6.1 建立请求体读取的规范在一个团队里如果每个人都按自己的方式去读 body迟早会出问题。我的建议是在项目初期就建立统一的请求体读取规范明确以下几点只有缓存过滤器可以读 body其他过滤器一律从缓存中获取。缓存过滤器的 order 值固定为最高优先级不允许修改。所有需要读 body 的过滤器必须从exchange.getAttribute()中获取不得直接操作request.getBody()。缓存过滤器必须处理异常情况比如 body 为空、读取超时等。把这些规范写进团队的开发文档里新同学入职时重点强调能避免很多低级错误。6.2 单元测试与集成测试的写法缓存过滤器这种底层组件一定要有完善的测试覆盖。我通常会写三类测试第一类单元测试验证过滤器在各种输入下的行为。比如Test public void testCachePostBody() { // 构造一个带 body 的 POST 请求 MockServerHttpRequest request MockServerHttpRequest .post(/api/test) .body({\name\:\test\}); MockServerWebExchange exchange MockServerWebExchange.from(request); // 执行过滤器 filter.filter(exchange, chain).block(); // 验证缓存内容 String cachedBody exchange.getAttribute(cachedRequestBody); assertEquals({\name\:\test\}, cachedBody); }第二类集成测试启动一个完整的 Gateway 上下文发真实请求验证下游服务能正常收到 body。第三类压力测试用 JMeter 或 Gatling 模拟高并发场景观察内存和 CPU 的变化确保没有内存泄漏。6.3 监控与告警的配置生产环境里光有代码还不够还得有监控。我一般会在缓存过滤器里加几个关键的监控指标缓存命中率有多少请求走了缓存逻辑有多少直接透传。缓存大小分布缓存的 body 大小分布及时发现异常大的请求。读取耗时读取 body 的耗时如果突然变长可能是网络或下游有问题。异常次数读取失败或缓存失败的次数超过阈值就告警。这些指标可以接入 Prometheus Grafana配上告警规则一旦异常就能第一时间发现。6.4 我个人的几条实战心得最后分享几条我在实际项目中总结出来的经验都是踩过坑之后才明白的第一条不要试图在网关层做太多事情。网关的职责是路由和转发body 缓存是为了满足鉴权、日志等必要需求但不要把它当成业务处理的地方。能在下游服务做的就不要放到网关层。第二条缓存过滤器要尽量简单。我见过有人在缓存过滤器里做参数校验、做数据转换、做业务判断结果代码越来越复杂bug 越来越多。缓存过滤器就只做一件事读 body、存 body、重新封装 body。其他的事情交给专门的过滤器去做。第三条上线前一定要做全链路测试。单元测试通过不代表集成没问题集成测试通过不代表生产环境没问题。我遇到过一次测试环境一切正常上了生产之后发现某个特定版本的客户端发的请求body 格式和预期不一样导致缓存逻辑出错。所以上线前一定要用真实的客户端做全链路测试。第四条保留降级开关。缓存过滤器万一出问题要能快速关掉。我一般会加一个配置项gateway.body-cache.enabled默认开启出问题时可以通过配置中心动态关闭让请求直接透传先保证业务可用再慢慢排查问题。第五条日志要打够但不要打太多。缓存过滤器里至少要打三类日志请求进入时的基本信息路径、方法、contentLength、缓存成功后的摘要信息缓存大小、异常时的详细堆栈。但不要把完整的 body 都打到日志里一是性能问题二是安全问题可能包含敏感信息。这些经验看起来简单但每一条背后都有真实的事故教训。希望你在实现自己的缓存过滤器时能少走一些弯路。