【翻】使用redis實(shí)現(xiàn)分布式鎖 - Redlock

在很多環(huán)境下,多個(gè)不同的進(jìn)程需要以排他的形式使用共享資源,這是使用分布式鎖機(jī)制是一種傳統(tǒng)但有效的方案。

有很多的庫和博客都介紹了如何用Redis去實(shí)現(xiàn)DLM(Distributed Lock Manger,分布式鎖管理器),但是每個(gè)庫彼此之間的實(shí)現(xiàn)方式都不相同。而且很多的實(shí)現(xiàn)方式都比較簡單,保險(xiǎn)級別較低。

此篇文章試圖介紹一種更為標(biāo)準(zhǔn)的用redis來實(shí)現(xiàn)分布式鎖的算法,我們將這種算法叫做Redlock,我們相信用此算法實(shí)現(xiàn)的DLM比普通的單redis實(shí)例實(shí)現(xiàn)方法更加安全。我們希望社區(qū)可以對其進(jìn)行評估,給予反饋,或者把它作為其他復(fù)雜算法實(shí)現(xiàn)的切入點(diǎn)或替代方案。

Implementations | 實(shí)現(xiàn)

在介紹算法之前,如下是基于此算法的一些現(xiàn)成的實(shí)現(xiàn),可以用來作為參考:

Safety and Liveness guarantees | 安全和存活性保證

我們的算法設(shè)計(jì)是基于如下三個(gè)特性建模的,在我們看來,這也是為了高效的使用分布式鎖所需要的最低的保證:

  1. 安全特性:排它性。在任何時(shí)間點(diǎn),只能有一個(gè)客戶端可以持有鎖。
  2. 存活特性A:無死鎖??偸强梢垣@取到鎖,即使在一個(gè)被客戶端鎖住的資源損壞了或分區(qū)了的情況下。
  3. 存活特性B:可容錯(cuò)。只要大多數(shù)的redis節(jié)點(diǎn)是可用的,那么客戶端就可以獲取和釋放鎖。

Why failover-based implementations are not enough | 為什么基于故障轉(zhuǎn)移的實(shí)現(xiàn)不能夠完全滿足要求

為了理解我們希望提高的地方,讓我們先來分析一下當(dāng)前大多數(shù)基于redis的分布式鎖的庫的實(shí)現(xiàn)狀況。

通過redis來鎖定一個(gè)資源的最簡單的實(shí)現(xiàn)就是在redis實(shí)例中創(chuàng)建一個(gè)key,創(chuàng)建的同時(shí)給key設(shè)置一個(gè)有效期。利用redis的過期機(jī)制,這個(gè)key最終總是會(huì)被釋放(上述的第二條特性)。當(dāng)客戶端需要釋放資源的時(shí)候,它只需要?jiǎng)h除這個(gè)key即可。

表面上來看這種方式?jīng)]有什么問題,但是這種實(shí)現(xiàn)存在一個(gè)單點(diǎn)故障的問題。如果redis節(jié)點(diǎn)宕機(jī)了怎么辦?好吧,那讓我們加上一個(gè)slave節(jié)點(diǎn),當(dāng)redis的master節(jié)點(diǎn)宕機(jī)了之后,slave節(jié)點(diǎn)可以接管服務(wù)。但是,很遺憾此種方法是不可行的,因此這種master-slave的實(shí)現(xiàn)無法完全保證互斥性,因?yàn)閞edis主從節(jié)點(diǎn)之間的復(fù)制是異步的。

在這個(gè)模型中顯然存在一個(gè)資源競爭因素:

  1. 客戶端A在master節(jié)點(diǎn)獲取到鎖。
  2. master節(jié)點(diǎn)在還沒有將key復(fù)制給slave節(jié)點(diǎn)之前宕機(jī)了。
  3. slave節(jié)點(diǎn)被提升為master。
  4. 客戶端B獲取到鎖,這樣子客戶端A和B就針對同一資源都拿到了鎖。違背了互斥性。

有時(shí)在特殊的情況下,這種實(shí)現(xiàn)方式是完全ok的。比如在發(fā)生故障的時(shí)候,多個(gè)客戶端允許同時(shí)持有鎖。除此之外,我們建議用此文章描述的算法來實(shí)現(xiàn)DLM較為妥當(dāng)。

