基于代碼實操SpringBoot、Redis、LUA秒殺系統(tǒng)
前言
那些吧redis基本的東西學(xué)的差不多了,卻沒有做過什么具體的項目實踐的,可以看看這篇文章做一個項目來鞏固知識。
相關(guān)需求&說明
一般來說秒殺系統(tǒng)的功能不會很多,有:
- 制定秒殺計劃。在某天幾點開始,售賣什么商品,準(zhǔn)備賣多少個,持續(xù)多久。
- 展示秒殺計劃列表。一般都是顯示當(dāng)天的,8點賣一些,10點賣一些這種。
- 商品詳情頁。
- 下單購買。
- 等等
本文主要目的還是用代碼實現(xiàn)一下防止商品超賣的功能,所以像制定秒殺計劃,展示商品等功能就不著重寫了。
還有電商的商品主要是SPU(例如iPhone 12,iPhone 11就是兩個SPU)及SKU(例如iPhone 12 64G 白色,iPhone 12 128G 黑色就是兩個SKU)的處理,展示的是SPU,購買扣庫存的是SKU,本文為了方便,就直接用product來替代了。
下單購買還會有一些前置條件,比如要經(jīng)過風(fēng)控系統(tǒng),確認(rèn)你是不是黃牛;營銷系統(tǒng),有沒有相關(guān)的優(yōu)惠券,虛擬貨幣之類的。
下單完成還要走庫管、物流,還有積分之類的,本文就不涉及了。 本文不涉及數(shù)據(jù)庫,一切都在Redis上操作,不過還是想說一下數(shù)據(jù)庫與緩存數(shù)據(jù)一致性的問題。
如果我們的系統(tǒng)并發(fā)不高,數(shù)據(jù)庫撐得住,則直接操作數(shù)據(jù)庫即可,為防止超賣,可以采用:
悲觀鎖
- select * from SKU表 where sku_id=1 for update;
樂觀鎖
- update SKU表 set stock=stock-1 where sku_id=1 and update_version=舊版本號;
果并發(fā)高一些,例如商品詳情頁一般并發(fā)最高,為了減少數(shù)據(jù)庫的壓力,都會使用Redis等緩存,為了保證數(shù)據(jù)庫與Redis的一致性,多是采用“修改后刪除”方案。 但是這個方案在更高并發(fā)情況下,如C10K、C10M等,在修改數(shù)據(jù)庫并刪除Redis內(nèi)容的一瞬間,大量查詢并發(fā)會傳導(dǎo)至數(shù)據(jù)庫,產(chǎn)生異常。 這種情況,SPU詳情這種接口就堅決不能與數(shù)據(jù)庫連接起來。 步驟應(yīng)該是:
- B端管理系統(tǒng)操作數(shù)據(jù)庫(這個并發(fā)不會高)。
- 數(shù)據(jù)入庫后,發(fā)送消息給MQ。
- 相關(guān)處理程序在接收到訂閱的MQ的Topic后,從數(shù)據(jù)庫取出信息,放入Redis。
- 相關(guān)服務(wù)接口只從Redis取數(shù)據(jù)。
代碼實現(xiàn)
在實際項目中,建議將ToC端的秒殺產(chǎn)品相關(guān)接口組合為一個微服務(wù),product-server。售賣接口組合為一個微服務(wù),order-server??梢詤⒖贾暗腟pring Cloud系列文章進(jìn)行編碼,本文就簡單使用了一個Spring Boot工程。
秒殺計劃實體類
省略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;
- /**
- * 庫存數(shù)
- */
- private Long stock;
- /**
- * 一個用戶只買一件商品標(biāo)識 0否1是
- */
- private int buyOneFlag;
- /**
- * 計劃狀態(tài) 0未提交,1已提交
- */
- private int planStatus;
- /**
- * 開始時間
- */
- private Date startTime;
- /**
- * 結(jié)束時間
- */
- private Date endTime;
- /**
- * 創(chuàng)建時間
- */
- private Date createTime;
- }
說明:
正如前文所說,秒殺的商品應(yīng)該展示的是SPU,售賣扣庫存的是SKU,本文為了方便,只用product來替代。
用戶購買秒殺商品,有兩種方式:
- 一個用戶只允許購買一件。
- 一個用戶可以多次購買多件。
所以本類使用buyOneFlag做標(biāo)識。
planStatus代表本次秒殺是否真正執(zhí)行。0不展示給C端,不進(jìn)行售賣;1展示給C端,進(jìn)行售賣。
添加秒殺計劃&查詢秒殺計劃
- @RestController
- public class ProductController {
- @Resource
- private RedisTemplate<String, String> redisTemplate;
- // 隨機(jī)生成秒殺計劃設(shè)置到Redis中
- @GetMapping("/addSecKillPlan")
- @ResponseBody
- public DefaultResult<List<SecKillPlanEntity>> addSecKillPlan(@RequestParam("saledate") String saleDate) {
- DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
- Random rand = new Random();
- Gson gson = new Gson();
- List<SecKillPlanEntity> 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<String, String> 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<List<SecKillPlanEntity>> findSecKillPlanByDate(@RequestParam("saledate") String saleDate) {
- Gson gson = new Gson();
- String planJson = redisTemplate.opsForValue().get("seckill_plan_" + saleDate);
- List<SecKillPlanEntity> list = gson.fromJson(planJson, new TypeToken<List<SecKillPlanEntity>>() {
- }.getType());
- // 設(shè)置新的庫存
- 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);
- }
- }
說明:
- addSecKillPlan就是隨機(jī)生成10個售賣計劃,有僅售一件的,也有售多件的。并將相關(guān)數(shù)據(jù)壓入Redis。
- seckill_plan_日期,代表某日的所有秒殺計劃,列表展示用。
- product_商品ID,代表某商品信息,詳情頁使用。
- product_one_stock_商品ID,代表僅售一件商品的庫存數(shù),值是List,有多少庫存,就往里面push多少個“1”。
- product_buyers_商品ID,代表僅售一件商品的購買者,已購買過的用戶不允許再買。
- product_stock_商品ID,代表可售多件商品的庫存數(shù),值是庫存數(shù)。
findSecKillPlanByDate,展示某日秒殺售賣計劃。庫存數(shù)從庫存相關(guān)的兩個KEY取。
LUA腳本
僅售一件buyone.lua:
- --商品庫存Key product_one_stock_XXX
- local stockKey = KEYS[1]
- --商品購買用戶記錄Key product_buyers_XXX
- local buyersKey = KEYS[2]
- --用戶ID
- local uid = KEYS[3]
- --校驗用戶是否已經(jīng)購買
- 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
- end
- else
- --已經(jīng)購買過
- return -3
- end
可售多件buymore.lua:
- --商品Key
- local key = KEYS[1]
- --購買數(shù)
- local val = ARGV[1]
- --現(xiàn)有總庫存
- local stock = redis.call("GET", key)
- if (tonumber(stock)<=0)
- then
- --沒有庫存
- return -1
- else
- --獲取扣減后的總庫存=總庫存-購買數(shù)
- local decrstock=redis.call("DECRBY", key, val)
- if(tonumber(decrstock)>=0)
- then
- --扣減購買數(shù)后沒有超賣,返回現(xiàn)庫存
- return decrstock
- else
- --超賣了,把扣減的再加回去
- redis.call("INCRBY", key, val)
- return -2
- end
- end
說明:
1、僅售一件。先把購買者的ID用命令“sadd”進(jìn)product_buyers_商品ID,如果返回1,代表此用戶之前沒有購買過,否則返回-3,已經(jīng)購買過。
- 在從product_one_stock_商品ID中l(wèi)pop出數(shù)值,如果還有庫存,必會返回1,有庫存,否則就是nil,無庫存。
2.、可售多件。之前講過,不再描述。 將兩個lua文件,放在Spring Boot工程的resources目錄下。
售賣接口
- @RestController
- public class OrderController {
- @Resource
- private RedisTemplate<String, String> redisTemplate;
- @GetMapping("/addOrder")
- @ResponseBody
- public DefaultResult<Void> 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<Void> result = DefaultResult.success(null);
- // 錯誤代碼的處理應(yīng)該使用ENUM,本文就節(jié)省了
- if (code < 0) {
- result.setCode(code);
- if (code == -1) {
- result.setMsg("沒有庫存");
- } else if (code == -2) {
- result.setMsg("庫存不足");
- } else if (code == -3) {
- result.setMsg("已經(jīng)購買過");
- }
- }
- return result;
- }
- private Long buyOne(String stockKey, String buysKey, long userId) {
- DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<Long>();
- defaultRedisScript.setResultType(Long.class);
- defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("buyone.lua")));
- // "{pre}:"
- List<String> keys = Lists.newArrayList(stockKey, buysKey, userId + "");
- Long result = redisTemplate.execute(defaultRedisScript, keys, "");
- return result;
- }
- private Long buyMore(String stockKey, int quantity) {
- DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<Long>();
- defaultRedisScript.setResultType(Long.class);
- defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("buymore.lua")));
- List<String> keys = Lists.newArrayList(stockKey);
- Long result = redisTemplate.execute(defaultRedisScript, keys, quantity+"");
- return result;
- }
- }
說明: 1、主要看buyOne、buyMore兩個私有方法,里面寫的是如何使用RedisTemplate執(zhí)行l(wèi)ua腳本。
另外我看有資料說如果使用的是Redis集群,則會報錯,因為我沒有Redis的集群環(huán)境,所以也沒法測試,大家有環(huán)境的可以試一試。
2、addOrder有一些代碼為了節(jié)省時間,就寫得很low了,比如一些校驗沒有加,錯誤碼應(yīng)該使用ENUM等。 測試用例:
- A用戶購買僅售一件商品1,成功。
- A用戶再購買僅售一件商品1,失敗。
- N用戶購買僅售一件商品1,庫存不足。
- A用戶購買可售多件商品2,成功。
- A用戶購買可售多件商品2,庫存不足。