Redis lua腳本解決搶購秒殺場景
介紹
秒殺搶購可以説是在分佈式環境下⼀個⾮常經典的案例,⾥邊有很多痛點:
1.⾼併發: 時間極短、瞬間⽤户量⼤,⼀瞬間的⾼QPS把系統或數據庫直接打死,響應失敗,導致與這個系統耦合的系統也GG
目前秒殺的實現方案主要有兩種:
2.超賣: 你只有⼀百件商品,由於是⾼併發的問題,導致超賣的情況
目前秒殺的實現方案主要有兩種:
1.用redis 將搶購信息進行存儲。然後再慢慢消費。 同時,服務器給與用户快速響應。
2.用mq實現,比如RabbitMQ,服務器將請求過來的數據先讓RabbitMQ存起來,然後再慢慢消費掉。
也可以結合redis與mq的方式,通過redis控制剩餘庫存,達到快速響應,將滿足條件的購買的訂單先讓RabbitMQ存起來,後續在慢慢消化。
整體流程:
1.服務器接收到了大量用户請求過來(1s 2000個請求)。比如傳了用户信息,產品信息,和購買數量信息。此時 服務器採用redis 的lua 腳本 去調用redis 中間件。lua 腳本的邏輯是減庫存,校驗庫存是否足夠。然後迅速給與服務器反饋(庫存是否夠,夠返回 1 ,不夠返回 0)。
2.服務器迅速給與用户的請求反饋。提示搶購成功.或者搶購失敗
3.搶購成功,將訂單信息放入MQ,其餘線程接受到MQ的信息後,將訂單信息存入DB中
4.後面客户就可以查詢 mysql 的訂單信息了。
代碼展示
架構採用springboot+redis+mysql+myBatis.
數據庫
CREATE TABLE `tb_product` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'id',
`price` decimal(65,18) NOT NULL DEFAULT '0',
`available_qty` bigint NOT NULL DEFAULT '0' COMMENT '發行數量',
`title` varchar(1024) NOT NULL DEFAULT '',
`end_time` bigint NOT NULL DEFAULT '0',
`start_time` bigint NOT NULL DEFAULT '0',
`created` bigint NOT NULL DEFAULT '0',
`updated` bigint NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
pom依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
lua 腳本:
1.減少庫存,校驗庫存是否充足
2.庫存數量回滾:
核心業務代碼展示
1.加載lua腳本
private final static DefaultRedisScript<Long> deductRedisScript = new DefaultRedisScript();
private final static DefaultRedisScript<Long> increaseRedisScript = new DefaultRedisScript();
//加載lua腳本
@PostConstruct
void init() {
//加載削減庫存lua腳本
deductRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("redis/fixedDeductInventory.lua")));
deductRedisScript.setResultType(Long.class);
//加載庫存回滾lua腳本
increaseRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("redis/fixedIncreaseInventory.lua")));
increaseRedisScript.setResultType(Long.class);
}
2.添加庫存到redis
注意點:在使用redis集羣時,lua腳本中存在多個key時,可以通過hash tag這個方法將不同key的值落在同一個槽位上,hash tag 是通過{}這對括號括起來的字符串,如果下列中{fixed:" + data.getProductId() + "} 作為tag,確保同一個產品的信息都在同一個槽位。
@Resource(name = "fixedCacheRedisTemplate")
private RedisTemplate<String, Long> fixedCacheRedisTemplate;
public void ProductToOngoing(Product data, Long time) {
//設置數量
long number = data.getAvailableQty();
fixedCacheRedisTemplate.opsForHash().putIfAbsent("{fixed:" + data.getProductId() + "}-residue_stock_" + data.getRecordId(),
"{fixed:" + data.getProductId() + "}-residueStock" , number);
String statusKey = "fixed_product_sold_status_"+ data.getRecordId();
long timeout = data.getEndTime() - data.getStartTime();
//添加產品出售狀態
fixedCacheRedisTemplate.opsForValue().set(statusKey, 1L, data.getEndTime() - data.getStartTime(), TimeUnit.MILLISECONDS);
}
3.下單&庫存校驗
//檢查庫存
public boolean checkFixedOrderQty(Long userId, Long productId, Long quantity, Long overTime) {
Boolean pendingOrder = false;
String userKey = "";
try {
//校驗是否開始
String statusKey = "fixed_product_sold_status_" + productId;
Long fixedStartStatus = fixedCacheRedisTemplate.opsForValue().get(statusKey);
if (fixedStartStatus == null || fixedStartStatus != 1L) {
//報錯返回,商品未開售
throw new WebException(ResultCode.SALE_HAS_NOT_START);
}
//檢查庫存數量
Long number = deductInventory(productId, quantity);
if (number != 1L) {
log.warn("availbale num is null:{} {}", productId, number);
throw new WebException(ResultCode.AVAILABLE_AMOUNT_INSUFFICIENT);
}
return true;
} catch (Exception e) {
log.warn("checkFixedOrderQty error:{}", e.getMessage(), e);
throw e;
}
}
//下單
public void createOrder(Long userId, Long productId, BigDecimal price, Long quantity){
boolean check = checkFixedOrderQty(userId, productId, quantity);
try {
if (check) {
//添加MQ等待下單,後續收到推送的線程保存靠DB中
CreateCoinOrderData data = new CreateCoinOrderData();
data.setUserId(userId);
data.setProductId(productId);
data.setPrice(price);
data.setQuantity(quantity);
rabbitmqProducer.sendMessage(1, JSONObject.toJSONString(data));
}
} catch (Exception e) {
//發生異常,庫存需要回滾
increaseInventory(recordId, quantity, 1L);
throw e;
}
}
//庫存回填
public Long increaseInventory(Long productId, Long num) {
try {
// 構建keys信息,代表hash值中所需要的key信息
List<String> keys = Arrays.asList("{fixed:" + productId + "}-residue_stock_"+ recordId, "{fixed:" + productId + "}-residueStock");
// 執行腳本
Object result = fixedCacheRedisTemplate.execute(increaseRedisScript, keys, num);
log.info("increaseInventory productId :{} num:{} result:{}", productId, num, result);
return (Long) result;
} catch (Exception e) {
log.warn("increaseInventory error productId:{} num:{}", productId, num);
}
return 0L;
}