不规范的枚举类代码引发的一场事故
今天开发跟我反馈了一个很奇怪的问题,说有个对象的状态属性是枚举类,设置了该对象的状态后,插入数据库,这个状态没了,凭空消失了,变成了空白字符串。这让人感觉非常奇怪,我参与了整个问题排查过程,得到的结论和枚举类的规范有关系,我将过程总结在这里提供大家一起学习交流。
问题代码
让我们先看看出问题的部分代码是什么样子的:
@Override public String insert(PayRequest payRequest) { // 省略部分无关代码 PayRequestDO payRequestDO = convertor.toDO(payRequest); payMapper.insert(payRequestDO); return payRequest.getPayNo(); }
这个方法很简单,就是把传过来的 PayRequest 对象转成 PayRequestDO 对象,然后插入数据库。
PayRequest 和 PayRequestDO 都是普通的 pojo 对象,没什么复杂的,只是 PayRequestDO 的 status 换成了 String:
public class PayRequest { private String payId; private String payNo; private Status status; // 省略其它属性和 getter setter }
public class PayRequestDO { private String payId; private String payNo; private String status; // 省略其它属性和 getter setter }
至于 PayConvertor#toDO 方法,也很简单,就是属性拷贝:
public PayRequestDO toDO(PayRequest payRequest) { PayRequestDO payRequestDO = new PayRequestDO(); payRequestDO.setPayId(payRequest.getPayId()); payRequestDO.setPayNo(payRequest.getPayNo()); payRequestDO.setStatus(payRequest.getStatus().getCode()); // 省略其它代码 return payRequestDO; }
开发一再强调,入参的 PayRequest 里面的 status 一定是有值的,而且是写死的,根本不可能是空,然后这些代码也多次检查过了,mybatis 的 mapper xml 写的也绝对没有问题,但是插入数据库就是没值,WHY ?
排查问题
先简单花些时间,排除掉一些写了代码没发布、或是部署错了版本等等类似的低级问题,确保服务器上面跑的代码就是上面贴出来的代码,这一点非常非常重要,永远是查问题时第一件要做的事情(其实大部分的问题在这一步就可以得到解决)。
这里提供两种快速确定线上的代码版本的方案:
方案一:使用 git-commit-id-plugin maven 插件
开启 spring boot 的 info actuator:
# 配置开放的 Actuator 端点,开放 endpoint 需要注意数据安全,可以配置不同的 management port 或脱敏敏感内容 management.endpoints.web.exposure.include=info
mvn packge 构建并以 java -jar 启动后,接下来就可以访问 localhost:8080/actuator/info 来获得当前的 git 提交信息:
{ "git":{ "commit":{ "time":{ "epochSecond":17011234567, "nano":0 }, "id":"1234567" }, "branch":"master" } }
通过这个 commit id 就可以找到代码具体是哪个版本。
方案二:
如果提前没有集成过 git maven 插件,或者没有打开 info endpoint。你还可以把 fat jar 包 down 下来,通过反编译来确定代码版本。当然也有一些在线就可以 dump 代码的方案,例如下面即将出场的 Arthas 有个 jad 命令,还有 JDK 自带的 HSDB,也可以直接 dump 内存中的 class 到本地磁盘,感兴趣可以自行搜索。
------------------------------ 分割线 ------------------------------
排除掉了低级问题,接下来我们分析问题出在哪里。因为问题的表现就是插到数据库里面值丢了,我们可以先看下 db 的 digest 日志分析下:
2023-11-14 21:06:04.221|PayCenter|00|8||N|trace8423002774916857900o38o50|payCenter|pay_request_record|pay_request_record|INSERT|insert into pay_request (id, pay_id, pay_no, status) values (null, 'pay2023001', '20231114000000001', '')|0|ServiceHandler-11.2.60.188:20880-thread-8|0.1
通过上面的 digest 日志的 sql 可以看出来,insert sql 里面的 status 字段,传的就已经是 '' 空白字符了,这说明问题不是发生在 orm 框架里面,这里排除掉了 xml 中 sql 的语句写的不对的问题。所以问题一定发生在插入数据库之前的业务方法中。
接下来,我怀疑入参传过来的 PayRequest 里面 status 字段是没值的,我需要看下正在运行中的线程栈中 PayRequest 对象的属性值,这个场景非常适合使用 Arthas 的 watch 方法,我的 idea 里面装了一个 Arthas 的插件(名字叫 Arthas Idea by 汪小哥),我们只需要在 insert 上面右键选择 Arthas Command,选择 Watch 子菜单,就可以拿到 watch 命令了,非常方便。
接下来我们登录到服务器上,切换到 admin 用户(Arthas 启动要求你用和 java 启动同一个用户),输入刚刚拷贝下来的命令:
watch com.xxxxx.paycenter.service.repository.impl.PayRequestRepositoryImpl insert '{params,returnObj,throwExp}' -n 1 -x 3
接着我们等待方法执行到 insert,就可以观察 Arthas watch 输出的内容:
method=com.xxxxx.paycenter.service.repository.impl.PayRequestRepositoryImpl$$EnhancerBySpringCGLIB$$4f979dec.insert location=AtExit ts=2023-11-15 14:54:49; [cost=6.001557ms] result=@ArrayList[ @Object[][ @PayRequest[ serialVersionUID=@Long[1], payId=@String[pay2023001], payNo=@String[20231114000000001], status=@Status[INIT], // ... ], ], @String[20231114000000001], null, ]
可以清楚地看到,这里入参的时候 status 属性还是有值的:Status.INIT
接下来,我们去看 mapper 的写数据的时候,status 属性还在不在,同样用 Arthas watch 命令:
watch com.xxxxx.paycenter.infrastructure.dal.mapper.PayMapper insert '{params,returnObj,throwExp}' -n 1 -x 3
接着等待方法执行到 mapper 的 insert,观察 Arthas watch 到的内容:
Affect(class count: 2 , method count: 1) cost in 289 ms, listenerId: 10 method=com.sun.proxy.$Proxy153.insert location=AtExit ts=2023-11-15 14:51:36; [cost=3.933553ms] result=@ArrayList[ @Object[][ @PayRequestDO[ id=null, payId=@String[pay2023002], payNo=@String[20231114000000002], status=@String[], // ... ], ], @Integer[1], null, ]
可以看到,很明显,到插入数据库时候,status 已经变成空了!!!
入参的时候有,写入数据库的时候没了,那说明唯一的问题,就在中间的对象转换方法 PayConvertor#toDO 了
用 Arthas watch 一下 toDO 的入参和出参:
watch com.xxxxx.paycenter.core.convertor.PayConvertor toDO '{params,returnObj,throwExp}' -n 1 -x 3
输出:
method=com.xxxxx.paycenter.core.convertor.PayConvertor.toDO location=AtExit ts=2023-11-15 15:01:35; [cost=0.432887ms] result=@ArrayList[ @Object[][ @PayRequest[ serialVersionUID=@Long[1], payId=@String[pay2023003], payNo=@String[20231114000000003], status=@Status[INIT], // ... ], ], @PayRequestDO[ id=null, payId=@String[pay2023003], payNo=@String[20231114000000003], status=@String[], // ... ], null, ]
看输出结果,问题确实发生在 toDO 的内部,数据转换后 status 的属性没了
确切来说,下面这行代码,丢了属性:
payRequestDO.setStatus(payRequest.getStatus().getCode());
找到原因
至此我们发现了原因,大概是 status 属性背后的枚举类 Status,在 getCode 的时候返回了空。
Status 的代码如下:
public enum Status { INIT("INIT", "初始态"), SUCCESS("SUCCESS", "成功"), FAILED("FAILED", "失败"), ; private String code; private String desc; Status(String code, String desc) { this.code = code; this.desc = desc; } public String getCode() { return code; } public void setCode(String code) { this.code = code; } public String getDesc() { return desc; } public void setDesc(String desc) { this.desc = desc; } }
看着枚举类属性的 setter 方法,我不由得陷入了沉思:为什么一个枚举类的属性,要提供 setter 方法?
通常来说枚举类的属性,一定要设置为 final 关键字修饰,不能提供 setter 方法。试想下如果我按照下面的方式通过 setter 把 FAILED 和 SUCCESS 的 code 换过来,那这代码还能不能继续愉快的玩耍下去了?
Status.FAILED.setCode("SUCCESS"); Status.SUCCESS.setCode("FAILED");
很显然这里提供的 setter 调用直接破坏了枚举类,所以,最好的办法就是为枚举类属性加上 final。
接下来通过 watch Status 的 '{target}' 参数,'{target}' 可以打印对象内部的状态,结果输出也进一步验证了我的猜想,Status 枚举类部分枚举的 code 属性已经成了空白字符串了:
watch com.xxxxx.paycenter.core.enums.Status getCode '{target}' -n 1 -x 3 method=com.xxxxx.paycenter.core.enums.Status.getCode location=AtExit ts=2023-11-15 15:05:36; [cost=0.005638ms] result=@ArrayList[ @Status[ INIT=@Status[ INIT=@Status[INIT], SUCCESS=@Status[SUCCESS], FAILED=@Status[FAILED], code=@String[], desc=@String[], name=@String[INIT], ordinal=@Integer[0], ],
根本原因
直接原因基本上已经找到了,接下来我们还需要知道到底是在哪里、出于什么需求调用了枚举类的 setCode 方法,因为我在整个项目里面没有搜到显示的调用,所以修改下枚举的 setCode,增加一些代码以便能在 setCode 被调用的时候打印一下调用栈出来:
public void setCode(String code) { try { throw new RuntimeException(); } catch (Exception e) { log.error("code before: {}, after: {}", this.code, code, e); } this.code = code; }
有的小伙伴反馈抛异常来看堆栈太丑了,这也提供一个不用抛异常的方案:
public void setCode(String code) { StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); log.error("code before: {}, after: {}", this.code, code, formatStackTrace(stackTrace)); this.code = code; } public static String formatStackTrace(StackTraceElement[] stackTrace) { StringBuilder stringBuilder = new StringBuilder(); for (StackTraceElement element : stackTrace) { stringBuilder.append(element.getClassName()) .append(".") .append(element.getMethodName()) .append("(") .append(element.getFileName()) .append(":") .append(element.getLineNumber()) .append(")") .append(System.lineSeparator()); } return stringBuilder.toString(); }
增加了代码后发布上去,很快打印出来了堆栈:
这是 podam 这个第三方的库引起的问题(始作俑者还是我引入的这个库),这个库的作用是可以通过传一个 class 对象,解析出来它的属性,进行赋值,简单来说就是根据 class 生成随机对象随机属性,测试的工具会用到这个功能,这个库在解析枚举类的时候可能没实现好,导致了通过反射调用了枚举的 setter 方法,最终导致了问题。
改进措施
我们从一个数据库插入属性丢失的问题排查,最终发现问题的原因是枚举类写的不规范导致的问题。
首要的是写代码还是要注意规范,最好本地装一些扫描工具,例如 sonar,发现风险一定要尽快按照建议修复。
其次是 podam 这个第三方的库对枚举的实现方式还是有问题的,需要尽快修复掉这个 bug。
最后我也在联系代码扫描的团队,看看能不能把这样的 case 落地成平台检测能力,提交代码的时候就扫描问题,这样可以把问题扼杀在摇篮里面。
可喜的是,我联系了代码扫描的同事,我司确实有这样一个检测,只是没有加到代码扫描的规则里面,在我的建议下,这个规则加到了代码扫描。
作者|萧易
本文为阿里云原创内容,未经允许不得转载。

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
使用 Helm Chart 部署分布式 GreptimeDB
GreptimeDB 作为云时代基础设施的时序数据库,从第一天开始就积极拥抱云原生技术,将数据库部署在 Kubernetes 上可以提供可伸缩性、自愈能力和简化的部署和管理,从而为应用程序提供了强大的弹性和可靠性。 Helm 是一个用于管理 Kubernetes 应用程序的包管理器,通过使用 Helm Chart,可以轻松地将应用程序打包、配置和部署到 Kubernetes 集群中。 本篇文章将讲解如何使用 Helm Chart 部署分布式 GreptimeDB,并将数据保存在 AWS S3 以及阿里云 OSS 等对象存储上。 配置 Helm Chart 环境 首先需要安装 Helm 工具,可以根据 安装文档 中的说明进行安装。 在部署应用程序之前,需要将 greptime 仓库 添加到 Helm 中,仓库包含了一系列可用的 Helm Charts。使用以下命令将 greptime 仓库添加到 Helm: helm repo add greptime https://greptimeteam.github.io/helm-charts/ helm repo update 使用以下命令浏...
- 下一篇
Socket.D 网络应用层协议,v2.1.15 发布
有用户说,“Socket.D 之于 Socket,尤如 Vue 之于 Js、Mvc 之于 Http” 主要特性 基于事件,每个消息都可事件路由 所谓语义,通过元信息进行语义描述 流关联性,有相关的消息会串成一个流 语言无关,使用二进制输传数据(支持 tcp, ws, udp)。支持多语言、多平台 断线重连,自动连接恢复 多路复用,一个连接便可允许多个请求和响应消息同时运行 双向通讯,单链接双向互听互发 自动分片,数据超出 16Mb(大小可配置),会自动分片、自动重组(udp 除外) 接口简单,是响应式但用回调接口 与其它协议的简单对比 对比项目 socket.d http websocket rsocket socket.io 发消息(Qos0) 有 无 有 有 有 发送并请求(Qos1) 有 有 无 有 无 发送并订阅 有 无 无 有 无 答复或响应 有 有 无 有 无 单连接双向通讯 有 无 有(不便) 有 有(不便) 数据分片 有 / 无 有 有 断线自动重连 有 / 无 有 有 有元信息 有 有 无 有 无 有事件(或路径) 有 有 无 无 有 有流(或消息关联性) 有 无 无...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- CentOS8编译安装MySQL8.0.19
- SpringBoot2初体验,简单认识spring boot2并且搭建基础工程
- Hadoop3单机部署,实现最简伪集群
- CentOS7编译安装Cmake3.16.3,解决mysql等软件编译问题
- CentOS7,8上快速安装Gitea,搭建Git服务器
- CentOS7设置SWAP分区,小内存服务器的救世主
- SpringBoot2全家桶,快速入门学习开发网站教程
- CentOS8安装Docker,最新的服务器搭配容器使用
- Docker安装Oracle12C,快速搭建Oracle学习环境
- Docker使用Oracle官方镜像安装(12C,18C,19C)