一次反射引发的宕机问题

1,890 阅读5分钟

背景介绍

整个系统是一个第三方支付平台,面向的是商户端;出现宕机是其中的通知服务,通知服务主要的功能是将支付的结果通知商户,通知地址其实就是每个商户在提交支付订单的时候提供的;而通知地址大部分都是域名地址,所以会出现大量通过域名获取ip的操作。

问题描述

通知系统在运行一段时间后(可能是一周,一个月,甚至几个月),突然出现宕机,无法提供通知,日志中出现大量的如下错误:

java.lang.NullPointerException
    at java.net.InetAddress$Cache.put(InetAddress.java:779) ~[?:1.7.0_79]
    at java.net.InetAddress.cacheAddresses(InetAddress.java:858) ~[?:1.7.0_79]
    at java.net.InetAddress.getAddressesFromNameService(InetAddress.java:1334) ~[?:1.7.0_79]
    at java.net.InetAddress.getAllByName0(InetAddress.java:1248) ~[?:1.7.0_79]
    at java.net.InetAddress.getAllByName(InetAddress.java:1164) ~[?:1.7.0_79]
    at java.net.InetAddress.getAllByName(InetAddress.java:1098) ~[?:1.7.0_79]
    at java.net.InetAddress.getByName(InetAddress.java:1048) ~[?:1.7.0_79]

通知服务本身部署了多节点,同时也提供了重试机制,为了尽快恢复生产,往往是直接重启宕机的节点,重启之后一切正常(果然重启大法好);再看问题本身出在了JDK提供的域名解析服务中,将域名映射的地址缓存的时候出现了空指针,所以有必要去了解一下JDK域名解析服务;

域名解析

JDK提供了InetAddress类来帮助我们通过域名地址获取IP地址,当然为了提高性能不可能每次都去远程的域名服务器获取地址,所以内部提供了缓存机制;使用起来也是非常简单;

如何使用

InetAddress inetAddress = InetAddress.getByName("www.baidu.com");
String address = inetAddress.getHostAddress();
String name = inetAddress.getHostName();

输出:
address = 180.101.49.12,name = www.baidu.com

缓存分析

每次获取域名映射的时候首先会去本地缓存查找,如果查找不到就去远程服务获取域名映射的ip地址;

  • 缓存类型

域名映射缓存分为两种类型分别是:PositiveNegative,可以简单理解就是成功的和失败的,并且都有各自的有效期;为什么会有成功和失败两种类型其实也好理解,成功的缓存好理解,失败了其实也一样,一次失败就让它在一段有效期时间内就不要去请求远程域名服务了,因为大概率也是失败的;

// 解析成功的缓存
private static Cache addressCache = new Cache(Cache.Type.Positive);
// 解析失败的缓存
private static Cache negativeCache = new Cache(Cache.Type.Negative);
  • 缓存策略 缓存的时间是通过内置的InetAddressCachePolicy类设置,其内置的枚举类包括:FOREVER、NEVER,分别表示从不过期和从不缓存;其实它们都分别对应一个整数:
public static final int FOREVER = -1;
public static final int NEVER = 0;

当然也可以自定义一个整数,官方给的解释如下:

# any negative value: cache forever
# any positive value: the number of seconds to cache negative lookup results
# zero: do not cache

0表示不缓存,负整数表示从不过期,正整数表示缓存的时间单位秒;

  • 设置过期时间 有了缓存策略需要给两种缓存类型分别设置过期时间,当然JDK也设置了默认的过期时间,大部分时间我们是不需要改动的; 具体配置文件路径:jdk/jre/lib/security/java.security,其中有分别针对两种类型的缓存时间设置:
#networkaddress.cache.ttl=-1
networkaddress.cache.negative.ttl=10

失败解析的缓存时间为10秒,而成功解析的缓存时间被注释了没有配置,官方给的注释如下:

# default value is forever (FOREVER). For security reasons, this
# caching is made forever when a security manager is set. When a security
# manager is not set, the default behavior in this implementation
# is to cache for 30 seconds.

如果设置了安全管理器这个是永久的,如果没有设置默认值为30秒;

获取流程

有了上面的了解,我们现在大致看一下获取映射地址的流程:

private static InetAddress[] getAllByName0 (String host, InetAddress reqAddr, boolean check)
        throws UnknownHostException  {
        ...
        InetAddress[] addresses = getCachedAddresses(host);
        
        if (addresses == null) {
            addresses = getAddressesFromNameService(host, reqAddr);
        }
        ...
    }

很好理解就是先去缓存获取地址,获取不到就去远程服务获取,在getAddressesFromNameService内会添加缓存操作cacheAddresses;

    private static InetAddress[] getCachedAddresses(String hostname) {
        hostname = hostname.toLowerCase();
        synchronized (addressCache) {
            cacheInitIfNeeded();

            CacheEntry entry = addressCache.get(hostname);
            if (entry == null) {
                entry = negativeCache.get(hostname);
            }

            if (entry != null) {
                return entry.addresses;
            }
        }
        return null;
    }

