天天看點

秒殺系統要如何設計

秒殺系統要如何設計

前言

高并發下如何設計秒殺系統?這是一個高頻面試題。這個問題看似簡單,但是裡面的水很深,它考查的是高并發場景下,從前端到後端多方面的知識。

秒殺一般出現在商城的促銷活動中,指定了一定數量(比如:10個)的商品(比如:手機),以極低的價格(比如:0.1元),讓大量使用者參與活動,但隻有極少數使用者能夠購買成功。這類活動商家絕大部分是不賺錢的,說白了是找個噱頭宣傳自己。

雖說秒殺隻是一個促銷活動,但對技術要求不低。下面給大家總結一下設計秒殺系統需要注意的9個細節。

秒殺系統要如何設計

1 瞬時高并發

一般在秒殺時間點(比如:12點)前幾分鐘,使用者并發量才真正突增,達到秒殺時間點時,并發量會達到頂峰。

但由于這類活動是大量使用者搶少量商品的場景,必定會出現狼多肉少的情況,是以其實絕大部分使用者秒殺會失敗,隻有極少部分使用者能夠成功。

正常情況下,大部分使用者會收到商品已經搶完的提醒,收到該提醒後,他們大機率不會在那個活動頁面停留了,如此一來,使用者并發量又會急劇下降。是以這個峰值持續的時間其實是非常短的,這樣就會出現瞬時高并發的情況,下面用一張圖直覺的感受一下流量的變化:

秒殺系統要如何設計

像這種瞬時高并發的場景,傳統的系統很難應對,我們需要設計一套全新的系統。可以從以下幾個方面入手:

  1. 頁面靜态化
  2. CDN加速
  3. 緩存
  4. mq異步處理
  5. 限流
  6. 分布式鎖

2. 頁面靜态化

活動頁面是使用者流量的第一入口,是以是并發量最大的地方。

如果這些流量都能直接通路服務端,恐怕服務端會因為承受不住這麼大的壓力,而直接挂掉。

秒殺系統要如何設計

活動頁面絕大多數内容是固定的,比如:商品名稱、商品描述、圖檔等。為了減少不必要的服務端請求,通常情況下,會對活動頁面做靜态化處理。使用者浏覽商品等正常操作,并不會請求到服務端。隻有到了秒殺時間點,并且使用者主動點了秒殺按鈕才允許通路服務端。

秒殺系統要如何設計

這樣能過濾大部分無效請求。

但隻做頁面靜态化還不夠,因為使用者分布在全國各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很遠,網速各不相同。

如何才能讓使用者最快通路到活動頁面呢?

這就需要使用CDN,它的全稱是Content Delivery Network,即内容分發網絡。

秒殺系統要如何設計

使使用者就近擷取所需内容,降低網絡擁塞,提高使用者通路響應速度和命中率。

3 秒殺按鈕

大部分使用者怕錯過秒殺時間點,一般會提前進入活動頁面。此時看到的秒殺按鈕是置灰,不可點選的。隻有到了秒殺時間點那一時刻,秒殺按鈕才會自動點亮,變成可點選的。

但此時很多使用者已經迫不及待了,通過不停重新整理頁面,争取在第一時間看到秒殺按鈕的點亮。

從前面得知,該活動頁面是靜态的。那麼我們在靜态頁面中如何控制秒殺按鈕,隻在秒殺時間點時才點亮呢?

沒錯,使用js檔案控制。

為了性能考慮,一般會将css、js和圖檔等靜态資源檔案提前緩存到CDN上,讓使用者能夠就近通路秒殺頁面。

看到這裡,有些聰明的小夥伴,可能會問:CDN上的js檔案是如何更新的?

秒殺開始之前,js标志為false,還有另外一個随機參數。

秒殺系統要如何設計

當秒殺開始的時候系統會生成一個新的js檔案,此時标志為true,并且随機參數生成一個新值,然後同步給CDN。由于有了這個随機參數,CDN不會緩存資料,每次都能從CDN中擷取最新的js代碼。

秒殺系統要如何設計

