title: 生产环境频繁被自动退出 tags:
- shiro
- redis
- lua
- 事务
- kickout categories: 工作日志 date: 2017-05-25 18:18:56
最近发现一个奇怪的现象,用户登录的时候总是提示
本以为只是正常偶发,突然最近日志
check了一下登录请求发现如下
192.168.1.170 - - [01/Jun/2017:10:39:10 +0800] "GET /kzf6/user/login.do?username=lihuaqixiu&password=96e79218965eb72c92a549dd5a330112 HTTP/1.1" 200 226
192.168.1.170 - - [01/Jun/2017:10:39:11 +0800] "GET /kzf6/user/login.do?username=lihuaqixiu&password=96e79218965eb72c92a549dd5a330112 HTTP/1.1" 200 226
复制代码
基本上通过按回车登录的用户均是会出现两条请求(即用户登录了两次<几乎同时>)
那么用户登陆了两次会有啥问题呢?
参考shiro实现用户踢出功能 实现
用户当登录成功后
会调用AuthenticationListener的onSuccess回调。
此时会校验当前用户是否有其他的sessionId存在,如果存在就按照踢出策略让对应session标记成被踢出,当回话再次访问时会直接跳到被踢出画面。
其实无论开涛或者我修改后的版本都存在一个问题,同步。
主要问题在此
while (lop.size(redisListKey) > maxSession) { Serializable kickoutSessionId;
if (kickoutAfter) { //如果踢出后者
kickoutSessionId = lop.rightPop(redisListKey);
} else { //否则踢出前者
kickoutSessionId = lop.leftPop(redisListKey);
}
复制代码
其实说起来也很明显,查询和做修改的操作并不是同步的,比如对于同一个redisKey来说(并发)即很有可能出现意料之外的问题。
那么改善呢也很简单,对应的根据redisKey来做一把锁(分布式情况较为复杂)恰巧我放系统正是分布式系统。那么如何解决呢?
和原先一样,使用redis的lua脚本来完成 参考 shiro实现用户踢出功能
改善后代码如下
--
-- Created by IntelliJ IDEA.
-- User: qixiaobo
-- Date: 2017/6/2
-- Time: 10:24
-- 移除session id,当sessionid数目小于允许登录数这返回空,使用lua脚本redis操作的保证原子性
-- keys[1]对应redis的list的key
-- args[1]对应maxSession
-- args[2]对应 true:left 或者false: right
-- 返回是否存入redis
local list_key = KEYS[1];
local max_session = ARGV[1];
local remove_before = ARGV[2]
local size = redis.call('LLEN', list_key);
local session_id;
if size > tonumber(max_session) then
if remove_before == 'true' then
session_id = redis.call('LPOP', list_key);
else
session_id = redis.call('RPOP', list_key);
end;
end;
return session_id;
复制代码
<bean id="removeSessionKey" class="org.springframework.data.redis.core.script.DefaultRedisScript">
<property name="location" value="classpath:removeSessionKey.lua"/>
<property name="resultType" value="java.lang.String"/>
</bean>
复制代码
/**
* Created by qixiaobo on 2017/5/22.
*/
public class KickOutSessionListener implements AuthenticationListener {
private static final String SESSION_KEY_KICKOUT = "kickout";
private static final String SESSION_KEY_KICKOUT_TIME = "kickout_time";
private static final String SESSION_KEY_KICKOUT_IP = "kickout_ip";
private static final String REDIS_KEY_PREFIX = CachingSessionDAO.ACTIVE_SESSION_CACHE_NAME + ":";
private Logger logger = LoggerFactory.getLogger(KickOutSessionListener.class);
private boolean kickoutAfter; //踢出之前登录的/之后登录的用户 默认踢出之前登录的用户
private int maxSession; //同一个帐号最大会话数 默认1
private SessionManager sessionManager;
@Autowired
@Qualifier(value = "stringRedisTemplate")
private StringRedisTemplate template;
@Autowired
private RedisScript<Boolean> addSessionAndExpireList;
@Autowired
private RedisScript<String> removeSessionKey;
@Value("#{T(java.lang.String).valueOf(${session.validation.interval}/1000)}")
private String sessionExpire;
@Value("${shiro.kickout}")
private boolean enable;
@Override
public void onSuccess(AuthenticationToken token, AuthenticationInfo info) {
if (enable) {
Subject subject = SecurityUtils.getSubject();
HttpServletRequest request = WebUtils.getHttpRequest(subject);
Session session;
final String username = token.getPrincipal().toString();
try {
session = subject.getSession();
} catch (SessionException ex) {
logger.warn(ex.getMessage(), ex);
return;
}
if (session == null) {
return;
}
String sessionId = (String) session.getId();
ListOperations<String, String> lop = template.opsForList();
final String redisListKey = getRedisKey(username);
//通常情况下 maxSession为1就不判断size了
try {
List<String> listKey = Collections.singletonList(redisListKey);
if (session.getAttribute(SESSION_KEY_KICKOUT) == null) {
template.execute(addSessionAndExpireList, listKey, sessionId, sessionExpire);
}
//如果队列里的sessionId数超出最大会话数,开始踢人
String kickoutSessionId;
while ((kickoutSessionId = template.execute(removeSessionKey, listKey, String.valueOf(maxSession), String.valueOf(!kickoutAfter))) != null) {
Session kickoutSession;
try {
kickoutSession = sessionManager.getSession(new DefaultSessionKey(kickoutSessionId));
} catch (SessionException exception) {
logger.warn(exception.getMessage(), exception);
kickoutSession = null;
}
if (kickoutSession != null) {
//设置会话的kickout属性表示踢出了
kickoutSession.setAttribute(SESSION_KEY_KICKOUT, true);
kickoutSession.setAttribute(SESSION_KEY_KICKOUT_TIME, new DateTime().toString(AppConstant.DEFAULT_DATE_FORMAT_PATTERN));
kickoutSession.setAttribute(SESSION_KEY_KICKOUT_IP, WxbStatic.getRemoteIp(request));
}
}
} catch (Exception ex) {
logger.error(ex.getMessage(), ex);
}
}
}
@Override
public void onFailure(AuthenticationToken token, AuthenticationException ae) {
}
@Override
public void onLogout(PrincipalCollection principals) {
}
public void setKickoutAfter(boolean kickoutAfter) {
this.kickoutAfter = kickoutAfter;
}
public void setMaxSession(int maxSession) {
this.maxSession = maxSession;
}
public void setSessionManager(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
public StringRedisTemplate getTemplate() {
return template;
}
public void setTemplate(StringRedisTemplate template) {
this.template = template;
}
private String getRedisKey(String key) {
return REDIS_KEY_PREFIX + key;
}
}
复制代码
核心改动就是将redis list的查询和修改放在了lua脚本中完成,维护了事务性。
问题回到原点,上述改动是对于事务做了改善,那么为何会出现用户被强制退出了呢?
经过调查发现 基本上用户早上过来会出现一波高峰被强制退出。
考虑可能和session过期时间有关,我们配置session的过期时间为180min,那么在用户早上过来我们可有认为是新会话(没有对应的sessionid)。
由于某种方式登录时如同开头时所说存在发起了两遍登录的请求(这个bug太cheap了)
分析一下
不带有sessionid
req1过来系统分配sessionId1
req2过来系统分配sessionId2
req1经过kickoutlistener 发生如下行为 将sessionId1放入list 同时校验sessionid1没问题 不会标记
req2经过kickoutlistener 发生如下行为 将sessionId2放入list 同时校验sessionid2没问题 不会标记 但是将sessionid1标记为退出
req1返回画面给浏览器 请求结束(将sessionid1写入到cookie)如果画面足够快的话此时还没有接收到req2的返回(就不会有req2的session2回写到cookie)
点击任何画面会自动标记成被踢出
带有sessionid
req1过来系统使用sessionId1
req2过来系统使用sessionId1
req1经过kickoutlistener 发生如下行为 将sessionId1放入list 如果存在就不放入否则放入
req2经过kickoutlistener 发生如下行为 将sessionId2放入list 如果存在就不放入否则放入
req1返回画面给浏览器 请求结束(将sessionid1写入到cookie)
没问题可以正常使用
因此问题的根本原因是一个没有登陆过或者已经过期或者清楚过所有cookie的同时登陆了两次系统造成。
修改对应代码 问题解决。