前几天线上出了一个奇怪的Bug,订单状态偶尔会错乱,用户明明已经付款了,页面上却显示"待付款"。这个问题不是必现的,偶尔出现一次,很难复现。
我排查了一夜,从前端查到后端,从数据库查到缓存,最后发现竟然是枚举类型的问题。这个Bug很隐蔽,涉及到枚举的序列化、反序列化和数据库存储,踩了好几个坑。
今天详细记录这次Bug的排查过程,包括问题现象、排查思路、根因分析和解决方案。希望能帮大家避免类似的坑,也分享一些线上问题排查的经验。
一、问题现象
那天晚上十点多,运营同学在群里说,有用户反馈订单状态不对,已经付了钱,但订单列表里还是显示"待付款"。用户很着急,以为没付成功,又付了一次,结果付了两次钱。
我赶紧登录后台查看这个订单,发现数据库里的状态是"已付款"(code=1),但前端显示的是"待付款"。这就奇怪了,数据库是对的,为什么前端显示错了?
我让用户刷新页面,刷新后状态又正常了。但过了一会儿,又有其他用户反馈同样的问题。看来不是偶发的,是一个真实的Bug。
我开始排查。
二、排查过程
第一步:检查前端代码
首先怀疑是前端的问题。前端拿到订单数据后,根据状态码显示对应的文字。我看了前端的代码,状态映射是这样的:
const statusMap = {
0: '待付款',
1: '已付款',
2: '已发货',
3: '已完成',
4: '已取消'
};看起来没问题,1对应的是"已付款"。那为什么会显示"待付款"呢?
我抓包看了一下接口返回的数据,发现接口返回的status字段有时候是数字1,有时候是字符串"1"。哦?这就有问题了。
当前端用statusMap[1]的时候,能找到"已付款"。但如果返回的是字符串"1",JavaScript里对象的key是字符串,statusMap["1"]也能找到"已付款"啊。这也不对。
等等,我再仔细看,有时候返回的不是1,也不是"1",而是"PAID"。哦!接口返回的状态值不一致,有时候是数字code,有时候是枚举名。
这就解释了为什么前端显示错了。如果返回的是"PAID",statusMap里没有这个key,就会显示undefined,前端可能有默认值或者fallback,就显示成了"待付款"。
那为什么接口返回的状态值会不一致呢?问题出在后端。
第二步:检查后端接口
我去看后端的接口代码。订单接口返回的是OrderVO,里面有一个status字段,类型是OrderStatus枚举。
public class OrderVO {
private Long id;
private OrderStatus status;
// ...其他字段
}用的是Spring Boot,默认用Jackson序列化。Jackson序列化枚举的时候,默认是用枚举的name()方法,也就是返回枚举名(比如"PAID"),而不是code。
但之前一直是好的,为什么现在突然返回枚举名了?而且有时候是数字,有时候是枚举名,不一致。
我看了一下代码历史,发现上周有个同事改了OrderStatus枚举,加了一个@JsonValue注解在getCode()方法上:
public enum OrderStatus {
NEW(0, "待付款"),
PAID(1, "已付款"),
// ...
@JsonValue
public int getCode() {
return code;
}
}加了@JsonValue之后,Jackson序列化枚举的时候就会用getCode()的返回值,也就是数字code。这应该是对的啊,为什么还会返回枚举名?
等等,我突然想到,这个接口可能走了缓存。如果缓存里存的是旧的数据(加@JsonValue之前序列化的,是枚举名),那从缓存取出来的就是枚举名,而新查数据库的是数字code,所以就不一致了!
我去查了一下Redis,果然,订单详情接口加了缓存,缓存时间是1小时。有些订单的缓存是加@JsonValue之前存的,里面的status是枚举名"PAID";有些是之后存的,是数字1。所以就出现了有时候是数字,有时候是枚举名的情况。
找到原因了!但等等,这还不能完全解释问题。因为缓存里的JSON反序列化到OrderVO的时候,status字段是OrderStatus枚举类型。Jackson反序列化枚举的时候,默认是根据name()来匹配的。如果缓存里是"PAID",反序列化的时候能匹配到OrderStatus.PAID,然后再序列化的时候,因为有@JsonValue,又会变成数字1。那为什么前端还会收到"PAID"?
第三步:深入排查反序列化
我仔细看了一下缓存的代码,发现缓存不是直接存OrderVO对象的JSON,而是存了一个Map<String, Object>。因为这个接口要返回的数据比较复杂,有订单信息、用户信息、商品信息,是组装出来的,用Map存的。
Map<String, Object> result = new HashMap<>();
result.put("order", orderVO);
result.put("user", userVO);
// ...
redisTemplate.opsForValue().set(key, result, 1, TimeUnit.HOURS);RedisTemplate用的是Jackson2JsonRedisSerializer,序列化Map的时候,里面的orderVO会被序列化成JSON对象,status字段因为有@JsonValue,会变成数字。但旧的缓存是加@JsonValue之前存的,status是枚举名"PAID"。
从缓存取出Map的时候,Jackson反序列化Map<String, Object>,因为value是Object类型,Jackson不知道具体类型,就会把status反序列化成字符串"PAID",而不是OrderStatus枚举。然后这个Map直接返回给前端,status就是字符串"PAID"了。
哦,原来如此!因为缓存用的是Map<String, Object>,没有具体的类型信息,反序列化的时候枚举就变成了字符串。新缓存是数字,旧缓存是字符串,所以不一致。
那解决方案看起来很简单:把旧缓存清掉就行了。但我觉得事情没这么简单,因为即使清了缓存,以后可能还会有类似的问题。而且,为什么加@JsonValue之前没有这个问题?
第四步:发现更深层的问题
我继续查,发现加@JsonValue之前,接口返回的status一直是枚举名(比如"PAID"),前端的statusMap是用数字做key的,那为什么之前前端显示正常?
我去看了前端的代码历史,发现前端之前的statusMap是用枚举名做key的:
const statusMap = {
'NEW': '待付款',
'PAID': '已付款',
// ...
};后来后端加了@JsonValue,返回变成了数字,前端不知道,还是用枚举名做key,就显示错了。然后前端同学改了statusMap,改成数字做key。但这时候缓存里还有旧数据(枚举名),所以就出现了有时候对有时候错的情况。
哦,原来这是一个前后端联调的问题。后端改了序列化方式,没有通知前端,也没有清理缓存,导致前后端不一致。
但事情还没完。我清了缓存之后,以为问题解决了。结果过了一会儿,又有用户反馈状态不对。我再查,发现这次不是缓存的问题,是数据库的问题。
第五步:数据库存储的问题
我查数据库,发现有些订单的status字段存的不是数字,而是字符串"PAID"。这是怎么回事?
我看了一下MyBatis的映射配置。Order实体类的status字段是OrderStatus枚举类型,MyBatis默认的枚举TypeHandler是用name()存储的,也就是存枚举名字符串。但之前的代码里,有人自定义了一个TypeHandler,用code存储:
public class OrderStatusTypeHandler implements TypeHandler<OrderStatus> {
@Override
public void setParameter(PreparedStatement ps, int i, OrderStatus parameter, JdbcType jdbcType) {
ps.setInt(i, parameter.getCode());
}
// ...
}这个TypeHandler在mybatis-config.xml里配置了,应该是生效的。那为什么有些记录存的是字符串?
我查了一下,发现有一个批量插入的方法,用的是foreach拼接SQL,没有走TypeHandler:
<insert id="batchInsert">
INSERT INTO orders (status, ...) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.status}, ...)
</foreach>
</insert>这里#{item.status},MyBatis会用默认的EnumTypeHandler,也就是用name(),所以存的是字符串"PAID"。而单个插入的方法用了自定义的TypeHandler,存的是数字。所以数据库里就有两种格式的数据,有的是数字,有的是字符串。
查询的时候,自定义TypeHandler从数据库读,如果是数字就能正确转成枚举,如果是字符串"PAID",ps.getInt()会报错或者返回0,导致状态错乱。
我的天,这是第二个坑!枚举在数据库里的存储格式不一致,有的是code,有的是name。
三、根因分析
排查了一夜,终于把所有问题都找出来了。总结一下根因:
第一个问题:枚举序列化方式变更,没有清理缓存,也没有通知前端。
- 后端给枚举加了@JsonValue,序列化从name变成了code
- Redis缓存里还有旧数据(name格式),和新数据(code格式)不一致
- 前端不知道后端改了,还是按旧格式处理,导致显示错误
第二个问题:数据库枚举存储格式不一致。
- 单个插入用了自定义TypeHandler,存code(数字)
- 批量插入没有用自定义TypeHandler,默认存name(字符串)
- 数据库里两种格式混用,查询时TypeHandler解析出错
第三个问题:缓存用Map<String, Object>,丢失类型信息。
- 反序列化时枚举变成普通字符串,无法正确转换
- 导致缓存数据和数据库数据格式不一致
这三个问题叠加在一起,导致了订单状态偶尔错乱的Bug。看起来是一个小问题,背后却有这么多坑。
四、解决方案
找到原因后,我开始修复。
1. 统一数据库存储格式
首先,把数据库里所有status是字符串的记录,改成对应的数字。写了一个SQL脚本,批量更新:
UPDATE orders SET status = CASE
WHEN status = 'NEW' THEN 0
WHEN status = 'PAID' THEN 1
WHEN status = 'SHIPPED' THEN 2
WHEN status = 'COMPLETED' THEN 3
WHEN status = 'CANCELLED' THEN 4
END WHERE status IN ('NEW', 'PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED');然后,给批量插入的方法也加上自定义TypeHandler:
<insert id="batchInsert">
INSERT INTO orders (status, ...) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.status, typeHandler=com.xxx.OrderStatusTypeHandler}, ...)
</foreach>
</insert>或者在mybatis-config.xml里全局配置这个TypeHandler,让所有用到OrderStatus的地方都用它。
2. 清理缓存
把订单相关的缓存全部清掉,让新的请求重新查数据库,生成新的缓存(code格式)。
3. 修复缓存的类型问题
把缓存从Map<String, Object>改成具体的DTO类,这样反序列化的时候有类型信息,枚举能正确转换。或者,在缓存序列化的时候保留类型信息(用Jackson的enableDefaultTyping),但这样有安全风险,不推荐。
最好的方式是定义一个明确的缓存对象类,不要用Map。
4. 前后端对齐
和前端同学确认,接口返回的status是数字code,前端按数字处理。以后后端改序列化方式,必须提前通知前端,并且要考虑缓存和历史数据的兼容性。
5. 加监控和告警
在接口里加日志,如果status字段不是预期的数字格式,就打error日志,配置告警。这样以后再出现类似问题,能第一时间发现。
五、经验总结
这次排查花了一夜,踩了很多坑,也总结了一些经验。
1. 枚举的序列化和存储要统一
枚举在系统中会出现在很多地方:数据库存储、接口返回、缓存、消息队列等。一定要统一格式,要么都用code,要么都用name,不要混用。最好的方式是:
- 数据库存code(数字,稳定不变)
- 接口返回code(和数据库一致)
- 缓存也存code
- 定义统一的TypeHandler和序列化注解,全局生效
2. 改序列化方式要考虑兼容性
给枚举加@JsonValue或者改TypeHandler,看起来是小改动,但影响很大。要考虑:
- 缓存里的旧数据怎么办?要不要清理?
- 前端是否知道这个改动?要不要兼容两种格式?
- 消息队列里的旧消息能不能正确解析?
- 数据库里的历史数据格式是否一致?
改之前要充分评估,改之后要清理缓存,通知相关方。
3. 不要用Map<String, Object>做缓存或返回值
Map没有类型信息,反序列化的时候枚举、日期等特殊类型会变成普通字符串或数字,导致问题。应该用明确的DTO类,保留类型信息。
4. 线上问题排查要系统
这次排查从前端到后端,从数据库到缓存,层层深入。线上问题往往不是单一原因,而是多个问题叠加。排查的时候要:
- 先复现问题,抓包看数据
- 从数据流向一步步查:前端 -> 接口 -> 缓存 -> 数据库
- 不要轻易下结论,每个环节都要验证
- 找到一个原因后,还要想想有没有其他原因
5. 批量操作要注意TypeHandler
MyBatis的批量插入(foreach)有时候不会走全局配置的TypeHandler,需要显式指定。这个坑很多人踩过,要注意。
六、写在最后
一个枚举类型的Bug,排查了一夜,背后藏着三个问题。这告诉我们,线上无小事,任何一个小改动都可能引发大问题。
枚举是我们日常开发中经常用到的类型,但很多人对它的序列化、存储、反序列化不够了解,容易踩坑。希望这篇文章能帮大家避免类似的问题。
最后,感谢那晚和我一起排查的同事,也感谢用户的耐心。线上问题不可怕,可怕的是不总结、不改进。每次排查都是一次学习,把经验记录下来,才能不断进步。
2021年的最后一天,用这篇Bug排查记录收尾。新的一年,希望Bug少一点,头发多一点。祝大家代码无Bug,系统永不宕机。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录