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.bjpowernode.seckill.service包下創建訂單接口OrdersService
public interface OrdersService {
/**
* 下訂單
*/
int addOrders(Orders orders);
/**
* 下單失敗對異常的處理
*/
void processException(Orders orders);
}
? 在15-seckill-service的com.bjpowernode.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