ThreadLocal引发的灾难
在 Java 里有个称之为线程本地变量的类型叫做 ThreadLocal,它与 ThreadLocal 之于 C# 中是一样的作用,可以在线程范围内设置变量,这个变量只会在当前线程可被访问,但是它们有一点不同的是,在 Java 中,当你设置好变量后,在线程使用完毕回到线程池之前,需要手动调用 ThreadLocal.remove() 方法去清除线程本地变量,否则变量随着线程回到线程池,并且在下次使用此线程时此变量继续存在,而在 C# 中,线程回到线程池时会自动清除本地变量,因此无需手动去清除。
我们的业务有这样一个场景:某个业务 UserService 类中,具有多个方法会频繁(甚至循环)调用一个获取用户标签的接口,具体原因是因为某些方法会进行递归,数据结构有个树状结构,因此,为了优化接口响应时间以及看起来不那么蠢,我使用 ThreadLocal 将用户标签接口的返回数据存储到当前线程,因为在单个请求中,多次调用此接口获取数据是不必要的,它看起来像这样:
/**
* 此静态变量ThreadLocal会为每个线程创建本地副本, 因此USER_TAGS_THREAD_LOCAL的更改只会影响当前线程 注意,
* 即使线程被回收到线程池, 此变量副本也不会被清除, 因此, 需要在使用完毕后手动清除, USER_TAGS_THREAD_LOCAL.remove()
* 否则可能会导致从线程池拿到的线程带有之前的变量 无论如何, 这不会产生内存泄漏问题
* 此变量在controller返回或controller异常时进行清除操作
*/
public final static ThreadLocal<List<Tag>> USER_TAGS_THREAD_LOCAL = ThreadLocal.withInitial(() -> new ArrayList<>());
/**
* 获取用户标签
*/
private List<Tag> getUserTags(String userId) {
if (StringUtil.isNullOrEmpty(userId)) {
return new ArrayList<>();
}
if (!CollectionUtils.isEmpty(USER_TAGS_THREAD_LOCAL.get())) {
// 返回线程本地存储的用户标签
return USER_TAGS_THREAD_LOCAL.get();
}
// 调用接口获取用户标签
List<Tag> tags = getUserTags(userId);
USER_TAGS_THREAD_LOCAL.get().addAll(tags);
return USER_TAGS_THREAD_LOCAL.get();
}
// 在多个方法中会用到获取用户标签,这里假设会递归调用
public User queryUserInfo(String userId) {
User user = getUser(userId);
List<Tag> userTags = getUserTags(userId);
user.Parent = queryUserInfo(user.ParentId);
...
}
这里有个问题,那就是清除 ThreadLocal 的时机,假设我们在 queryUserInfo 方法中,增加一个 try/catch/finally 块,在每次调用完毕后进行清除,这肯定不合适,这样的递归方法,每次都清除,会失去 ThreadLocal 的意义,那么在 queryUserInfo 外层再增加一个方法,例如:
public User queryUserInfoWapper(String userId) {
try {
return queryUserInfo(userId);
} catch {
...
} finally {
USER_TAGS_THREAD_LOCAL.remove();
}
}
这看起来确实解决了问题。但是我们在这个 UserService 类中有多个类似 queryUserInfo 的方法,为每个类似 queryUserInfo 的方法都增加 try/catch/finally 块,你得找到所有的点去清除 ThreadLocal,很可能会出现某些点漏掉,或者一次请求线程中多次清除了 ThreadLocal。
于是我开始寻找,某种能在线程池回收线程时的钩子,在线程回收时清除线程上的变量,当然最后由于实现难度或开发进度等种种原因,我选择了在控制器入口进行清除,我将 USER_TAGS_THREAD_LOCAL 属性暴露给外层,在控制器入口调用 USER_TAGS_THREAD_LOCAL.remove():
@PostMapping
public User queryUserInfo(String userId) {
try {
User user = userService.queryUserInfo(userId);
return user;
} catch (Exception exception) {
throw exception;
} finally {
UserService.USER_TAGS_THREAD_LOCAL.remove();
}
}
@HystrixCommand(fallbackMethod = "fallbackMethod", commandKey = "commandKey", threadPoolKey = "threadPoolKey")
@PostMapping
public User anotherQueryUserInfo(String userId) {
try {
User user = userService.queryUserInfo(userId);
return user;
} catch (Exception exception) {
throw exception;
} finally {
UserService.USER_TAGS_THREAD_LOCAL.remove();
}
}
实际上这种做法很不好,它隐式的需要你在使用了某些调用过 getUserTags 的方法后手动使用 USER_TAGS_THREAD_LOCAL.remove(),否则线程回到线程池依然带有上次保存的数据。然而这种隐式的操作,只有了解这个 UserService 人才知道,别人可能直接使用业务方法而不知道需要手动调用 USER_TAGS_THREAD_LOCAL.remove(),我们的 BUG 也正因此产生。
在某个功能上线后,我们原先运行正常的 queryUserInfo 接口突然数据不正确,查看日志排查问题后发现,是因为 getUserTags 方法的 USER_TAGS_THREAD_LOCAL 数据不正常,在多个不同用户之间数据错乱,A 的标签数据被 B 返回,B 的标签数据被 C 返回。找了一个多小时的原因,这段代码怎么看也没问题,因为无论如何在控制器的 finally 块会清除 USER_TAGS_THREAD_LOCAL, 最终没辙,准备先解决问题(移除 USER_TAGS_THREAD_LOCAL 的使用)再详细查看问题的原因。正改着代码,突然一旁的同事表示,他知道原因了,他在某个其它接口的内部方法调用了 UserService,但是他并不知道需要清除 USER_TAGS_THREAD_LOCAL。那么问题找到了,接口数据不正常是因为其它接口的用户请求后会将 USER_TAGS_THREAD_LOCAL 数据设置到请求线程,线程回到线程池没有被清除 USER_TAGS_THREAD_LOCAL,而后 queryUserInfo 接口再次拿到相同的线程就会有上个用户留下的数据。
等等,queryUserInfo 接口虽然数据错误,但是 anotherQueryUserInfo 接口却数据正常,这两调用的是完全相同的业务方法啊?仔细一看,哦,原来 anotherQueryUserInfo 使用了 Hystrix,它使用自己的线程池去分配线程执行,因此,即使 默认线程池的线程带有错误数据,也不影响它。