慕课网、乐字节Java电商秒杀项目

技术点介绍:
前端:Thymeleaf,Bootstrap,Jquerry
后端:SpringBoot,MybatisPlus,Lombok
中间件:RabbitMQ,Redis
秒杀方案:
分布式会话:用户登录,共享session
功能开发:商品列表,商品详情,秒杀,订单详情
系统压测:JMeter入门,yace
页面优化:缓存,静态化分离
服务优化:RabbitMQ消息队列,接口优化,分布式锁
安全优化:隐藏秒杀地址,算术验证码,接口限流。
秒杀介绍:
秒杀,对我们来说,都不是一个陌生的东西。每年的双11,618以及时下流行的直播等等。秒杀对于我们系统而言是一个巨大的考验。秒杀主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。

秒杀的整体架构可以概括为“稳、准、快”几个关键字。稳要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提;“准”就是要求保证数据的一致性,秒杀几台东西就只能卖几台东西;“快”就是要求系统的性能要足够高。

从技术角度上看“稳、准、快”,就对应了我们架构上的高可用、一致性和高性能的要求。

高性能。 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。对应的方案比如动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化.
一致性。 秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为“拍下减库存”“付款减库存”以及预扣等几种,在大并发更新的过程中都要保证数据的准确性,其难度可想而知
高可用。 现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB 来兜底,以便在最坏情况发生时仍然能够从容应对。
项目搭建
选择Spring Initializr, sdk选择1.8,java版本选择8,添加Lombok,Spring Web,Thymeleaf依赖。

添加依赖:pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion>
<!--SpringBoot依赖-->
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.3.4.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>

	<groupId>com.xxxx</groupId>
	<artifactId>seckill-demo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>seckill-demo</name>
	<description>Demo project for Spring Boot</description>

	<properties>
		<java.version>8</java.version>
	</properties>

	<dependencies>
		<!--thymeleaf 组件-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<!--web 组件-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<!--mybatisplus依赖-->
		<dependency>
			<groupId>com.baomidou</groupId>
			<artifactId>mybatis-plus-boot-starter</artifactId>
			<version>3.4.0</version>
		</dependency>
		<!--mysql-->
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<!--lombok-->
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<!--test组件-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<!-- md5 依赖 -->
		<dependency>
			<groupId>commons-codec</groupId>
			<artifactId>commons-codec</artifactId>
		</dependency>
		<!-- validation组件 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>
		<!--spring data redis 依赖-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>
		<!--commons-pool2 对象池依赖-->
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-pool2</artifactId>
		</dependency>
		<!-- AMQP依赖 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-amqp</artifactId>
		</dependency>
		<!-- 验证码依赖 -->
		<dependency>
			<groupId>com.github.whvcse</groupId>
			<artifactId>easy-captcha</artifactId>
			<version>1.6.2</version>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>
</project>

配置文件:application.yml

spring:
  #静态资源处理
  resources:
    #启动默认静态资源处理,默认启用
    add-mappings: true
    cache:
      cachecontrol:
        #缓存相应时间,单位秒
        max-age: 3600
    chain:
      #资源链启动缓存,默认启动
      cache: true
      #启用资源链,默认禁用
      enabled: true
      #启用压缩资源(gzip,brotli)解析,默认禁用
      compressed: true
      #启用h5应用缓存,默认禁用
      html-application-cache: true
    static-locations: classpath:/static/
  # thymelaef配置
  thymeleaf:
    # 关闭缓存
    cache: false
  # 数据源配置
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.56.10:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    hikari:
      #连接池名
      pool-name: DateHikariCP
      # 最小空闲连接出
      minimum-idle: 5
      # 空闲连接存活最大时间,默认600000(10分钟)
      idle-timeout: 600000
      #最大连接数,默认10
      maximum-pool-size: 10
      # 从连接池返回的连接自动提交
      auto-commit: true
      # 连接最大存活时间,0表示永久存活,默认1800000(30分钟)
      max-lifetime: 1800000
      # 连接超时时间,默认30000(30)
      connection-timeout: 30000
      # 测试连接是否可用的查询语句
      connection-test-query: SELECT 1

  # redis配置
  redis:
    #服务器地址
    host: 192.168.56.10
    #端口
    port: 6379
    #数据库
    database: 0
    #超时时间
    timeout: 10000ms
    #密码
    lettuce:
      pool:
        #最大连接数,默认8
        max-active: 8
        #最大连接阻塞等待时间,默认-1
        max-wait: 10000ms
        #最大空闲连接,默认8
        max-idle: 200
        #最小空闲连接,默认0
        min-idle: 5
  # RabbitMQ
  rabbitmq:
    # 服务器
    host: 192.168.56.10
    #用户名
    username: root
    #密码
    password: root
    # 虚拟主机
    virtual-host: /
    #端口
    port: 5672
    listener:
      simple:
        #消费者最小数量
        concurrency: 10
        #消费者最大数量
        max-concurrency: 10
        #限制消费者每次只处理一条消息,处理完再继续下一条消息
        prefetch: 1
        #启动时是否默认启动容器,默认true
        auto-startup: true
        #被拒绝时重新进入队列
        default-requeue-rejected: true
    template:
      retry:
        #发布重试,默认false
        enabled: true
        #重试时间,默认1000ms
        initial-interval: 1000ms
        #重试最大次数,默认3次
        max-attempts: 3
        #重试最大间隔时间,默认10000ms
        max-interval: 10000ms
        #重试的间隔乘数。比如配2.0,第一次就等10s,第二次就等20s,第三次就等40s
        multiplier: 1

