高并發(fā)下如何防重惰拱?

前言

最近測試給我提了一個bug,說我之前提供的一個批量復(fù)制商品的接口啊送,產(chǎn)生了重復(fù)的商品數(shù)據(jù)偿短。

追查原因之后發(fā)現(xiàn),這個事情沒想象中簡單馋没,可以說一波多折昔逗。

1. 需求

產(chǎn)品有個需求:用戶選擇一些品牌,點擊確定按鈕之后篷朵,系統(tǒng)需要基于一份默認(rèn)品牌的商品數(shù)據(jù)勾怒,復(fù)制出一批的商品。

拿到這個需求時覺得太簡單了声旺,三下五除二就搞定笔链。

我提供了一個復(fù)制商品的基礎(chǔ)接口,給商城系統(tǒng)調(diào)用腮猖。

當(dāng)時的流程圖如下:


image.png

如果每次復(fù)制的商品數(shù)量不多鉴扫,使用同步接口調(diào)用的方案問題也不大。

2. 性能優(yōu)化

但由于每次需要復(fù)制的商品數(shù)量比較多澈缺,可能有幾千坪创。

如果每次都是用同步接口的方式復(fù)制商品,可能會有性能問題谍椅。

因此误堡,后來我把復(fù)制商品的邏輯改成使用mq異步處理。

改造之后的流程圖:


image.png

復(fù)制商品的結(jié)果還需要通知商城系統(tǒng):


image.png

這個方案看起來雏吭,挺不錯的锁施。

但后來出現(xiàn)問題了。

3. 出問題了

測試給我們提了一個bug杖们,說我之前提供的一個批量復(fù)制商品的接口悉抵,產(chǎn)生了重復(fù)的商品數(shù)據(jù)。

經(jīng)過追查之后發(fā)現(xiàn)摘完,商城系統(tǒng)為了性能考慮姥饰,也改成異步了。

他們沒有在接口中直接調(diào)用基礎(chǔ)系統(tǒng)的復(fù)制商品接口孝治,而是在job中調(diào)用的列粪。

站在他們的視角流程圖是這樣的:


image.png

用戶調(diào)用商城的接口审磁,他們會往請求記錄表中寫入一條數(shù)據(jù),然后在另外一個job中岂座,異步調(diào)用基礎(chǔ)系統(tǒng)的接口去復(fù)制商品态蒂。

但實際情況是這樣的:商城系統(tǒng)內(nèi)部出現(xiàn)了bug,在請求記錄表中费什,同一條請求產(chǎn)生了重復(fù)的數(shù)據(jù)钾恢。這樣導(dǎo)致的結(jié)果是,在job中調(diào)用基礎(chǔ)系統(tǒng)復(fù)制商品接口時鸳址,發(fā)送了重復(fù)的請求瘩蚪。

剛好基礎(chǔ)系統(tǒng)現(xiàn)在是使用RocketMQ異步處理的。由于商城的job一次會取一批數(shù)據(jù)(比如:20條記錄)稿黍,在極短的時間內(nèi)(其實就是在一個for循環(huán)中)多次調(diào)用接口疹瘦,可能存在相同的請求參數(shù)連續(xù)調(diào)用復(fù)制商品接口情況。于是巡球,出現(xiàn)了并發(fā)插入重復(fù)數(shù)據(jù)的問題拱礁。

為什么會出現(xiàn)這個問題呢?

4. 多線程消費

RocketMQ的消費者辕漂,為了性能考慮,默認(rèn)是用多線程并發(fā)消費的吴超,最大支持64個線程钉嘹。

例如:

@RocketMQMessageListener(topic = "${com.susan.topic:PRODUCT_TOPIC}",
        consumerGroup = "${com.susan.group:PRODUCT_TOPIC_GROUP}")
@Service
public class MessageReceiver implements RocketMQListener<MessageExt> {