获取缓存两个要点分别是: 第一点获取缓存的时候需要加锁,这是因为在获取的时候其实会做有效期检查,如果过期会做删除处理,需要保证线程的安全; 第二点是首先去成功获取解析结果的缓存中获取,如果没有再去失败的缓存中获取; 保存缓存的方法cacheAddresses也类似,这个我们在下面重点介绍一下,报错的地方也是在这;

问题分析

根据上面的错误堆栈可以快速定位到报错的地方也就是cacheAddresses方法,也就是每次从远程域名服务获取地址后进行缓存的地方:

    private static void cacheAddresses(String hostname,
                                       InetAddress[] addresses,
                                       boolean success) {
        hostname = hostname.toLowerCase();
        synchronized (addressCache) {
            cacheInitIfNeeded();
            if (success) {
                addressCache.put(hostname, addresses);
            } else {
                negativeCache.put(hostname, addresses);
            }
        }
    }

同getCachedAddresses方法一样需要有加锁操作,因为在put的时候同样会检查缓存是否过期;如果获取成功就放入addressCache中,失败放入negativeCache中,下面重点看一下put方法:

//存放缓存使用的是LinkedHashMap
private LinkedHashMap<String, CacheEntry> cache;
 
public Cache put(String host, InetAddress[] addresses) {
		int policy = getPolicy();
		if (policy == InetAddressCachePolicy.NEVER) {
			return this;
		}

		if (policy != InetAddressCachePolicy.FOREVER) {
			LinkedList<String> expired = new LinkedList<>();
			long now = System.currentTimeMillis();
			for (String key : cache.keySet()) {
				CacheEntry entry = cache.get(key);
				if (entry.expiration >= 0 && entry.expiration < now) {
					expired.add(key);
				} else {
					break;
				}
			}

			for (String key : expired) {
				cache.remove(key);
			}
		}
		long expiration;
		if (policy == InetAddressCachePolicy.FOREVER) {
			expiration = -1;
		} else {
			expiration = System.currentTimeMillis() + (policy * 1000);
		}
		CacheEntry entry = new CacheEntry(addresses, expiration);
		cache.put(host, entry);
		return this;
	}

put操作的大致流程:先获取缓存策略,这个在上面介绍过,如果是NEVER那就需要缓存直接返回,如果是FOREVER表示缓存是永久的,不需要检查缓存是否过期;最后缓存的时间计算过期的时间点,然后保存到LinkedHashMap中; 根据堆栈日志最终定位的行是在遍历LinkedHashMap,检查是否过期的地方出现了空指针,也就是说LinkedHashMap中出现了空对象,导致每次跑到这里的时候直接报错,而通知服务很关键的一步就是域名解析,最终导致整个节点无法提供服务;

为什么会出现空对象

问题分析完关键点就是为什么会出现空对象,很容易联想到线程安全问题,发现LinkedHashMap是非线程安全的,最早的时候怀疑过是不是JDK的bug,因为LinkedHashMap本身是非线程安全的,而且会同时出现get,remove,put等操作,这些操作都没有加锁操作,从而引发线程安全问题;但其实经过上面的分析,其实在所有调用get和put的外层方法中都添加了锁,所以并不会出现线程安全问题;想想也是JDK怎么可能会有这种低级错误(此处应该有狗头);

反射猜测

思来想去有无果,突然灵光一闪,会不会有哪个二哈直接通过反射的方式去改变JDK里面的属性值;直接全局搜索关键字addressCache,发现如下代码:

static {
      Class clazz = java.net.InetAddress.class; 
      final Field cacheField = clazz.getDeclaredField("addressCache");  
      cacheField.setAccessible(true);  
      final Object o = cacheField.get(clazz);  
      Class clazz2 = o.getClass();  
      final Field cacheMapField = clazz2.getDeclaredField("cache");  
      cacheMapField.setAccessible(true);  
      final Map cacheMap = (Map)cacheMapField.get(o); 
}

你没有看错,直接通过反射拿到InetAddress中的addressCache,然后再拿到cache,其实这个cache就是上面介绍的LinkedHashMap,下面的代码就不贴出来了,对LinkedHashMap做了一些删除操作,重点是没有任何加锁操作,导致了最终的线程安全问题;

问题解决

给出解决问题之前我们应该先想想为什么要通过反射去改变JDK内部的对象属性,是不是有这个必要;真要这样做的话那需要给这里的cache加上同样的锁:synchronized (addressCache);获取可以参考dns缓存项目:java-dns-cache-manipulator

总结

遇到生产问题我们首要是先恢复生产,其次再对问题进行全方位分析,找到问题的根因,有时候不妨发散思维,做出一些大胆的猜测,说不准有意外的收获。

感谢关注

可以关注微信公众号「 回滚吧代码」,第一时间阅读,文章持续更新;专注Java源码、架构、算法和面试;本文 GitHub 已经收录,欢迎Star。