恐怖!ThreadLocal引起的一次线上事故

69 篇文章 3 订阅
25 篇文章 1 订阅

前言

不知道什么时候年轻的我曾一度认为Java没啥难度,没有我实现不了的需求,没有我解不了的bug直到我遇到至今难忘的一个bug 。 线上用户存储数据后查看提示无权限

初次定位

明明自己添加的数据,为什么提示自己没有权限呢?我一开始自信地认为是我们的客户操作有问题、或者是我们权限配置有问题

但是带我自己亲自验证了一下之后发现这个问题时现时不现,属于一个偶发的问题。这个在开发阶段还真的不容易发现。

问题升级

  • 经过自己的测试后让我更加怀疑人生了,你要么就有问题要么就没问题。一会有一会没有到底又是几个意思呢?偶先的问题真的很难解决啊。问题定位到这里我已经精疲力竭了。然后就放弃了定位
  • 但是问题还是得解决,第二天我又硬着头皮开始研究了。可能第二天头脑比较清醒我发现我们系统中在插入数据的时候会自动获取到当前登录用户并在数据库中记录次数据的创建者及最新的修改者。这更应该说明我们的问题离谱,但是问题在我们获取当前登录用户的时候出现了问题
  • 对此,我将问题追踪了一下,终于将问题本质找到了。我们获取当前登录用户是通过ThreadLocal 来实现的。那么问题就是``ThreadLocal` 获取用户信息有问题
  • 我们分布式开发系统。我们会在每个模块里添加一个aop拦截器,通过请求头的token再去user模块查询用户基本信息。然后放到``ThreadLocal中。这样我们的系统中随处都可以通过ThreadLocal` 这个对象是获取我们的登陆用户。
  • 别问我为什么要在每个模块都这样做?别问我为什么用ThreadLocal?别问我为什么是分布式还要这样做? 因为今天我们的重点是解决bug
    在这里插入图片描述

开门见山

问题就出现在getUser那块逻辑里。因为我们的设计就是在系统中随处都可以获取到User对象。当然我们这里指的是任何请求里。对于MQ、定时器这些模块里肯定是没有User的。因为这些没法走AOP拦截
ThreadLocal获取用户信息乱串,导致用户新增数据权限异常

最终定位

我们的ThreadLocal 是个对象,我们系统中是通过一个工具类获取这个对象的属性的。在这个对象我们提供set、get方法。
在这里插入图片描述
上面的流程展示了在获取到User用户之后就会加入到工厂。如果工厂已经存在了就不会加入。否则就不会加入我们的用户

这样也是避免我们不断加入重复的用户信息。因为同一个线程对应的只可能是一个用户。

思考

public static UserInfo getUser() {
    return userThreadLocal.get();
}

上面是我们工具类的get方法。这就是将ThreadLocal对象存储的内容返回出去。这一步应该不会出现问题。

在getUser中很明显没有问题,我们利用排除法只剩下了setUser了。虽然排除了别人的嫌疑但是setUser我还是看不出有什么问题。经过一阵debug断点跟踪后我发现我们setUser逻辑的确有问题

setUser是将用户信息保存到``Threadlocal 对象中,但是前提是ThreadLocal`中没有用户。对就是这个问题,如果已经有了用户呢?那么我们真正的用户就会无法添加进去到了这里问题逐渐地明朗起来。使我们ThreadLocal对象管理的有问题。导致保存了上次的用户信息从而导致用户信息乱串的现象

解决问题

既然我们已经定位到ThreadLocal的管理问题,那么我们就好办了。

ThreadLocal简单梳理

在这里插入图片描述

  • ThreadLocal 将对象保存在线程中。换句话说就是每个线程的数据会相互隔离。基于这个特性我们可以将用户信息存储在这里,这样我们能保证我们的当前线程下执行分各种方法都能通过他获取到用户信息

  • ThreadLocal内部是将已自己为key, 存储对象为value存储到当前线程中的map中。这个map会随着线程的销毁而被JVM回收。

  • 但是在我们实际开发中经常会使用线程池来避免线程的重复创建及销毁。那么线程往往是不会被销毁的

  • 在Spring中集成的类似Tomcat、JBoss等web容器中都是默认使用的一定数量的线程数的。而我们在-spring中使用的线程复用功能就导致了我们在获取当前线程的用户时因为此线程被别人使用过从未导致用户信息没有被更新成功。从而引发我们上面提到的奇怪的问题

  • 那么既然是没有被更新,到这里我们就很好解决了,要么每次使用完成后都将ThreadLocal中的数据remove。因为他内部是弱引用在下次回收就会将对象回收这样也不会造成内存泄漏的问题

  • 或者我们在我们的AOP中setUser之前先将用户ThreadLocal清空。两种方式都可以完美解决我们的问题

具体代码实现

/**
 * 请求生命周期最后一步销毁是做的回调事件
 * 用于销毁在线用户信息,防止在线用户信息互相干扰(在多线程复用时)
 */
@WebListener
@Primary
public class SysServletRequestListener implements ServletRequestListener {
    @Override
    public void requestDestroyed(ServletRequestEvent requestEvent) {
        UserInfoUtil.clearUserInfo();
    }

    @Override
    public void requestInitialized(ServletRequestEvent sre) {

    }
}
  • 我们可以通过spring提供的监听器,监听一个请求的生命周期在这个请求完成之后将我们的ThreadLocal进行remove。 为什么我推荐这种做法呢。因为请求结束就清空可以快速地让出内存让他去做更加有用的事情。

  • 如果是第二种方法那么如果我们没有人在登录,或者说在下一次登录之前这块不需要的内存永远被占着

总结忠告

这次问题出现得很是奇怪,一度让我怀疑人生,但是永远相信程序是不会无缘无故地出问题的。

出问题的只能是我们的代码有问题,要善于解析问题,将问题细化,细化到我们代码层面而不是业务层面

使用一个技术时最好能先了解他内部的一个原理。或者最起码先了解他的大概逻辑

别看这篇文章寥寥几字就解决了我们的问题,但是实际上我在解决他的过程中吃了不少的苦。好几个夜晚都是我在陪他战斗

我在定位到时ThreadLocal后就花了一个小时学习了下他的逻辑并跟踪了他的源码。最后结合我们的业务才发现了眉目

总之有问题是好事情,有了问题我们才能成长。至少在这次的问题中我学习到了ThreadLocal。我的这次问题也是使用他的典型问题,另外还有一个内存泄漏的问题这是在学习他源码的过程领悟到的一点。关于内存泄漏我们有时间再看吧。问题解决。终于可以继续happy了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值