前言:

由于项目需求,需要在集群环境下实现在线用户列表的功能,并依靠在线列表实现用户单一登陆(同一账户只能一处登陆)功能:

在单机环境下,在线列表的实现方案可以采用SessionListener来完成,当有Session创建和销毁的时候做相应的操作即可完成功能及将相应的Session的引用存放于内存中,由于持有了所有的Session的引用,故可以方便的实现用户单一登陆的功能(比如在第二次登陆的时候使之前登陆的账户所在的Session失效)。

而在集群环境下,由于用户的请求可能分布在不同的Web服务器上,继续将在线用户列表储存在单机内存中已经不能满足需要,不同的Web服务器将会产生不同的在线列表,并且不能有效的实现单一用户登陆的功能,因为某一用户可能并不在接受到退出请求的Web服务器的在线用户列表中(在集群中的某台服务器上完成的登陆操作,而在其他服务器上完成退出操作)。

现有解决方案:

1.将用户的在线情况记录进入数据库中,依靠数据库完成对登陆状况的检测

2.将在线列表放在一个公共的缓存服务器上

由于缓存服务器可以为缓存内容设置指定有效期,可以方便实现Session过期的效果,以及避免让数据库的读写性能成为系统瓶颈等原因,我们采用了Redis来作为缓存服务器用于实现该功能。

单机环境下的解决方案:

基于HttpSessionListener:

1 import java.util.Date;
 2 import java.util.Hashtable;
 3 import java.util.Iterator;
 4 import javax.servlet.http.HttpSession;
 5 import javax.servlet.http.HttpSessionEvent;
 6 import javax.servlet.http.HttpSessionListener;
 7 import com.xxx.common.util.StringUtil;
 8 /**
 9  * 
10  * @ClassName: SessionListener
11  * @Description: 记录所有登陆的Session信息,为在线列表做基础
12  * @author libaoting
13  * @date 2013-9-18 09:35:13
14  *
15  */
16 public class SessionListener implements HttpSessionListener {
17     //在线列表<uid,session>
18     private static Hashtable<String,HttpSession> sessionList = new Hashtable<String, HttpSession>();
19     public void sessionCreated(HttpSessionEvent event) {
20         //不做处理,只处理登陆用户的列表
21     }
22     public void sessionDestroyed(HttpSessionEvent event) {
23         removeSession(event.getSession());
24     }
25     public static void removeSession(HttpSession session){
26         if(session == null){
27             return ;
28         }
29         String uid=(String)session.getAttribute("clientUserId");//已登陆状态会将用户的UserId保存在session中
30         if(!StringUtil.isBlank(uid)){//判断是否登陆状态
31             removeSession(uid);
32         }
33     }
34     public static void removeSession(String uid){
35         HttpSession session = sessionList.get(uid);
36         try{
37             sessionList.remove(uid);//先执行,防止session.invalidate()报错而不执行
38             if(session != null){
39                 session.invalidate();
40             }
41         }catch (Exception e) {
42             System.out.println("Session invalidate error!");
43         }
44     }
45     public static void addSession(String uid,HttpSession session){
46         sessionList.put(uid, session);
47     }
48     public static int getSessionCount(){
49         return sessionList.size();
50     }
51     public static Iterator<HttpSession> getSessionSet(){
52         return sessionList.values().iterator();
53     }
54     public static HttpSession getSession(String id){
55         return sessionList.get(id);
56     }
57     public static boolean contains(String uid){
58         return sessionList.containsKey(uid);
59     }
60     /**
61      * 
62      * @Title: isLoginOnThisSession
63      * @Description: 检测是否已经登陆
64      * @param @param uid 用户UserId
65      * @param @param sid 发起请求的用户的SessionId
66      * @return boolean true 校验通过 
67      */
68     public static boolean isLoginOnThisSession(String uid,String sid){
69         if(uid==null||sid==null){
70             return false;
71         }
72         if(contains(uid)){
73             HttpSession session = sessionList.get(uid);
74             if(session!=null&&session.getId().equals(sid)){
75                 return true;
76             }
77         }
78         return false;
79     }
80 }

  用户的在线状态全部维护记录在sessionList中,并且可以通过sessionList获取到任意用户的session对象,可以用来完成使指定用户离线的功能(调用该用户的session.invalidate()方法)。

