暢購商城第十三天

第13章 秒殺

學習目標

  • 秒殺業務分析
  • 秒殺商品壓入Redis緩存
  • Spring定時任務瞭解-定時將秒殺商品存入到Redis中
  • 秒殺商品頻道頁實現-秒殺商品列表頁
  • 秒殺商品詳情頁實現
  • 下單實現(普通下單)
  • 多線程異步搶單實現-隊列削峯

1 秒殺業務分析

1.1 需求分析

所謂“秒殺”,就是網絡賣家發佈一些超低價格的商品,所有買家在同一時間網上搶購的一種銷售方式。通俗一點講就是網絡商家爲促銷等目的組織的網上限時搶購活動。由於商品價格低廉,往往一上架就被搶購一空,有時只用一秒鐘。

秒殺商品通常有兩種限制:庫存限制、時間限制。

需求:

(1)錄入秒殺商品數據,主要包括:商品標題、原價、秒殺價、商品圖片、介紹、秒殺時段等信息
(2)秒殺頻道首頁列出秒殺商品(進行中的)點擊秒殺商品圖片跳轉到秒殺商品詳細頁。
(3)商品詳細頁顯示秒殺商品信息,點擊立即搶購實現秒殺下單,下單時扣減庫存。當庫存爲0或不在活動期範圍內時無法秒殺。
(4)秒殺下單成功,直接跳轉到支付頁面(微信掃碼),支付成功,跳轉到成功頁,填寫收貨地址、電話、收件人等信息,完成訂單。
(5)當用戶秒殺下單5分鐘內未支付,取消預訂單,調用微信支付的關閉訂單接口,恢復庫存。

1.2 表結構說明

秒殺商品信息表

