在保持用户登录态的这个需求下,为啥要用 ThreadLocal 存储用户信息,而不是采用常见的 Cookie + Session。
Cookie + Session
由于 HTTP 协议是无状态的,完成操作关闭浏览器后,客户端和服务端的连接就断开了,所以我们必须要有一种机制来保证客户端和服务端之间会话的连续性,常见的,就是使用 Cookie + Session(会话) 的方式。
具体来说,当客户端请求服务端的时候,服务端会为此次请求开辟一块内存空间(Session 对象),服务端可以在此存储客户端在该会话期间的一些操作记录(比如用户信息就可以存在 Session 中),同时会生成一个 sessionID,并通过响应头的 Set-Cookie:JSESSIONID=XXXXXXX 命令,将 seesionID 存储进客户端的 Cookie 中。
可能会有同学问为啥不直接把数据全部存在 Cookie 中,还整个 Session 出来然后把 sessionID 存在 Cookie 中的?
- Cookie 长度的限制:首先,最基本的,Cookie 是有长度限制的,这限制了它能存储的数据的长度
- 性能影响:Cookie 确实和 Session 一样可以让服务端程序跟踪每个客户端的访问,但是每次客户端的访问都必须传回这些 Cookie,那如果 Cookie 中存储的数据比较多的话,这无疑增加了客户端与服务端之间的数据传输量,增加了服务器的压力。
- 安全性:Session 数据其实是属于服务端的数据,而 Cookie 属于客户端,把本应在 Session 中存储的数据放到客户端 Cookie,使得服务端数据延伸到了外部网络及客户端,显然是存在安全性上的问题的。当然我们可以对这些数据做加密,不过从技术来讲物理上不接触才是最安全的。
这样,按照 Cookie + Seesion 的机制,服务端在接到客户端请求的时候,只要去 Cookie 中获取到 sessionID 就能据此拿到 Session了。Session 存活期间,我们认为客户端一直处于活跃状态(用户处于登录态),一旦 Session 超期过时,那么就可以认为客户端已经停止和服务器进行交互了(用户退出登录)。
如果遇到禁用 Cookie 的情况,一般的做法就是把这个 sessionID 放到 URL 参数中。这也是经常在面试中会被问到的问题。
这种机制在单体应用时代应用非常广泛,但是,随着分布式时代的到来,Session 的缺点也逐渐暴露出来。
举个例子,比如我们有多个服务器,客户端 1 向服务器发送了一个请求,由于负载均衡的存在,该请求被转发给了服务器 A,于是服务器 A 创建并存储了这个 Session。
紧接着,客户端 1 又向服务器发送了一个请求,但是这一次请求被负载均衡给了服务器 B,而服务器 B 这时候是没有存储服务器 A 的 Session 的,这就导致 Session 的失效。
明明用户在上一个界面还是登录的,跳到下一个界面就退出登录了,这显然不合理。
分布式集群 Session 共享
分布式session共享方案:
1、粘性session:在nginx中提供一致性哈希策略,可以保持用户ip进行hash值计算固定分配到某台服务器上,负载也比较均衡,其问题是假如有一台服务器挂了,session也丢失了。
2、同步session:当某一台服务器存了session后,同步到其他服务器中,其问题是同步session到其他服务器会对服务器性能产生影响,服务器之间耦合性较强。
3、共享session:单独搞一台服务器用来存session,其他服务器都向这台服务器获取session,其问题是这台服务器挂了,session就全部丢失。
4、redis集中管理session(主流方法):redis为内存数据库,读写效率高,并可在集群环境下做高可用。
当然了,对此的解决方法其实也有很多种,其实就是如何解决 Session 在多个服务器之间的共享问题:
同步Session
这个是最容易想到的,既然服务器 B 没有服务器 A 存储的 Session,那各个服务器之间同步一下 Session 数据不就完了
这种方案存在的问题也是显而易见的:
- 同步 Session 数据带来了额外的网络带宽开销。只要 Session 数据有变化,就需要将数据同步到所有其他机器上,机器越多,同步带来的网络带宽开销就越大。
每台Web服务器都要保存所有 Session 数据,如果整个集群的 Session 数据很多(比如很多人同时访问网站的情况),每台服务器用于保存 Session 数据的内存占用会非常严重。
粘性Session
从名称也能看出来,Sticky,即让负载均衡器能够根据每次的请求的会话标识来进行请求的转发,保证一个会话中的每次请求都能落到同一台服务器上面。
存在问题的:如果某台服务器宕机或者重启了,那么它上面存储的 Session 数据就丢失了,用户就需要重新进行登陆。
- 负载均衡器变为一个有状态的节点,因为他需要保存 Session 到具体服务器的映射,和之前无状态的节点相比,内存消耗会更大,容灾方面会更麻烦。
共享Session
单独搞一台服务器用来存session,其他服务器都向这台服务器获取session,其问题是这台服务器挂了,session就全部丢失。Session 数据集中存储
借助外部存储(Redis、MySQL 等),将 Session 数据进行集中存储,然后所有的服务器都从这个外部存储中拿 Session
存在的问题也很显然:
- 过度依赖外部存储,如果集中存储 Session 的外部存储机器出问题了,就会直接影响到我们的应用
ThreadLocal
事实上,无论采用何种方案,使用 Session 机制,会使得服务器集群很难扩展,因此,Session 适用于中小型 Web 应用程序。对于大型 Web 应用程序来说,通常需要避免使用 Session 机制。
So,在 Echo 项目中,我们决定摒弃 Session,一个 ThreadLocal 解决所有问题(狗头)!
ThreadLocal 线程本地内存,很好理解,就是每个访问 ThreadLocal 变量的线程都有自己的一个 “本地” 实例副本,每个线程之间互相隔离,互不干涉。
这里我就不详细解释底层原理了,ThreadLocal 适用于如下两种场景:
- 每个线程需要有自己单独的实例(数据)
- 实例(数据)需要在多个方法中共享,但不希望被多线程共享
来看如何用 ThreadLocal 实现我们的需求:显示登录信息,在本次请求中持有当前用户数据。
首先我们需要明白的是,ThreadLocal 只跟其归属的线程有关,线程死亡了,那么它对应的 ThreadLocal 中存储的信息也就被清除了(线程死亡前一定要释放掉绑定的用户数据,不然会出现 OOM 问题),也就是说,ThreadLocal 只用于在本次请求中持有数据。
简单来说,我们把用户数据存入 ThreadLocal 里,这样,只要本次请求未处理完,这个线程就一直还在,当前用户数据就一直被持有,当服务器对本次请求做出响应后,这个线程就会被销毁。
那同一个用户发出的两次请求可能被不同的两个线程进行处理,如何使得这个两个线程的 ThreadLocal 持有相同的用户信息呢?
拦截器。
具体来说,我们定义一个拦截器,在每次请求前都对用户进行判断(为了避免每次请求都经过过滤器,可以将登录成功的用户信息暂时存储到 Redis 中),然后将已经登录成功的用户信息存到 ThreadLocal 里,从而使得该线程在本次请求中持有该用户信息。
ThreadLocal基本原理
https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html#_3-threadlocal
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get()和 set()方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。
每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。
ThreadLocal 内存泄露问题
ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法。
弱引用介绍:
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。