☰
空响应、缓存和降级:一个“假正常”故障的完整排障实录
2026/11/2 20:58:29 网站建设 项目流程

生产环境里最让人不安的告警,往往不是服务已经宕机,而是进程还活着,接口也返回 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 里却发现了一个可疑 key:order: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?userId=10001' \ -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

如果线程没有长时间 BLOCKED,GC 也没有频繁 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 后,再看值类型、内容和 TTL:

redis-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 { List<Order> 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 Result<List<Order>> list(@RequestParam Long userId) { List<Order> 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因为数据库连接池超时抛出了异常,客户端看到的依然是code=0和空数组。如果用这个接口做自动化测试,测试用例会误以为“用户没有订单”。

4.2 日志中没有异常的原因

之所以应用日志中看不到 ERROR,原因可能有三个。

第一,异常被 catch 后只打了 WARN。如果日志收集或告警只关注 ERROR,WARN 就不会触发任何通知。第二,日志框架配置了level=ERROR,WARN 被过滤掉了。第三,logger的名字写错,或者日志文件滚动过快,信息被覆盖。

所以排查空响应时,不能只看有没有 ERROR,还要看有没有 WARN、降级分支、缓存回源失败等弱信号。建议日志中增加一个业务字段degraded=true,让下游和监控能够识别降级结果。

4.3 空值缓存如何形成长时间空结果

当时更隐蔽的问题是空列表被写入了缓存。代码里可能有一段类似这样的逻辑:

public List<Order> queryByUser(Long userId) { String key = "order:list:" + userId; List<Order> cached = redisTemplate.opsForValue().get(key); if (cached != null) { return cached; } List<Order> orders = queryFromDb(userId); if (orders == null || orders.isEmpty()) { redisTemplate.opsForValue().set(key, Collections.emptyList(), 5, TimeUnit

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询