电商项目-秒杀系统(一)秒杀业务分析
一、 秒杀业务分析
1.1 需求分析
每一个电商平台都有秒杀,打折促销的相关活 动。一般来说对于秒杀的活动用户的瞬时访问量是非常巨大 的,当到达了某一个时间点后,用户会 瞬间进入到我们的平台中,进行抢购。由于秒杀场景的特殊性,所以一般来说对于秒杀系统都是单独部署的。而且还需要考虑很多的问题,比方说如何进行分布式事务的实现,如何进行分布式锁的实现,如何预防秒杀商品的超卖,如何对秒杀系统进行高可用。对于秒杀系统的高并发,我们应该如何进行操作等等的这些问题。
对于秒杀商品来说,他最少多会存在两个限制分别是:商品库存限制,和时间的限制
秒杀商品通常有两种限制:库存限制、时间限制。
对于我们的项目来说,秒杀系统也是单独部署的。对于秒杀的数据来说,会单独存在秒杀数据库。
需求:
(1)秒杀频道首页列出秒杀商品
(4)点击立即抢购实现秒杀下单,下单时扣减库存。当库存为0或不在活动期范围内时无法秒杀。
(5)秒杀下单成功,直接跳转到支付页面(微信扫码),支付成功,跳转到成功页,填写收货地址、电话、收件人等信息,完成订单。
(6)当用户秒杀下单5分钟内未支付,取消预订单,调用微信支付的关闭订单接口,恢复库存。
1.2 表结构说明
秒杀商品信息表:
CREATE TABLE `tb_seckill_goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`goods_id` bigint(20) DEFAULT NULL COMMENT 'spu ID',
`item_id` bigint(20) DEFAULT NULL COMMENT 'sku ID',
`title` 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) DEFAULT NULL COMMENT '剩余库存数',
`introduction` varchar(2000) DEFAULT NULL COMMENT '描述',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
秒杀订单表:
CREATE TABLE `tb_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;
二、 秒杀商品存入缓存
为什么要把秒杀商品存入缓存?
主要是为了解决两点问题:
第一:我们可以减轻在高并发下的数据库的访问压力,
第二:我们就是为了解决在高并发下的商品超卖的问题,因为Redis他提供了原子性操作即使有很多用户他是同时到达的,但是对于Redis来说,他也是依次进行执行的。所以基于这两点我们将秒杀商品从MySQL中查询出来,存入到缓存中。
对于当前功能的操作流程如下图:
秒杀商品由B端存入Mysql,设置定时任务,每隔一段时间就从Mysql中将符合条件的数据从Mysql中查询出来并存入缓存中,redis以Hash类型进行数据存储。
2.1 秒杀服务搭建
步骤1)新建服务shangcheng_service_seckill
步骤2)添加依赖信息,详情如下:
<dependencies>
<dependency>
<groupId>com.shangcheng</groupId>
<artifactId>shangcheng_common_db</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>com.shangcheng</groupId>
<artifactId>shangcheng_service_order_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.shangcheng</groupId>
<artifactId>shangcheng_service_seckill_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.shangcheng</groupId>
<artifactId>shangcheng_service_goods_api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.amqp</groupId>
<artifactId>spring-rabbit</artifactId>
</dependency>
<!--oauth依赖-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
</dependencies>
步骤 3) 添加启动类
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan(basePackages = {"com.shangcheng.seckill.dao"})
@EnableScheduling
public class SecKillApplication {
public static void main(String[] args) {
//TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
SpringApplication.run(SecKillApplication.class,args);
}
@Bean
public IdWorker idWorker(){
return new IdWorker(1,1);
}
/**
* 设置 redisTemplate 的序列化设置
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
// 1.创建 redisTemplate 模版
RedisTemplate<Object, Object> template = new RedisTemplate<>();
// 2.关联 redisConnectionFactory
template.setConnectionFactory(redisConnectionFactory);
// 3.创建 序列化类
GenericToStringSerializer genericToStringSerializer = new GenericToStringSerializer(Object.class);
// 6.序列化类,对象映射设置
// 7.设置 value 的转化格式和 key 的转化格式
template.setValueSerializer(genericToStringSerializer);
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
步骤 4) 添加application.yml
server:
port: 9011
spring:
jackson:
time-zone: GMT+8
application:
name: seckill
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://192.168.200.128:3306/shangcheng_seckill?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true&serverTimezone=GMT%2b8
username: root
password: root
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
redis:
host: 192.168.200.128
rabbitmq:
host: 192.168.200.128
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
instance:
prefer-ip-address: true
feign:
hystrix:
enabled: true
client:
config:
default: #配置全局的feign的调用超时时间 如果 有指定的服务配置 默认的配置不会生效
connectTimeout: 60000 # 指定的是 消费者 连接服务提供者的连接超时时间 是否能连接 单位是毫秒
readTimeout: 20000 # 指定的是调用服务提供者的 服务 的超时时间() 单位是毫秒
#hystrix 配置
hystrix:
command:
default:
execution:
timeout:
#如果enabled设置为false,则请求超时交给ribbon控制
enabled: true
isolation:
strategy: SEMAPHORE
thread:
# 熔断器超时时间,默认:1000/毫秒
timeoutInMilliseconds: 20000
步骤 5) 添加公钥
步骤 6) 添加Oauth配置类
@Configuration
@EnableResourceServer
//开启方法上的PreAuthorize注解
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "public.key";
/***
* 定义JwtTokenStore
* @param jwtAccessTokenConverter
* @return
*/
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
/***
* 定义JJwtAccessTokenConverter
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
/***
* Http安全配置,对每个到达系统的http请求链接进行校验
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
.anyRequest().
authenticated(); //其他地址需要认证授权
}
}
步骤 7) 更改网关路径过滤类,添加秒杀工程过滤信息
步骤 8) 更改网关配置文件,添加请求路由转发
#秒杀微服务
- id: shangcheng_seckill_route
uri: lb://seckill
predicates:
- Path=/api/seckill/**
filters:
- StripPrefix=1
2.2 时间操作工具类
2.2.1 秒杀商品时间段分析
根据产品原型图结合秒杀商品表设计可以得知,秒杀商品是存在开始时间与结束时间的,当前秒杀商品是按照秒杀时间段进行显示,如果当前时间在符合条件的时间段范围之内,则用户可以秒杀购买当前时间段之内的秒杀商品。
缓存数据加载思路:定义定时任务,每天凌晨会进行当天所有时间段秒杀商品预加载。并且在B端进行限制,添加秒杀商品的话,只能添加当前日期+1的时间限制,比如说:当前日期为8月5日,则添加秒杀商品时,开始时间必须为6日的某一个时间段,否则不能添加。
2.2.2 秒杀商品时间段计算
将资源/DateUtil.java添加到公共服务中。基于当前工具类可以进行时间段的计算。
在该工具类中,进行时间计算测试:
public static void main(String[] args) {
//定义存储结果的集合
List<Date> dateList = new ArrayList<>();
//获取本日凌晨时间点
Date currentData = toDayStartHour(new Date());
//循环12次 (因为要获取每隔两个时间为一个时间段的值)
for (int i=0;i<12;i++){
dateList.add(addDateHour(currentData,i*2));
}
for (Date date : dateList) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String format = simpleDateFormat.format(date);
System.out.println(format);
}
}
测试结果:
2.2.3 当前业务整体流程分析
1.查询所有符合条件的秒杀商品
–1) 获取时间段集合并循环遍历出每一个时间段
–2) 获取每一个时间段名称,用于后续redis中key的设置
–3) 状态必须为审核通过 status=1
–4) 商品库存个数>0
–5) 秒杀商品开始时间>=当前时间段
–6) 秒杀商品结束<当前时间段+2小时
–7) 排除之前已经加载到Redis缓存中的商品数据
–8) 执行查询获取对应的结果集
2.将秒杀商品存入缓存
2.3 代码实现
步骤一: 更改启动类,添加开启定时任务注解
@EnableScheduling
步骤二: 定义定时任务类
秒杀工程新建task包,并新建任务类SeckillGoodsPushTask
业务逻辑:
1)获取秒杀时间段菜单信息
2)遍历每一个时间段,添加该时间段下秒杀商品
2.1)将当前时间段转换为String,作为redis中的key
2.2)查询商品信息(状态为1,库存大于0,秒杀商品开始时间大于当前时间段,秒杀商品结束时间小于当前时间段,当前商品的id不在redis中)
3)添加redis
/**
* 添加秒杀秒伤定时任务
*/
@Component
public class SeckillGoodsPushTask {
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
@Autowired
private RedisTemplate redisTemplate;
private static final String SECKILL_GOODS_KEY="seckill_goods_";
/**
* 定时将秒杀商品存入redis
* 暂定为30秒一次,正常业务为每天凌晨触发
*/
@Scheduled(cron = "0/30 * * * * ?")
public void loadSecKillGoodsToRedis(){
List<Date> dateMenus = DateUtil.getDateMenus();
for (Date dateMenu : dateMenus) {
//每次用最好都重新new
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
SimpleDateFormat simpleDateFormat1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String redisExtName = DateUtil.date2Str(dateMenu);
Example example = new Example(SeckillGoods.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("status","1");
criteria.andGreaterThan("stockCount",0);
criteria.andGreaterThanOrEqualTo("startTime",simpleDateFormat.format(dateMenu));
criteria.andLessThan("endTime",simpleDateFormat1.format(DateUtil.addDateHour(dateMenu,2)));
Set keys = redisTemplate.boundHashOps(SECKILL_KEY + redisExtName).keys();
if (keys!=null && keys.size()>0){
criteria.andNotIn("id",keys);
}
List<SeckillGoods> seckillGoodsList = seckillGoodsMapper.selectByExample(example);
//添加到缓存中
for (SeckillGoods seckillGoods : seckillGoodsList) {
redisTemplate.boundHashOps(SECKILL_KEY + redisExtName).put(seckillGoods.getId(),seckillGoods);
}
}
}
}