用户登录的时候调用addSession(uid,session)方法将用户与其登录的Session信息记录至sessionList中,再退出的时候调用removeSession(session) or removeSession(uid)方法,在强制下线的时候调用removeSession(uid)方法,以及一些其他的操作即可实现相应的功能。

基于Redis的解决方案:

该解决方案的实质是将在线列表的所在的内存共享出来,让集群环境下所有的服务器都能够访问到这部分数据,并且将用户的在线状态在这块内存中进行维护。

Redis连接池工具类:

1 import java.util.ResourceBundle;
  2 import redis.clients.jedis.Jedis;
  3 import redis.clients.jedis.JedisPool;
  4 import redis.clients.jedis.JedisPoolConfig;
  5 public class RedisPoolUtils {
  6     private static final JedisPool pool;
  7     static{
  8         ResourceBundle bundle = ResourceBundle.getBundle("redis");
  9         JedisPoolConfig config = new JedisPoolConfig();
 10         if (bundle == null) {    
 11             throw new IllegalArgumentException("[redis.properties] is not found!");    
 12         }
 13         //设置池配置项值  
 14         config.setMaxActive(Integer.valueOf(bundle.getString("jedis.pool.maxActive")));    
 15         config.setMaxIdle(Integer.valueOf(bundle.getString("jedis.pool.maxIdle")));    
 16         config.setMaxWait(Long.valueOf(bundle.getString("jedis.pool.maxWait")));    
 17         config.setTestOnBorrow(Boolean.valueOf(bundle.getString("jedis.pool.testOnBorrow")));    
 18         config.setTestOnReturn(Boolean.valueOf(bundle.getString("jedis.pool.testOnReturn")));
 19         pool = new JedisPool(config, bundle.getString("redis.ip"),Integer.valueOf(bundle.getString("redis.port")) );
 20     }
 21     /**
 22      * 
 23      * @Title: release
 24      * @Description: 释放连接
 25      * @param @param jedis
 26      * @return void
 27      * @throws
 28      */
 29     public static void release(Jedis jedis){
 30         pool.returnResource(jedis);
 31     }
 32     public static Jedis getJedis(){
 33         return pool.getResource();
 34     }
 35 }
 36 Redis在线列表工具类:
 37 import java.util.ArrayList;
 38 import java.util.Collections;
 39 import java.util.Comparator;
 40 import java.util.Date;
 41 import java.util.List;
 42 import java.util.Set;
 43 import net.sf.json.JSONObject;
 44 import net.sf.json.JsonConfig;
 45 import net.sf.json.processors.JsonValueProcessor;
 46 import cn.sccl.common.util.StringUtil;
 47 import com.xxx.common.util.JsonDateValueProcessor;
 48 import com.xxx.user.model.ClientUser;
 49 import redis.clients.jedis.Jedis;
 50 import redis.clients.jedis.Pipeline;
 51 import tools.Constants;
 52 /**
 53  * 
 54  * Redis缓存中存放两组key:
 55  * 1.SID_PREFIX开头,存放登陆用户的SessionId与ClientUser的Json数据
 56  * 2.UID_PREFIX开头,存放登录用户的UID与SessionId对于的数据
 57  *
 58  * 3.VID_PREFIX开头,存放位于指定页面用户的数据(与Ajax一起使用,用于实现指定页面同时浏览人数的限制功能)
 59  * 
 60  * @ClassName: OnlineUtils
 61  * @Description: 在线列表操作工具类
 62  * @author BuilderQiu
 63  * @date 2014-1-9 上午09:25:43
 64  *
 65  */
 66 public class OnlineUtils {
 67     //KEY值根据SessionID生成    
 68     private static final String SID_PREFIX = "online:sid:";
 69     private static final String UID_PREFIX = "online:uid:";
 70     private static final String VID_PREFIX = "online:vid:";
 71     private static final int OVERDATETIME = 30 * 60;
 72     private static final int BROADCAST_OVERDATETIME = 70;//ax每60秒发起一次,超过BROADCAST_OVERDATETIME时间长度未发起表示已经离开该页面
 73     public static void login(String sid,ClientUser user){
 74         Jedis jedis = RedisPoolUtils.getJedis();
 75         jedis.setex(SID_PREFIX+sid, OVERDATETIME, userToString(user));
 76         jedis.setex(UID_PREFIX+user.getId(), OVERDATETIME, sid);
 77         RedisPoolUtils.release(jedis);
 78     }
 79     public static void broadcast(String uid,String identify){
 80         if(uid==null||"".equals(uid)) //异常数据,正常情况下登陆用户才会发起该请求
 81             return ;
 82         Jedis jedis = RedisPoolUtils.getJedis();
 83         jedis.setex(VID_PREFIX+identify+":"+uid, BROADCAST_OVERDATETIME, uid);
 84         RedisPoolUtils.release(jedis);
 85     }
 86     private static String userToString(ClientUser user){
 87         JsonConfig  config = new JsonConfig();
 88         JsonValueProcessor processor = new JsonDateValueProcessor("yyyy-MM-dd HH:mm:ss");
 89         config.registerJsonValueProcessor(Date.class, processor);
 90         JSONObject obj = JSONObject.fromObject(user, config);
 91         return obj.toString();
 92     }
 93     /**
 94      * 
 95      * @Title: logout
 96      * @Description: 退出
 97      * @param @param sessionId
 98      * @return void
 99      * @throws
100      */
101     public static void logout(String sid,String uid){
102         Jedis jedis = RedisPoolUtils.getJedis();
103         jedis.del(SID_PREFIX+sid);
104         jedis.del(UID_PREFIX+uid);
105         RedisPoolUtils.release(jedis);
106     }
107     /**
108      * 
109      * @Title: logout
110      * @Description: 退出
111      * @param @param UserId  使指定用户下线
112      * @return void
113      * @throws
114      */
115     public static void logout(String uid){
116         Jedis jedis = RedisPoolUtils.getJedis();
117         //删除sid
118         jedis.del(SID_PREFIX+jedis.get(UID_PREFIX+uid));
119         //删除uid
120         jedis.del(UID_PREFIX+uid);
121         RedisPoolUtils.release(jedis);
122     }
123     public static String getClientUserBySessionId(String sid){
124         Jedis jedis = RedisPoolUtils.getJedis();
125         String user = jedis.get(SID_PREFIX+sid);
126         RedisPoolUtils.release(jedis);
127         return user;
128     }
129     public static String getClientUserByUid(String uid){
130         Jedis jedis = RedisPoolUtils.getJedis();
131         String user = jedis.get(SID_PREFIX+jedis.get(UID_PREFIX+uid));
132         RedisPoolUtils.release(jedis);
133         return user;
134     }
135     /**
136      * 
137      * @Title: online
138      * @Description: 所有的key
139      * @return List  
140      * @throws
141      */
142     public static List online(){
143         Jedis jedis = RedisPoolUtils.getJedis();
144         Set online = jedis.keys(SID_PREFIX+"*");
145         RedisPoolUtils.release(jedis);
146         return new ArrayList(online);
147     }
148     /**
149      * 
150      * @Title: online
151      * @Description: 分页显示在线列表
152      * @return List  
153      * @throws
154      */
155     public static List onlineByPage(int page,int pageSize) throws Exception{
156         Jedis jedis = RedisPoolUtils.getJedis();
157         Set onlineSet = jedis.keys(SID_PREFIX+"*");
158         List onlines =new ArrayList(onlineSet);
159         if(onlines.size() == 0){
160             return null;
161         }
162         Pipeline pip = jedis.pipelined();
163         for(Object key:onlines){
164             pip.get(getKey(key));
165         }
166         List result = pip.syncAndReturnAll();
167         RedisPoolUtils.release(jedis);
168         List<ClientUser> listUser=new ArrayList<ClientUser>();
169         for(int i=0;i<result.size();i++){
170             listUser.add(Constants.json2ClientUser((String)result.get(i)));
171         }
172         Collections.sort(listUser,new Comparator<ClientUser>(){
173             public int compare(ClientUser o1, ClientUser o2) {
174                 return o2.getLastLoginTime().compareTo(o1.getLastLoginTime());
175             }
176         });
177         onlines=listUser;
178         int start = (page - 1) * pageSize;
179         int toIndex=(start+pageSize)>onlines.size()?onlines.size():start+pageSize;
180         List list = onlines.subList(start, toIndex);
181         return list;
182     }
183     private static String getKey(Object obj){
184         String temp = String.valueOf(obj);
185         String key[] = temp.split(":");
186         return SID_PREFIX+key[key.length-1];
187     }
188     /**
189      * 
190      * @Title: onlineCount
191      * @Description: 总在线人数
192      * @param @return
193      * @return int
194      * @throws
195      */
196     public static int onlineCount(){
197         Jedis jedis = RedisPoolUtils.getJedis();
198         Set online = jedis.keys(SID_PREFIX+"*");
199         RedisPoolUtils.release(jedis);
200         return online.size();
201     }
202     /**
203      * 获取指定页面在线人数总数
204      */
205     public static int broadcastCount(String identify) {
206         Jedis jedis = RedisPoolUtils.getJedis();
207         Set online = jedis.keys(VID_PREFIX+identify+":*");
208         RedisPoolUtils.release(jedis);
209         return online.size();
210     }
211     /**
212      * 自己是否在线
213      */
214     public static boolean broadcastIsOnline(String identify,String uid) {
215         Jedis jedis = RedisPoolUtils.getJedis();
216         String online = jedis.get(VID_PREFIX+identify+":"+uid);
217         RedisPoolUtils.release(jedis);
218         return !StringUtil.isBlank(online);//不为空就代表已经找到数据了,也就是上线了
219     }
220     /**
221      * 获取指定页面在线人数总数
222      */
223     public static int broadcastCount() {
224         Jedis jedis = RedisPoolUtils.getJedis();
225         Set online = jedis.keys(VID_PREFIX+"*");
226         RedisPoolUtils.release(jedis);
227         return online.size();
228     }
229     /**
230      * 
231      * @Title: isOnline
232      * @Description: 指定账号是否登陆
233      * @param @param sessionId
234      * @param @return
235      * @return boolean 
236      * @throws
237      */
238     public static boolean isOnline(String uid){
239         Jedis jedis = RedisPoolUtils.getJedis();
240         boolean isLogin = jedis.exists(UID_PREFIX+uid);
241         RedisPoolUtils.release(jedis);
242         return isLogin;
243     }
244     public static boolean isOnline(String uid,String sid){
245         Jedis jedis = RedisPoolUtils.getJedis();
246         String loginSid = jedis.get(UID_PREFIX+uid);
247         RedisPoolUtils.release(jedis);
248         return sid.equals(loginSid);
249     }
250 }

  由于在线状态是记录在Redis中的,并不单纯依靠Session的过期机制来实现,所以需要通过拦截器在每次发送请求的时候去更新Redis中相应的缓存过期时间来更新用户的在线状态。

