每個店鋪都可以釋出優惠券:
當用戶搶購時,就會生成訂單,並儲存到tb_voucher_order這張表中。訂單表如果使用資料庫的自增id就存在一些問題:
解決方案:使用全域性ID生成器。
(1)全域性ID生成器是一種在分散式系統下用來生成全域性唯一ID的工具(也稱為分散式唯一ID),一般要滿足下列特性:
唯一性
高可用
高效能
遞增性
安全性
(2)全域性唯一ID生成策略:
(3)我們這裡使用redis作為全域性唯一生成器的實現方案,原因如下:
redis是獨立於資料庫之外的,它只有一個,當所有人都來存取redis時,它的自增一定是唯一的(唯一性)
使用redis的叢集、主從方案、哨兵功能,可以維持它的高可用性(高可用)
redis具有高效能(高效能)
可以使用redis的String型別,具有自增性(如:incr命令)(自增性)
Redis Incr 命令將 key 中儲存的數位值增一
如果 key 不存在,那麼 key 的值會先被初始化為 0 ,然後再執行 INCR 操作
為了增加id的安全性,我們不會直接使用自增redis自增的id,而是拼接一些其他資訊:(安全性)
ID構造:時間戳+計數器(使用long型別,共八位元組,64bit)
符號位:1bit,永遠為0
時間戳:31bit,以秒為單位,可以使用約69年
序列號:32bit,秒內的計數器,這樣可以支援每秒產生2^32個不同的ID
(1)建立全域性ID生成器RedisIdWorker
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* @author 李
* @version 1.0
*/
@Component
public class RedisIdWorker {
//開始時間戳(1970-01-01T00:00:00到2022-01-01T00:00:00的秒數)
private static final long BEGIN_TIMESTAMP = 1640995200L;
//序列號的位數
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
//public static void main(String[] args) {
// //開始時間
// LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
// //得到1970-01-01T00:00:00Z.到指定時間為止的具體秒數
// long second = time.toEpochSecond(ZoneOffset.UTC);
// System.out.println(second);//1640995200L
//}
public long nextId(String keyPrefix) {
//1.生成時間戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
//開始時間到當前時間的 時間戳
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
//2.生成序列號(keyPrefix代表業務字首)
/*
* Redis的 Incr命令將 key 中儲存的數位值增1,如果key不存在,那麼key的值會先被初始化為0,然後再執行INCR操作。
* 根據這個特性,我們每一天拼接不同的日期,當做key。也就是說同一天下單採用相同的key,不同天下單採用不同的key
* 這種方法不僅可以防止訂單號使用完(redis的的自增最多可以有2^64位元,我們採取其中32位元作計數器),
* 還可以根據不同的日期,統計該天的訂單數量
*/
//2.1獲取當前的日期(精確到天)
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//2.2做自增長
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3.拼接並返回
//將時間戳左移32位元,空出來的右邊32位元使用count填充,共64位元
return timeStamp << COUNT_BITS | count;
}
}
(2)測試類(部分程式碼)
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
public void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
//執行緒,生成100個id
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id" + id);
}
latch.countDown();
};
long start = System.currentTimeMillis();
//共執行300次任務
for (int i = 0; i < 300; i++) {
es.submit(task);
}
//讓所有執行緒執行完才計時
latch.await();
long end = System.currentTimeMillis();
System.out.println("共用時=" + (end - start));
}
關於countdownlatch
countdownlatch名為訊號槍:主要的作用是同步協調在多執行緒的等待於喚醒問題。如果沒有CountDownLatch ,由於程式是非同步的,當非同步程式沒有執行完時,主執行緒可能就已經執行完了。如果期望的是分執行緒全部走完之後,主執行緒再走,此時就需要使用到CountDownLatch。CountDownLatch 中有兩個最重要的方法:1.countDown 2.await
await 方法是阻塞方法,使用await可以讓main執行緒阻塞,當CountDownLatch 內部維護的變數變為0時,就不再阻塞,直接放行。那麼什麼時候CountDownLatch 維護的變數變為0 呢?我們只需要呼叫一次countDown ,內部變數就減少1。
根據這個性質,讓分執行緒和變數繫結, 執行完一個分執行緒就減少一個變數,當分執行緒全部走完,CountDownLatch 維護的變數就是0,此時await就不再阻塞,統計出來的時間也就是所有分執行緒執行完後的時間。
測試結果:
檢視redis中的資料:對應的key的自增值已經變為30000,說明生成了3w個id
全域性唯一ID生成策略:
Redis自增ID策略:
每個店鋪都可以釋出優惠券,分為平價券和特價券。平價券可以任意購買,而特價券需要秒殺搶購:
這兩張券對應的資料庫表結構如下:
tb_voucher:(優惠券表)優惠券的基本資訊、優惠金額、使用規則等(包括平價券和秒殺券)
tb_seckill_voucher:(秒殺優惠券表)優惠券的庫存、開始搶購時間、結束搶購時間。秒殺優惠券才需要填寫這些資訊。
要求在店鋪詳情中實現下單購買秒殺券:
下單時需要判斷兩點:
優惠券訂單表結構:
業務流程分析:
(1)優惠券訂單實體:VoucherOrder.java
package com.hmdp.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 優惠券訂單實體
*
* @author 李
* @version 1.0
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_voucher_order")
public class VoucherOrder implements Serializable {
private static final long serialVersionUID = 1L;
//主鍵
@TableId(value = "id", type = IdType.INPUT)
private Long id;
//下單的使用者id
private Long userId;
//購買的代金券id
private Long voucherId;
//支付方式 1:餘額支付;2:支付寶;3:微信
private Integer payType;
//訂單狀態,1:未支付;2:已支付;3:已核銷;4:已取消;5:退款中;6:已退款
private Integer status;
//下單時間
private LocalDateTime createTime;
//支付時間
private LocalDateTime payTime;
//核銷時間
private LocalDateTime useTime;
//退款時間
private LocalDateTime refundTime;
//更新時間
private LocalDateTime updateTime;
}
(2)mapper介面
package com.hmdp.mapper;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* Mapper 介面
*
* @author 李
* @version 1.0
*/
public interface VoucherOrderMapper extends BaseMapper<VoucherOrder> {
}
(3)IVoucherOrderService 服務類
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* 服務類
*
* @author 李
* @version 1.0
*/
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
(4)VoucherOrderServiceImpl 服務實現類
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* 服務實現類
*
* @author 李
* @version 1.0
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//根據id查詢優惠券資訊
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("該優惠券不存在,請重新整理!");
}
//判斷秒殺券是否在有效時間內
//若不在有效期,則返回異常結果
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒殺尚未開始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒殺已經結束!");
}
//若在有效期,判斷庫存是否充足
if (voucher.getStock() < 1) {//庫存不足
return Result.fail("秒殺券庫存不足!");
}
//庫存充足,則扣減庫存(操作秒殺券表)
boolean success = seckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).update();
if (!success) {//操作失敗
return Result.fail("秒殺券庫存不足!");
}
//扣減庫存成功,則建立訂單,返回訂單id
VoucherOrder voucherOrder = new VoucherOrder();
//設定訂單id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//設定使用者id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//設定代金券id
voucherOrder.setVoucherId(voucherId);
//將訂單寫入資料庫(操作優惠券訂單表)
this.save(voucherOrder);
//返回訂單id
return Result.ok(orderId);
}
}
(5)控制器 VoucherOrderController
package com.hmdp.controller;
import com.hmdp.dto.Result;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.service.IVoucherService;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 秒殺券前端控制器
*
* @author 李
* @version 1.0
*/
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
(6)測試,在前端頁面點選購買,顯示搶購成功,訂單號如下:
優惠券訂單表tb_voucher_order成功插入一條資料:
對應的秒殺券的庫存減一:
4.2的程式碼並沒有考慮到並行的問題:當有多個使用者同時對一個秒殺券進行搶購,並行會讓系統出現超賣問題:即賣出的秒殺券數量>實際的秒殺券庫存
我們使用jemeter測試:
執行上述設定,測試結果如下:
秒殺券表中,id=2的秒殺券庫存出現了負數:
訂單表中,對應的數量為104單,但是對應的秒殺券的庫存最多隻有100張。也就是說:出現了超賣問題
出現超賣問題的原因:
4.2的程式碼只是簡單地進行庫存判斷,並沒有考慮到執行緒並行。當有多個執行緒同時去判斷庫存時,如果當前庫存大於0,則這些執行緒都會去進行庫存扣減,從而發生並行安全問題:
超賣問題是典型的多執行緒安全問題,針對這一問題的常見解決方案就是加鎖:
這裡使用樂觀鎖方案。樂觀鎖的關鍵是判斷之前查詢到的資料是否有被修改過:
常見的方式有兩種:
(1)版本號法:
表中設定一個版本號欄位,執行緒在修改表之前,先查詢一次版本號。對資料庫表操作時,再查詢一次版本號,如果值和之前的一致,說明此時表的資料在兩次查詢之間沒有被修改過,我們就可以進行業務操作,並設定新的版本號。
update語句會對當前修改的行進行鎖定操作(資料庫有行級鎖,不用擔心一行記錄被同時修改)。
因此,進行表修改時,由於資料庫行鎖,其他執行緒會等待資料修改後再更新庫存
sql執行是交給資料庫的,如果開啟了事務的話,就是兩個事務的並行問題,此時將會啟動兩階段封鎖協定,保證事務並行安全
(2)CAS法:
這裡為了簡化,使用庫存代替版本號,原理和方案1是一致的:執行緒在修改表之前,先查詢一次庫存的值。對資料庫表操作時,再查詢一次庫存值,如果值和之前的一致,說明此時表的資料在兩次查詢之間沒有被修改過,我們就可以進行業務操作。
CAS 有三個運算元:記憶體值 V、預期值 A、要修改的值 B。CAS 最核心的思路就是,僅當預期值 A 和當前的記憶體值 V 相同時,才將記憶體值修改為 B。
為了簡便,這裡使用方案2,但實際的業務還是建議使用版本法來避免其他問題。
(1)修改VoucherOrderServiceImpl,新增如下程式碼:
(2)測試:
清除之前的訂單資訊(tb_voucher_order):
還原tb_seckill_voucher表的測試資料:
然後使用jemeter進行測試:
測試結果:
券沒有超賣,但是出現了新的問題:前幾個請求中就出現了下單失敗的情況,200個執行緒只有100-63=37個執行緒下單成功(理想情況下是100,即秒殺券全部賣出)
原因分析:這是因為,當有一個執行緒去修改資料時,其他很多的執行緒也來同時請求,它們都根據第一次查詢的stock值去判斷,發現stock值變化了,因此當第一個執行緒修改資料後,都沒有去對資料進行操作),導致發生了庫存充足,仍然搶不到券的情況(搶券失敗率偏高)。
(3)改進:修改VoucherOrderServiceImpl,修改如下劃線處:
分析:執行緒A獲取stock值,通過業務判斷,然後去對庫存值進行update操作;因為update語句會對當前修改的行進行鎖定操作,因此,進行表修改時,由於資料庫行鎖,其他執行緒會等待資料修改後再更新庫存。當等待後獲取鎖,將where stock > 0作為update條件,這時,只要stock不小於0就仍可以售券。
update where 是先走where去拿鎖,拿不到就阻塞,等拿到鎖了再去執行update
再次對其測試:可以看到200個執行緒並行,100張秒殺券全部售完。並且沒有出現超賣現象,同時解決了庫存充足卻搶不到券的問題。
超賣這樣的執行緒安全問題,解決方案有哪些?