此外,前端還可以加一個定時器,控制比如:10秒之内,隻允許發起一次請求。如果使用者點選了一次秒殺按鈕,則在10秒之内置灰,不允許再次點選,等到過了時間限制,又允許重新點選該按鈕。

4 讀多寫少

在秒殺的過程中,系統一般會先查一下庫存是否足夠,如果足夠才允許下單,寫資料庫。如果不夠,則直接傳回該商品已經搶完。

由于大量使用者搶少量商品,隻有極少部分使用者能夠搶成功,是以絕大部分使用者在秒殺時,庫存其實是不足的,系統會直接傳回該商品已經搶完。

這是非常典型的:讀多寫少 的場景。

秒殺系統要如何設計

如果有數十萬的請求過來,同時通過資料庫查緩存是否足夠,此時資料庫可能會挂掉。因為資料庫的連接配接資源非常有限,比如:mysql,無法同時支援這麼多的連接配接。

而應該改用緩存,比如:redis。

即便用了redis,也需要部署多個節點。

秒殺系統要如何設計

5 緩存問題

通常情況下,我們需要在redis中儲存商品資訊,裡面包含:商品id、商品名稱、規格屬性、庫存等資訊,同時資料庫中也要有相關資訊,畢竟緩存并不完全可靠。

使用者在點選秒殺按鈕,請求秒殺接口的過程中,需要傳入的商品id參數,然後服務端需要校驗該商品是否合法。

大緻流程如下圖所示:

秒殺系統要如何設計

根據商品id,先從緩存中查詢商品,如果商品存在,則參與秒殺。如果不存在,則需要從資料庫中查詢商品,如果存在,則将商品資訊放入緩存,然後參與秒殺。如果商品不存在,則直接提示失敗。

這個過程表面上看起來是OK的,但是如果深入分析一下會發現一些問題。

5.1 緩存擊穿

比如商品A第一次秒殺時,緩存中是沒有資料的,但資料庫中有。雖說上面有如果從資料庫中查到資料,則放入緩存的邏輯。

然而,在高并發下,同一時刻會有大量的請求,都在秒殺同一件商品,這些請求同時去查緩存中沒有資料,然後又同時通路資料庫。結果悲劇了,資料庫可能扛不住壓力,直接挂掉。

如何解決這個問題呢?

這就需要加鎖,最好使用分布式鎖。

秒殺系統要如何設計

當然,針對這種情況,最好在項目啟動之前,先把緩存進行預熱。即事先把所有的商品,同步到緩存中,這樣商品基本都能直接從緩存中擷取到,就不會出現緩存擊穿的問題了。

是不是上面加鎖這一步可以不需要了?

表面上看起來,确實可以不需要。但如果緩存中設定的過期時間不對,緩存提前過期了,或者緩存被不小心删除了,如果不加速同樣可能出現緩存擊穿。

其實這裡加鎖,相當于買了一份保險。

5.2 緩存穿透

如果有大量的請求傳入的商品id,在緩存中和資料庫中都不存在,這些請求不就每次都會穿透過緩存,而直接通路資料庫了。

由于前面已經加了鎖,是以即使這裡的并發量很大,也不會導緻資料庫直接挂掉。

但很顯然這些請求的處理性能并不好,有沒有更好的解決方案?

這時可以想到布隆過濾器。

秒殺系統要如何設計

系統根據商品id,先從布隆過濾器中查詢該id是否存在,如果存在則允許從緩存中查詢資料,如果不存在,則直接傳回失敗。

雖說該方案可以解決緩存穿透問題,但是又會引出另外一個問題:布隆過濾器中的資料如何跟緩存中的資料保持一緻?

這就要求,如果緩存中資料有更新,則要及時同步到布隆過濾器中。如果資料同步失敗了,還需要增加重試機制,而且跨資料源,能保證資料的實時一緻性嗎?

顯然是不行的。

是以布隆過濾器絕大部分使用在緩存資料更新很少的場景中。

如果緩存資料更新非常頻繁,又該如何處理呢?

這時,就需要把不存在的商品id也緩存起來。

秒殺系統要如何設計

