19、聊聊redis(二)

集羣

Redis 集羣是一組能進行數據共享的Redis 實例(服務或者節點)的設施,集羣可以使用的功能是普通單機 Redis 所能使用的功能的一個子集;Redis 集羣通常具有高可用、可擴展性、分佈式、容錯等特性。瞭解redis的集羣后,這些晦澀的概念可結合redis的主從、集羣分區和集羣運維等角度理解體會。

槽(slot)的基本概念

從上面集羣的簡單操作中,我們已經知道redis存取key的時候,都要定位相應的槽(slot)。
Redis 集羣鍵分佈算法使用數據分片(sharding)而非一致性哈希(consistency hashing)來實現: 一個 Redis 集羣包含 16384 個哈希槽(hash slot), 它們的編號爲0、1、2、3……16382、16383,這個槽是一個邏輯意義上的槽,實際上並不存在。redis中的每個key都屬於這 16384 個哈希槽的其中一個,存取key時都要進行key->slot的映射計算。
下面我們來看看啓動集羣時候打印的信息:

>>> Creating cluster
>>> Performing hash slots allocation on 6 nodes...
Using 3 masters:
192.168.2.128:7031
192.168.2.128:7032
192.168.2.128:7033
Adding replica 192.168.2.128:7034 to 192.168.2.128:7031
Adding replica 192.168.2.128:7035 to 192.168.2.128:7032
Adding replica 192.168.2.128:7036 to 192.168.2.128:7033
M: bee706db5ae182c5be9b9bdf94c2d6f3f8c8ec5c 192.168.2.128:7031
   slots:0-5460 (5461 slots) master
M: 72826f06dbf3be163f2f456ca24caed76a15bdf4 192.168.2.128:7032
   slots:5461-10922 (5462 slots) master
M: ab6e9d1dfc471225eef01e57be563157f81d26b3 192.168.2.128:7033
   slots:10923-16383 (5461 slots) master
......
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.

從上面信息可以看出,創建集羣的時候,哈希槽被分配到了三個主節點上,從節點是沒有哈希槽的。7031負責編號爲0-5460 共5461個 slots,7032負責編號爲 5461-10922共5462 個 slots,7033負責編號爲10923-16383 共5461個 slots。

鍵-槽映射算法

和memcached一樣,redis也採用一定的算法進行鍵-槽(key->slot)之間的映射。memcached採用一致性哈希(consistency hashing)算法進行鍵-節點(key-node)之間的映射,而redis集羣使用集羣公式來計算鍵 key 屬於哪個槽:

HASH_SLOT(key)= CRC16(key) % 16384

其中 CRC16(key) 語句用於計算鍵 key 的 CRC16 校驗和 。key經過公式計算後得到所對應的哈希槽,而哈希槽被某個主節點管理,從而確定key在哪個主節點上存取,這也是redis將數據均勻分佈到各個節點上的基礎。

無論是memcached的一致性哈希算法,還是redis的集羣分區,最主要的目的都是在移除、添加一個節點時對已經存在的緩存數據的定位影響儘可能的降到最小。redis將哈希槽分佈到不同節點的做法使得用戶可以很容易地向集羣中添加或者刪除節點, 比如說:

1)、如果用戶將新節點 D 添加到集羣中, 那麼集羣只需要將節點 A 、B 、 C 中的某些槽移動到節點 D 就可以了。

2)、與此類似, 如果用戶要從集羣中移除節點 A , 那麼集羣只需要將節點 A 中的所有哈希槽移動到節點 B 和節點 C , 然後再移除空白(不包含任何哈希槽)的節點 A 就可以了。

因爲將一個哈希槽從一個節點移動到另一個節點不會造成節點阻塞, 所以無論是添加新節點還是移除已存在節點, 又或者改變某個節點包含的哈希槽數量, 都不會造成集羣下線,從而保證集羣的可用性。

查看集羣信息

cluster info 查看集羣狀態,槽分配,集羣大小等,cluster nodes也可查看主從節點。

192.168.2.128:7031> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:6
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_sent:119
cluster_stats_messages_received:119
192.168.2.128:7031>

新增節點

(1)新增節點配置文件
執行下面的腳本創建腳本配置文件

