老项目突然之间客户要用了而且用户量还不少,后端移动端都需要给升级。第一改进的时候做了移动端与后端的服务分流,这次升级为分布式集群模式。分布式集群模式需要解决Session共享问题和数据一致性分布式锁处理。因为历史原因,应用是单体应用并非微服务技术实现。为应对移动端大概20000左右的用户使用量做的如下改造。
目录
服务器端口分配管理
服务器作用与集群部署的节点说明(实际应用过程中并没有使用到这么多集群节点,规划的多一点备用)。
分布式集群session共享管理
Tomcat session共享设置
做分布式除了Token,一般需要解决Session共享问题。
- Tomcat的session共享Redis方案可以参考:https://github.com/mzd123/session_manager
- 国外开源项目: https://github.com/jcoleman/tomcat-redis-session-manager
- 推荐开源项目支持Tomcat789: https://github.com/ran-jit/tomcat-cluster-redis-session-manager
这一块都是基于tomcat的改造细节就不多说了,实验过程用的是mzd123的。配置说明:
1、修改Tomcat/config/redis-data-cache.properties =====解决redissession同步问题
2、检查Tomcat/lib是否存在commons-logging-1.2.jar、commons-pool2-2.4.2.jar、jedis-2.9.0.jar、tomcat-cluster-redis-session-manager-2.0.4.jar =====解决redissession同步问题
3、设置Tomcat/config/context.xml =====解决redissession同步问题
<!--redis管理session配置-->
<Valve className="tomcat.request.session.redis.SessionHandlerValve"/>
<Manager className="tomcat.request.session.redis.SessionManager"/>
节点可以看下Tomcat目录结构:
业务系统Session管理
首先添加一个Session监听监控应用系统Session创建和销毁等操作,下面的代码有其他业务凑合看吧:
SessionListener.java
package com.boonya.listener;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionAttributeListener;
import javax.servlet.http.HttpSessionBindingEvent;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionListener;
import org.apache.log4j.Logger;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.boonya.cache.XHTSystemConfig;
import com.boonya.webservice.util.RedisDistributedLock;
import com.boonya.webservice.util.RedisUtil;
import com.boonya.xht.util.Constants;
import data.common.util.StringUtils;
/**
*
* @function 功能:系统Session管理
* @author PJL
* @package com.boonya.listener
* @filename SessionListener.java
* @time 2019年12月19日 下午6:08:03
*/
public class SessionListener implements HttpSessionListener,HttpSessionAttributeListener {
private static Logger logger = Logger.getLogger(SessionListener.class);
/**
* 保存当前登录的所有用户
*/
public static Map<HttpSession, String> loginUser = new ConcurrentHashMap<HttpSession, String>();
/**
* 记录当前在线用户数量
*/
public static Long loginCount = 0L;
/**
* 用这个作为session中的key,userName 为手机号
*/
public static String SESSION_LOGIN_NAME = "userName";
/**
* session创建时监听
*/
@Override
public void sessionCreated(HttpSessionEvent arg0) {
//logger.info("sessionCreated");
}
/**
* session销毁时
*/
@Override
public void sessionDestroyed(HttpSessionEvent arg0) {
if (XHTSystemConfig.clusterModeForTomcat) {
try {
// 删除共享REDIS SESSION KEY
RedisUtil.expire(arg0.getSession().getId(), Constants.MOBILE_TOKEN_KEY_EXPIRED_NOW);
// 删除REDIS SESSION KEY
String key=Constants.CLUSTER_USER_SESSION_KEY+arg0.getSession().getId();
RedisUtil.expire(key, Constants.MOBILE_TOKEN_KEY_EXPIRED_NOW);
} catch (Exception e) {
e.printStackTrace();
}finally{
// 更新登录session数量
updateLoginUserCount();
}
} else {
try {
// 移除用户session
loginUser.remove(arg0.getSession());
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* 添加属性时
*/
@Override
public void attributeAdded(HttpSessionBindingEvent arg0) {
if (XHTSystemConfig.clusterModeForTomcat) {
// 如果添加的属性是用户名, 则加入map中
if (arg0.getName().equals(SESSION_LOGIN_NAME)) {
try {
// REDIS SESSION KEY
String key=Constants.CLUSTER_USER_SESSION_KEY+arg0.getSession().getId();
// 设置登录session
RedisUtil.hset(key, arg0.getSession().getId(), arg0.getValue().toString());
RedisUtil.expire(key, Constants.MOBILE_TOKEN_KEY_ONE_HOUR);
// 设置登录用户名
String userName=(String) arg0.getSession().getAttribute(SESSION_LOGIN_NAME);
String userNameKey=Constants.CLUSTER_USER_SESSION_USER+arg0.getSession().getId();
RedisUtil.hset(userNameKey, userName,userName);
RedisUtil.expire(userNameKey, Constants.MOBILE_TOKEN_KEY_ONE_HOUR);
// 更新登录session数量
updateLoginUserCount();
} catch (Exception e) {
e.printStackTrace();
}
}
} else {
// 如果添加的属性是用户名, 则加入map中
if (arg0.getName().equals(SESSION_LOGIN_NAME)) {
loginUser.put(arg0.getSession(), arg0.getValue().toString());
loginCount++;
}
}
}
/**
* 移除属性时
*/
@Override
public void attributeRemoved(HttpSessionBindingEvent arg0) {
if (XHTSystemConfig.clusterModeForTomcat) {
// 如果添加的属性是用户名, 则加入map中
if (arg0.getName().equals(SESSION_LOGIN_NAME)) {
try {
// REDIS SESSION KEY
String key=Constants.CLUSTER_USER_SESSION_KEY+arg0.getSession().getId();
// 设置登录session立即失效
RedisUtil.expire(key, Constants.MOBILE_TOKEN_KEY_EXPIRED_NOW);
// 设置登录用户名
String userNameKey=Constants.CLUSTER_USER_SESSION_USER+arg0.getSession().getId();
RedisUtil.expire(userNameKey, Constants.MOBILE_TOKEN_KEY_EXPIRED_NOW);
// 更新登录session数量
updateLoginUserCount();
} catch (Exception e) {
e.printStackTrace();
}
}
} else {
// 如果移除的属性是用户名, 则从map中移除
if (arg0.getName().equals(SESSION_LOGIN_NAME)) {
try {
loginUser.remove(arg0.getSession());
loginCount--;
} catch (Exception e) {
}
}
}
}
/**
* 属性更新时
*/
@Override
public void attributeReplaced(HttpSessionBindingEvent arg0) {
if (XHTSystemConfig.clusterModeForTomcat) {
if (arg0.getName().equals(SESSION_LOGIN_NAME)) {
try {
// REDIS SESSION KEY
String key=Constants.CLUSTER_USER_SESSION_KEY+arg0.getSession().getId();
// 设置登录session
RedisUtil.hset(key, arg0.getSession().getId(), arg0.getValue().toString());
RedisUtil.expire(key, Constants.MOBILE_TOKEN_KEY_ONE_HOUR);
// 设置登录用户名
String userName=(String) arg0.getSession().getAttribute(SESSION_LOGIN_NAME);
String userNameKey=Constants.CLUSTER_USER_SESSION_USER+arg0.getSession().getId();
RedisUtil.hset(userNameKey, userName,userName);
RedisUtil.expire(userNameKey, Constants.MOBILE_TOKEN_KEY_ONE_HOUR);
} catch (Exception e) {
e.printStackTrace();
}finally{
// 更换新登录用户数量
updateLoginUserCount();
}
}
} else {
if (arg0.getName().equals(SESSION_LOGIN_NAME)) {
loginUser.put(arg0.getSession(), arg0.getValue().toString());
}
}
}
/**
* 判断当前用户是否已经登录
*
* @param userId
* @return
*/
public static boolean isLogonUser(String userName) {
if (XHTSystemConfig.clusterModeForTomcat) {
HttpServletRequest request=((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String username=RedisUtil.hget(Constants.CLUSTER_USER_SESSION_USER+request.getSession().getId(), userName);
if(StringUtils.IsNullOrEmpty(username)){
return false;
}
return true;
} else {
Set<HttpSession> keys = SessionListener.loginUser.keySet();
for (HttpSession key : keys) {
if (SessionListener.loginUser.get(key).equals(userName)) {
return true;
}
}
return false;
}
}
/**
* 判断当前session是否已经登录
*
* @param hs
* @return
*/
public static boolean isLogonUser(HttpSession hs) {
if (XHTSystemConfig.clusterModeForTomcat) {
String sessionValue=RedisUtil.hget(Constants.CLUSTER_USER_SESSION_KEY+hs.getId(), hs.getId());
if(StringUtils.IsNullOrEmpty(sessionValue)){
return false;
}
return true;
} else {
Set<HttpSession> keys = SessionListener.loginUser.keySet();
for (HttpSession key : keys) {
if (key.equals(hs)) {
return true;
}
}
return false;
}
}
/**
* 获取当前登录用户的数量
*
* @return
*/
public static Long getLoginUserCount() {
if (XHTSystemConfig.clusterModeForTomcat) {
// 获取数量
String key=Constants.CLUSTER_USER_SESSION_KEY.substring(0, Constants.CLUSTER_USER_SESSION_KEY.length()-1);
String newKey=key+":*";
// 模糊查询
Set<String> keys=RedisUtil.keys(newKey);
Long userNumber=0L;
if(null!=keys){
userNumber=Long.valueOf( keys.size()+"");
}
return userNumber;
} else {
return loginCount;
}
}
/**
* 更新用户数量统计
*
*/
public static long updateLoginUserCount(){
final String requestId=UUID.randomUUID().toString();
boolean success=RedisDistributedLock.tryGetDistributedLock(
Constants.CLUSTER_APPLICATION_COUNT_LOCK, requestId,
Constants.CLUSTER_APPLICATION_COUNT_LOCK_TIME);
logger.error("更新USER COUNT分布式锁:"+success);
// 获取登录session数量
long countSession=getLoginUserCount();
while(!success){
try {
Thread.sleep(100);
logger.error("更新USER COUNT分布式锁:休眠1000ms!");
} catch (InterruptedException e) {
e.printStackTrace();
}
success=RedisDistributedLock.tryGetDistributedLock(
Constants.CLUSTER_APPLICATION_COUNT_LOCK, requestId,
Constants.CLUSTER_APPLICATION_COUNT_LOCK_TIME);
if(success){
countSession=getLoginUserCount();
break;
}
}
// 分布式不宜进行加减运算
RedisUtil.set(Constants.CLUSTER_USER_SESSION_COUNT, countSession+"");
RedisDistributedLock.releaseDistributedLock(
Constants.CLUSTER_APPLICATION_COUNT_LOCK, requestId);
logger.error("释放更新USER COUNT分布式锁成功!");
return countSession;
}
/**
* 清除已经登录用户的缓存
*
* @param userName
*/
@SuppressWarnings("rawtypes")
public static void removeSession(String userName) {
if (XHTSystemConfig.clusterModeForTomcat) {
try {
HttpServletRequest request=((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// REDIS SESSION KEY
String key=Constants.CLUSTER_USER_SESSION_KEY+request.getSession().getId();
// 设置登录session立即失效
RedisUtil.expire(key, Constants.MOBILE_TOKEN_KEY_EXPIRED_NOW);
// 更换新登录用户数量
updateLoginUserCount();
} catch (Exception e) {
e.printStackTrace();
}
} else {
Set<HttpSession> keys = SessionListener.loginUser.keySet();
// 如果使用老remove方法则要用iterator遍历。
Iterator iterator = keys.iterator();
while (iterator.hasNext()) {
Object key = iterator.next();
if (!StringUtils.IsNullOrEmpty(userName)
&& userName.equals(SessionListener.loginUser.get(key))) {
/** 这行代码是关键。否则报concurrentModificationException **/
/** 详情原因见:http://www.cnblogs.com/dolphin0520/p/3933551.html **/
iterator.remove();
loginUser.remove(key);
loginCount--;
}
}
}
}
}
注意:核心关注Session创建和销毁。
web.xml配置监听:
<listener>
<listener-class>com.boonya.listener.SessionListener</listener-class>
</listener>
Nginx集群配置管理
106前置机内网外服务器Nginx配置(服务和代理在同一台机器),之前有同事建议做每个集群的虚拟机部署,因为只是为了验收收钱不必要做的太复杂故而未予以采纳。
http {
include mime.types;
#default_type application/octet-stream;
default_type text/html;
charset utf-8;
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
#nginx服务器与被代理服务连接超时时间,代理超时
proxy_connect_timeout 300;
#nginx服务器发送数据给被代理服务器超时时间,单位秒,
#规定时间内nginx服务器没发送数据,则超时
proxy_send_timeout 300;
#nginx服务器接收被代理服务器数据超时时间,单位秒,
#规定时间内nginx服务器没收到数据,则超时
proxy_read_timeout 300;
proxy_buffer_size 64k;
proxy_buffers 8 128k;
proxy_busy_buffers_size 128k;
proxy_temp_file_write_size 128k;
# 客户端请求头设置
client_header_buffer_size 10m;
# 客户端请求体过大设置
client_max_body_size 128m;
#gzip on;
gzip on;
#BS WEB集群配置:服务器列表10
upstream WebCluster{
server localhost:9001 weight=1;
server localhost:9002 weight=1;
server localhost:9003 weight=1;
server localhost:9004 weight=1;
server localhost:9005 weight=1;
server localhost:9006 weight=1;
server localhost:9007 weight=1;
server localhost:9008 weight=1;
server localhost:9009 weight=1;
server localhost:9010 weight=1;
}
#APP集群配置:服务器列表3
upstream AppCluster{
server localhost:9021 weight=1;
server localhost:9022 weight=1;
server localhost:9023 weight=1;
}
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
# 限请求数配置
#limit_req_zone $binary_remote_addr zone=perip:20m rate=100r/s;
#limit_req_zone $server_name zone=perserver:20m rate=10000r/s;
#移动端服务代理转发配置IP地址请求适配
location ^~ /webService/ {
proxy_pass http://AppCluster/webService/;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-real-ip $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#limit_req zone=perip burst=1000;
#limit_req zone=perserver burst=100000;
}
# 集群统一文件访问路径
location ^~ /upload/ {
alias D:/application-images/upload/;
}
# 移动端APP下载代理
location /forestryapp/ {
alias D:/forestryapp/;
}
# 默认访问后台管理系统服务
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
#root html;
#index home.html index.html server.html;
proxy_pass http://WebCluster;
}
#.....
}
#.....
}
遇到的问题
1、连接池关闭问题:Jedis连接池未关闭导致Redis连接池、Tomcat线程资源耗尽,主要是引入了Redis分布式锁做用户Session数量的统计而这部分代码没有关闭jedis连接,实际上没必要加锁,因为可以直接读取节点获得数量,只是为了从redis里面直接看结果而做的辅助处理。
2、共享Session 移除问题:隐约觉得哪里没有做完就匆忙上线了,结果用户的组织机构无法过滤,因为共享session没有移除即使单个Tomcat注销了但是整个Redis管理的共享session并没有被移除掉,只需要在session销毁的地方移除掉共享Session的redis key。
3、集群节点卡壳:没有启动的节点Nginx配置策略没有完善导致轮询过程中卡顿。
回顾历史版本改造改进处理
1、第一次改造:移动端和后端分流。
2、第二次改造:MySQL数据库索引解决查询慢的问题(包括轨迹查询、统计分析等)。
3、第三次改造:从内存方式全面转向Redis读取。
4、第四次改造:Redis主从方式,从节点只读负责系统业务数据查看。
5、第五次改造:组织机构人员树与地图联动分离后地图聚合数据改造,分布式集群支持。
注:由于历史原因组织机构人员树加载要10几秒,上万节点后加上业务耦合到树上计算基本树就没法用了。这次树与地图联动分离页面加载提升到1秒以内,并且得到了客户的认可这是最可贵的,索引查询数据5万左右的可以做到毫秒级分页加载。