
生产环境里最让人不安的告警往往不是服务已经宕机而是进程还活着接口也返回 200但用户端什么都看不到。那次排障开始前监控面板上已经写着一行告警某个订单查询接口连续 4 小时 37 分钟返回空响应错误率曲线是零平均响应时间也没有明显波动。表面上看服务是在“正常地 serving nothing”应用进程存活端口在监听HTTP 状态码是 200可业务结果为空用户只能看到空列表。这类问题比 500 更隐蔽因为常规监控没有触发任何红灯。沿着“从入口到数据源”的排查链路最终定位到的不只是一个空缓存而是一组错误处理逻辑的叠加下游超时被捕获后返回空集合空集合又被写入缓存且不断续期监控指标又只看状态码不看响应体长度。整条链路看起来处处都有兜底但结果就是系统在数小时内没有提供任何有效数据。下面按当时的排查顺序把思路、命令和代码拆开讲清楚。1. 先理解“serving nothing”是什么故障形态1.1 一种伪装成正常的故障“serving nothing”描述的是服务进程活着、请求能被路由、HTTP 状态码正常但返回的响应体不包含任何业务数据的状态。它可能是真正的空数据比如用户确实没有订单也可能是系统错误被吞掉后返回的空列表、空对象或白屏。这里的核心问题是调用方无法从“200 空数据”中分辨出“正常无数据”和“查询失败但被降级为空”。这种故障形态在分布式系统里很常见。单机服务如果数据库查询失败异常会向上抛出客户端会收到 500但一旦加入了降级、兜底、缓存、网关等逻辑异常就可能被某个中间层静默转换成空结果。等到监控面板看到“服务空转”时问题已经发生了很久。1.2 三种视角下的空响应要准确描述空响应最好把客户端、接入层、应用层分开看。客户端视角最直接请求返回 200但响应体长度是 0或者是一个空 JSON 对象{}又或者是一个空数组[]。在浏览器里可能是白屏在 App 里可能是空列表页面。这个视角只能证明“没有数据”不能证明原因。接入层视角看到的是上游响应码、响应时间、响应体字节数。例如 Nginx 日志里的body_bytes_sent为 0 或 2而upstream_status为 200说明应用确实返回了空内容。这个视角能帮助判断空响应来自哪一层但不能回答为什么空。应用层视角最接近根因Controller 执行完成并返回了空集合是因为查询条件没有命中还是因为异常被 catch 后返回了空集合这个视角需要结合日志、调用链、缓存状态才能判断。下面用一张表整理三种空响应形态及初步排查方向。空响应形态典型表现初步排查方向HTTP 200 空 bodybody_bytes_sent为 0Nginx/应用响应体是否被截断序列化问题HTTP 200 空 JSON{}或{data:null}对象序列化时 null 字段处理返回对象未赋值HTTP 200 空数组[]查询结果为空或 fallback 返回空集合HTTP 200 空页面HTML body 为空前端路由、后端模板渲染、静态资源问题HTTP 204没有响应体接口语义原本就是 No Content或配置错误1.3 为什么这类问题比 5xx 更难发现5xx 错误码是明确的故障信号告警规则通常会对5xx 数量、5xx 比例、错误日志数量设置阈值。而“serving nothing”在 HTTP 层是成功状态在应用层可能是一条 WARN 日志甚至没有任何日志。真正能发现空响应问题的是业务指标比如“订单列表接口返回了一万次空列表”“购物车总金额持续为 0”“用户登录后首页推荐位曝光数为 0”。这些指标需要对业务有一定的抽象而不是简单的技术指标。如果一个系统完全没有业务指标监控那么空响应持续 4 小时 37 分钟是完全可以发生的。理解这个前提后再来看当时的排障过程就会更有针对性。2. 故障案例连续 4 小时 37 分钟返回空数据2.1 现场信息与告警标题这次排障的技术栈并不特殊Java 服务部署在容器中Nginx 作为入口网关MySQL 存业务数据Redis 做接口缓存服务之间通过 HTTP 调用。告警标题就是“4 hours and 37 minutes of serving nothing”触发条件不是错误率而是业务监控里的“订单查询接口返回空数组的时长”超过阈值。现场信息整理如下接口/api/orders返回 HTTP 200响应体为{code:0,data:[]}。Nginx 访问日志显示过去 4 小时有大量请求upstream_status都是 200。应用日志中 ERROR 很少但有一类 WARN 反复出现redis timeout, fallback empty list。MySQL 和 Redis 进程都正常应用线程池没有堆积CPU 使用率也不高。用户反馈“订单列表为空”且不是单个用户是大量用户同时出现。这些信息组合起来已经能排除进程崩溃、网络分区、资源耗尽这几类问题。剩下的问题是为什么数据库和 Redis 都正常接口却一直返回空列表2.2 从告警到定位的时间线排障时间线可以简化为四个阶段。第一阶段确认“空”是真实响应。用curl -i和 Nginx 日志确认响应体确实为空数组而不是客户端渲染问题。此时记录到body_bytes_sent只有 2 字节也就是[]。第二阶段发现问题集中在读取链路。多请求几个接口后发现只有依赖订单表的接口返回空用户资料等基础接口正常。于是把排查范围从“整个服务”缩小到“订单读取链路”。第三阶段检查数据库和缓存。数据库show processlist没有看到大量慢查询连接池也没有异常。Redis 里却发现了一个可疑 keyorder:list:all值是一个空数组TTL 是 -1也就是没有过期时间。第四阶段定位到错误处理逻辑。线上代码里有一个 catch 块在 Redis 读取超时或数据库查询异常时会把空列表写入缓存然后返回成功。空列表的 TTL 被设置为 5 秒但因为每个请求都走 fallback 路径缓存不断被重写TTL 不断被刷新最终效果等于永久缓存了空列表。整个定位过程大约用了 40 分钟但距离故障开始已经过去了 4 小时 37 分钟。多出来的时间主要花在“没有第一时间把 WARN 日志和空响应关联起来”。2.3 从现象提炼的判断原则这个案例给出一条很直接的判断原则当服务存活、端口正常、错误率低但业务结果为空时优先检查“异常是否被静默降级”和“空结果是否被缓存”。不需要一开始就去抓包或者分析 GC也不需要重启服务因为问题不在进程生命周期里而在数据链路的某个分支上。另一个判断原则是观察告警标题里的“serving nothing”不要把它当成空数据而要当成“服务正在提供一种无内容的结果”。这是行为不是状态。3. 按链路逐层排查缩小空响应根因3.1 接入层验证空是 HTTP 空还是业务空排查第一步永远是从最外层确认现象。对订单接口发起一次带响应头和耗时的请求curl -i -s -X GET http://app.example.com/api/orders?userId10001 \ -H Accept: application/json如果返回内容是 200 和一个空数组就要再去 Nginx 访问日志里看一下响应体大小tail -n 200 access.log \ | grep /api/orders \ | awk {print $NF, $(NF-3), $(NF-2)}不同 Nginx 日志格式字段位置不一样但重点看body_bytes_sent和upstream_status。如果body_bytes_sent长期是 2说明应用层把空数组当正常结果返回了。如果body_bytes_sent是 0还可能是序列化器把 null 写成了空内容。接入层确认后再把网关或负载均衡的健康检查结果看一下。很多健康检查只探测/health而/health不依赖数据库所以健康检查永远正常。这一步也能解释为什么服务没有从负载均衡中摘除。注意接入层看到 200 空响应时不要急着进应用层先记录下 body 长度。这个数字在后面对比是否持续为空时非常有用。3.2 应用层看线程、连接和日志在应用层首先看进程状态确认不是资源问题top -H -p pid jstack pid | grep -A 20 http-nio-8080-exec- jstat -gcutil pid 1000 10如果线程没有长时间 BLOCKEDGC 也没有频繁 Full GC基本可以排除 CPU、线程、JVM 内存问题。接下来要看日志重点是 WARN 级别。很多团队只监控 ERROR但静默降级通常只打 WARN。推荐登进容器或服务器按关键字过滤日志grep -E NULL|empty|fallback|timeout|WARN app.log | tail -n 200如果发现类似redis timeout, fallback empty list的日志就已经接近根因了。此时要回到代码里确认哪个方法捕获了异常捕获后返回了什么返回值是否被缓存。3.3 数据层数据库与缓存数据库侧主要检查连接和长时间事务。MySQL 可以执行SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command ! Sleep ORDER BY time DESC;这个查询能列出正在执行且耗时较长的 SQL。如果time列很大说明有慢 SQL 或长事务占用了连接。接着查看 Innodb 锁等待SELECT * FROM information_schema.innodb_trx; SELECT * FROM information_schema.innodb_lock_waits;Redis 侧检查可疑 key。先按接口前缀扫描redis-cli --scan --pattern order:list:*扫描到 key 后再看值类型、内容和 TTLredis-cli --scan --pattern order:list:* | while read key; do echo key$key redis-cli type $key redis-cli strlen $key redis-cli ttl $key done如果 key 的值是空数组TTL 又一直在变化就能判断“空缓存被反复续期”的路径是否存在。注意MONITOR命令会带来性能开销生产环境不建议直接开启优先用OBJECT IDLETIME或采样日志判断。3.4 依赖层下游服务和第三方 SDK如果接口是聚合服务还要检查下游。很多空响应不是本服务产生而是下游把异常降级成了空列表本服务原样透传。检查方式是在链路追踪平台查看/api/orders的 span 数据特别关注下游服务的调用耗时。下游返回的错误码或异常信息。本服务对下游异常的处理方式。如果底层调用是 RPC 或 HTTP 客户端代码里常见的静默降级是try { ListOrder orders orderClient.getOrders(userId); return orders; } catch (Exception ex) { log.warn(fetch orders failed, use empty list, ex); return Collections.emptyList(); }这段代码的问题在于调用方收到空列表之后无法感知降级。如果后续把getOrders的返回值再缓存起来故障就会被放大成长期空数据。下面用表格归纳各层的排查对象和判定标准。排查层主要现象常用命令/工具关键判定接入层HTTP 200 但 body 小curl -i、Nginx access logbody_bytes_sent是否为 0 或 2应用层WARN 日志反复出现jstack、grep日志异常是否被 catch 后返回空集合数据层缓存 key 为空且 TTL 异常show processlist、redis-cli是否存在长事务、空缓存续期依赖层下游接口返回空数组链路追踪、RPC 日志降级逻辑是否静默返回空4. 根因定位空数据是如何被一层层放行的4.1 先复现一个最小空响应案例理解根因的最好方法是复现一个最小案例。下面是一个简化版订单接口它用一个catch块把查询异常转换为空列表。RestController public class OrderController { private static final Logger log LoggerFactory.getLogger(OrderController.class); GetMapping(/api/orders) public ResultListOrder list(RequestParam Long userId) { ListOrder orders; try { orders orderService.queryByUser(userId); } catch (Exception ex) { log.warn(query order failed, return empty list, ex); orders Collections.emptyList(); } return Result.success(orders); } }这段代码功能上能跑但存在一个语义问题Result.success(Collections.emptyList())对外表达的是“操作成功”并没有表达“查询失败”。假设orderService.queryByUser因为数据库连接池超时抛出了异常客户端看到的依然是code0和空数组。如果用这个接口做自动化测试测试用例会误以为“用户没有订单”。4.2 日志中没有异常的原因之所以应用日志中看不到 ERROR原因可能有三个。第一异常被 catch 后只打了 WARN。如果日志收集或告警只关注 ERRORWARN 就不会触发任何通知。第二日志框架配置了levelERRORWARN 被过滤掉了。第三logger的名字写错或者日志文件滚动过快信息被覆盖。所以排查空响应时不能只看有没有 ERROR还要看有没有 WARN、降级分支、缓存回源失败等弱信号。建议日志中增加一个业务字段degradedtrue让下游和监控能够识别降级结果。4.3 空值缓存如何形成长时间空结果当时更隐蔽的问题是空列表被写入了缓存。代码里可能有一段类似这样的逻辑public ListOrder queryByUser(Long userId) { String key order:list: userId; ListOrder cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } ListOrder orders queryFromDb(userId); if (orders null || orders.isEmpty()) { redisTemplate.opsForValue().set(key, Collections.emptyList(), 5, TimeUnit