引言
最近我收到一个非常诡异的线上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就不会被改呢?从代码初步看是没有问题的,好像可以排除代码的问题
kotlin
代码解读
复制代码
@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
整理了一份好像面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记【点击此处即可】免费获取
csharp
代码解读
复制代码
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中获取的
less
代码解读
复制代码
@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
java
代码解读
复制代码
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
less
代码解读
复制代码
@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
ini
代码解读
复制代码
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
yaml
代码解读
复制代码
server: tomcat: threads: max: 1
然后先调用租户1的获取任务列表,再调用/api/sync,第一次调用/api/sync接口是新增任务的租户ID为1,第二次调用是修改操作租户ID被改成2,完全和线上的BUG一样。
bash
代码解读
复制代码
### 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中
scss
代码解读
复制代码
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变量创建一个新的对象
javascript
代码解读
复制代码
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为什么使用弱引用
scala
代码解读
复制代码
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对象中的值就好了
java
代码解读
复制代码
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
ini
代码解读
复制代码
ThreadLocal.ThreadLocalMap threadLocals = null; ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
InheritableThreadLocal是继承了是ThreadLocal,只是在getMap()是返回了Thread对象中的inheritableThreadLocals,在createMap()时将ThreadLocalMap对象符给Thread对象中的 inheritableThreadLocals成员变量。
scala
代码解读
复制代码
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
csharp
代码解读
复制代码
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对象的构造方法里有一段如下代码:
ini
代码解读
复制代码
if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
在创建线程时,如果父级线程的parent.inheritableThreadLocals不为空,则将父级线程中的inheritableThreadLocals给当前线程,也就是说使用InheritableThreadLocal只能在创建线程时同步父级线程中的值,后面父级线中的值修改是不会同步到子线程的。
我们看下面的代码:在创建子线程后我们在主线程里将inheritableThreadLocal中的值修改
csharp
代码解读
复制代码
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
xml
代码解读
复制代码
<dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.12.6</version> </dependency>
使用方法:
csharp
代码解读
复制代码
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 解决了线程池复用时主线程变量修改同步子线程的问题