    @Override
    public void onMessage(MessageExt message) {
        String message = new String(message.getBody(), StandardCharsets.UTF_8);
        doSamething(message);
    }
}

也就是說,如果在極短的時間內(nèi)鲸阻,連續(xù)發(fā)送重復(fù)的消息跋涣,就會被不同的線程消費。

即使在代碼中有這樣的判斷:

Product oldProduct = query(hashCode);
if(oldProduct == null) {
    productMapper.insert(product);
}

在插入數(shù)據(jù)之前鸟悴,先判斷該數(shù)據(jù)是否已經(jīng)存在陈辱,只有不存在才會插入。

但由于在并發(fā)情況下细诸,不同的線程都判斷商品數(shù)據(jù)不存在沛贪,于是同時進(jìn)行了插入操作,所以就產(chǎn)生了重復(fù)數(shù)據(jù)震贵。

如下圖所示:


image.png

5. 順序消費

為了解決上述并發(fā)消費重復(fù)消息的問題利赋,我們從兩方面著手:

  1. 商城系統(tǒng)修復(fù)產(chǎn)生重復(fù)記錄的bug。
  2. 基礎(chǔ)系統(tǒng)將消息改成單線程順序消費猩系。

我仔細(xì)思考了一下媚送,如果只靠商城系統(tǒng)修復(fù)bug,以后很難避免不出現(xiàn)類似的重復(fù)商品問題寇甸,比如:如果用戶在極短的時間內(nèi)點擊創(chuàng)建商品按鈕多次塘偎,或者商城系統(tǒng)主動發(fā)起重試疗涉。

所以,基礎(chǔ)系統(tǒng)還需進(jìn)一步處理吟秩。

其實RocketMQ本身是支持順序消費的咱扣,需要消息的生產(chǎn)者和消費者一起改。

生產(chǎn)者改為:

生產(chǎn)者改為:

rocketMQTemplate.asyncSendOrderly(topic, message, hashKey, new SendCallback() {
  @Override
  public void onSuccess(SendResult sendResult) {
      log.info("sendMessage success");
  }

  @Override
  public void onException(Throwable e) {
      log.error("sendMessage failed!");
  }
});

重點是要調(diào)用rocketMQTemplate對象的asyncSendOrderly方法峰尝,發(fā)送順序消息偏窝。

消費者改為:

@RocketMQMessageListener(topic = "${com.susan.topic:PRODUCT_TOPIC}",
        consumeMode = ConsumeMode.ORDERLY,
        consumerGroup = "${com.susan.group:PRODUCT_TOPIC_GROUP}")
@Service
public class MessageReceiver implements RocketMQListener<MessageExt> {

    @Override
    public void onMessage(MessageExt message) {
        String message = new String(message.getBody(), StandardCharsets.UTF_8);
        doSamething(message);
    }
}

接收消息的重點是RocketMQMessageListener注解中的consumeMode參數(shù),要設(shè)置成ConsumeMode.ORDERLY武学,這樣就能順序消費消息了祭往。

修改后關(guān)鍵流程圖如下:
image.png

兩邊都修改之后,復(fù)制商品這一塊就沒有再出現(xiàn)重復(fù)商品的問題了火窒。

But硼补,修完bug之后,我又思考了良久熏矿。

復(fù)制商品只是創(chuàng)建商品的其中一個入口已骇,如果有其他入口,跟復(fù)制商品功能同時創(chuàng)建新商品呢票编?

不也會出現(xiàn)重復(fù)商品問題褪储?

雖說,這種概率非常非常小慧域。

但如果一旦出現(xiàn)重復(fù)商品問題鲤竹,后續(xù)涉及到要合并商品的數(shù)據(jù),非常麻煩昔榴。

經(jīng)過這一次的教訓(xùn)辛藻,一定要防微杜漸。

不管是用戶互订,還是自己的內(nèi)部系統(tǒng)吱肌,從不同的入口創(chuàng)建商品,都需要解決重復(fù)商品創(chuàng)建問題仰禽。

