BUG现场

这是一份若干年前的历史代码,当时的同学写这份代码的时候设计思路是这样的,我复现这份BUG现场的代码如下

要点1 有一个用来传输数据的DTO,大致如下

@Data
public class CategoryBrandBu implements Serializable {

    protected static final Map<String,String> BRAND_RELATION_MAP = new HashMap<>();
    public Map<String,String> getRelationMap(){
        return BRAND_RELATION_MAP;
    }
    private Long id;
    private String brandCode;
    private String brandName;

    public String getBrandName() {
        if (StringUtils.isBlank(brandCode)){
            return StringUtils.EMPTY;
        }
        return BRAND_RELATION_MAP.getOrDefault(brandCode,StringUtils.EMPTY);
    }
}

DTO中声明了一个静态常量BRAND_RELATION_MAP的HashMap,用来保存某个不常更新的表中的映射信息,表中的数据基本只有几十条,所以放在这用来获取品牌名称

要点2. 再另外起一个定时job,定时来刷新这个HashMap中的映射关系数据,代码大意如下

@Configuration
public class ScheduledConfig {

    @Scheduled(fixedDelay = 5000)
    public void updateMap(){
        Random random = new Random();
        int i = random.nextInt(1000);
        System.out.println("updated: "+i);
        CategoryBrandBu dto = new CategoryBrandBu();
        dto.getRelationMap().put("ABC", String.valueOf(i));
    }
}

其中代码先new CategoryBrandBu()getRelationMap(),再put的操作虽然看起来比较挫,不过嗯、至少确实还是有用的(原来的代码就是这样),这个不是重点,另外fixedDelay = 5000是我特地调整的5秒刷新一次。

要点3. 接下来一个至关重要的东西,当前Application中提供了对外服务RPC(某自研RPC框架)接口用于查询相关数据,其中有一个接口的返回值中就用到了当前涉及的对象CategoryBrandBu。

RPC基础服务类中将请求参数序列化之后,发送到目标Application中。目标Application接收到请求之后,将请求信息解析之后调用对应bean实例的对应方法,且同时反序列化对应的请求参数为对应方法的java参数对象。这里这序列化和反序列化中用的fastjson(能用,不过也不是很高明的样子)。

BUG表现情况

我们的@Scheduled定时任务无论怎样刷新CategoryBrandBu类中静态成员变量Map的信息,外部应用实例(B)通过RPC访问过来的之后得到的结果永远都是第一次初始化之后得到Map中的值(其实也不是第一次初始化的值,后面我再说到)

举个栗子来说明下,首先当我们的Application(A)启动之后,当前静态成员变量上Map被初始化成了如下

(A)->AAA
(B)->BBB
(C)->CCC

此时外部的应用实例(B)通过RPC访问到当前Application(A)的对应接口之后,得到了这个DTO序列化的结果,其中包含已经被序列化了的BRAND_RELATION_MAP,而我们对应外部应用实例(B)的系统也恰巧用了同样的DTO的java类文件,于是在外部应用实例(B)接收到序列化的返回结果同时,也接收到了序列化的BRAND_RELATION_MAP,外部应用实例(B)对数据进行反序列化,同时也对外部应用实例(B)中的CategoryBrandBu类中的静态成员变量BRAND_RELATION_MAP进行赋值。

于是我们在外部应用实例(B)中CategoryBrandBu类的静态成员变量BRAND_RELATION_MAP也被修改成了接收到的Map

(A)->AAA
(B)->BBB
(C)->CCC

此时Application(A)的ScheduledConfig中配置的定时任务开始执行,将BRAND_RELATION_MAP修改为如下

(A)->A22
(B)->B22

而,我们之前写这份代码的同学在这里又再次偷了个懒,直接用这个CategoryBrandBu类作为向我们外部应用实例(B)向Application(A)请求的参数对象。所以,外部应用实例(B)中的BRAND_RELATION_MAP有再次被序列化之后发送下我们的Application(A)。