#Mybatis-plus配置
mybatis-plus:
  # 配置Mapper.xml映射文件
  mapper-locations: classpath*:/mapper/*Mapper.xml
  # 配置MyBatis数据返回类型别名(默认别名是类名)
  type-aliases-package: com.xxxx.seckill.pojo

# MyBatis SQL打印(方法接口所在的包,不是Mapper.xml所在的包)
#logging:
#  level:
#    com.xxxx.seckill.mapper: debug

添加公共结果返回对象:

//公共返回对象枚举
public enum RespBeanEnum {
	//通用
	SUCCESS(200, "SUCCESS"),
	ERROR(500, "服务端异常"),
	//登录模块5002xx
	LOGIN_ERROR(500210, "用户名或密码不正确"),
	MOBILE_ERROR(500211, "手机号码格式不正确"),
	BIND_ERROR(500212, "参数校验异常"),
	MOBILE_NOT_EXIST(500213, "手机号码不存在"),
	PASSWORD_UPDATE_FAIL(500214, "密码更新失败"),
	SESSION_ERROR(500215, "用户不存在"),
	//秒杀模块5005xx
	EMPTY_STOCK(500500, "库存不足"),
	REPEATE_ERROR(500501, "该商品每人限购一件"),
	REQUEST_ILLEGAL(500502, "请求非法,请重新尝试"),
	ERROR_CAPTCHA(500503, "验证码错误,请重新输入"),
	ACCESS_LIMIT_REAHCED(500504, "访问过于频繁,请稍后再试"),
	//订单模块5003xx
	ORDER_NOT_EXIST(500300, "订单信息不存在"),
	;
	private final Integer code;
	private final String message;
}
//公共返回对象
public class RespBean {
	private long code;
	private String message;
	private Object obj;
	/**
	 * 功能描述: 成功返回结果
	 */
	public static RespBean success(){
		return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessage(),null);
	}
	/**
	 * 功能描述: 成功返回结果
	 */
	public static RespBean success(Object obj){
		return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBean.success().getMessage(),obj);
	}
	/**
	 * 功能描述: 失败返回结果
	 */
	public static RespBean error(RespBeanEnum respBeanEnum){
		return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),null);
	}
	
	/**
	 * 功能描述: 失败返回结果
	 */
	public static RespBean error(RespBeanEnum respBeanEnum,Object obj){
		return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),obj);
	}

}

分布式会话
登录功能:两次MD5加密

客户端 → 服务端:MD5(用户输入 + salt)
服务端 → 数据库:MD5(服务端结果 + salt)

public class MD5Util {
	public static String md5(String src){
		return DigestUtils.md5Hex(src);
	}
	private static final String salt="1a2b3c4d";
	public static String inputPassToFromPass(String inputPass){
		String str = "" +salt.charAt(0)+salt.charAt(2)+inputPass+salt.charAt(5)+salt.charAt(4);
		return md5(str);
	}
	public static String formPassToDBPass(String formPass,String salt){
		String str = "" +salt.charAt(0)+salt.charAt(2)+formPass+salt.charAt(5)+salt.charAt(4);
		return md5(str);
	}

	public static String inputPassToDBPass(String inputPass,String salt){
		String fromPass = inputPassToFromPass(inputPass);
		String dbPass = formPassToDBPass(fromPass, salt);
		return dbPass;
	}
	
	public static void main(String[] args) {
		// d3b1294a61a07da9b49b6e22b2cbd7f9
		System.out.println(inputPassToFromPass("123456"));
		System.out.println(formPassToDBPass("d3b1294a61a07da9b49b6e22b2cbd7f9","1a2b3c4d"));
		System.out.println(inputPassToDBPass("123456","1a2b3c4d"));
	}
}

通过逆向工程基于 t_user 表生产对应的POJO、Mapper、Service、ServiceImpl、Controller

等类,项目中使用了MybatisPlus,所以逆向工程也是用了MybatisPlus提供的AutoGenerator.

手机号码校验:

public class ValidatorUtil {
	private static final Pattern mobile_pattern = Pattern.compile("[1]([3-9])[0-9]{9}$");
	public static boolean isMobile(String mobile){
		if (StringUtils.isEmpty(mobile)){
			return false;
		}
		Matcher matcher = mobile_pattern.matcher(mobile);
		return matcher.matches();
	}
}

自定义注解,@IsMobile来判断是否为手机号

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class})
public @interface IsMobile {
	boolean required() default true;
	String message() default "手机号码格式错误";
	Class<?>[] groups() default { };
	Class<? extends Payload>[] payload() default { };
}
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 context) {
		if (required){
			return ValidatorUtil.isMobile(value);
		}else {
			if (StringUtils.isEmpty(value)){
				return true;
			}else {
				return ValidatorUtil.isMobile(value);
			}
		}
	}
}

异常处理:

系统中异常包括:编译时异常和运行时异常 RuntimeException ,前者通过捕获异常从而获取异常信息,后者主要通过规范代码开发、测试通过手段减少运行时异常的发生。在开发中,不管是dao层、service层还是controller层,都有可能抛出异常,在Springmvc中,能将所有类型的异常处理从各处理过程解耦出来,既保证了相关处理过程的功能较单一,也实现了异常信息的统一处理和维护。SpringBoot全局异常处理方式主要两种:

使用 @ControllerAdvice 和 @ExceptionHandler 注解
使用 ErrorController类来实现

区别:

@ControllerAdvice 方式只能处理控制器抛出的异常。此时请求已经进入控制器中。
ErrorController类 方式可以处理所有的异常,包括未进入控制器的错误,比如404,401等错误
如果应用中两者共同存在,则 @ControllerAdvice 方式处理控制器抛出的异常,ErrorController类 方式处理未进入控制器的异常。
@ControllerAdvice 方式可以定义多个拦截方法,拦截不同的异常类,并且可以获取抛出的异常信息,自由度更大