那么氮墨,如何解決這個問題呢?

6. 唯一索引

解決重復(fù)商品數(shù)據(jù)問題坟瓢,最快成本最低最有效的辦法是:給表建唯一索引勇边。

想法是好的,但我們這邊有個規(guī)范就是:業(yè)務(wù)表必須都是邏輯刪除折联。

而我們都知道粒褒,要刪除表的某條記錄的話,如果用delete語句操作的話诚镰。

例如:

delete from product where id=123;

這種delete操作是物理刪除奕坟,即該記錄被刪除之后祥款,后續(xù)通過sql語句基本查不出來。(不過通過其他技術(shù)手段可以找回月杉,那是后話了)

還有另外一種是邏輯刪除刃跛,主要是通過update語句操作的。

例如:

例如:

update product set delete_status=1,edit_time=now(3) 
where id=123;

邏輯刪除需要在表中額外增加一個刪除狀態(tài)字段苛萎,用于記錄數(shù)據(jù)是否被刪除桨昙。在所有的業(yè)務(wù)查詢的地方,都需要過濾掉已經(jīng)刪除的數(shù)據(jù)腌歉。

通過這種方式刪除數(shù)據(jù)之后蛙酪,數(shù)據(jù)任然還在表中,只是從邏輯上過濾了刪除狀態(tài)的數(shù)據(jù)而已翘盖。

其實對于這種邏輯刪除的表桂塞,是沒法加唯一索引的。

為什么呢馍驯?

假設(shè)之前給商品表中的name和model加了唯一索引阁危,如果用戶把某條記錄刪除了,delete_status設(shè)置成1了汰瘫。后來狂打,該用戶發(fā)現(xiàn)不對,又重新添加了一模一樣的商品混弥。

由于唯一索引的存在菱父,該用戶第二次添加商品會失敗,即使該商品已經(jīng)被刪除了剑逃,也沒法再添加了。

這個問題顯然有點嚴(yán)重官辽。

有人可能會說:把name蛹磺、model和delete_status三個字段同時做成唯一索引不就行了?

答:這樣做確實可以解決用戶邏輯刪除了某個商品同仆,后來又重新添加相同的商品時萤捆,添加不了的問題。但如果第二次添加的商品俗批,又被刪除了俗或。該用戶第三次添加相同的商品,不也出現(xiàn)問題了岁忘?

由此可見辛慰,如果表中有邏輯刪除功能,是不方便創(chuàng)建唯一索引的干像。

5. 分布式鎖

接下來帅腌,你想到的第二種解決數(shù)據(jù)重復(fù)問題的辦法可能是:加分布式鎖驰弄。

目前最常用的性能最高的分布式鎖,可能是redis分布式鎖了速客。

使用redis分布式鎖的偽代碼如下:

使用redis分布式鎖的偽代碼如下:

try{
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
      doSamething();
      return true;
  }
  return false;
} finally {
    unlock(lockKey,requestId);
}  

不過需要在finally代碼塊中釋放鎖戚篙。

其中l(wèi)ockKey是由商品表中的name和model組合而成的,requestId是每次請求的唯一標(biāo)識溺职,以便于它每次都能正確得釋放鎖岔擂。還需要設(shè)置一個過期時間expireTime,防止釋放鎖失敗浪耘,鎖一直存在乱灵,導(dǎo)致后面的請求沒法獲取鎖。

如果只是單個商品点待,或者少量的商品需要復(fù)制添加阔蛉,則加分布式鎖沒啥問題。

主要流程如下:
image.png

可以在復(fù)制添加商品之前癞埠,先嘗試加鎖状原。如果加鎖成功,則在查詢商品是否存在苗踪,如果不存在,則添加商品通铲。此外,在該流程中如果加鎖失敗颅夺,或者查詢商品時不存在,則直接返回吧黄。