CREATE TABLE `tb_seckill_goods` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `sup_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 '秒殺價格',
  `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 '用戶',
  `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)狀態必須爲審覈通過 status=1
	2)商品庫存個數>0
	3)活動沒有結束  endTime>=now()
	4)在Redis中沒有該商品的緩存
	5)執行查詢獲取對應的結果集
2.將活動沒有結束的秒殺商品入庫

我們首先搭建一個秒殺服務工程,然後按照上面步驟實現。

搭建changgou-service-seckill,作爲秒殺工程的服務提供工程。

(1)pom.xml依賴

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>changgou-service</artifactId>
        <groupId>com.changgou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>
    <description>秒殺微服務</description>
    <artifactId>changgou-service-seckill</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.changgou</groupId>
            <artifactId>changgou-service-seckill-api</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>
</project>

(2) application.yml配置

server:
  port: 18093
spring:
  application:
    name: seckill
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://192.168.211.132:3306/changgou_seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
    username: root
    password: 123456
  rabbitmq:
    host: 192.168.211.132 #mq的服務器地址
    username: guest #賬號
    password: guest #密碼
  main:
    allow-bean-definition-overriding: true
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:7001/eureka
  instance:
    prefer-ip-address: true
feign:
  hystrix:
    enabled: true
#hystrix 配置
hystrix:
  command:
    default:
      execution:
        timeout:
        #如果enabled設置爲false,則請求超時交給ribbon控制
          enabled: true
        isolation:
          thread:
            timeoutInMilliseconds: 10000
          strategy: SEMAPHORE

(3) 導入生成文件

將生成的Dao文件和Pojo文件導入到工程中,如下圖:
在這裏插入圖片描述

(4) 啓動類配置

@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
@MapperScan(basePackages = {"com.changgou.seckill.dao"})
@EnableScheduling
public class SeckillApplication {


    public static void main(String[] args) {
        SpringApplication.run(SeckillApplication.class,args);
    }

    @Bean
    public IdWorker idWorker(){
        return new IdWorker(1,1);
    }
}

2.2 定時任務

一會兒我們採用Spring的定時任務定時將符合參與秒殺的商品查詢出來再存入到Redis緩存,所以這裏需要使用到定時任務。

這裏我們瞭解下定時任務相關的配置,配置步驟如下:

1)在定時任務類的指定方法上加上@Scheduled開啓定時任務
2)定時任務表達式:使用cron屬性來配置定時任務執行時間

2.2.1 定時任務方法配置

創建com.changgou.seckill.timer.SeckillGoodsPushTask類,並在類中加上定時任務執行方法,代碼如下:

@Component
public class SeckillGoodsPushTask {

    /****
     * 每30秒執行一次
     */
    @Scheduled(cron = "0/30 * * * * ?")
    public void loadGoodsPushRedis(){
        System.out.println("task demo");
    }
}

2.2.2 定時任務常用時間表達式

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 , - * /

使用說明:

通配符說明:
* 表示所有值. 例如:在分的字段上設置 "*",表示每一分鐘都會觸發。

? 表示不指定值。使用的場景爲不需要關心當前設置這個字段的值。

例如:要在每月的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 數據檢索條件分析

按照2.1中的幾個步驟實現將秒殺商品從數據庫中查詢出來,並存入到Redis緩存

1.查詢活動沒結束的所有秒殺商品
	1)計算秒殺時間段
	2)狀態必須爲審覈通過 status=1
	3)商品庫存個數>0
	4)活動沒有結束  endTime>=now()
	5)在Redis中沒有該商品的緩存
	6)執行查詢獲取對應的結果集
2.將活動沒有結束的秒殺商品入庫

上面這裏會涉及到時間操作,所以這裏提前準備了一個時間工具包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包裏已經實現,代碼如下:

/***
 * 獲取時間菜單
 * @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;
}

2.3.3 查詢秒殺商品導入Reids

我們可以寫個定時任務,查詢從當前時間開始,往後延續4個時間菜單間隔,也就是一共只查詢5個時間段搶購商品數據,並壓入緩存,實現代碼如下:

修改SeckillGoodsPushTask的loadGoodsPushRedis方法,代碼如下:

@Component
public class SeckillGoodsPushTask {

    @Autowired
    private SeckillGoodsMapper seckillGoodsMapper;

    @Autowired
    private RedisTemplate redisTemplate;

    /****
     * 定時任務方法
     * 0/30 * * * * ?:從每分鐘的第0秒開始執行,每過30秒執行一次
     */
    @Scheduled(cron = "0/30 * * * * ?")
    public void loadGoodsPushRedis(){
        //獲取時間段集合
        List<Date> dateMenus = DateUtil.getDateMenus();
        //循環時間段
        for (Date startTime : dateMenus) {
            // namespace = SeckillGoods_20195712
            String extName = DateUtil.data2str(startTime,DateUtil.PATTERN_YYYYMMDDHH);

            //根據時間段數據查詢對應的秒殺商品數據
            Example example = new Example(SeckillGoods.class);
            Example.Criteria criteria = example.createCriteria();
            // 1)商品必須審覈通過  status=1
            criteria.andEqualTo("status","1");
            // 2)庫存>0
            criteria.andGreaterThan("stockCount",0);
            // 3)開始時間<=活動開始時間
            criteria.andGreaterThanOrEqualTo("startTime",startTime);
            // 4)活動結束時間<開始時間+2小時
            criteria.andLessThan("endTime", DateUtil.addDateHour(startTime,2));
            // 5)排除之前已經加載到Redis緩存中的商品數據
            Set keys = redisTemplate.boundHashOps("SeckillGoods_" + extName).keys();
            if(keys!=null && keys.size()>0){
                criteria.andNotIn("id",keys);
            }

            //查詢數據
            List<SeckillGoods> seckillGoods = seckillGoodsMapper.selectByExample(example);

            //將秒殺商品數據存入到Redis緩存
            for (SeckillGoods seckillGood : seckillGoods) {
                redisTemplate.boundHashOps("SeckillGoods_"+extName).put(seckillGood.getId(),seckillGood);
                 redisTemplate.expireAt("SeckillGoods_"+extName,DateUtil.addDateHour(dateMenu, 2));
            
            }
        }
    }
}

Redis數據如下:
在這裏插入圖片描述

3 秒殺頻道頁

在這裏插入圖片描述
秒殺頻道首頁,顯示正在秒殺的和未開始秒殺的商品(已經開始或者還沒開始,未結束的秒殺商品)

3.1 秒殺時間菜單

在這裏插入圖片描述
如上圖,時間菜單需要根據當前時間動態加載,時間菜單的計算上面功能中已經實現,在DateUtil工具包中。我們只需要將時間菜單獲取,然後響應到頁面,頁面根據對應的數據顯示即可。

創建com.changgou.seckill.controller.SeckillGoodsController,並添加菜單獲取方法,代碼如下:

@RestController
@CrossOrigin
@RequestMapping(value = "/seckill/goods")
public class SeckillGoodsController {

    /*****
     * 獲取時間菜單
     * URLL:/seckill/goods/menus
     */
    @RequestMapping(value = "/menus")
    public List<Date> dateMenus(){
        return DateUtil.getDateMenus();
    }
}

使用Postman測試,效果如下:

http://localhost:18084/seckill/goods/menus
在這裏插入圖片描述

3.2 秒殺頻道頁

在這裏插入圖片描述
秒殺頻道頁是指將對應時區的秒殺商品從Reids緩存中查詢出來,併到頁面顯示。對應時區秒殺商品存儲的時候以Hash類型進行了存儲,key=SeckillGoods_2019010112,value=每個商品詳情。

每次用戶在前端點擊對應時間菜單的時候,可以將時間菜單的開始時間以yyyyMMddHH格式提交到後臺,後臺根據時間格式查詢出對應時區秒殺商品信息。

3.2.1 業務層

創建com.changgou.seckill.service.SeckillGoodsService,添加根據時區查詢秒殺商品的方法,代碼如下:

public interface SeckillGoodsService {

    /***
     * 獲取指定時間對應的秒殺商品列表
     * @param key
     */
    List<SeckillGoods> list(String key);
}

創建com.changgou.seckill.service.impl.SeckillGoodsServiceImpl,實現根據時區查詢秒殺商品的方法,代碼如下:

@Service
public class SeckillGoodsServiceImpl implements SeckillGoodsService {

    @Autowired
    private RedisTemplate redisTemplate;

    /***
     * Redis中根據Key獲取秒殺商品列表
     * @param key
     * @return
     */
    @Override
    public List<SeckillGoods> list(String key) {
        return redisTemplate.boundHashOps("SeckillGoods_"+key).values();
    }
}

3.2.2 控制層

修改com.changgou.seckill.controller.SeckillGoodsController,並添加秒殺商品查詢方法,代碼如下:

@Autowired
private SeckillGoodsService seckillGoodsService;

/****
 * URL:/seckill/goods/list
 * 對應時間段秒殺商品集合查詢
 * 調用Service查詢數據
 * @param time:2019050716
 */
@RequestMapping(value = "/list")
public List<SeckillGoods> list(String time){
    //調用Service查詢數據
    return seckillGoodsService.list(time);
}

使用Postman測試,效果如下:

http://localhost:18084/seckill/goods/list?time=2019052414
在這裏插入圖片描述

4 秒殺詳情頁

通過秒殺頻道頁點擊請購按鈕,會跳轉到商品秒殺詳情頁,秒殺詳情頁需要根據商品ID查詢商品詳情,我們可以在頻道頁點擊秒殺搶購的時候將ID一起傳到後臺,然後根據ID去Redis中查詢詳情信息。

4.1 業務層

修改com.changgou.seckill.service.SeckillGoodsService,添加如下方法實現查詢秒殺商品詳情,代碼如下:

/****
 * 根據ID查詢商品詳情
 * @param time:時間區間
 * @param id:商品ID
 */
SeckillGoods one(String time,Long id);

修改com.changgou.seckill.service.impl.SeckillGoodsServiceImpl,添加查詢秒殺商品詳情,代碼如下:

/****
 * 根據商品ID查詢商品詳情
 * @param time:時間區間
 * @param id:商品ID
 * @return
 */
@Override
public SeckillGoods one(String time, Long id) {
    return (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_"+time).get(id);
}

4.2 控制層

修改com.changgou.seckill.controller.SeckillGoodsController,添加如下方法實現查詢秒殺商品詳情,代碼如下:

/****
 * URL:/seckill/goods/one
 * 根據ID查詢商品詳情
 * 調用Service查詢商品詳情
 * @param time
 * @param id
 */
@RequestMapping(value = "/one")
public SeckillGoods one(String time,Long id){
    //調用Service查詢商品詳情
    return seckillGoodsService.one(time,id);
}

使用Postman測試,效果如下:

http://localhost:18084/seckill/goods/one?id=1131814843662340096&time=2019052414
在這裏插入圖片描述

5 下單實現

用戶下單,從控制層->Service層->Dao層,所以我們先把dao創建好,再創建service層,再創建控制層。

用戶下單,爲了提升下單速度,我們將訂單數據存入到Redis緩存中,如果用戶支付了,則將Reids緩存中的訂單存入到MySQL中,並清空Redis緩存中的訂單。

5.1 業務層

創建com.changgou.seckill.service.SeckillOrderService,並在接口中增加下單方法,代碼如下:

public interface SeckillOrderService {

    /***
     * 添加秒殺訂單
     * @param id:商品ID
     * @param time:商品秒殺開始時間
     * @param username:用戶登錄名
     * @return
     */
    Boolean add(Long id, String time, String username);
}

創建com.changgou.seckill.service.impl.SeckillOrderServiceImpl實現類,並在類中添加下單實現方法,代碼如下:

@Service
public class SeckillOrderServiceImpl implements SeckillOrderService {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private SeckillGoodsMapper seckillGoodsMapper;

    @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("SeckillGoods_" + 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("SeckillOrder").put(username,seckillOrder);

        //庫存減少
        goods.setStockCount(goods.getStockCount()-1);

        //判斷當前商品是否還有庫存
        if(goods.getStockCount()<=0){
            //並且將商品數據同步到MySQL中
            seckillGoodsMapper.updateByPrimaryKeySelective(goods);
            //如果沒有庫存,則清空Redis緩存中該商品
            redisTemplate.boundHashOps("SeckillGoods_" + time).delete(id);
        }else{
            //如果有庫存,則直數據重置到Reids中
            redisTemplate.boundHashOps("SeckillGoods_" + time).put(id,goods);
        }
        return true;
    }
}

5.2 控制層

創建com.changgou.seckill.controller.SeckillOrderController,添加下單方法,代碼如下:

@RestController
@CrossOrigin
@RequestMapping(value = "/seckill/order")
public class SeckillOrderController {

    @Autowired
    private SeckillOrderService seckillOrderService;

    /****
     * URL:/seckill/order/add
     * 添加訂單
     * 調用Service增加訂單
     * 匿名訪問:anonymousUser
     * @param time
     * @param id
     */
    @RequestMapping(value = "/add")
    public Result add(String time, Long id){
        try {
            //用戶登錄名
            String username = TokenDcode.getUserInfo().get("username");

            //調用Service增加訂單
            Boolean bo = seckillOrderService.add(id, time, username);

            if(bo){
                //搶單成功
                return new Result(true,StatusCode.OK,"搶單成功!");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return new Result(true,StatusCode.ERROR,"服務器繁忙,請稍後再試");
    }
}

問題分析:

上述功能完成了秒殺搶單操作,但沒有解決併發相關的問題,例如併發、超賣現象,這塊甚至有可能產生雪崩問題。

6 多線程搶單

6.1 實現思路分析

在這裏插入圖片描述
在審視秒殺中,操作一般都是比較複雜的,而且併發量特別高,比如,檢查當前賬號操作是否已經秒殺過該商品,檢查該賬號是否存在存在刷單行爲,記錄用戶操作日誌等。

下訂單這裏,我們一般採用多線程下單,但多線程中我們又需要保證用戶搶單的公平性,也就是先搶先下單。我們可以這樣實現,用戶進入秒殺搶單,如果用戶複合搶單資格,只需要記錄用戶搶單數據,存入隊列,多線程從隊列中進行消費即可,存入隊列採用左壓,多線程下單採用右取的方式。

6.2 異步實現

要想使用Spring的異步操作,需要先開啓異步操作,用@EnableAsync註解開啓,然後在對應的異步方法上添加註解@Async即可。

創建com.changgou.seckill.task.MultiThreadingCreateOrder類,在類中創建一個createOrder方法,並在方法上添加@Async,代碼如下:

@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測試如下:

http://localhost:18084/seckill/order/add?id=1131814847898587136&time=2019052510
在這裏插入圖片描述

6.3 多線程搶單

在這裏插入圖片描述
用戶每次下單的時候,我們都讓他們先進行排隊,然後採用多線程的方式創建訂單,排隊我們可以採用Redis的隊列實現,多線程下單我們可以採用Spring的異步實現。

6.3.1 多線程下單

將之前下單的代碼全部挪到多線程的方法中,com.changgou.seckill.service.impl.SeckillOrderServiceImpl類的方法值負責調用即可,代碼如下:
在這裏插入圖片描述

多線程下單代碼如下圖:
在這裏插入圖片描述
上圖代碼如下:

@Component
public class MultiThreadingCreateOrder {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private SeckillGoodsMapper seckillGoodsMapper;

    @Autowired
    private IdWorker idWorker;

    /***
     * 多線程下單操作
     */
    @Async
    public void createOrder(){
        try {
            //時間區間
            String time = "2019052510";
            //用戶登錄名
            String username="szitheima";
            //用戶搶購商品
            Long id = 1131814847898587136L;

            //獲取商品數據
            SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_" + 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("SeckillOrder").put(username,seckillOrder);

            //庫存減少
            goods.setStockCount(goods.getStockCount()-1);

            //判斷當前商品是否還有庫存
            if(goods.getStockCount()<=0){
                //並且將商品數據同步到MySQL中
                seckillGoodsMapper.updateByPrimaryKeySelective(goods);
                //如果沒有庫存,則清空Redis緩存中該商品
                redisTemplate.boundHashOps("SeckillGoods_" + time).delete(id);
            }else{
                //如果有庫存,則直數據重置到Reids中
                redisTemplate.boundHashOps("SeckillGoods_" + time).put(id,goods);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

此時測試,是可以正常下單的,但是用戶名和訂單都寫死了,此處需要繼續優化。

6.3.2 排隊下單

6.3.2.1 排隊信息封裝

用戶每次下單的時候,我們可以創建一個隊列進行排隊,然後採用多線程的方式創建訂單,排隊我們可以採用Redis的隊列實現。 排隊信息中需要有用戶搶單的商品信息,主要包含商品ID,商品搶購時間段,用戶登錄名。我們可以設計個javabean,如下:

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;
    }
    
    //get、set...略
}
6.3.2.2 排隊實現

我們可以將秒殺搶單信息存入到Redis中,這裏採用List方式存儲,List本身是一個隊列,用戶點擊搶購的時候,就將用戶搶購信息存入到Redis中,代碼如下:

@Service
public class SeckillOrderServiceImpl implements SeckillOrderService {

    @Autowired
    private MultiThreadingCreateOrder multiThreadingCreateOrder;

    @Autowired
    private RedisTemplate redisTemplate;

    /****
     * 添加訂單
     * @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("SeckillOrderQueue").leftPush(seckillStatus);

        //多線程操作
        multiThreadingCreateOrder.createOrder();
        return true;
    }
}

多線程每次從隊列中獲取數據,分別獲取用戶名和訂單商品編號以及商品秒殺時間段,進行下單操作,代碼如下:
在這裏插入圖片描述
上圖代碼如下:

/***
 * 多線程下單操作
 */
@Async
public void createOrder(){
    //從隊列中獲取排隊信息
    SeckillStatus seckillStatus = (SeckillStatus) redisTemplate.boundListOps("SeckillOrderQueue").rightPop();
    try {
        if(seckillStatus!=null){
            //時間區間
            String time = seckillStatus.getTime();
            //用戶登錄名
            String username=seckillStatus.getUsername();
            //用戶搶購商品
            Long id = seckillStatus.getGoodsId();

            //...略
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

6.3.3 下單狀態查詢

按照上面的流程,雖然可以實現用戶下單異步操作,但是並不能確定下單是否成功,所以我們需要做一個頁面判斷,每過1秒鐘查詢一次下單狀態,多線程下單的時候,需要修改搶單狀態,支付的時候,清理搶單狀態。

6.3.3.1 下單更新搶單狀態

用戶每次點擊搶購的時候,如果排隊成功,則將用戶搶購狀態存儲到Redis中,多線程搶單的時候,如果搶單成功,則更新搶單狀態。

修改SeckillOrderServiceImpl的add方法,記錄狀態,代碼如下:
在這裏插入圖片描述
上圖代碼如下:

//將搶單狀態存入到Redis中
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStatus);

多線程搶單更新狀態,修改MultiThreadingCreateOrder的createOrder方法,代碼如下:
在這裏插入圖片描述
上圖代碼如下:

//搶單成功,更新搶單狀態,排隊->等待支付
seckillStatus.setStatus(2);
seckillStatus.setOrderId(seckillOrder.getId());
seckillStatus.setMoney(seckillOrder.getMoney().floatValue());
redisTemplate.boundHashOps("UserQueueStatus").put(username,seckillStatus);
6.3.3.2 後臺查詢搶單狀態

後臺提供搶單狀態查詢方法,修改SeckillOrderService,添加如下查詢方法:

/***
 * 搶單狀態查詢
 * @param username
 */
SeckillStatus queryStatus(String username);

修改SeckillOrderServiceImpl,添加如下實現方法:

/***
 * 搶單狀態查詢
 * @param username
 * @return
 */
@Override
public SeckillStatus queryStatus(String username) {
    return (SeckillStatus) redisTemplate.boundHashOps("UserQueueStatus").get(username);
}

修改SeckillOrderController,添加如下查詢方法:
在這裏插入圖片描述
上圖代碼如下:

/****
 * 查詢搶購
 * @return
 */
@RequestMapping(value = "/query")
public Result queryStatus(){
    //獲取用戶名
    String username = tokenDcode.getUserInfo().get("username");

    //根據用戶名查詢用戶搶購狀態
    SeckillStatus seckillStatus = seckillOrderService.queryStatus(username);

    if(seckillStatus!=null){
        return new Result(true,seckillStatus.getStatus(),"搶購狀態");
    }
    //NOTFOUNDERROR =20006,沒有對應的搶購數據
    return new Result(false,StatusCode.NOTFOUNDERROR,"沒有搶購信息");
}
6.3.3.3 測試

使用Postman測試查詢狀態

http://localhost:18084/seckill/order/query

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章