秒杀/高并发方案-介绍

@

秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter )-01

  1. 秒杀/ 高并发,其实主要解决两个问题:一个是并发读,一个是并发写
  2. 并发读的核心优化理念就是尽量减少用户到 DB 来 “读” 数据,或者让他们读更少的数据,并发写的处理原则也是一样的
  3. 针对秒杀系统需要做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
  4. 系统架构降要满足高可用:流量符合预期时要稳定,要保证秒杀活动顺利完成,即秒杀商品顺利被卖出去,这个是最基本的前提
  5. 系统保证数据的一致性:就是秒杀 10 个商品,那就只能成交 10 个商品,多一个少一个都不行。一旦库存不对,就要承担损失
  6. 系统要满足高性能:也就是系统的性能要足够高,需要支撑大流量,不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就 “快” 了
  7. 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键,对应的方案:比如:页面缓存方案,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
*/
@Service
public 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
@AllArgsConstructor
public 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
@AllArgsConstructor
public 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; /**
* 全局异常处理定义
*/
@RestControllerAdvice
public 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
*/
@Service
public 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 共享问题:

  1. 当 Nginx 对请求进行负载均衡后,可能对应到不同的 Tomcat
  2. 比如:如果一个商品,一个用户只能购买一次,不可以多购。第 1 次请求,均衡到 TomcatA,这时 Session 就记录在 TomcatA,第 2 次请求均衡到 TomcatB,这时就出现问题了,因为 TomcatB 会认为该用户时第 1 次来的,就会允许购买请求。
  3. 这样就会造成重复购买。

解决方案:

  1. Session 绑定/粘滞

什么是 session 绑定/粘滞/黏滞

Session 绑定/粘滞/黏滞 :服务器会把用户的请求急,交给 tomcat 集群中的一个节点,以后此节点就复杂该用户的 Session。

  1. Session 绑定可以利用负载均衡的源地址 Hash(ip_hast)算法实现。
  2. 负载均衡服务器总是将来源手同一个 IP 的请求分发到同一台服务器上,也可以根据 Cookie 信息将同一个用户的请求总是分发到同一台服务器上。
  3. 这样整个会话期间,该用所有的请求都在同一台服务器上处理,即 Session 绑定在某台特定服务器上,保证 Session 总能在这台服务器上获取。这种方法又被称为 Session /粘滞/黏滞

优点:不占用服务端内存

缺点:

  1. 增加新机器,会重新 Hash,导致重新登录,前面存储的就 Session 信息丢失。

  2. 应用重启,也是需要重新登录。

  3. 某台服务器宕机,该机器上的 Session 也就不存在了,用户请求切换到其他机器后,因为没有 Session 而无法完成业务处理,这种发案不符合系统高可用需求,使用较少。

  4. Session 复制:

Session 复制:是小型架构使用较多的一种服务器集群 Session 管理机制。

  1. 应用服务器开启 Web 容器的 Session 复制功能,在集群中的几台服务器之间同步 Session 对象,使每台服务器上都保存了所有用户的 Session 信息。
  2. 这样任何一台机器都不会 导致 Sessin 数据的丢失,而服务器使用 Session 时,也只需要在本机获取即可。·

优点:无需修改代码,修改 Tomcat 配置即可。

缺点:

  1. Session 同步传输占用内网带宽。

  2. 多台 Tomcat 同步性能指数级下降。

  3. Session 占用内存,无法有效水平扩展。

  4. 前端存储

优点:不占用服务器内存

缺点:

  1. 存在安全风险。

  2. 数据大小受到 Cookie 本身容量的限制。

  3. 占用外网带宽

  4. 后端集中存储:使用第三方的缓存数据库存储,比如:Redis 存储我们的 Session 信息。

优点:安全,容易水平扩展

缺点:增加复杂度,需要修改代码

分布式 Session 解决方案 1-SpringSession 实现分布式 Session

一句话:将用户 Session 不再存放到各自登录的 Tomcat 服务器,而是统一存在 Redis,从而解决 Session 分布式问题

  1. 如图, 将用户的 Session 信息统一保存到 Redis 进行管理
  2. 说明: 在默认情况下是以原生形式保存的, 后面可以进一步优化

需求说明: 用户登录,将用户 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 redis
root 3690 0.2 0.2 162516 9956 ? Ssl 09:08 0:00 redis-server *:6379
root 3696 0.0 0.0 112812 980 pts/1 S+ 09:08 0:00 grep --color=auto redis
  1. 先打开 Redis 所在 Linux 防火墙的 6379 端口
#打开端口
firewall-cmd --zone=public --add-port=6379/tcp --permanent
#重启防火墙, 才能生效
firewall-cmd --reload
#查看端口是否打开
firewall-cmd --list-ports
  1. 配置 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: debug
server:
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
*/ @Configuration
public 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
*/
@Service
public 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
*/
@Service
public 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

最后:

“在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。”

