單機環境下的秒殺問題
全域性唯一ID
為什麼要使用全域性唯一ID:
當使用者搶購時,就會生成訂單並儲存到訂單表中,而訂單表如果使用資料庫自增ID就存在一些問題:
- 受單表資料量的限制
- id的規律性太明顯
場景分析一:如果我們的id具有太明顯的規則,使用者或者說商業對手很容易猜測出來我們的一些敏感資訊,比如商城在一天時間內,賣出了多少單,這明顯不合適。
場景分析二:隨著我們商城規模越來越大,mysql的單表的容量不宜超過500W,資料量過大之後,我們要進行拆庫拆表,但拆分表了之後,他們從邏輯上講他們是同一張表,所以他們的id是不能一樣的, 於是乎我們需要保證id的唯一性。
場景分析三:如果全部使用資料庫自增長ID,那麼多張表都會出現相同的ID,不滿足業務需求。
在分散式系統下全域性唯一ID需要滿足的特點:
- 唯一性
- 遞增性
- 安全性
- 高可用(服務穩定)
- 高效能(生成速度夠快)
為了提高資料庫效能,這裡採用Java中的數值型別(Long--8(Byte)位元組,64位),
- ID的組成部分:符號位:1bit,永遠為0
- 時間戳:31bit,以秒為單位,可以使用69年
- 序列號:32bit,秒內的計數器,支援每秒產生2^32個不同ID
類雪花演算法開發
我們的生成策略是基於redis的自增長,及序列號部分,在實現的時候需要傳入不同的字首(即不同業務不同序列號)
我們開始實現時間戳位數,先設定一個基準值,即某一時間的秒數,使用的時候用當前時間秒數-基準時間=所得秒數即時間戳;
基準值計算:這裡我是用2023/1/1 0:0:0
;秒數為:1672531200
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2023, 1, 1, 0, 0, 0);
//設定時區
long l = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(l);
}
開始生成時間戳:獲得當前時間的秒數-基準值(BEGIN_TIMESTAMP=1672531200
)
LocalDateTime dateTime = LocalDateTime.now();
//秒數設定時區
long nowSecond = dateTime.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
然後生成序列號,採用Redis的自增操作實現。keyPrefix業務Key(傳入的)
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix);
這一行程式碼的使用問題是,同一個業務使用的同一個key,但是redis的自增上上限為2^64,總有時候會超過32位,所以最好是讓其同一業務也要有不同的key值,這裡我們可以加上當前時間。
//獲取當日日期,精確到天
String date = dateTime.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//自增長上限2^64
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
這樣做的好處是:
- 在redis中快取是分層的,方便檢視,也方便統計每天、每月的訂單量或者其他資料等
- 不會超過Redis的自增長的值,安全性提高
最後將時間戳和序列號進行拼接即可,位運算。COUNT_BITS
=32
timestamp << COUNT_BITS | count;
首先將時間戳左移32位,低處補零,然後進行或運算(遇1得1),這樣實現整個的全域性唯一ID。
測試
在同一個業務中使用全域性唯一ID生成。
/**
* 測試全域性唯一ID生成器
* @throws InterruptedException
*/
@Test
public void testIdWorker() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(300);
ExecutorService executorService = Executors.newFixedThreadPool(300);
Runnable task = ()->{
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id:"+id);
}
//計數-1
countDownLatch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
executorService.submit(task);
}
//等待子執行緒結束
countDownLatch.await();
long endTime = System.currentTimeMillis();
System.out.println("time= "+(endTime-begin));
}
time= 2608ms=2.68s,生成數量:30000
取兩個相近的十進位制轉為二進位制對比:
id : 148285184708444304
0010 0000 1110 1101 0000 1001 0111 0000 0000 0000 0000 0000 0000 1001 0000
id : 148285184708444305
0010 0000 1110 1101 0000 1001 0111 0000 0000 0000 0000 0000 0000 1001 0001
短碼生成策略
僅支援很小的呼叫量,用於生成活動配置類編號,保證全域性唯一
import java.util.Calendar;
import java.util.Random;
/**
* @author xbhog
* @describe:短碼生成策略,僅支援很小的呼叫量,用於生成活動配置類編號,保證全域性唯一
* @date 2022/9/18
*/
@Slf4j
@Component
public class ShortCode implements IIdGenerator {
@Override
public synchronized long nextId() {
Calendar calendar = Calendar.getInstance();
int year = calendar.get(Calendar.YEAR);
int week = calendar.get(Calendar.WEEK_OF_YEAR);
int day = calendar.get(Calendar.DAY_OF_WEEK);
int hour = calendar.get(Calendar.HOUR_OF_DAY);
log.info("年:{},周:{},日:{},小時:{}",year, week,day,hour);
//打亂順序:2020年為準 + 小時 + 週期 + 日 + 三位隨機數
StringBuilder idStr = new StringBuilder();
idStr.append(year-2020);
idStr.append(hour);
idStr.append(String.format("%02d",week));
idStr.append(day);
idStr.append(String.format("%03d",new Random().nextInt(1000)));
log.info("檢視拼接之後的值:{}",idStr);
return Long.parseLong(idStr.toString());
}
public static void main(String[] args) {
long l = new ShortCode().nextId();
System.out.println(l);
}
}
日誌記錄:
14:40:22.336 [main] INFO ShortCode - 年:2023,周:5,日:7,小時:14
14:40:22.341 [main] INFO ShortCode - 檢視拼接之後的值:314057012
314057012
秒殺下單功能及併發測試
完整程式碼GitHub:https://github.com/xbhog/hm-dianping/tree/20230130-xbhog-redisSpike
秒殺條件分析:
- 秒殺是否開始或結束,如果尚未開始或已經結束則無法下單
- 庫存是否充足,不足則無法下單
業務流程圖:
開發流程:
優惠卷訂單服務處理流程
-
查詢優惠卷
-
判斷使用者是否在秒殺時間段內
-
判斷是否庫存充足
-
- 不足:返回異常資訊
- 充足:執行步驟4
-
建立優惠卷訂單
-
落庫
-
返回訂單ID
流程比較簡單,這裡需要注意的點是在庫存扣減這部分
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查詢優惠券
// 2.判斷秒殺是否開始
// 3.判斷秒殺是否已經結束
// 4.判斷庫存是否充足
if (voucher.getStock() < 1) {
// 庫存不足
return Result.fail("庫存不足!");
}
//5,扣減庫
//update tb_seckill_voucher set stock=stock -1 where voucher_id = #{voucherId}
boolean success = seckillVoucherMapper.updateDateByVoucherId(voucherId);
if (!success) {
//扣減庫存
return Result.fail("庫存不足!");
}
//6.建立訂單
// 6.1.全域性唯一ID生成:訂單id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.使用者id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
jmeter進行測試:
條件:執行緒200,迴圈一次,檢視彙總報告可以看出:
預期結果應該為異常是50%,但是這裡顯示為0%,檢視資料庫可以看出生成訂單200個,庫存為-100;
原因分析:
假設執行緒1過來查詢庫存,判斷出來庫存大於1,正準備去扣減庫存,但是還沒有來得及去扣減,此時執行緒2過來,執行緒2也去查詢庫存,發現這個數量一定也大於1,那麼這兩個執行緒都會去扣減庫存,最終多個執行緒相當於一起去扣減庫存,由此就會出現庫存的超賣問題。
鎖解決超賣問題
完整程式碼GitHub:https://github.com/xbhog/hm-dianping/tree/20230130-xbhog-redisSpike
解決方式
- 悲觀鎖:可以實現對於資料的序列化執行,比如syn,和lock都是悲觀鎖的代表,同時,悲觀鎖中又可以再細分為公平鎖,非公平鎖,可重入鎖,等等
- 樂觀鎖:會有一個版本號,每次運算元據會對版本號+1,再提交回資料時,會去校驗是否比之前的版本大1 ,如果大1 ,則進行操作成功,這套機制的核心邏輯在於,如果在操作過程中,版本號只比原來大1 ,那麼就意味著操作過程中沒有人對他進行過修改,他的操作就是安全的,如果不大1,則資料被修改過,當然樂觀鎖還有一些變種的處理方式比如cas
採用樂觀鎖解決超賣問題:
在操作時,對版本號進行+1 操作,然後要求version 如果是1 的情況下,才能操作,那麼第一個執行緒在操作後,資料庫中的version變成了2,但是他自己滿足version=1 ,所以沒有問題,此時執行緒2執行,執行緒2 最後也需要加上條件version =1 ,但是現在由於執行緒1已經操作過了,所以執行緒2,操作時就不滿足version=1 的條件了,所以執行緒2無法執行成功。
修改上述程式碼有兩種修改方式:
- 只要我扣減庫存時的庫存和之前我查詢到的庫存是一樣的,就意味著沒有人在中間修改過庫存,那麼此時就是安全的。
- 判斷條件為庫存數stock>0即可(解決問題)
測試第一種方式:100執行緒併發;資料庫訂單數為1,庫存99(預期時庫存0)。
透過測試發現會有99%失敗的情況,跟我們預計的0%失敗率來說相差很遠,失敗的原因在於:在使用樂觀鎖過程中假設100個執行緒同時都拿到了100的庫存,然後大家一起去進行扣減,但是100個人中只有1個人能扣減成功,其他的人在處理時,他們在扣減時,庫存已經被修改過了,所以此時其他執行緒都會失敗。
解決方式就是修改庫存數條件為stock>0
一人一單秒殺併發問題
完整程式碼GitHub:https://github.com/xbhog/hm-dianping/tree/20230130-xbhog-redisSpike
上述秒殺訂單有一個問題,一個使用者可以秒殺多次;優惠卷是為了引流,但是目前的情況是,一個人可以無限制的搶這個優惠卷,所以我們應當增加一層邏輯,讓一個使用者只能下一個單,而不是讓一個使用者下多個單。
相關流程圖如下:
在原來的程式碼上增加使用者判斷:
// 5.一人一單邏輯
// 5.1.使用者id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判斷是否存在
if (count > 0) {
// 使用者已經購買過了
return Result.fail("使用者已經購買過一次!");
}
存在問題:現在的問題還是和之前一樣,併發過來,查詢資料庫,都不存在訂單,所以我們還是需要加鎖,但是樂觀鎖比較適合更新資料,而現在是插入資料,所以我們需要使用悲觀鎖操作
當前注意點:
- 執行緒安全實現
- 鎖的範圍(顆粒度)
- 事務問題
處理執行緒安全問題,將對資料庫更新和插入的操作單獨作為一個方法進行封裝:
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 5.1.查詢訂單
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判斷是否存在
if (count > 0) {
// 使用者已經購買過了
return Result.fail("使用者已經購買過一次!");
}
// 6.扣減庫存
//開始扣減庫存(透過樂觀鎖--->對應資料庫中行鎖實現)
boolean success = seckillVoucherMapper.updateDateByVoucherId(voucherId);
if (!success) {
// 扣減失敗
return Result.fail("庫存不足!");
}
// 7.建立訂單
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.訂單id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.使用者id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回訂單id
return Result.ok(orderId);
}
當前操作雖然可以解決執行緒安全,但是效率太低,每個進來的執行緒都要鎖一下,這裡我們可以嘗試以使用者ID來作為鎖條件,但是使用userId.toString(),是重新new了一個物件,這就造成每個執行緒進來都不一樣,鎖不住。
public static String toString(long i) {
if (i == Long.MIN_VALUE)
return "-9223372036854775808";
int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
char[] buf = new char[size];
getChars(i, size, buf);
return new String(buf, true);
}
這裡我們使用userId.toString().intern()
從常量池中查詢資料。解決鎖物件不一致的問題。
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
.......
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
log.info("開始進行使用者秒殺活動:{}",userId);
//一人一單邏輯
Integer count = voucherOrderService.query().eq("voucher_id", voucherId).eq("user_id", userId).count();
if(count > 0){
return Result.fail("該使用者已參加活動。");
}
//開始扣減庫存(透過樂觀鎖--->對應資料庫中行鎖實現)
boolean success = seckillVoucherMapper.updateDateByVoucherId(voucherId);
if(!success){
return Result.fail("庫存不足,正在補充!");
}
//建立訂單
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
voucherOrderService.save(voucherOrder);
return Result.ok(orderId);
}
//這裡事務還沒有提交事務,但是鎖已經釋放了。
}
但是! 以上程式碼還是存在問題;
問題的原因在於當前方法被spring的事務控制,如果你在方法內部加鎖,可能會導致當前方法事務還沒有提交,但是鎖已經釋放也會導致問題.
解決:把使用者ID放入外部.將當前方法整體包裹起來,確保事務不會出現問題
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private SeckillVoucherMapper seckillVoucherMapper;
@Resource
private IVoucherOrderService voucherOrderService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//查詢優惠卷庫存資訊
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
log.info("查詢秒殺優惠卷:{}",voucher);
//判斷秒殺是否開始:開始時間,結束時間
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("庫存不足,正在補充!");
}
Long userId = UserHolder.getUser().getId();
//這一步有問題
synchronized (userId.toString().intern()){
return this.createVoucherOrder(voucherId);
}
}
@Override
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
log.info("開始進行使用者秒殺活動:{}",userId);
//一人一單邏輯
Integer count = voucherOrderService.query().eq("voucher_id", voucherId).eq("user_id", userId).count();
if(count > 0){
return Result.fail("該使用者已參加活動。");
}
//開始扣減庫存(透過樂觀鎖--->對應資料庫中行鎖實現)
boolean success = seckillVoucherMapper.updateDateByVoucherId(voucherId);
if(!success){
return Result.fail("庫存不足,正在補充!");
}
//建立訂單
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
voucherOrderService.save(voucherOrder);
return Result.ok(orderId);
}
}
但是但是!還是有問題。
因為我們呼叫的方法,其實是this.的方式呼叫的,事務想要生效,還得利用代理來生效,所以這個地方,我們需要獲得原始的事務物件, 來操作事務。
代理使用需要進行配置和包的引入:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
在啟動類中加入:@EnableAspectJAutoProxy(exposeProxy = true)
;暴露代理物件,不設定無法獲取代理物件;
在呼叫時,透過AopContext來獲取當前代理物件。
synchronized (userId.toString().intern()){
//獲取原始事務代理物件
IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
return iVoucherOrderService.createVoucherOrder(voucherId);
}
Jmeter測試條件:100執行緒,迴圈1次,檢視結果樹和彙總報告可以看出;
檢視資料庫,一個使用者秒殺成功一個訂單,對比異常率,滿足我們的需求。