[root@localhost redis-cluster]# mkdir /usr/local/redis-cluster/7037 && cp /usr/local/redis-cluster/7031/redis.conf /usr/local/redis-cluster/7037/redis.conf && sed -i “s/7031/7037/g” /usr/local/redis-cluster/7037/redis.conf

(2)啓動新增節點

[root@localhost bin]# /usr/local/redis/bin/redis-server /usr/local/redis-cluster/7037/redis.conf

(3)添加節點到集羣
現在已經添加了新增一個節點所需的配置文件,但是這個這點還沒有添加到集羣中,現在讓它成爲集羣中的一個主節點

[root@localhost redis-cluster]# cd /usr/local/redis/bin/
[root@localhost bin]# ./redis-trib.rb add-node 192.168.2.128:7037 192.168.2.128:7036
>>> Adding node 192.168.2.128:7037 to cluster 192.168.2.128:7036
>>> Performing Cluster Check (using node 192.168.2.128:7036)
S: 2c8d72f1914f9d6052065f7e9910cc675c3c717b 192.168.2.128:7036
   slots: (0 slots) slave
   replicates 6dbb4aa323864265c9507cf336ef7d3b95ea8d1b
M: 6dbb4aa323864265c9507cf336ef7d3b95ea8d1b 192.168.2.128:7033
   slots:10923-16383 (5461 slots) master
   1 additional replica(s)
S: 791a7924709bfd7ef5c36d9b9c838925e41e3c2e 192.168.2.128:7034
   slots: (0 slots) slave
   replicates d9e3c78a7c49689c29ab67a8a17be9d95cb08452
M: d9e3c78a7c49689c29ab67a8a17be9d95cb08452 192.168.2.128:7031
   slots:0-5460 (5461 slots) master
   1 additional replica(s)
M: 69b63d8db629fa8a689dd1ed25ed941c076d4111 192.168.2.128:7032
   slots:5461-10922 (5462 slots) master
   1 additional replica(s)
S: e669a91866225279aafcac29bf07b826eb5be91c 192.168.2.128:7035
   slots: (0 slots) slave
   replicates 69b63d8db629fa8a689dd1ed25ed941c076d4111
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
>>> Send CLUSTER MEET to node 192.168.2.128:7037 to make it join the cluster.
[OK] New node added correctly.
[root@localhost bin]#

./redis-trib.rb add-node 命令中,7037 是新增的主節點,7036 是集羣中已有的從節點。再來看看集羣信息

192.168.2.128:7031> cluster info
cluster_state:ok
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_slots_pfail:0
cluster_slots_fail:0
cluster_known_nodes:7
cluster_size:3
cluster_current_epoch:6
cluster_my_epoch:1
cluster_stats_messages_sent:11256
cluster_stats_messages_received:11256


(4)分配槽
從添加主節點輸出信息和查看集羣信息中可以看出,我們已經成功的向集羣中添加了一個主節點,但是這個主節還沒有成爲真正的主節點,因爲還沒有分配槽(slot),也沒有從節點,現在要給它分配槽(slot)

[root@localhost bin]# ./redis-trib.rb reshard 192.168.2.128:7031
>>> Performing Cluster Check (using node 192.168.2.128:7031)
M: 1a544a9884e0b3b9a73db80633621bd90ceff64a 192.168.2.128:7031
   ......
[OK] All nodes agree about slots configuration.
>>> Check for open slots...
>>> Check slots coverage...
[OK] All 16384 slots covered.
How many slots do you want to move (from 1 to 16384)? 1024
What is the receiving node ID?

系統提示要移動多少個配槽(slot),並且配槽(slot)要移動到哪個節點,任意輸入一個數,如1024,再輸入新增節點的ID cf48228259def4e51e7e74448e05b7a6c8f5713f.

(5)指定從節點
現在從節點7036的主節點是7033,現在我們要把他變爲新增加節點(7037)的從節點,需要登錄7036的客戶端

[root@localhost bin]#  /usr/local/redis/bin/redis-cli -c -h 192.168.2.128 -p 7036
192.168.2.128:7036> cluster replicate cf48228259def4e51e7e74448e05b7a6c8f5713f
OK

刪除節點