Correct implementation with a single instance | 單redis實(shí)例的正確實(shí)現(xiàn)方式

在嘗試用Redlock解決上述案例的缺點(diǎn)之前,讓我們先看看如何正確使用redis單實(shí)例來實(shí)現(xiàn)分布式鎖。如果應(yīng)用程序的非頻繁的競態(tài)條件是可接受的,那么單redis實(shí)例是一個(gè)可行的方案,而且這種方案的實(shí)現(xiàn)也是接下來要介紹的分布式算法的基礎(chǔ)。

要獲取一個(gè)鎖,可以使用如下的命令:

SET resource_name my_random_value NX PX 30000

這條命令只有當(dāng)key不存在時(shí)才會(huì)生成它(NX選項(xiàng)),并且超時(shí)時(shí)間為30000毫秒(PX選項(xiàng))。key的值被設(shè)置為my_random_value。這個(gè)值必須要在所有的客戶端和所有的鎖請求中唯一。這個(gè)隨機(jī)的值用來以一種安全的方式釋放鎖,通過一個(gè)腳本來通知redis:當(dāng)這個(gè)key存在,并且key的值也完全與期望的一致,才移除這個(gè)key。這樣一個(gè)邏輯通過Lua腳本的實(shí)現(xiàn)如下:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

這樣子做可以避免刪除其他客戶端創(chuàng)建的鎖。比如說,一個(gè)客戶端可能請求一個(gè)鎖,這個(gè)客戶端接下來進(jìn)行其他任務(wù),并且其他任務(wù)執(zhí)行了較長的時(shí)間,超過了key過期的時(shí)間,然后此客戶端再回來刪除鎖,如果這個(gè)時(shí)候它直接使用DEL命令來刪除的話,有可能刪除的是其他客戶端的鎖(因?yàn)閗ey已近超時(shí)被刪除,其他客戶端獲取到了新的鎖)。使用上面的邏輯,相當(dāng)于給每一個(gè)客戶端創(chuàng)建的鎖都做了一次“簽名”(通過給key賦予隨機(jī)且唯一的值),客戶端刪除鎖的時(shí)候只能刪除之前“簽名”過的那個(gè)鎖。

那么這個(gè)隨機(jī)的值應(yīng)該是什么形式的呢?最好是從/dev/urandom中生成的20字節(jié)的隨機(jī)值,但是你也可以使用其他更廉價(jià)的方式生成唯一值,只要它足夠的“唯一”。比如說通過使用/dev/urandom選取RC4種子,然后從中生成偽隨機(jī)數(shù)據(jù)流。一個(gè)更簡單的方案就是使用帶有微秒的unix時(shí)間戳和其他數(shù)據(jù)的組合,比如客戶端id,這樣子不是絕對安全,但是對于大多數(shù)任務(wù)來說都已經(jīng)足夠了。

這里的key的生存時(shí)間我們叫做“鎖的有效時(shí)間”。它既是鎖的自動(dòng)釋放時(shí)間,也是當(dāng)前客戶端在其他客戶端獲取鎖之前執(zhí)行必要操作所花的時(shí)間。這樣子在技術(shù)上沒有違反互斥性原則,只是從獲取鎖的那一刻開始,限制一個(gè)指定的時(shí)間窗口。

那么到目前為止,我們已經(jīng)有了一個(gè)很好的請求和釋放鎖的方案。這是一個(gè)非分布式系統(tǒng),構(gòu)建于一個(gè)單一的redis實(shí)例之上,只要理論上此redis實(shí)例永遠(yuǎn)在線,那么這個(gè)方案就是安全的。 接下來讓我們將這些概念擴(kuò)展到一個(gè)分布式系統(tǒng)之上,在這個(gè)分布式系統(tǒng)上,我們不需要保證每個(gè)redis實(shí)例的永久可用性。

The Redlock algorithm | Redlock算法

