秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter )-01
秒杀/高并发方案-介绍
@
秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter )-01
- Github:China-Rainbow-sea/seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )
- Gitee:seckill: 秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis + RabbitMQ +MyBatis-Plus + Maven + Linux + Jmeter )
- 秒杀/ 高并发,其实主要解决两个问题:一个是并发读,一个是并发写。
- 并发读的核心优化理念就是尽量减少用户到 DB 来 “读” 数据,或者让他们读更少的数据,并发写的处理原则也是一样的。
- 针对秒杀系统需要做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
- 系统架构降要满足高可用:流量符合预期时要稳定,要保证秒杀活动顺利完成,即秒杀商品顺利被卖出去,这个是最基本的前提。
- 系统保证数据的一致性:就是秒杀 10 个商品,那就只能成交 10 个商品,多一个少一个都不行。一旦库存不对,就要承担损失。
- 系统要满足高性能:也就是系统的性能要足够高,需要支撑大流量,不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就 “快” 了。
- 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键,对应的方案:比如:页面缓存方案,Redis 预减库存 / 内存标记与隔离,请求的削峰(RabbitMA / 异步请求),分布式 Session 共享等处理。
用户登录 sql 脚本
DROP TABLE IF EXISTS `seckill_user`;CREATE TABLE `seckill_user`( `id` BIGINT(20) NOT NULL COMMENT '用户 ID, 设为主键, 唯一 手机号', `nickname` VARCHAR(255) NOT NULL DEFAULT '', `password` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'MD5(MD5(pass 明 文 + 固 定salt)+salt)', `slat` VARCHAR(10) NOT NULL DEFAULT '', `head` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '头像', `register_date` DATETIME DEFAULT NULL COMMENT '注册时间', `last_login_date` DATETIME DEFAULT NULL COMMENT '最后一次登录时间', `login_count` INT(11) DEFAULT '0' COMMENT '登录次数', PRIMARY KEY (`id`)) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;
分布式会话 Session 共享
加密密码设置
MD5 的加密的依赖包:
这里我们解读一下密码的设计!!:
登录为例讲解:
传统方式:
客户端——> password 明文——>后端(md5(password 明文)) == db 中存放的 password 是否一致) :这种传统方式存在的问题:
传统方式改进的方式 1:客户端——> md5(password 明文)——>后端(md5(md5(password 明文)) ) == db 中存放的 password 是否一致) 。这样就算黑客拦截到了我们前端发送的信息,也是被加密的,所以无妨。
我们可以在传统方式的基础上,在进行一个加盐上的处理。让密码更加安全一些。
传统方式改进的方式 2:客户端——> md5(password 明文+salt1(固定的盐))——>后端(md5(md5(password 明文+salt1(固定的盐)+salt2(从数据库当中获取的盐,不同用户对应的盐也不同))) ) == db 中存放的 password 是否一致) 。
注意:是对称加密的。
Md5 加密所需的相关依赖 。
<!--md5依赖--> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.15</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version> </dependency>
Junit Jupiter: Junit Jupiter 提供了 JUnit5 的新的编程模型,是 JUnit5 新特性的核心。内部 包含了一个测试引擎,用于在 Junit Platform 上运行
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>5.7.2</version> <scope>compile</scope> </dependency>
加密密码工具类编写
package com.rainbowsea.seckill.utill;import org.apache.commons.codec.digest.DigestUtils;/** * MD5 加密工具类,根据前面密码设计方案提供相应的方法 */public class MD5Util { /** * 第一次加密所需的盐。 */ private static final String SALT ="UCmP7xHA"; /** * MD5 加密 * * @param src 要加密的字符串 * @return String */ public static String md5(String src) { return DigestUtils.md5Hex(src); } /** * 加密加盐,完成的是 md5(pass+salt1) * * @param inputPass 输入的密码 * @return String */ public static String inputPassToMidPass(String inputPass) { String str ="" + SALT.charAt(0) + inputPass + SALT.charAt(6); return md5(str); } /** * 这个盐随机生成,成的是 md5( md5(pass+salt1)+salt2) * * @param midPass 加密的密码 * @param salt 从数据库获取到不同用户加密的盐 * @return String */ public static String midPassToDBPass(String midPass, String salt) { String str = salt.charAt(1) + midPass + salt.charAt(5); return md5(str); } /** * 进行两次加密加盐 最后存到数据库的 md5( md5(pass+salt1)+salt2) * salt1是前端进行的salt2 是后端进行的随机生成 */ public static String inputPassToDBPass(String inputPass, String salt) { String midPass = inputPassToMidPass(inputPass); String dbPass = midPassToDBPass(midPass, salt); return dbPass; }}
validation参数校验
<!--validation参数校验--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> <version>2.4.5</version> </dependency>
用户登录逻辑编写:
package com.rainbowsea.seckill.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.rainbowsea.seckill.mapper.UserMapper;import com.rainbowsea.seckill.pojo.User;import com.rainbowsea.seckill.service.UserService;import com.rainbowsea.seckill.utill.MD5Util;import com.rainbowsea.seckill.utill.ValidatorUtil;import com.rainbowsea.seckill.vo.LoginVo;import com.rainbowsea.seckill.vo.RespBean;import com.rainbowsea.seckill.vo.RespBeanEnum;import org.springframework.stereotype.Service;import org.springframework.util.StringUtils;import javax.annotation.Resource;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;/** * @author huo * @description 针对表【seckill_user】的数据库操作Service实现 * @createDate 2025-04-24 15:38:01 */@Servicepublic class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Resource private UserMapper userMapper; /** * 登录校验 * * @param loginVo 登录时发送的信息 * @param request request * @param response response * @return RespBean */ @Override public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) { String mobile = loginVo.getMobile(); String password = loginVo.getPassword(); // 判断手机号/id,和密码是否为空 if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) { return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 判断手机号是否合格 if (!ValidatorUtil.isMobile(mobile)) { return RespBean.error(RespBeanEnum.MOBILE_ERROR); } // 查询DB,判断用户是否存在 User user = userMapper.selectById(mobile); if (null == user) { return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 如果用户存在,则对比密码! // 注意:我们从 LoginVo 取出的密码是中间密码(即客户端经过一次加密加盐处理的密码) if (!MD5Util.midPassToDBPass(password, user.getSlat()).equals(user.getPassword())) { return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 登录成功 return RespBean.success(user); }}
package com.rainbowsea.seckill.controller;import com.rainbowsea.seckill.service.UserService;import com.rainbowsea.seckill.vo.LoginVo;import com.rainbowsea.seckill.vo.RespBean;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Controller;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.ResponseBody;import javax.annotation.Resource;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import javax.validation.Valid;@Slf4j@Controller@RequestMapping("/login")public class LoginController { @Resource private UserService userService; /** * 用户登录 * * @return 返回登录页面 */ @RequestMapping("/toLogin") public String toLogin() { return"login"; // 到templates/login.html } /** * 登录功能 */ @RequestMapping("/doLogin") @ResponseBody public RespBean doLogin (@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) { log.info("{}", loginVo); return userService.doLogin(loginVo, request, response); }}
注解自定义校验
自定义校验器:
package com.rainbowsea.seckill.validator;import javax.validation.Constraint;import javax.validation.Payload;import java.lang.annotation.Documented;import java.lang.annotation.Retention;import java.lang.annotation.Target;import static java.lang.annotation.ElementType.ANNOTATION_TYPE;import static java.lang.annotation.ElementType.CONSTRUCTOR;import static java.lang.annotation.ElementType.FIELD;import static java.lang.annotation.ElementType.METHOD;import static java.lang.annotation.ElementType.PARAMETER;import static java.lang.annotation.ElementType.TYPE_USE;import static java.lang.annotation.RetentionPolicy.RUNTIME;/** * 开发一个自定义的注解:替换如下,登录校验时的代码 * <p> * <p> * // 判断手机号/id,和密码是否为空 * if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) { * return RespBean.error(RespBeanEnum.LOGIN_ERROR); * } * <p> * // 判断手机号是否合格 * if (!ValidatorUtil.isMobile(mobile)) { * return RespBean.error(RespBeanEnum.MOBILE_ERROR); * } */@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})@Retention(RUNTIME)@Documented@Constraint(validatedBy = {IsMobileValidator.class})public @interface IsMobile { String message() default"手机号码格式错误"; boolean required() default true; Class<?>[] groups() default {}; // 默认参数 Class<? extends Payload>[] payload() default {}; //默认参数}
package com.rainbowsea.seckill.validator;import com.rainbowsea.seckill.utill.ValidatorUtil;import org.springframework.util.StringUtils;import javax.validation.ConstraintValidator;import javax.validation.ConstraintValidatorContext;/** * 我们自拟定注解 IsMobile(手机号是否正确) 的校验规则 */public class IsMobileValidator implements ConstraintValidator<IsMobile, String> { private boolean required = false; @Override public void initialize(IsMobile constraintAnnotation) { // 初始化 required = constraintAnnotation.required(); } @Override public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { //必填 if (required) { return ValidatorUtil.isMobile(value); } else {//非必填 if (!StringUtils.hasText(value)) { return true; } else { return ValidatorUtil.isMobile(value); } } }}
package com.rainbowsea.seckill.vo;import com.rainbowsea.seckill.validator.IsMobile;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;import org.hibernate.validator.constraints.Length;import javax.validation.constraints.NotNull;/** * 接收用户登录时,发送的信息(mobile,password) */@Data@NoArgsConstructor@AllArgsConstructorpublic class LoginVo { // 添加 validation 组件后使用 @NotNull @IsMobile //自拟定注解 private String mobile; @Length(min = 32) @NotNull private String password;}
全局异常处理定义
package com.rainbowsea.seckill.exception;import com.rainbowsea.seckill.vo.RespBeanEnum;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;/** * 全局异常处理类 */@Data@NoArgsConstructor@AllArgsConstructorpublic class GlobalException extends RuntimeException { private RespBeanEnum respBeanEnum;}
package com.rainbowsea.seckill.exception;import com.rainbowsea.seckill.vo.RespBean;import com.rainbowsea.seckill.vo.RespBeanEnum;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.validation.BindException;/** * 全局异常处理定义 */@RestControllerAdvicepublic class GlobalExceptionHandler { /** * 处理所有的异常 * * @param e 异常对象 * @return RespBean */ @ExceptionHandler(Exception.class) public RespBean ExceptionHandler(Exception e) { //如果是全局异常,正常处理 if (e instanceof GlobalException) { GlobalException ex = (GlobalException) e; return RespBean.error(ex.getRespBeanEnum()); } else if (e instanceof BindException) { // BindException 绑定异常 // 如果是绑定异常 :由于我们自定义的注解只会在控制台打印错误信息,想让改信息传给前端。 // 需要获取改异常 BindException,进行打印 BindException ex = (BindException) e; RespBean respBean = RespBean.error(RespBeanEnum.BING_ERROR); respBean.setMessage(" 参 数 校 验 异 常 ~ :" + ex.getBindingResult().getAllErrors().get(0).getDefaultMessage()); return respBean; } return RespBean.error(RespBeanEnum.ERROR); }}
package com.rainbowsea.seckill.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.rainbowsea.seckill.exception.GlobalException;import com.rainbowsea.seckill.mapper.UserMapper;import com.rainbowsea.seckill.pojo.User;import com.rainbowsea.seckill.service.UserService;import com.rainbowsea.seckill.utill.MD5Util;import com.rainbowsea.seckill.utill.ValidatorUtil;import com.rainbowsea.seckill.vo.LoginVo;import com.rainbowsea.seckill.vo.RespBean;import com.rainbowsea.seckill.vo.RespBeanEnum;import org.springframework.stereotype.Service;import org.springframework.util.StringUtils;import javax.annotation.Resource;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;/** * @author huo * @description 针对表【seckill_user】的数据库操作Service实现 * @createDate 2025-04-24 15:38:01 */@Servicepublic class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Resource private UserMapper userMapper; /** * 登录校验 * * @param loginVo 登录时发送的信息 * @param request request * @param response response * @return RespBean */ @Override public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) { String mobile = loginVo.getMobile(); String password = loginVo.getPassword(); // 判断手机号/id,和密码是否为空 //if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) { // return RespBean.error(RespBeanEnum.LOGIN_ERROR); //} // 判断手机号是否合格 //if (!ValidatorUtil.isMobile(mobile)) { // return RespBean.error(RespBeanEnum.LOGIN_ERROR); //} // 查询DB,判断用户是否存在 User user = userMapper.selectById(mobile); if (null == user) { throw new GlobalException(RespBeanEnum.LOGIN_ERROR); //return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 如果用户存在,则对比密码! // 注意:我们从 LoginVo 取出的密码是中间密码(即客户端经过一次加密加盐处理的密码) if (!MD5Util.midPassToDBPass(password, user.getSlat()).equals(user.getPassword())) { throw new GlobalException(RespBeanEnum.LOGIN_ERROR); //return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 登录成功 return RespBean.success(user); }}
完成测试 , 运行项目,访问 http://localhost:8080/login/toLogin
分布式 Session 共享
编写工具类:
第一个工具类:用于生成唯一的 UUID ,作为一个唯一的 userTicket
package com.rainbowsea.seckill.utill;import java.util.UUID;/** * 用户生产唯一的 UUID ,作为 session */public class UUIDUtil { public static String uuid() { // 默认下: 生成的字符串形式 xxxx-yyyy-zzz-ddd // 把 UUID中的-替换掉,所以使用 replace("-","") return UUID.randomUUID().toString().replace("-",""); }}
这是一个工具类, 直接使用即可. (该工具了,可以让我们更方便的操作 cookie , 比如编码处理等等
package com.rainbowsea.seckill.utill;import javax.servlet.http.Cookie;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.UnsupportedEncodingException;import java.net.URLDecoder;import java.net.URLEncoder;/** * 这是一个工具类, 直接使用即可. (该工具了,可以让我们更方便的操作 cookie , 比如编码 * 处理等等. */public class CookieUtil { /** * 得到Cookie的值, 不编码 * * @param request * @param cookieName * @return */ public static String getCookieValue(HttpServletRequest request, String cookieName) { return getCookieValue(request, cookieName, false); } /** * 得到Cookie的值, * * @param request * @param cookieName * @return */ public static String getCookieValue(HttpServletRequest request, String cookieName, boolean isDecoder) { Cookie[] cookieList = request.getCookies(); if (cookieList == null || cookieName == null) { return null; } String retValue = null; try { for (int i = 0; i < cookieList.length; i++) { if (cookieList[i].getName().equals(cookieName)) { if (isDecoder) { retValue = URLDecoder.decode(cookieList[i].getValue(),"UTF-8"); } else { retValue = cookieList[i].getValue(); } break; } } } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return retValue; } /** * 得到Cookie的值, * * @param request * @param cookieName * @param encodeString * @return */ public static String getCookieValue(HttpServletRequest request, String cookieName, String encodeString) { Cookie[] cookieList = request.getCookies(); if (cookieList == null || cookieName == null) { return null; } String retValue = null; try { for (int i = 0; i < cookieList.length; i++) { if (cookieList[i].getName().equals(cookieName)) { retValue = URLDecoder.decode(cookieList[i].getValue(), encodeString); break; } } } catch (UnsupportedEncodingException e) { e.printStackTrace(); } return retValue; } /** * 设置Cookie的值 不设置生效时间默认浏览器关闭即失效,也不编码 */ public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue) { setCookie(request, response, cookieName, cookieValue, -1); } /** * 设置Cookie的值 在指定时间内生效,但不编码 */ public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage) { setCookie(request, response, cookieName, cookieValue, cookieMaxage, false); } /** * 设置Cookie的值 不设置生效时间,但编码 */ public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, boolean isEncode) { setCookie(request, response, cookieName, cookieValue, -1, isEncode); } /** * 设置Cookie的值 在指定时间内生效, 编码参数 */ public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) { doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, isEncode); } /** * 设置Cookie的值 在指定时间内生效, 编码参数(指定编码) */ public static void setCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, String encodeString) { doSetCookie(request, response, cookieName, cookieValue, cookieMaxage, encodeString); } /** * 删除Cookie带cookie域名 */ public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName) { doSetCookie(request, response, cookieName,"", -1, false); } /** * 设置Cookie的值,并使其在指定时间内生效 * * @param cookieMaxage cookie生效的最大秒数 */ private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, boolean isEncode) { try { if (cookieValue == null) { cookieValue =""; } else if (isEncode) { cookieValue = URLEncoder.encode(cookieValue,"utf-8"); } Cookie cookie = new Cookie(cookieName, cookieValue); if (cookieMaxage > 0) { cookie.setMaxAge(cookieMaxage); }// if (null != request) {// 设置域名的cookie// String domainName = getDomainName(request);// if (!"localhost".equals(domainName)) {// cookie.setDomain(domainName);// }// } cookie.setPath("/"); response.addCookie(cookie); } catch (Exception e) { e.printStackTrace(); } } /** * 设置Cookie的值,并使其在指定时间内生效 * * @param cookieMaxage cookie生效的最大秒数 */ private static final void doSetCookie(HttpServletRequest request, HttpServletResponse response, String cookieName, String cookieValue, int cookieMaxage, String encodeString) { try { if (cookieValue == null) { cookieValue =""; } else { cookieValue = URLEncoder.encode(cookieValue, encodeString); } Cookie cookie = new Cookie(cookieName, cookieValue); if (cookieMaxage > 0) { cookie.setMaxAge(cookieMaxage); } if (null != request) {// 设置域名的cookie String domainName = getDomainName(request); System.out.println(domainName); if (!"localhost".equals(domainName)) { cookie.setDomain(domainName); } } cookie.setPath("/"); response.addCookie(cookie); } catch (Exception e) { e.printStackTrace(); } } /** * 得到cookie的域名 */ private static final String getDomainName(HttpServletRequest request) { String domainName = null; // 通过request对象获取访问的url地址 String serverName = request.getRequestURL().toString(); if ("".equals(serverName)) { domainName =""; } else { // 将url地下转换为小写 serverName = serverName.toLowerCase(); // 如果url地址是以http://开头 将http://截取 if (serverName.startsWith("http://")) { serverName = serverName.substring(7); } int end = serverName.length(); // 判断url地址是否包含"/" if (serverName.contains("/")) { //得到第一个"/"出现的位置 end = serverName.indexOf("/"); } // 截取 serverName = serverName.substring(0, end); // 根据"."进行分割 final String[] domains = serverName.split("\\."); int len = domains.length; if (len > 3) { // www.abc.com.cn domainName = domains[len - 3] +"." + domains[len - 2] +"." + domains[len - 1]; } else if (len > 1) { // abc.com or abc.cn domainName = domains[len - 2] +"." + domains[len - 1]; } else { domainName = serverName; } } if (domainName.indexOf(":") > 0) { String[] ary = domainName.split("\\:"); domainName = ary[0]; } return domainName; }}
注意:将 ticket 保存到 cookie,cookieName 不可以随便写,必须时 "userTicket"
。
分布式 Session 共享 详解
上图分析-分布式存在的 Session 共享问题:
- 当 Nginx 对请求进行负载均衡后,可能对应到不同的 Tomcat
- 比如:如果一个商品,一个用户只能购买一次,不可以多购。第 1 次请求,均衡到 TomcatA,这时 Session 就记录在 TomcatA,第 2 次请求均衡到 TomcatB,这时就出现问题了,因为 TomcatB 会认为该用户时第 1 次来的,就会允许购买请求。
- 这样就会造成重复购买。
解决方案:
- Session 绑定/粘滞
什么是 session 绑定/粘滞/黏滞
Session 绑定/粘滞/黏滞 :服务器会把用户的请求急,交给 tomcat 集群中的一个节点,以后此节点就复杂该用户的 Session。
- Session 绑定可以利用负载均衡的源地址
Hash(ip_hast)
算法实现。 - 负载均衡服务器总是将来源手同一个 IP 的请求分发到同一台服务器上,也可以根据 Cookie 信息将同一个用户的请求总是分发到同一台服务器上。
- 这样整个会话期间,该用所有的请求都在同一台服务器上处理,即 Session 绑定在某台特定服务器上,保证 Session 总能在这台服务器上获取。这种方法又被称为 Session /粘滞/黏滞
优点:不占用服务端内存
缺点:
增加新机器,会重新 Hash,导致重新登录,前面存储的就 Session 信息丢失。
应用重启,也是需要重新登录。
某台服务器宕机,该机器上的 Session 也就不存在了,用户请求切换到其他机器后,因为没有 Session 而无法完成业务处理,这种发案不符合系统高可用需求,使用较少。
Session 复制:
Session 复制:是小型架构使用较多的一种服务器集群 Session 管理机制。
- 应用服务器开启 Web 容器的 Session 复制功能,在集群中的几台服务器之间同步 Session 对象,使每台服务器上都保存了所有用户的 Session 信息。
- 这样任何一台机器都不会 导致 Sessin 数据的丢失,而服务器使用 Session 时,也只需要在本机获取即可。·
优点:无需修改代码,修改 Tomcat 配置即可。
缺点:
Session 同步传输占用内网带宽。
多台 Tomcat 同步性能指数级下降。
Session 占用内存,无法有效水平扩展。
前端存储
优点:不占用服务器内存
缺点:
存在安全风险。
数据大小受到 Cookie 本身容量的限制。
占用外网带宽
后端集中存储:使用第三方的缓存数据库存储,比如:Redis 存储我们的 Session 信息。
优点:安全,容易水平扩展
缺点:增加复杂度,需要修改代码
分布式 Session 解决方案 1-SpringSession 实现分布式 Session
一句话:将用户 Session 不再存放到各自登录的 Tomcat 服务器,而是统一存在 Redis,从而解决 Session 分布式问题
- 如图, 将用户的 Session 信息统一保存到 Redis 进行管理
- 说明: 在默认情况下是以原生形式保存的, 后面可以进一步优化
需求说明: 用户登录,将用户 Session 统一存放到指定 Redis ,而不是分布式存放到不同
的 Tomcat 所在服务器
安装配置 Redis:大家可以参考移步至:✏️✏️✏️ 二. Redis 超详细的安装教程((七步)一步一步指导,步步附有截屏操作步骤)_truenas安装redis-CSDN博客
安装 redis-desktop-manager
一句话:这个是 Redis 可视化操作工具
安装过程非常简单,直接下一步即可
启动我们虚拟机当中的 Redis 服务器 :
[root@localhost ~]# redis-server /etc/redis.conf[root@localhost ~]# ps -aux | grep redisroot 3690 0.2 0.2 162516 9956 ? Ssl 09:08 0:00 redis-server *:6379root 3696 0.0 0.0 112812 980 pts/1 S+ 09:08 0:00 grep --color=auto redis
- 先打开 Redis 所在 Linux 防火墙的 6379 端口
#打开端口firewall-cmd --zone=public --add-port=6379/tcp --permanent#重启防火墙, 才能生效firewall-cmd --reload#查看端口是否打开firewall-cmd --list-ports
- 配置 Redis, 允许远程访问, 修改配置文件 redis.con/conf, 老师为了方便,没有设置远程访问密码。
protected-mode no
重启 Redis 生效
通过 telnet 来连接 Linux Redis , 看看是否 OK,如果连接不上,检查前面的配置是否
正确, 特别注意: 需要保证 Redis Desktop 所在机器, 允许访问 6379
运行 Redis Desktop, 连接到 Linux 的 Redis
在 pom.xml 文件当中加入相关的 Redis 依赖。
<!--spring data redis依赖,即 spring整合 redis--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>2.4.5</version> </dependency> <!--pool2对象池依赖--> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.9.0</version> </dependency> <!--实现分布式 session,即将 Session保存到指定的 Redis--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
在项目的 application.yml, 配置 Redis 信息
spring: # 配置Redis redis: host: 192.168.198.135 port: 6379 password: rainbowsea database: 0 timeout: 10000ms lettuce: pool: #最大连接数,默认是8 max-active: 8 #最大连接等待/阻塞时间,默认-1 max-wait: 10000ms #最大空闲连接 max-idle: 200 #最小空闲连接,默认0 min-idle: 5#mybatis-plus配置mybatis-plus: #配置mapper.xml映射文件 mapper-locations: classpath*:/mapper/*Mapper.xml #配置mybatis数据返回类型别名 type-aliases-package: com.rainbowsea.seckill.pojo#mybatis sql 打印logging: level: com.rainbowsea.seckill.mapper: debugserver: port: 8080
完成测试,启动项目,用户登录
浏览器输入http://localhost:8080/login/toLogin
如下优化将:保存到 Redis 当中的数据
分布式 Session 解决方案 2-直接将用户信息统一放入 Redis
一句话:前面将 Session 统一存放指定 Redis, 是以原生的形式存放, 在操作时, 还需要反序列化,不方便,我们可以直接将登录用户信息统一存放到 Redis, 利于操作
如图-将登录用户信息统一存放到 Redis, 方便操作
我们进行改进: 直接将登录用户信息统一存放到 Redis, 利于操作
这里,我们既然要使用自己配置的 Session ,将信息直接存储到 Redis 的话,我们必须要将在 pom.xml 当中 org.springframework.session
提供的 Session 会话处理的 jar 报依赖,注释掉,防止冲突。
创建:RedisConfig.java 一个关于 Redis 的一个配置类。
把 session 信息提取出来存到 redis 中,主要实现序列化, 这里是以常规操作
package com.rainbowsea.seckill.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.io.ClassPathResource;import org.springframework.data.redis.connection.RedisConnectionFactory;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;import org.springframework.data.redis.serializer.StringRedisSerializer;/** * 把session信息提取出来存到redis中 * 主要实现序列化, 这里是以常规操作 * @author Rainbowsea * @version 1.0 */@Configurationpublic class RedisConfig { /** * 自定义 RedisTemplate对象, 注入到容器 * 后面我们操作Redis时,就使用自定义的 RedisTemplate对象 * @param redisConnectionFactory * @return RedisTemplate<String, Object> */ @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); //设置相应key的序列化 redisTemplate.setKeySerializer(new StringRedisSerializer()); //value序列化 //redis默认是jdk的序列化是二进制,这里使用的是通用的json数据,不用传具体的序列化的对象 redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); //设置相应的hash序列化 redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); //注入连接工厂 redisTemplate.setConnectionFactory(redisConnectionFactory); System.out.println("测试--> redisTemplate" + redisTemplate.hashCode()); return redisTemplate; } /** * 增加执行脚本 * @return DefaultRedisScript<Long> */ @Bean public DefaultRedisScript<Long> script() { DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); //设置要执行的lua脚本位置, 把lock.lua文件放在resources redisScript.setLocation(new ClassPathResource("lock.lua")); redisScript.setResultType(Long.class); return redisScript; }}
package com.rainbowsea.seckill.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.rainbowsea.seckill.exception.GlobalException;import com.rainbowsea.seckill.mapper.UserMapper;import com.rainbowsea.seckill.pojo.User;import com.rainbowsea.seckill.service.UserService;import com.rainbowsea.seckill.utill.CookieUtil;import com.rainbowsea.seckill.utill.MD5Util;import com.rainbowsea.seckill.utill.UUIDUtil;import com.rainbowsea.seckill.utill.ValidatorUtil;import com.rainbowsea.seckill.vo.LoginVo;import com.rainbowsea.seckill.vo.RespBean;import com.rainbowsea.seckill.vo.RespBeanEnum;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.stereotype.Service;import org.springframework.util.StringUtils;import javax.annotation.Resource;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;/** * @author huo * @description 针对表【seckill_user】的数据库操作Service实现 * @createDate 2025-04-24 15:38:01 */@Servicepublic class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Resource private UserMapper userMapper; @Resource private RedisTemplate redisTemplate; /** * 登录校验 * * @param loginVo 登录时发送的信息 * @param request request * @param response response * @return RespBean */ @Override public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) { String mobile = loginVo.getMobile(); String password = loginVo.getPassword(); // 判断手机号/id,和密码是否为空 //if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) { // return RespBean.error(RespBeanEnum.LOGIN_ERROR); //} // 判断手机号是否合格 //if (!ValidatorUtil.isMobile(mobile)) { // return RespBean.error(RespBeanEnum.LOGIN_ERROR); //} // 查询DB,判断用户是否存在 User user = userMapper.selectById(mobile); if (null == user) { throw new GlobalException(RespBeanEnum.LOGIN_ERROR); //return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 如果用户存在,则对比密码! // 注意:我们从 LoginVo 取出的密码是中间密码(即客户端经过一次加密加盐处理的密码) if (!MD5Util.midPassToDBPass(password, user.getSlat()).equals(user.getPassword())) { throw new GlobalException(RespBeanEnum.LOGIN_ERROR); //return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 登录成功 // 给每个用户生成 ticket 唯一 String ticket = UUIDUtil.uuid(); // 为实现分布式 Session ,把登录用户信息存放到 Redis 当中 System.out.println("使用 redisTemplate->" + redisTemplate.hashCode()); redisTemplate.opsForValue().set("user:" + ticket, user); // 将登录成功的用户保存到 session //request.getSession().setAttribute(ticket, user); // 将 ticket 保存到 cookie,cookieName 不可以随便写,必须时"userTicket" CookieUtil.setCookie(request, response,"userTicket", ticket); return RespBean.success(); }}
测试:运行查看,我们在 Redis 保存的信息是否,符合我们的预期:
完成测试,启动项目,用户登录
浏览器输入http://localhost:8080/login/toLogin
我们还还需要修改,登录成功,进入商品的处理,因为我们登录成功了,需要改为从 Redis 当中获取 Session 信息了。如果 Redis 当中没有该登录的用户的信息,那么就说明该用户没有登录过,需要登录,才能访问,我们的商品列表信息页面。
package com.rainbowsea.seckill.service;import com.baomidou.mybatisplus.extension.service.IService;import com.rainbowsea.seckill.pojo.User;import com.rainbowsea.seckill.vo.LoginVo;import com.rainbowsea.seckill.vo.RespBean;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;/** * @author huo * @description 针对表【seckill_user】的数据库操作Service * @createDate 2025-04-24 15:38:01 */public interface UserService extends IService<User> { /** * 根据 Cookie 当中的 userTicket 获取判断,存储到 Redis 当中的用户信息 * @param userTicket Cookie 当中的 userTicket * @param request * @param response * @return 存储到 Redis 当中的 User 对象信息 */ User getUserByCookieByRedis(String userTicket, HttpServletRequest request, HttpServletResponse response);}
package com.rainbowsea.seckill.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.rainbowsea.seckill.exception.GlobalException;import com.rainbowsea.seckill.mapper.UserMapper;import com.rainbowsea.seckill.pojo.User;import com.rainbowsea.seckill.service.UserService;import com.rainbowsea.seckill.utill.CookieUtil;import com.rainbowsea.seckill.utill.MD5Util;import com.rainbowsea.seckill.utill.UUIDUtil;import com.rainbowsea.seckill.utill.ValidatorUtil;import com.rainbowsea.seckill.vo.LoginVo;import com.rainbowsea.seckill.vo.RespBean;import com.rainbowsea.seckill.vo.RespBeanEnum;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.stereotype.Service;import org.springframework.util.StringUtils;import javax.annotation.Resource;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;/** * @author huo * @description 针对表【seckill_user】的数据库操作Service实现 * @createDate 2025-04-24 15:38:01 */@Servicepublic class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Resource private UserMapper userMapper; @Resource private RedisTemplate redisTemplate; /** * 登录校验 * * @param loginVo 登录时发送的信息 * @param request request * @param response response * @return RespBean */ @Override public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) { String mobile = loginVo.getMobile(); String password = loginVo.getPassword(); // 判断手机号/id,和密码是否为空 //if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) { // return RespBean.error(RespBeanEnum.LOGIN_ERROR); //} // 判断手机号是否合格 //if (!ValidatorUtil.isMobile(mobile)) { // return RespBean.error(RespBeanEnum.LOGIN_ERROR); //} // 查询DB,判断用户是否存在 User user = userMapper.selectById(mobile); if (null == user) { throw new GlobalException(RespBeanEnum.LOGIN_ERROR); //return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 如果用户存在,则对比密码! // 注意:我们从 LoginVo 取出的密码是中间密码(即客户端经过一次加密加盐处理的密码) if (!MD5Util.midPassToDBPass(password, user.getSlat()).equals(user.getPassword())) { throw new GlobalException(RespBeanEnum.LOGIN_ERROR); //return RespBean.error(RespBeanEnum.LOGIN_ERROR); } // 登录成功 // 给每个用户生成 ticket 唯一 String ticket = UUIDUtil.uuid(); // 为实现分布式 Session ,把登录用户信息存放到 Redis 当中 System.out.println("使用 redisTemplate->" + redisTemplate.hashCode()); redisTemplate.opsForValue().set("user:" + ticket, user); // 将登录成功的用户保存到 session //request.getSession().setAttribute(ticket, user); // 将 ticket 保存到 cookie,cookieName 不可以随便写,必须时"userTicket" CookieUtil.setCookie(request, response,"userTicket", ticket); return RespBean.success(); } /** * 根据 Cookie 当中的 userTicket 获取判断,存储到 Redis 当中的用户信息 * @param userTicket Cookie 当中的 userTicket * @param request * @param response * @return 存储到 Redis 当中的 User 对象信息 */ @Override public User getUserByCookieByRedis(String userTicket, HttpServletRequest request, HttpServletResponse response) { if(!StringUtils.hasText(userTicket)) { return null; } // 根据 Cookie 当中的 userTicket 获取判断,存储到 Redis 当中的用户信息 // 注意:这里我们在 Redis 存储的 Key是:"user:+userTicket" User user = (User) redisTemplate.opsForValue().get("user:" + userTicket); // 如果用户不为 null,就重新设置 cookie,刷新,防止cookie超时了, if(user != null) { // cookieName 不可以随便写,必须是"userTicket" CookieUtil.setCookie(request,response,"userTicket",userTicket); } return user; }}
package com.rainbowsea.seckill.controller;import com.rainbowsea.seckill.pojo.User;import com.rainbowsea.seckill.service.UserService;import org.springframework.stereotype.Controller;import org.springframework.ui.Model;import org.springframework.util.StringUtils;import org.springframework.web.bind.annotation.CookieValue;import org.springframework.web.bind.annotation.RequestMapping;import javax.annotation.Resource;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import javax.servlet.http.HttpSession;/** * 商品列表处理 */@Controller@RequestMapping("/goods")public class GoodsController { @Resource private UserService userService; // 跳转到商品列表页 //@RequestMapping(value ="/toList") //public String toList(HttpSession session, // Model model, // @CookieValue("userTicket") String ticket, // ) { @RequestMapping(value ="/toList") public String toList(Model model, @CookieValue("userTicket") String ticket, HttpServletRequest request, HttpServletResponse response ) { // @CookieValue("userTicket") String ticket 注解可以直接获取到,对应"userTicket" 名称 // 的cookievalue 信息 if (!StringUtils.hasText(ticket)) { return"login"; } // 通过 cookieVale 当中的 ticket 获取 session 中存放的 user //User user = (User) session.getAttribute(ticket); // 改为从 Redis 当中获取 User user = userService.getUserByCookieByRedis(ticket, request, response); if (null == user) { // 用户没有成功登录 return"login"; } // 将 user 放入到 model,携带该下一个模板使用 model.addAttribute("user", user); return"goodsList"; }}
测试:运行查看,我们在 Redis 保存的信息是否,符合我们的预期:
完成测试,启动项目,用户登录
浏览器输入http://localhost:8080/login/toLogin
最后:
“在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。”