//全局异常
public class GlobalException extends RuntimeException {
	private RespBeanEnum respBeanEnum;
}
//全局异常处理类
@RestControllerAdvice
public class GlobalExceptionHandler {
	@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 ex = (BindException) e;
			RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR);
			respBean.setMessage("参数校验异常:" + ex.getBindingResult().getAllErrors().get(0).getDefaultMessage());
			return respBean;
		}
		return RespBean.error(RespBeanEnum.ERROR);
	}
}

每次用户登录,根据UUIDUtil工具类,生成随机字符,并作为userTicket的cookie值,之后可以通过cookie获取用户。

public class UUIDUtil {
   public static String uuid() {
      return UUID.randomUUID().toString().replace("-", "");
   }
}
{
		String ticket = UUIDUtil.uuid();
		request.getSession().setAttribute(ticket,user);
		CookieUtil.setCookie(request, response, "userTicket", ticket);
}
//根据cookie获取用户
public User getUserByCookie(String userTicket, HttpServletRequest request, HttpServletResponse response) {
		if (StringUtils.isEmpty(userTicket)) {
			return null;
		}
		User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
		if (user != null) {
			CookieUtil.setCookie(request, response, "userTicket", userTicket);
		}
		return user;
	}

分布式session问题:
之前的代码在我们之后一台应用系统,所有操作都在一台Tomcat上,没有什么问题。当我们部署多台系统,配合Nginx的时候会出现用户登录的问题。

原因:由于 Nginx 使用默认负载均衡策略(轮询),请求将会按照时间顺序逐一分发到后端应用上。也就是说刚开始我们在 Tomcat1 登录之后,用户信息放在 Tomcat1 的 Session 里。过了一会,请求又被 Nginx 分发到了 Tomcat2 上,这时 Tomcat2 上 Session 里还没有用户信息,于是又要登录。

解决方案:

1.session复制
优点:无需修改代码,只需要修改Tomcat配置
缺点:Session同步传输占用内网带宽;多台Tomcat同步性能指数级下降;Session占用内存,无法有效水平扩展
2.前端存储
优点:不占用服务端内存
缺点:存在安全风险;数据大小受cookie限制;占用外网带宽
3.Session粘滞
优点:无需修改代码;服务端可以水平扩展
缺点:增加新机器,会重新Hash,导致重新登录;应用重启,需要重新登录
4.后端集中存储
优点:安全,容易水平扩展
缺点:增加复杂度,需要修改代码

综上,我们可以使用redis中间件作为后端存储,实现分布式session。

Redis实现分布式Session有2种方法,方法一是使用SpringSession实现,添加依赖和配置后,其余代码暂时不动,重新登录测试,会发现session已经存储在Redis上;方法二是将用户信息存入Redis,这里选用方法二。

添加redis配置类:

public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
		RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
		//key序列化
		redisTemplate.setKeySerializer(new StringRedisSerializer());
		//value序列化
		redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
		//hash类型 key序列化
		redisTemplate.setHashKeySerializer(new StringRedisSerializer());
		//hash类型 value序列化
		redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
		//注入连接工厂
		redisTemplate.setConnectionFactory(redisConnectionFactory);
		return redisTemplate;
	}

优化登录功能:

因为大量接口需要用到User对象的传入,所以用到MVC对User进行解析。

/**
 * 自定义用户参数
 */
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
	@Autowired
	private IUserService userService;
	@Override
	public boolean supportsParameter(MethodParameter parameter) {
		Class<?> clazz = parameter.getParameterType();
		return clazz == User.class;
	}
	@Override
	public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
	                              NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
			HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
			HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
			String ticket = CookieUtil.getCookieValue(request, "userTicket");
			if (StringUtils.isEmpty(ticket)) {
					return null;
			}
			return userService.getByUserTicket(ticket, request, response);
	}
}

/**
 * MVC配置类
 */
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
	@Autowired
	private UserArgumentResolver userArgumentResolver;
	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
		resolvers.add(userArgumentResolver);
	}
}

用户完整登录接口实现代码:

public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
		String mobile = loginVo.getMobile();
		String password = loginVo.getPassword();
		User user = userMapper.selectById(mobile);
		if (null == user) {
			throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
		}
		//判断密码是否正确
		if (!MD5Util.formPassToDBPass(password, user.getSlat()).equals(user.getPassword())) {
			throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
		}
		//生成cookie
		String ticket = UUIDUtil.uuid();
		//将用户信息存入redis中
		redisTemplate.opsForValue().set("user:" + ticket, user);
		CookieUtil.setCookie(request, response, "userTicket", ticket);
		return RespBean.success(ticket);
	}

	/**
	 * 功能描述: 根据cookie获取用户
	 */
	@Override
	public User getUserByCookie(String userTicket, HttpServletRequest request, HttpServletResponse response) {
		if (StringUtils.isEmpty(userTicket)) {
			return null;
		}
		User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
		if (user != null) {
			CookieUtil.setCookie(request, response, "userTicket", userTicket);
		}
		return user;
	}

秒杀功能

商品列表页
逆向工程生成所需要的类:

GoodsVo:商品表和秒杀商品表的返回对象
public class GoodsVo extends Goods {
	private BigDecimal seckillPrice;
	private Integer stockCount;
	private Date startDate;
	private Date endDate;
}

GoodsMapper

public interface GoodsMapper extends BaseMapper<Goods> {
	/**
	 * 功能描述: 获取商品列表
	 */
	List<GoodsVo> findGoodsVo();