需要重新分區(即把當前節點的槽分出去)纔可以刪除節點。
添加節點、分配槽、刪除節點的過程,不用停止集羣,不阻塞集羣的其他操作

生存時間(expire)管理

lazy expiration機制:
1、在訪問key的時候判定key是否過期,如果過期,則進行過期處理。
2、每秒對volatile keys 進行抽樣測試,如果有過期鍵,那麼對所有過期key進行處理。
redis術語裏面,把設置了expire time的key 叫做:volatile keys。 意思就是不穩定的key。

內存淘汰機制

我們可以通過配置redis.conf中的maxmemory這個值來開啓內存淘汰功能,至於這個值有什麼意義,我們可以通過了解內存淘汰的過程來理解它的意義:

  1. 客戶端發起了需要申請更多內存的命令(如set)。

  2. Redis檢查內存使用情況,如果已使用的內存大於maxmemory則開始根據用戶配置的不同淘汰策略來淘汰內存(key),從而換取一定的內存。

  3. 如果上面都沒問題,則這個命令執行成功。

maxmemory爲0的時候表示我們對Redis的內存使用沒有限制。

Redis提供了下面幾種淘汰策略供用戶選擇,其中默認的策略爲noeviction策略:

· noeviction:當內存使用達到閾值的時候,所有引起申請內存的命令會報錯。

· allkeys-lru:在主鍵空間中,優先移除最近未使用的key。

· volatile-lru:在設置了過期時間的鍵空間中,優先移除最近未使用的key。

· allkeys-random:在主鍵空間中,隨機移除某個key。

· volatile-random:在設置了過期時間的鍵空間中,隨機移除某個key。

· volatile-ttl:在設置了過期時間的鍵空間中,具有更早過期時間的key優先移除。

這裏補充一下主鍵空間和設置了過期時間的鍵空間,舉個例子,假設我們有一批鍵存儲在Redis中,則有那麼一個哈希表用於存儲這批鍵及其值,如果這批鍵中有一部分設置了過期時間,那麼這批鍵還會被存儲到另外一個哈希表中,這個哈希表中的值對應的是鍵被設置的過期時間。設置了過期時間的鍵空間爲主鍵空間的子集。

我們瞭解了Redis大概提供了這麼幾種淘汰策略,那麼如何選擇呢?淘汰策略的選擇可以通過下面的配置指定:

maxmemory-policy noeviction

但是這個值填什麼呢?爲解決這個問題,我們需要了解我們的應用請求對於Redis中存儲的數據集的訪問方式以及我們的訴求是什麼。同時Redis也支持Runtime修改淘汰策略,這使得我們不需要重啓Redis實例而實時的調整內存淘汰策略。

下面看看幾種策略的適用場景

· allkeys-lru:如果我們的應用對緩存的訪問符合冪律分佈(也就是存在相對熱點數據),或者我們不太清楚我們應用的緩存訪問分佈狀況,我們可以選擇allkeys-lru策略。

· allkeys-random:如果我們的應用對於緩存key的訪問概率相等,則可以使用這個策略

· volatile-ttl:這種策略使得我們可以向Redis提示哪些key更適合被eviction。

另外,volatile-lru策略和volatile-random策略適合我們將一個Redis實例既應用於緩存和又應用於持久化存儲的時候,然而我們也可以通過使用兩個Redis實例來達到相同的效果,值得一提的是將key設置過期時間實際上會消耗更多的內存。

對象存儲

序列化對象爲二進制

使用redis的接口:

jedis.get(byte[] key)
jedis.set(byte[] key, byte[] value)

序列化方式,我們有很多種選擇,比如:Java serialize,Protobuf,或者自己手動序列化都行

序列化對象爲字符串

使用redis的接口:

jedis.get(String key);
jedis.set(String key, String value);

序列化爲字符串,我們也有很多選擇:Json(Jackson,FastJson),Xml等方式

轉換對象爲Map

使用redis的接口:

jedis.hgetAll(String key);
jedis.hmset(String key, Map<String,String> values);

一般情況使用改寫的序列化工具。

Raft算法

