在使用线程池+shiro情况下,shiro获取session时数据异常问题探究
问题引入
前段时间同事完成了一个业务日志的功能,在业务人员完成某些操作时系统会记录操作时间、业务类型、业务变动情况以及操作人等字段,并用aop封装好,后续新增功能只需要在方法上添加注解即可。
上线后该功能一直正常运行着,没出过啥bug,直到前几天我在多线程的情况下调用了该注解添加日志。
我们的系统使用shiro进行认证,
授权,
加密,
会话管理。当前用户的相关信息存储在session中。记录日志时,系统会从session中获取当前操作的操作人,存入数据库。测试人员在登录多个账号检测日志生成情况时发现我完成的功能偶尔会出现 A用户
进行了操作,但日志中记录的确是用户B
情况发生,即下面这种情况。
我仅仅是调用了该注解实现日志功能,与其他使用了该注解的功能相比,除了业务逻辑与多线程之外并没有做其他改动业务逻辑被doAround
增强了,出问题也不可能是出在这,那就只有多线程了。作为一名小菜鸡,我以前也没研究过shiro的源码,只好一步一步打着断点来了。
最初调试
注:以下调试全程都是在登录两个不同账号的情况下产生的
首先,线程池部分情况如下:
public class DemoThreadPoolUtil {
private static ThreadPoolTaskExecutor pool;
/**
* 核心线程数
* 核心线程会一直存活,即使没有任务需要执行
*/
private static final int CORE_POOL_SIZE = 3;
/**
* 最大线程数
* 当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务
* 当线程数=maxPoolSize,且任务队列已满时,线程池会拒绝处理任务而抛出异常
*/
private static final int MAXIMUM_POOL_SIZE = 4;
/**
* 任务队列容量(阻塞队列)
* 当核心线程数达到最大时,新任务会放在队列中排队等待执行
*/
public static final int QUEUE_CAPACITY_SIZE = 1000;
/**
* 线程名称前缀
*/
private static final String THREAD_NAME_PREFIX = "SYNC-PRODUCT-STATUS-POOL-";
/**
* 线程空闲时间
* 当线程空闲时间达到keepAliveTime时,线程会退出,直到线程数量=corePoolSize
* 如果allowCoreThreadTimeout=true,则会直到线程数量=0
*/
private static final int KEEP_ALIVE_TIME = 1000;
/**
* 核心线程会被回收
*/
private static final boolean ALLOW_CORE_THREAD_TIME_OUT = true;
private static synchronized ThreadPoolTaskExecutor getPool() {
return getPool("");
}
private static synchronized ThreadPoolTaskExecutor getPool(String otherThreadNamePrefix) {
if (pool == null) {
pool = new ThreadPoolTaskExecutor();
// 线程池设置代码
......
}
return pool;
}
public static void execute(Runnable r) {
ProductStatusThreadPoolUtil.getPool().execute(r);
}
}
跟着断点,我一路来到了获取用户信息的最里层SimpleSession
的getAttribute(Object key)
方法,源码如下:
public class SimpleSession implements ValidatingSession, Seriazable {
// other codes
......
// session中的属性字段
private transient Map<Object, Object> attributes;
// setAttributes codes and others
......
// 根据key获取属性的方法
public Object getAttribute(Object key) {
Map<Object, Object> attributes = getAttributes();
if (attributes == null) {
return null;
}
return attributes.get(key);
}
// 获取当前session的所有属性
public Map<Object, Object> getAttributes() {
return attributes;
}
......
}
代码很简单,相信不用多说也能理解。
调试信息如下:
在使用用户A完成操作后进入该段代码,获取到用户A的相关信息,当前线程为http-nio-8988-exec-2
,id如图所示。
接着我使用用户B完成操作后进入该段代码的调试,期望获取到用户B的相关信息,然而结果却是这样的:
用户B进行了相关操作,但session中获取到的确实用户A的数据。由于此时session已经生成,attribute是session的成员变量,因此拿到相应session后,attribute应该是不变的。于是我沿着代码往上寻找,希望找到是何时获取到的session对象。
public class DelegatingSubject implements Subject {
protected Session session;
protected boolean sessionCreationEnabled;
protected transient SecurityManager securityManager;
private static final String RUN_AS_PRINCIPALS_SESSION_KEY =
DelegatingSubject.class.getName() + ".RUN_AS_PRINCIPALS_SESSION_KEY";
private List<PrincipalCollection> getRunAsPrincipalsStack() {
Session session = getSession(false);
if (session != null) {
return (List<PrincipalCollection>) session.getAttribute(RUN_AS_PRINCIPALS_SESSION_KEY);
}
return null;
}
public Session getSession(boolean create) {
// 日志记录代码
......
if (this.session == null && create) {
......
SessionContext sessionContext = createSessionContext();
Session session = this.securityManager.start(sessionContext);
this.session = decorate(session);
}
return this.session;
}
}
上面这段代码在shiro源码中,获取session的关键代码都在getSession(boolean create)
中,值得注意的是此处this.session == null && create
并不会在线程池中获取用户信息时触发,而是在登出等其他地方会用到,暂且不详细阅读if块中的代码。可以看到,此处的session
早就实例化了,也就是说还得继续往前找。
再往上倒就到了获取subject
的地方了
public abstract class SecurityUtils {
// 获取subject的方法,如果为空则会新创建一个
public static Subject getSubject() {
Subject subject = ThreadContext.getSubject();
if (subject == null) {
subject = (new Subject.Builder()).buildSubject();
ThreadContext.bind(subject);
}
return subject;
}
}
// shiro封装的一个线程独享资源的类,其中数据使用InheritableThreadLocalMap保存,问题即出现在这
public abstract class ThreadContext {
public static final String SUBJECT_KEY = ThreadContext.class.getName() + "_SUBJECT_KEY";
private static final ThreadLocal<Map<Object, Object>> resources = new InheritableThreadLocalMap<Map<Object, Object>>();
public static Subject getSubject() {
return (Subject) get(SUBJECT_KEY);
}
public static Object get(Object key) {
if (log.isTraceEnabled()) {
String msg = "get() - in thread [" + Thread.currentThread().getName() + "]";
log.trace(msg);
}
Object value = getValue(key);
if ((value != null) && log.isTraceEnabled()) {
String msg = "Retrieved value of type [" + value.getClass().getName() + "] for key [" +
key + "] " + "bound to thread [" + Thread.currentThread().getName() + "]";
log.trace(msg);
}
return value;
}
private static Object getValue(Object key) {
Map<Object, Object> perThreadResources = resources.get();
return perThreadResources != null ? perThreadResources.get(key) : null;
}
}
通过断点调试:发现这是用户A的session。
于是我去找这个数据怎么来的,就看到了这样一个变量:
private static final ThreadLocal<Map<Object, Object>> resources = new InheritableThreadLocalMap<Map<Object, Object>>();
好家伙,搁这给我玩继承父类的ThreadLocalMap是吧?再点进去就是这了
private static final class InheritableThreadLocalMap<T extends Map<Object, Object>> extends InheritableThreadLocal<Map<Object, Object>> {
/**
* This implementation was added to address a
* <a href="http://jsecurity.markmail.org/search/?q=#query:+page:1+mid:xqi2yxurwmrpqrvj+state:results">
* user-reported issue</a>.
* @param parentValue the parent value, a HashMap as defined in the {@link #initialValue()} method.
* @return the HashMap to be used by any parent-spawned child threads (a clone of the parent HashMap).
*/
@SuppressWarnings({"unchecked"})
protected Map<Object, Object> childValue(Map<Object, Object> parentValue) {
if (parentValue != null) {
return (Map<Object, Object>) ((HashMap<Object, Object>) parentValue).clone();
} else {
return null;
}
}
}
简单来说就是一句话,shiro使用了可继承父类的ThreadLocal变量,来保证线程间的数据隔离。然而在使用线程池时,各个线程是可复用的,就导致ThreadLocal变量只在创建线程时生成了一份,后续使用该线程的所有流程都使用的是创建线程时生成的ThreadLocal变量,即A用户操作时可能会获取到B用户创建的操作线程,从而获取到B用户的信息。
解决方法
既然找到了问题所在,那解决方法肯定就好找了
简单粗暴:不使用线程池
线程池不是会保留线程吗,那我不用线程池,直接创建新线程不就行了嘛!
这确实是一种可行的方法,但在我们项目中却没办法用,要去除线程池,改用显式创建新线程的方法会对代码非常大的改动,工程量比较大。
shiro提供的associateWith(runnable)方法
这么大个框架,这问题肯定早就想好解决办法了。在翻阅shiro源码的时候,我在subject
接口中看到了这样几个方法:
public interface Subject {
/**
* Associates the specified {@code Callable} with this {@code Subject} instance and then executes it on the
* currently running thread. If you want to execute the {@code Callable} on a different thread, it is better to
* use the {@link #associateWith(Callable)} method instead.
* 简单翻译一下:将指定Callable与当前Subject实例关联起来在当前线程执行。如果你要在其他线程执行Callable,最好使用associateWith(Callable)方法
*
* @param callable the Callable to associate with this subject and then execute.
* @param <V> the type of return value the {@code Callable} will return
* @return the resulting object returned by the {@code Callable}'s execution.
* @throws ExecutionException if the {@code Callable}'s {@link Callable#call call} method throws an exception.
* @since 1.0
*/
<V> V execute(Callable<V> callable) throws ExecutionException;
/**
* Associates the specified {@code Runnable} with this {@code Subject} instance and then executes it on the
* currently running thread. If you want to execute the {@code Runnable} on a different thread, it is better to
* use the {@link #associateWith(Runnable)} method instead.
* 简单翻译一下:将指定Runnable与当前Subject实例关联起来在当前线程执行。如果你要在其他线程执行Runnable,最好使用associateWith(Runnable)方法
* <p/>
* <b>Note</b>: This method is primarily provided to execute existing/legacy Runnable implementations. It is better
* for new code to use {@link #execute(Callable)} since that supports the ability to return values and catch
* exceptions.
*
* @param runnable the {@code Runnable} to associate with this {@code Subject} and then execute.
* @since 1.0
*/
void execute(Runnable runnable);
/**
* Returns a {@code Callable} instance matching the given argument while additionally ensuring that it will
* retain and execute under this Subject's identity. The returned object can be used with an
* {@link java.util.concurrent.ExecutorService ExecutorService} to execute as this Subject.
* <p/>
* This will effectively ensure that any calls to
* {@code SecurityUtils}.{@link SecurityUtils#getSubject() getSubject()} and related functionality will continue
* to function properly on any thread that executes the returned {@code Callable} instance.
*
* 简单翻译一下:返回一个新的Callable实例,确保其在当前Subject的身份下运行代码。该返回对象可以以当前Subject运行在ExecutorService(线程池)中。
* 这种方法可以确保任何线程从SecurityUtils.getSubject()以及相关方法会正常执行返回的Callable实例
* @param callable the callable to execute as this {@code Subject}
* @param <V> the {@code Callable}s return value type
* @return a {@code Callable} that can be run as this {@code Subject}.
* @since 1.0
*/
<V> Callable<V> associateWith(Callable<V> callable);
/**
* Returns a {@code Runnable} instance matching the given argument while additionally ensuring that it will
* retain and execute under this Subject's identity. The returned object can be used with an
* {@link java.util.concurrent.Executor Executor} or another thread to execute as this Subject.
* <p/>
* This will effectively ensure that any calls to
* {@code SecurityUtils}.{@link SecurityUtils#getSubject() getSubject()} and related functionality will continue
* to function properly on any thread that executes the returned {@code Runnable} instance.
* <p/>
* *Note that if you need a return value to be returned as a result of the runnable's execution or if you need to
* react to any Exceptions, it is highly recommended to use the
*
* {@link #associateWith(java.util.concurrent.Callable) createCallable} method instead of this one.
*
* 简单翻译一下:返回一个新的Runnable实例,确保其在当前Subject的身份下运行代码。该返回对象可以以当前Subject运行在ExecutorService(线程池)中。
* 这种方法可以确保任何线程从SecurityUtils.getSubject()以及相关方法会正常执行返回的Runnable实例
* 注意:如果你想要获取返回值或者想要捕获任何异常,最好使用associateWith(java.util.concurrent.Callable)方法来创建线程而不是使用该方法
*
* @param runnable the runnable to execute as this {@code Subject}
* @return a {@code Runnable} that can be run as this {@code Subject} on another thread.
* @see #associateWith (java.util.concurrent.Callable)
* @since 1.0
*/
Runnable associateWith(Runnable runnable);
}
看到这,聪明的读者肯定想到了解决方法吧,只需要在线程池的execute方法中将传入的runnable方法再包一层shiro给的associateWith(Runnable runnable)方法就行,像这样:
package com.jeesite.modules.utils;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.Objects;
import java.util.concurrent.ThreadPoolExecutor;
/**
* @Description: 产品状态线程池Util
* @Author: 王小木
* @CreateDate: 2022/3/30 10:36
*/
public class ProductStatusThreadPoolUtil {
private static ThreadPoolTaskExecutor pool;
// 其他初始化线程池代码
......
public static void execute(Runnable r) {
Subject subject = ThreadContext.getSubject();
if (Objects.nonNull(subject)) {
ProductStatusThreadPoolUtil.getPool().execute(subject.associateWith(r));
} else {
ProductStatusThreadPoolUtil.getPool().execute(r);
}
}
}
我们简单的跟踪一下associateWith()
方法
public class DelegatingSubject implements Subject {
public Runnable associateWith(Runnable runnable) {
if (runnable instanceof Thread) {
String msg = "This implementation does not support Thread arguments because of JDK ThreadLocal " +
"inheritance mechanisms required by Shiro. Instead, the method argument should be a non-Thread " +
"Runnable and the return value from this method can then be given to an ExecutorService or " +
"another Thread.";
throw new UnsupportedOperationException(msg);
}
return new SubjectRunnable(this, runnable);
}
}
此处的this
是调用associateWith(runnable)的subject。
public class SubjectRunnable implements Runnable {
protected final ThreadState threadState;
private final Runnable runnable;
public SubjectRunnable(Subject subject, Runnable delegate) {
this(new SubjectThreadState(subject), delegate);
}
protected SubjectRunnable(ThreadState threadState, Runnable delegate) throws IllegalArgumentException {
if (threadState == null) {
throw new IllegalArgumentException("ThreadState argument cannot be null.");
}
this.threadState = threadState;
if (delegate == null) {
throw new IllegalArgumentException("Runnable argument cannot be null.");
}
this.runnable = delegate;
}
......
}
此处设置了线程的状态
public class SubjectThreadState implements ThreadState {
private Map<Object, Object> originalResources;
private final Subject subject;
private transient SecurityManager securityManager;
public SubjectThreadState(Subject subject) {
if (subject == null) {
throw new IllegalArgumentException("Subject argument cannot be null.");
}
this.subject = subject;
SecurityManager securityManager = null;
if ( subject instanceof DelegatingSubject) {
securityManager = ((DelegatingSubject)subject).getSecurityManager();
}
if ( securityManager == null) {
securityManager = ThreadContext.getSecurityManager();
}
this.securityManager = securityManager;
}
}
此处将调用associateWith(runnable)
的subject
放入了State
中。至此,我们能够确定,在调用了associateWith(runnable)
方法后能将调用者的subject信息传入子线程中,而不是使用线程创建者的subject信息。