文章目录
为什么需要服务器集群Server Cluster?
随着qps的提高,单台服务器的处理能力会成为瓶颈,虽然可以购买更强大的硬件,但是总会有上限而且后期成本就成指数级增长了。于是可以横向扩展的服务器集群应运而生。
负载均衡
为什么需要负载均衡
服务器集群的产生,首先需要解决的问题是如何将不同的访问请求分给不同的服务器来处理,这就需要使用到负载均衡。
负载均衡算法
- 轮询 优点:实现简单 缺点:没有考虑每台服务器的处理能力
- 加权轮询:在轮询的基础上,按照配置的权重将请求分发到每个服务器 优点:考虑了不同服务器的处理能力
- 最少连接:记录每个服务器正在处理的连接数,将新到的请求分发到最少连接的服务器上,这是最符合负载均衡定义的算法。
- 加权最少连接 优点:在最少连接的基础上,为每台服务器加上权值。算法为**( 活动连接数 * 256 + 非活动连接数 ) / 权重**,计算出来的值小的服务器优先被选择。
- 源地址散列:根据请求来源的IP地址进行Hash计算,得到相应的服务器。 优点:能实现同一个用户访问同一个服务器
nginx服务默认端口为80
在/usr/local/nginx/conf/nginx.conf文件中的http节点下增加include vhost/*.conf
upstream www.cowboymeng.shop{
server 127.0.0.1:8080 weight=1;
server 127.0.0.1:9080 weight=1;
}
server {
listen 80;
autoindex on;
server_name cowboymeng.shop www.cowboymeng.shop;
#access_log /usr/local/Cellar/nginx/1.12.2_1/logs/access.log combined;
index index.html index.htm index.jsp index.php;
location / {
proxy_pass http://www.cowboymeng.shop;
add_header Access-Control-Allow-Origin *;
}
}
源地址散列的负载均衡配置方法:
upstream test {
ip_hash;
server 192.168.0.1;
server 192.168.0.2;
}
启动nginx
在/usr/local/nginx/conf/sbin
下执行sudo ./nginx -s reload
单机部署多个tomcat
- 首先在/etc/profile下添加如下环境变量,再执行source /etc/profile使配置文件生效
export CATALINA_BASE=/Users/choumeng/download\ software/tomcat1
export CATALINA_HOME=/Users/choumeng/download\ software/tomcat1
export TOMCAT_HOME=/Users/choumeng/download\ software/tomcat1
export CATALINA_2_BASE=/Users/choumeng/download\ software/tomcat2
export CATALINA_2_HOME=/Users/choumeng/download\ software/tomcat2
export TOMCAT_2_HOME=/Users/choumeng/download\ software/tomcat2
- 在tomcat2目录下的bin(存放可执行文件)目录下的
catalina.sh
(负责tomcat的启动和关闭)新增如下
export CATALINA_BASE = $CATALINA_2_BASE
export CATALINA_HOME = $CATALINA_2_HOME
- 打开tomcat2目录下的conf目录下的
server.xml
,修改三个端口
8005 —> 9005 server port
8080 —> 9080 connector port
8009 —> 9009 connector port
负载均衡方法
- HTTP重定向负载均衡:根据用户的请求计算一台真实的服务器地址,将该地址写入重定向响应中返回个用户,用户再根据该地址请求对应的服务器。优点:简单 缺点:需要两次请求才能完成一次访问,性能较差;重定向服务器自身处理能力有可能称为瓶颈;使用重定向响应,有可能使搜索引擎判断为SEO作弊,降低搜索排名。实际这种方法不多见。
- DNS域名解析负载均衡:利用DNS处理域名解析请求的同时进行负载均衡处理 优点:将负载均衡工作转交给DNS,省掉了维护负载均衡服务器的麻烦。缺点:当下线某台服务器,即使修改了DNS的A记录,要使其生效也需要较长时间,这段时间内,DNS依然会将域名解析到已经下线的服务器,导致用户访问失败;而且DNS的负载均衡控制权在域名服务商那里,无法对其作出改善。
- 反向代理负载均衡:反向代理服务器缓存资源,改善性能的同时,可提供负载均衡的功能。由于服务器不直接对外提供访问,因此服务器不需要使用外部IP地址,而反向代理服务器需要配置双网卡和内部外部两套IP地址。
- IP负载均衡:在网络层通过修改请求目标地址进行负载均衡,在内核进程完成数据分发。 优点:相比于反向代理负载均衡(在应用程序中完成数据分发)有更好的性能。 缺点:数据吞吐量受制于负载均衡服务器的网带宽。能不能让复杂均衡器只分发请求,而使响应数据从真实物理服务器直接返回给用户呢?于是就有了数据链路层负载均衡
- 数据链路层负载均衡(三角传输模式):配置所有真实物理服务器的虚拟IP和负载均衡服务器IP地址一致,负载均衡中不修改IP地址,只修改目的mac地址,避免了负载均衡服务器网卡带宽成为瓶颈。这是目前大型网站使用最广的一种负载均衡手段。
本项目采用的是单台Web服务器,多个Tomcat实例组成分布式Tomcat集群,使用Nginx轮询负载均衡将请求分给不同的Tomcat实例进行处理,每个Tomcat上部署的服务代码是相同的。
架构演进
一期架构
二期架构体验版:这种架构每个Session还都是每个Tomcat实例自己来维护的。这个架构图中的首先要解决Session共享的问题。
二期架构正式版:nginx使用的是轮询的负载均衡策略。session不交给tomcat自己管理,已经交由左侧的redis分布式集群来管理。
项目开发遇到的开发问题
Session共享问题
一人访问Atomcat,然后session在A上,然后突然负载均衡或者A坏了使他连接到B了,那需要用户重新登录显然是不正确的,所以需要解决session共享的问题。
一期验证用户登录
login流程:将用户对象信息保存在session中
ServerResponse<User> response = iUserService.login(username, password);
if (response.isSuccess()) {
session.setAttribute(Const.CURRENT_USER, response.getData());
}
return response;
logout流程:从session中移除用户信息即可
session.removeAttribute(Const.CURRENT_USER);
return ServerResponse.createBySuccess();
每次调用业务方法的时候都要执行用户是否登录的判断代码如下,造成很多重复代码。
User user = (User) session.getAttribute(Const.CURRENT_USER);
if (user == null) {
return ServerResponse.createByErrorCodeMessage(ResponseCode.NEED_LOGIN.getCode(), ResponseCode.NEED_LOGIN.getDesc());
}
二期改进:
整体流程:在登录的时候写Cookie,写Redis,使用的时候读Cookie,读Redis,登出的时候删除Cookie,删除Redis中的session信息。
首先为了实现通过Redis的客户端Jedis来操作Redis,需要从JedisPool池中取出一个Jedis实例,然后通过取出的Jedis实例操作Redis。
- JedisPool的配置:
首先设置好池子的配置JedisPoolConfig,然后new一个池子,最后从池子中取出Jedis实例。
- 封装一个RedisPoolUtil用来使用Jedis提供的的各种方法来操作Redis
// 调用jedis.get(key)
public static String get(String key)
// 调用jedis.set(key, value)
public static String set(String key, String value)
// 调用jedis.setex(key, exTime, value), 时间单位是秒
public static String setex(String key, int exTime, String value)
// 调用jedis.expire(key, exTime)
public static Long expire(String key, int exTime)
// 调用jedis.del(key)
public static Long del(String key)
get方法实现如下,其他方法类似。
然后我们就可以将用户对象信息通过这个工具类写到redis中,但是面临一个问题:一期中session存储的是用户对象信息,但是二期写入redis是不能将User对象写进去的,也就是说没有set(String key, User user)
这种方法,因为redis只支持5中数据结构类型。为了能使用工具类提供的set(String key, String value)
,我们就需要一个JsonUtil工具类来提供User对象和String的序列化和反序列方法。
- JsonUtil工具类实现序列化和反序列化
ObjectMapper配置,详细说明见注释。
封装2个序列化和3个反序列化方法:
序列化,使用writeValueAsString(Object value)
。第二个序列化排版了输出格式,前面要加上writeWithDefaultPrettyPrinter()
方法。
反序列化。第一个方法只能反序列化只含有一种类型的对象,第二种和第三种是反序列的通用方法,分别传入TypeReference<T> typeReference
和多个Class对象(可变长参数)Class<?> collectionClass
、Class<?>... elementClasses
,可以反序列化包含多种类型的对象。
接着改造用户登录
一期存储用户信息:Session存储“currentUser” —> User对象的映射
二期存储用户信息:
客户端Cookie中携带:”mmall_login_token" —> sessionId(为字符串)
redis分布式中存储:sessionId —> User对象的json字符串
服务端:将以前用户信息存入session的代码改成存储到redis中,key是JSESSIONID(session.getId()
返回类型为String),value是序列化后的User对象字符串,默认有效期30分钟
客户端:Cookie的key是"mmall_login_token",value是JSESSIONID(session.getId()
)
Cookie的一些方法
// 新建Cookie,key --> value
Cookie cookie = new Cookie(COOKIE_NAME, token);
// 设置该Cookie在哪个域名下
cookie.setDomain(COOKIE_DOMAIN);
// 代表设置在根目录
cookie.setPath("/");
cookie.setHttpOnly(true);
// 单位是秒。如果maxAge不设置的话,cookie就不会写入硬盘,而是写在内存。只在当前页面有效。
cookie.setMaxAge(60 * 60 * 24 * 365); // 如果是-1,代表永久
response.addCookie(cookie);
改造用户登出
// 先获取token,然后分别删除客户端和服务端的用户信息
String loginToken = CookieUtil.readLoginToken(request);
CookieUtil.delLoginToken(request, response);
RedisPoolUtil.del(loginToken);
return ServerResponse.createBySuccess();
改造其他方法中获取用户信息的那一块
String loginToken = CookieUtil.readLoginToken(request);
if (StringUtils.isEmpty(loginToken)) {
return ServerResponse.createByErrorMessage("用户未登录,无法获取当前用户的信息");
}
String userJson = RedisPoolUtil.get(loginToken);
User user = JsonUtil.string2Obj(userJson, User.class);
if (user != null) {
......
}
return ServerResponse.createByErrorMessage("用户未登录,无法获取当前用户的信息");
其中要封装CookieUtil工具类用于服务端向客户端种Cookie,删Cookie和读Cookie
private static COOKIE_NAME = "mmall_login_token";
privagte static COOKIE_DOMAIN = ".cowboymeng.shop";
public static void writeLoginToken(HttpServletResponse response, String token) {
Cookie cookie = new Cookie(COOKIE_NAME, token);
cookie.setDomain(COOKIE_DOMAIN);
cookie.setPath("/");
// -1代表永久,0代表删除
cookie.setMaxAge(60 * 60 * 24 * 365);
// 只支持http请求,为了安全性
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
public static void delLoginToken(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (StringUtils.equals(cookie.getName(), COOKIE_NAME)) {
cookie.setMaxAge(0);
response.addCookie(cookie);
return;
}
}
}
}
public static String readLoginToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (StringUtils.equals(cookie.getName(), COOKIE_NAME)) {
return cookie.getValue();
}
}
}
return null;
}
刷新过期时间的问题
问题:现在我们在服务端存储的用户信息是30分钟有效期的,我们不希望用户从一登录开始,浏览商品、购物、下单等整个流程只有30分钟的时间,所以对于每一个访问请求我们都要将用户信息的有效期重置30分钟
解决方案:过滤器Filter
- web.xml中配置Filter
- 写一个SessionExpireFilter过滤器,用来在请求之前重置Session有效期
实现Filter接口,重写init、doFilter、destroy方法,其中主要实现doFilter方法,其他两个方法空实现即可
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String loginToken = CookieUtil.readLoginToken(request);
if (!StringUtils.isEmpty(loginToken)) {
String userJson = RedisPoolUtil.get(loginToken);
User user = JsonUtil.string2Obj(userJson, User.class);
if (user != null) {
RedisPoolUtil.expire(loginToken, Const.RedisCacheExtime.REDIS_SESSION_EXTIME);
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
一期的忘记密码中提示问题的答案填对之后,会将一个forgetToken存储到Guava Cache(设置的有效期为12小时)中,即"token_" + username —> forgetToken(由UUID产生的串),然后在forgetResetPassword中将传递来的forgetToken和Guava Cache中token相比,如果相等才表示验证通过,可以重置密码。
而有了Tomcat集群之后,可能忘记密码提示问题答对由一个tomcat处理,填对之后将forgetToken存储到这个tomcat中的Guava Cache中,而在forgetResetPassword中请求打到另一个tomcat中,而这个tomcat的Guava Cache中没有forgerToken,所以即使用户填对了密码提示问题的答案,但是由于tomcat集群的关系,而导致不能重置密码。
所以有了tomcat集群之后,本地Guava Cache将变得不再适用,我们需要使用Redis来存储forgetToken,这样就达到了多个tomcat共享forgetToken了,解决了上面的问题。
权限拦截的问题
过滤器Filter
功能:对资源访问请求进行拦截
- web.xml中配置过滤器和要拦截的资源路径
<filter>
<filter-name>你的过滤器的名字</filter-name>
<filter-class>你编写的过滤器的所在路径</filter-class>
<init-param>
<param-name>参数1</param-name>
<param-value>参数值1</param-value>
</init-param>
<init-param>
<param-name>参数2</param-name>
<param-value>参数值2</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>与filter节点中的过滤器的名字保持相同</filter-name>
<url-pattern>要拦截的请求路径,URL匹配</url-pattern>
// 四种取值,不配置的话默认是REQUEST
// REQUEST 拦截直接请求,包含FORWARD和INCLUDE
// FORWARD 请求转发
// INCLUDE 拦截请求包含
// ERROR 拦截错误转发
<dispatcher>REQUEST</dispatcher>
</filter-mapping>
Filter的url-pattern三种匹配模式如下,注意:只要匹配成功,这些Filter都会被调用
(1)精确匹配 比如/detail.html
(2)路径匹配 以/
开始,以*
结束,比如/product/*
(3)后缀匹配 以 *.xxx
结束
Filter调用顺序:当配置有多个filter时,按照web.xml中<filter-mapping>
出现的先后顺序来调用相应的过滤器。
- 编写自己的过滤器:实现Filter接口,实现init、doFilter、destroy方法。Filter实例对象只会创建一次。
public class MyFilter implements Filter {
@Override
// init方法在web服务器启动时执行且只执行一次,用来初始化
// filterConfig对象封装了过滤器的初始化参数,FilterConfig接口有四个方法:
// getFilterName() 获取过滤器名称
// getInitParameter(String name) 获取参数name的参数值
// getInitParameterNames() 获取所有初始化参数名字
// getServletContext() 获取Servlet上下文对象
public void init(FilterConfig filterConfig) throw ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
......
// FilterChain的doFilter方法将请求继续往下一个Filter传递,如果没有下一个Filter,则请求目标资源
// 如果不执行FilterChain的doFilter方法,则请求不会再向下传递
filterChain.doFilter(servletRequest, servletResponse);
}
@Override
// destroy方法在web服务器停止时执行且只执行一次,释放资源等
public void destroy() {
}
}
如何设置监听器
监听器Listener
定义:其实就是一个实现特定接口的普通java程序,这个程序专门用于监听另一个java对象的方法调用或属性改变,当被监听对象发生上述事件后,监听器某个方法立即被执行
事件源:谁产生了事件
事件对象:产生了什么事件
监听器:监听事件源的动作
JavaWeb中的监听器
步骤:(1)实现监听器接口,重写方法
(2)web.xml中配置监听器
- 监听三大域对象的创建与销毁
ServletContextListener、HttpSessionListener、ServletRequestListener三个监听器
public MyListener implements ServletContextListener {
@Override
// ServletContextEvent含有getServletContext()方法
public void contextInitialized(ServletContextEvent sce) {
// 项目启动时调用
...
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
// 项目关闭时调用
...
}
}
public MyListener implements HttpSessionListener {
@Override
// HttpSessionEvent含有getSession()方法
public void sessionCreated(HttpSessionEvent hse) {
// 会话产生时调用
...
}
@Override
public void sessionDestroyed(HttpSessionEvent hse) {
// 会话关闭时调用
...
}
}
public MyListener implements ServletRequestListener {
@Override
// ServletRequestEvent含有getServletRequest()和getServletContext()方法
public void requestInitialized(ServletRequestEvent sre) {
// 请求产生时调用
...
}
@Override
public void requestDestroyed(ServletRequestEvent sre) {
// 请求关闭时调用
...
}
}
<listener>
<listener-class>你编写的监听器所在的路径</listener-class>
</listener>
- 监听三大域对象的属性添加、移除和替换
ServletContextAttributeListener、HttpSessionAttributeListener、ServletRequestAttributeListener三个监听器。
public MyListener implements ServletContextAttributeListener {
@Override
public void attributeAdded(ServletContextAttributeEvent scae) {
// 给ServletContext对象添加属性时调用
...
}
@Override
public void attributeRemoved(ServletContextAttributeEvent scae) {
// 给ServletContext对象删除属性时调用
...
}
@Override
public void attributeReplaced(ServletContextAttributeEvent scae) {
// 给ServletContext对象替换属性值时调用
...
}
}
public MyListener implements HttpSessionAttributeListener {
@Override
public void attributeAdded(HttpSessionBindingEvent hsbe) {
// 给HttpSession对象添加属性时调用
...
}
@Override
public void attributeRemoved(HttpSessionBindingEvent hsbe) {
// 给HttpSession对象删除属性时调用
...
}
@Override
public void attributeReplaced(HttpSessionBindingEvent hsbe) {
// 给HttpSession对象替换属性值时调用
...
}
}
public MyListener implements ServletRequestAttributeListener {
@Override
public void attributeAdded(ServletRequestAttributeEvent srae) {
// 给ServletRequest对象添加属性时调用
...
}
@Override
public void attributeRemoved(ServletRequestAttributeEvent srae) {
// 给ServletRequest对象删除属性时调用
...
}
@Override
public void attributeReplaced(ServletRequestAttributeEvent srae) {
// 给ServletRequest对象替换属性值时调用
...
}
}
// ServletContextAttributeEvent、HttpSessionBindingEvent、ServletRequestAttributeEvent
// 1. 分别继承自ServletContextEvent、HttpSessionEvent、ServletRequestEvent
// 2. 都具有String getName()和Object getValue()方法分别用来获取添加、删除、替换的属性名和属性值
web.xml中配置略
- 监听Session绑定JavaBean对象操作
HttpSessionBindingListener:实现了HttpSessionBindingListener接口的JavaBean对象可以感知自己被绑定到Session中和 Session中删除的事件。
HttpSessionActivationListener:实现了HttpSessionActivationListener接口且绑定到Session中的JavaBean对象可以感知自己被活化(反序列化)和钝化(序列化)的事件。JavaBean对象必须是序列化的(实现了Serializable接口)
两个监听器都由JavaBean对象来实现,不需要在web.xml中配置
public MyJavaBean implements HttpSessionBindingListener {
// 成员变量
@Override
public void valueBound(HttpSessionBindingEvent hsbe) {
...
}
@Override
public void valueUnbound(HttpSessionBindingEvent hsbe) {
...
}
// 其他成员方法
}
// 该对象必须绑定到Session中,并且必须实现Serializable接口
public MyJavaBean implements HttpSessionActivationListener, Serializable {
// 成员变量
@Override
public void sessionWillPassivate(HttpSessionEvent hse) {
...
}
@Override
public void sessionDidActivate(HttpSessionEvent hse) {
...
}
// 其他成员方法
}
HttpServletRequest中的关键方法:
getSession()
getServletContext()(实际调用的是GenericServlet类中的getServletContext()方法)
geContextPath()(获取web项目的根目录,比如/或者/servlet,在配置tomcat的时候所指定的那个根目录)
redis分布式算法
上面我们实现了将用户信息的存储位置从Session中迁移到了Redis中,但是我们只是存储到了一台Redis上。实际当存储的数据量很多时,一台Redis服务器肯定是不够用的,所以接下来我们就要将Redis做成分布式,组成多个redis集群来存储用户的Session信息。
传统分布式算法:先hash,再取模(模数为redis服务器的台数)。这种算法当服务器增加或者减少时,缓存命中率显著降低,比如hash之后的1~20的hash值映射从4台服务器增加到5台服务器时,缓存命中率只有4/20=20%。这样很多数据不得不再次从数据库中读取,增加了数据库的压力。
一致性Hash算法:
- 构造一个长度为232的整数环,值的分布范围是0~232-1
- 根据服务器节点的hash值将缓存服务器节点放置在hash环上
- 根据要缓存数据的key值计算得到的hash值,在hash环上顺时针查找距离这个hash值最近的服务器节点,将数据存储到这个服务器上。
当增加或者移除节点时,都只影响从新服务器节点在环上的位置 / 待移除服务器节点在环上的位置 开始,逆时针到最近一个服务器节点这一段区域的数据的存储
理想与现实总是有差距的,现实中缓存服务器节点在环上的分布并不总是均匀的,比如如下图所示,3个服务器节点在环上分布不均匀,这就导致了大量的数据都存储在A服务器上,导致各个服务器负载不均匀,出现了hash倾斜。
针对现实中hash倾斜性,提出的解决方案是为每个服务器节点都增设虚拟节点。
每个物理节点的虚拟节点越多,各个物理节点之间的负载越均衡,新加入物理服务器对原有的物理服务器的影响越保持一致,这就是一致性hash这个名称的由来。一台服务器配置的虚拟节点个数太多会影响性能,太少又会导致负载不均衡,一般来说,经验值是150。
命中率计算公式:1 - n / (n + m),其中n是服务器台数,m为新增服务器台数
虚拟节点个数默认为160,源码中为160 * weight,可以通过设置该权重来配置虚拟节点的个数。
底层采用TreeMap来存储虚拟节点,key进行hash后,在TreeMap中找到大于等于该key的虚拟节点,然后虚拟节点映射到真实的物理节点上,将key存储到该真实的物理节点上。
底层采用LinkedHashMap来存储真实的物理节点。
可以采用哨兵机制来监测主redis服务器的运行状态,当redis服务器挂掉的时候,可以从redis中把备份的数据拿回来,可以使得从redis服务器来暂时顶替主redis服务器。哨兵的作用主要是监控master节点的状态,当master节点挂掉时通过选举机制选出一个slave节点成为一个新的master
将单个Redis服务器改成Redsi分布式(两个Redis)
配置方法:复制一份redis文件,将文件中的redis.conf中的端口改为6380,然后以配置文件的形式分别启动端口为6379和6380的redis。
// redis服务端启动
// 按照默认的6379端口启动
redis-server
// 配置文件方式启动
redis-server ../redis.conf
// 指定端口方式启动
redis-server --port 6379
// redis客户端启动
redis-cli
redis-cli -p 6379
redis-cli -h 127.0.0.1
redis-cli -a 你的密码
redis-cli -p 6379 -h 127.0.0.1 -a 你的密码
// redis客户端关闭
redis-cli shutdown
redis-cli -p 6379 shutdown
redis-cli -h 127.0.0.1 shutdown
redis-cli -p 6379 -h 127.0.0.1 shutdown
RedisPool
—> RedisShardedPool
变动部分:将一个redisIp、redisPort增加到两个redisIp、redisPort
pool的创建如下:
JedisShardInfo info1 = new JedisShardInfo(redis1Ip, redis1Port, 1000 * 2);
JedisShardInfo info2 = new JedisShardInfo(redis2Ip, redis2Port, 1000 * 2);
List<JedisShardInfo> jedisShardInfoList = new ArrayList<>();
jedisShardInfoList.add(info1);
jedisShardInfoList.add(info2);
// Hashing.MURMUR_HASH: 默认的分片策略,是一致性hash算法
// Sharded.DEFALULT_KEY_TAG_PATTERN: 匹配模式
pool = new ShardedJedisPool(config, jedisShardInfoList, Hashing.MURMUR_HASH, Sharded.DEFALULT_KEY_TAG_PATTERN);
// 接下来getRedis()方法返回的就不是Jedis了,而是返回ShardedJedis
public static ShardedJedis getJedis {
return pool.getResource();
}
ShardedJedis继承自BinaryShardedJedis,BinaryShardedJedis继承自Sharded,Sharded类中虚拟节点的个数是160 * DEFAULT_WEIGHT,DEFAULT_WEIGHT默认为1,所以默认配置的虚拟节点个数为160个。
RedisPoolUtil
— > RedisShardedPoolUtil
将从RedisPool中获取Jedis(RedisPool.getJedis())改为从RedisShardedPool中获取ShardedJedis(RedisShardedPool.getJedis())
Sharded类维护了
TreeMap:基于红黑树实现,用来存放经过一致性哈希计算后的redis节点
LinkedHashMap:用来保存ShardInfo与Jedis实例的对应关系
定位的流程如下:先在TreeMap中找到对应key所对应的ShardInfo,然后通过ShardInfo在LinkedHashMap中找到对应的Jedis实例。当然ShardInfo是一个抽象类,实际的实现类是JedisShardInfo
private TreeMap<Long, S> nodes;
private final Map<ShardInfo<R>, R> resources;
在Sharded类的源码中初始化过程如下。可以看到,它对每一个ShardInfo通过一定规则计算其哈希值,然后存到TreeMap中,这里它实现了一致性哈希算法中虚拟节点的概念,因为我们可以看到同一个ShardInfo不止一次被放到TreeMap中,权重*160。
private void initialize(List<S> shards) {
this.nodes = new TreeMap();
for(int i = 0; i != shards.size(); ++i) {
S shardInfo = (ShardInfo)shards.get(i);
int n;
if (shardInfo.getName() == null) {
for(n = 0; n < 160 * shardInfo.getWeight(); ++n) {
this.nodes.put(this.algo.hash("SHARD-" + i + "-NODE-" + n), shardInfo);
}
} else {
for(n = 0; n < 160 * shardInfo.getWeight(); ++n) {
this.nodes.put(this.algo.hash(shardInfo.getName() + "*" + shardInfo.getWeight() + n), shardInfo);
}
}
this.resources.put(shardInfo, shardInfo.createResource());
}
}
最后将代码中用RedisPoolUtil存储用户Session信息的代码全部改为RedisShardedPoolUtil存储用户Session信息,即将代码中的RedisPoolUtil改为RedisShardedPoolUtil即可,两者的调用方法完全相同。
集群是一种物理形态,分布式是一种工作方式。
我们自己实现的无论是单个redis的单点登录还是redis分布式的单点登录,对业务是有入侵的。(入侵性小就是说原来代码改的少,例如spring session直接在原生的session上做包装。我们可以不用动之前的登录相关session的各种代码。)因此,引入了Spring Session来实现不入侵业务的单点登录的方式。
Spring Session提供了一套创建和管理HttpSession的方案,提供了集群Session(Clustered Session)功能,默认采用外置的Redis来存储Session数据,以此来解决Session共享的问题。
Spring Session框架中,只有JedisShardInfo成员变量而没有List这种JedisShardInfo集合,也就是说Spring Session框架不支持Redis分布式,只能支持单个Redis。
小知识点:redis可以通过冒号(:)的方式来管理redis的命名空间,比如执行set product:cellphone iPhone,在Redis Desktop Manager中查看可以看到,实际上在db0中(如果没有指定数据库的话,使用db0),创建了product目录,该文件夹下面才存储这真正的key为product:cellphone,也就是说,key值可以通过:进行分层管理。
Spring MVC全局异常处理
注意这里的包装的“异常”实际上是一个对异常处理之后的ModelAndView对象!
为了不在浏览器请求错误时,服务器端的异常信息直接暴露在客户端浏览器上,避免服务器关键代码和机密信息的对外泄露,有必要在DispatcherServlet层对服务器端的异常信息进行包装和处理再返回给浏览器。课程中由于封装了一个针对客户端的高复用响应对象ServerResponse,所以ModelAndView里面的信息按照ServerResponse里面定义的字段信息来设置和返回。
全局异常实现原理:
写一个类ExceptionResolver,实现HandlerExceptionResolver
接口,重写resolveException方法
@Slf4j
// 一定要注意:记得将这个异常处理类注入到spring容器中,否则不会起作用
@Component
public class ExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object o, Exception e) {
// 在服务端打印详细异常日志
log.error("{} exception", request.getRequestURI(), e);
// 当使用的是jackson2.x的时候使用MappingJackson2JsonView,课程中使用的是1.9。
ModelAndView modelAndView = new ModelAndView(new MappingJacksonJsonView());
modelAndView.addObject("status", ResponseCode.ERROR.getCode());
modelAndView.addObject("msg", "接口异常,详情请查看服务端日志的异常信息");
// e.toString()只会显示异常的简单信息
modelAndView.addObject("data", e.toString());
return modelAndView;
}
}
SpringMVC拦截器实现权限统一校验
拦截器流程:
自己写一个AuthorityInterceptor,实现HandlerInterceptor
接口,重写接口中的preHandle、postHandle、afterCompletion方法
// 这三个方法的执行顺序:preHandle(在调用controller层方法前执行) --> postHandle(在调用controller层方法后执行) --> afterCompletion(在所有处理完成之后被调用,该方法将在整个请求结束之后,也就是在DispatcherServlet 渲染了对应的视图之后执行。用于进行资源清理。)
@Slf4j
public class AuthorityInterceptor implements HandlerInterceptor {
@Override
// 3个方法中只有该方法有返回值boolean,默认返回false,false代表不将请求向下转发,直接中止,true
// 表示请求继续向下转发
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) {
log.info("preHandle");
// 方法参数中Object o实际是HandlerMethod,通过该对象可以获取所调用的方法名和该方法所属的类
HandlerMethod handlerMethod = (HandlerMethod) o;
String methodName = handlerMethod.getMethod().getName();
String className = handlerMethod.getBean().getClass().getSimpleName();
// 解决管理员登录死循环的解决方案二
if (StringUtils.equals(className, "UserManageController") && StringUtils.equals(methodName, "login")) {
return true;
}
User user = null;
String loginToken = CookieUtil.readLoginToken(request);
if (StringUtils.isNotEmpty(loginToken)) {
String userJson = RedisShardedPoolUtil.get(loginToken);
user = JsonUtil.string2Obj(userJson, User.class);
}
// 上传由于富文本的控件要求,要特殊处理返回值,这里面区分是否登录以及是否有权限
if (user == null || user.getRole() != Const.Role.ROLE_ADMIN) {
// 这里要reset,否则会报getWritter() has already been called
response.reset();
//不设置会乱码
response.setCharacterEncoding("UTF-8");
// 这里要设置返回值的类型,因为全部是json接口
resposnse.setContentType("application/json,charset=UTF-8");
PrintWriter out = response.getWriter();
if (user == null) {
if (StringUtils.equals(className, "ProductManageController") && StringUtils.equals(methodName, "richtextImgUpload")) {
Map resultMap = Maps.newHashMap();
resultMap.put("success", false);
resultMap.put("msg", "请登录管理员");
out.print(JsonUtil.obj2String(resultMap));
} else {
out.print(JsonUtil.obj2String(ServerResponse.createByErrorMessage("拦截器拦截,用户未 登录")));
}
} else {
if (StringUtils.equals(className, "ProductManageController") && StringUtils.equals(methodName, "richtextImgUpload")) {
Map resultMap = Maps.newHashMap();
resultMap.put("success", false);
resultMap.put("msg", "无权限操作");
out.print(JsonUtil.obj2String(resultMap));
} else {
out.print(JsonUtil.obj2String(ServerResponse.createByErrorMessage("拦截器拦截,用户无 权限操作")));
}
}
out.flush();
out.close();
return false;
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object o, ModelAndView modelAndView) {
log.info("postHandle");
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse resposne,
Object o, Exception e) {
log.info("afterCompletion");
}
}
然后再SpringMVC配置文件:dispatcher-servlet.xml中配置拦截器:
<mvc:interceptors>
<!-- 配置在这里的bean,所有的都会拦截 -->
<mvc:interceptor>
<mvc:mapping path="/manage/**"></mvc:mapping>
// 解决管理员登录死循环的解决方案一
<mvc:exclude-mapping path="/manage/user/login.do" ></mvc:exclude-mapping>
<bean class="你写的拦截器类的所在路径"></bean>
</mvc:interceptor>
</mvc:interceptors>
<mvc:mapping path=" " />
/** 匹配所有路径,包含里面的子路径
/* 当前路径下的所有路径,不含子路径
/ web项目的根路径
其中,有两点需要注意。
- 当第一次请求管理员登录时,redis中没有存用户Session信息,拦截器直接拦截下来,controller层中的login方法执行不了,也就是不能将管理员Session信息写入redis和cookie中,之后再请求管理员登录永远返回的都是用户未登录,死循环。为了解决这个死循环,对于管理员登录的请求,拦截器不应该拦截,解决方法有2种。一是在拦截器的配置文件中,拦截器拦截路径中除去请求管理员登录的路径。二是在自定义拦截器实现类的preHandle方法中添加匹配,如果匹配到请求管理员登录的方法,则直接返回true,将请求直接向下传递。
- ProductManageController中的富文本上传方法richtextImgUpload的返回值要求是Map,因为使用了simditor,所以按照simditor的要求进行返回。在用户未登录或者登录用户不是管理员的时候,返回值要区分是ServerRespose还是Map。
RESTful风格接口改造
- 将方法中的参数作为占位符写入@RequestMapping注解的value属性中
- 将方法的参数前(准确的说是参数类型前)加上@PathVariable注解
注意事项:restful风格的url匹配不是按参数类型来匹配的,而是按照url形式规则来匹配的。比如两个RESTful接口:/{keyword}/{pageNum}/{pageSize}/{orderBy}和/{categoryId}/{pageNum}/{pageSize}/{orderBy},http://www.cowboymeng.shop/product/100012/1/10/price_asc
则无法匹配这两个接口中的一个,即存在模糊匹配的问题。对于模糊匹配,一般采用将RESTful接口中增加标识符,比如上面两个接口重新改造为:/keyword/{keyword}/{pageNum}/{pageSize}/{orderBy}和/category/{categoryId}/{pageNum}/{pageSize}/{orderBy}
遵循上面的步骤和注意事项可以将Controller层的所有类的方法都改造为RESTful风格的接口
引入定时任务步骤
- 在spring配置文件applicationContext中增加注解:
<!-- 引入定时任务 -->
<task:annotation-driven />
Spring Schedule Cron表达式
格式:秒 分 时 日 月 周 年(可选)
- 写一个CloseOrderTask类来实现定时关单任务,以当前时间为基准关闭hour个小时以前的未付款的订单,在对应的方法上加上@Schedule注解。注意:被注解的方法不能有任何返回值(也就是必须为void)和方法参数
关闭订单方法V1版本(一个定时任务)
// 每1分钟执行一次该方法,即每1分钟执行一次定时任务
@Schedule(cron="0 */1 * * * ?")
public void closeOrderTaskV1(int hour) {
// 从mmall.properties文件中读取时间(关闭2小时前的未付款的订单)
int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour", "2"));
iOrderService.closeOrder(hour);
}
其中,在closeOrder(hour)方法中,我们先取出所有在hour小时前的未付款的订单,然后遍历每个订单,对于每个订单中的每件商品,先查出该商品的库存数量,这一个查询使用了MySQL悲观锁机制,select stock from mmall_product where id = #{id} for update
。然后恢复库存。
SELECT … FOR UPATE实现了悲观锁,要使用InnoDB引擎,是“先取锁再访问”的保守策略。悲观锁是读取的时候为后面的更新加锁,之后再来的读操作都会等待,是数据库锁
行锁和表锁:
根据主键/索引查询,并且查询到数据,主键字段产生行锁
根据主键/索引查询,没有查询到数据,不产生锁
根据主键/索引的模糊查询(比如!=或者like)) / 非索引查询,产生表锁
关于为什么查询商品库存这一sql要使用悲观锁的解释:(来自Geely老师)
这个是做并发处理常见的情况,如果不加锁,我们在回填库存的时候,假设现在拿到的是1件,在订单上,库存里是100件。这个时候先保证库存应该回归到101件。在并发的情况下,如果不上锁,内存里拿到了原来是100,加上1 是101。如果这个时候那个库存被下了10件。那么在这个节点之后,还会执行更新库存,那么库存就又变成了101件,但是已经卖出10件了哟。
总结:保证商品库存恢复的过程防止此时库存数量的修改
缺点:该方法只适用于单个tomcat服务器,而本项目是tomcat分布式,当有多个tomcat服务器时,每个tomcat服务器都会执行定时任务,浪费了服务器的资源,而我们只需要1个tomcat服务器执行定时任务即可。这就涉及到分布式锁了。
关闭订单方法V2版本
思想:构建一个lockName —> currentTime + timeout的锁,每次执行关单方法时,先设置这个锁到分布式redis中,如果设置成功,说明成功获取到锁,就设置锁的有效期(设置锁的有效期的目的在于防止死锁。如果锁不设置有效期,当一个线程设置锁成功后,执行的时间很长,锁一直没释放,其他线程就只能等待),执行关单,然后删除(释放)锁;如果设置失败,证明锁已经被其他进程获取,此时走其他路线。
public void closeOrderTaskV2() {
long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout", 5000));
Long setnxResult = RedisShardedPoolUtil.setnx(Const.RedisLock.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));
if (setnxResult != null && setnxResult.intValue() == 1) {
// 成功获取到锁
closeOrder(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
} else {
log.info("没有获取到分布式锁:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
}
}
private void closeOrder(String lockName) {
// 设置锁的有效期
RedisShardedPoolUtil.expire(lockName, 5);
// 执行关单
int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour", "2"));
iOrderService.closeOrder(hour);
// 删除锁
RedisShardedPoolUtil.del(lockName);
}
@PreDestroy
public void delLock() {
RedisShardedPoolUtil.del(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
}
缺点:当设置锁成功后,此时tomcat关闭重启了,那么这个锁还没有来得及设置有效期就中断了,那么这个锁的有效期就是永久了,当重启之后,执行定时关单每次都会导致获取锁失败,导致bug。针对tomcat平滑关闭的情况,解决办法是在tomcat关闭前执行自己写的一个delLock()方法将锁释放,这个方法要加上@PreDestroy表示服务器在销毁前执行此方法。但是总的来说,这个版本还是有两点不足:第一是当@PreDestroy声明的方法要删除很多锁,执行时间长,导致tomcat服务器关闭时间长。第二是tomcat粗暴关闭时,不会执行@PreDestroy声明的delLock方法,问题仍然存在。
关闭tomcat的两种方法:
- 平滑关闭:shutdown
- 粗暴关闭:直接kill掉tomcat进程
关闭订单方法V3版本
前面V2版本没有用到锁的value值,也就是String.valueOf(System.currentTimeMillis() + lockTimeout)。
在V2版本的基础上,在else代码块中再次试图获取锁
String lockValueStr = RedisShardedPoolUtil.get(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
if (lockValueStr != null && System.currentTimeMillis() > Long.parseLong(lockValueStr)) {
String getsetResult = RedisShardedPoolUtil.getset(Const.RedisLock.CLOSE_ORDER_TASK_LOCK, System.currentTimeMillis() + lockTimeout);
if (getsetResult == null || StringUtils.equals(lockValuestr, getsetResult)) {
// 真正获取到锁
closeOrder(Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
} else {
log.info("获取分布式锁失败:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
}
} else {
log.info("获取分布式锁失败:{}", Const.RedisLock.CLOSE_ORDER_TASK_LOCK);
}
expire设置的时间就是根据业务的完成时间去设置的,就是说一定会大于关闭所有订单所用的时间
redis主从配置,从redis只能读不能写。
在redis.conf
配置文件中,释放# slaveof <masterip> <masterport>
这一栏,写上master的ip和端口
从redis用来做数据备份,当主redis挂掉的时候可以从从redis中恢复数据。
当数据库被高并发访问的情况
经常访问的页面比如首页不应该访问数据库,经常访问的页面可以从缓存服务器中读取。
订单模块中,从下单到创建订单的整套流程:
商品详情页中,选择商品数量,点击加入购物车按钮,然后去购物车中查看,在购物中,可以选中要结算的商品,并且还可以修改商品的数量,总价一栏会实时的显示要支付的总额,然后点击去结算按钮,进入了订单确认页,在订单确认页中,可以新增收货地址或者选择已经保存过的默认的收货地址,然后点击提交订单按钮,就生成了一个订单号,和一个二维码,需要扫码支付。付款成功后,可以查看订单列表,看看自己下过哪些订单。每一个订单有订单号、创建时间、收件人、订单状态、订单总价和具体的购买的商品描述、单价、数量、单个商品总价等。
组装订单明细的时候,需要判断每个商品下单的数量是否小于等于数据库中的该商品的库存,如果大于则提示相应商品库存不足。
Order
:订单对象
OrderItem
:订单子项,包含某一商品的单价、购买数量、该商品购买总价、购买的用户、所属的订单号等
包装对象:
OrderVo
:在Order对象的基础上取我们需要的字段,然后加入订单的明细private List<OrderItemVo> orderItemVoList
这个商品明细字段,加入private ShippingVo shippingVo
这个收货地址字段,将Date类型的日期字段转化为String类型的日期字段
OrderItemVo
:在OrderItem对象的基础上取我们需要的字段,将Date类型的日期字段转化为String类型的日期字段
生成订单号的逻辑:
// 考虑到高并发情况,可能同一时间多个用户下单,所以订单号不能简单采用当前系统时间来作为订单号,而是应该添加一个随机数
private long generateOrderNo() {
long currentTime = System.currentTimeMillis();
return currentTime + new Random().nextInt(100);
}
@RequestParam
注解可以设置前端传来的参数名称是什么,如果该参数没传该参数的默认值是什么以及控制是否前端必须传递此参数。如下面所示:
@RequestParam(value="pageNum", defaultValue="1" required="false") int pageNum
如何找到哪段代码造成cpu load特别大
- 首先排查哪些进程cpu占用率高。 通过命令 ps ux
- 查看对应Java进程的每个线程的CPU占用率,找出该进程内最耗费CPU的线程,通过命令:ps -Lfp pid或者top -Hp pid得到最耗费cpu的线程id,然后使用printf “%x\n” 线程id得到该线程id的十六进制值
- 追踪线程内部,查看load过高原因。通过命令:jstack pid | grep 最耗费cpu的线程id的十六进制值
- 通过jstack查看代码运行轨迹,结合已有源码,一般可以分析出死循环的地方。
cpu load的飙升,一方面可能和full gc的次数增大有关,一方面可能和死循环有关系。
文本搜索一个单词
Apache Lucene
搜索就是用户输入关键字,从索引(index)中进行搜索的过程。根据关键字搜索索引,根据索引找到对应的文档,从而找到要搜索的内容(这里指磁盘上的文件)。
传统方法是根据文件找到该文件的内容,在文件内容中匹配搜索关键字,这种方法是顺序扫描方法,数据量大、搜索慢。
倒排索引结构是根据内容(词语)找文档,如下图:
用户登录模块中,注册过程只校验用户名和邮箱是否已经在数据库中有了。存储redis服务器中的User对象,其中的密码字段已经被设置为""。
登录:login
登出:logout
注册:register
获取用户信息:get_information
忘记密码获取密码提示问题:forgetGetQuestion
忘记密码核对答案:forgetCheckAnswer
忘记密码重置密码:forgetResetPassword
要校验前端传过来的token和后端缓存中的token是不是相等,相等才能重置密码。在进行答案校验的时候, 即在checkAnswer方法中,如果答案正确,那么就针对这个用户,把一个token存入缓存中。当该用户想改密码时,将传入的token跟缓存中的token进行比较,如果一致,则认为是该用户在操作。
token要过期时间的原因是,这次修改需要保证在一段时间内有效,过期就无效了。
因为token也可以被拦截~~再进一步的做法是使用token修改完之后,把token置成失效。
例如不加token,那么通过修改密码的接口 就可以随便改其他username的密码了。
那么加token,加了有效时间,起码在一段时间内,我保证自己的修改是有效且防止其他无效。
这在互联网上修改密码是一个很常用的做法,例如,忘记密码修改邮件里面给的链接,都会有一个提示,告诉你,这个修改密码的链接在10个小时之内有效,过期请重新获取该链接~
redis和memcached的区别
- redis与memcached相比,比仅支持简单的key-value数据类型,同时还提供list,set,zset,hash等数据结构的存储
- redis支持数据的备份,即master-slave模式的数据备份
- redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用等等
定时关单新的实现方式
在添加数据时,将ID和过期时间放到redis里,用那个能排序的结构sortSet,或者类似的能记录时间的中间件,做好排序。然后起个后台任务或者新起个项目,专门是扫描这个redis的第一条数据,也就是最快要过期的,这样只需要查询一条就行了,只要第一条不过期,那后面的就不用看了,也就不需要去操作数据库。倘若第一条过期了,就做相应的处理,然后移除掉,再去扫第二条,依次类推。这样查询就很少,也不需要查表。所以可以把扫描间隔设的很短,来达到强实时性。