下次,再有該商品id的請求過來,則也能從緩存中查到資料,隻不過該資料比較特殊,表示商品不存在。需要特别注意的是,這種特殊緩存設定的逾時時間應該盡量短一點。

6 庫存問題

對于庫存問題看似簡單,實則裡面還是有些東西。

真正的秒殺商品的場景,不是說扣完庫存,就完事了,如果使用者在一段時間内,還沒完成支付,扣減的庫存是要加回去的。

是以,在這裡引出了一個預扣庫存的概念,預扣庫存的主要流程如下:

秒殺系統要如何設計

扣減庫存中除了上面說到的預扣庫存和回退庫存之外,還需要特别注意的是庫存不足和庫存超賣問題。

6.1 資料庫扣減庫存

使用資料庫扣減庫存,是最簡單的實作方案了,假設扣減庫存的sql如下:

pdate product set stock=stock-1 where id=123;      

這種寫法對于扣減庫存是沒有問題的,但如何控制庫存不足的情況下,不讓使用者操作呢?

這就需要在update之前,先查一下庫存是否足夠了。

僞代碼如下:

int stock = mapper.getStockById(123);
if(stock > 0) {
  int count = mapper.updateStock(123);
  if(count > 0) {
    addOrder(123);
  }
}      

大家有沒有發現這段代碼的問題?

沒錯,查詢操作和更新操作不是原子性的,會導緻在并發的場景下,出現庫存超賣的情況。

有人可能會說,這樣好辦,加把鎖,不就搞定了,比如使用synchronized關鍵字。

确實,可以,但是性能不夠好。

還有更優雅的處理方案,即基于資料庫的樂觀鎖,這樣會少一次資料庫查詢,而且能夠天然的保證資料操作的原子性。

隻需将上面的sql稍微調整一下:

update product set stock=stock-1 where id=product and stock > 0;      

在sql最後加上:stock > 0,就能保證不會出現超賣的情況。

但需要頻繁通路資料庫,我們都知道資料庫連接配接是非常昂貴的資源。在高并發的場景下,可能會造成系統雪崩。而且,容易出現多個請求,同時競争行鎖的情況,造成互相等待,進而出現死鎖的問題。

6.2 redis扣減庫存

redis的incr方法是原子性的,可以用該方法扣減庫存。僞代碼如下:

boolean exist = redisClient.query(productId,userId);
  if(exist) {
    return -1;
  }
  int stock = redisClient.queryStock(productId);
  if(stock <=0) {
    return 0;
  }
  redisClient.incrby(productId, -1);
  redisClient.add(productId,userId);
return 1;      

代碼流程如下:

  1. 先判斷該使用者有沒有秒殺過該商品,如果已經秒殺過,則直接傳回-1。
  2. 查詢庫存,如果庫存小于等于0,則直接傳回0,表示庫存不足。
  3. 如果庫存充足,則扣減庫存,然後将本次秒殺記錄儲存起來。然後傳回1,表示成功。

估計很多小夥伴,一開始都會按這樣的思路寫代碼。但如果仔細想想會發現,這段代碼有問題。

有什麼問題呢?

如果在高并發下,有多個請求同時查詢庫存,當時都大于0。由于查詢庫存和更新庫存非原則操作,則會出現庫存為負數的情況,即庫存超賣。

當然有人可能會說,加個synchronized不就解決問題?

調整後代碼如下:

= redisClient.query(productId,userId);
   if(exist) {
    return -1;
   }
   synchronized(this) {
       int stock = redisClient.queryStock(productId);
       if(stock <=0) {
         return 0;
       }
       redisClient.incrby(productId, -1);
       redisClient.add(productId,userId);
   }

return 1;      

加synchronized确實能解決庫存為負數問題,但是這樣會導緻接口性能急劇下降,每次查詢都需要競争同一把鎖,顯然不太合理。

為了解決上面的問題,代碼優化如下:

boolean exist = redisClient.query(productId,userId);
if(exist) {
  return -1;
}
if(redisClient.incrby(productId, -1)<0) {
  return 0;
}
redisClient.add(productId,userId);
return 1;      