在這個(gè)算法的分布式版本中,我們假設(shè)擁有N個(gè)redis主節(jié)點(diǎn)。這些節(jié)點(diǎn)是完全彼此獨(dú)立的,沒有使用replication或其他協(xié)調(diào)系統(tǒng)。我們已經(jīng)介紹了如何在一個(gè)單redis實(shí)例中獲取和釋放鎖,這個(gè)Redlock算法在單redis實(shí)例上對鎖的操作機(jī)制也是一樣的。在接下來的舉例中,我們設(shè)置節(jié)點(diǎn)數(shù)為5,因此我們需要在5臺不同的機(jī)器上分別運(yùn)行redis實(shí)例,確保它們在宕機(jī)時(shí)彼此之間不會(huì)有影響。

為了獲取鎖,客戶端需要執(zhí)行如下操作步驟:

  1. 獲取當(dāng)前系統(tǒng)的以毫秒為單位的系統(tǒng)時(shí)間。
  2. 嘗試按照順序在N個(gè)redis實(shí)例中獲取鎖,并且保持在所有的實(shí)例中都使用相同的key和value。在此步驟中,當(dāng)客戶端在每一個(gè)實(shí)例中獲取鎖時(shí),它同時(shí)會(huì)設(shè)置一個(gè)超時(shí)時(shí)間,這個(gè)超時(shí)時(shí)間應(yīng)該遠(yuǎn)小于鎖的自動(dòng)釋放時(shí)間。比如說如果鎖的自動(dòng)釋放時(shí)間為10秒,那么這個(gè)超時(shí)時(shí)間就可以設(shè)置在5-50毫秒之間。這樣子就避免客戶端在與一個(gè)已經(jīng)宕機(jī)的redis實(shí)例的通信中浪費(fèi)太多時(shí)間,如果一個(gè)節(jié)點(diǎn)不可用,客戶端應(yīng)該馬上與下一個(gè)節(jié)點(diǎn)進(jìn)行通信。
  3. 客戶端在每一個(gè)redis節(jié)點(diǎn)上獲取鎖時(shí)都會(huì)計(jì)算從開始到現(xiàn)在總共過去了多少時(shí)間,此計(jì)算只需要用當(dāng)前的時(shí)間減去第一步中保存下來的時(shí)間即可得到。當(dāng)且僅當(dāng)客戶端在多數(shù)節(jié)點(diǎn)中獲取到了鎖(至少3個(gè)),并且總共消耗的時(shí)間小于鎖的初始有效時(shí)間,這個(gè)鎖才被認(rèn)為是獲取成功了。
  4. 如果獲取鎖成功,那么鎖的有效時(shí)間應(yīng)該設(shè)置為初始有效時(shí)間減去時(shí)間流逝的時(shí)長,即步驟3的計(jì)算結(jié)果。
  5. 如果客戶端獲取鎖失敗(沒有在超過N/2+1個(gè)節(jié)點(diǎn)上獲取到鎖或計(jì)算出有效時(shí)間是負(fù)數(shù)都認(rèn)為獲取鎖失?。?,此時(shí)客戶端會(huì)嘗試對所有的節(jié)點(diǎn)進(jìn)行解鎖操作(即使對于那些獲取鎖失敗的節(jié)點(diǎn))

譯者注:之所以在每個(gè)redis節(jié)點(diǎn)上面都計(jì)算時(shí)間的流逝時(shí)長,并將lock的有效時(shí)間設(shè)置為:初始有效時(shí)間 - 流逝時(shí)長。是因?yàn)檫@樣子可以保證,所有的redis節(jié)點(diǎn)的lock會(huì)在同一個(gè)時(shí)間點(diǎn)過期。如果在每一個(gè)redis節(jié)點(diǎn)上面設(shè)置相同的lock有效時(shí)間,由于在每個(gè)結(jié)點(diǎn)上獲取鎖操作是串行的,那么此時(shí)必然會(huì)造成后面加鎖的節(jié)點(diǎn)的鎖過期會(huì)晚于前面的節(jié)點(diǎn),這樣子就造成了不同步。

Is the algorithm asynchronous? | 這個(gè)算法是異步的嗎?