	/**
	 * 功能描述: 获取商品详情
	 */
	GoodsVo findGoodsVoByGoodsId(Long goodsId);
}
XML:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xxxx.seckill.mapper.GoodsMapper">

	<!-- 通用查询映射结果 -->
	<resultMap id="BaseResultMap" type="com.xxxx.seckill.pojo.Goods">
		<id column="id" property="id"/>
		<result column="goods_name" property="goodsName"/>
		<result column="goods_title" property="goodsTitle"/>
		<result column="goods_img" property="goodsImg"/>
		<result column="goods_detail" property="goodsDetail"/>
		<result column="goods_price" property="goodsPrice"/>
		<result column="goods_stock" property="goodsStock"/>
	</resultMap>

	<!-- 通用查询结果列 -->
	<sql id="Base_Column_List">
        id, goods_name, goods_title, goods_img, goods_detail, goods_price, goods_stock
    </sql>
	<!-- 获取商品列表 -->
	<select id="findGoodsVo" resultType="com.xxxx.seckill.vo.GoodsVo">
        SELECT
            g.id,
            g.goods_name,
            g.goods_title,
            g.goods_img,
            g.goods_detail,
            g.goods_price,
            g.goods_stock,
            sg.seckill_price,
            sg.stock_count,
            sg.start_date,
            sg.end_date
        FROM
            t_goods g
            LEFT JOIN t_seckill_goods AS sg ON g.id = sg.goods_id
    </select>
	<!-- 获取商品详情 -->
	<select id="findGoodsVoByGoodsId" resultType="com.xxxx.seckill.vo.GoodsVo">
        SELECT
            g.id,
            g.goods_name,
            g.goods_title,
            g.goods_img,
            g.goods_detail,
            g.goods_price,
            g.goods_stock,
            sg.seckill_price,
            sg.stock_count,
            sg.start_date,
            sg.end_date
        FROM
            t_goods g
            LEFT JOIN t_seckill_goods AS sg ON g.id = sg.goods_id
        WHERE
            g.id =#{goodsId}
    </select>
</mapper>

GoodsService以及GoodsServiceImpl

public interface IGoodsService extends IService<Goods> {
	/**
	 * 功能描述: 获取商品列表
	 */
	List<GoodsVo> findGoodsVo();
	/**
	 * 功能描述: 获取商品详情
	 */
	GoodsVo findGoodsVoByGoodsId(Long goodsId);
}
@Service
public class GoodsServiceImpl extends ServiceImpl<GoodsMapper, Goods> implements IGoodsService {
	@Autowired
	private GoodsMapper goodsMapper;
	/**
	 * 功能描述: 获取商品列表
	 */
	@Override
	public List<GoodsVo> findGoodsVo() {
		return goodsMapper.findGoodsVo();
	}
	/**
	 * 功能描述: 获取商品详情
	 */
	@Override
	public GoodsVo findGoodsVoByGoodsId(Long goodsId) {
		return goodsMapper.findGoodsVoByGoodsId(goodsId);
	}
}

GoodsController(后面要加入redis缓存,代码见后)
图片无法访问的情况需要修改此配置类,在WebConfig中加入如下代码:

@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
	}

商品列表页html:

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>商品列表</title>
    <!-- jquery -->
    <script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
    <!-- bootstrap -->
    <link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}"/>
    <script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
    <!-- layer -->
    <script type="text/javascript" th:src="@{/layer/layer.js}"></script>
    <!-- common.js -->
    <script type="text/javascript" th:src="@{/js/common.js}"></script>
</head>
<body>
<div class="panel panel-default">
    <div class="panel-heading">秒杀商品列表</div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td>商品图片</td>
            <td>商品原价</td>
            <td>秒杀价</td>
            <td>库存数量</td>
            <td>详情</td>
        </tr>
        <tr th:each="goods,goodsStat : ${goodsList}">
            <td th:text="${goods.goodsName}"></td>
            <td><img th:src="@{/{photo}(photo=${goods.goodsImg})}" width="100" height="100"/></td>
            <td th:text="${goods.goodsPrice}"></td>
            <td th:text="${goods.seckillPrice}"></td>
            <td th:text="${goods.stockCount}"></td>
            <td><a th:href="'/goodsDetail.htm?goodsId='+${goods.id}">详情</a></td>
        </tr>
    </table>
</div>
</body>
</html>

商品详情页
主要是对秒杀情况的判定:秒杀前,秒杀时,秒杀后。后面因加入redis缓存,故放入部分代码:

Date startDate = goodsVo.getStartDate();
		Date endDate = goodsVo.getEndDate();
		Date nowDate = new Date();
		//秒杀状态
		int secKillStatus = 0;
		//秒杀倒计时
		int remainSeconds = 0;
		//秒杀还未开始
		if (nowDate.before(startDate)) {
			remainSeconds = ((int) ((startDate.getTime() - nowDate.getTime()) / 1000));
		} else if (nowDate.after(endDate)) {
			//	秒杀已结束
			secKillStatus = 2;
			remainSeconds = -1;
		} else {
			//秒杀中
			secKillStatus = 1;
			remainSeconds = 0;
		}

秒杀功能实现
秒杀实现接口:先从数据表中通过秒杀商品的id获取当前秒杀的商品信息,将其库存减一(限制每人只能买一件),并根据SQL语句更新秒杀商品信息,并生成订单。