秒杀/高并发解决方案+落地实现 (技术栈: SpringBoot+Mysql + Redis +RabbitMQ +MyBatis-Plus +Maven + Linux + Jmeter )-01的更多相关文章

  1. coding++:高并发解决方案限流技术---漏桶算法限流--demo

    1.漏桶算法 漏桶作为计量工具(The Leaky Bucket Algorithm as a Meter)时,可以用于流量整形(Traffic Shaping)和流量控制(TrafficPolici ...

  2. coding++:高并发解决方案限流技术--计数器--demo

    1.它是限流算法中最简单最容易的一种算法 计数器实现限流 每分钟只允许10个请求 第一个请求进去的时间为startTime,在startTime + 60s内只允许10个请求 当60s内超过十个请求后 ...

  3. coding++:高并发解决方案限流技术-使用RateLimiter实现令牌桶限流-Demo

    RateLimiter是guava提供的基于令牌桶算法的实现类,可以非常简单的完成限流特技,并且根据系统的实际情况来调整生成token的速率. 通常可应用于抢购限流防止冲垮系统:限制某接口.服务单位时 ...

  4. 高并发解决方案限流技术-----使用RateLimiter实现令牌桶限流

    1,RateLimiter是guava提供的基于令牌桶算法的实现类,可以非常简单的完成限流特技,并且根据系统的实际情况来调整生成token的速率.通常可应用于抢购限流防止冲垮系统:限制某接口.服务单位 ...

  5. IDEA SpringBoot+JPA+MySql+Redis+RabbitMQ 秒杀系统

    先放上github地址:spike-system,可以直接下载完整项目运行测试 SpringBoot+JPA+MySql+Redis+RabbitMQ 秒杀系统 技术栈:SpringBoot, MyS ...

  6. PHP面试(二):程序设计、框架基础知识、算法与数据结构、高并发解决方案类

    一.程序设计 1.设计功能系统——数据表设计.数据表创建语句.连接数据库的方式.编码能力 二.框架基础知识 1.MVC框架基本原理——原理.常见框架.单一入口的工作原理.模板引擎的理解 2.常见框架的 ...

  7. 手把手让你实现开源企业级web高并发解决方案(lvs+heartbeat+varnish+nginx+eAccelerator+memcached)

    原创作品,允许转载,转载时请务必以超链接形式标明文章 原始出处 .作者信息和本声明.否则将追究法律责任.http://freeze.blog.51cto.com/1846439/677348 此文凝聚 ...

  8. 关于SQL SERVER高并发解决方案

    现在大家都比较关心的问题就是在多用户高并发的情况下,如何开发系统,这对我们程序员来说,确实是值得研究,最近找工作面试时也经常被问到,其实我早有去关心和了解这类问题,但一直没有总结一下,导致面试时无法很 ...

  9. java并发编程与高并发解决方案

    下面是我对java并发编程与高并发解决方案的学习总结: 1.并发编程的基础 2.线程安全—可见性和有序性 3.线程安全—原子性 4.安全发布对象—单例模式 5.不可变对象 6.线程封闭 7.线程不安全 ...

  10. 高并发解决方案--负载均衡(HTTP,DNS,反向代理服务器)(解决大流量,高并发)

    高并发解决方案--负载均衡(HTTP,DNS,反向代理服务器)(解决大流量,高并发) 一.总结 1.什么是负载均衡:当一台服务器的性能达到极限时,我们可以使用服务器集群来提高网站的整体性能.那么,在服 ...

随机推荐

  1. Spark - spark on yarn 的作业提交流程

    YarnClient YarnCluster 客户端(Client)通过YARN的ResourceManager提交应用程序.在此过程中,客户端进行权限验证,生成Job ID和资源上传路径,并将这些信 ...

  2. 大数据之路Week08_day03 (Hive的动态分区和分桶)

    一.动态分区 先来说说我对动态分区的理解与一些感受吧. 由于我们通过hive去查询数据的时候,实际还是查询HDFS上的数据,一旦一个目录下有很多文件呢?而我们去查找的数据也没有那么多,全盘扫描就会浪费 ...

  3. go string转int strconv包

    前言 strconv 主要用于字符串和基本类型的数据类型的转换 s := "aa"+100 //字符串和整形数据不能放在一起 所以需要将 100 整形转为字符串类型 //+号在字符 ...

  4. CentOS7图形化界面和命令行界面之间的转换

    最近在学习Lunix操作系统下的CentOS7系统,参考了网页上大多数的资料并进行在自己的亲身实践,最终想要记录一下我在CentOS7系统中有关命令行和图形化界面之间的转换.1.查看当前的默认界面形式 ...

  5. MD5加密BASE64加解密

    MD5需要引入system.Hash,BASE64需要引入System.NetEncoding,这两个单元应该只有高版本的DELPHI IDE才有 (貌似XE5以上版本才有).如果是D7的话,找第三方 ...

  6. Noise——随机之美

    本篇博文介绍图形学中噪音生成的一般方法. Noise可以干什么? 不规则表面生成 有机体模拟 流体烟雾模拟 甚至是使用noise对灯光强度,位置做扰动: 只有我们想象不到的,没有noise不能涉猎的! ...

  7. 在 JavaScript 中,判断一个对象是否为空有几种方法。

    使用 Object.keys() 方法检查对象的键值对数量: function isObjectEmpty(obj) { return Object.keys(obj).length === 0; } ...

  8. "油猴脚本""篡改猴"领域的一些基本常识

    本文简要介绍本人对"油猴脚本","篡改猴"领域的一些见解,内容注定不可能一步到位和事无巨细,欢迎各位仁人志士对我批评指正,提出意见建议.另外转载前请务必注明作者 ...

  9. 0x03 搜索与图论

    搜索与图论 广度优先搜索\(BFS\) 概念 广度优先搜索(Breadth-First Search)是一种图遍历算法,用于在图或树中按层次逐层访问节点.它从源节点(起始节点)开始,首先访问源节点的所 ...

  10. fastjson bug: parseObject 死循环

    版本: com.alibaba:fastjson:1.2.83 描述: 反序列化时,会陷入死循环 JSON:[""] 引起bug代码: List<Map<String, ...