這個(gè)算法依賴于所有運(yùn)行redis實(shí)例的主機(jī)相互之間都沒有做時(shí)鐘同步的情形,它們彼此都使用自己的本地時(shí)間,時(shí)鐘周期應(yīng)該是極度近似的(理論上來說每一臺機(jī)器的時(shí)鐘周期都不可能是絕對相同的,但是彼此之間的這種極微小差異,是可以容忍和忽略的)。這種類比就好像是真實(shí)世界中的計(jì)算機(jī)一樣:每一臺計(jì)算機(jī)都有一個(gè)本地的時(shí)鐘,他們彼此之間的時(shí)鐘的偏移是極小的,通常是可承受的。

在這一點(diǎn)上,我們需要更加詳細(xì)的說明我們算法的互斥性規(guī)則: 它保證了,只要客戶端持有鎖,客戶端就會(huì)在鎖的有效期之內(nèi)(這里的有效期指的是上面的步驟3獲取到的時(shí)間,而不是鎖的初始有效期時(shí)間)完成它對資源的操作工作,當(dāng)然還得減去一些時(shí)間(這些時(shí)間通常已毫秒為單位,是針對不同進(jìn)程間的時(shí)鐘偏移的補(bǔ)償)。

關(guān)于需要約束時(shí)鐘偏移的相似系統(tǒng)的更多信息,這邊文章是比較受人關(guān)注的:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency。

Retry on failure | 失敗重試

當(dāng)一個(gè)客戶端無法獲取到鎖時(shí),它應(yīng)該在一個(gè)隨機(jī)的延時(shí)時(shí)間之后重新嘗試,以避免多個(gè)客戶端延時(shí)相同的時(shí)間,造成它們在同一時(shí)間對同一資源進(jìn)行訪問的問題(此時(shí)容易造成腦裂現(xiàn)象)。同樣的,客戶端發(fā)送消息的延時(shí)越小,出現(xiàn)腦裂條件(和所需要的重試)的時(shí)間窗口就越小,所以,理想的情況是,客戶端應(yīng)該嘗試通過多播的方式,在同一時(shí)間向所有redis實(shí)例發(fā)送SET指令。

客戶端如果沒有在多數(shù)redis實(shí)例上獲取到鎖,它應(yīng)該立刻在已經(jīng)獲取到鎖的節(jié)點(diǎn)上釋放鎖,對于這一點(diǎn)是值的重點(diǎn)強(qiáng)調(diào)的。這樣子的話,對于這個(gè)資源的鎖就不用等到key自動(dòng)過期之后才能夠被再次獲?。ㄈ欢?,如果出現(xiàn)網(wǎng)絡(luò)分區(qū)這種情況,客戶端將無法與redis實(shí)例進(jìn)行通信,此資源就必須等到key自動(dòng)過期之后才能被使用了)。

Releasing the lock | 釋放鎖

對于鎖的釋放就比較簡單了,客戶端只需要在所有的redis實(shí)例上執(zhí)行鎖的釋放命令,而不用管客戶端是否之前在此節(jié)點(diǎn)上成功的獲取了鎖。

Safety arguments | 安全性論證

這個(gè)算法真的安全嗎?我們接下來模擬一下在不同的場景下都會(huì)發(fā)生些什么。

在開始之前,我們假設(shè)客戶端可以在多數(shù)的redis實(shí)例上獲取到鎖。所有的實(shí)例都存在一個(gè)相同的key,并且此key有相同的生存時(shí)間,然而,在不同的實(shí)例上,針對于此key設(shè)置的實(shí)際生存時(shí)間是不同的,所以key會(huì)在不同的時(shí)候過期。但是如果第一個(gè)key在最壞的情況下設(shè)置的時(shí)間為T1(這個(gè)時(shí)間是與第一個(gè)redis實(shí)例通信之前的時(shí)間),最后一個(gè)key在最壞的情況下設(shè)置的時(shí)間為T2(從最后一個(gè)服務(wù)器的響應(yīng)中獲取的),我們確信在整個(gè)集合中的第一個(gè)key的存活時(shí)間至少為MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。其他的key都會(huì)在其之后失效,所以我們確信所有的key會(huì)至少在這個(gè)時(shí)間內(nèi)被同時(shí)設(shè)置完成。