@Override
@Transactional
public Order seckill(User user, GoodsVo goods) {
//秒杀商品表减库存
		SeckillGoods seckillGoods = seckillGoodsService.getOne(new
		QueryWrapper<SeckillGoods>().eq("goods_id",
		goods.getId()));
		seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
		seckillGoodsService.updateById(seckillGoods);
		//生成订单
		Order order = new Order();
		order.setUserId(user.getId());
		order.setGoodsId(goods.getId());
		order.setDeliveryAddrId(0L);
		order.setGoodsName(goods.getGoodsName());
		order.setGoodsCount(1);
		order.setGoodsPrice(seckillGoods.getSeckillPrice());
		order.setOrderChannel(1);
		order.setStatus(0);
		order.setCreateDate(new Date());
		orderMapper.insert(order);
		//生成秒杀订单
		SeckillOrder seckillOrder = new SeckillOrder();
		seckillOrder.setOrderId(order.getId());
		seckillOrder.setUserId(user.getId());
		seckillOrder.setGoodsId(goods.getId());
		seckillOrderService.save(seckillOrder);
		return order;
}

SeckillController:进行秒杀时,会先判断User是否登录或者当前库存是否不足或者User是否重复抢购,再进行秒杀。

@RequestMapping("/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
		if (user == null) {
			return "login";
		}
		model.addAttribute("user", user);
		GoodsVo goods = goodsService.findGoodsVoByGoodsId(goodsId);
		//判断库存
		if (goods.getStockCount() < 1) {
			model.addAttribute("errmsg", RespBeanEnum.EMPTY_STOCK.getMessage());
			return "seckillFail";
		}
		//判断是否重复抢购
		SeckillOrder seckillOrder = seckillOrderService.getOne(new
			QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq(
			"goods_id",goodsId));
		if (seckillOrder != null) {
			model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
			return "seckillFail";
		}
		Order order = orderService.seckill(user, goods);
		model.addAttribute("order",order);
		model.addAttribute("goods",goods);
		return "orderDetail";
}

优化阶段
缓存优化:避免频繁对数据库的访问,降低系统性能,对频繁读取,变更少的数据放入缓存中,变更多的数据不易放入缓存中,处理缓存一致性问题会比较麻烦。

页面缓存:

@RequestMapping(value = "/toList", produces = "text/html;charset=utf-8")
	@ResponseBody
	public String toList(Model model, User user,
	                     HttpServletRequest request, HttpServletResponse response) {
		//Redis中获取页面,如果不为空,直接返回页面
		ValueOperations valueOperations = redisTemplate.opsForValue();
		String html = (String) valueOperations.get("goodsList");
		if (!StringUtils.isEmpty(html)) {
			return html;
		}
		model.addAttribute("user", user);
		model.addAttribute("goodsList", goodsService.findGoodsVo());
		// return "goodsList";
		//如果为空,手动渲染,存入Redis并返回
		WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(),
				model.asMap());
		html = thymeleafViewResolver.getTemplateEngine().process("goodsList", context);
		if (!StringUtils.isEmpty(html)) {
			valueOperations.set("goodsList", html, 60, TimeUnit.SECONDS);
		}
		return html;
	}
/**
	 * 功能描述: 跳转商品详情页
	 */
	@RequestMapping(value = "/toDetail2/{goodsId}", produces = "text/html;charset=utf-8")
	@ResponseBody
	public String toDetail2(Model model, User user, @PathVariable Long goodsId,
	                        HttpServletRequest request, HttpServletResponse response) {
		ValueOperations valueOperations = redisTemplate.opsForValue();
		//Redis中获取页面,如果不为空,直接返回页面
		String html = (String) valueOperations.get("goodsDetail:" + goodsId);
		if (!StringUtils.isEmpty(html)) {
			return html;
		}
		model.addAttribute("user", user);
		GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
		Date startDate = goodsVo.getStartDate();
		Date endDate = goodsVo.getEndDate();
		Date nowDate = new Date();
		//秒杀状态
		int secKillStatus = 0;
		//秒杀倒计时
		int remainSeconds = 0;
		//秒杀还未开始
		if (nowDate.before(startDate)) {
			remainSeconds = ((int) ((startDate.getTime() - nowDate.getTime()) / 1000));
		} else if (nowDate.after(endDate)) {
			//	秒杀已结束
			secKillStatus = 2;
			remainSeconds = -1;
		} else {
			//秒杀中
			secKillStatus = 1;
			remainSeconds = 0;
		}
		model.addAttribute("remainSeconds", remainSeconds);
		model.addAttribute("secKillStatus", secKillStatus);
		model.addAttribute("goods", goodsVo);
		WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(),
				model.asMap());
		html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", context);
		if (!StringUtils.isEmpty(html)) {
			valueOperations.set("goodsDetail:" + goodsId, html, 60, TimeUnit.SECONDS);
		}
		return html;
	}

秒杀页面静态化,订单详情页面静态化,新建goodsDetail.htm,orderDetail.htm放入static目录下,并修改相应的controller。

库存超卖问题解决:
💥在订单实现类中,通过SQL语句解决,减库存时判断库存是否足够

SeckillGoods seckillGoods = seckillGoodsService.getOne(new QueryWrapper<SeckillGoods>().eq("goods_id",
			goods.getId()));
	seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
	seckillGoodsService.update(new UpdateWrapper<SeckillGoods>().setSql("stock_count = "+
			"stock_count-1").eq(
			"goods_id", goods.getId()).gt("stock_count", 0));

解决同一用户同时秒杀多件商品。
💥可以通过数据库建立唯一索引避免,将用户id和商品id建唯一索引,将秒杀订单信息存入Redis,方便判断是否重复抢购时进行查询