該代碼主要流程如下:

  1. 先判斷該使用者有沒有秒殺過該商品,如果已經秒殺過,則直接傳回-1。
  2. 扣減庫存,判斷傳回值是否小于0,如果小于0,則直接傳回0,表示庫存不足。
  3. 如果扣減庫存後,傳回值大于或等于0,則将本次秒殺記錄儲存起來。然後傳回1,表示成功。

該方案咋一看,好像沒問題。

但如果在高并發場景中,有多個請求同時扣減庫存,大多數請求的incrby操作之後,結果都會小于0。

雖說,庫存出現負數,不會出現超賣的問題。但由于這裡是預減庫存,如果負數值負的太多的話,後面萬一要回退庫存時,就會導緻庫存不準。

那麼,有沒有更好的方案呢?

6.3 lua腳本扣減庫存

我們都知道lua腳本,是能夠保證原子性的,它跟redis一起配合使用,能夠完美解決上面的問題。

lua腳本有段非常經典的代碼:

= new StringBuilder();
  lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
  lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
  lua.append("    if (stock == -1) then");
  lua.append("        return 1;");
  lua.append("    end;");
  lua.append("    if (stock > 0) then");
  lua.append("        redis.call('incrby', KEYS[1], -1);");
  lua.append("        return stock;");
  lua.append("    end;");
  lua.append("    return 0;");
  lua.append("end;");
  lua.append("return -1;");      

該代碼的主要流程如下:

  1. 先判斷商品id是否存在,如果不存在則直接傳回。
  2. 擷取該商品id的庫存,判斷庫存如果是-1,則直接傳回,表示不限制庫存。
  3. 如果庫存大于0,則扣減庫存。
  4. 如果庫存等于0,是直接傳回,表示庫存不足。

7 分布式鎖

之前我提到過,在秒殺的時候,需要先從緩存中查商品是否存在,如果不存在,則會從資料庫中查商品。如果資料庫中,則将該商品放入緩存中,然後傳回。如果資料庫中沒有,則直接傳回失敗。

大家試想一下,如果在高并發下,有大量的請求都去查一個緩存中不存在的商品,這些請求都會直接打到資料庫。資料庫由于承受不住壓力,而直接挂掉。

那麼如何解決這個問題呢?

這就需要用redis分布式鎖了。

7.1 setNx加鎖

使用redis的分布式鎖,首先想到的是setNx指令。

if (jedis.setnx(lockKey, val) == 1) {
   jedis.expire(lockKey, timeout);
}      

用該指令其實可以加鎖,但和後面的設定逾時時間是分開的,并非原子操作。

假如加鎖成功了,但是設定逾時時間失敗了,該lockKey就變成永不失效的了。在高并發場景中,該問題會導緻非常嚴重的後果。

那麼,有沒有保證原子性的加鎖指令呢?

7.2 set加鎖

使用redis的set指令,它可以指定多個參數。

String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
    return true;
}
return false;      

其中:

  • lockKey:鎖的辨別
  • requestId:請求id
  • NX:隻在鍵不存在時,才對鍵進行設定操作。
  • PX:設定鍵的過期時間為 millisecond 毫秒。
  • expireTime:過期時間

由于該指令隻有一步,是以它是原子操作。

7.3 釋放鎖

接下來,有些朋友可能會問:在加鎖時,既然已經有了lockKey鎖辨別,為什麼要需要記錄requestId呢?

答:requestId是在釋放鎖的時候用的。

if (jedis.get(lockKey).equals(requestId)) {
    jedis.del(lockKey);
    return true;
}
return false;      

在釋放鎖的時候,隻能釋放自己加的鎖,不允許釋放别人加的鎖。

這裡為什麼要用requestId,用userId不行嗎?

答:如果用userId的話,假設本次請求流程走完了,準備删除鎖。此時,巧合鎖到了過期時間失效了。而另外一個請求,巧合使用的相同userId加鎖,會成功。而本次請求删除鎖的時候,删除的其實是别人的鎖了。

當然使用lua腳本也能避免該問題:

if redis.call('get', KEYS[1]) == ARGV[1] then 
 return redis.call('del', KEYS[1]) 
else 
  return 0      