加分布式鎖的目的是:保證查詢商品和添加商品的兩個操作是原子性的操作部服。

但現(xiàn)在的問題是,我們這次需要復(fù)制添加的商品數(shù)量很多廓八,如果每添加一個商品都要加分布式鎖的話赵抢,會非常影響性能剧蹂。

顯然對于批量接口,加redis分布式鎖烦却,不是一個理想的方案宠叼。

6. 統(tǒng)一mq異步處理

前面我們已經(jīng)聊過其爵,在批量復(fù)制商品的接口筹裕,我們是通過RocketMQ的順序消息窄驹,單線程異步復(fù)制添加商品的,可以暫時解決商品重復(fù)的問題乐埠。

但那只改了一個添加商品的入口,還有其他添加商品的入口丈咐。

能不能把添加商品的底層邏輯統(tǒng)一一下,最終都調(diào)用同一段代碼伤疙。然后通過RocketMQ的順序消息辆影,單線程異步添加商品。

主要流程如下圖所示:
image.png

這樣確實能夠解決重復(fù)商品的問題蛙讥。

但同時也帶來了另外兩個問題:

  1. 現(xiàn)在所有的添加商品功能都改成異步了,之前同步添加商品的接口如何返回數(shù)據(jù)呢旁涤?這就需要修改前端交互迫像,否則會影響用戶體驗。
  2. 之前不同的添加商品入口闻妓,是多線程添加商品的,現(xiàn)在改成只能由一個線程添加商品纷闺,這樣修改的結(jié)果導(dǎo)致添加商品的整體效率降低了份蝴。

由此犁功,綜合考慮了一下各方面因素,這個方案最終被否定了婚夫。

7. insert on duplicate key update

其實浸卦,在mysql中存在這樣的語法,即:insert on duplicate key update案糙。

在添加數(shù)據(jù)時靴庆,mysql發(fā)現(xiàn)數(shù)據(jù)不存在怒医,則直接insert。如果發(fā)現(xiàn)數(shù)據(jù)已經(jīng)存在了焰薄,則做update操作扒袖。

不過要求表中存在唯一索引PRIMARY KEY,這樣當(dāng)這兩個值相同時季率,才會觸發(fā)更新操作,否則是插入鞭光。

現(xiàn)在的問題是PRIMARY KEY是商品表的主鍵蠢络,是根據(jù)雪花算法提前生成的,不可能產(chǎn)生重復(fù)的數(shù)據(jù)啡省。

但由于商品表有邏輯刪除功能髓霞,導(dǎo)致唯一索引在商品表中創(chuàng)建不了。

由此方库,insert on duplicate key update這套方案纵潦,暫時也沒法用。

此外邀层,insert on duplicate key update在高并發(fā)的情況下,可能會產(chǎn)生死鎖問題劲赠,需要特別注意一下。

8. insert ignore

在mysql中還存在這樣的語法凛澎,即:insert ... ignore塑煎。

在insert語句執(zhí)行的過程中:mysql發(fā)現(xiàn)如果數(shù)據(jù)重復(fù)了,就忽略轧叽,否則就會插入。

它主要是用來忽略待逞,插入重復(fù)數(shù)據(jù)產(chǎn)生的Duplicate entry 'XXX' for key 'XXXX'異常的网严。

不過也要求表中存在唯一索引PRIMARY KEY

但由于商品表有邏輯刪除功能怜庸,導(dǎo)致唯一索引在商品表中創(chuàng)建不了垢村。

由此可見,這個方案也不行嘉栓。

溫馨的提醒一下,使用insert ... ignore也有可能會導(dǎo)致死鎖麻昼。

9. 防重表

之前聊過馋辈,因為有邏輯刪除功能,給商品表加唯一索引叉抡,行不通答毫。

后面又說了加分布式鎖,或者通過mq單線程異步添加商品,影響創(chuàng)建商品的性能。

那么侦副,如何解決問題呢驼鞭?