而当Application(A)接收到外部应用实例(B)再次发来的RPC请求的时候,因为需要对参数反序列化,而写这份代码的同学同样也在这里偷了个懒,接收请求的参数也是CategoryBrandBu类,所以在接CategoryBrandBu类中的静态成员变量在反序列化的过程中又再次被更新为之前已经在外部应用实例(B)中缓存下来的HashMap

(A)->AAA
(B)->BBB
(C)->CCC

至此我们的Application(A)中的BRAND_RELATION_MAP又变回了原来的数据内容。这便是整个BUG发生的过程说明。

因为系统中@Scheduled()设置的执行间隔很长、一天才更新一次且都是在深夜没有访问的时间点,而外部系统调用的频率是很平凡的,所以我们的Application(A)中的BRAND_RELATION_MAP一直无法被刷新。

代码调试简单复现

我们简单的写份demo代码复现下这个情况

CategoryBrandBu dto = new CategoryBrandBu();
dto.getRelationMap().put("aa","A");
dto.getRelationMap().put("bb","B");
dto.getRelationMap().put("cc","C");
dto.setId(123L);
String str = JSON.toJSONString(dto);
dto.getRelationMap().clear();
dto.getRelationMap().put("aa","123");
dto.getRelationMap().put("bb","456");
dto.getRelationMap().put("cc","789");
CategoryBrandBu ddd = JSONObject.parseObject(str, CategoryBrandBu.class);

并打上如下断点

当执行到第一个断点的时候我们得到了序列化了之后返回给外部系统的信息str变量

可以看到连同静态成员变量的Map也一起返回出去了

{"brandName":"","id":123,"relationMap":{"aa":"A","bb":"B","cc":"C"}}

而此时通过dto.getRelationMap()可以得到当前系统中的Map的值。再执行到下一个断点的时候,及表名本系统中更新了这个静态的map的值

而str中序列化的结果返回给外部系统之后,不会再改变。此时我们在继续往下执行,在本系统中反序列化str字符串,及相对应的表示接收到外部系统的请求之后,再次反序列化str。

同时原来的map又再次在反序列化的过程中修改回去。

问题总结及处理

整个BUG发生的过程理清楚了之后感觉有点啼笑皆非,之前写这份代码的同学想到了用个静态的Map把这些对应关系缓存下来,是个好事,但是坏在几个地方都偷懒。

偷懒的地方上面也提到了,首先第一个这个Map不应该放在一个DTO类中,这整个显得非常不伦不类,这也导致了后面这个Map在列化了之后被在两个系统中传来传去。这也是一个非常不规范的写法。可以考虑外部缓存的解决方案,而且现在的应用都是多实例部署的,这样的作法很容易导致单个应用实例内的缓存数据与其他应用实例的数据不一致的问题,导致的原因有很多,这里不多做赘述。

其次,外部系统中再接收的时候不应该再需要这个Map了,Map在这里的作用就是为了查询获取brandName字段信息,在序列化对象返回给外部系统的时候,序列化会自动调用对应的字段的get方法,所以此时序列化的结果中brandName是有值的。

所以在传出外部系统的时候这个Map就已经没必要了,所以外部系统中就不再需要这个Map,外部系统的接收DTO直接这样就行

@Data
public class CategoryBrandBuOuterApp implements Serializable {
    private Long id;
    private String catalogCode;
    private String brandCode;
    private String brandName;
}

或者给外部系统返回的时候不序列化这个静态的Map,给他加上@JSONField(serialize = false)即可

再其次,不应该同样继续用CategoryBrandBu这个类对象作为接收请求的参数,请求参数的对象就单独创建一个,而用CategoryBrandBu在这里给作为接口的接收请求的参数对象,就给了外部系统可以更改本系统内部静态变量的可乘之机。

还有,如果真就这么偷懒,就非要用CategoryBrandBu作为接口接收请求的对象,那么也至少应该BRAND_RELATION_MAP加上注解@JSONField(deserialize = false),防止在反序列化的时候被修改。

以上几处,但凡有一个地方注意避免下,那么都不会发生这个bug。