基於SpringBoot、Redis、LUA秒殺系統程式碼實現

關聯閱讀

《高併發秒殺場景下,基於Redis、LUA防止商品超賣 》

本文就是這篇文章在Spring Boot上的實現。關鍵知識點就是基於RedisTemplate來執行LUA指令碼,從而實現防止商品超賣。

相關需求&說明

一般來說秒殺系統的功能不會很多,有:

1、制定秒殺計劃。在某天幾點開始,售賣什麼商品,準備賣多少個,持續多久。

2、展示秒殺計劃列表。一般都是顯示當天的,8點賣一些,10點賣一些這種。

3、商品詳情頁。

4、下單購買。

等。

本文主要目的還是用程式碼實現一下防止商品超賣的功能,所以像制定秒殺計劃,展示商品等功能就不著重寫了。

還有電商的商品主要是SPU(例如iPhone 12,iPhone 11就是兩個SPU)及SKU(例如iPhone 12 64G 白色,iPhone 12 128G 黑色就是兩個SKU)的處理,展示的是SPU,購買扣庫存的是SKU,本文為了方便,就直接用product來替代了。

下單購買還會有一些前置條件,比如要經過風控系統,確認你是不是黃牛;營銷系統,有沒有相關的優惠券,虛擬貨幣之類的。

下單完成還要走庫管、物流,還有積分之類的,本文就不涉及了。

本文不涉及資料庫,一切都在Redis上操作,不過還是想說一下資料庫與快取資料一致性的問題。

如果我們的系統併發不高,資料庫撐得住,則直接操作資料庫即可,為防止超賣,可以採用:

悲觀鎖

select * from SKU表 where sku_id=1 for update;

或樂觀鎖

update SKU表 set stock=stock-1 where sku_id=1 and update_version=舊版本號;

如果併發高一些,例如商品詳情頁一般併發最高,為了減少資料庫的壓力,都會使用Redis等快取,為了保證資料庫與Redis的一致性,多是採用“修改後刪除”方案。

但是這個方案在更高併發情況下,如C10K、C10M等,在修改資料庫並刪除Redis內容的一瞬間,大量查詢併發會傳導至資料庫,產生異常。

這種情況,SPU詳情這種介面就堅決不能與資料庫連線起來。

步驟應該是:

1、B端管理系統操作資料庫(這個併發不會高)。

2、資料入庫後,傳送訊息給MQ。

3、相關處理程式在接收到訂閱的MQ的Topic後,從資料庫取出資訊,放入Redis。

4、相關服務介面只從Redis取資料。

程式碼實現

在實際專案中,建議將ToC端的秒殺產品相關介面組合為一個微服務,product-server。售賣介面組合為一個微服務,order-server。可以參考之前的Spring Cloud系列文章進行編碼,本文就簡單使用了一個Spring Boot工程。

《SpringCloud2020替換Netflix套件實踐一 》

《SpringCloud2020替換Netflix套件實踐二》

《SpringCloud2020替換Netflix套件實踐三》

《SpringCloud2020替換Netflix套件實踐四 》

秒殺計劃實體類:

省略get/set

public class SecKillPlanEntity implements Serializable { private static final long serialVersionUID = 8866797803960607461L; /** * id */ private Long id; /** * 商品id */ private Long productId; /** * 商品名稱 */ private String productName; /** * 價格 單位:分 */ private Long price; /** * 劃線價 單位:分 */ private Long linePrice; /** * 庫存數 */ private Long stock; /** * 一個使用者只買一件商品標識 0否1是 */ private int buyOneFlag; /** * 計劃狀態 0未提交,1已提交 */ private int planStatus; /** * 開始時間 */ private Date startTime; /** * 結束時間 */ private Date endTime; /** * 建立時間 */ private Date createTime;}

說明:

1、正如前文所說,秒殺的商品應該展示的是SPU,售賣扣庫存的是SKU,本文為了方便,只用product來替代。

2、使用者購買秒殺商品,有兩種方式:

A、一個使用者只允許購買一件。

B、一個使用者可以多次購買多件。

所以本類使用buyOneFlag做標識。

3、planStatus代表本次秒殺是否真正執行。0不展示給C端,不進行售賣;1展示給C端,進行售賣。

新增秒殺計劃&查詢秒殺計劃:

