文档章节

生产环境频繁被自动退出

Mr_Qi
 Mr_Qi
发布于 2017/06/02 13:27
字数 1378
阅读 21
收藏 0
点赞 0
评论 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
粉丝 252
博文 298
码字总数 312931
作品 0
南京
程序员
我们应该如何基于容器来进行软件的持续交付(二)?

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

wise2c ⋅ 2016/12/26 ⋅ 0

我们应该如何基于容器来进行软件的持续交付(一)?

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

wise2c ⋅ 2016/12/22 ⋅ 0

谈谈持续集成,持续交付,持续部署之间的区别

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

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

CI持续集成介绍

互联网软件的开发和发布,已经形成了一套标准流程,最重要的组成部分就是持续集成(Continuous integration,简称CI)。 一、概念 持续集成(Continuous Integration)指的是,频繁地(一天多...

若与 ⋅ 2017/11/26 ⋅ 0

持续交付:发布可靠软件的系统方法

1 持续交付和持续部署 持续交付 是目前的一个挺火的概念,它所描述的软件开发,是从原始需求识别到 最终产品部署到生产环境这个过程中,需求以小批量形式在团队的各个角色间顺 畅流动,能够以...

onlyzq ⋅ 2015/03/29 ⋅ 0

Jenkins与网站代码上线解决方案

1.1 前言 Jenkins是一个用Java编写的开源的持续集成工具。在与Oracle发生争执后,项目从Hudson项目独立。 Jenkins提供了软件开发的持续集成服务。它运行在Servlet容器中(例如Apache Tomcat...

侯召顺 ⋅ 2017/12/01 ⋅ 0

6.2、 产品经理甩锅指南-CI环境

上次在这里提到了敏捷开发scrum,6、产品经理和包工头的故事,敏捷开发背景下,还有CI(持续集成)环境,持续交付和持续部署。 小奈:今天捅了个篓子,我只是叫开发,修改个导航栏而已。 也测试...

产品经理的技术课堂 ⋅ 06/01 ⋅ 0

PDM、ERP及MES的实施顺序探讨来源

1、PDM与c谁先谁后   前面的规点,首先从数据流向上看它是对的。没有产品设计,没有零件目录,没有工艺文件和工时定额,生产组织是无法进行的。这无论搞不搞信息化都得如此。但是,就信息集...

ciohome ⋅ 2010/06/06 ⋅ 0

解读基础设施即代码

现代软件开发对基础设施的管理提出了更苛刻的要求。产品要适应瞬息万变的市场,要求基础设施要有更快的响应速度。而持续交付和DevOps的推行要求产品团队对部署和运维要有更高的自主性。技术的...

无敌西瓜 ⋅ 2017/05/07 ⋅ 0

使用Spring profile 多环境配置管理

使用Spring profile 多环境配置管理 现象:在实际项目开发中,由于都存在复杂多变的配置文件(redis、mysql等等),这个时候我们在频繁迭代过程中,需要不断的发布新版本,这个时候就会涉及到...

秋日芒草 ⋅ 05/08 ⋅ 0

没有更多内容

加载失败,请刷新页面

加载更多

下一页

服务网关过滤器

过滤器作用 我们的微服务应用提供的接口就可以通过统一的API网关入口被客户端访问到了。但是,每个客户端用户请求微服务应用提供的接口时,它们的访问权限往往都需要有一定的限制,系统并不会...

明理萝 ⋅ 1分钟前 ⋅ 1

【2018.06.21学习笔记】【linux高级知识 14.1-14.3】

14.1 NFS介绍 14.2 NFS服务端安装配置 14.3 NFS配置选项

lgsxp ⋅ 10分钟前 ⋅ 0

Day18 vim编辑模式、命令模式与练习

编辑模式 命令模式 :nohl 不高亮显示 :x与:wq类似,如果在更改文件之后操作,两者效果一样;如果打开文件,没有任何操作; :wq会更改mtime,但是:x不会。 练习题 扩展 vim的特殊用法 ht...

杉下 ⋅ 13分钟前 ⋅ 0

Enum、EnumMap、EnumSet

1、Enum 不带参数 public enum Car { AUDI { @Override public int getPrice() { return 25000; } }, MERCEDES { ......

职业搬砖20年 ⋅ 14分钟前 ⋅ 0

Java中的锁使用与实现

1.Lock接口 锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源。 在Lock出现之前,java程序是靠synchronized关键字实现锁功能的,而Java SE5之后,...

ZH-JSON ⋅ 15分钟前 ⋅ 0

线程组和 ThreadLocal

前言 在上面文章中,我们从源码的角度上解析了一下线程池,并且从其 execute 方法开始把线程池中的相关执行流程过了一遍。那么接下来,我们来看一个新的关于线程的知识点:线程组。 线程组 ...

猴亮屏 ⋅ 16分钟前 ⋅ 0

相对路径和绝对路径

基本概念   文件路径就是文件在电脑中的位置,表示文件路径的方式有两种,相对路径和绝对路径。在网页设计中通过路径可以表示链接,插入图像、Flash、CSS文件的位置。   物理路径:物理路...

临江仙卜算子 ⋅ 20分钟前 ⋅ 0

消息队列属性及常见消息队列介绍

什么是消息队列? 消息队列是在消息的传输过程中保存消息的容器,用于接收消息并以文件的方式存储,一个队列的消息可以同时被多个消息消费者消费。分布式消息服务DMS则是分布式的队列系统,消...

中间件小哥 ⋅ 22分钟前 ⋅ 0

java程序员使用web3j进行以太坊开发详解

如何使用web3j为Java应用或Android App增加以太坊区块链支持,教程内容即涉及以太坊中的核心概念,例如账户管理包括账户的创建、钱包创建、交易转账,交易与状态、智能合约开发与交互、过滤器...

笔阁 ⋅ 23分钟前 ⋅ 0

vim编辑模式、vim命令模式

vim编辑模式 使用vim filename 进入的界面是一般模式,在这个模式下虽然我们能够查看,复制,剪切,粘贴,但是不能编辑新的内容,如何能直接写入东西呢?这就需要进入编辑模式了,从一般模式...

李超小牛子 ⋅ 26分钟前 ⋅ 0

没有更多内容

加载失败,请刷新页面

加载更多

下一页

返回顶部
顶部