它能保證查詢鎖是否存在和删除鎖是原子操作。

7.4 自旋鎖

上面的加鎖方法看起來好像沒有問題,但如果你仔細想想,如果有1萬的請求同時去競争那把鎖,可能隻有一個請求是成功的,其餘的9999個請求都會失敗。

在秒殺場景下,會有什麼問題?

答:每1萬個請求,有1個成功。再1萬個請求,有1個成功。如此下去,直到庫存不足。這就變成均勻分布的秒殺了,跟我們想象中的不一樣。

如何解決這個問題呢?

答:使用自旋鎖。

try {
  Long start = System.currentTimeMillis();
  while(true) {
      String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
     if ("OK".equals(result)) {
        return true;
     }
     
     long time = System.currentTimeMillis() - start;
      if (time>=timeout) {
          return false;
      }
      try {
          Thread.sleep(50);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
  }
 
} finally{
    unlock(lockKey,requestId);
}  
return false;      

在規定的時間,比如500毫秒内,自旋不斷嘗試加鎖,如果成功則直接傳回。如果失敗,則休眠50毫秒,再發起新一輪的嘗試。如果到了逾時時間,還未加鎖成功,則直接傳回失敗。

7.5 redisson

除了上面的問題之外,使用redis分布式鎖,還有鎖競争問題、續期問題、鎖重入問題、多個redis執行個體加鎖問題等。

這些問題使用redisson可以解決,由于篇幅的原因,在這裡先保留一點懸念,有疑問的私聊給我。後面會出一個專題介紹分布式鎖,敬請期待。

8 mq異步處理

我們都知道在真實的秒殺場景中,有三個核心流程:

秒殺系統要如何設計

而這三個核心流程中,真正并發量大的是秒殺功能,下單和支付功能實際并發量很小。是以,我們在設計秒殺系統時,有必要把下單和支付功能從秒殺的主流程中拆分出來,特别是下單功能要做成mq異步處理的。而支付功能,比如支付寶支付,是業務場景本身保證的異步。

于是,秒殺後下單的流程變成如下:

秒殺系統要如何設計

如果使用mq,需要關注以下幾個問題:

8.1 消息丢失問題

秒殺成功了,往mq發送下單消息的時候,有可能會失敗。原因有很多,比如:網絡問題、broker挂了、mq服務端磁盤問題等。這些情況,都可能會造成消息丢失。

那麼,如何防止消息丢失呢?

答:加一張消息發送表。

秒殺系統要如何設計

在生産者發送mq消息之前,先把該條消息寫入消息發送表,初始狀态是待處理,然後再發送mq消息。消費者消費消息時,處理完業務邏輯之後,再回調生産者的一個接口,修改消息狀态為已處理。

如果生産者把消息寫入消息發送表之後,再發送mq消息到mq服務端的過程中失敗了,造成了消息丢失。

這時候,要如何處理呢?

答:使用job,增加重試機制。

秒殺系統要如何設計

用job每隔一段時間去查詢消息發送表中狀态為待處理的資料,然後重新發送mq消息。

8.2 重複消費問題

本來消費者消費消息時,在ack應答的時候,如果網絡逾時,本身就可能會消費重複的消息。但由于消息發送者增加了重試機制,會導緻消費者重複消息的機率增大。

那麼,如何解決重複消息問題呢?

答:加一張消息處理表。

秒殺系統要如何設計

消費者讀到消息之後,先判斷一下消息處理表,是否存在該消息,如果存在,表示是重複消費,則直接傳回。如果不存在,則進行下單操作,接着将該消息寫入消息處理表中,再傳回。

有個比較關鍵的點是:下單和寫消息處理表,要放在同一個事務中,保證原子操作。

8.3 垃圾消息問題

這套方案表面上看起來沒有問題,但如果出現了消息消費失敗的情況。比如:由于某些原因,消息消費者下單一直失敗,一直不能回調狀态變更接口,這樣job會不停的重試發消息。最後,會産生大量的垃圾消息。

那麼,如何解決這個問題呢?

秒殺系統要如何設計

每次在job重試時,需要先判斷一下消息發送表中該消息的發送次數是否達到最大限制,如果達到了,則直接傳回。如果沒有達到,則将次數加1,然後發送消息。

這樣如果出現異常,隻會産生少量的垃圾消息,不會影響到正常的業務。

8.4 延遲消費問題

通常情況下,如果使用者秒殺成功了,下單之後,在15分鐘之内還未完成支付的話,該訂單會被自動取消,回退庫存。

那麼,在15分鐘内未完成支付,訂單被自動取消的功能,要如何實作呢?

我們首先想到的可能是job,因為它比較簡單。

但job有個問題,需要每隔一段時間處理一次,實時性不太好。

還有更好的方案?

答:使用延遲隊列。

我們都知道rocketmq,自帶了延遲隊列的功能。

秒殺系統要如何設計

下單時消息生産者會先生成訂單,此時狀态為待支付,然後會向延遲隊列中發一條消息。達到了延遲時間,消息消費者讀取消息之後,會查詢該訂單的狀态是否為待支付。如果是待支付狀态,則會更新訂單狀态為取消狀态。如果不是待支付狀态,說明該訂單已經支付過了,則直接傳回。

還有個關鍵點,使用者完成支付之後,會修改訂單狀态為已支付。

秒殺系統要如何設計

9 如何限流?

通過秒殺活動,如果我們運氣爆棚,可能會用非常低的價格買到不錯的商品(這種機率堪比買福利彩票中大獎)。

但有些高手,并不會像我們一樣老老實實,通過秒殺頁面點選秒殺按鈕,搶購商品。他們可能在自己的伺服器上,模拟正常使用者登入系統,跳過秒殺頁面,直接調用秒殺接口。

如果是我們手動操作,一般情況下,一秒鐘隻能點選一次秒殺按鈕。

秒殺系統要如何設計

但是如果是伺服器,一秒鐘可以請求成上千接口。

秒殺系統要如何設計

這種差距實在太明顯了,如果不做任何限制,絕大部分商品可能是被機器搶到,而非正常的使用者,有點不太公平。

是以,我們有必要識别這些非法請求,做一些限制。那麼,我們該如何現在這些非法請求呢?

目前有兩種常用的限流方式:

  1. 基于nginx限流
  2. 基于redis限流

9.1 對同一使用者限流

為了防止某個使用者,請求接口次數過于頻繁,可以隻針對該使用者做限制。

秒殺系統要如何設計

限制同一個使用者id,比如每分鐘隻能請求5次接口。

9.2 對同一ip限流

有時候隻對某個使用者限流是不夠的,有些高手可以模拟多個使用者請求,這種nginx就沒法識别了。

這時需要加同一ip限流功能。

秒殺系統要如何設計

限制同一個ip,比如每分鐘隻能請求5次接口。

但這種限流方式可能會有誤殺的情況,比如同一個公司或網吧的出口ip是相同的,如果裡面有多個正常使用者同時發起請求,有些使用者可能會被限制住。

9.3 對接口限流

别以為限制了使用者和ip就萬事大吉,有些高手甚至可以使用代理,每次都請求都換一個ip。

這時可以限制請求的接口總次數。

秒殺系統要如何設計

在高并發場景下,這種限制對于系統的穩定性是非常有必要的。但可能由于有些非法請求次數太多,達到了該接口的請求上限,而影響其他的正常使用者通路該接口。看起來有點得不償失。

9.4 加驗證碼

相對于上面三種方式,加驗證碼的方式可能更精準一些,同樣能限制使用者的通路頻次,但好處是不會存在誤殺的情況。

秒殺系統要如何設計

通常情況下,使用者在請求之前,需要先輸入驗證碼。使用者發起請求之後,服務端會去校驗該驗證碼是否正确。隻有正确才允許進行下一步操作,否則直接傳回,并且提示驗證碼錯誤。

此外,驗證碼一般是一次性的,同一個驗證碼隻允許使用一次,不允許重複使用。

普通驗證碼,由于生成的數字或者圖案比較簡單,可能會被破解。優點是生成速度比較快,缺點是有安全隐患。

9.5 提高業務門檻