網(wǎng)上有很多關(guān)于pos機(jī)接收返回超時(shí),100億訂單超時(shí)處理的知識(shí),也有很多人為大家解答關(guān)于pos機(jī)接收返回超時(shí)的問(wèn)題,今天pos機(jī)之家(www.mxllmx.com)為大家整理了關(guān)于這方面的知識(shí),讓我們一起來(lái)看下吧!
本文目錄一覽:
pos機(jī)接收返回超時(shí)
▌背景:
超時(shí)處理,是一個(gè)很有技術(shù)難度的問(wèn)題。
所以很多的小伙伴,在寫(xiě)簡(jiǎn)歷的時(shí)候,喜歡把這個(gè)技術(shù)難題寫(xiě)在簡(jiǎn)歷里邊, 體現(xiàn)自己高超的技術(shù)水平。
在40歲老架構(gòu)師 尼恩的讀者社區(qū)(50+個(gè))中,尼恩經(jīng)常指導(dǎo)大家 優(yōu)化簡(jiǎn)歷。
最近,有小伙伴寫(xiě)簡(jiǎn)歷,就寫(xiě)了這問(wèn)題:
通過(guò) 定時(shí)任務(wù)+ 數(shù)據(jù)分片的方式,進(jìn)行訂單的超時(shí)處理。
尼恩去問(wèn)他,怎么進(jìn)行分片、怎么進(jìn)行調(diào)度的。
小伙伴就開(kāi)始一半糊涂、一半清晰。也就是說(shuō),他也能講一些細(xì)節(jié),比如怎么分片,怎么調(diào)度,感覺(jué)好像是接觸過(guò)。
為啥說(shuō)一半糊涂呢? 就是他的方案的 空白點(diǎn)太多, 隨便找?guī)讉€(gè)點(diǎn)發(fā)問(wèn), 就講不清楚了。
一半糊涂、一半清晰的方案,問(wèn)題很?chē)?yán)重,為啥呢? 從面試官的角度來(lái)說(shuō),這 就是沒(méi)有真正干過(guò), 說(shuō)明是盜用的其他人的方案。
這種案例,在尼恩的招人生涯中見(jiàn)得太多。面試過(guò)N多這種看上去牛逼轟轟,實(shí)際上 稀里糊涂的面試者,沒(méi)有一次讓他們過(guò)的。
那么問(wèn)題來(lái)了,訂單的超時(shí)處理的方案,具體是什么樣的呢?
這里尼恩給大家做一下系統(tǒng)化、體系化的 訂單的超時(shí)處理的方案,使得大家可以充分展示一下大家雄厚的 “技術(shù)肌肉”,讓面試官愛(ài)到 “不能自已、口水直流”。
也一并把這個(gè)題目以及參考答案,收入咱們的《尼恩java面試寶典 PDF》,供后面的小伙伴參考,提升大家的 3高 架構(gòu)、設(shè)計(jì)、開(kāi)發(fā)水平。
注:本文以 PDF 持續(xù)更新,最新尼恩 架構(gòu)筆記、面試題 的PDF文件,請(qǐng)到《技術(shù)自由圈》公號(hào)領(lǐng)取
▌100億訂單的超時(shí)處理難題
100億訂單的超時(shí)處理,是一個(gè)很有技術(shù)難度的問(wèn)題。
比如,用戶下了一個(gè)訂單之后,需要在指定時(shí)間內(nèi)(例如30分鐘)進(jìn)行支付,在到期之前可以發(fā)送一個(gè)消息提醒用戶進(jìn)行支付。
下面是一個(gè)訂單的流程:
如上圖所示,實(shí)際的生產(chǎn)場(chǎng)景中,一個(gè)訂單流程中有許多環(huán)節(jié)要用到定時(shí)處理,比如:
買(mǎi)家超時(shí)未付款:超過(guò)15分鐘沒(méi)有支付,訂單自動(dòng)取消。商家超時(shí)未發(fā)貨:商家超過(guò)1個(gè)月沒(méi)發(fā)貨,訂單自動(dòng)取消。買(mǎi)家超時(shí)未收貨:商家發(fā)貨后,買(mǎi)家沒(méi)有在14天內(nèi)點(diǎn)擊確認(rèn)收貨,則自動(dòng)收貨。▌海量任務(wù)的定時(shí)處理方案
基于內(nèi)存的延遲隊(duì)列/優(yōu)先級(jí)隊(duì)列處理基于內(nèi)存的時(shí)間輪調(diào)度基于分布式隊(duì)列延遲消息的定時(shí)方案基于分布式K-V組件(如Redis)過(guò)期時(shí)間的定時(shí)方案一些消息中間件的Broker端內(nèi)置了延遲消息支持的能力
▌方案1:基于內(nèi)存的延時(shí)隊(duì)列
JDK中提供了一種延遲隊(duì)列數(shù)據(jù)結(jié)構(gòu)DelayQueue,其本質(zhì)是封裝了PriorityQueue,可以把元素進(jìn)行排序。
Java 的Timer、JUC的延遲調(diào)度,最終都是基于 PriorityQueue。
基于內(nèi)存的延時(shí)隊(duì)列進(jìn)行調(diào)度的邏輯,其實(shí)比較簡(jiǎn)單,具體如下:
把訂單插入DelayQueue中,以超時(shí)時(shí)間作為排序條件,將訂單按照超時(shí)時(shí)間從小到大排序。起一個(gè)線程不停輪詢隊(duì)列的頭部,如果訂單的超時(shí)時(shí)間到了,就出隊(duì)進(jìn)行超時(shí)處理,并更新訂單狀態(tài)到數(shù)據(jù)庫(kù)中。為了防止機(jī)器重啟導(dǎo)致內(nèi)存中的DelayQueue數(shù)據(jù)丟失,每次機(jī)器啟動(dòng)的時(shí)候,需要從數(shù)據(jù)庫(kù)中初始化未結(jié)束的訂單,加入到DelayQueue中。基于內(nèi)存的延時(shí)隊(duì)列調(diào)度的優(yōu)點(diǎn)和缺點(diǎn):
優(yōu)點(diǎn):
簡(jiǎn)單,不需要借助其他第三方組件,成本低。缺點(diǎn):
所有超時(shí)處理訂單都要加入到DelayQueue中,占用內(nèi)存大。沒(méi)法做到分布式處理,只能在集群中選一臺(tái)leader專(zhuān)門(mén)處理,效率低。不適合訂單量比較大的場(chǎng)景。▌方案2:RocketMQ的定時(shí)消息
RocketMQ支持任意秒級(jí)的定時(shí)消息,如下圖所示:
使用門(mén)檻低,只需要在發(fā)送消息的時(shí)候設(shè)置延時(shí)時(shí)間即可,以 java 代碼為例:
MessageBuilder messageBuilder = null;Long deliverTimeStamp = System.currentTimeMillis() + 10L * 60 * 1000; //延遲10分鐘Message message = messageBuilder.setTopic("topic") //設(shè)置消息索引鍵,可根據(jù)關(guān)鍵字精確查找某條消息。 .setKeys("messageKey") //設(shè)置消息Tag,用于消費(fèi)端根據(jù)指定Tag過(guò)濾消息。 .setTag("messageTag") //設(shè)置延時(shí)時(shí)間 .setDeliverytimestamp(deliverTimeStamp) //消息體 .setBody("messageBody".getBytes()) .build();SendReceipt sendReceipt = producer.send(message);System.out.println(sendReceipt.getMessageId());
▌RocketMQ的定時(shí)消息是如何實(shí)現(xiàn)的呢?
RocketMQ 定時(shí)消息的推送,主要分為 : 延遲消息、定時(shí)消息。
在 RocketMQ 4.x 版本,使用 延遲消息來(lái)實(shí)現(xiàn)消息的多個(gè)級(jí)別延遲消息——粗粒度延遲。在 RocketMQ 5.x 版本,使用定時(shí)消息來(lái)實(shí)現(xiàn)消息的更精準(zhǔn)定時(shí)消息,——細(xì)粒度延遲。RocketMQ 4.x 版本只支持 延遲消息,有一些局限性。而 RocketMQ 5.x 版本引入了定時(shí)消息,彌補(bǔ)了 延遲消息的不足。
▌RocketMQ 4.x 粗粒度 延遲消息
RocketMQ 的 延遲消息是指 Producer 發(fā)送消息后,Consumer 不會(huì)立即消費(fèi),而是需要等待固定的時(shí)間才能消費(fèi)。
在一些場(chǎng)景下, 延遲消息是很有用的,比如電商場(chǎng)景下關(guān)閉 30 分鐘內(nèi)未支付的訂單。
使用 延遲消息非常簡(jiǎn)單,只需要給消息的 delayTimeLevel 屬性賦值就可以。
發(fā)送 分級(jí)定時(shí)消息,參考下面代碼:
Message message = new Message("TestTopic", ("Hello scheduled message " + i).getBytes());//第 3 個(gè)級(jí)別,10smessage.setDelayTimeLevel(3);producer.send(message);
延遲消息有 18 個(gè)級(jí)別,如下:
private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
▌實(shí)現(xiàn)原理
延遲消息的實(shí)現(xiàn)原理如下圖:
Producer 把消息發(fā)送到 Broker 后,Broker 判斷是否 延遲消息,
如果是,首先會(huì)把消息投遞到延時(shí)隊(duì)列(Topic = SCHEDULE_TOPIC_XXXX,queueId = delayTimeLevel - 1)。
另外,由于18 個(gè)級(jí)別的延遲,所以定時(shí)任務(wù)線程池會(huì)有 18 個(gè)線程來(lái)對(duì)延時(shí)隊(duì)列進(jìn)行調(diào)度,每個(gè)線程調(diào)度一個(gè)延時(shí)級(jí)別,
調(diào)度任務(wù)把 延遲消息再投遞到原始隊(duì)列,這樣 Consumer 就可以拉取到了到期的消息。
▌RocketMQ 4.x 粗粒度 延遲消息存在不足
RocketMQ 4.x 延遲消息存在著一些不足:
1、延時(shí)級(jí)別只有 18 個(gè),粒度很粗,并不能滿足所有場(chǎng)景;
"1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
2、可以通過(guò)修改 messageDelayLevel 配置來(lái)自定義延時(shí)級(jí)別,雖然可以修改,但是不靈活
比如一個(gè)在大規(guī)模的平臺(tái)上,延時(shí)級(jí)別成百上千,而且隨時(shí)可能增加新的延時(shí)時(shí)間;
3.延時(shí)時(shí)間不準(zhǔn)確,后臺(tái)的定時(shí)線程可能會(huì)因?yàn)樘幚硐⒘看髮?dǎo)致延時(shí)誤差大。
▌RocketMQ 5.x 細(xì)粒度定時(shí)消息
為了彌補(bǔ) 延遲消息的不足,RocketMQ 5.0 引入了細(xì)粒度定時(shí)消息。
經(jīng)典的時(shí)間輪算法如下:
時(shí)間輪類(lèi)似map,key 為時(shí)間的刻度,value為此刻度所對(duì)應(yīng)的任務(wù)列表。
一般來(lái)說(shuō),可以理解為一種環(huán)形結(jié)構(gòu),像鐘表一樣被分為多個(gè) slot 槽位。
每個(gè) slot 代表一個(gè)時(shí)間段,每個(gè) slot 中可以存放多個(gè)任務(wù),使用的是鏈表結(jié)構(gòu)保存該時(shí)間段到期的所有任務(wù)。
時(shí)間輪通過(guò)一個(gè)時(shí)針隨著時(shí)間一個(gè)個(gè) slot 轉(zhuǎn)動(dòng),并執(zhí)行 slot 中的所有到期任務(wù)。
從內(nèi)部結(jié)構(gòu)來(lái)看,是一個(gè)Bucket 數(shù)組,每個(gè) Bucket 表示時(shí)間輪中一個(gè) slot。
從 Bucket 的結(jié)構(gòu)定義可以看出,Bucket 內(nèi)部是一個(gè)雙向鏈表結(jié)構(gòu),雙向鏈表的每個(gè)節(jié)點(diǎn)持有一個(gè) task 對(duì)象,task 代表一個(gè)定時(shí)任務(wù)。每個(gè) Bucket 都包含雙向鏈表 head 和 tail 兩個(gè) task 節(jié)點(diǎn),這樣就可以實(shí)現(xiàn)不同方向進(jìn)行鏈表遍歷
時(shí)間輪的算法、以及時(shí)間輪的演進(jìn),非常重要
內(nèi)容太多,這里不做展開(kāi),具體請(qǐng)看尼恩的3高架構(gòu)筆記《徹底穿透Caffeine底層源碼和架構(gòu)》PDF,里邊介紹了三個(gè)超高并發(fā)組件:時(shí)間輪、 多級(jí)時(shí)間輪、條帶環(huán)狀結(jié)構(gòu)、MPSC隊(duì)列,大家一定認(rèn)真看看。
▌RocketMQ 5.X的時(shí)間輪
RocketMQ 定時(shí)消息引入了秒級(jí)的時(shí)間輪算法。注意,是 秒級(jí)時(shí)間輪。
從源碼來(lái)看,RocketMQ 定義了一個(gè) 7 天的以秒為單位的時(shí)間輪
注意:時(shí)間刻度為1s,沒(méi)有再細(xì),比如 10ms、100ms之類(lèi)的 。
作為參考下面提供一個(gè)一分鐘的,以1s為刻度的時(shí)間輪,如下圖:
圖中是一個(gè) 60s 的時(shí)間輪,每一個(gè)槽位是一個(gè)鏈表,鏈表里邊的節(jié)點(diǎn),通過(guò)TimerLog節(jié)點(diǎn)結(jié)構(gòu)來(lái)記錄不同時(shí)刻的消息。
所以,RocketMQ 使用 TimerWheel 來(lái)描述時(shí)間輪,TimerWheel 中每一個(gè)時(shí)間節(jié)點(diǎn)是一個(gè) Slot,Slot 保存了這個(gè)延時(shí)時(shí)間的 TimerLog 信息的鏈表。
Slot 數(shù)據(jù)結(jié)構(gòu)如下圖:
參考下面代碼:
//類(lèi) TimerWheelpublic void putSlot(long timeMs, long firstPos, long lastPos, int num, int magic) { localBuffer.get().position(getSlotIndex(timeMs) * Slot.SIZE); localBuffer.get().putLong(timeMs / precisionMs); localBuffer.get().putLong(firstPos); localBuffer.get().putLong(lastPos); localBuffer.get().putInt(num); localBuffer.get().putInt(magic);}
▌綁定時(shí)間輪
時(shí)間通過(guò)TimerWheel來(lái)描述時(shí)間輪不同的時(shí)刻,
并且,對(duì)于所處于同一個(gè)刻度的的消息,組成一個(gè)槽位的鏈表,每一個(gè)定時(shí)消息,有一個(gè)TimerLog 描述定時(shí)相關(guān)的信息
TimerLog 有一個(gè)核心字段prevPos,同一個(gè)時(shí)間輪槽位里邊的TimerLog ,會(huì)通過(guò)prevPos串聯(lián)成一個(gè)鏈表.
首先看一下 TimerLog 保存的數(shù)據(jù)結(jié)構(gòu),如下圖:
參考下面代碼:
//TimerMessageStore類(lèi)ByteBuffer tmpBuffer = timerLogBuffer;tmpBuffer.clear();tmpBuffer.putInt(TimerLog.UNIT_SIZE); //sizetmpBuffer.putLong(slot.lastPos); //prev postmpBuffer.putInt(magic); //magictmpBuffer.putLong(tmpWriteTimeMs); //currWriteTimetmpBuffer.putInt((int) (delayedTime - tmpWriteTimeMs)); //delayTimetmpBuffer.putLong(offsetPy); //offsettmpBuffer.putInt(sizePy); //sizetmpBuffer.putInt(hashTopicForMetrics(realTopic)); //hashcode of real topictmpBuffer.putLong(0); //reserved value, just set to 0 nowlong ret = timerLog.append(tmpBuffer.array(), 0, TimerLog.UNIT_SIZE);if (-1 != ret) { // If it's a delete message, then slot's total num -1 // TODO: check if the delete msg is in the same slot with "the msg to be deleted". timerWheel.putSlot(delayedTime, slot.firstPos == -1 ? ret : slot.firstPos, ret, isDelete ? slot.num - 1 : slot.num + 1, slot.magic);}
▌時(shí)間輪上的時(shí)間指針
時(shí)間輪上,會(huì)有一個(gè)指向當(dāng)前時(shí)間的指針定時(shí)地移動(dòng)到下一個(gè)時(shí)間(秒級(jí))。
TimerWheel中的每一格代表著一個(gè)時(shí)刻,同時(shí)會(huì)有一個(gè)firstPos指向槽位鏈的首條TimerLog記錄的地址,一個(gè)lastPos指向這個(gè)槽位鏈最后一條TimerLog的記錄的地址。
當(dāng)需要新增一條記錄的時(shí)候,
例如現(xiàn)在我們要新增一個(gè) “1-4”。
那么就將新記錄的 prevPos 指向當(dāng)前的 lastPos,即 “1-3”,然后修改 lastPos 指向 “1-4”。
這樣就將同一個(gè)刻度上面的 TimerLog 記錄全都串起來(lái)了。
內(nèi)容太多,這里不做展開(kāi),具體請(qǐng)看尼恩的3高架構(gòu)筆記《徹底穿透Caffeine底層源碼和架構(gòu)》PDF,里邊介紹了三個(gè)超高并發(fā)組件:時(shí)間輪、 多級(jí)時(shí)間輪、條帶環(huán)狀結(jié)構(gòu)、MPSC隊(duì)列,大家一定認(rèn)真看看。
▌精準(zhǔn)定時(shí)消息發(fā)送方式
使用 RocketMQ 定時(shí)消息時(shí),客戶端定義精準(zhǔn)定時(shí)消息的示例代碼如下:
//定義消息投遞時(shí)間// deliveryTime = 未來(lái)的一個(gè)時(shí)間戳org.apache.rocketmq.common.message.Message messageExt = this.sendMessageActivity.buildMessage(null, Lists.newArrayList( Message.newBuilder() .setTopic(Resource.newBuilder() .setName(TOPIC) .build()) .setSystemProperties(SystemProperties.newBuilder() .setMessageId(msgId) .setQueueId(0) .setMessageType(MessageType.DELAY) .setDeliveryTimestamp(Timestamps.fromMillis(deliveryTime)) .setBornTimestamp(Timestamps.fromMillis(System.currentTimeMillis())) .setBornHost(StringUtils.defaultString(RemotingUtil.getLocalAddress(), "127.0.0.1:1234")) .build()) .setBody(ByteString.copyFromUtf8("123")) .build() ),Resource.newBuilder().setName(TOPIC).build()).get(0);
▌源碼分析:消息投遞原理
客戶端SDK代碼中,Producer 創(chuàng)建消息時(shí)給消息傳了一個(gè)系統(tǒng)屬性 deliveryTimestamp,
這個(gè)屬性指定了消息投遞的時(shí)間,并且封裝到消息的 TIMER_DELIVER_MS 屬性,代碼如下:
protected void fillDelayMessageProperty(apache.rocketmq.v2.Message message, org.apache.rocketmq.common.message.Message messageWithHeader) { if (message.getSystemProperties().hasDeliveryTimestamp()) { Timestamp deliveryTimestamp = message.getSystemProperties().getDeliveryTimestamp(); //delayTime 這個(gè)延時(shí)時(shí)間默認(rèn)不能超過(guò) 1 天,可以配置 long deliveryTimestampMs = Timestamps.toMillis(deliveryTimestamp); validateDelayTime(deliveryTimestampMs); //... String timestampString = String.valueOf(deliveryTimestampMs); //MessageConst.PROPERTY_TIMER_DELIVER_MS="TIMER_DELIVER_MS" MessageAccessor.putProperty(messageWithHeader, MessageConst.PROPERTY_TIMER_DELIVER_MS, timestampString); }}
服務(wù)端代碼中,Broker 收到這個(gè)消息后,判斷到 TIMER_DELIVER_MS 這個(gè)屬性是否有值,
如果有,就會(huì)把這個(gè)消息投遞到 Topic 是 rmq_sys_wheel_timer 的隊(duì)列中,這個(gè)只有一個(gè)分區(qū),queueId 是 0,作為中轉(zhuǎn)的隊(duì)列
中轉(zhuǎn)的時(shí)候,同時(shí)會(huì)保存原始消息的 Topic、queueId、投遞時(shí)間(TIMER_OUT_MS)。
TimerMessageStore 中有個(gè)定時(shí)任務(wù) TimerEnqueueGetService 會(huì)從 rmq_sys_wheel_timer 這個(gè) Topic 中讀取消息,然后封裝 TimerRequest 請(qǐng)求并放到內(nèi)存隊(duì)列 enqueuePutQueue。
一個(gè)異步任務(wù)TimerEnqueuePutService 從上面的 enqueuePutQueue 取出 TimerRequest 然后封裝成 TimerLog,然后綁定到時(shí)間輪
▌TimerLog 是怎么和時(shí)間輪關(guān)聯(lián)起來(lái)的呢?
RocketMQ 使用 TimerWheel 來(lái)描述時(shí)間輪,
從源碼上看,RocketMQ 定義了一個(gè) 7 天的以秒為單位的時(shí)間輪。TimerWheel 中每一個(gè)時(shí)間節(jié)點(diǎn)是一個(gè) Slot,Slot 保存了這個(gè)延時(shí)時(shí)間的 TimerLog 信息。
數(shù)據(jù)結(jié)構(gòu)如下圖:
參考下面代碼:
//類(lèi) TimerWheelpublic void putSlot(long timeMs, long firstPos, long lastPos, int num, int magic) { localBuffer.get().position(getSlotIndex(timeMs) * Slot.SIZE); localBuffer.get().putLong(timeMs / precisionMs); localBuffer.get().putLong(firstPos); localBuffer.get().putLong(lastPos); localBuffer.get().putInt(num); localBuffer.get().putInt(magic);}
通過(guò)TimerWheel的 putSlot方法,TimerLog 跟 時(shí)間輪就綁定起來(lái)了,見(jiàn)下圖:
如果時(shí)間輪的一個(gè)時(shí)間節(jié)點(diǎn)(Slot)上有一條新的消息到來(lái),那只要新建一個(gè) TimerLog,然后把它的指針指向該時(shí)間節(jié)點(diǎn)的最后一個(gè) TimerLog,然后把 Slot 的 lastPos 屬性指向新建的這個(gè) TimerLog,如下圖:
▌時(shí)間輪中的 定時(shí)消息異步處理總流程
終于,咱們的定時(shí)消息進(jìn)入到時(shí)間輪了。
那么,隨著時(shí)間刻度的步進(jìn), 上面的消息,怎么轉(zhuǎn)移到原始的topic的 分區(qū)呢?
由于 rocketmq的源碼是超高性能的,所以,這里有N個(gè)隊(duì)列做緩沖,有N個(gè)任務(wù)
這里用到 5 個(gè)定時(shí)任務(wù)和 3個(gè)隊(duì)列來(lái)實(shí)現(xiàn)。
定時(shí)消息的處理流程如下圖:
▌時(shí)間輪轉(zhuǎn)動(dòng)
轉(zhuǎn)動(dòng)時(shí)間輪時(shí),TimerDequeueGetService 這個(gè)定時(shí)任務(wù)從當(dāng)前時(shí)間節(jié)點(diǎn)(Slot)對(duì)應(yīng)的 TimerLog 中取出數(shù)據(jù),封裝成 TimerRequest 放入 dequeueGetQueue 隊(duì)列。
▌CommitLog 中讀取消息
定時(shí)任務(wù) TimerDequeueGetMessageService 從隊(duì)列 dequeueGetQueue 中拉取 TimerRequest 請(qǐng)求,然后根據(jù) TimerRequest 中的參數(shù)去 CommitLog(MessageExt) 中查找消息,查出后把消息封裝到 TimerRequest 中,然后把 TimerRequest 寫(xiě)入 dequeuePutQueue 這個(gè)隊(duì)列。
▌寫(xiě)入原隊(duì)列
定時(shí)任務(wù) TimerDequeuePutMessageService 從 dequeuePutQueue 隊(duì)列中獲取消息,
把消息轉(zhuǎn)換成原始消息,投入到原始隊(duì)列中,這樣消費(fèi)者就可以拉取到了。
▌RocketMQ的定時(shí)消息的優(yōu)點(diǎn)和不足
▌要注意的地方
對(duì)于定時(shí)時(shí)間的定義,客戶端、Broker 和時(shí)間輪的默認(rèn)最大延時(shí)時(shí)間定義是不同的,使用的時(shí)候需要注意。
▌RocketMQ的定時(shí)消息優(yōu)點(diǎn)
精度高,支持任意時(shí)刻。使用門(mén)檻低,和使用普通消息一樣。▌RocketMQ的定時(shí)消息缺點(diǎn)
時(shí)長(zhǎng)的使用限制:定時(shí)和延時(shí)消息的msg.setStartDeliverTime參數(shù)可設(shè)置40天內(nèi)的任何時(shí)刻(單位毫秒),超過(guò)40天消息發(fā)送將失敗。
設(shè)置定時(shí)和延時(shí)消息的投遞時(shí)間后,從中轉(zhuǎn)隊(duì)列調(diào)度到了原始的消息隊(duì)列之后,依然受3天的消息保存時(shí)長(zhǎng)限制。例如,設(shè)置定時(shí)消息5天后才能被消費(fèi),如果第5天后一直沒(méi)被消費(fèi),那么這條消息將在第8天被刪除。
海量 消息場(chǎng)景,存儲(chǔ)成本高:在海量訂單場(chǎng)景中,如果每個(gè)訂單需要新增一個(gè)定時(shí)消息,且不會(huì)馬上消費(fèi),額外給MQ帶來(lái)很大的存儲(chǔ)成本。
同一個(gè)時(shí)刻大量消息會(huì)導(dǎo)致消息延遲:定時(shí)消息的實(shí)現(xiàn)邏輯需要先經(jīng)過(guò)定時(shí)存儲(chǔ)等待觸發(fā),定時(shí)時(shí)間到達(dá)后才會(huì)被投遞給消費(fèi)者。
因此,如果將大量定時(shí)消息的定時(shí)時(shí)間設(shè)置為同一時(shí)刻,則到達(dá)該時(shí)刻后會(huì)有大量消息同時(shí)需要被處理,會(huì)造成系統(tǒng)壓力過(guò)大,導(dǎo)致消息分發(fā)延遲,影響定時(shí)精度。
▌方案3:Redis的過(guò)期監(jiān)聽(tīng)
和RocketMQ定時(shí)消息一樣,Redis支持過(guò)期監(jiān)聽(tīng),也能達(dá)到的能力
▌通過(guò)Redis中key的過(guò)期事件,作為延遲消息
可以通過(guò)Redis中key的過(guò)期事件,作為延遲消息。使用Redis進(jìn)行訂單超時(shí)處理的流程圖如下
具體步驟如下:
1.在服務(wù)器中 修改redis配置文件, 開(kāi)啟"notify-keyspace-events Ex"
原來(lái)notify-keyspace-events 屬性是" " 空的,我們只需要填上“Ex”就行了
2.監(jiān)聽(tīng)key的過(guò)期回調(diào)
創(chuàng)建一個(gè)Redis監(jiān)控類(lèi),用于監(jiān)控過(guò)期的key,該類(lèi)需繼承KeyExpirationEventMessageListener
public class KeyExpiredListener extends KeyExpirationEventMessageListener { public KeyExpiredListener(RedisMessageListenerContainer listenerContainer) { super(listenerContainer); } @Override public void onMessage(Message message, byte[] pattern) { String keyExpira = message.toString(); System.out.println("監(jiān)聽(tīng)到key:" + expiredKey + "已過(guò)期"); }}
3.創(chuàng)建Redis配置類(lèi) , 裝配這個(gè) 監(jiān)聽(tīng)器
@Configurationpublic class RedisConfiguration { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public RedisMessageListenerContainer redisMessageListenerContainer() { RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer(); redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory); return redisMessageListenerContainer; } @Bean public KeyExpiredListener keyExpiredListener() { return new KeyExpiredListener(this.redisMessageListenerContainer()); }}
▌Redis過(guò)期時(shí)間作為延遲消息的原理
每當(dāng)一個(gè)key設(shè)置了過(guò)期時(shí)間,Redis就會(huì)把該key帶上過(guò)期時(shí)間,在redisDb中通過(guò)expires字段維護(hù):
typedef struct redisDb { dict *dict; /* 維護(hù)所有key-value鍵值對(duì) */ dict *expires; /* 過(guò)期字典,維護(hù)設(shè)置失效時(shí)間的鍵 */ ....} redisDb;
這個(gè)結(jié)構(gòu),通過(guò)一個(gè) 過(guò)期字典dict來(lái)維護(hù)
過(guò)期字典dict 本質(zhì)上是一個(gè)鏈表,每個(gè)節(jié)點(diǎn)的數(shù)據(jù)結(jié)構(gòu)結(jié)構(gòu)如下:
key是一個(gè)指針,指向某個(gè)鍵對(duì)象。value是一個(gè)long long類(lèi)型的整數(shù),保存了key的過(guò)期時(shí)間。為了提升性能,Redis主要使用了定期刪除和惰性刪除策略來(lái)進(jìn)行過(guò)期key的刪除
定期刪除:每隔一段時(shí)間(默認(rèn)100ms)就隨機(jī)抽取一些設(shè)置了過(guò)期時(shí)間的key,檢查其是否過(guò)期,如果有過(guò)期就刪除。之所以這么做,是為了通過(guò)限制刪除操作的執(zhí)行時(shí)長(zhǎng)和頻率來(lái)減少對(duì)cpu的影響。不然每隔100ms就要遍歷所有設(shè)置過(guò)期時(shí)間的key,會(huì)導(dǎo)致cpu負(fù)載太大。惰性刪除:不主動(dòng)刪除過(guò)期的key,每次從數(shù)據(jù)庫(kù)訪問(wèn)key時(shí),都檢測(cè)key是否過(guò)期,如果過(guò)期則刪除該key。惰性刪除有一個(gè)問(wèn)題,如果這個(gè)key已經(jīng)過(guò)期了,但是一直沒(méi)有被訪問(wèn),就會(huì)一直保存在數(shù)據(jù)庫(kù)中。從以上可以知道,Redis過(guò)期刪除是不精準(zhǔn)的,
在訂單超時(shí)處理的場(chǎng)景下,訂單的數(shù)據(jù)如果沒(méi)有去訪問(wèn),那么惰性刪除基本上也用不到,
所以,無(wú)法保證key在過(guò)期的時(shí)候可以立即刪除,更不能保證能立即通知。
如果訂單量比較大,那么延遲幾分鐘也是有可能的。
總而言之,Redis過(guò)期通知也是不可靠的,Redis在過(guò)期通知的時(shí)候,
如果應(yīng)用正好重啟了,那么就有可能通知事件就丟了,會(huì)導(dǎo)致訂單一直無(wú)法關(guān)閉,有穩(wěn)定性問(wèn)題。
▌方案4:超大規(guī)模分布式定時(shí)批處理 架構(gòu)
海量訂單過(guò)期的 分布式定時(shí)批處理解決方案,分為兩步:
step1:通過(guò)分布式定時(shí)不停輪詢數(shù)據(jù)庫(kù)的訂單,將已經(jīng)超時(shí)的訂單撈出來(lái)
step2:分而治之,分發(fā)給不同的機(jī)器分布式處理
在阿里內(nèi)部,幾乎所有的業(yè)務(wù)都使用超大規(guī)模分布式定時(shí)批處理架構(gòu),大致的架構(gòu)圖如下:
▌超大規(guī)模分布式定時(shí)批處理 宏觀架構(gòu)
前段時(shí)間指導(dǎo)簡(jiǎn)歷,小伙伴簡(jiǎn)歷里邊寫(xiě)了這個(gè)項(xiàng)目,但是對(duì)這個(gè)項(xiàng)目的思想和精髓沒(méi)有理解,導(dǎo)致漏洞百出。
接下來(lái),尼恩首先給大家梳理一下 超大規(guī)模分布式定時(shí)批處理 宏觀架構(gòu)。
如何讓超時(shí)調(diào)度中心不同的節(jié)點(diǎn)協(xié)同工作,拉取不同的數(shù)據(jù)?宏觀的架構(gòu)如下:
▌調(diào)度中心:海量任務(wù)調(diào)度執(zhí)行系統(tǒng)
通常的解決方案如
自研分布式調(diào)度系統(tǒng),尼恩曾經(jīng)自研過(guò) 基于DB的分布式調(diào)度系統(tǒng)、基于zookeeper 的分布式調(diào)度系統(tǒng)開(kāi)源的分布式調(diào)度系統(tǒng) 如 xxl-job,阿里巴巴分布式任務(wù)調(diào)度系統(tǒng)SchedulerX,不但兼容主流開(kāi)源任務(wù)調(diào)度系統(tǒng),也兼容Spring @Scheduled注解,優(yōu)先推薦,是使用最新版本的、基于時(shí)間輪的xxl-job,或者基于xxl-job做定制開(kāi)發(fā),后面估計(jì)尼恩可能會(huì)通過(guò)對(duì) xxl-job的架構(gòu)和源碼進(jìn)行介紹。
xxl-job基于 時(shí)間輪完成調(diào)度,關(guān)于時(shí)間輪和 高性能多級(jí)時(shí)間輪,非常重要,但是內(nèi)容太多,這里不做展開(kāi),
具體請(qǐng)看尼恩的3高架構(gòu)筆記《徹底穿透Caffeine底層源碼和架構(gòu)》PDF,里邊介紹了三個(gè)超高并發(fā)組件:時(shí)間輪、 多級(jí)時(shí)間輪、條帶環(huán)狀結(jié)構(gòu)、MPSC隊(duì)列,大家一定認(rèn)真看看。
那么在100億級(jí)海量訂單超時(shí)處理場(chǎng)景中,雖然海量訂單,但是對(duì)于調(diào)度系統(tǒng)來(lái)說(shuō),不是海量的。
因?yàn)闉榱私oDB降低壓力,訂單是批量處理的,不可能一個(gè)訂單一個(gè)延遲任務(wù),而是一大批訂單一個(gè)延遲任務(wù)。
所以,任務(wù)的數(shù)量級(jí),成數(shù)量級(jí)的下降。
所以00億級(jí)海量訂單超時(shí)處理場(chǎng)景的壓力不在調(diào)度,而在于 數(shù)據(jù)庫(kù)。
問(wèn)題的關(guān)鍵是如何進(jìn)行數(shù)據(jù)分片。
▌海量數(shù)據(jù)分片模型
首先來(lái)看 DB數(shù)據(jù)的數(shù)據(jù)是如何分片的。 DB數(shù)據(jù)分片模型的架構(gòu),本身場(chǎng)景復(fù)雜:
分庫(kù)分表場(chǎng)景一張大表場(chǎng)景海量數(shù)據(jù)存儲(chǔ)組件 hbase、hdfs 等等海量數(shù)據(jù)分片模型 要解決問(wèn)題:
首先是復(fù)雜的分片模型的問(wèn)題其次就是數(shù)據(jù)批處理的過(guò)程中,如何減少數(shù)據(jù)傳輸,提升性能的問(wèn)題。第一個(gè)問(wèn)題:復(fù)雜的分片模型的問(wèn)題。
由于 分片模型與數(shù)據(jù)庫(kù)的存儲(chǔ)方案的選型,和分庫(kù)分表的設(shè)計(jì)有關(guān),這里不做展開(kāi)。
后面給大家講大數(shù)據(jù)的時(shí)候,再展開(kāi)。
咱們的卷王目標(biāo)是左手大數(shù)據(jù)、右手云原生, 大數(shù)據(jù)是咱們后面卷的重點(diǎn)。
▌輕量級(jí)MapReduce模型
第2個(gè)問(wèn)題:如何減少數(shù)據(jù)傳輸。
注意,如果數(shù)據(jù)源在mysql這樣的結(jié)構(gòu)化db,修改訂單的狀態(tài),是可以直接通過(guò)結(jié)構(gòu)化sql,進(jìn)行批量更新的。這個(gè)一條sql搞定,是非常簡(jiǎn)單的。
但是,既然是海量數(shù)據(jù),就不一定在結(jié)構(gòu)化DB,而是在異構(gòu)的DB(NOSQL)中。
異構(gòu)DB(NOSQL)就沒(méi)有辦法通過(guò)結(jié)構(gòu)化sql,進(jìn)行批量更新了。
很多的NOSQL DB的記錄修改,是需要兩步:
step1:把數(shù)據(jù)讀取出來(lái),step2:改完之后,再寫(xiě)入。所以,如果訂單的數(shù)據(jù)源,不一定是DB,而且在異構(gòu)的DB(NOSQL)中, 那么久存在大量的數(shù)據(jù)傳輸?shù)膯?wèn)題。
100億級(jí)訂單規(guī)模,基本上會(huì)涉及到異構(gòu)DB,所以阿里的訂單狀態(tài)修改,要求既能兼容 結(jié)構(gòu)化DB,又能兼容異構(gòu) DB, 那么就存在大量的數(shù)據(jù)傳輸問(wèn)題。
為了減少數(shù)據(jù)傳輸,提升批處理性能的問(wèn)題。阿里技術(shù)團(tuán)隊(duì),使用了輕量級(jí)MapReduce模型。
▌重量級(jí)MapReduce模型
首先看看,什么是MapReduce模型。
傳統(tǒng)的基于hadoop的文件批處理,數(shù)據(jù)在hdfs文件系統(tǒng)中,讀取到內(nèi)存之后,再把結(jié)果寫(xiě)入到hdfs,這種基于文件的批處理模式,存在大量的數(shù)據(jù)傳輸、磁盤(pán)IO,性能太低太低,根本不在目前的這個(gè)場(chǎng)景考慮之內(nèi)哈
基于內(nèi)存的批量數(shù)據(jù)處理,比如spark中的離線批量處理,采用的內(nèi)存處理方式,和基于文件的批量處理相比,速度有了質(zhì)的飛越
在spark基于內(nèi)存的批處理流程中(這里簡(jiǎn)稱(chēng)為基于內(nèi)存的MR),首先把數(shù)據(jù)加載到內(nèi)存, 進(jìn)行map轉(zhuǎn)換、reduce 規(guī)約(或者聚合)之后,再把結(jié)果通過(guò)網(wǎng)絡(luò)傳輸?shù)较乱粋€(gè)環(huán)節(jié)。這里,存在著大家的數(shù)據(jù)傳輸。
所以,不論是內(nèi)存的批處理、還是基于文件批處理,都需要大量的傳輸數(shù)據(jù),
大量的數(shù)據(jù)傳輸,意味著性能太低, 不適用 海量訂單批處理場(chǎng)景。
如何縮減數(shù)據(jù)傳輸?shù)拈|蜜,避免數(shù)據(jù)的大規(guī)模傳輸呢?
▌輕量級(jí)MapReduce模型
阿里自研了輕量級(jí)MapReduce模型,可以簡(jiǎn)稱(chēng)為本地批處理。
所以輕量級(jí)MapReduce模型,就是減少數(shù)據(jù)傳輸,在原來(lái)的數(shù)據(jù)庫(kù)里邊進(jìn)行數(shù)據(jù)的計(jì)算(比如通過(guò)SQL語(yǔ)句),不需要把數(shù)據(jù)加載到內(nèi)存進(jìn)行計(jì)算。
輕量級(jí)MapReduce模型在執(zhí)行的過(guò)程中,計(jì)算節(jié)點(diǎn)主要管理的是 轉(zhuǎn)換、規(guī)約 的分片規(guī)則、執(zhí)行狀態(tài)和結(jié)果,對(duì)這些任務(wù)中的業(yè)務(wù)數(shù)據(jù),計(jì)算節(jié)點(diǎn)不去讀取計(jì)算的目標(biāo)數(shù)據(jù),減少了海量業(yè)務(wù)數(shù)據(jù)的反復(fù)讀取、寫(xiě)入。
轉(zhuǎn)換階段:
輕量級(jí)MapReduce模型,通過(guò)實(shí)現(xiàn)map函數(shù),通過(guò)代碼自行構(gòu)造分片,調(diào)度系統(tǒng)將分片平均分給超時(shí)中心的不同節(jié)點(diǎn)分布式執(zhí)行。
注意,在MR集群中,分片由Master主節(jié)點(diǎn)完成,保存在內(nèi)存數(shù)據(jù)庫(kù)H2中。工作節(jié)點(diǎn)的調(diào)度,也是Master完成。
▌規(guī)約階段
通過(guò)實(shí)現(xiàn)reduce函數(shù),可以做聚合,可以判斷這次跑批有哪些分片跑失敗了,從而通知下游處理。
注意,在MR集群中,work的分片任務(wù)執(zhí)行結(jié)果匯報(bào)到Master,這些結(jié)果保存在內(nèi)存數(shù)據(jù)庫(kù)H2中,由Master完成規(guī)約(或聚合)。
有了這個(gè)自研了輕量級(jí)MapReduce模型,阿里的超時(shí)調(diào)度中心可以針對(duì)任意異構(gòu)數(shù)據(jù)源,簡(jiǎn)單幾行代碼就可以實(shí)現(xiàn)海量數(shù)據(jù)秒級(jí)別跑批。
▌定時(shí)任務(wù)分布式批處理的方案的優(yōu)勢(shì):
使用定時(shí)任務(wù)分布式批處理的方案具有如下優(yōu)勢(shì):
▌穩(wěn)定性強(qiáng):
基于通知的方案(比如MQ和Redis),比較擔(dān)心在各種極端情況下導(dǎo)致通知的事件丟了。使用定時(shí)任務(wù)跑批,只需要保證業(yè)務(wù)冪等即可,如果這個(gè)批次有些訂單沒(méi)有撈出來(lái),或者處理訂單的時(shí)候應(yīng)用重啟了,下一個(gè)批次還是可以撈出來(lái)處理,穩(wěn)定性非常高。▌效率高:
基于MQ的方案,需要一個(gè)訂單一個(gè)定時(shí)消息,consumer處理定時(shí)消息的時(shí)候也需要一個(gè)訂單一個(gè)訂單更新,對(duì)數(shù)據(jù)庫(kù)tps很高。使用定時(shí)任務(wù)跑批方案,一次撈出一批訂單,處理完了,可以批量更新訂單狀態(tài),減少數(shù)據(jù)庫(kù)的tps。在海量訂單處理場(chǎng)景下,批量處理效率最高。▌可運(yùn)維:
基于數(shù)據(jù)庫(kù)存儲(chǔ),可以很方便的對(duì)訂單進(jìn)行修改、暫停、取消等操作,所見(jiàn)即所得。如果業(yè)務(wù)跑失敗了,還可以直接通過(guò)sql修改數(shù)據(jù)庫(kù)來(lái)進(jìn)行批量運(yùn)維。▌成本低:
相對(duì)于其他解決方案要借助第三方存儲(chǔ)組件,復(fù)用數(shù)據(jù)庫(kù)的成本大大降低。▌定時(shí)任務(wù)分布式批處理的方案的缺點(diǎn):
但是使用定時(shí)任務(wù)有個(gè)天然的缺點(diǎn):沒(méi)法做到精度很高。
定時(shí)任務(wù)的延遲時(shí)間,由定時(shí)任務(wù)的調(diào)度周期決定。
如果把頻率設(shè)置很小,就會(huì)導(dǎo)致數(shù)據(jù)庫(kù)的qps比較高,容易造成數(shù)據(jù)庫(kù)壓力過(guò)大,從而影響線上的正常業(yè)務(wù)。
解決方案就是DB解耦:
阿里內(nèi)部,一般需要解耦單獨(dú)超時(shí)庫(kù),單獨(dú)做訂單的超時(shí)調(diào)度,不會(huì)和業(yè)務(wù)庫(kù)在在一起操作。
同時(shí)也有獨(dú)立的超時(shí)中心,完成 數(shù)據(jù)的分片,以及跑批任務(wù)的調(diào)度。
▌訂單任務(wù)調(diào)度場(chǎng)景的選型:
(1)超時(shí)精度比較高、超時(shí)任務(wù)不會(huì)有峰值壓力的場(chǎng)景
如果對(duì)于超時(shí)精度比較高,不會(huì)有峰值壓力的場(chǎng)景,推薦使用RocketMQ的定時(shí)消息解決方案。
(2)超時(shí)精度比較低、超時(shí)任務(wù)不會(huì)有峰值壓力的場(chǎng)景(100億級(jí)訂單)
在電商業(yè)務(wù)下,許多訂單超時(shí)場(chǎng)景都在24小時(shí)以上,對(duì)于超時(shí)精度沒(méi)有那么敏感,并且有海量訂單需要批處理,推薦使用基于定時(shí)任務(wù)的跑批解決方案。
阿里的100億級(jí)訂單超時(shí)處理選型,選擇的是后面的方案。
▌架構(gòu)的魅力:
通過(guò)以上的梳理,大家如果需要把 海量任務(wù)定時(shí)調(diào)度的方案寫(xiě)入簡(jiǎn)歷,再也不會(huì)一半清晰、一半糊涂了。
如果還不清楚怎么寫(xiě)入簡(jiǎn)歷,可以來(lái)找尼恩進(jìn)行簡(jiǎn)歷指導(dǎo)。保證 脫胎換骨、金光閃閃、天衣無(wú)縫。
總之,架構(gòu)魅力,在于沒(méi)有最好的方案,只有更好的方案。
大家如果有疑問(wèn),或者更好的方案,可以多多交流,此題,后面的答案,也會(huì)不斷的完善和優(yōu)化。
注:本文以 PDF 持續(xù)更新,最新尼恩 架構(gòu)筆記、面試題 的PDF文件,請(qǐng)到《技術(shù)自由圈》公號(hào)領(lǐng)取
▌技術(shù)自由的實(shí)現(xiàn)路徑 PDF領(lǐng)?。?/strong>
▌實(shí)現(xiàn)你的架構(gòu)自由:
《吃透8圖1模板,人人可以做架構(gòu)》PDF《10Wqps評(píng)論中臺(tái),如何架構(gòu)?B站是這么做的!??!》PDF《阿里二面:千萬(wàn)級(jí)、億級(jí)數(shù)據(jù),如何性能優(yōu)化? 教科書(shū)級(jí) 答案來(lái)了》PDF《峰值21WQps、億級(jí)DAU,小游戲《羊了個(gè)羊》是怎么架構(gòu)的?》PDF《100億級(jí)訂單怎么調(diào)度,來(lái)一個(gè)大廠的極品方案》PDF《2個(gè)大廠 100億級(jí) 超大流量 紅包 架構(gòu)方案》PDF… 更多架構(gòu)文章,正在添加中
▌實(shí)現(xiàn)你的 響應(yīng)式 自由:
《響應(yīng)式圣經(jīng):10W字,實(shí)現(xiàn)Spring響應(yīng)式編程自由》PDF這是老版本 《Flux、Mono、Reactor 實(shí)戰(zhàn)(史上最全)》PDF▌實(shí)現(xiàn)你的 spring cloud 自由:
《Spring cloud Alibaba 學(xué)習(xí)圣經(jīng)》 PDF《分庫(kù)分表 Sharding-JDBC 底層原理、核心實(shí)戰(zhàn)(史上最全)》PDF《一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之間混亂關(guān)系(史上最全)》PDF▌實(shí)現(xiàn)你的 linux 自由:
《Linux命令大全:2W多字,一次實(shí)現(xiàn)Linux自由》PDF▌實(shí)現(xiàn)你的 網(wǎng)絡(luò) 自由:
《TCP協(xié)議詳解 (史上最全)》PDF《網(wǎng)絡(luò)三張表:ARP表, MAC表, 路由表,實(shí)現(xiàn)你的網(wǎng)絡(luò)自由!!》PDF▌實(shí)現(xiàn)你的 分布式鎖 自由:
《Redis分布式鎖(圖解 - 秒懂 - 史上最全)》PDF《Zookeeper 分布式鎖 - 圖解 - 秒懂》PDF▌實(shí)現(xiàn)你的 王者組件 自由:
《隊(duì)列之王: Disruptor 原理、架構(gòu)、源碼 一文穿透》PDF《緩存之王:Caffeine 源碼、架構(gòu)、原理(史上最全,10W字 超級(jí)長(zhǎng)文)》PDF《緩存之王:Caffeine 的使用(史上最全)》PDF《Java Agent 探針、字節(jié)碼增強(qiáng) ByteBuddy(史上最全)》PDF▌實(shí)現(xiàn)你的 面試題 自由:
4000頁(yè)《尼恩Java面試寶典》PDF 40個(gè)專(zhuān)題....
注:以上尼恩 架構(gòu)筆記、面試題 的PDF文件,請(qǐng)到《技術(shù)自由圈》公號(hào)領(lǐng)取
還需要啥自由,可以告訴尼恩。 尼恩幫你實(shí)現(xiàn).......
以上就是關(guān)于pos機(jī)接收返回超時(shí),100億訂單超時(shí)處理的知識(shí),后面我們會(huì)繼續(xù)為大家整理關(guān)于pos機(jī)接收返回超時(shí)的知識(shí),希望能夠幫助到大家!
