[TOC]
1 秒杀业务分析
1.1 需求分析
所谓“秒杀”,就是商家发布一些超低价格的商品,买家在同一时间网上抢购的一种销售方式。通俗讲就是商家为促销等目的组织的网上限时抢购活动。由于商品价格低廉,往往一上架就被抢购一空,有时只用一秒钟,所以成为秒杀
秒杀商品通常有两种限制:库存限制、时间限制。
需求:
(1)录入秒杀商品数据,主要包括:商品标题、原价、秒杀价、商品图片、介绍、秒杀时段等信息
(2)秒杀频道首页列出秒杀商品(进行中的)点击秒杀商品图片跳转到秒杀商品详细页。
(3)商品详细页显示秒杀商品信息,点击立即抢购实现秒杀下单,下单时扣减库存。当库存为0或不在活动期范围内时无法秒杀。
(4)秒杀下单成功,直接跳转到支付页面,支付成功,跳转到成功页,填写收货地址、电话、收件人等信息,完成订单。
(5)当用户秒杀下单5分钟内未支付,取消预订单,调用微信支付的关闭订单接口,恢复库存。
1.2 表结构说明
秒杀商品信息表
CREATE TABLE `seckill_goods_` (
`id_` bigint(20) NOT NULL AUTO_INCREMENT,
`spu_id_` bigint(20) DEFAULT NULL COMMENT 'spu ID',
`sku_id_` bigint(20) DEFAULT NULL COMMENT 'sku ID',
`name_` varchar(100) DEFAULT NULL COMMENT '标题',
`small_pic_` varchar(150) DEFAULT NULL COMMENT '商品图片',
`price_` decimal(10,2) DEFAULT NULL COMMENT '原价格',
`cost_price_` decimal(10,2) DEFAULT NULL COMMENT '秒杀价格',
`seller_id_` varchar(100) DEFAULT NULL COMMENT '商家ID',
`create_time_` datetime DEFAULT NULL COMMENT '添加日期',
`check_time_` datetime DEFAULT NULL COMMENT '审核日期',
`status_` char(1) DEFAULT NULL COMMENT '审核状态,0未审核,1审核通过,2审核不通过',
`start_time_` datetime DEFAULT NULL COMMENT '开始时间',
`end_time_` datetime DEFAULT NULL COMMENT '结束时间',
`num_` int(11) DEFAULT NULL COMMENT '秒杀商品数',
`stock_count_` int(11) unsigned DEFAULT NULL COMMENT '剩余库存数',
`introduction_` varchar(2000) DEFAULT NULL COMMENT '描述',
PRIMARY KEY (`id_`)
) ENGINE=InnoDB AUTO_INCREMENT=11058 DEFAULT CHARSET=utf8;
秒杀订单表
CREATE TABLE `seckill_order_` (
`id_` bigint(20) NOT NULL COMMENT '主键',
`seckill_id_` bigint(20) DEFAULT NULL COMMENT '秒杀商品ID',
`money_` decimal(10,2) DEFAULT NULL COMMENT '支付金额',
`user_id_` varchar(50) DEFAULT NULL COMMENT '用户',
`seller_id_` varchar(50) DEFAULT NULL COMMENT '商家',
`create_time_` datetime DEFAULT NULL COMMENT '创建时间',
`pay_time_` datetime DEFAULT NULL COMMENT '支付时间',
`status_` char(1) DEFAULT NULL COMMENT '状态,0未支付,1已支付',
`receiver_address_` varchar(200) DEFAULT NULL COMMENT '收货人地址',
`receiver_mobile_` varchar(20) DEFAULT NULL COMMENT '收货人电话',
`receiver_` varchar(20) DEFAULT NULL COMMENT '收货人',
`transaction_id_` varchar(30) DEFAULT NULL COMMENT '交易流水',
PRIMARY KEY (`id_`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
1.3 秒杀方案设计
秒杀技术实现核心思想是运用缓存减少数据库瞬间的访问压力!读取商品详细信息时运用缓存,当用户点击抢购时减少缓存中的库存数量,当库存数为0时或活动期结束时,同步到数据库。 产生的秒杀预订单也不会立刻写到数据库中,而是先写到缓存,当用户付款成功后再写入数据库。
当然,上面实现的思路只是一种最简单的方式,并未考虑其中一些问题,例如并发状况容易产生的问题。我们看看下面这张思路更严谨的图:
2 秒杀商品压入缓存
我们这里秒杀商品列表和秒杀商品详情都是从Redis中取出来的,所以我们首先要将符合参与秒杀的商品定时查询出来,并将数据存入到Redis缓存中。
数据存储类型我们可以选择Hash类型。
秒杀分页列表这里可以通过获取redisTemplate.boundHashOps(key).values()获取结果数据。
秒杀商品详情,可以通过redisTemplate.boundHashOps(key).get(key)获取详情。
2.1 秒杀服务工程
我们将商品数据压入到Reids缓存,可以在秒杀工程的服务工程中完成,可以按照如下步骤实现:
1.查询活动没结束的所有秒杀商品
1)计算秒杀时间段
2)状态必须为审核通过 status=1
3)商品库存个数>0
4)date_menu_=秒杀时间段菜单(20210627)
5)在Redis中没有该商品的缓存
6)执行查询获取对应的结果集
2.将活动没有结束的秒杀商品入库
我们首先搭建一个秒杀服务工程,然后按照上面步骤实现。
工程结构:
2.1.1 秒杀父工程
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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>legou-parent</artifactId>
<groupId>com.lxs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>legou-seckill</artifactId>
<packaging>pom</packaging>
<modules>
<module>legou-seckill-interface</module>
<module>legou-seckill-service</module>
</modules>
</project>
2.1.2 秒杀接口工程
legou-seckill/legou-seckill-interface/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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>legou-seckill</artifactId>
<groupId>com.lxs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>legou-seckill-interface</artifactId>
<dependencies>
<dependency>
<groupId>com.lxs</groupId>
<artifactId>legou-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.core.Starter</mainClass>
<layout>ZIP</layout>
<classifier>exec</classifier>
<includeSystemScope>true</includeSystemScope>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
实体类
legou-seckill/legou-seckill-interface/src/main/java/com/lxs/legou/seckill/po/SeckillGoods.java
package com.lxs.legou.seckill.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.lxs.legou.core.po.BaseEntity;
import lombok.Data;
import java.util.Date;
@Data
@TableName("seckill_goods_")
public class SeckillGoods extends BaseEntity {
@TableField("spu_id_")
private Long supId;//spu ID
@TableField("sku_id_")
private Long skuId;//sku ID
@TableField("name_")
private String name;//标题
@TableField("small_pic_")
private String smallPic;//商品图片
@TableField("price_")
private String price;//原价格
@TableField("cost_price_")
private String costPrice;//秒杀价格
@TableField("create_time_")
private Date createTime;//添加日期
@TableField("check_time_")
private Date checkTime;//审核日期
@TableField("status_")
private String status;//审核状态,0未审核,1审核通过,2审核不通过
@TableField("start_time_")
private Date startTime;//开始时间
@TableField("end_time_")
private Date endTime;//结束时间
@TableField("num_")
private Integer num;//秒杀商品数
@TableField("stock_count_")
private Integer stockCount;//剩余库存数
@TableField("introduction_")
private String introduction;//描述
}
legou-seckill/legou-seckill-interface/src/main/java/com/lxs/legou/seckill/po/SeckillOrder.java
package com.lxs.legou.seckill.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.lxs.legou.core.po.BaseEntity;
import lombok.Data;
import java.util.Date;
@Data
@TableName("seckill_order_")
public class SeckillOrder extends BaseEntity {
@TableField("seckill_id_")
private Long seckillId;//秒杀商品ID
@TableField("money_")
private String money;//支付金额
@TableField("user_id_")
private String userId;//用户
@TableField("seller_id_")
private String sellerId;
@TableField("create_time_")
private Date createTime;//创建时间
@TableField("pay_time_")
private Date payTime;//支付时间
@TableField("status_")
private String status;//状态,0未支付,1已支付
@TableField("receiver_address_")
private String receiverAddress;//收货人地址
@TableField("receiver_mobile_")
private String receiverMobile;//收货人电话
@TableField("receiver_")
private String receiver;//收货人
@TableField("transaction_id_")
private String transactionId;//交易流水
}
2.1.3 秒杀微服务工程
legou-seckill/legou-seckill-service/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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>legou-seckill</artifactId>
<groupId>com.lxs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>legou-seckill-service</artifactId>
<dependencies>
<!-- redis 使用-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>com.lxs</groupId>
<artifactId>legou-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.lxs</groupId>
<artifactId>legou-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.46</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.lxs</groupId>
<artifactId>legou-seckill-interface</artifactId>
<version>${project.version}</version>
</dependency>
<!-- swagger -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
</dependencies>
</project>
配置文件
legou-seckill/legou-seckill-service/src/main/resources/bootstrap.yml
spring:
application:
name: seckill-service
config-repo/seckill-service.yml
server:
port: 9011
spring:
redis:
host: 192.168.220.110
port: 6379
jackson:
default-property-inclusion: always
date-format: yyyy-MM-dd HH:mm:00
time-zone: GMT+8
mybatis-plus:
mapper-locations: classpath*:mybatis/*/*.xml
type-aliases-package: com.lxs.legou.*.po
configuration:
# 下划线驼峰转换
map-underscore-to-camel-case: true
lazy-loading-enabled: true
aggressive-lazy-loading: false
logging:
#file: demo.log
pattern:
console: "%d - %msg%n"
level:
org.springframework.web: debug
com.lxs: debug
启动器
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/SeckillApplication.java
package com.lxs.legou.seckill;
import com.lxs.legou.common.utils.IdWorker;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@EnableCircuitBreaker
public class SeckillApplication {
public static void main(String[] args) {
SpringApplication.run(SeckillApplication.class, args);
}
}
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/config/MybatisPlusConfig.java
package com.lxs.legou.seckill.config;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.optimize.JsqlParserCountOptimize;
import com.github.pagehelper.PageInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("com.lxs.legou.seckill.dao")
public class MybatisPlusConfig {
/**
* 分页插件
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
// 开启 count 的 join 优化,只针对 left join !!!
return new PaginationInterceptor().setCountSqlParser(new JsqlParserCountOptimize(true));
}
@Bean
public PageInterceptor pageInterceptor() {
return new PageInterceptor();
}
}
2.2 定时任务
一会儿我们采用Spring的定时任务定时将符合参与秒杀的商品查询出来再存入到Redis缓存,所以这里需要使用到定时任务。
这里我们了解下spring boot定时任务相关的配置,配置步骤如下:
1)在启动器配置开启定时任务@EnableScheduling
2)在定时任务类的指定方法上加上@Scheduled开启定时任务
2)定时任务表达式:使用cron属性来配置定时任务执行时间
2.2.1 定时任务方法配置
配置开启定时任务
@EnableScheduling
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/timer/SeckillGoodsPushTask.java
@Component
public class SeckillGoodsPushTask {
/****
* 每30秒执行一次
*/
@Scheduled(cron = "0/30 * * * * ?")
public void loadGoodsPushRedis(){
System.out.println("task demo");
}
}
2.2.2 定时任务常用时间表达式
0 0 0 2 * ? 每个月2号零点
0 0 0 ? * 3 每个周2号零点
每天的16:00 -> 0 0 16 ?
CronTrigger配置完整格式为: [秒][分] [小时][日] [月][周] [年]
序号 | 说明 | 是否必填 | 允许填写的值 | 允许的通配符 |
---|---|---|---|---|
1 | 秒 | 是 | 0-59 | , - * / |
2 | 分 | 是 | 0-59 | , - * / |
3 | 小时 | 是 | 0-23 | , - * / |
4 | 日 | 是 | 1-31 | , - * ? / L W |
5 | 月 | 是 | 1-12或JAN-DEC | , - * / |
6 | 周 | 是 | 1-7或SUN-SAT | , - * ? / L W |
7 | 年 | 否 | empty 或1970-2099 | , - * / |
使用说明:
通配符说明:
* 表示所有值. 例如:在分的字段上设置 "*",表示每一分钟都会触发。
? 表示不指定值。使用的场景为不需要关心当前设置这个字段的值,第4为何第6为必须有一个是?
例如:要在每月的10号触发一个操作,但不关心是周几,所以需要周位置的那个字段设置为"?" 具体设置为 0 0 0 10 * ?
- 表示区间。例如 在小时上设置 "10-12",表示 10,11,12点都会触发。
, 表示指定多个值,例如在周字段上设置 "MON,WED,FRI" 表示周一,周三和周五触发 12,14,19
/ 用于递增触发。如在秒上面设置"5/15" 表示从5秒开始,每增15秒触发(5,20,35,50)。 在月字段上设置'1/3'所示每月1号开始,每隔三天触发一次。
L 表示最后的意思。在日字段设置上,表示当月的最后一天(依据当前月份,如果是二月还会依据是否是润年[leap]), 在周字段上表示星期六,相当于"7"或"SAT"。如果在"L"前加上数字,则表示该数据的最后一个。例如在周字段上设置"6L"这样的格式,则表示“本月最后一个星期五"
W 表示离指定日期的最近那个工作日(周一至周五). 例如在日字段上设置"15W",表示离每月15号最近的那个工作日触发。如果15号正好是周六,则找最近的周五(14号)触发, 如果15号是周未,则找最近的下周一(16号)触发.如果15号正好在工作日(周一至周五),则就在该天触发。如果指定格式为 "1W",它则表示每月1号往后最近的工作日触发。如果1号正是周六,则将在3号下周一触发。(注,"W"前只能设置具体的数字,不允许区间"-").
# 序号(表示每月的第几个周几),例如在周字段上设置"6#3"表示在每月的第三个周六.注意如果指定"#5",正好第五周没有周六,则不会触发该配置(用在母亲节和父亲节再合适不过了) ;
常用表达式
0 0 10,14,16 * * ? 每天上午10点,下午2点,4点
0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时
0 0 12 ? * WED 表示每个星期三中午12点
"0 0 12 * * ?" 每天中午12点触发
"0 15 10 ? * *" 每天上午10:15触发
"0 15 10 * * ?" 每天上午10:15触发
"0 15 10 * * ? *" 每天上午10:15触发
"0 15 10 * * ? 2005" 2005年的每天上午10:15触发
"0 * 14 * * ?" 在每天下午2点到下午2:59期间的每1分钟触发
"0 0/5 14 * * ?" 在每天下午2点到下午2:55期间的每5分钟触发
"0 0/5 14,18 * * ?" 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发
"0 0-5 14 * * ?" 在每天下午2点到下午2:05期间的每1分钟触发
"0 10,44 14 ? 3 WED" 每年三月的星期三的下午2:10和2:44触发
"0 15 10 ? * MON-FRI" 周一至周五的上午10:15触发
"0 15 10 15 * ?" 每月15日上午10:15触发
"0 15 10 L * ?" 每月最后一日的上午10:15触发
"0 15 10 ? * 6L" 每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6L 2002-2005" 2002年至2005年的每月的最后一个星期五上午10:15触发
"0 15 10 ? * 6#3" 每月的第三个星期五上午10:15触发
2.3 秒杀商品压入缓存实现
2.3.1 数据检索条件分析
1.查询活动没结束的所有秒杀商品
1)计算秒杀时间段
2)状态必须为审核通过 status=1
3)商品库存个数>0
4)秒杀时间段=当前秒杀时间段(2020121716)
5)在Redis中没有该商品的缓存
6)执行查询获取对应的结果集
2.将活动没有结束的秒杀商品入库
上述逻辑对应的伪代码
/**
* select * from seckill_goods_ where
stock_count>0
and `status`='1'
and date_menu_="当前秒杀时间段2020121716"
*/
上面这里会涉及到时间操作,所以这里提前准备了一个时间工具包DateUtil。
2.3.2 时间菜单分析
我们将商品数据从数据库中查询出来,并存入Redis缓存,但页面每次显示的时候,只显示当前正在秒杀以及往后延时2个小时、4个小时、6个小时、8个小时的秒杀商品数据。我们要做的第一个事是计算出秒杀时间菜单,这个菜单是从后台获取的。
这个时间菜单的计算我们来分析下,可以先求出当前时间的凌晨,然后每2个小时后作为下一个抢购的开始时间,这样可以分出12个抢购时间段,如下:
00:00-02:00
02:00-04:00
04:00-06:00
06:00-08:00
08:00-10:00
10:00-12:00
12:00-14:00
14:00-16:00
16:00-18:00
18:00-20:00
20:00-22:00
22:00-00:00
而现实的菜单只需要计算出当前时间在哪个时间段范围,该时间段范围就属于正在秒杀的时间段,而后面即将开始的秒杀时间段的计算也就出来了,可以在当前时间段基础之上+2小时、+4小时、+6小时、+8小时。
关于时间菜单的运算,在给出的DateUtil包里已经实现,代码如下:
package com.lxs.legou.common.utils;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
public class DateUtil {
//时间格式
public static final String PATTERN_YYYYMMDDHH = "yyyyMMddHH";
public static final String PATTERN_YYYY_MM_DDHHMM = "yyyy-MM-dd HH:mm";
/***
* 从yyyy-MM-dd HH:mm格式转成yyyyMMddHH格式
* @param dateStr
* @return
*/
public static String formatStr(String dateStr,String opattern,String npattern){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(opattern);
try {
Date date = simpleDateFormat.parse(dateStr);
simpleDateFormat = new SimpleDateFormat(npattern);
return simpleDateFormat.format(date);
} catch (ParseException e) {
e.printStackTrace();
}
return null;
}
/***
* 获取指定日期的凌晨
* @return
*/
public static Date toDayStartHour(Date date){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
Date start = calendar.getTime();
return start;
}
/***
* 时间增加N分钟
* @param date
* @param minutes
* @return
*/
public static Date addDateMinutes(Date date,int minutes){
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.MINUTE, minutes);// 24小时制
date = calendar.getTime();
return date;
}
/***
* 时间递增N小时
* @param hour
* @return
*/
public static Date addDateHour(Date date,int hour){
//Jota-time
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.HOUR, hour);// 24小时制
date = calendar.getTime();
return date;
}
/***
* 获取时间菜单
* @return
*/
public static List<Date> getDateMenus(){
//定义一个List<Date>集合,存储所有时间段
List<Date> dates = getDates(12);
//判断当前时间属于哪个时间范围
Date now = new Date();
for (Date cdate : dates) {
//开始时间<=当前时间<开始时间+2小时
if(cdate.getTime()<=now.getTime() && now.getTime()<addDateHour(cdate,2).getTime()){
now = cdate;
break;
}
}
//当前需要显示的时间菜单
List<Date> dateMenus = new ArrayList<Date>();
for (int i = 0; i <5 ; i++) {
dateMenus.add(addDateHour(now,i*2));
}
return dateMenus;
}
/***
* 指定时间往后N个时间间隔
* @param hours
* @return
*/
public static List<Date> getDates(int hours) {
List<Date> dates = new ArrayList<Date>();
//循环12次
Date date = toDayStartHour(new Date()); //凌晨
for (int i = 0; i <hours ; i++) {
//每次递增2小时,将每次递增的时间存入到List<Date>集合中
dates.add(addDateHour(date,i*2));
}
return dates;
}
/***
* 时间转成yyyyMMddHH
* @param date
* @param pattern
* @return
*/
public static String data2str(Date date, String pattern){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(pattern);
return simpleDateFormat.format(date);
}
}
2.3.3 秒杀商品存入Reids
我们可以写个定时任务,查询从当前时间开始,往后延续4个时间菜单间隔,也就是一共只查询5个时间段抢购商品数据,并压入缓存,实现代码如下:
修改SeckillGoodsPushTask的loadGoodsPushRedis方法,代码如下:
1.查询活动没结束的所有秒杀商品
1)计算秒杀时间段
2)状态必须为审核通过 status=1
3)商品库存个数>0
4)秒杀时间段=当前秒杀时间段(2020121716)
5)在Redis中没有该商品的缓存
6)执行查询获取对应的结果集
2.将活动没有结束的秒杀商品入库
上述逻辑对应的伪代码
/**
* select * from seckill_goods_ where
stock_count>0
and `status`='1'
and date_menu_="当前秒杀时间段2020121716"
*/
代码实现:
package com.lxs.legou.seckill.task;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.lxs.legou.common.utils.DateUtil;
import com.lxs.legou.common.utils.SystemConstants;
import com.lxs.legou.seckill.dao.SeckillGoodsDao;
import com.lxs.legou.seckill.po.SeckillGoods;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
import java.util.Set;
//@Component
public class SeckillGoodsPushTask {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SeckillGoodsDao seckillGoodsDao;
/**
* 定时任务:每个30秒执行一次
*/
@Scheduled(cron = "0/30 * * * * ?")
public void loadGoodsPushRedis() {
//1 获取当前时间后的秒杀时间间隔(5)
List<Date> dateMenus = DateUtil.getDateMenus();
for (Date dateMenu : dateMenus) {
String extName = DateUtil.data2str(dateMenu, DateUtil.PATTERN_YYYYMMDDHH);//2020122110
/**
* select * from seckill_goods_ where
stock_count_>0
and `status_`='1'
and date_menu_="当前秒杀时间段2020121716"
and id_ not in (redis中已有id)
*/
QueryWrapper<SeckillGoods> query = Wrappers.<SeckillGoods>query();
query.gt("stock_count_", 0);
query.eq("status_", 1);
query.eq("date_menu_", extName);
//现有的redis中的秒杀商品id
Set keys = redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + extName).keys();
if (keys != null && keys.size() >0) {
query.notIn("id_", keys);
}
List<SeckillGoods> seckillGoods = seckillGoodsDao.selectList(query);
for (SeckillGoods seckillGood : seckillGoods) {
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + extName).put(seckillGood.getId(), seckillGood);
//设置有效期(2小时)
redisTemplate.expireAt(SystemConstants.SEC_KILL_GOODS_PREFIX + extName, DateUtil.addDateHour(starttime, 2));
}
}
}
}
Redis数据如下:
3 秒杀频道页
秒杀频道首页,显示正在秒杀的和未开始秒杀的商品(已经开始或者还没开始,未结束的秒杀商品)
3.1 秒杀时间菜单
如上图,时间菜单需要根据当前时间动态加载,时间菜单的计算上面功能中已经实现,在DateUtil工具包中。我们只需要将时间菜单获取,然后响应到页面,页面根据对应的数据显示即可。
创建com.changgou.seckill.controller.SeckillGoodsController,并添加菜单获取方法,代码如下:
package com.lxs.legou.seckill.controller;
import com.lxs.legou.common.utils.DateUtil;
import com.lxs.legou.core.controller.BaseController;
import com.lxs.legou.seckill.po.SeckillGoods;
import com.lxs.legou.seckill.pojo.SeckillStatus;
import com.lxs.legou.seckill.service.ISeckillGoodsService;
import com.lxs.legou.seckill.service.ISeckillOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.List;
@RestController
@RequestMapping("/seckill-goods")
@CrossOrigin
public class SeckillGoodsController extends BaseController<ISeckillGoodsService, SeckillGoods> {
@Autowired
private ISeckillGoodsService seckillGoodsService;
/**
* 获取当前的时间基准的5个时间段
* @return
*/
@GetMapping("/menus")
public List<Date> datemenus() {
List<Date> dateMenus = DateUtil.getDateMenus();
for (Date date : dateMenus) {
System.out.println(date);
}
return dateMenus;
}
}
使用Postman测试,效果如下:
http://localhost:9011/seckill-goods/menus
3.2 秒杀频道页
秒杀频道页是指将对应时间间隔的秒杀商品从Reids缓存中查询出来,并到页面显示。对应时区秒杀商品存储的时候以Hash类型进行了存储,key=SeckillGoods_2019010112,value=每个商品详情。
每次用户在前端点击对应时间菜单的时候,可以将时间菜单的开始时间以yyyyMMddHH格式提交到后台,后台根据时间格式查询出对应时区秒杀商品信息。
3.2.1 业务层
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/service/ISeckillGoodsService.java
package com.lxs.legou.seckill.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.seckill.po.SeckillGoods;
import java.util.List;
public interface ISeckillGoodsService extends ICrudService<SeckillGoods> {
/***
* 获取指定时间对应的秒杀商品列表
* @param key
*/
List<SeckillGoods> list(String key);
}
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/service/impl/SeckillGoodsServiceImpl.java
package com.lxs.legou.seckill.service.impl;
import com.lxs.legou.common.utils.SystemConstants;
import com.lxs.legou.core.service.impl.CrudServiceImpl;
import com.lxs.legou.seckill.po.SeckillGoods;
import com.lxs.legou.seckill.service.ISeckillGoodsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class SeckillGoodsServiceImpl extends CrudServiceImpl<SeckillGoods> implements ISeckillGoodsService {
@Autowired
private RedisTemplate redisTemplate;
/***
* Redis中根据Key获取秒杀商品列表
* @param key
* @return
*/
@Override
public List<SeckillGoods> list(String key) {
return redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + key).values();
}
}
定义常量的类
legou-common/src/main/java/com/lxs/legou/common/utils/SystemConstants.java
package com.lxs.legou.common.utils;
public class SystemConstants {
/**
* 秒杀商品存储到前缀的KEY
*/
public static final String SEC_KILL_GOODS_PREFIX="SeckillGoods_";
/**
* 存储域订单的hash的大key
*/
public static final String SEC_KILL_ORDER_KEY="SeckillOrder";
/**
* 用户排队的队列的KEY
*/
public static final String SEC_KILL_USER_QUEUE_KEY="SeckillOrderQueue";
/**
* 用户排队标识的key (用于存储 谁 买了什么商品 以及抢单的状态)
*/
public static final String SEC_KILL_USER_STATUS_KEY = "UserQueueStatus";
/**
* 用于防止重复排队的hash的key 的值
*/
public static final String SEC_KILL_QUEUE_REPEAT_KEY="UserQueueCount";
/**
* 防止超卖的问题的 队列的key
*/
public static final String SEC_KILL_CHAOMAI_LIST_KEY_PREFIX="SeckillGoodsCountList_";
/**
* 所有的商品计数的大的key(用于存储所有的 商品 对应的 库存 数据)
*
* bigkey field1(商品ID 1) value(库存数2)
* field1(商品ID 2) value(库存数5)
*/
public static final String SECK_KILL_GOODS_COUNT_KEY = "SeckillGoodsCount";
}
3.2.2 控制层
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/controller/SeckillGoodsController.java
@Autowired
private ISeckillGoodsService seckillGoodsService;
/**
* 根据时间段(2019090516) 查询该时间段的所有的秒杀的商品
* @param time
* @return
*/
@RequestMapping("/list/{time}")
public List<SeckillGoods> list(@PathVariable("time") String time){
List<SeckillGoods> seckillGoodsList = seckillGoodsService.list(time);
for (SeckillGoods seckillGoods : seckillGoodsList) {
System.out.println("id是:"+seckillGoods.getId());
}
return seckillGoodsList;
}
测试
4 秒杀详情页
通过秒杀频道页点击抢购按钮,会跳转到商品秒杀详情页,秒杀详情页需要根据商品ID查询商品详情,我们可以在频道页点击秒杀抢购的时候将ID一起传到后台,然后根据ID去Redis中查询详情信息。
4.1 业务层
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/service/ISeckillGoodsService.java
/****
* 根据ID查询商品详情
* @param time:时间区间
* @param id:商品ID
*/
SeckillGoods one(String time,Long id);
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/service/impl/SeckillGoodsServiceImpl.java
/****
* 根据商品ID查询商品详情
* @param time:时间区间
* @param id:商品ID
* @return
*/
@Override
public SeckillGoods one(String time, Long id) {
return (SeckillGoods) redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + time).get(id);
}
4.2 控制层
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/controller/SeckillGoodsController.java
/**
* 根据时间段 和秒杀商品的ID 获取商的数据
* @param time
* @param id
* @return
*/
@GetMapping("/one")
public SeckillGoods one(String time,Long id){
SeckillGoods seckillGoods = seckillGoodsService.one(time, id);
return seckillGoods;
}
5 下单实现
用户下单,为了提升下单速度,我们将订单数据存入到Redis缓存中,如果用户支付了,则将Reids缓存中的订单存入到MySQL中,并清空Redis缓存中的订单。
5.1 业务层
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/service/ISeckillOrderService.java
package com.lxs.legou.seckill.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.seckill.po.SeckillOrder;
import com.lxs.legou.seckill.pojo.SeckillStatus;
public interface ISeckillOrderService extends ICrudService<SeckillOrder> {
/***
* 添加秒杀订单
* @param id:商品ID
* @param time:商品秒杀开始时间
* @param username:用户登录名
* @return
*/
Boolean add(Long id, String time, String username);
}
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/service/impl/SeckillOrderServiceImpl.java
package com.lxs.legou.seckill.service.impl;
import com.lxs.legou.common.utils.IdWorker;
import com.lxs.legou.common.utils.SystemConstants;
import com.lxs.legou.core.service.impl.CrudServiceImpl;
import com.lxs.legou.seckill.dao.SeckillGoodsDao;
import com.lxs.legou.seckill.po.SeckillOrder;
import com.lxs.legou.seckill.pojo.SeckillStatus;
import com.lxs.legou.seckill.service.ISeckillOrderService;
import com.lxs.legou.seckill.task.MultiThreadingCreateOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class SeckillOrderServiceImpl extends CrudServiceImpl<SeckillOrder> implements ISeckillOrderService {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SeckillGoodsDao seckillGoodsDao;
@Autowired
private IdWorker idWorker;
/****
* 添加订单
* @param id
* @param time
* @param username
*/
@Override
public Boolean add(Long id, String time, String username) {
//获取商品数据
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + time).get(id);
//如果没有库存,则直接抛出异常
if (goods == null || goods.getStockCount() <= 0) {
throw new RuntimeException("已售罄!");
}
//如果有库存,则创建秒杀商品订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setId(idWorker.nextId());
seckillOrder.setSeckillId(id);
seckillOrder.setMoney(goods.getCostPrice());
seckillOrder.setUserId(username);
seckillOrder.setCreateTime(new Date());
seckillOrder.setStatus("0");
//将秒杀订单存入到Redis中
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_ORDER_KEY).put(username, seckillOrder);
//库存减少
goods.setStockCount(goods.getStockCount() - 1);
//判断当前商品是否还有库存
if (goods.getStockCount() <= 0) {
//并且将商品数据同步到MySQL中
seckillGoodsDao.updateById(goods);
//如果没有库存,则清空Redis缓存中该商品
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + time).delete(id);
} else {
//如果有库存,则直数据重置到Reids中
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + time).put(id, goods);
}
}
}
5.2 控制层
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/controller/SeckillOrderController.java
package com.lxs.legou.seckill.controller;
import com.lxs.legou.core.controller.BaseController;
import com.lxs.legou.seckill.po.SeckillOrder;
import com.lxs.legou.seckill.pojo.SeckillStatus;
import com.lxs.legou.seckill.service.ISeckillOrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/seckill-order")
@CrossOrigin
public class SeckillOrderController extends BaseController<ISeckillOrderService, SeckillOrder> {
@Autowired
private ISeckillOrderService seckillOrderService;
@RequestMapping(value = "/add")
public ResponseEntity add(String username, String time, Long id){
try {
//调用Service增加订单
Boolean bo = seckillOrderService.add(id, time, username);
if(bo){
//抢单成功
return ResponseEntity.ok("抢单成功");
}
} catch (Exception e) {
e.printStackTrace();
}
return new ResponseEntity("服务器繁忙,请稍后再试", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
问题分析:
上述功能完成了秒杀抢单操作,但没有解决并发相关的问题,例如并发、超卖现象,这块甚至有可能产生雪崩问题。
6 多线程抢单
6.1 多线程抢单方案
在审视秒杀中,操作一般都是比较复杂的,而且并发量特别高,比如,检查当前账号操作是否已经秒杀过该商品,检查该账号是否存在存在刷单行为,记录用户操作日志等。
下订单这里,我们一般采用多线程下单,但多线程中我们又需要保证用户抢单的公平性,也就是先抢先下单。我们可以这样实现,用户进入秒杀抢单,如果用户复合抢单资格,只需要记录用户抢单数据,存入队列,多线程从队列中进行消费即可,存入队列采用左压,多线程下单采用右取的方式。
队列削峰
6.2 异步实现
要想使用Spring的异步操作,需要先开启异步操作,用@EnableAsync
注解开启,然后在对应的异步方法上添加注解@Async
即可。
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/task/MultiThreadingCreateOrder.java
@Component
public class MultiThreadingCreateOrder {
/***
* 多线程下单操作
*/
@Async
public void createOrder(){
try {
System.out.println("准备执行....");
Thread.sleep(20000);
System.out.println("开始执行....");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
上面createOrder方法进行了休眠阻塞操作,我们在下单的方法调用createOrder方法,如果下单的方法没有阻塞,继续执行,说明属于异步操作,如果阻塞了,说明没有执行异步操作。
修改秒杀抢单SeckillOrderServiceImpl代码,注入MultiThreadingCreateOrder,并调用createOrder方法,代码如下:
使用Postman测试如下:
6.3 多线程抢单
用户每次下单的时候,我们都让他们先进行排队,然后采用多线程的方式创建订单,排队我们可以采用Redis的队列实现,多线程下单我们可以采用Spring的异步实现。
6.3.1 多线程下单
将之前下单的代码全部挪到多线程的方法中,MultiThreadingCreateOrder.java类的方法值负责调用即可,代码如下:
package com.lxs.legou.seckill.task;
import com.lxs.legou.common.utils.IdWorker;
import com.lxs.legou.common.utils.SystemConstants;
import com.lxs.legou.seckill.dao.SeckillGoodsDao;
import com.lxs.legou.seckill.po.SeckillGoods;
import com.lxs.legou.seckill.po.SeckillOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class MultiThreadingCreateOrder {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private SeckillGoodsDao seckillGoodsDao;
@Autowired
private IdWorker idWorker;
/**
* 多线程下单操作
*/
@Async
public void createOrder(Long id, String time, String username) {
//获得秒杀商品信息
SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + time).get(id);
//如果没有库存,直接抛出异常
if (goods == null || goods.getStockCount() <=0) {
throw new RuntimeException("已售罄");
}
//将秒杀订单存入redis
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setId(idWorker.nextId());
seckillOrder.setSeckillId(id);
seckillOrder.setMoney(goods.getCostPrice());
seckillOrder.setUserId(username);
seckillOrder.setCreateTime(new Date());
seckillOrder.setStatus("0");
//模拟下单耗时操作
try {
System.out.println("*********************开始模拟下单操作***************************");
Thread.sleep(10000);
System.out.println("*********************结束模拟下单操作***************************");
} catch (InterruptedException e) {
e.printStackTrace();
}
/**
* hash -> namespace = SeckillOrder
* - key value
* username seckillOrder
*/
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_ORDER_KEY).put(username, seckillOrder);
//库存递减
goods.setStockCount(goods.getStockCount() - 1);
if (goods.getStockCount() <= 0) {
//库存为0,同步到mysql
seckillGoodsDao.updateById(goods);
//清楚redis中的秒杀商品
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + time).delete(id);
} else {
//同步库存到redis
redisTemplate.boundHashOps(SystemConstants.SEC_KILL_GOODS_PREFIX + time).put(id, goods);
}
}
}
此时测试,是可以正常下单的,但是用户名和订单都写死了,此处需要继续优化。
6.3.2 排队下单
6.3.2.1 排队信息封装
用户每次下单的时候,我们可以创建一个队列进行排队,然后采用多线程的方式创建订单,排队我们可以采用Redis的队列实现。 排队信息中需要有用户抢单的商品信息,主要包含商品ID,商品抢购时间段,用户登录名。我们可以设计个javabean,如下:
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/pojo/SeckillStatus.java
package com.lxs.legou.seckill.pojo;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
public class SeckillStatus implements Serializable {
//秒杀用户名
private String username;
//创建时间
private Date createTime;
//秒杀状态 1:排队中,2:秒杀等待支付,3:支付超时,4:秒杀失败,5:支付完成
private Integer status;
//秒杀的商品ID
private Long goodsId;
//应付金额
private Float money;
//订单号
private Long orderId;
//时间段
private String time;
public SeckillStatus() {
}
public SeckillStatus(String username, Date createTime, Integer status, Long goodsId, String time) {
this.username = username;
this.createTime = createTime;
this.status = status;
this.goodsId = goodsId;
this.time = time;
}
}
6.3.2.2 排队实现
我们可以将秒杀抢单信息存入到Redis中,这里采用List方式存储,List本身是一个队列,用户点击抢购的时候,就将用户抢购信息存入到Redis中,代码如下:
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/service/impl/SeckillOrderServiceImpl.java
/****
* 添加订单
* @param id
* @param time
* @param username
*/
@Override
public Boolean add(Long id, String time, String username) {
//排队信息封装
SeckillStatus seckillStatus = new SeckillStatus(username, new Date(),1, id,time);
//将秒杀抢单信息存入到Redis中,这里采用List方式存储,List本身是一个队列
redisTemplate.boundListOps( SystemConstants.SEC_KILL_USER_QUEUE_KEY).leftPush(seckillStatus);
//多线程操作
multiThreadingCreateOrder.createOrder();
return true;
}
}
多线程每次从队列中获取数据,分别获取用户名和订单商品编号以及商品秒杀时间段,进行下单操作,代码如下:
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/task/MultiThreadingCreateOrder.java
/***
* 多线程下单操作
*/
@Async
public void createOrder(){
//从队列中获取排队信息
SeckillStatus seckillStatus = (SeckillStatus) redisTemplate.boundListOps(SystemConstants.SEC_KILL_USER_QUEUE_KEY).rightPop();
try {
if(seckillStatus!=null){
//时间区间
String time = seckillStatus.getTime();
//用户登录名
String username=seckillStatus.getUsername();
//用户抢购商品
Long id = seckillStatus.getGoodsId();
//下单略。。。。。
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
7 防止秒杀重复排队
用户每次抢单的时候,一旦排队,我们设置一个自增值,让该值的初始值为1,每次进入抢单的时候,对它进行递增,如果值>1,则表明已经排队,不允许重复排队,如果重复排队,则对外抛出异常,并抛出异常信息100表示已经正在排队。
修改SeckillOrderServiceImpl的add方法,新增递增值判断是否排队中,代码如下:
上图代码如下:
/*
在redis中存储用户是否抢单,结构:
namespace UserQueueCount
- username 次数
*/
Long userQueueCount = redisTemplate.boundHashOps(SystemConstants.SEC_KILL_QUEUE_REPEAT_KEY).increment(username, 1);
//判断 是否大于1 如果是,返回 ,否则 就放行 重复了.
if(userQueueCount > 1){
throw new RuntimeException("20006");
}
8 分布式锁
8.1 为什么要使用分布式锁
超卖问题,这里是指多人抢购同一商品的时候,多人同时判断是否有库存,如果只剩一个,则都会判断有库存,此时会导致超卖现象产生,也就是一个商品下了多个订单的现象,解决超卖问题可以使用分布式锁得方案
我们在开发应用的时候,如果需要对某一个共享变量进行多线程同步访问的时候,可以使用我们学到的Java多线程的18般武艺进行处理,并且可以完美的运行,毫无Bug!
注意这是单机应用,也就是所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个JVM内部的一块内存空间!
后来业务发展,需要做集群,或者微服务架构,一个应用需要部署到几台机器上然后做负载均衡,大致如下图:
上图可以看到,变量A存在JVM1、JVM2、JVM3三个JVM内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的!即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!
如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题!
为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
8.2 分布式锁应该具备哪些条件
在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备可重入特性;
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
8.3 基于数据库的实现方式
8.3.1 基于数据库表
基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。
- 创建一个表:
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
`desc` varchar(255) NOT NULL COMMENT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
- 想要执行某个方法,就使用这个方法名向表中插入数据:
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
因为我们对method_name
做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
- 成功插入则获取锁,执行完成后删除对应的行数据释放锁:
delete `_name ='methodName';
- 总结:
上面这种简单的实现有以下几个问题:
- 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
当然,我们也可以有其他方式解决上面的问题。
- 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
- 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
- 非阻塞的?搞一个while循环,直到insert成功再返回成功。
- 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
8.3.2 基于数据库排他锁
- 原理:
除了可以通过增删操作数据表中的记录以外,其实还可以借助数据库中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:
在查询语句后面增加for update
,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
通过connection.commit()
操作来释放锁。
- 演示
在Mysql中演示排它锁实现分布式锁。
启动2个窗口分别执行如下命令
docker exec -it lg_mysql /bin/bash
mysql -u root -p root
start transaction;
select * from user_ where id_ = 15 for update;
commit;
可以看到只有一个事务提交后,另一个事务才能获得锁。
- 总结:
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
- 阻塞锁?
for update
语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。 - 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但是还是无法直接解决数据库单点问题。
这里还可能存在另外一个问题,虽然我们对method_name
使用了唯一索引,并且显示使用for update
来使用行级锁。但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。
还有一个问题,就是我们要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆
8.3.3 总结
总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
数据库实现分布式锁的优点
- 直接借助数据库,容易理解。
数据库实现分布式锁的缺点
会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销,性能问题需要考虑。
使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。
8.4 基于Redis的实现方式
8.4.1 Redis实现分布式锁优点
(1)Redis有很高的性能;
(2)Redis命令对此支持较好,实现起来比较方便
8.4.2 命令介绍
(1)SETNX
SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
(2)expire
expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
(3)delete
delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。
8.4.3 实现思想
(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
(2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
8.4.4 总结
可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如Tair的put方法,redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。
使用缓存实现分布式锁的优点
- 性能好,实现起来较为方便。
使用缓存实现分布式锁的缺点
- 通过超时时间来控制锁的失效时间并不是十分的靠谱。
后面项目中采用的Redisson,就是采用redis实现分布式锁
Redisson是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多,也非常强大,此处我们只用它的分布式锁功能。
8.5 基于ZooKeeper的实现方式
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,是Apache Hadoop 的一个子项目,它主要是用来解决分布式应用中经常遇到的一些数据管理问题,如:统一命名服务、状态同步服务、集群管理、分布式应用配置项的管理等。
上面的解释有点抽象,简单来说zookeeper=文件系统+监听通知机制。
8.5.1 实现分析
- 创建一个目录mylock;
- 线程A想获取锁就在mylock目录下创建临时顺序节点;
- 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
- 线程B获取所有节点,判断自己不是最小节点,设置监听比自己小的节点;
- 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
来看下Zookeeper能不能解决前面提到的问题。
- 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
- 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
- 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
- 单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
可以直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。
Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。
使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。
其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)
8.5.2 总结
使用Zookeeper实现分布式锁的优点
- 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
使用Zookeeper实现分布式锁的缺点
- 性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解,比较复杂
8.6 分布式锁总结
上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
从理解的难易程度角度(从低到高)
- 数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
- Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
- 缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
- Zookeeper > 缓存 > 数据库
9 Redisson
Redisson是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多,也非常强大,此处我们只用它的分布式锁功能,来避免超卖问题
9.1 超卖问题分析
多线程抢单,会产生超卖问题
这里采用Redisson框架,基于redis实现分布式锁,解决超卖问题
9.2 Redisson分布式锁基本用法
9.2.1 引入依赖
legou-seckill/legou-seckill-service/pom.xml
<!-- redis 使用-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.14.0</version>
</dependency>
9.2.2 配置文件
config-repo/seckill-service.yml
spring:
redis:
host: 192.168.220.110
port: 6379
9.2.3 代码实现
legou-seckill/legou-seckill-service/src/main/java/com/lxs/legou/seckill/task/MultiThreadingCreateOrder.java
秒杀下单前加锁
下单结束释放锁
测试结果可以看到,库存stockCount=2时,只能产生两个订单
10 下单支付(作业)
秒杀下单支付,包括下单后延迟5分钟未支付取消订单回滚库存,和之前的订单延迟队列实习方案相同,只是把MySql操作换成redis而已,这里流程作业大家自行完成