Redis 的预减,已经存在了原子性,就是一条一条执行的,不会存在,复购,多购的可能性。
修改 SeckillController.java 实现 InitializingBean
接口,
InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
package com.rainbowsea.seckill.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
import java.util.List;
@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
@Resource
private RedisTemplate redisTemplate;
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V3.0版本,
* - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
* - 使用 优化秒杀: Redis 预减库存+Decrement
*
* @param model 返回给模块的 model 信息
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("秒杀 V 2.0 ");
if (null == user) { //用户没有登录
return "login";
}
// 登录了,则返回用户信息给下一个模板内容
model.addAttribute("user", user);
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() list = goodsService.findGoodsVo();
// 先判断是否为空
if (CollectionUtils.isEmpty(list)) {
return;
}
// 遍历 List,然后将秒杀商品的库存量,放入到 Redis
// key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
list.forEach(
goodsVo -> {
redisTemplate.opsForValue()
.set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
}
);
}
}
测试
启动项目测试时, 确保多用户的 userticket 已经正确的保存到 Redis 中
确保商品库存已经正确的加载/保存到 Redis 中
启动线程组,进行测试
测试结果, 不在出现超卖和复购问题
一个思考题:
预减库存的代码, 能否放在 防止复购代码之前? 试分析可能出现什么情况?
预减库存的代码, 能否放在 防止复购代码之前? 试分析可能出现什么情况?
不可以,这样会导致,我们的预减,减了之后,发现该用户其实已经复购了
,则Redis 当中存储的库存信息减了,但是该用户却时不能购买的,就会
导致,有DB数据库,库存存在遗留问题,10w 用户抢购,只有 1w个商品
却还有遗留的问题。
**使用map进行内存标记的设计思路: **
修改 SeckillController.java 添加上一个 Map 属性用于,如果某个商品库存已经为空,
则标记到 entryStockMap
package com.rainbowsea.seckill.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.rainbowsea.seckill.pojo.Order;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;
@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
// 如果某个商品库存已经为空, 则标记到 entryStockMap
@Resource
private RedisTemplate redisTemplate;
// 定义 map- 记录秒杀商品
private HashMap entryStockMap = new HashMap();
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V 4.0版本,
* - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
* - 使用 优化秒杀: Redis 预减库存+Decrement
* - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
*
* @param model 返回给模块的 model 信息
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("秒杀 V 4.0 ");
// 定义 map - 记录秒杀商品是否还有库存
if (null == user) { //用户没有登录
return "login";
}
// 登录了,则返回用户信息给下一个模板内容
model.addAttribute("user", user);
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() list = goodsService.findGoodsVo();
// 先判断是否为空
if (CollectionUtils.isEmpty(list)) {
return;
}
// 遍历 List,然后将秒杀商品的库存量,放入到 Redis
// key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
list.forEach(
goodsVo -> {
redisTemplate.opsForValue()
.set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
// 初始化 map
// 如果 goodsId: false 表示有库存
// 如果 goodsId: true 表示没有库存
entryStockMap.put(goodsVo.getId(), false);
});
}
}
测试
启动项目测试时, 确保多用户的 userticket 已经正确的保存到 Redis 中
确保商品库存已经正确的加载/保存到 Redis 中
启动线程组,进行测试
测试结果, 不在出现超卖和复购问题
前面秒杀,没有实现异步机制,是完成下订单后,再返回的,当有大并发请求
加入消息队列,实现秒杀的异步请求下订单操作时,数据库来不及响应,容易造成线程堆积。
解决方案:
RabbitMQ 启动,配合 Spring Boot 配置
引入:Spring Boot 当中相关的 RabbitMQ 的 jar 包
org.springframework.boot
spring-boot-starter-amqp
spring:
rabbitmq:
host: 192.168.76.156
username: admin
password: 123
#虚拟主机
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
#设置初始化的重试时间间隔
initial-interval: 1000ms
#重试最大次数,默认是3
max-attempts: 3
#重试最大时间间隔,默认是10s
max-interval: 10000ms
#重试时间间隔的乘数
#比如配置是2 :第1次等 1s, 第2次等 2s,第3次等 4s..
#比如配置是1 :第1次等 1s, 第2次等 1s,第3次等 1s..
multiplier: 1
项目的完整 yaml 配置信息
spring:
thymeleaf:
#关闭缓存
cache: false
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf-8&useSSL=true
username: root
password: MySQL123
# 数据库连接池
hikari:
#连接池名
pool-name: Hsp_Hikari_Poll
#最小空闲连接
minimum-idle: 5
#空闲连接存活最大时间,默认60000(10分钟)
idle-timeout: 60000
# 最大连接数,默认是10
maximum-pool-size: 10
#从连接池返回来的连接自动提交
auto-commit: true
#连接最大存活时间。0表示永久存活,默认180000(30分钟)
max-lifetime: 180000
#连接超时时间,默认30000(30秒)
connection-timeout: 30000
#测试连接是否可用的查询语句
connection-test-query: select 1
# 配置Redis
redis:
host: 192.168.76.168
port: 6379
password: rainbowsea
database: 0
timeout: 10000ms
lettuce:
pool:
#最大连接数,默认是8
max-active: 8
#最大连接等待/阻塞时间,默认-1
max-wait: 10000ms
#最大空闲连接
max-idle: 200
#最小空闲连接,默认0
min-idle: 5
# rabbimq 配置
rabbitmq:
host: 192.168.76.156
username: admin
password: 123
#虚拟主机
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
#设置初始化的重试时间间隔
initial-interval: 1000ms
#重试最大次数,默认是3
max-attempts: 3
#重试最大时间间隔,默认是10s
max-interval: 10000ms
#重试时间间隔的乘数
#比如配置是2 :第1次等 1s, 第2次等 2s,第3次等 4s..
#比如配置是1 :第1次等 1s, 第2次等 1s,第3次等 1s..
multiplier: 1
#mybatis-plus配置
mybatis-plus:
#配置mapper.xml映射文件
mapper-locations: classpath*:/mapper/*Mapper.xml
#配置mybatis数据返回类型别名
type-aliases-package: com.rainbowsea.seckill.pojo
#mybatis sql 打印
#logging:
# level:
# com.rainbowsea.seckill.mapper: debug
server:
port: 8080
创建一个 SeckillMessage pojo 类,用于 RabbitMQ 生产者,消费者之间发送信息的封装。同时我们后续需要将该对象转换为 JSON 格式的 String 进行在 RabbitMQ 消息队列当中发送传输处理。
引入 JSON 转换工具 的 jar 包类
cn.hutool
hutool-all
5.3.3
package com.rainbowsea.seckill.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* SeckillMessage 秒杀消息对象,用于 RabbitMQ 消息队列进行发送传输
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillMessage {
private User user;
private Long goodsId;
}
创建 RabbitMQSecKillConfig ,作为 RabbitMQ 的消息队列的配置。
配置类,RabbitMQ 创建消息队列和交换机,以及消息队列和交换机的之间的关系
package com.rainbowsea.seckill.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.amqp.core.Queue;
/**
* 配置类,RabbitMQ 创建消息队列和交换机,以及消息队列和交换机的之间的关系
*/
@Configuration
public class RabbitMQSecKillConfig {
// 定义消息队列和交换机名
public static final String QUEUE = "seckillQueue";
public static final String EXCHANGE = "seckillExchange";
/**
* 创建队列
*
* @return Queue 队列
*/
@Bean // 没有指明 value ,默认就是方法名
public Queue queue_seckill() {
return new Queue(QUEUE);
}
/**
* @return TopicExchange 主题交换机
*/
@Bean
public TopicExchange topicExchange_seckill() {
return new TopicExchange(EXCHANGE);
}
/**
* 将队列绑定到对应的交换机当中,并指定路由,"主题"(哪些信息发送给 seckill.# 哪个队列)
*
* @return
*/
@Bean
public Binding binding_seckill() {
return BindingBuilder.bind(queue_seckill()).to(topicExchange_seckill())
.with("seckill.#");
}
}
创建 MQSenderMessage 对象,作为消息队列的发送者,发送信息
消息的生产者/发送者 发送【秒杀消息】
package com.rainbowsea.seckill.rabbitmq;
import com.rainbowsea.seckill.config.RabbitMQSecKillConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
/**
* 消息的生产者/发送者 发送【秒杀消息】
*/
@Slf4j
@Service
public class MQSenderMessage {
// 装配 RabbitTemplate
@Resource
private RabbitTemplate rabbitTemplate;
/**
* 发送者,将信息发送给交换机
*
* @param message
*/
public void sendSeckillMessage(String message) {
log.info("发送消息: " + message);
rabbitTemplate.convertAndSend(RabbitMQSecKillConfig.EXCHANGE,
"seckill.message", // 对应队列的 routingKey
message);
}
}
创建:MQReceiverConsumer 对象类,作为:消息的接收者/消费者,接收生产者,发送过来的信息
接收到信息后,调用秒杀商品的方法,orderService.seckill(user, goodsVo);
package com.rainbowsea.seckill.rabbitmq;
import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.config.RabbitMQSecKillConfig;
import com.rainbowsea.seckill.pojo.SeckillMessage;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
/**
* 消息的接收者/消费者,接收生产者,发送过来的信息
* ,接收到信息后,调用秒杀商品的方法,orderService.seckill(user, goodsVo);
*/
@Service
@Slf4j
public class MQReceiverConsumer {
@Resource
private GoodsService goodsService;
@Resource
private OrderService orderService;
/**
* 接受这个 queues = RabbitMQSecKillConfig.QUEUE 队列的当中的信息
*
* @param message 生产者发送的信息,其实就是 seckillMessage 对象信息,被我们转换为了 JSON
* 格式的 String
*/
@RabbitListener(queues = RabbitMQSecKillConfig.QUEUE)
public void queue(String message) {
log.info("接收到的消息是: " + message);
/*
这里我么们从队列中取出的是 String 类型
但是,我们需要的是 SeckillMessage,因此需要一个工具类 JSONUtil
,该工具需要引入 hutool 工具类的 jar 包
*/
SeckillMessage seckillMessage = JSONUtil.toBean(message, SeckillMessage.class);
// 秒杀用户对象
User user = seckillMessage.getUser();
// 秒杀用户的商品ID
Long goodsId = seckillMessage.getGoodsId();
// 通过商品ID,得到对应的 GoodsVo 秒杀商品信息对象
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 下单操作
orderService.seckill(user, goodsVo);
}
}
控制层处理:采用消息队列:SeckillController
package com.rainbowsea.seckill.controller;
import cn.hutool.json.JSONUtil;
import com.rainbowsea.seckill.pojo.SeckillMessage;
import com.rainbowsea.seckill.pojo.SeckillOrder;
import com.rainbowsea.seckill.pojo.User;
import com.rainbowsea.seckill.rabbitmq.MQSenderMessage;
import com.rainbowsea.seckill.service.GoodsService;
import com.rainbowsea.seckill.service.OrderService;
import com.rainbowsea.seckill.service.SeckillOrderService;
import com.rainbowsea.seckill.vo.GoodsVo;
import com.rainbowsea.seckill.vo.RespBeanEnum;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;
@Controller
@RequestMapping("/seckill")
// InitializingBean 当中的 afterPropertiesSet 表示项目启动就自动给执行该方法当中的内容
public class SeckillController implements InitializingBean {
// 装配需要的组件/对象
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
// 如果某个商品库存已经为空, 则标记到 entryStockMap
@Resource
private RedisTemplate redisTemplate;
// 定义 map- 记录秒杀商品
private HashMap entryStockMap = new HashMap();
// 装配消息的生产者/发送者
@Resource
private MQSenderMessage mqSenderMessage;
/**
* 方法: 处理用户抢购请求/秒杀
* 说明: 我们先完成一个 V 5.0版本,
* - 利用 MySQL默认的事务隔离级别【REPEATABLE-READ】
* - 使用 优化秒杀: Redis 预减库存+Decrement
* - 优化秒杀: 加入内存标记,避免总到 Redis 查询库存
* - 优化秒杀: 加入消息队列,实现秒杀的异步请求
*
* @param model 返回给模块的 model 信息
* @param user User 通过用户使用了,自定义参数解析器获取 User 对象,
* @param goodsId 秒杀商品的 ID 信息
* @return 返回到映射在 resources 下的 templates 下的页面
*/
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("秒杀 V 5.0 ");
// 定义 map - 记录秒杀商品是否还有库存
if (null == user) { //用户没有登录
return "login";
}
// 登录了,则返回用户信息给下一个模板内容
model.addAttribute("user", user);
// 获取到 GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
// 判断库存
if (goodsVo.getStockCount() list = goodsService.findGoodsVo();
// 先判断是否为空
if (CollectionUtils.isEmpty(list)) {
return;
}
// 遍历 List,然后将秒杀商品的库存量,放入到 Redis
// key:秒杀商品库存量对应 key:seckillGoods:商品Id,value:该商品的库存量
list.forEach(
goodsVo -> {
redisTemplate.opsForValue()
.set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
// 初始化 map
// 如果 goodsId: false 表示有库存
// 如果 goodsId: true 表示没有库存
entryStockMap.put(goodsVo.getId(), false);
});
}
}
测试:和上述测试一样。重置相关数据表
“在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。”
参与评论
手机查看
返回顶部