首发公众号:赵侠客
引言
最近我收到一个非常诡异的线上BUG,触发BUG的业务流程大概是这样的:A系统新建任务数据需要同步到B系统,数据是多租户的,比如C租户在A系统新建了一条任务,那么C租户登录B系统后会看到这条任务,如果在A系统修改这条任务,任务信息会也会同步到B系统,本来是一个很简单的数据同步问题,但是诡异的事情发生了。
二、问题排查
BUG现象:
- C租户在A系统新建任务后,去B系统找不到这条任务
- C租户在A系统修改任务后,去B系统又看到了这条任务
开发初步排查:
- C租户在A系统新建任务后数据确实入了B系统的数据库,只是租户ID变成了D租户,所以在B系统中查不到这条任务
- C租户在A系统修改任务后B系统的任务租户ID又被改成了租户C的,所以在B系统中又能查到这条任务
排查思路:
我收到这个问题时,另一个开发已经做了初步排查,说这个问题太诡异了,他找不出原因,于是请我帮忙排查一下,因为这个项目不是我开发的,业务也不是很熟悉,我的排查思路大致是这样的:
- 最近有没有发过版
我找到运维看线上包是6月17号发的版,而出现问题是从7月23号,所以不太可能是发版导致的问题
- 诡异的租户ID
因为新增任务租户ID被改成固定值25678,我在代码及配置中搜索了这个关键字,没有搜索到,然后怀疑有人改了代码未合并Master
- 线上代码反编译
为了排除是代码不一致导致的问题,我反编译了一下线上环境的代码,发现和Master是一样的,所以排除了代码不一致的情况,也排除了代码中有固定用租户ID的情况
- 查看代码
代码其实是比较简单的,我省略其它不必要的逻辑,主要功能就是接收一个对象,然后使用taskRepository.save()保存到数据库中,添加和修改都是调用这个接口,那为什么添加任务租户ID会被改,修改任务租户ID就不会被改呢?从代码初步看是没有问题的,好像可以排除代码的问题
@PostMapping("/api/sync")
public ResponseEntity<String> sync(@RequestBody TaskDO taskDO) {
taskRepository.save(taskDO);
return ResponseEntity.ok("ok");
}
public class TaskDO {
private Long id;
private String name;
private Long tenantId;
}
- 线上Debug
然后我使用Arthas 在线上追踪了一个这个sync方法,在save()之前taskDO租户ID是正确的,然而save()之后taskDO的tenantId就被改掉了,这时我断定肯定是有拦截之类的东西修改了租户ID
- 重启大法
问题一时没找到,然后我使用了重启大法,试下重启后能不能解决问题,结果重启后真的就好了,这时可以肯定的是25678租户ID不是代码和配置里取的,肯定是从内存里取的,因为重启后内存里没有了这个数字,内存取不到也就好了
- 继续耐心看代码
重启大法确实好用,但是问题没有找到,说不定过段时间问题又会出现,于是我还是耐心去看代码,因为代码比较老也不是我写的,而且老代码你们都知道,基本上就是一座屎山,看起来还是非常要耐心的,当我看到代码里使用了ThreadLocal保存租户ID,这里我就知道是啥原因了,大概率是使用了ThreadLocal后没有清理,Tomcat处理请求使用了线程池复用导致的。
三、问题还原
这里我简化一下不必要的代码,大致复原一下核心代码,首先有一个UserContext使用了ThreadLocal保存TenantId
public class UserContext {
private static ThreadLocal<Long> userTenant=new ThreadLocal<>();
public static void setTenantId(Long tenantId){
userTenant.set(tenantId);
}
public static Long getTenantId(){
return userTenant.get();
}
public static void remove(){
userTenant.remove();
}
}
然后有一个获取当前租户任务的接口,这里的租户ID是从UserContext中获取的
@GetMapping("/task")
public ResponseEntity<List<TaskDO>> list() {
log.info("GET /task use threadId: {}",Thread.currentThread().getId());
return ResponseEntity.ok(taskRepository.findAllByTenantId(UserContext.getTenantId()));
}
另外有一个登录拦截器,大致逻辑是从请求Token里解析出tenantId然后设置到UserContext
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String tenantId = request.getHeader("X-TENANT-ID");
if (tenantId != null) {
UserContext.setTenantId(Long.parseLong(tenantId));
}
return true;
}
}
在数据实体对象上有一个@EntityListeners
@Entity
@Data
@Table(name = "task")
@EntityListeners(value = { AddListener.class })
public class TaskDO {
@Id
private Long id;
private String name;
private Long tenantId;
}
在AddListener里使用 @PrePersist拦截了添加操作,将当前对象的tenantId设置成UserContext的租户ID
public class AddListener {
@PrePersist
public void preSetTenantId(Object entity) throws Exception {
Long tenantId = UserContext.getTenantId();
if (tenantId == null) {
return;
}
Field tenantidField = entity.getClass().getDeclaredField("tenantId");
if (tenantidField == null) {
return;
}
tenantidField.setAccessible(true);
tenantidField.set(entity, tenantId);
}
}
为了更快的模拟出效果我们将Tomcat的最大线程数量设置为1
server:
tomcat:
threads:
max: 1
然后先调用租户1的获取任务列表,再调用/api/sync,第一次调用/api/sync接口是新增任务的租户ID为1,第二次调用是修改操作租户ID被改成2,完全和线上的BUG一样。
###
GET localhost/task
X-TENANT-ID: 1
###
POST localhost/api/sync
Content-Type: application/json
{
"id": 8,
"name": "赵侠客任务8",
"tenantId": 2
}
四、** **ThreadLocal总结
其实ThreadLocal并不是什么很高明的设计,它只是对Thread对象中一个Map成员变量的封装,说白了你完全可以在Thread对象中定义一个Map,然后通过Thread.currentThread().getMap()来获取这个Map,然后直接通过map.put()保存当前线程的数据,也能达到ThreadLocal一样的效果,而且使用起来更简单方便。我们可以简单看下ThreadLocal的实现:
ThreadLocal的set()方法:
ThreadLocal的set()方法主要通过以下三步:
-
通过Thread.currentThread()获取当前线程对象
-
通过 getMap(t)获取当前线程对象中的ThreadLocalMap
-
将当前ThreadLocal对象当作Key,要设置的value当作值添加到map中
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
ThreadLocal的getMap()方法
getMap()方法直接返回了Thread对象中的threadLocals, 如果map对象是空会调用createMap()方法将Thread对象的中ThreadLocalMap变量创建一个新的对象
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
ThreadLocalMap对象
ThreadLocalMap对象并不是JAVA中的Map,而是ThreadLocal中定义的一个简单Map,使用Entry存储MAP中的数据,这里值得注意的是Entry继承了WeakReference是不个弱引用。
弱引用:弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了「只具有弱引用」的对象,不管当前内存空间足够与否,都会回收它的内存
关于这里为什么要使用弱引用,主要是因为使用强应用会导致Entry对象一直不被回收从而产生内存泄露,具体原因网上有很多文章详细分析了,有兴趣可以搜下ThreadLocal为什么使用弱引用
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
ThreadLocal对象原理是非常简单的,使用后一定要即使的清理,本次BUG解决方法就是在请求结束后调用UserContext.remove()清理当前线程中的保存ThreadLocal对象中的值就好了
public class LoginInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserContext.remove();
}
}
五、InheritableThreadLocal
在Thread对象中除了有一个 ThreadLocal.ThreadLocalMap threadLocals对象外还有一个成员变量 ThreadLocal.ThreadLocalMap inheritableThreadLocals,操作它对应的封装类叫InheritableThreadLocal
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
InheritableThreadLocal是继承了是ThreadLocal,只是在getMap()是返回了Thread对象中的inheritableThreadLocals,在createMap()时将ThreadLocalMap对象符给Thread对象中的 inheritableThreadLocals成员变量。
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
public InheritableThreadLocal() {}
protected T childValue(T parentValue) {
return parentValue;
}
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}
InheritableThreadLocal和ThreadLocal的区别就是InheritableThreadLocal在子线程中可以获取到主线程中的值,我们看下面的Demo
public class TestThreadLocal implements Runnable {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
private static ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
public static void main(String[] args) {
threadLocal.set("公众号");
inheritableThreadLocal.set("赵侠客");
Thread tt = new Thread(new TestThreadLocal());
tt.start();
}
@Override
public void run() {
System.out.println("子线程中的值threadLocal:" + threadLocal.get());
System.out.println("子线程中的值inheritableThreadLocal:" + inheritableThreadLocal.get());
}
}
▲子线程可获取主线程中的变量
从图中可以看出在子线程中可以通过InheritableThreadLocal获取主线程中的值,但是ThreadLocal获取不到的
InheritableThreadLocal原理
我们看Thread对象的构造方法里有一段如下代码:
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
在创建线程时,如果父级线程的parent.inheritableThreadLocals不为空,则将父级线程中的inheritableThreadLocals给当前线程,也就是说使用InheritableThreadLocal只能在创建线程时同步父级线程中的值,后面父级线中的值修改是不会同步到子线程的。
我们看下面的代码:在创建子线程后我们在主线程里将inheritableThreadLocal中的值修改
public class TestThreadLocal implements Runnable {
private static ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
inheritableThreadLocal.set("公众号-赵侠客");
Thread tt = new Thread(new TestThreadLocal());
tt.start();
inheritableThreadLocal.set("掘金-赵侠客");
System.out.println("主线程inheritableThreadLocal:"+inheritableThreadLocal.get());
tt.join();
}
@Override
public void run() {
System.out.println("子线程inheritableThreadLocal:" + inheritableThreadLocal.get());
}
}
▲主线程修改变量子线程不会更新
可以看出主线程中修改了InheritableThreadLocal中的值,在子线程是不会更新的,获取的还是老的值。
那么有没有什么破解之法呢?当然有了,这时就轮到我们TransmittableThreadLocal登场了,TransmittableThreadLocal是阿里开源的一个框架指在解决InheritableThreadLocal主线程对象修改无法同步子线程的问题
六、TransmittableThreadLocal
官网地址:https://github.com/alibaba/transmittable-thread-local
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.12.6</version>
</dependency>
使用方法:
public class TestThreadLocal implements Runnable {
public static ExecutorService executorService = Executors.newFixedThreadPool(1);
private static TransmittableThreadLocal<String> transmittableThreadLocal = new TransmittableThreadLocal<>();
public static void main(String[] args) {
transmittableThreadLocal.set("公众号-赵侠客");
Runnable task = new TestThreadLocal();
//首次提交任务
executorService.submit(TtlRunnable.get(task));
//主线程修改值后需要再次提交任务
transmittableThreadLocal.set("掘金-赵侠客");
executorService.submit(TtlRunnable.get(task));
}
@Override
public void run() {
System.out.println("子线程transmittableThreadLocal:" + transmittableThreadLocal.get());
}
}
▲主线程修改变量子线程中可以获取到更新后的值
可以看出子线程中成功获取到了主线程中修改后的值。
总结
本文从排查一个线程的BUG总结了ThreadLocal的基本用法主注意事项并引出了InheritableThreadLocal和TransmittableThreadLocal,针对这三个类可以做以下总结:
- ThreadLocal中的变量作用域为当前线程,解决了多线程并发问题
- ThreadLocal中的变量使用后要及时清理
- ThreadLocal中Map对象是Key是自己,值为需要保存的对象
- ThreadLocal子线程无法获取主线程中的值
- InheritableThreadLocal 解决了子线程中获取主线程值的问题
- InheritableThreadLocal 在主线程中修改变量后,子线程不会同步
- TransmittableThreadLocal 解决了线程池复用时主线程变量修改同步子线程的问题