前几天线上出了一个奇怪的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,系统永不宕机。