//判断是否重复抢购
		SeckillOrder seckillOrder =
				(SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
		if (seckillOrder != null) {
			return RespBean.error(RespBeanEnum.REPEATE_ERROR);
		}

服务优化
RabbitMQ:
Fanout模式:不处理路由键,只需要简单的将队里绑定到交换机上;发送到交换机的消息都会被转发到与该交换机绑定的所有队列上;Fanout交换机转发消息是最快
Direct模式:所有发送到Direct Exchange的消息被转发到RouteKey中指定的Queue。注意:Direct模式可以使用RabbitMQ自带的Exchange:default Exchange,所以不需要将Exchange进行任何绑定(binding)操作,消息传递时,RouteKey必须完全匹配才会被队列接收,否则该消息会被抛弃。重点:routing key与队列queues 的key保持一致,即可以路由到对应的queue中。
Topic模式:所有发送到Topic Exchange的消息被转发到所有管线RouteKey中指定Topic的Queue上,Exchange将RouteKey和某Topic进行模糊匹配,此时队列需要绑定一个Topic。对于routing key匹配模式定义规则举例如下:routing key为一个句点号 . 分隔的字符串(我们将被句点号 . 分隔开的每一段独立的字符串称为一个单词)“stock.usd.nyse”、“nyse.vmw”、“quick.orange.rabbit”;routing key中可以存在两种特殊字符 * 与 # ,用于做模糊匹配,其中 * 用于匹配一个单词, # 用于匹配多个单词(可以是零个)
Headers模式:不依赖routingkey,使用发送消息时basicProperties对象中的headers匹配队列;headers是一个键值对类型,键值对的值可以是任何类型;在队列绑定交换机时用x-match来指定,all代表定义的多个键值对都要满足,any则代表只要满足一个可以了
采用Topic模式。

接口优化
目的:减少数据库访问

系统初始化,把商品库存数量加载到Redis
收到请求,Redis预减库存。库存不足,直接返回。否则进入第3步
请求入队,立即返回排队中
请求出队,生成订单,减少库存
客户端轮询,是否秒杀成功

/**
	 * 系统初始化,把商品库存数量加载到Redis
	 */
	@Override
	public void afterPropertiesSet() throws Exception {
		List<GoodsVo> list = goodsService.findGoodsVo();
		if (CollectionUtils.isEmpty(list)) {
			return;
		}
		list.forEach(goodsVo -> {
					redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
					EmptyStockMap.put(goodsVo.getId(), false);//用map做内存标记
				}
		);
	}

		//内存标记,减少Redis的访问
		if (EmptyStockMap.get(goodsId)) {
			return RespBean.error(RespBeanEnum.EMPTY_STOCK);
		}
		//预减库存,每次减1
		Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
		if (stock < 0) {
			EmptyStockMap.put(goodsId, true);
			valueOperations.increment("seckillGoods:" + goodsId);
			return RespBean.error(RespBeanEnum.EMPTY_STOCK);
		}
		SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
		mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));//入队

RabbitMQ接收消息后秒杀
RabbitMQ配置类-Topic

@Configuration
public class RabbitMQTopicConfig {
	private static final String QUEUE = "seckillQueue";
	private static final String EXCHANGE = "seckillExchange";
	@Bean
	public Queue queue() {
		return new Queue(QUEUE);
	}
	@Bean
	public TopicExchange topicExchange() {
		return new TopicExchange(EXCHANGE);
	}
	@Bean
	public Binding binding() {
		return BindingBuilder.bind(queue()).to(topicExchange()).with("seckill.#");
	}
}

MQSender

public void sendSeckillMessage(String message) {
		//log.info("发送消息:" + message);
		rabbitTemplate.convertAndSend("seckillExchange", "seckill.message", message);
	}

MQReceiver

@Service
@Slf4j
public class MQReceiver {
	@Autowired
	private IGoodsService goodsService;
	@Autowired
	private RedisTemplate redisTemplate;
	@Autowired
	private IOrderService orderService;

	/**
	 * 下单操作
	 */
	@RabbitListener(queues = "seckillQueue")
	public void receive(String message) {
		//log.info("接收的消息:" + message);
		SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class);
		Long goodId = seckillMessage.getGoodId();
		User user = seckillMessage.getUser();
		//判断库存
		GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodId);
		if (goodsVo.getStockCount() < 1) {
			return;
		}
		//判断是否重复抢购
		SeckillOrder seckillOrder =
				(SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodId);
		if (seckillOrder != null) {
			return;
		}
		//下单操作
		orderService.seckill(user, goodsVo);
	}
}

客户端轮询秒杀结果

@Service
public class SeckillOrderServiceImpl extends ServiceImpl<SeckillOrderMapper, SeckillOrder> implements ISeckillOrderService {
	@Autowired
	private SeckillOrderMapper seckillOrderMapper;
	@Autowired
	private RedisTemplate redisTemplate;
	/**
	 * 功能描述: 获取秒杀结果
	 * @param:
	 * @return:orderId:成功,-1:秒杀失败,0:排队中
	 */
	@Override
	public Long getResult(User user, Long goodsId) {
		SeckillOrder seckillOrder = seckillOrderMapper.selectOne(new QueryWrapper<SeckillOrder>().eq("user_id",
				user.getId()).eq("goods_id",
				goodsId));
		if (null != seckillOrder) {
			return seckillOrder.getOrderId();
		} else if (redisTemplate.hasKey("isStockEmpty:" + goodsId)) {
			return -1L;
		} else {
			return 0L;
		}
	}
}

问题
使用Long stock = valueOperations.decrement(“seckillGoods:” + goodsId);进行预减库存时,多进行几次测试,会发现秒杀商品库存为0时,订单数并没有为秒杀商品的数量,原因在于Redis没有做到原子性。我们采用锁去解决。

