使用Copy on write改造本地缓存

1,366 阅读3分钟
  • 背景

    周四下午正在吃的下午茶,偷闲刷了一会手机(光明正大的),突然就有客服中心的小姐姐找上门来说xxx操作又出现失败了,但是多点几次又没问题了(之前也出现过,可是代码中没有任何异常处理和日志的输出很难排查,没办法老代码,前任写的我也没办法,只能加上等复现的时候再看看),看着小姐姐焦急的表情,下午茶瞬间就不香了,找bug去!
  • 产生原因

  • 定位

    在rancher上输入账号找到对应的服务,根据关键字找到相关日志映入眼帘的是java.lang.NullPointException 跟随报错的行数找到了相关代码块:
if (StringUtils.isNotEmpty(feeSetting.getFileId())){
    return schoolService.deal(sysConfigService.getString("url"));
}

其中报错的是

schoolService.deal(sysConfigService.getString("url"));

定位问题,应该是调用 String getString(String key); 空指针导致的.

  • 分析

相关代码:

public String getString(String key) {
    if (configs == null) {
        initConfig();
    }
    return configs.get(key);
}

其中initConfig()的实现:

private void initConfig() {
    synchronized (lock) {
        if ((configs == null) || configs.isEmpty()) {
            configs = new HashMap<String, String>();
            //从db中加载到configs
            loadSysConfig();
        }
    }
}

其中configs 是个成员变量

private static Map<String, String> configs = null;
  1. 查了一下数据库,有对应的数据存在,不是数据的问题
  2. getString(String key) 接口内部没报错,说明这个程序没报错 抓了抓头(有点意思),只有Map中没有相应的数据才有可能报空指针,查找了相关方法,找到了如下代码:
public void reload() {
    if ((configs != null) && !configs.isEmpty()) {
        configs.clear();
        this.initConfig();
    }
  }

只有一处调用该方法

@Component
public class SysConfgMQListener implements MessageListenerConcurrently {
   protected final Logger log = LoggerFactory.getLogger(SysConfgMQListener.class);

   @Autowired
   private ISysConfigService sysConfigService;


   @Override
   public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
      log.info("SysConfgMQListener retrieving...");
      for (MessageExt msg : msgs) {
         log.info("messageExt, body:{}", new String(msg.getBody()));
         this.sysConfigService.reload();
      }
      return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
   }

}

这是RocketMq的消费者这里调用了,而且还是广播模式,所有节点都能消费,这个Mq的生产者是在后台触发刷新时候产生的.

  • 真相只有一个

  • 首先触发Mq的消费,导致Map刷新,重新加载调用reload()
  • 当执行configs.clear();之后Map就是一个空对象,没有任何数据
  • 如果这个时候是有多个线程访问getString(String key)获取到的值就是null
  • 改造

  • 第一个想到的是用Redis来替换,但是很快就自我否定了,这个接口在没有触发刷新机制的前提下运行了几年是好好的,而且基础配置放Redis的话过期时间的设置不好判断,并且还要多个IO的传递,性能没有本地的Map好.
  • 第二个想到的方案就是在getString(String key) 方法中加锁,这只能当做下下策
  • 正在一筹莫展的时候,突然灵光一闪,这不是跟注册中心很像吗?各个客户端去拉取数据,而nacos为了高性能就是用了Copy on write的思想来实现的,越想越行,干!

代码改造如下:

public void reload() {
    if ((configs != null) && !configs.isEmpty()) {
        //先清除再加载会出现,在两个操作之间请求的接口获取都为空
        //configs.clear();
        //this.initConfig();
        this.reloadForConfigs();
    }
   }

其中 this.reloadForConfigs();

private void reloadForConfigs() {
    Map<String, String> newConfigs = new HashMap<>();
    try {
        List<Config> datas = configDao.listConfigs();
        if (datas != null) {
            for (Config cf : datas) {
                newConfigs.put(cf.getKey(), cf.getValue());
            }
        }
    } catch (Exception e) {
        LogUtil.exception(log, e);
    }
    if (CollectionUtil.isNotEmpty(newConfigs)){
        //替换旧的
        this.configs = newConfigs;
    }
}

这改造完上线之后,跟踪了一段时间日志中也没发现空指针(小姐姐也不来找我了-_-,不开森),有那么一点点的成就感.

  • 总结

  • 开发的时候要考虑多线程和并发场景
  • 遇到问题别慌,认真分析
  • 好的方案不是一蹴而就的
  • 多读好的代码如框架源码,不断的积累,现在用不上,某一时刻就用上了