文档章节

生产环境频繁被自动退出

Mr_Qi
 Mr_Qi
发布于 2017/06/02 13:27
字数 1378
阅读 26
收藏 0

最近发现一个奇怪的现象,用户登录的时候总是提示

本以为只是正常偶发,突然最近日志

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的同时登陆了两次系统造成。

修改对应代码 问题解决。

© 著作权归作者所有

共有 人打赏支持
Mr_Qi

Mr_Qi

粉丝 277
博文 356
码字总数 365332
作品 0
南京
程序员
生产系统DRDS建表报sequence已经存在问题

1、 问题描述 客户反馈生产环境系统drds创建表报错sequence已经存在,具体报错如下: 但是虽然执行create已经报错,但是表已经有了,并且可以正常的插入数据; 2、 问题排查 排查drds的docke...

silencecxq
09/06
0
0
我们应该如何基于容器来进行软件的持续交付(二)?

概述 接着上一篇的内容,我们有讲到“持续交付是文化,自动化是基石,垮职能团队协作是根本”,本文将以软文的形式介绍持续交付平台WiseBuild结合Rancher容器管理平台我们是如何进行跨职能团...

wise2c
2016/12/26
85
0
你准备好持续交付(CD)了吗?

[toc] 持续交付(CD, Continuous delivery)就是说每次提交代码时立即构建,并可以将构建部署到生产环境中,本文将分享一些持续交付相关的方法和经验。 自动化(Automation) 自动化对于完善...

好雨云帮
10/15
0
0
我们应该如何基于容器来进行软件的持续交付(一)?

概述 在过去的一段时间里容器已经大量的使用到了IT软件生产的各个环节当中:从软件开发,持续集成,持续部署,测试环境到生产环境。 除了Docker官方的Docker Swarm, Docker Machine以及Docke...

wise2c
2016/12/22
163
0
谈谈持续集成,持续交付,持续部署之间的区别

经常会听到持续集成,持续交付,持续部署,三者究竟是什么,有何联系和区别呢? 假如把开发工作流程分为以下几个阶段: 编码 -> 构建 -> 集成 -> 测试 -> 交付 -> 部署 正如你在上图中看到,...

风起云飞fir_im
2016/08/04
46
0

没有更多内容

加载失败,请刷新页面

加载更多

python中cv2模块imread函数

导入 >> import cv2>> import numpy as np 读图片 >> image_arr = cv2.imread('file_path') 灰度图扩展成彩色图 可以通过图片的channel判断是否是灰度图。如果需要可以将灰度图扩展到RGB......

温子寒
18分钟前
0
0
利用剪切板JS API优化输入框的粘贴体验

直接复制记录下 /**@description 表单输入框粘贴体验优化,出处https://www.zhangxinxu.com/wordpress/?p=8003@author zhangxinxu*/// 遍历所有的输入框[].slice.call(document.qu...

红羊在厦门
26分钟前
0
0
2018CTF大赛学习

1.C伪随机数,可预测,可用来做加密解密,如 int main() { // This program will create same sequence of // random numbers on every program run for(int i = 0; ......

simpower
39分钟前
0
0
XamarinEssentials教程移除键值首选项的键值

XamarinEssentials教程移除键值首选项的键值 如果开发者不再使用首选项中的某一项时,可以将该项移除掉。此时可以使用Preferences类的Remove()方法,该方法可以将存在于首选项中的指定键以及...

大学霸
47分钟前
0
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部