Redis分布式锁
采用lua脚本:

Lua脚本优势:使用方便,Redis内置了对Lua脚本的支持;Lua脚本可以在Rdis服务端原子的执行多个Redis命令;由于网络在很大程度上会影响到Redis性能,使用Lua脚本可以让多个命令一次执行,可以有效解决网络给Redis带来的性能问题.
使用Lua脚本思路:提前在Redis服务端写好Lua脚本,然后在java客户端去调用脚本;可以在java客户端写Lua脚本,写好之后,去执行。需要执行时,每次将脚本发送到Redis上去执行
创建Lua脚本(放在resources目录下):

if (redis.call('exists', KEYS[1]) == 1) then
   local stock = tonumber(redis.call('get', KEYS[1]));
   if (stock > 0) then
      redis.call('incrby', KEYS[1], -1);
      return stock;
   end;
    return 0;
end;

RedisConfig调用脚本

@Bean
	public DefaultRedisScript<Long> script() {
		DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
		//放在和application.yml 同层目录下
		redisScript.setLocation(new ClassPathResource("stock.lua"));
		redisScript.setResultType(Long.class);
		return redisScript;
	}

秒杀操作controller:

@RequestMapping(value = "/{path}/doSeckill", method = RequestMethod.POST)
	@ResponseBody
	public RespBean doSeckill(@PathVariable String path, User user, Long goodsId) {
		if (user == null) {
			return RespBean.error(RespBeanEnum.SESSION_ERROR);
		}
		ValueOperations valueOperations = redisTemplate.opsForValue();
		boolean check = orderService.checkPath(user, goodsId, path);
		if (!check) {
			return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
		}
		//判断是否重复抢购
		SeckillOrder seckillOrder =
				(SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
		if (seckillOrder != null) {
			return RespBean.error(RespBeanEnum.REPEATE_ERROR);
		}
		//内存标记,减少Redis的访问
		if (EmptyStockMap.get(goodsId)) {
			return RespBean.error(RespBeanEnum.EMPTY_STOCK);
		}
		//预减库存,lua脚本,原子性操作
		// Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
		Long stock = (Long) redisTemplate.execute(script, Collections.singletonList("seckillGoods:" + goodsId),
				Collections.EMPTY_LIST);
		if (stock < 0) {
			EmptyStockMap.put(goodsId, true);
			valueOperations.increment("seckillGoods:" + goodsId);
			return RespBean.error(RespBeanEnum.EMPTY_STOCK);
		}
		SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
		mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));
		return RespBean.success(0);
}

安全优化
接口地址隐藏:采用用户id+MD5的方式隐藏

图形验证码:采用算术验证码

简单接口限流:计数器算法

通用接口限流:根据ThreadLocal,将当前登录用户放入当前线程,重写preHandle实现限流

总结:
学习这个项目时,发现该项目的代码还是有不少问题。
在OrderServiceImpl中的代码

if (seckillGoods.getStockCount()<1) {
			//判断是否还有库存
			valueOperations.set("isStockEmpty:" + goods.getId(), "0");
			return null;
		}

当库存小于1时,缓存里会加上"isStockEmpty:" + goods.getId()这个key值,最后客户端做轮询时,会通过判断是否有这个key值来决定秒杀结果,

/**
	 * 功能描述: 获取秒杀结果
	 * @param:
	 * @return:orderId:成功,-1:秒杀失败,0:排队中
	 */
	@Override
	public Long getResult(User user, Long goodsId) {
		SeckillOrder seckillOrder = seckillOrderMapper.selectOne(new QueryWrapper<SeckillOrder>().eq("user_id",
				user.getId()).eq("goods_id",
				goodsId));
		if (null != seckillOrder) {
			return seckillOrder.getOrderId();
		} else if (redisTemplate.hasKey("isStockEmpty:" + goodsId)) {
			return -1L;
		} else {
			return 0L;
		}
	}

//客户端做轮询
function getResult(goodsId) {
        g_showLoading();
        $.ajax({
            url: "/seckill/result",
            type: "GET",
            data: {
                goodsId: goodsId,
            },
            success: function (data) {
                if (data.code == 200) {
                    var result = data.obj;
                    if (result < 0) {
                        layer.msg("对不起,秒杀失败!");
                    } else if (result == 0) {
                        setTimeout(function () {
                            getResult(goodsId);
                        }, 50);
                    } else {
                        layer.confirm("恭喜你,秒杀成功!查看订单?", {btn: ["确定", "取消"]},
                            function () {
                                window.location.href = "/orderDetail.htm?orderId=" + result;
                            },
                            function () {
                                layer.close();
                            })
                    }
                }
            },
            error: function () {
                layer.msg("客户端请求错误");
            }
        })
    }

但是在RabbitMQ接收到消息时,已经对库存做了判断,库存小于1的时候就直接返回,程序无法向下执行,比如库存为0时进行秒杀,前端界面就会一直在轮询中,不会出现库存不足的提示

/**
	 * 下单操作
	 */
	@RabbitListener(queues = "seckillQueue")
	public void receive(String message) {
		//log.info("接收的消息:" + message);
		SeckillMessage seckillMessage = JsonUtil.jsonStr2Object(message, SeckillMessage.class);
		Long goodId = seckillMessage.getGoodId();
		User user = seckillMessage.getUser();
		//判断库存
		GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodId);
		if (goodsVo.getStockCount() < 1) {//库存为0时进行秒杀,程序无法向下执行,前端会一直轮询
			return;
		}
		//判断是否重复抢购
		SeckillOrder seckillOrder =
				(SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodId);
		if (seckillOrder != null) {
			return;
		}
		//下单操作
		orderService.seckill(user, goodsVo);
	}