雖然現在很廣泛使用的Zookeeper也是基於Paxos算法來實現,但是Zookeeper使用的ZAB(Zookeeper Atomic Broadcast)協議對Paxos進行了很多的改進與優化,算法複雜我想會是制約他發展的一個重要原因;引出本篇文章的主角Raft一致性算法,沒錯Raft就是在這個背景下誕生的,文章開頭也說到了Paxos最大的問題就是複雜,Raft一致性算法就是比Paxos簡單又能實現Paxos所解決的問題的一致性算法。

從2013年發佈到現在不過只有兩年,到現在已經有了十多種語言的Raft算法實現框架,較爲出名的有etcd,Google的Kubernetes也是用了etcd作爲他的服務發現框架;由此可見易懂性是多麼的重要。

Raft和Paxos一樣只要保證n/2+1節點正常就能夠提供服務;衆所周知但問題較爲複雜時可以把問題分解爲幾個小問題來處理,Raft也使用了分而治之的思想把算法流程分爲三個子問題:選舉(Leader election)、日誌複製(Log replication)、安全性(Safety)三個子問題;這裏先簡單介紹下Raft的流程;
  Raft開始時在集羣中選舉出Leader負責日誌複製的管理,Leader接受來自客戶端的事務請求(日誌),並將它們複製給集羣的其他節點,然後負責通知集羣中其他節點提交日誌,Leader負責保證其他節點與他的日誌同步,當Leader宕掉後集羣其他節點會發起選舉選出新的Leader;

角色

  Raft把集羣中的節點分爲三種狀態:Leader、 Follower 、Candidate,理所當然每種狀態負責的任務也是不一樣的,Raft運行時提供服務的時候只存在Leader與Follower兩種狀態;
  Leader(領導者):負責日誌的同步管理,處理來自客戶端的請求,與Follower保持heartBeat的聯繫;
  Follower(追隨者):剛啓動時所有節點爲Follower狀態,響應Leader的日誌同步請求,響應Candidate的請求,把請求到Follower的事務轉發給Leader;
  Candidate(候選者):負責選舉投票,Raft剛啓動時由一個節點從Follower轉爲Candidate發起選舉,選舉出Leader後從Candidate轉爲Leader狀態;

這裏寫圖片描述

Term

  在Raft中使用了一個可以理解爲週期(第幾屆、任期)的概念,用Term作爲一個週期,每個Term都是一個連續遞增的編號,每一輪選舉都是一個Term週期,在一個Term中只能產生一個Leader;先簡單描述下Term的變化流程: Raft開始時所有Follower的Term爲1,Follower的邏輯時鐘到期後轉換爲Candidate,Term加1這是Term爲2(任期),然後開始選舉,這時候有幾種情況會使Term發生改變:
  1:如果當前Term爲2的任期內沒有選舉出Leader或出現異常,則Term遞增,開始新一任期選舉
  2:當這輪Term爲2的週期選舉出Leader後,過後Leader宕掉了,然後其他Follower轉爲Candidate,Term遞增,開始新一任期選舉
  3:當Leader或Candidate發現自己的Term比別的Follower小,Leader或Candidate將轉爲Follower,Term遞增
  4:當Follower的Term比別的Term小,Follower也將更新Term保持與其他Follower一致;
  可以說每次Term的遞增都將發生新一輪的選舉,Raft保證一個Term只有一個Leader,在Raft正常運轉中所有的節點的Term都是一致的,如果節點不發生故障一個Term(任期)會一直保持下去,當某節點收到的請求中Term比當前Term小時則拒絕該請求;

選舉(Election)

  Raft的選舉由定時器來觸發,每個節點的選舉定時器時間都是不一樣的,開始時狀態都爲Follower某個節點定時器觸發選舉後Term遞增,狀態由Follower轉爲Candidate,向其他節點發起RequestVote RPC請求,這時候有三種可能的情況發生:
  1:該RequestVote請求接收到n/2+1(過半數)個節點的投票,從Candidate轉爲Leader,向其他節點發送heartBeat以保持Leader的正常運轉
  2:在此期間如果收到其他節點發送過來的AppendEntries RPC請求,如該節點的Term大則當前節點轉爲Follower,否則保持Candidate拒絕該請求
  3:Election timeout發生則Term遞增,重新發起選舉
  在一個Term期間每個節點只能投票一次,所以當有多個Candidate存在時就會出現每個Candidate發起的選舉都存在接收到的投票數都不過半的問題,這時每個Candidate都將Term遞增、重啓定時器並重新發起選舉,由於每個節點中定時器的時間都是隨機的,所以就不會多次存在有多個Candidate同時發起投票的問題。
  有這麼幾種情況會發起選舉,1:Raft初次啓動,不存在Leader,發起選舉;2:Leader宕機或Follower沒有接收到Leader的heartBeat,發生election timeout從而發起選舉;