登陆、退出操作与单机版相似,强制下线需要配合拦截器实现,当用户下次访问的时候,自己来校验自己的状态是否为已经下线,不再由服务器控制。

配合拦截器实现在线状态维持与强制登陆(使其他地方登陆了该账户的用户下线)功能:

1   ...
 2     if(uid != null){//已登录
 3         if(!OnlineUtils.isOnline(uid, session.getId())){
 4             session.invalidate();
 5             return ai.invoke();
 6         }else{
 7             OnlineUtils.login(session.getId(), (ClientUser)session.getAttribute("clientUser"));
 8             //刷新缓存
 9         }
10     }
11     ...

注:Redis在线列表工具类中的部分代码是后来需要实现限制同时访问指定页面浏览人数功能而添加的,同样基于Redis实现,前端由Ajax轮询来更新用户停留页面的状态。

附录:

Redis连接池配置文件:

###redis##config########
#redis服务器ip # 
#redis.ip=localhost
#redis服务器端口号#
redis.port=6379
###jedis##pool##config###
#jedis的最大分配对象#
jedis.pool.maxActive=1024
#jedis最大保存idel状态对象数 #
jedis.pool.maxIdle=200
#jedis池没有对象返回时,最大等待时间 #
jedis.pool.maxWait=1000
#jedis调用borrowObject方法时,是否进行有效检查#
jedis.pool.testOnBorrow=true
#jedis调用returnObject方法时,是否进行有效检查 #
jedis.pool.testOnReturn=true