-
背景
周四下午正在吃的下午茶,偷闲刷了一会手机(光明正大的),突然就有客服中心的小姐姐找上门来说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;
- 查了一下数据库,有对应的数据存在,不是数据的问题
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;
}
}
这改造完上线之后,跟踪了一段时间日志中也没发现空指针(小姐姐也不来找我了-_-,不开森),有那么一点点的成就感.
-
总结
- 开发的时候要考虑多线程和并发场景
- 遇到问题别慌,认真分析
- 好的方案不是一蹴而就的
- 多读好的代码如框架源码,不断的积累,现在用不上,某一时刻就用上了