1. 在GoodsController中定义seckill方法对秒杀请求进行处理,在处理的时候需要进行一些判断
//执行秒杀
@PostMapping("/seckill/goods/{random}/{id}")
public @ResponseBody ReturnObject seckill(@PathVariable("random") String random,@PathVariable("id") Integer id){
ReturnObject returnObject = new ReturnObject();
return returnObject;
}
2. 请求参数random合法性验证,我们这里采用的是长度判断,有些公司将random的某个位置值固定,判断是否为那个值
//1.random参数合法性验证,我们这里采用的是长度判断,有些公司将random的某个位置值固定,判断是否为那个值
if(random.length() != 36){
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("请求参数有误");
return returnObject;
}
3. 根据商品id从Redis中查询出缓存的商品,判断请求参数random和商品的randomName是否匹配
//2.根据商品id从Redis中查询出缓存的商品,判断请求参数random和商品的randomName是否匹配
String goodsJSON = redisTemplate.opsForValue().get(Constants.REDIS_GOODS+id);
Goods goods = JSONObject.parseObject(goodsJSON,Goods.class);
if (!random.equals(goods.getRandomname())){
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("请求参数有误");
return returnObject;
}
4. 为了保险起见,我们再次验证一下是否在秒杀时间内(这步可以省略)
这里既没有操作磁盘,也没有操作数据库,也没有走网络,所以不会对性能产生影响
//3.为了保险起见,我们再次验证一下是否在秒杀时间内
Long currentTime = System.currentTimeMillis();
Long startTime = goods.getStarttime().getTime();
Long endTime = goods.getEndtime().getTime();
if(currentTime < startTime){
//秒杀尚未开始
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("秒杀尚未开始");
return returnObject;
}else if(currentTime > endTime){
//秒杀已经结束
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("秒杀已经结束");
return returnObject;
}else{
//如果秒杀已经开始,处理业务继续写在这里
return returnObject;
}
5. 如果已经开始秒杀验证商品是否已经卖光
需求:
如果商品已经卖光,那么提示用户,不能参与秒杀了
常规思路
● 直接查询数据库中商品的库存,如果直接操作数据库,秒杀场景,高并发大流量会给数据库带来很大的压力。
● 从Redis中缓存的商品信息中获取,但是后续秒杀结束后,涉及对库存做修改,操作Redis的商品信息比较麻烦。另外,如果我们5秒缓存预热一次,数据库中商品的库存还没有修改,会被再次把数据库中的库存更新到Redis中。
解决方案
所以我们在缓存预热的时候,直接将商品的库存单独存放到Redis中。并且这个信息需要在缓存预热的时候生成,而且只能生成一次,因为我们减库存我的时候,也是操作Redis,数据库暂时不会变,如果每5秒初始化一次,那么会将数据库的原始库存又初始化到Redis中。
设置值的时候使用setIfAbsent方法
如果key不存在,那么设置值,如果已经存在,不对其进行设置值了
在15-seckill-service缓存预热的定时任务中缓存商品库存
Key的格式 redis:store:商品id Value的值:就是商品的库存
/**
* 把数据库中商品的库存也预热到Redis
* 注意:这里只能放一次,因为我们减库存我的时候,也是操作Redis,数据库暂时不会变
* 如果每5秒初始化一次,那么会将数据库的原始库存又初始化到Redis中
* setIfAbsent:如果key不存在,那么设置值,如果已经存在,不对其进行设置值了
*/
redisTemplate.opsForValue()
.setIfAbsent(Constants.REDIS_STORE + goods.getId(),String.valueOf(goods.getStore()));
在15-seckill-interface的Constants常量类下定义商品库存key的前缀
/**
* 定义Redis中商品库存的key的前缀
* Redis中存放商品库存的格式:redis:goods:商品id
*/
public static final String REDIS_STORE = "redis:store:";
重新运行15-seckill-service,通过Redis DeskTop Manager查看Redis数据
在GoodsControll编写验证商品是否卖光代码
//4.验证商品是否已经卖光了
//根据商品id,从Redis中获取商品库存
String redisStore = redisTemplate.opsForValue().get(Constants.REDIS_STORE + id);
//判断是否为空 如果不为空将redis存放的库存转换为整形
Integer store = StringUtils.isEmpty(redisStore)? 0 :Integer.valueOf(redisStore);
//其实不会出现小于0的情况
if(store <= 0 ){
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("来晚了,商品已经抢光了");
return returnObject;
}
为了对String操作更加方便,在15-seckill-web中引入commons-lang的依赖
<!--对常用类操作的增强包-->
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.5</version>
</dependency>
6. 验证该用户是否已经秒杀过该商品
需求
同一件商品,同一个用户只能秒杀一次
常规思路
去数据库订单表中查询,是否有用户对该商品的下单信息,但是秒杀场景,高并发大流量下,会给数据库带来很大的压力
解决方案
我们这里还是查询采用Redis,如果用户秒杀了该商品,那就将用户信息及商品信息组合放到Redis中,生成一条秒杀记录,然后再秒杀的时候,从Redis中取数据进行判断
格式:redis:buy:id:uid
在15-seckill-interface的Constants常量中添加用户是否购买过商品的key的前缀
/**
* 定义Redis中用户是否买过该商品的key的前缀
* Redis中存放用户是否买过该商品的格式:redis:buy:商品id:用户id
*/
public static final String REDIS_BUY = "redis:buy:";
在15-seckill-web的GoodsController中编写验证是否买过该商品的代码
//5.验证用户是否买过该商品
//假设用户的id为888888,实际开发的使用用户的id可以从session中获取
Integer uid = 888888;
String redisBuy = redisTemplate.opsForValue().get(Constants.REDIS_BUY + id +":"+ uid);
//这里我们不需要关心redisBuy中放了什么,只要不为空,就说明用户买个该商品
if(StringUtils.isNotEmpty(redisBuy)){
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("您已经秒杀过该商品了,请换个商品秒杀");
return returnObject;
}
7. 限流
需求
在秒杀场景中,为每一个商品限制最大的参与抢购的人数为10w
不能为所有商品整体一个限流,否则会不平衡的问题,很多人都去秒杀一件商品,但是另一件商品在秒杀的时候,被限制了,误杀!
实现方式
一般有专门的限流算法
我们使用Redis的List类型或者Redis计数器实现
如果用户参与秒杀,向Redis的List中放一条记录,然后判断List的长度,Redis格式: redis:limit:商品id
在15-seckill-interface的Constants类中,添加限流最大值以及商品秒杀限流key的前缀常量
//商品限流最大值
public static final int MAX_LIMIT = 100000;
/**
* 定义Redis中商品秒杀限流key的前缀
* Redis中存放当前商品的流量访问值的格式:redis:limit:商品id
*/
public static final String REDIS_LIMIT = "redis:limit:";
在15-seckill-web的GoodsController中编写限流代码
//6.限流
//从Redis中查询出当前商品的访问量
Long currentSize = redisTemplate.opsForList().size(Constants.REDIS_LIMIT + id);
if(currentSize > Constants.MAX_LIMIT){
//超过最大限流值,拒绝访问
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("服务器繁忙,请稍后再试!~");
return returnObject;
}else{
//可以继续执行秒杀
// 先向Redis的限流List中放一条数据 返回放完数据之后List的长度
Long afterPushSize = redisTemplate.opsForList().leftPush(Constants.REDIS_LIMIT + id,String.valueOf(uid));
/*放完元素之后再次判断List的长度是否大于限流值
主要处理多线程情况下,很多线程都满足限流条件,都向Redis的List添加元素,避免List元素超出限流值
*/
if(afterPushSize >Constants.MAX_LIMIT){
redisTemplate.opsForList().rightPop(Constants.REDIS_LIMIT + id);
//超过最大限流值,拒绝访问
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("服务器繁忙,请稍后再试!~");
return returnObject;
}
}
8. 秒杀
减库存
需求
秒杀结束后,将商品的库存信息减1
常规的方式
减库存是直接操作数据库,高并发大流量的秒杀场景下,会给数据库瞬间带来极大的压力,可能数据库无法支撑(单台MySQL并发能力700左右,单台Redis并发能力5w左右)
解决方案
所以减库存在Redis中减
A、 15-seckill-web减库存代码
//减库存
Long leftStore = redisTemplate.opsForValue().decrement(Constants.REDIS_STORE +"id",1);
下订单(仅仅是将订单发送给MQ)
需求
秒杀之后,仅仅将订单发送给MQ,暂时不想数据库订单表中插入数据
常规的做法
直接是向数据库中插入订单信息
秒杀场景,可能有很多订单可以插入到数据库,而且主要是瞬间的操作,例如:1s,5s内向数据库插入10w条数据。
所以下单的时候不能直接操作数据库
解决方案
我们采用MQ,进行异步下单
同步是阻塞的,是需要等结果的,是可以拿到结果的
异步是非阻塞的,不需要等结果,但是有可能马上拿不到结果
让MQ接收瞬间的巨大的下单请求,但并不是马上瞬间处理完毕,而是一个个处理,插入数据库的频率的降低。这个频率的降低,我们叫做流量削峰,将单位时刻内,对数据库的操作降缓
MQ处理完毕之后,仅仅是将消息发送到了ActiveMQ的消息队列中,并没有真正的同步数据库,所以不能马上给前台结果,那么这个时候我们可以告诉前台页面一个中间结果,秒杀请求提交成功,正在处理……或一个图片转动
A、 在15-seckill-web的pom.xml文件添加ActiveMQ起步依赖
<!--SpringBoot集成ActiveMQ的起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-activemq</artifactId>
</dependency>
B、 在15-seckill-web的核心配置文件中配置ActiveMQ信息
#配置activemq的连接信息
spring.activemq.broker-url=tcp://192.168.235.128:61616
# 用户名
spring.activemq.user=system
# 密码
spring.activemq.password=123456
#目的地
spring.jms.template.default-destination=seckillQueue
C、 在15-seckill-web的GoodsController类中注入JmsTemplate
@Autowired
private JmsTemplate jmsTemplate;
D、 在15-seckill-web的GoodsController类中编写下订单代码
//7.减库存
Long leftStore = redisTemplate.opsForValue().decrement(Constants.REDIS_STORE +id,1);
//8.下单到MQ
if(leftStore >= 0){
//可以秒杀,执行下单操作
//标记用户已经买过该商品
redisTemplate.opsForValue().set(Constants.REDIS_BUY + id +":" +uid,String.valueOf(uid));
//创建订单对象
Orders orders = new Orders();
orders.setBuynum(1);
orders.setBuyprice(goods.getPrice());
orders.setCreatetime(new Date());
orders.setGoodsid(id);
orders.setOrdermoney(goods.getPrice().multiply(new BigDecimal(1)));
orders.setStatus(1);//待支付
orders.setUid(uid);
//将订单对象转换为json字符串
String ordersJSON = JSONObject.toJSONString(orders);
//通过JmsTemplate向ActiveMQ发送消息
jmsTemplate.send(new MessageCreator() {
@Override
public Message createMessage(Session session) throws JMSException {
return session.createTextMessage(ordersJSON);
}
});
returnObject.setErrorCode(Constants.ONE);
returnObject.setErrorMessage("秒杀请求提交成功,正在处理....");
return returnObject;
}else{
//不可以卖了,不能执行下单操作
/*
此时Redis中的商品库存可能已经减成负数了,但是对我们业务的处理没有任何影响
但为了保持数据的一致性,我们将值再恢复一下
*/
redisTemplate.opsForValue().increment(Constants.REDIS_STORE + id,1);
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("来晚了,商品已经抢光了");
return returnObject;
}
E、在15-seckill-web的seckill.js的execseckill函数中处理返回信息
//执行秒杀请求
execSeckill:function (random,id) {
$.ajax({
//url格式: /15-seckill-web/seckill/goods/Ffdaskfjkadlsjklfa/1
url: seckillObj.url.seckillURL() + random +"/" +id,
type:"post",
dataType:"json",
success:function (rtnMessage) {
//处理响应结果
if(rtnMessage.errorCode == 1){
//秒杀成功,已经下单到MQ,返回中间结果 可以做动画处理
$("#seckillTip").html("<span style='color:red;'>"+ rtnMessage.errorMessage +"</span>");
//接下来再发送一个请求获取最终秒杀的结果
}else{
//秒杀失败 展示失败信息
$("#seckillTip").html("<span style='color:red;'>"+ rtnMessage.errorMessage +"</span>");
}
}
});
}
F、 启动ActiveMQ,Redis,MySQL,15-seckill-service,15-seckill-web测试
在15-seckill-service中的RedisTask中同步MySQL数据库库存
/**
* 每3秒同步一次Redis中的库存到数据库
*/
@Scheduled(cron = "0/3 * * * * *")
public void syncRedisStoreToDB(){
System.out.println("同步Redis中的库存到数据库...........");
//1.查询出所有秒杀商品在Redis中的库存值
Set<String> keys = redisTemplate.keys(Constants.REDIS_STORE + "*");
for (String key : keys) {
//根据Redis的商品库存key,获取商品的库存
int store = Integer.valueOf(redisTemplate.opsForValue().get(key));
//获取商品的id 在Redis中存放商品库存的格式 redis:store:id
int goodsId = Integer.valueOf(key.split(":")[2]);
//同步到数据库
Goods goods = new Goods();
goods.setId(goodsId);
goods.setStore(store);
goodsMapper.updateByPrimaryKeySelective(goods);
}
}
9. 异步下单的处理
需求
将MQ中的订单同步到数据库
实现思路
● 在15-seckill-service中使用异步接收消息的方式对秒杀的订单消息进行消费
● 为了方便对事务的处理,我们在消息消费者MyMessageListener中不直接调用Mapper,而是调用订单的Service
● 如果下单成功
在Service中将秒杀的最终结果返回给前台页面,这里存在一个问题,就是如何将秒杀的结果响应给前台页面?
传统的做法,前台页面可以直接查询数据库的订单表,获取最终的秒杀结果,但是会对数据库造成压力,我们这里借助第三方Redis,将返回的结果保存到Redis中,然后让前台页面到Redis中进行查询。
● 如果下单失败
在Service层中抛出异常,在MyMessageListener中捕获异常,对之前做的处理进行恢复,主要包括库存恢复、购买标记、限流列表中删除一个元素
恢复的操作我们也专门在Service中封装方法
在15-seckill-service中添加ActiveMQ相关依赖
<!--SpringBoot集成ActiveMQ的起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-activemq</artifactId>
</dependency>
在15-seckill-service中的pom.xml文件中添加ActiveMQ配置信息
#配置activemq的连接信息
spring.activemq.broker-url=tcp://192.168.235.128:61616
spring.activemq.user=system
spring.activemq.password=123456
#目的地
spring.jms.template.default-destination=seckillQueue
# 消息发送模式 true发布订阅 false点对点 默认false点对点
spring.jms.pub-sub-domain=false
# SpringBoot 2.1.3之后需要配置
spring.jms.cache.enabled=false
从13-activemq-boot-receiver-async-02中拷贝ActiveMQ异步接收的代码config和listener目录下的内容
ActiveMQConfig代码(不需要修改)
@Configuration//相当于applicationContext-jms.xml文件
public class ActiveMQConfig {
@Autowired
private ActiveMQConnectionFactory connectionFactory;
@Autowired
private MyMessageListener myMessageListener;
@Value("${spring.jms.template.default-destination}")
private String destination;
@Value("${spring.jms.pub-sub-domain}")
private boolean pubSubDomain;
@Bean //@Bean注解就相当于配置文件的bean标签
public DefaultMessageListenerContainer defaultMessageListenerContainer(){
DefaultMessageListenerContainer listenerContainer = new DefaultMessageListenerContainer();
listenerContainer.setConnectionFactory(connectionFactory);
listenerContainer.setDestinationName(destination);
listenerContainer.setMessageListener(myMessageListener);
//设置消息发送模式方式为发布订阅
listenerContainer.setPubSubDomain(pubSubDomain);
return listenerContainer;
}
}
修改15-seckill-service中的MyMessageListener消费消息
@Component
public class MyMessageListener implements MessageListener{
@Autowired
private OrdersService ordersService;
public void onMessage(Message message) {
if(message instanceof TextMessage){
try {
String ordersJSON = ((TextMessage) message).getText();
System.out.println("SpringBoot监听器异步接收到的消息为:" + ordersJSON);
Orders orders = JSONObject.parseObject(ordersJSON,Orders.class);
try {
//接收到消息,下订单
ordersService.addOrders(orders);
} catch (Exception e) {
e.printStackTrace();
//下单失败了,要将之前的一些处理恢复一下
ordersService.processException(orders);
}
} catch (JMSException e) {
e.printStackTrace();
}
}
}
}
在15-seckill-interface的com.sxbdqn.seckill.service包下创建订单接口OrdersService
public interface OrdersService {
/**
* 下订单
*/
int addOrders(Orders orders);
/**
* 下单失败对异常的处理
*/
void processException(Orders orders);
}
在15-seckill-service的com.sxbdqn.seckill.service.impl包下中创建订单接口实现类OrdersServiceImpl
@Service
public class OrdersServiceImpl implements OrdersService{
@Autowired
private OrdersMapper ordersMapper;
@Autowired
private RedisTemplate<String,String> redisTemplate;
@Transactional
@Override
public int addOrders(Orders orders) {
int addRow = ordersMapper.insertSelective(orders);
if(addRow >0){
/*下单成功,告知前台秒杀最终结果,我们这是service项目,由消息消费者调用,不能
直接和前台打交道,所以需要前台重新发送请求,去数据库订单表中查询结果,但是这样
对数据库带来压力,所以我们将秒杀最终结果放到Redis中,然后前台页面去Redis中查询
*/
//用我们自定义我的RTO对象封装秒杀结果
ReturnObject returnObject = new ReturnObject();
returnObject.setErrorCode(Constants.ONE);
returnObject.setErrorMessage("秒杀成功");
returnObject.setData(orders);
String returnJSON = JSONObject.toJSONString(returnObject);
redisTemplate.opsForValue().set(Constants.REDIS_RESULT +
orders.getGoodsid() +":" + orders.getUid(),returnJSON);
//当前这个人秒杀全部结束,应该把当前这个人从限流列表中删除,让后面的人再进来秒杀
redisTemplate.opsForList().rightPop(Constants.REDIS_LIMIT + orders.getGoodsid());
}else{
//下单失败,抛出运行时异常
throw new RuntimeException("秒杀下单失败");
}
return addRow;
}
/**
下单失败之后,进行之前处理数据的恢复
*/
@Override
public void processException(Orders orders) {
// 1.库存恢复
redisTemplate.opsForValue().increment(Constants.REDIS_STORE + orders.getGoodsid(),1);
//2.购买标记清除
redisTemplate.delete(Constants.REDIS_BUY + orders.getGoodsid() +":" + orders.getUid());
// 3.限流列表中删除一个元素
redisTemplate.opsForList().rightPop(Constants.REDIS_LIMIT + orders.getGoodsid());
//4.将失败信息放到Redis中,便于前台页面再次获取
ReturnObject returnObject = new ReturnObject();
returnObject.setErrorCode(Constants.ZERO);
returnObject.setErrorMessage("秒杀失败");
returnObject.setData(orders);
String returnJSON = JSONObject.toJSONString(returnObject);
redisTemplate.opsForValue().set(Constants.REDIS_RESULT +
orders.getGoodsid() + ":" + orders.getUid(), returnJSON);
}
}
在15-seckill-interface的Constants类中添加存放最终秒杀结果Key的前缀
/**
* 定义Redis中商品秒杀秒杀结果key的前缀
* Redis中存放当前商品的流量访问值的格式:redis:result:商品id:用户id
*/
public static final String REDIS_RESULT = "redis:result:";
在15-seckill-service的Application类上开启事务
@EnableTransactionManagement
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
10. 库存超卖的解读
参照面试题11-Summary\互联网金融项目-面试.docx