我們能否換一種思路,加一張防重表挣棕,在防重表中增加商品表的name和model字段作為唯一索引洛心。
例如:

CREATE TABLE `product_unique` (
  `id` bigint(20) NOT NULL COMMENT 'id',
  `name` varchar(130) DEFAULT NULL COMMENT '名稱',
  `model` varchar(255)  NOT NULL COMMENT '規(guī)格',
  `user_id` bigint(20) unsigned NOT NULL COMMENT '創(chuàng)建用戶id',
  `user_name` varchar(30)  NOT NULL COMMENT '創(chuàng)建用戶名稱',
  `create_date` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '創(chuàng)建時間',
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_name_model` (`name`,`model`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品防重表';

其中表中的id可以用商品表的id,表中的name和model就是商品表的name和model词身,不過在這張防重表中增加了這兩個字段的唯一索引法严。

視野一下子被打開了。

在添加商品數(shù)據(jù)之前深啤,先添加防重表。如果添加成功诱桂,則說明可以正常添加商品苫幢,如果添加失敗,則說明有重復(fù)數(shù)據(jù)触菜。

防重表添加失敗哀峻,后續(xù)的業(yè)務(wù)處理,要根據(jù)實際業(yè)務(wù)需求而定剩蟀。

如果業(yè)務(wù)上允許添加一批商品時,發(fā)現(xiàn)有重復(fù)的丙号,直接拋異常,則可以提示用戶:系統(tǒng)檢測到重復(fù)的商品喳魏,請刷新頁面重試怀薛。

例如:

try {
  transactionTemplate.execute((status) -> {
      productUniqueMapper.batchInsert(productUniqueList);
      productMapper.batchInsert(productList);
  return Boolean.TRUE;
  });
} catch(DuplicateKeyException e) {
   throw new BusinessException("系統(tǒng)檢測到重復(fù)的商品,請刷新頁面重試");
}

在批量插入數(shù)據(jù)時创倔,如果出現(xiàn)了重復(fù)數(shù)據(jù)焚碌,捕獲DuplicateKeyException異常,轉(zhuǎn)換成BusinessException這樣運行時的業(yè)務(wù)異常呐能。

還有一種業(yè)務(wù)場景,要求即使出現(xiàn)了重復(fù)的商品朗徊,也不拋異常偎漫,讓業(yè)務(wù)流程也能夠正常走下去。

例如:

try {
  transactionTemplate.execute((status) -> {
      productUniqueMapper.insert(productUnique);
      productMapper.insert(product);
  return Boolean.TRUE;
  });
} catch(DuplicateKeyException e) {
   product = productMapper.query(product);
}

在插入數(shù)據(jù)時温亲,如果出現(xiàn)了重復(fù)數(shù)據(jù)杯矩,則捕獲DuplicateKeyException,在catch代碼塊中再查詢一次商品數(shù)據(jù)魂务,將數(shù)據(jù)庫已有的商品直接返回泌射。

如果調(diào)用了同步添加商品的接口,這里非常關(guān)鍵的一點孤紧,是要返回已有數(shù)據(jù)的id拒秘,業(yè)務(wù)系統(tǒng)做后續(xù)操作臭猜,要拿這個id操作押蚤。

當(dāng)然在執(zhí)行execute之前,還是需要先查一下商品數(shù)據(jù)是否存在,如果已經(jīng)存在量愧,則直接返回已有數(shù)據(jù),如果不存在煞烫,才執(zhí)行execute方法累颂。這一步千萬不能少。

例如:

Product oldProduct = productMapper.query(product);
if(Objects.nonNull(oldProduct)) {
    return oldProduct;
}

try {
  transactionTemplate.execute((status) -> {
      productUniqueMapper.insert(productUnique);
      productMapper.insert(product);
  return Boolean.TRUE;
  });
} catch(DuplicateKeyException e) {
   product = productMapper.query(product);
}
return product;

千萬注意:防重表和添加商品的操作必須要在同一個事務(wù)中料饥,否則會出問題朱监。

順便說一下,還需要對商品的刪除功能做特殊處理一下巡蘸,在邏輯刪除商品表的同時擂送,要物理刪除防重表。用商品表id作為查詢條件即可搬味。

說實話躺苦,解決重復(fù)數(shù)據(jù)問題的方案挺多的,沒有最好的方案匹厘,只有最適合業(yè)務(wù)場景的愈诚,最優(yōu)的方案牛隅。

如果這篇文章對您有所幫助酌泰,或者有所啟發(fā)的話,幫忙掃描下發(fā)二維碼關(guān)注一下陵刹,您的支持是我堅持寫作最大的動力衰琐。

原文鏈接:https://mp.weixin.qq.com/s/L556U70XRp3CSxHBWOv6Ew

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市狸剃,隨后出現(xiàn)的幾起案子狗热,更是在濱河造成了極大的恐慌,老刑警劉巖僧凰,帶你破解...
    沈念sama閱讀 217,277評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件熟丸,死亡現(xiàn)場離奇詭異,居然都是意外死亡隙弛,警方通過查閱死者的電腦和手機狞山,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,689評論 3 393
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來总珠,“玉大人勘纯,你說我怎么就攤上這事∫迹” “怎么了堤结?”我有些...
    開封第一講書人閱讀 163,624評論 0 353
  • 文/不壞的土叔 我叫張陵鸭丛,是天一觀的道長唐责。 經(jīng)常有香客問我鼠哥,道長,這世上最難降的妖魔是什么朴恳? 我笑而不...
    開封第一講書人閱讀 58,356評論 1 293
  • 正文 為了忘掉前任菜皂,我火速辦了婚禮厉萝,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘章母。我一直安慰自己翩剪,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,402評論 6 392
  • 文/花漫 我一把揭開白布蚪缀。 她就那樣靜靜地躺著恕出,像睡著了一般。 火紅的嫁衣襯著肌膚如雪浙巫。 梳的紋絲不亂的頭發(fā)上的畴,一...
    開封第一講書人閱讀 51,292評論 1 301
  • 那天,我揣著相機與錄音护桦,去河邊找鬼煎娇。 笑死抱慌,一個胖子當(dāng)著我的面吹牛眨猎,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播睡陪,決...
    沈念sama閱讀 40,135評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼兰迫,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了涡拘?” 一聲冷哼從身側(cè)響起据德,我...
    開封第一講書人閱讀 38,992評論 0 275
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎橱野,沒想到半個月后善玫,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,429評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡蜗元,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,636評論 3 334
  • 正文 我和宋清朗相戀三年系冗,在試婚紗的時候發(fā)現(xiàn)自己被綠了毕谴。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,785評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡循帐,死狀恐怖舀武,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤跛梗,帶...
    沈念sama閱讀 35,492評論 5 345
  • 正文 年R本政府宣布棋弥,位于F島的核電站,受9級特大地震影響漾岳,放射性物質(zhì)發(fā)生泄漏粉寞。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,092評論 3 328
  • 文/蒙蒙 一捅儒、第九天 我趴在偏房一處隱蔽的房頂上張望振亮。 院中可真熱鬧,春花似錦双炕、人聲如沸撮抓。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,723評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽乖酬。三九已至,卻和暖如春算撮,著一層夾襖步出監(jiān)牢的瞬間县昂,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,858評論 1 269
  • 我被黑心中介騙來泰國打工审洞, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留亦渗,地道東北人。 一個月前我還...
    沈念sama閱讀 47,891評論 2 370
  • 正文 我出身青樓痴晦,卻偏偏與公主長得像,于是被迫代替她去往敵國和親旨袒。 傳聞我的和親對象是個殘疾皇子术辐,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,713評論 2 354

推薦閱讀更多精彩內(nèi)容