踩坑记之《静态类中发生的故事》

29 阅读7分钟

背景

大家好,本文章记录了笔者最近在生产环境遇到的一个诡异bug,觉得还比较经典,所以来分享给大家。

PS:该问题看到最后发现很简单,但是没有把该代码放在真实的业务环境中去的时候,干扰因素众多,所以笔者排查了接近一个小时才恍然大悟。

代码现场

废话不多说,下面一起来看看代码吧。

先上环境:JDK 1.8 + lombok 1.18.8 + guava 23.0

/**
 * @author jszhao
 * @date 2024/8/3 13:29
 */
public class BugExample1 {
    final public static Map<String, List<StudentEnum>> CITY_STUDENT_MAP = new HashMap() {{
        put(CityEnum.BEIJING, Lists.newArrayList(StudentEnum.ZHANGSAN));
        put(CityEnum.SHANGHAI, Lists.newArrayList(StudentEnum.ZHANGSAN, StudentEnum.LISI));
        put(CityEnum.HANGZHOU, Lists.newArrayList(StudentEnum.WANGWU, StudentEnum.ZHAOLIU));
    }};

    public static List<StudentEnum> getStudentList(CityEnum city) {
        List<StudentEnum> studentList = CITY_STUDENT_MAP.getOrDefault(city, Lists.newArrayList());
        if (CollectionUtils.isEmpty(studentList)) {
            return studentList;
        }

        if (Objects.equals(city, CityEnum.SHANGHAI)) {
            // 业务上存在过滤逻辑,某些情况下需要过滤某个枚举值
            if (needFilter()) {
                studentList.remove(StudentEnum.LISI);
                return studentList;
            }
        }

        return studentList;
    }


    private static boolean needFilter() {
        // 真实情况下,不同用户的请求进来,大部分用户不需要过滤,极少数用户需要过滤,所以这里用取余来模拟即可
        return System.currentTimeMillis() % 10 == 1;
    }
}

其中用到的枚举没有什么特殊之处:

@AllArgsConstructor
public enum CityEnum {
    BEIJING("北京"),
    SHANGHAI("上海"),
    HANGZHOU("杭州");

    @Getter
    private final String name;
}
@AllArgsConstructor
public enum StudentEnum {
    ZHANGSAN("张三"),
    LISI("李四"),
    WANGWU("王五"),
    ZHAOLIU("赵六"),
    SUNQI("孙七"),
    ZHOUBA("周八");

    @Getter
    private final String name;
}

线上问题描述

代码逻辑其实比较简单(为了脱敏,文章中提供的代码逻辑跟笔者真实代码一模一样,只是把类名称尝试转化一下):

有两个实体(城市和学生)枚举,通过静态代码块中Map来建立关联,然后提供静态方法查询出去。

笔者的预期是,一般来说(对于不需要过滤的,实际的业务逻辑,极少数需要过滤):

public static void main(String[] args) {
    System.out.println(BEIJING +":" + getStudentList(BEIJING));// 北京:[张三]
    System.out.println(SHANGHAI +":" + getStudentList(SHANGHAI)); // 上海:[张三, 李四]
    System.out.println(HANGZHOU +":" + getStudentList(HANGZHOU)); // 杭州:[王五, 赵六]
}

笔者接到的工单反馈是:当传入city=上海时,判断用户不需要过滤,正确的值的值应该是”张三+李四“,然后实际上返回的却是只有张三。

思考区

各位看官可以先review一下代码,看看是不是可以一眼发现问题。

,

思考区 start

,

,

,

,

思考区...

,

,

,

,

思考区...

,

,

,

,

思考区 end

笔者排查过程

相信眼尖的看官已经发现了问题(如果已经确定找到了答案,可以直接跳转到文章最后,笔者排查过程中一度走偏了路)。

笔者进行排查的时候,第一反应是配错了枚举,比如是不是少配置了valueList中的元素,或者哪里配置错了。所以当检查map的时候果然发现了冤假错案:Map的key是String类型,而put方法用的是枚举本身,所以笔者第一反应就是问题就是出现在了这里。

猜测1:map的key类型不匹配直接导致查询为空?

image.png

但是仔细一想,又不对劲,笔者虽然平时粗糙了一点,但是该功能可是经过测试回归上线的(这里并不是想甩锅给测试通过,明确是“回归”,而不是“测试”。 本来配置在这个map里面的多个关系是前任代码散落在系统的各个地方,用多个不同的类实现的,笔者为了长期可维护,才做了技术改造,所以只是让测试回归了一下功能正常就上线了),所以如果是这么简单的一个问题,开发自测+测试回归不会发现不了问题,而让用户来发现。