這裏寫圖片描述

這裏寫圖片描述

這裏寫圖片描述

Raft採用心跳機制觸發Leader選舉。系統啓動後,全部節點初始化爲Follower,term爲0.節點如果收到了RequestVote或者AppendEntries,就會保持自己的Follower身份。如果一段時間內沒收到AppendEntries消息直到選舉超時,說明在該節點的超時時間內還沒發現Leader,Follower就會轉換成Candidate,自己開始競選Leader。一旦轉化爲Candidate,該節點立即開始下面幾件事情:

1、增加自己的term。
2、啓動一個新的定時器。
3、給自己投一票。
4、向所有其他節點發送RequestVote(term + commit_index),並等待其他節點的回覆。
如果在這過程中收到了其他節點發送的AppendEntries,就說明已經有Leader產生,自己就轉換成Follower,選舉結束。
如果在計時器超時前,節點收到多數節點的同意投票,就轉換成Leader。同時向所有其他節點發送AppendEntries,告知自己成爲了Leader。

每個節點在一個term內只能投一票,採取先到先得的策略,Candidate前面說到已經投給了自己,Follower會投給第一個收到RequestVote的節點。每個Follower有一個計時器,在計時器超時時仍然沒有接受到來自Leader的心跳RPC, 則自己轉換爲Candidate, 開始請求投票,就是上面的的競選Leader步驟。

如果多個Candidate發起投票,每個Candidate都沒拿到多數的投票(Split Vote),那麼就會等到計時器超時後重新成爲Candidate,重複前面競選Leader步驟。

Raft協議的定時器採取隨機超時時間,這是選舉Leader的關鍵。每個節點定時器的超時時間隨機設置,隨機選取配置時間的1倍到2倍之間。由於隨機配置,所以各個Follower同時轉成Candidate的時間一般不一樣,在同一個term內,先轉爲Candidate的節點會先發起投票,從而獲得多數票。多個節點同時轉換爲Candidate的可能性很小。即使幾個Candidate同時發起投票,在該term內有幾個節點獲得一樣高的票數,只是這個term無法選出Leader。由於各個節點定時器的超時時間隨機生成,那麼最先進入下一個term的節點,將更有機會成爲Leader。連續多次發生在一個term內節點獲得一樣高票數在理論上機率很小,實際上可以認爲完全不可能發生。一般1-2個term類,Leader就會被選出來。

日誌複製(Log Replication)

  日誌複製(Log Replication)主要作用是用於保證節點的一致性,這階段所做的操作也是爲了保證一致性與高可用性;當Leader選舉出來後便開始負責客戶端的請求,所有事務(更新操作)請求都必須先經過Leader處理,這些事務請求或說成命令也就是這裏說的日誌,我們都知道要保證節點的一致性就要保證每個節點都按順序執行相同的操作序列,日誌複製(Log Replication)就是爲了保證執行相同的操作序列所做的工作;在Raft中當接收到客戶端的日誌(事務請求)後先把該日誌追加到本地的Log中,然後通過heartbeat把該Entry同步給其他Follower,Follower接收到日誌後記錄日誌然後向Leader發送ACK,當Leader收到大多數(n/2+1)Follower的ACK信息後將該日誌設置爲已提交併追加到本地磁盤中,通知客戶端並在下個heartbeat中Leader將通知所有的Follower將該日誌存儲在自己的本地磁盤中。

