1. 前言
秒殺本質上屬於短時突發性高併發訪問問題,業務特點如下:
- 定時觸發,流量在瞬間突增
- 秒殺請求中常常只有部分能夠成功
- 秒殺商品數量往往有限,不能超賣,但能接受少賣
- 不要求立即返回真實下單結果
本文主要講解秒殺場景中 RocketMQ 實戰使用,不詳細講解秒殺其他業務流程。
下面是秒殺流程圖:
想要了解具體實現的,參見詳細程式碼:大佬原始碼
2. 秒殺業務概述
通過對秒殺核心業務流程進行非同步化,我們能夠將主流程分為收單、下單兩個階段。
2.1 秒殺流程--收單
- 使用者訪問秒殺入口,將秒殺請求提交給秒殺平臺收單閘道器,平臺對秒殺請求進行前置校驗
- 校驗通過後,將下單請求通過快取/佇列/執行緒池等中間層進行提交,在投遞完成同時的同時就給使用者返回“排隊中”
- 對於前置校驗失敗的下單請求同步返回秒殺下單失敗
到此,對使用者側的互動就告一段落。
收單過程中,將秒殺訂單放入 RocketMQ 中間層中。
2.2 秒殺流程--下單
下單流程中,平臺的壓力通過中間層的緩衝其實已經小了很多,之所以會少,一方面是因為在使用者下單的同步校驗過程中就過濾掉了部分非法請求;另一方面,我們通過在中間層做一些限流、過濾等邏輯對下單請求做限速、壓單等操作,將下單請求在內部慢慢消化,儘可能減少流量對平臺持久層的衝擊。這裡其實就體現了中間層 “削峰填谷” 的特點。
基於上述前提,我們簡單總結下秒殺下單部分的業務邏輯。
- 秒殺訂單服務獲取中間層的下單請求,進行真實的下單前校驗,這裡主要進行庫存的真實校驗
- 扣減庫存(或稱鎖庫存)成功後,發起真實的下單操作。扣減庫存(鎖庫存)與下單操作一般在一個事務域中
- 下單成功後,平臺往往會發起訊息推送,告知使用者下單成功,並引導使用者進行支付操作
- 使用者一段時間(如:30mins)沒有支付,則訂單作廢,庫存恢復,給其他排隊中的使用者提供購買機會
- 如果使用者支付成功,則訂單狀態更新,訂單流轉到其他子系統,如:物流系統對該支付成功的處理中訂單進行發貨等後續處理
到此,基本上就是秒殺業務的核心主流程。
進一步抽象 秒殺請求->中間層->真實下單 這個場景,是不是很像我們經常用到的一種非同步業務處理模式?
相信有心的你已經看出來了,沒錯,這就是 “生產者-消費者” 模式。
“生產者-消費者”模式 在程式內,常常通過 阻塞佇列 或者 “等待-通知” 等機制實現,在服務之間則往往通過訊息佇列實現,這也是本次實戰所採用的技術實現手段。本文將通過 RocketMQ 訊息佇列,對秒殺下單進行解耦,實現削峰填谷、提高系統吞吐量的目的。
接下來將具體講解怎麼使用 RocketMQ 實現上述場景。
3. 實戰
3.1 結構
- 使用者訪問秒殺閘道器seckill-gateway-service,對感興趣的商品發起秒殺操作。特別的,對於商品資訊,在系統初始化的時候已經載入到 seckill-gateway-service。在進行前置庫存校驗的時候,依據快取已經做了一次使用者下單流量的過濾
- 閘道器對秒殺訂單進行充分的預校驗之後,將秒殺下單訊息投遞到 RocketMQ 中,同步向使用者返回排隊中
- 秒殺訂單平臺 seckill-order-service 訂閱秒殺下單訊息,對訊息進行冪等處理,並對商品庫存進行真實校驗後,進行真實下單操作
3.2 資料庫結構
3.3 NameServer配置
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class MQNamesrvConfig {
@Value("${rocketmq.nameServer.offline}")
String offlineNamesrv;
@Value("${rocketmq.nameServer.aliyun}")
String aliyunNamesrv;
/**
* 根據環境選擇nameServer地址
* @return
*/
public String nameSrvAddr() {
String envType = System.getProperty("envType");
//System.out.println(envType);
if (StringUtils.isBlank(envType)) {
throw new IllegalArgumentException("please insert envType");
}
switch (envType) {
case "offline" : {
return offlineNamesrv;
}
case "aliyun" : {
return aliyunNamesrv;
}
default : {
throw new IllegalArgumentException("please insert right envType, offline/aliyun");
}
}
}
}
複製程式碼
3.4 訊息協議
這裡通過實現 BaseMsg 的模板方法 encode、decode(分別表示對訊息進行編碼、解碼),通過對this物件進行屬性設定,實現了訊息協議的自編解碼。
/**
* @desc 基礎協議類
*/
public abstract class BaseMsg {
public Logger LOGGER = LoggerFactory.getLogger(this.getClass());
/**版本號,預設1.0*/
private String version = "1.0";
/**主題名*/
private String topicName;
public abstract String encode();
public abstract void decode(String msg);
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
public String getTopicName() {
return topicName;
}
public void setTopicName(String topicName) {
this.topicName = topicName;
}
@Override
public String toString() {
return "BaseMsg{" +
"version='" + version + '\'' +
", topicName='" + topicName + '\'' +
'}';
}
}
複製程式碼
/**
* @className OrderNofityProtocol
* @desc 訂單結果通知協議
*/
public class ChargeOrderMsgProtocol extends BaseMsg implements Serializable {
private static final long serialVersionUID = 73717163386598209L;
/**訂單號*/
private String orderId;
/**使用者下單手機號*/
private String userPhoneNo;
/**商品id*/
private String prodId;
/**使用者交易金額*/
private String chargeMoney;
private Map<String, String> header;
private Map<String, String> body;
@Override
public String encode() {
// 組裝訊息協議頭
ImmutableMap.Builder headerBuilder = new ImmutableMap.Builder<String, String>()
.put("version", this.getVersion())
.put("topicName", MessageProtocolConst.SECKILL_CHARGE_ORDER_TOPIC.getTopic());
header = headerBuilder.build();
body = new ImmutableMap.Builder<String, String>()
.put("orderId", this.getOrderId())
.put("userPhoneNo", this.getUserPhoneNo())
.put("prodId", this.getProdId())
.put("chargeMoney", this.getChargeMoney())
.build();
ImmutableMap<String, Object> map = new ImmutableMap.Builder<String, Object>()
.put("header", header)
.put("body", body)
.build();
// 返回序列化訊息Json串
String ret_string = null;
ObjectMapper objectMapper = new ObjectMapper();
try {
ret_string = objectMapper.writeValueAsString(map);
} catch (JsonProcessingException e) {
throw new RuntimeException("ChargeOrderMsgProtocol訊息序列化json異常", e);
}
return ret_string;
}
@Override
public void decode(String msg) {
Preconditions.checkNotNull(msg);
ObjectMapper mapper = new ObjectMapper();
try {
JsonNode root = mapper.readTree(msg);
// header
this.setVersion(root.get("header").get("version").asText());
this.setTopicName(root.get("header").get("topicName").asText());
// body
this.setOrderId(root.get("body").get("orderId").asText());
this.setUserPhoneNo(root.get("body").get("userPhoneNo").asText());
this.setChargeMoney(root.get("body").get("chargeMoney").asText());
this.setProdId(root.get("body").get("prodId").asText());
} catch (IOException e) {
throw new RuntimeException("ChargeOrderMsgProtocol訊息反序列化異常", e);
}
}
public String getOrderId() {
return orderId;
}
public ChargeOrderMsgProtocol setOrderId(String orderId) {
this.orderId = orderId;
return this;
}
public String getUserPhoneNo() {
return userPhoneNo;
}
public ChargeOrderMsgProtocol setUserPhoneNo(String userPhoneNo) {
this.userPhoneNo = userPhoneNo;
return this;
}
public String getProdId() {
return prodId;
}
public ChargeOrderMsgProtocol setProdId(String prodId) {
this.prodId = prodId;
return this;
}
public String getChargeMoney() {
return chargeMoney;
}
public ChargeOrderMsgProtocol setChargeMoney(String chargeMoney) {
this.chargeMoney = chargeMoney;
return this;
}
@Override
public String toString() {
return "ChargeOrderMsgProtocol{" +
"orderId='" + orderId + '\'' +
", userPhoneNo='" + userPhoneNo + '\'' +
", prodId='" + prodId + '\'' +
", chargeMoney='" + chargeMoney + '\'' +
", header=" + header +
", body=" + body +
"} " + super.toString();
}
}
複製程式碼
3.5 秒殺訂單生產者初始化
通過 @PostConstruct 方式載入(即 init() 方式)
import org.apache.rocketmq.acl.common.AclClientRPCHook;
import org.apache.rocketmq.acl.common.SessionCredentials;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.gateway.common.config.MQNamesrvConfig;
import org.apache.rocketmq.gateway.common.util.LogExceptionWapper;
import org.apache.rocketmq.message.constant.MessageProtocolConst;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* @className SecKillChargeOrderProducer
* @desc 秒殺訂單生產者初始化
*/
@Component
public class SecKillChargeOrderProducer {
private static final Logger LOGGER = LoggerFactory.getLogger(SecKillChargeOrderProducer.class);
@Autowired
MQNamesrvConfig namesrvConfig;
@Value("${rocketmq.acl.accesskey}")
String aclAccessKey;
@Value("${rocketmq.acl.accessSecret}")
String aclAccessSecret;
private DefaultMQProducer defaultMQProducer;
@PostConstruct
public void init() {
defaultMQProducer =
new DefaultMQProducer
(MessageProtocolConst.SECKILL_CHARGE_ORDER_TOPIC.getProducerGroup(),
new AclClientRPCHook(new SessionCredentials(aclAccessKey, aclAccessSecret)));
defaultMQProducer.setNamesrvAddr(namesrvConfig.nameSrvAddr());
// 傳送失敗重試次數
defaultMQProducer.setRetryTimesWhenSendFailed(3);
try {
defaultMQProducer.start();
} catch (MQClientException e) {
LOGGER.error("[秒殺訂單生產者]--SecKillChargeOrderProducer載入異常!e={}", LogExceptionWapper.getStackTrace(e));
throw new RuntimeException("[秒殺訂單生產者]--SecKillChargeOrderProducer載入異常!", e);
}
LOGGER.info("[秒殺訂單生產者]--SecKillChargeOrderProducer載入完成!");
}
public DefaultMQProducer getProducer() {
return defaultMQProducer;
}
}
複製程式碼
3.6 秒殺訂單入隊(生產者)
/**
* 平臺下單介面
* @param chargeOrderRequest
* @return
*/
@RequestMapping(value = "charge.do", method = {RequestMethod.POST})
public @ResponseBody Result chargeOrder(@ModelAttribute ChargeOrderRequest chargeOrderRequest) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String sessionId = attributes.getSessionId();
// 下單前置引數校驗
if (!secKillChargeService.checkParamsBeforeSecKillCharge(chargeOrderRequest, sessionId)) {
return Result.error(CodeMsg.PARAM_INVALID);
}
// 前置商品校驗
String prodId = chargeOrderRequest.getProdId();
if (!secKillChargeService.checkProdConfigBeforeKillCharge(prodId, sessionId)) {
return Result.error(CodeMsg.PRODUCT_NOT_EXIST);
}
// 前置預減庫存
if (!secKillProductConfig.preReduceProdStock(prodId)) {
return Result.error(CodeMsg.PRODUCT_STOCK_NOT_ENOUGH);
}
// 秒殺訂單入隊
return secKillChargeService.secKillOrderEnqueue(chargeOrderRequest, sessionId);
}
複製程式碼
生產者:secKillChargeService::secKillOrderEnqueue
/**
* 秒殺訂單入隊
* @param chargeOrderRequest
* @param sessionId
* @return
*/
@Override
public Result secKillOrderEnqueue(ChargeOrderRequest chargeOrderRequest, String sessionId) {
// 訂單號生成,組裝秒殺訂單訊息協議
String orderId = UUID.randomUUID().toString();
String phoneNo = chargeOrderRequest.getUserPhoneNum();
//訊息封裝
ChargeOrderMsgProtocol msgProtocol = new ChargeOrderMsgProtocol();
msgProtocol.setUserPhoneNo(phoneNo)
.setProdId(chargeOrderRequest.getProdId())
.setChargeMoney(chargeOrderRequest.getChargePrice())
.setOrderId(orderId);
String msgBody = msgProtocol.encode();
LOGGER.info("秒殺訂單入隊,訊息協議={}", msgBody);
DefaultMQProducer mqProducer = secKillChargeOrderProducer.getProducer();
// 組裝RocketMQ訊息體
Message message = new Message(MessageProtocolConst.SECKILL_CHARGE_ORDER_TOPIC.getTopic(), msgBody.getBytes());
try {
// 訊息傳送
SendResult sendResult = mqProducer.send(message);
//判斷SendStatus
if (sendResult == null) {
LOGGER.error("sessionId={},秒殺訂單訊息投遞失敗,下單失敗.msgBody={},sendResult=null", sessionId, msgBody);
return Result.error(CodeMsg.BIZ_ERROR);
}
if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
LOGGER.error("sessionId={},秒殺訂單訊息投遞失敗,下單失敗.msgBody={},sendResult=null", sessionId, msgBody);
return Result.error(CodeMsg.BIZ_ERROR);
}
ChargeOrderResponse chargeOrderResponse = new ChargeOrderResponse();
BeanUtils.copyProperties(msgProtocol, chargeOrderResponse);
LOGGER.info("sessionId={},秒殺訂單訊息投遞成功,訂單入隊.出參chargeOrderResponse={},sendResult={}", sessionId, chargeOrderResponse.toString(), JSON.toJSONString(sendResult));
return Result.success(CodeMsg.ORDER_INLINE, chargeOrderResponse);
} catch (Exception e) {
int sendRetryTimes = mqProducer.getRetryTimesWhenSendFailed();
LOGGER.error("sessionId={},sendRetryTimes={},秒殺訂單訊息投遞異常,下單失敗.msgBody={},e={}", sessionId, sendRetryTimes, msgBody, LogExceptionWapper.getStackTrace(e));
}
return Result.error(CodeMsg.BIZ_ERROR);
}
複製程式碼
3.7 秒殺消費
3.7.1 定義消費者客戶端
秒殺下單消費者
@Component
public class SecKillChargeOrderConsumer {
private static final Logger LOGGER = LoggerFactory.getLogger(SecKillChargeOrderConsumer.class);
@Autowired
MQNamesrvConfig namesrvConfig;
@Value("${rocketmq.acl.accesskey}")
String aclAccessKey;
@Value("${rocketmq.acl.accessSecret}")
String aclAccessSecret;
private DefaultMQPushConsumer defaultMQPushConsumer;
@Resource(name = "secKillChargeOrderListenerImpl")
private MessageListenerConcurrently messageListener;
@PostConstruct
public void init() {
defaultMQPushConsumer =
new DefaultMQPushConsumer(
MessageProtocolConst.SECKILL_CHARGE_ORDER_TOPIC.getConsumerGroup(),
new AclClientRPCHook(new SessionCredentials(aclAccessKey, aclAccessSecret)),
// 平均分配佇列演算法,hash
new AllocateMessageQueueAveragely());
defaultMQPushConsumer.setNamesrvAddr(namesrvConfig.nameSrvAddr());
// 從頭開始消費
defaultMQPushConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
// 消費模式:叢集模式
// 叢集:同一條訊息 只會被一個消費者節點消費到
// 廣播:同一條訊息 每個消費者都會消費到
defaultMQPushConsumer.setMessageModel(MessageModel.CLUSTERING);
// 註冊監聽器
defaultMQPushConsumer.registerMessageListener(messageListener);
// 設定每次拉取的訊息量,預設為1
defaultMQPushConsumer.setConsumeMessageBatchMaxSize(1);
// 訂閱所有訊息
try {
defaultMQPushConsumer.subscribe(MessageProtocolConst.SECKILL_CHARGE_ORDER_TOPIC.getTopic(), "*");
// 啟動消費者
defaultMQPushConsumer.start();
} catch (MQClientException e) {
LOGGER.error("[秒殺下單消費者]--SecKillChargeOrderConsumer載入異常!e={}", LogExceptionWapper.getStackTrace(e));
throw new RuntimeException("[秒殺下單消費者]--SecKillChargeOrderConsumer載入異常!", e);
}
LOGGER.info("[秒殺下單消費者]--SecKillChargeOrderConsumer載入完成!");
}
}
複製程式碼
3.7.2 實現秒殺收單核心邏輯
實現秒殺收單核心的邏輯,也就是實現我們自己的MessageListenerConcurrently。
@Component
public class SecKillChargeOrderListenerImpl implements MessageListenerConcurrently {
private static final Logger LOGGER = LoggerFactory.getLogger(SecKillChargeOrderListenerImpl.class);
@Resource(name = "secKillOrderService")
SecKillOrderService secKillOrderService;
@Autowired
SecKillProductService secKillProductService;
/**
* 秒殺核心消費邏輯
* @param msgs
* @param context
* @return
*/
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
for (MessageExt msg : msgs) {
// 訊息解碼
String message = new String(msg.getBody());
int reconsumeTimes = msg.getReconsumeTimes();
String msgId = msg.getMsgId();
String logSuffix = ",msgId=" + msgId + ",reconsumeTimes=" + reconsumeTimes;
LOGGER.info("[秒殺訂單消費者]-SecKillChargeOrderConsumer-接收到訊息,message={},{}", message, logSuffix);
// 反序列化協議實體
ChargeOrderMsgProtocol chargeOrderMsgProtocol = new ChargeOrderMsgProtocol();
chargeOrderMsgProtocol.decode(message);
LOGGER.info("[秒殺訂單消費者]-SecKillChargeOrderConsumer-反序列化為秒殺入庫訂單實體chargeOrderMsgProtocol={},{}", chargeOrderMsgProtocol.toString(), logSuffix);
// 消費冪等:查詢orderId對應訂單是否已存在
String orderId = chargeOrderMsgProtocol.getOrderId();
OrderInfoDobj orderInfoDobj = secKillOrderService.queryOrderInfoById(orderId);
if (orderInfoDobj != null) {
LOGGER.info("[秒殺訂單消費者]-SecKillChargeOrderConsumer-當前訂單已入庫,不需要重複消費!,orderId={},{}", orderId, logSuffix);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// 業務冪等:同一個prodId+同一個userPhoneNo只有一個秒殺訂單
OrderInfoDO orderInfoDO = new OrderInfoDO();
orderInfoDO.setProdId(chargeOrderMsgProtocol.getProdId())
.setUserPhoneNo(chargeOrderMsgProtocol.getUserPhoneNo());
Result result = secKillOrderService.queryOrder(orderInfoDO);
if (result != null && result.getCode().equals(CodeMsg.SUCCESS.getCode())) {
LOGGER.info("[秒殺訂單消費者]-SecKillChargeOrderConsumer-當前使用者={},秒殺的產品={}訂單已存在,不得重複秒殺,orderId={}",
orderInfoDO.getUserPhoneNo(), orderInfoDO.getProdId(), orderId);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// 秒殺訂單入庫
OrderInfoDO orderInfoDODB = new OrderInfoDO();
BeanUtils.copyProperties(chargeOrderMsgProtocol, orderInfoDODB);
// 庫存校驗
String prodId = chargeOrderMsgProtocol.getProdId();
SecKillProductDobj productDobj = secKillProductService.querySecKillProductByProdId(prodId);
// 取庫存校驗
int currentProdStock = productDobj.getProdStock();
if (currentProdStock <= 0) {
LOGGER.info("[decreaseProdStock]當前商品已售罄,訊息消費成功!prodId={},currStock={}", prodId, currentProdStock);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// 正式下單
if (secKillOrderService.chargeSecKillOrder(orderInfoDODB)) {
LOGGER.info("[秒殺訂單消費者]-SecKillChargeOrderConsumer-秒殺訂單入庫成功,訊息消費成功!,入庫實體orderInfoDO={},{}", orderInfoDO.toString(), logSuffix);
// 模擬訂單處理,直接修改訂單狀態為處理中
secKillOrderService.updateOrderStatusDealing(orderInfoDODB);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
} catch (Exception e) {
LOGGER.info("[秒殺訂單消費者]消費異常,e={}", LogExceptionWapper.getStackTrace(e));
}
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
複製程式碼
3.7.3 秒殺實際入庫
實際下單操作與實際庫存扣減處於同一個本地事務中
/**
* 秒殺訂單入庫
* @param orderInfoDO
* @return
*/
@Transactional(rollbackFor = Exception.class)
@Override
public boolean chargeSecKillOrder(OrderInfoDO orderInfoDO) {
int insertCount = 0;
String orderId = orderInfoDO.getOrderId();
String prodId = orderInfoDO.getProdId();
// 減庫存
if (!secKillProductService.decreaseProdStock(prodId)) {
LOGGER.info("[insertSecKillOrder]orderId={},prodId={},下單前減庫存失敗,下單失敗!", orderId, prodId);
// TODO 此處可給使用者傳送通知,告知秒殺下單失敗,原因:商品已售罄
return false;
}
// 設定產品名稱
SecKillProductDobj productInfo = secKillProductService.querySecKillProductByProdId(prodId);
orderInfoDO.setProdName(productInfo.getProdName());
try {
insertCount = secKillOrderMapper.insertSecKillOrder(orderInfoDO);
} catch (Exception e) {
LOGGER.error("[insertSecKillOrder]orderId={},秒殺訂單入庫[異常],事務回滾,e={}", orderId, LogExceptionWapper.getStackTrace(e));
String message =
String.format("[insertSecKillOrder]orderId=%s,秒殺訂單入庫[異常],事務回滾", orderId);
throw new RuntimeException(message);
}
if (insertCount != 1) {
LOGGER.error("[insertSecKillOrder]orderId={},秒殺訂單入庫[失敗],事務回滾,e={}", orderId);
String message =
String.format("[insertSecKillOrder]orderId=%s,秒殺訂單入庫[失敗],事務回滾", orderId);
throw new RuntimeException(message);
}
return true;
}
複製程式碼
4. 小結&參考資料
小結
再看看一遍流程圖,不懂的地方,看看原始碼。
對於下單後的支付、物流等操作都可以通過使用 RocketMQ 進行非同步化處理。
本文全部程式碼來源於大佬原始碼
僅為學習研究 RocketMQ 實戰。