且众所周知的是:Java的泛型仅作用于编译时期,到了运行期就擦除了,且JDK会进行类型推断,所以在编译器不会报错,此猜测更加不会在运行期导致问题。

猜测2:map的key用了枚举,而枚举的hash算法导致了错误的返回了其它key的valueList?

笔者有此番猜测属于是因为:符合预期的返回结果如下:

北京:[张三]
上海:[张三, 李四]

所以,但是当用户传入“上海”,却返回了“北京”的valueList,即“张三”。

笔者主观判断,既然当前用户传入上海时,绝对不会有过滤逻辑,那么就应该返回“张三,李四”,所以一定是该map有异常,导致返回了“北京”的值。

但是随即又推翻了自己的猜测:

  • 当传入枚举值作为map的key时,jdk默认会调用toString方法作为其真实key;
  • 查阅枚举的源码可知,其默认的toString方法是返回了枚举值的name,所以由编译器保证不会重复;
  • 不同的name使用HashMap也绝对不会返回错误的value,此由HashMap底层结构来保证;

所以综合此三点,本猜测不成立。

小黄鸭调试法

既然猜测1/2都不成立,拉着两位同事当小黄鸭来解读代码,也没发现问题,那只能在本地尝试来复现了。

本地成功复现

模拟不同用户来调用(演示代码由于把不同用户的差异,简化为随机性的概率问题,入参还是没有user)

模拟调用一千次

public static void main(String[] args) {
    for (int i = 0; i < 1000; i++) {
        System.out.println(i + ":" + getStudentList(CityEnum.SHANGHAI));
    }
}

随即发现本地能复现问题:

image.png

从第一次remove操作开始,后面的所有请求,都返回了异常的数据。

突然,仿佛醍醐灌顶,一切都明了了。

因为一个最简单基础的bug:getStudentList方法返回的是引用地址,所以在返回出去被使用和Map中valueList都指向同一个地址,所以后续的所有请求都拿不到正确的valueList。

解决方案

到了这里,相信大家都感觉这个问题其实很简单,但是缺乏的发现问题的契机。且各位看官在这里看,只能看到这一个类,在真实生产环境中,繁杂的各种链路,要定位着实不易。

解决代码如下:返回上层时new一个新的ArrayList对象即可。

public static List<StudentEnum> getStudentList(CityEnum city) {
        List<StudentEnum> studentList = CITY_STUDENT_MAP.getOrDefault(city, Lists.newArrayList());
        if (CollectionUtils.isEmpty(studentList)) {
            return studentList;
        }

        if (Objects.equals(city, CityEnum.SHANGHAI)) {
            // 业务上存在过滤逻辑,某些情况下需要过滤某个枚举值
            if (needFilter()) {
                studentList.remove(StudentEnum.LISI);
                System.out.println("remove");
                return studentList;
            }
        }

        // return studentList;
        return Lists.newArrayList(studentList);
    }

那么有看官可能又要问了:为什么不把返回出去的ArrayList定义为不可修改的呢? 如:

return Collections.unmodifiableList(studentList);

笔者考虑的是:考虑到是团队编程,若是有其他开发要调用此方法时,或由于精力问题、或由于能力问题,并不一定会让外面在开发、测试阶段关注到此ArrayList不可修改,所以有可能导致一个潜在的线上问题,所以在不明显增加笔者工作量的前提下,笔者会尽可能考虑到代码的兼容性。

顺便一提,其实我这种改法,如果在其它场景下也会留坑。因为guava包的Lists.newArrayList是浅拷贝,而非深拷贝,所以如果ArrayList中存放的元素不是枚举(枚举值都是单例的,且不可修改)而是其他业务对象(可修改,如Student对象,修改name等),那么本质上这里对外返回的业务对象和枚举定义中的对象还是同一个对象,所以本文所踩的坑,可能还会踩一遍。

总结经验

  • 合适的场景下选择合适的方案:其实类似本文中的例子,存储此类实体关系,可考虑用DB存储或配置中心存储,而不是像本文一下在代码中存储,这样后续每一次改动都需要代码发版。笔者这样实现是因为修改频率较低,大概半年修改1-2次,所以可以承受。
  • 静态类中的常量,一定需要定义为final类型,否则在并发多线程情况下,大概率会出现问题。
  • 业务代码多多打日志,减少问题排查时间。