在這個(gè)時(shí)間期間,多數(shù)的key會(huì)被設(shè)置,其他客戶端將不可能得到鎖,因?yàn)橐呀?jīng)有超過半數(shù)的key已經(jīng)被設(shè)定,所以N/2+1 SET NX操作會(huì)失敗。所以一旦lock被獲取,它就不可能在同一時(shí)間被其他客戶端獲?。ú蝗痪瓦`背了互斥性)。

然而,我們也想要確保多個(gè)客戶端在同一時(shí)間不可能同時(shí)成功的獲取到鎖。

如果一個(gè)客戶端鎖住了多數(shù)redis實(shí)例,使用的時(shí)間接近于或超過了鎖的最大存活時(shí)間(TTL的最大初始值),它會(huì)認(rèn)為獲取的鎖無效,并且會(huì)解鎖所有的redis實(shí)例,所以我們只需要考慮客戶端可以獲取到大多數(shù)redis節(jié)點(diǎn)的鎖,并且時(shí)間小于TTL有效時(shí)長的這種情況。在這種情況下,這個(gè)爭論就如上面鎖描述的那樣,對于MIN_VALIDITY,沒有客戶端可以重復(fù)獲取到鎖。所以,多個(gè)客戶端同時(shí)鎖定n/2 +1個(gè)redis實(shí)例的情況只會(huì)發(fā)生于鎖定時(shí)長超過TTL時(shí)間的這種情況,而這種情況發(fā)生后,客戶端會(huì)標(biāo)識此鎖為無效的,并解除鎖定。

如果你可以提供一個(gè)對此安全性的有效的證據(jù),指出現(xiàn)存的相似的算法,或發(fā)現(xiàn)了bug,我們將會(huì)非常感激你的提醒。

Liveness arguments | 存活性論證

系統(tǒng)的存活性基于三個(gè)主要的特性:

  1. 鎖的自動(dòng)釋放(由于key過期),過期之后key可以被其他客戶端獲取。
  2. 實(shí)際上客戶端也會(huì)有移除鎖的機(jī)制,當(dāng)客戶端獲取鎖失敗時(shí),或者當(dāng)
    鎖獲取到了,但是需要加鎖處理的工作被終結(jié)了的時(shí)候。這種情況下,我們就不需要等到key自動(dòng)失效了。
  3. 當(dāng)客戶端需要重新嘗試獲取一個(gè)鎖時(shí),它等待的時(shí)長會(huì)超過需要獲取鎖的總時(shí)長。從而在概率上就使得在資源競爭中的腦裂現(xiàn)象變得不太可能。

然而這一機(jī)制的實(shí)現(xiàn)是以可用性為代價(jià)的。當(dāng)發(fā)生網(wǎng)絡(luò)分區(qū)時(shí),整個(gè)系統(tǒng)的可用性代價(jià)就是TTL時(shí)長。如果發(fā)生連續(xù)性的網(wǎng)絡(luò)分區(qū),那么可用性就無法確定了。這種情況發(fā)生于客戶端獲取到了鎖,還沒來得及釋放鎖時(shí)被網(wǎng)絡(luò)分區(qū)隔離了。

Performance, crash-recovery and fsync | 性能,故障修復(fù)和fsync

很多用戶在需要高性能的場景中使用redis作為鎖服務(wù)器。為了滿足這一需求,使用多播技術(shù)(或poor man's multiplexing,將socket放在非阻塞同步模型中,發(fā)送所有的命令,然后之后讀取這些命令,假設(shè)客戶端和每個(gè)redis實(shí)例中的RTT都類似)與N個(gè)redis實(shí)例通信可以用來降低延時(shí)。

然而如果我們需要實(shí)現(xiàn)一個(gè)故障恢復(fù)的模型的話那就需要考慮數(shù)據(jù)的持久化了。

我們先假設(shè)所有的redis實(shí)例沒有配置持久化。一個(gè)客戶端在總共5個(gè)實(shí)例中的3個(gè)中獲取到了鎖。之后這3個(gè)實(shí)例中的其中一個(gè)重啟了,這時(shí)對于同一個(gè)資源,又有個(gè)3個(gè)redis實(shí)例可以獲取鎖,其他客戶端就會(huì)對此資源進(jìn)行鎖定,這樣子就違背了鎖的排它性。

如果我們啟用AOF持久化,事情會(huì)變得好一點(diǎn)。例如說,我們可以對其中一個(gè)redis服務(wù)器發(fā)起SHUTDOWN指令來重啟它。在重啟完成之后,此redis實(shí)例從AOF文件中將數(shù)據(jù)恢復(fù)出來,給鎖設(shè)置的生存時(shí)間也會(huì)同步減少重啟所花費(fèi)的這些時(shí)間,一切都沒有問題。然后,也只有當(dāng)是正常關(guān)機(jī)的時(shí)候才不會(huì)引發(fā)問題,如果是一個(gè)電源中斷的宕機(jī)呢?如果redis配置的是每秒fsync數(shù)據(jù)到硬盤,那么重啟之后有可能我們的key會(huì)丟失,理論上,如果我們要確保在任意形式的服務(wù)器重啟的情況下的鎖的安全性,我們需要配置fsync=always。而這種配置會(huì)極大的降低性能,性能上甚至都趕不上傳統(tǒng)的以一種安全的方法實(shí)施分布式鎖的相同等級的CP系統(tǒng)。

然而,事情比看起來要好很多。只要實(shí)例在宕機(jī)之后能夠重新啟動(dòng),算法的安全性就沒有影響。當(dāng)它重啟后,他不會(huì)影響到當(dāng)前已激活的鎖的計(jì)算,所以,當(dāng)前已激活的鎖會(huì)從此機(jī)器之外的實(shí)例中獲取。

為了保證這一點(diǎn),在redis服務(wù)器重啟完成之后,我們需要讓此redis實(shí)例在啟動(dòng)之后的開始的一段時(shí)間不可用,此時(shí)長要大于最大TTL時(shí)長一點(diǎn)點(diǎn)。這么設(shè)計(jì)是為了保證在此服務(wù)器宕機(jī)時(shí)的那些key能夠自動(dòng)過期。

使用delayed restarts就可以達(dá)到上述目標(biāo),甚至不需要配置任何的redis持久化。然而,這一點(diǎn)會(huì)轉(zhuǎn)化為對系統(tǒng)可用性的懲罰。例如,如果大多數(shù)的redis實(shí)例都宕機(jī)了,這個(gè)系統(tǒng)在TTL時(shí)長內(nèi)就變?yōu)槿植豢捎脿顟B(tài)了(這里的全局不可用的意思是客戶端沒法針對資源加鎖,導(dǎo)致接下來的業(yè)務(wù)無法進(jìn)行下去)

Making the algorithm more reliable: Extending the lock | 將這個(gè)算法變得更可靠:鎖的延期

如果業(yè)務(wù)的執(zhí)行由客戶端的一系列步驟所組成,默認(rèn)可以將鎖的有效時(shí)間設(shè)置的更小,并實(shí)現(xiàn)一個(gè)鎖的ttl延長機(jī)制。如果客戶端的計(jì)算工作只處理了一半,同時(shí)鎖的有效時(shí)間已經(jīng)不多了,它可以通過一個(gè)Lua腳本的來給所有的redis實(shí)例延長key的ttl,并且此key的值保持不變。

客戶端應(yīng)該只有當(dāng)它可以在鎖的有效期之內(nèi)在多數(shù)redis實(shí)例上延長鎖時(shí)才會(huì)考慮鎖的重新獲取。基本上,鎖的延長算法非常類似于獲取鎖的算法。

這一機(jī)制對整體的算法沒有影響,所以鎖的重新獲取的最大嘗試數(shù)量也應(yīng)該被限制,否則,就會(huì)違背存活性特性。

Want to help? | 尋求幫助?

如果你在開發(fā)分布式系統(tǒng),對此有自己的觀點(diǎn)和分析,請告訴我們?;蛘邘椭覀儗⒋怂惴ㄊ褂闷渌_發(fā)語言來實(shí)現(xiàn)它。

非常感謝!

Analysis of Redlock | Redlock的外部分析

  1. Martin Kleppmann的分析在這里。我不同意他的分析,相關(guān)的回復(fù)發(fā)表在這里

完!

感覺中間關(guān)于Safety arguments這一段理解的不是很透徹,基本是按照字面意思翻譯的,如果有錯(cuò)誤的地方麻煩提出指正。多謝!

參考資料
Distributed locks with Redis

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容