超卖问题,重复买问题
超卖问题:这个项目中是使用了SQL语句来解决,防止库存变为负数

seckillGoodsService.update(newUpdateWrapper<SeckillGoods>().setSql("stock_count = "+
"stock_count-1").eq(
"goods_id", goods.getId()).gt("stock_count", 0));

这用到了MySQL中的排他锁,排他锁就是不能与其他锁并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。类似于在执行update操作的时候,这一行是一个事务(默认加了排他锁)。这一行不能被任何其他线程修改和读写。

重复买问题:数据库加用户ID与商品ID的联合唯一索引,防止用户重复购买。每次抢购成功的用户写进一个表中,然后根据这个表给用户下单,如果是用用户ID与商品ID作为唯一索引时,当用户已经抢购了该商品,写入了该表当中,第二次抢购成功,发现已经有相同的字段就不会再往表格中写,也就不会重复下单了。

其实从这个项目的代码来看,大量用户进行秒杀时,为了解决超卖问题,都会去访问数据库,会对系统性能带来极大影响,而且重复买问题,该项目是一个用户只能买一件商品,所以建了用户ID与商品ID的联合唯一索引,每次下单成功后再把key值放入缓存中,以后就通过判断表单或者缓存中的key值是否为重复抢购,判断表单时还是会访问数据库,也会对 系统性能带来影响。而且每次从秒杀接口doSeckill到MQReceiver的receive,都会判断缓存中的key值是否为重复抢购,还有库存判断,有点显得代码冗余,执行lua脚本也会带来时间消耗。

还有一个极端问题:假设当同一个用户同时对某一个商品发了多个请求,用户还没买入,缓存中没他的信息,这时系统判断用户没买过,可能会出现库存减少多次,程序运行到写入订单时,如果有一次写入成功,事务会发生回滚,这时可能会出现库存减少多个,订单只有一个的状况?

修改方案
采用Redisson里的信号量锁来锁秒杀商品,信号量的大小用秒杀商品的数量来定,用这个来解决超卖问题,当信号量大小为0时,线程就无法获得锁,进行秒杀。

用分布式锁占位,即redisTemplate.opsForValue().setIfAbsent,占位成功说明从来没有买过,失败表示已经订购或准备订购,来解决用户重复买问题。

信号量锁的方法semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);可以通过设置num的值,来表示一个用户可以买num个商品。

秒杀流程:

系统初始化,把商品库存数量加载到redissonClient信号锁

/**
	 * 系统初始化,把商品库存数量加载到redissonClient信号锁
	 */
	@Override
	public void afterPropertiesSet() throws Exception {
		List<GoodsVo> list = goodsService.findGoodsVo();
		if (CollectionUtils.isEmpty(list)) {
			return;
		}
		list.forEach(goodsVo -> {
			//获取分布式锁,使用库存作为信号量
			RSemaphore semaphore = redissonClient.getSemaphore("seckillGoods:" + goodsVo.getId());
			semaphore.trySetPermits(goodsVo.getStockCount());//设置信号量:能秒杀商品数量

				}
		);
	}

通过分布式锁占位,判断是否重复抢购,抢占未成功表示未购买过,执行下一步
获取信号量锁,通过tryAcquire获得,表示要购买几个,成功则表示可以秒杀,执行下一步
将秒杀信息放入消息队列,等待处理

@RequestMapping(value = "/{path}/doSeckill", method = RequestMethod.POST)
	@ResponseBody
	public RespBean doSeckill(@PathVariable String path, User user, Long goodsId) throws InterruptedException {
		if (user == null) {
			return RespBean.error(RespBeanEnum.SESSION_ERROR);
		}
		ValueOperations valueOperations = redisTemplate.opsForValue();
		if (redisTemplate.hasKey("isStockEmpty:" + goodsId)){
			return RespBean.error(RespBeanEnum.EMPTY_STOCK);
		}
		boolean check = orderService.checkPath(user, goodsId, path);
		if (!check) {
			return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
		}
		//判断是否重复抢购
		//通过分布式锁占位,占位成功说明从来没有买过,失败表示已经订购
		String buyKey = "order:" + user.getId() + ":" + goodsId;
		Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(buyKey, 1, 60, TimeUnit.SECONDS);
		if (!aBoolean) {
			return RespBean.error(RespBeanEnum.REPEATE_ERROR);
		}
		//获取信号量
		RSemaphore semaphore = redissonClient.getSemaphore("seckillGoods:" + goodsId);
		//num为可以单个用户可以秒杀的商品,
		int num = 1;
		boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
		//保证b还有库存
		if (b) {
			//创建订单号和订单信息发送给MQ
			SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
			mqSender.sendSeckillMessage(JsonUtil.object2JsonStr(seckillMessage));
			return RespBean.success(0);
		}
		//可获得信号量为0,秒杀商品数量为0
		if (semaphore.availablePermits() == 0) {
			valueOperations.set("isStockEmpty:" + goodsId, "0");
		}
		return RespBean.error(RespBeanEnum.EMPTY_STOCK);
	}

这时候,RabbitMQ里的接收消息就不用再判断库存和重复抢购了,秒杀的实现接口也不用在判断库存,SQL语句只需要执行简单的update,使库存减一,不用再执行判断让库存大于0.

秒杀项目代码以及乐字节以下项目的代码需要的可以私聊我,或者加我q2971104726!欢迎大家来讨论!

在这里插入图片描述

————————————————
转载自: 「yjzzz9」的文章
原文链接:https://blog.csdn.net/yjzzz97/article/details/11799460

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值