1. 为什么默认日志里看不到POST参数
做过线上排查的人大概都遇到过这种尴尬:接口报了个错,日志里只有一行POST /api/order/submit HTTP/1.1 500,请求体里到底传了什么,完全看不到。想复现问题,只能靠猜或者让前端重新发一次。Tomcat默认的访问日志(AccessLogValve)只记录请求行、状态码、耗时这些元信息,请求体压根不进去。这不是Tomcat偷懒,而是有意的设计取舍——请求体可能很大、可能含敏感信息、可能是一次性流,贸然记录会带来性能和安全隐患。
所以“让Tomcat日志记录POST请求参数”这件事,本质上不是打开某个开关,而是要在一个正确的时机、用正确的方式,把请求体读出来并落到日志里,同时不能破坏后续业务代码对请求体的正常读取。这里面的坑比想象中多:流被提前消费导致Controller拿不到参数、大文件上传把日志撑爆、敏感字段明文落盘、异步请求时序错乱等等。这篇内容就是围绕这些真实问题,把方案、原理和踩坑经验一次讲透,适合正在做线上问题定位、接口审计或者日志体系建设的后端同学参考,无论你用的是Spring Boot内嵌Tomcat还是传统外置容器,思路是通用的。
先说清楚一个前提:请求参数的记录位置,决定了方案的复杂度。如果你只是想在应用层(Filter/Interceptor)里记,那属于Java Web的常规操作;如果你想在Tomcat容器层(Valve)记,那就要面对容器API和请求体缓存的问题。两条路各有适用场景,下面分开讲,最后给出选型建议。
2. 先搞清楚请求体为什么“读一次就没了”
在动手之前,必须理解一个底层事实:HTTP请求体在Servlet规范里是通过InputStream暴露的,而这个流默认只能被读取一次。Tomcat接收到请求后,把socket里的字节包装成CoyoteInputStream,业务代码调用request.getInputStream()或getParameter()时,底层缓冲被消费,读指针走到末尾,再读就是空的。
这就解释了一个非常经典的翻车场景:你在Filter里为了打日志,顺手调了request.getParameterMap(),结果Controller里@RequestBody接收到的对象全是null。原因就是getParameter()触发了表单解析,把流读完了。很多人第一次遇到会以为是Spring的bug,其实是Servlet规范的行为。
要解决这个问题,核心思路只有一个:把请求体提前读出来,缓存成字节数组,然后包装一个可重复读的Request对象传下去。这样日志组件读一份,业务代码读另一份,互不干扰。业界常见的ContentCachingRequestWrapper就是干这个的,但它有个坑——它是在第一次读取时才缓存,如果你在Filter里没触发读取,日志里依然是空的。所以更稳妥的做法是自己实现一个HttpServletRequestWrapper,在构造时就把body完整读进内存。
这里给一个最小可用的包装类骨架,逻辑很直白:
public class CachedBodyRequestWrapper extends HttpServletRequestWrapper { private final byte[] cachedBody; public CachedBodyRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 构造时一次性读完,后续所有读取都走缓存 this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream()); } @Override public ServletInputStream getInputStream() { ByteArrayInputStream bais = new ByteArrayInputStream(cachedBody); return new ServletInputStream() { @Override public int read() { return bais.read(); } @Override public boolean isFinished() { return bais.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener l) { /* 同步场景留空 */ } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader( new ByteArrayInputStream(cachedBody), StandardCharsets.UTF_8)); } public byte[] getCachedBody() { return cachedBody; } }注意setReadListener这里留空是有意的——异步Servlet场景下这个包装类不适用,后面会专门讲。另外getParameterMap()在包装后依然可能触发原始流的解析,所以如果你的接口既有表单提交又有JSON提交,包装类里最好把getParameter系列方法也重写,直接从缓存解析,避免二次消费。
提示:缓存body意味着内存占用等于请求体大小。对于普通JSON接口(几KB到几百KB)没问题,但如果是文件上传接口,一个100MB的请求就能把堆打满。所以包装逻辑一定要加大小阈值,超过阈值直接跳过缓存、只记录“body过大已跳过”,这是生产环境的保命线。
3. 应用层Filter方案:可控性最高的常规打法
绝大多数团队最终落地的都是Filter方案,因为它不依赖容器实现、可移植性好、能拿到Spring的上下文、方便做脱敏和采样。整体链路是:请求进来 → Filter判断是否需要记录 → 包装Request → 放行 → 业务处理 → 响应返回后在Filter里统一落日志。这里的关键设计点有几个,逐个说。
3.1 判断“哪些请求值得记”
无差别记录所有POST请求是新手最容易犯的错。一个中等流量的系统,每秒几百个请求,全量记录body会让日志文件以GB级增长,磁盘和IO都扛不住。我的做法是按需记录,判断条件通常组合这几条:请求方法为POST/PUT/PATCH、Content-Type是application/json或application/x-www-form-urlencoded、URL命中需要审计的路径前缀、或者请求头里带了特定的调试标记(比如X-Debug-Log: true)。这样既能覆盖问题排查场景,又不会把日志系统压垮。
具体实现上,可以在Filter里维护一个路径白名单/黑名单,或者用Spring的AntPathMatcher做模式匹配。我一般会配置成“默认不记,指定路径记”,因为排查需求往往是针对特定接口的。另外建议加一个采样率参数,高流量接口按百分比记录,比如10%,既能抽样定位问题,又不至于爆量。
3.2 包装时机与放行顺序
Filter的执行顺序直接决定成败。包装Request必须发生在任何会读取body的组件之前,所以这个日志Filter的order要设得很小(比如Ordered.HIGHEST_PRECEDENCE + 10),确保它在Spring的DispatcherServlet、参数解析器之前执行。放行时传入包装后的request,而不是原始request,否则下游拿到的还是不可重复读的流。
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; if (!shouldLog(request)) { chain.doFilter(req, res); return; } CachedBodyRequestWrapper wrapped = new CachedBodyRequestWrapper(request); long start = System.currentTimeMillis(); try { chain.doFilter(wrapped, res); } finally { // 在finally里记录,保证异常请求也能留下body logRequest(wrapped, System.currentTimeMillis() - start); } }把日志写在finally里是个小技巧——接口抛异常时恰恰是最需要看请求参数的时候,如果只在正常返回后记录,异常场景就漏了。这一点很多人第一次写会忽略。
3.3 脱敏与截断,两个不能省的动作
请求体里出现密码、手机号、身份证、token是家常便饭,明文落盘等于给自己埋雷。脱敏要在序列化之后、写日志之前做,常见做法是把body解析成JSON树,递归遍历把敏感key的值替换成***。敏感key列表建议做成可配置,至少覆盖password、pwd、token、secret、idCard、phone、mobile这些。
截断同样重要。有些接口的body是批量提交,动辄几MB,直接打进日志会让单行日志超长,很多日志采集组件(比如基于行的采集器)会截断甚至丢弃。我的经验是单条body超过8KB就截断,保留前8KB并追加...[truncated, total=xxx bytes],既能看到关键字段,又不至于失控。
| 控制项 | 建议值 | 说明 |
|---|---|---|
| 缓存body上限 | 1MB | 超过则不缓存,仅记录元信息 |
| 日志body截断 | 8KB | 超出部分丢弃并标注总长度 |
| 采样率 | 按接口配置 | 高频接口建议10%以下 |
| 敏感字段 | 可配置列表 | 命中即替换为掩码 |
3.4 异步请求的时序陷阱
Spring MVC的异步接口(返回Callable、DeferredResult)会让Filter的doFilter在业务真正完成前就返回,此时finally里记录的耗时和响应状态都不准,body倒是能记(因为包装时已经缓存了),但日志的时序会误导排查。处理办法是判断request.isAsyncStarted(),如果是异步请求,把日志记录逻辑挪到AsyncListener的onComplete回调里。这个细节不做,异步接口的日志时间戳会全部偏早,排查时容易得出错误结论。
4. Tomcat Valve方案:容器层的另一种选择
有些场景下应用层Filter够不着,比如你想记录所有部署在该Tomcat上的应用的请求,或者应用本身没有统一的Filter入口(老系统、多框架混布),这时候就得下沉到容器层,用Tomcat的Valve机制。Valve是Tomcat处理请求的责任链节点,AccessLogValve就是其中一个,我们可以自定义一个Valve插到链上,在invoke方法里读取请求体。
Valve方案的优点是覆盖面广、与业务框架解耦;缺点是拿不到Spring的Bean,脱敏配置、采样策略这些都得自己从配置文件读,而且请求体缓存同样要自己处理。核心代码结构大致是继承ValveBase,在invoke(Request, Response)里判断方法类型,然后通过request.getInputStream()读取——注意这里的Request是Tomcat内部接口org.apache.catalina.connector.Request,不是Servlet的HttpServletRequest,读完之后要调用request.setRequest()把包装后的对象塞回去,否则后续还是读不到。
public class PostBodyLogValve extends ValveBase { @Override public void invoke(Request request, Response response) throws IOException, ServletException { if ("POST".equalsIgnoreCase(request.getMethod())) { // 读取并缓存body,再包装回request byte[] body = readBody(request.getInputStream()); request.setRequest(new CachedBodyRequestWrapper(request, body)); // 记录日志... } getNext().invoke(request, response); } }配置上,在server.xml的<Host>节点里加一行<Valve className="...PostBodyLogValve"/>即可生效。但要提醒一句:Valve里做IO和JSON解析会同步阻塞请求线程,对吞吐有直接影响,所以这个方案更适合低流量、以审计为目的的场景,高并发系统还是老老实实用Filter加采样。
还有一个容易忽略的点:Valve的加载顺序和类加载器。自定义Valve的jar要放到Tomcat的lib目录(而不是应用的WEB-INF/lib),否则容器启动时找不到类。这个坑我在第一次部署时踩过,报ClassNotFoundException查了半天才发现是jar位置放错了。
5. 完整落地:从配置到验证的闭环
方案讲完,落到实际项目里还需要一套可操作的配置和验证流程。这里以Spring Boot内嵌Tomcat + Filter方案为例,给出一条能直接抄的落地路径。
5.1 配置项设计
把开关、阈值、脱敏字段、采样率都外置到配置文件,避免硬编码。一个典型的配置结构如下:
request-log: enabled: true include-paths: - /api/** exclude-paths: - /api/health/** max-body-size: 1048576 # 1MB,超过不缓存 truncate-size: 8192 # 日志截断8KB sample-rate: 1.0 # 1.0表示全量,高频接口调低 sensitive-keys: - password - token - idCard - phoneinclude-paths和exclude-paths用Ant风格匹配,exclude优先级高于include,这样健康检查、静态资源这类噪音就能自动排除。
5.2 验证清单
配置写完不代表能用,必须逐项验证。我一般按这个清单过一遍:
- 普通JSON接口:发一个带body的POST,确认日志里body完整、业务正常收到参数。
- 表单提交接口:
application/x-www-form-urlencoded,确认getParameter仍能取到值。 - 文件上传接口:发一个大文件,确认日志里显示“body过大已跳过”,且上传功能不受影响。
- 异常接口:故意让接口抛异常,确认
finally里仍记录了body。 - 异步接口:调用返回
Callable的接口,确认日志时间戳和实际完成时间一致。 - 敏感字段:body里带
password,确认日志里是掩码。 - 并发压测:用压测工具打一轮,观察内存和GC,确认缓存没有导致堆膨胀。
第3条和第7条是最容易被跳过、也最容易出事的。文件上传那条如果没验证,上线后一个大文件就能把服务打挂;压测那条能暴露缓存对象没及时释放的问题。
5.3 一个真实的排查案例
之前有个接口偶发参数丢失,日志里body是完整的,但业务侧收到的字段是null。排查下来发现是另一个同事在更早的Filter里调用了request.getParameter("xxx")做鉴权,把流消费了,而我们的日志Filter order更靠后,包装时读到的已经是空流——但日志里显示的却是完整body,因为那个Filter自己也缓存了一份。两个Filter各自包装、互不感知,导致下游拿到的是第一层包装的对象,第二层的缓存没生效。最后统一成一个包装入口才解决。这个案例说明:包装Request这件事,全链路只能有一个入口,多个Filter各自包装会互相打架,排查时非常隐蔽。
6. 性能、安全与长期维护的取舍
把POST参数记进日志,本质是用一点性能和存储换排查效率,所以边界要划清楚。性能上,缓存body的内存开销、JSON解析的CPU开销、写日志的IO开销三者叠加,在QPS上千的接口上不可忽视。我的经验是:核心交易链路全量记但严格截断,高频查询类接口采样记,文件上传类接口只记元信息。这个分级策略比一刀切的全量或全不记都更实用。
安全上,除了脱敏,还要注意日志文件的访问权限和保留周期。请求体里可能含用户隐私,日志文件不能谁都能读,保留时间也不宜过长,一般7到30天足够排查用。另外,日志里不要记录完整的URL query(可能含token),只记path即可。
长期维护上,建议把请求日志的开关做成运行时可变的(比如接配置中心),出问题时临时打开某个接口的记录,问题定位完再关掉。这样既不影响日常性能,又能在关键时刻拿到现场。我现在的项目就是这么做的,排查效率比翻默认日志高太多。
最后分享一个小技巧:日志里给每个请求打一个traceId,body日志和业务日志用同一个traceId串起来,排查时按traceId一搜,请求参数、业务处理、异常堆栈全在一条时间线上,比对着时间戳猜要快得多。这个traceId可以在日志Filter里生成(如果链路里还没有的话),塞进MDC,后续所有日志自动带上。