一次"突然出现"的接口报错,表面看是参数问题,实际是一个月前一次无关提交顺手删掉了一个空构造器。
一、现象
前端调用"查看 Pod 事件"接口:
POST /api/k8s/pod/event/list
{ "product": "xxx", "environment": "xxx", "workloadName": "xxx" }
返回:
{ "code": 1003, "message": "Invalid parameter." }
诡异之处在于:
同一组
product/environment参数,调"工作负载列表""Pod 列表"都正常返回 200;换产品、换环境、加字段、减字段,全部 1003;
更早之前这个功能是能用的,属于"突然坏了"。
二、排查过程:从外到内逐层剥离
1. 先排除"参数本身有问题"
把同一份请求体分别打给三个同族接口:
环境配置、鉴权、权限过滤、网关路由全部走同一套逻辑,只有第三个失败 —— 说明问题在"事件查询"这条独有链路上,与入参无关。
2. 再排除网关
带上完整的用户上下文请求头,绕过网关直连业务服务实例,结果依旧是 1003。网关嫌疑排除。
3. 用指标定位到具体一跳
微服务都开了 actuator,直接把 HTTP 请求指标拉出来看:
curl -s localhost:8080/actuator/prometheus | grep 'pod/event/list' | grep _count
上游业务服务:
...uri="/api/k8s/pod/event/list", exception="None" 107
下游工具服务:
...uri="/api/tools/pod/event/list", exception="HttpMessageNotReadableException" 108
到这里结论非常清晰:
上游 107 次全部正常执行完,只是把下游的返回值原样透传;
下游 108 次请求 100% 反序列化失败,一次都没成功过。
HttpMessageNotReadableException 的含义很明确:请求体 JSON 无法绑定到目标对象。
4. 代码层面对比
出问题的入参 DTO:
@Data
public class QueryPodEventReqDTO {
private AuthInfo authInfo;
private String namespace;
private String customName;
private String podName;
private String workloadName;
// 只有这一个带参构造器
public QueryPodEventReqDTO(ListPodEventReqDTO reqDTO, AuthInfo auth,
String ns, String custom) {
...
}
}
同目录下正常工作的一批 DTO:
@Data
public class QueryPodReqDTO {
...
public QueryPodReqDTO(ListPodReqDTO reqDTO, AuthInfo a, String ns, String cn) { ... }
public QueryPodReqDTO() { } // ← 差别就在这一行
}
三、根因
三个条件叠加导致:
Lombok 的行为:
@Data会隐式生成@RequiredArgsConstructor,但只要类里已经显式声明了任意构造器,Lombok 就不会再生成构造器,包括无参构造器;DTO 缺少默认构造器:类里只有一个 4 参构造器,也没有
@NoArgsConstructor;Jackson 的反序列化要求:没有无参构造器、也没有
@JsonCreator标注时,Jackson 无法实例化对象,直接抛HttpMessageNotReadableException。
而这个下游服务的全局异常处理,把"请求体反序列化失败"统一映射成了"参数非法":
if (exception instanceof HttpMessageNotReadableException) {
return new UniResult(CodeEnum.INVALID_PARAM); // 1003
}
于是上游拿到 1003 原样透传,前端只看到一个毫无信息量的"参数无效"。
为什么本地/单测发现不了:调用方(上游服务)用带参构造器 new 出对象再经 Feign 序列化发出,序列化永远成功;只有在接收端反序列化那一刻才会暴露。单测覆盖"构造 DTO → 调下游"的路径时,如果没真正走一遍 HTTP 反序列化,就完全测不出来。
四、为什么"突然"才报错
代码考古给出了答案:
关键点:缺陷代码已经在主干上躺了 19 天,但因为没重新发布,线上一直跑的是旧镜像,所以"看起来一直正常"。发布是触发器,不是根因。
这类问题的典型特征是:缺陷引入时间 ≠ 故障暴露时间,两者之间隔着一次部署。排查时如果只看"最近改了什么",很容易把范围圈错。
五、修复
补回无参构造器即可,一行:
/**
* 默认构造方法(供 Jackson 反序列化使用)
*/
public QueryPodEventReqDTO() {
}
或者更规范地用注解,避免以后再被"手滑删掉":
@Data
@NoArgsConstructor
@AllArgsConstructor
public class QueryPodEventReqDTO { ... }
修复后重新发布下游服务,验证结果:
接口返回 200,事件列表正常;
指标里
exception="None"开始增长,HttpMessageNotReadableException不再新增。
六、复盘与可落地的防护
DTO 一律显式声明无参构造器:凡是需要 JSON 反序列化的入参对象,用
@NoArgsConstructor明确标注,不要依赖"Lombok 应该会生成"的默认认知 —— 一旦有人加了带参构造器,默认构造器就静默消失了。异常映射不要吞掉语义:
HttpMessageNotReadableException与"业务参数校验失败"是两类问题,前者是协议层错误,后者是业务层错误。合并成同一个错误码后,排查成本从"看一眼日志"变成"翻三层服务"。至少要在日志里打出目标类型和失败字段。善用 actuator 指标做链路定位:本例中
http_server_requests_seconds_count{uri, exception}一次就把故障锁定到具体一跳,比逐层加日志快得多。建议所有内部服务都暴露并按uri + exception维度观察。重构提交要控制爆炸半径:一次"只改副本数"的重构,删掉了另一个功能的构造器。改动无关文件时,diff review 必须逐行看被删的行,而不是只看"这次改了哪些文件"。
发布前对关键链路做一次真实调用:编译通过 + 单测通过,都无法覆盖"跨服务反序列化"这条缝。上线后用真实请求打一遍关键接口,是最便宜的兜底。
七、一句话总结
@Data + 显式构造器 = 没有无参构造器 = Jackson 反序列化必失败;
错误码把"反序列化失败"翻译成"参数无效",让一个一行的缺陷看起来像前端传错了参数,藏了 19 天。
排查经验:当"同族接口只有一个报错"且"错误码是通用参数错误"时,优先怀疑下游服务的入参绑定,而不是上游的参数。