一个空构造器引发的线上接口故障:记一次 1003 报错排查

一个空构造器引发的线上接口故障:记一次 1003 报错排查

一次"突然出现"的接口报错,表面看是参数问题,实际是一个月前一次无关提交顺手删掉了一个空构造器。

一、现象

前端调用"查看 Pod 事件"接口:

POST /api/k8s/pod/event/list
{ "product": "xxx", "environment": "xxx", "workloadName": "xxx" }

返回:

{ "code": 1003, "message": "Invalid parameter." }

诡异之处在于:

  • 同一组 product / environment 参数,调"工作负载列表""Pod 列表"都正常返回 200;

  • 换产品、换环境、加字段、减字段,全部 1003;

  • 更早之前这个功能是能用的,属于"突然坏了"。

二、排查过程:从外到内逐层剥离

1. 先排除"参数本身有问题"

把同一份请求体分别打给三个同族接口:

接口

结果

/k8s/workload/list

200

/k8s/pod/list

200

/k8s/pod/event/list

1003

环境配置、鉴权、权限过滤、网关路由全部走同一套逻辑,只有第三个失败 —— 说明问题在"事件查询"这条独有链路上,与入参无关。

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() { }   // ← 差别就在这一行
}

三、根因

三个条件叠加导致:

  1. Lombok 的行为:@Data 会隐式生成 @RequiredArgsConstructor,但只要类里已经显式声明了任意构造器,Lombok 就不会再生成构造器,包括无参构造器;

  2. DTO 缺少默认构造器:类里只有一个 4 参构造器,也没有 @NoArgsConstructor;

  3. Jackson 的反序列化要求:没有无参构造器、也没有 @JsonCreator 标注时,Jackson 无法实例化对象,直接抛 HttpMessageNotReadableException。

而这个下游服务的全局异常处理,把"请求体反序列化失败"统一映射成了"参数非法":

if (exception instanceof HttpMessageNotReadableException) {
    return new UniResult(CodeEnum.INVALID_PARAM);   // 1003
}

于是上游拿到 1003 原样透传,前端只看到一个毫无信息量的"参数无效"。

为什么本地/单测发现不了:调用方(上游服务)用带参构造器 new 出对象再经 Feign 序列化发出,序列化永远成功;只有在接收端反序列化那一刻才会暴露。单测覆盖"构造 DTO → 调下游"的路径时,如果没真正走一遍 HTTP 反序列化,就完全测不出来。

四、为什么"突然"才报错

代码考古给出了答案:

时间

事件

T-25 天

功能首次上线,DTO 带无参构造器,工作正常

T-19 天

一次与事件功能完全无关的重构(修改副本数编辑逻辑),顺手把构造器参数改名,同时把"默认构造方法"整段删掉了

T

测试环境重新发布,首次把这个提交带上线 → 开始 1003

关键点:缺陷代码已经在主干上躺了 19 天,但因为没重新发布,线上一直跑的是旧镜像,所以"看起来一直正常"。发布是触发器,不是根因。

这类问题的典型特征是:缺陷引入时间 ≠ 故障暴露时间,两者之间隔着一次部署。排查时如果只看"最近改了什么",很容易把范围圈错。

五、修复

补回无参构造器即可,一行:

/**
 * 默认构造方法(供 Jackson 反序列化使用)
 */
public QueryPodEventReqDTO() {
}

或者更规范地用注解,避免以后再被"手滑删掉":

@Data
@NoArgsConstructor
@AllArgsConstructor
public class QueryPodEventReqDTO { ... }

修复后重新发布下游服务,验证结果:

  • 接口返回 200,事件列表正常;

  • 指标里 exception="None" 开始增长,HttpMessageNotReadableException 不再新增。

六、复盘与可落地的防护

  1. DTO 一律显式声明无参构造器:凡是需要 JSON 反序列化的入参对象,用 @NoArgsConstructor 明确标注,不要依赖"Lombok 应该会生成"的默认认知 —— 一旦有人加了带参构造器,默认构造器就静默消失了。

  2. 异常映射不要吞掉语义:HttpMessageNotReadableException 与"业务参数校验失败"是两类问题,前者是协议层错误,后者是业务层错误。合并成同一个错误码后,排查成本从"看一眼日志"变成"翻三层服务"。至少要在日志里打出目标类型和失败字段。

  3. 善用 actuator 指标做链路定位:本例中 http_server_requests_seconds_count{uri, exception} 一次就把故障锁定到具体一跳,比逐层加日志快得多。建议所有内部服务都暴露并按 uri + exception 维度观察。

  4. 重构提交要控制爆炸半径:一次"只改副本数"的重构,删掉了另一个功能的构造器。改动无关文件时,diff review 必须逐行看被删的行,而不是只看"这次改了哪些文件"。

  5. 发布前对关键链路做一次真实调用:编译通过 + 单测通过,都无法覆盖"跨服务反序列化"这条缝。上线后用真实请求打一遍关键接口,是最便宜的兜底。

七、一句话总结

@Data + 显式构造器 = 没有无参构造器 = Jackson 反序列化必失败;
错误码把"反序列化失败"翻译成"参数无效",让一个一行的缺陷看起来像前端传错了参数,藏了 19 天。

排查经验:当"同族接口只有一个报错"且"错误码是通用参数错误"时,优先怀疑下游服务的入参绑定,而不是上游的参数。

自建 grafana 使用 JWT 进行用户认证和授权 2026-09-04

评论区