安全性(Safety)

  安全性是用於保證每個節點都執行相同序列的安全機制,如當某個Follower在當前Leader commit Log時變得不可用了,稍後可能該Follower又會選舉爲Leader,這時新Leader可能會用新的Log覆蓋先前已committed的Log,這就是導致節點執行不同序列;Safety就是用於保證選舉出來的Leader一定包含先前 commited Log的機制;
  選舉安全性(Election Safety)
  每個Term只能選舉出一個Leader
  Leader完整性(Leader Completeness)
  這裏所說的完整性是指Leader日誌的完整性,當Log在Term1被Commit後,那麼以後Term2、Term3…等的Leader必須包含該Log;Raft在選舉階段就使用Term的判斷用於保證完整性:當請求投票的該Candidate的Term較大或Term相同Index更大則投票,否則拒絕該請求;

leader選舉的效率

Raft中的每個server在某個term輪次內只能投一次票,哪個candidate先請求投票誰就可能先獲得投票,這樣就可能造成split vote,即各個candidate都沒有收到過半的投票,Raft通過candidate設置不同的超時時間,來快速解決這個問題,使得先超時的candidate(在其他人還未超時時)優先請求來獲得過半投票
ZooKeeper中的每個server,在某個electionEpoch輪次內,可以投多次票,只要遇到更大的票就更新,然後分發新的投票給所有人。這種情況下不存在split vote現象,同時有利於選出含有更新更多的日誌的server,但是選舉時間理論上相對Raft要花費的多。

怎麼發現已完成選舉的leader?

一個server啓動後(該server本來就屬於該集羣的成員配置之一,所以這裏不是新加機器),如何加入一個已經選舉完成的集羣
Raft:比較簡單,該server啓動後,會收到leader的AppendEntries RPC,這時就會從RPC中獲取leader信息,識別到leader,即使該leader是一個老的leader,之後新leader仍然會發送AppendEntries RPC,這時就會接收到新的leader了(因爲新leader的term比老leader的term大,所以會更新leader)

ZooKeeper:該server啓動後,會向所有的server發送投票通知,這時候就會收到處於LOOKING、FOLLOWING狀態的server的投票(這種狀態下的投票指向的leader),則該server放棄自己的投票,判斷上述投票是否過半,過半則可以確認該投票的內容就是新的leader。

分區的應對

目前ZooKeeper和Raft都是過半即可,所以對於分區是容忍的。如5臺機器,分區發生後分成2部分,一部分3臺,另一部分2臺,這2部分之間無法相互通信

其中,含有3臺的那部分,仍然可以湊成一個過半,仍然可以對外提供服務,但是它不允許有server再掛了,一旦再掛一臺則就全部不可用了。

含有2臺的那部分,則無法提供服務,即只要連接的是這2臺機器,都無法執行相關請求。

所以ZooKeeper和Raft在一旦分區發生的情況下是是犧牲了高可用來保證一致性,即CAP理論中的CP。但是在沒有分區發生的情況下既能保證高可用又能保證一致性,所以更想說的是所謂的CAP二者取其一,並不是說該系統一直保持CA或者CP或者AP,而是一個會變化的過程。在沒有分區出現的情況下,既可以保證C又可以保證A,在分區出現的情況下,那就需要從C和A中選擇一樣。ZooKeeper和Raft則都是選擇了C

上一輪次的leader的殘留的數據怎麼處理?

Raft:對於之前term的過半或未過半複製的日誌採取的是保守的策略,全部判定爲未提交,只有噹噹前term的日誌過半了,纔會順便將之前term的日誌進行提交
ZooKeeper:採取激進的策略,對於所有過半還是未過半的日誌都判定爲提交,都將其應用到狀態機中

Raft的保守策略更多是因爲Raft在leader選舉完成之後,沒有同步更新過程來保持和leader一致(在可以對外處理請求之前的這一同步過程)。而ZooKeeper是有該過程的

應用

註冊中心

dubbo利用redis作爲註冊中心

分佈式鎖

全局主鍵

利用redis的lua腳本執行功能,在每個節點上通過lua腳本生成唯一ID。

秒殺

總數自減

共享session

MQ

利用list實現隊列

duboo利用redis註冊服務


redis沒有watch機制,只能輪詢檢測了..

參考:
https://blog.csdn.net/javaloveiphone/article/details/52352894
https://www.cnblogs.com/cchust/p/5634782.html
http://m635674608.iteye.com/blog/2337085

發佈了74 篇原創文章 · 獲贊 32 · 訪問量 11萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章