@RestControllerpublic class ProductController { @Resource private RedisTemplate redisTemplate; // 隨機生成秒殺計劃設定到Redis中 @GetMapping(“/addSecKillPlan”) @ResponseBody public DefaultResult> addSecKillPlan(@RequestParam(“saledate”) String saleDate) { DateTimeFormatter dtf = DateTimeFormatter。ofPattern(“yyyy-MM-dd HH:mm:ss”); Random rand = new Random(); Gson gson = new Gson(); List list = Lists。newArrayList(); for (int i = 0; i < 10; i++) { long productId = rand。nextInt(100) + 1; long price = rand。nextInt(100) + 1; long stock = rand。nextInt(100) + 1; String saleStartTime = “ 10:00:00”; String saleEndTime = “ 12:00:00”; int buyOneFlag = 0; if (i > 4) { saleStartTime = “ 14:00:00”; saleEndTime = “ 16:00:00”; buyOneFlag = 1; } SecKillPlanEntity entity = new SecKillPlanEntity(); entity。setId(i + 1L); entity。setProductId(productId); entity。setProductName(“商品” + productId); entity。setBuyOneFlag(buyOneFlag); entity。setLinePrice(999999L); entity。setPlanStatus(1); entity。setPrice(price * 100); entity。setStock(stock); entity。setEndTime(Date 。from(LocalDateTime。parse(saleDate + saleEndTime, dtf)。atZone(ZoneId。systemDefault())。toInstant())); entity。setStartTime(Date。from( LocalDateTime。parse(saleDate + saleStartTime, dtf)。atZone(ZoneId。systemDefault())。toInstant())); entity。setCreateTime(new Date()); // 商品詳情寫入Redis ValueOperations setProduct = redisTemplate。opsForValue(); setProduct。set(“product_” + productId, gson。toJson(entity)); // 寫入庫存 if (buyOneFlag == 1) { // 一個使用者只買一件商品 // 商品購買使用者Set redisTemplate。opsForSet()。add(“product_buyers_” + productId, “”); // 商品庫存 for (int j = 0; j < stock; j++) { redisTemplate。opsForList()。leftPush(“product_one_stock_” + productId, “1”); } } else { // 使用者可買多個 redisTemplate。opsForValue()。set(“product_stock_” + productId, stock + “”); } list。add(entity); System。out。println(gson。toJson(entity)); } redisTemplate。opsForValue()。set(“seckill_plan_” + saleDate, gson。toJson(list)); return DefaultResult。success(list); } @GetMapping(“/findSecKillPlanByDate”) @ResponseBody public DefaultResult> findSecKillPlanByDate(@RequestParam(“saledate”) String saleDate) { Gson gson = new Gson(); String planJson = redisTemplate。opsForValue()。get(“seckill_plan_” + saleDate); List list = gson。fromJson(planJson, new TypeToken>() { }。getType()); // 設定新的庫存 for (SecKillPlanEntity entity : list) { if (entity。getBuyOneFlag() == 1) { long newStock = redisTemplate。opsForList()。size(“product_one_stock_” + entity。getProductId()); entity。setStock(newStock); } else { long newStock = Long 。parseLong(redisTemplate。opsForValue()。get(“product_stock_” + entity。getProductId())); entity。setStock(newStock); } } return DefaultResult。success(list); }}

說明:

1、addSecKillPlan就是隨機生成10個售賣計劃,有僅售一件的,也有售多件的。並將相關資料壓入Redis。

seckill_plan_日期,代表某日的所有秒殺計劃,列表展示用。

product_商品ID,代表某商品資訊,詳情頁使用。

product_one_stock_商品ID,代表僅售一件商品的庫存數,值是List,有多少庫存,就往裡面push多少個“1”。

product_buyers_商品ID,代表僅售一件商品的購買者,已購買過的使用者不允許再買。

product_stock_商品ID,代表可售多件商品的庫存數,值是庫存數。

2、findSecKillPlanByDate,展示某日秒殺售賣計劃。庫存數從庫存相關的兩個KEY取。

LUA指令碼:

僅售一件buyone。lua:

——商品庫存Key product_one_stock_XXXlocal stockKey = KEYS[1]——商品購買使用者記錄Key product_buyers_XXXlocal buyersKey = KEYS[2]——使用者IDlocal uid = KEYS[3]——校驗使用者是否已經購買local result=redis。call(“sadd” , buyersKey , uid )if(tonumber(result)==1)then ——沒有購買過,可以購買 local stock=redis。call(“lpop” , stockKey ) ——除了nil和false,其他值都是真(包括0) if(stock) then ——有庫存 return 1 else ——沒有庫存 return -1 endelse ——已經購買過 return -3end

可售多件buymore。lua:

——商品Keylocal key = KEYS[1]——購買數local val = ARGV[1]——現有總庫存local stock = redis。call(“GET”, key)if (tonumber(stock)<=0) then ——沒有庫存 return -1else ——獲取扣減後的總庫存=總庫存-購買數 local decrstock=redis。call(“DECRBY”, key, val) if(tonumber(decrstock)>=0) then ——扣減購買數後沒有超賣,返回現庫存 return decrstock else ——超賣了,把扣減的再加回去 redis。call(“INCRBY”, key, val) return -2 endend

說明:

1、僅售一件。先把購買者的ID用命令“sadd”進product_buyers_商品ID,如果返回1,代表此使用者之前沒有購買過,否則返回-3,已經購買過。

在從product_one_stock_商品ID中lpop出數值,如果還有庫存,必會返回1,有庫存,否則就是nil,無庫存。

2、可售多件。之前講過,不再描述。

將兩個lua檔案,放在Spring Boot工程的resources目錄下。

售賣介面:

@RestControllerpublic class OrderController { @Resource private RedisTemplate redisTemplate; @GetMapping(“/addOrder”) @ResponseBody public DefaultResult addOrder(@RequestParam(“uid”) long userId, @RequestParam(“pid”) long productId, @RequestParam(“quantity”) int quantity) { Gson gson = new Gson(); String productJson = redisTemplate。opsForValue()。get(“product_” + productId); SecKillPlanEntity entity = gson。fromJson(productJson, SecKillPlanEntity。class); //TODO 要校驗售賣計劃是否已提交,是否到了售賣時間 long code = 0; if (entity。getBuyOneFlag() == 1) { // 使用者只買一件 code = this。buyOne(“product_one_stock_” + productId, “product_buyers_” + productId, userId); } else { // 使用者買多件 code = this。buyMore(“product_stock_” + productId, quantity); } DefaultResult result = DefaultResult。success(null); // 錯誤程式碼的處理應該使用ENUM,本文就節省了 if (code < 0) { result。setCode(code); if (code == -1) { result。setMsg(“沒有庫存”); } else if (code == -2) { result。setMsg(“庫存不足”); } else if (code == -3) { result。setMsg(“已經購買過”); } } return result; } private Long buyOne(String stockKey, String buysKey, long userId) { DefaultRedisScript defaultRedisScript = new DefaultRedisScript(); defaultRedisScript。setResultType(Long。class); defaultRedisScript。setScriptSource(new ResourceScriptSource(new ClassPathResource(“buyone。lua”))); // “{pre}:” List keys = Lists。newArrayList(stockKey, buysKey, userId + “”); Long result = redisTemplate。execute(defaultRedisScript, keys, “”); return result; } private Long buyMore(String stockKey, int quantity) { DefaultRedisScript defaultRedisScript = new DefaultRedisScript(); defaultRedisScript。setResultType(Long。class); defaultRedisScript。setScriptSource(new ResourceScriptSource(new ClassPathResource(“buymore。lua”))); List keys = Lists。newArrayList(stockKey); Long result = redisTemplate。execute(defaultRedisScript, keys, quantity+“”); return result; }}

說明:

1、主要看buyOne、buyMore兩個私有方法,裡面寫的是如何使用RedisTemplate執行lua指令碼。

另外我看有資料說如果使用的是Redis叢集,則會報錯,因為我沒有Redis的叢集環境,所以也沒法測試,大家有環境的可以試一試。

2、addOrder有一些程式碼為了節省時間,就寫得很low了,比如一些校驗沒有加,錯誤碼應該使用ENUM等。

測試用例:

1、A使用者購買僅售一件商品1,成功。

2、A使用者再購買僅售一件商品1,失敗。

3、N使用者購買僅售一件商品1,庫存不足。

4、A使用者購買可售多件商品2,成功。

5、A使用者購買可售多件商品2,庫存不足。

本人在本機環境下跑過,沒有問題,結果我就不放了。

牛年的第一篇文章,如果您覺得不錯,想繼續看的話,還請關注、點贊、評論、收藏、轉發[謝謝]。