Redis集群(六)

一、概述

集群,即Redis Cluster,是Redis 3.0開始引入的分布式存儲方案。

集群由多個節點(Node)組成,Redis的數據分布在這些節點中。集群中的節點分為主節點和從節點:只有主節點負責讀寫請求和集群信息的維護;從節點只進行主節點數據和狀態信息的復制。

集群的作用,可以歸納為兩點:

1、數據分區:數據分區(或稱數據分片)是集群最核心的功能。

集群將數據分散到多個節點,一方面突破了Redis單機內存大小的限制,存儲容量大大增加;另一方面每個主節點都可以對外提供讀服務和寫服務,大大提高了集群的響應能力。

Redis單機內存大小受限問題。例如,如果單機內存太大,bgsave和bgrewriteaof的fork操作可能導致主進程阻塞,主從環境下主機切換時可能導致從節點長時間無法提供服務,全量復制階段主節點的復制緩沖區可能溢出……。

2、高可用:集群支持主從復制和主節點的自動故障轉移(與哨兵類似);當任一節點發生故障時,集群仍然可以對外提供服務。

本文內容基于Redis 5.0.7。

二、集群搭建

我們將搭建一個簡單的集群:共6個節點,3主3從。方便起見:所有節點在同一臺服務器上,以端口號進行區分;配置從簡。3個主節點端口號:7001/7002/7003,對應的從節點端口號:7004/7005/7006。

集群的搭建有兩種方式:(1)手動執行Redis命令,一步步完成搭建;(2)使用Ruby腳本搭建。二者搭建的原理是一樣的,只是Ruby腳本將Redis命令進行了打包封裝;在實際應用中推薦使用腳本方式,簡單快捷不容易出錯。下面分別介紹這兩種方式。

執行Redis命令搭建集群

集群的搭建可以分為三步:

(1)啟動節點:將節點啟動,此時節點是獨立的,并沒有建立聯系;

(2)創建集群

(3)指定主從關系:為從節點指定主節點。

1. 啟動節點

集群節點的啟動仍然是使用redis-server命令,但需要使用集群模式啟動。下面是7001節點的配置文件(只列出了節點正常工作關鍵配置,其他配置(如開啟AOF)可以參照單機節點進行):

'redis-7001.conf'
bind 172.16.71.183 #一定要寫本機ip并且建立集群的時候要用這個ip建立
port 7001
cluster-enabled yes #開啟集群
cluster-config-file "node-7001.conf" #節點信息,自動生成
cluster-node-timeout 5000  #超時時間
logfile "log-7001.log"
dbfilename "dump-7001.rdb"
daemonize yes

#建議增加 
dir /var/user/redis-5.0.7/7001/  #文件路徑
pidfile /var/run/redis_7001.pid  #pid位置
daemonize yes  #守護線程模式(后臺啟動)
requirepass “CSFW” #訪問密碼
masterauth “CSFW” #主機密碼
  • cluster-enabled yes:Redis實例可以分為單機模式(standalone)和集群模式(cluster);cluster-enabled yes可以啟動集群模式。在單機模式下啟動的Redis實例,如果執行info server命令,可以發現redis_mode一項為standalone,集群模式下的節點,其redis_mode為cluster

  • cluster-config-file:該參數指定了集群配置文件的位置。每個節點在運行過程中,會維護一份集群配置文件;每當集群信息發生變化時(如增減節點),集群內所有節點會將最新信息更新到該配置文件;當節點重啟后,會重新讀取該配置文件,獲取集群信息,可以方便的重新加入到集群中。也就是說,當Redis節點以集群模式啟動時,會首先尋找是否有集群配置文件,如果有則使用文件中的配置啟動,如果沒有,則初始化配置并將配置保存到文件中。集群配置文件由Redis節點維護,不需要人工修改。

編輯好配置文件后,使用redis-server命令啟動該節點:

redis-server redis-7001.conf

節點啟動以后,通過cluster nodes命令可以查看節點的情況

redis-cli -p 7001 cluster nodes

ab30ec479f19462d9985d20589cc571170b62e4d 127.0.0.1:7001@17001 myself,master - 0 1578122318377 1 connected 0-5460

其中返回值第一項表示節點id,由40個16進制字符串組成,Redis每次啟動runId都會重新創建,但是節點id只在集群初始化時創建一次,然后保存到集群配置文件中,以后節點重新啟動時會直接在集群配置文件中讀取。

其他節點使用相同辦法啟動,不再贅述。需要特別注意,在啟動節點階段,節點是沒有主從關系的,因此從節點不需要加replicaof配置。

2. 創建集群

  • 創建不含slaver 的集群:
redis-cli --cluster create 172.16.71.183:7001 172.16.71.184:7002 172.16.71.185:7003 --cluster-replicas 0

>>> Performing hash slots allocation on 3 nodes...
Master[0] -> Slots 0 - 5460
Master[1] -> Slots 5461 - 10922
Master[2] -> Slots 10923 - 16383
M: 736cd7825917ef90ef76869e7f769586e3a49ca2 127.0.0.1:7001
   slots:[0-5460] (5461 slots) master
M: 70239ba26e56ceaa537c1040a99f7f3fc9e744f1 127.0.0.1:7002
   slots:[5461-10922] (5462 slots) master
M: bbfb8a71c9c43fcf28c76c785f0b11ce69f55e72 127.0.0.1:7003
   slots:[10923-16383] (5461 slots) master
Can I set the above configuration? (type 'yes' to accept): yes
>>> Nodes configuration updated
>>> Assign a different config epoch to each node
>>> Sending CLUSTER MEET messages to join the cluster
Waiting for the cluster to join
..
>>> Performing Cluster Check (using node 127.0.0.1:7001)
M: 736cd7825917ef90ef76869e7f769586e3a49ca2 127.0.0.1:7001
   slots:[0-5460] (5461 slots) master
M: 70239ba26e56ceaa537c1040a99f7f3fc9e744f1 127.0.0.1:7002
   slots:[5461-10922] (5462 slots) master
M: bbfb8a71c9c43fcf28c76c785f0b11ce69f55e72 127.0.0.1:7003
   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.

3. 指定主從關系

  • 使用創建時的cluster-master-id 或執行 redis-cli --cluster check 172.16.71.183:7001 查看運行狀態
redis-cli --cluster check 127.0.0.1:7001
127.0.0.1:7001 (736cd782...) -> 0 keys | 5461 slots | 0 slaves.
127.0.0.1:7002 (70239ba2...) -> 0 keys | 5462 slots | 0 slaves.
127.0.0.1:7003 (bbfb8a71...) -> 0 keys | 5461 slots | 0 slaves.
[OK] 0 keys in 3 masters.
0.00 keys per slot on average.
>>> Performing Cluster Check (using node 127.0.0.1:7001)
M: 736cd7825917ef90ef76869e7f769586e3a49ca2 127.0.0.1:7001
   slots:[0-5460] (5461 slots) master
M: 70239ba26e56ceaa537c1040a99f7f3fc9e744f1 127.0.0.1:7002
   slots:[5461-10922] (5462 slots) master
M: bbfb8a71c9c43fcf28c76c785f0b11ce69f55e72 127.0.0.1:7003
   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.
  • 掛載slaver
redis-cli --cluster add-node 127.0.0.1:7004  127.0.0.1:7001 --cluster-slave --cluster-master-id 736cd7825917ef90ef76869e7f769586e3a49ca2

redis-cli --cluster add-node 127.0.0.1:7005  127.0.0.1:7001 --cluster-slave --cluster-master-id 70239ba26e56ceaa537c1040a99f7f3fc9e744f1

redis-cli --cluster add-node 127.0.0.1:7006  127.0.0.1:7001 --cluster-slave --cluster-master-id bbfb8a71c9c43fcf28c76c785f0b11ce69f55e72
  • 連接集群
redis-cli -c -h 127.0.0.1 -p 7001  -a CSFW  # -a 密碼
CLUSTER NODES 查看集群節點狀態

在Redis集群中,借助槽實現數據分區,具體原理后文會介紹。集群有16384個槽,槽是數據管理和遷移的基本單位。當數據庫中的16384個槽都分配了節點時,集群處于上線狀態(ok);如果有任意一個槽沒有分配節點,則集群處于下線狀態(fail)cluster info命令可以查看集群狀態,分配槽之前狀態為fail,所有槽分配完畢,集群進入上線狀態ok。

至此,集群搭建完畢。

集群方案設計

設計集群方案時,至少要考慮以下因素:

  • 高可用要求:根據故障轉移的原理,至少需要3個主節點才能完成故障轉移,且3個主節點不應在同一臺物理機上;每個主節點至少需要1個從節點,且主從節點不應在一臺物理機上;因此高可用集群至少包含6個節點。

  • 數據量和訪問量:估算應用需要的數據量和總訪問量(考慮業務發展,留有冗余),結合每個主節點的容量和能承受的訪問量(可以通過benchmark得到較準確估計),計算需要的主節點數量。

  • 節點數量限制:Redis官方給出的節點數量限制為1000,主要是考慮節點間通信帶來的消耗。在實際應用中應盡量避免大集群;如果節點數量不足以滿足應用對Redis數據量和訪問量的要求,可以考慮:

    1. 業務分割,大集群分為多個小集群;
    2. 減少不必要的數據;
    3. 調整數據過期策略等。
  • 適度冗余:Redis可以在不影響集群服務的情況下增加節點,因此節點數量適當冗余即可,不用太大。

三、集群的基本原理

集群最核心的功能是數據分區,因此首先介紹數據的分區規則;然后介紹集群實現的細節:通信機制和數據結構;最后以cluster meet(節點握手)、cluster addslots(槽分配)為例,說明節點是如何利用上述數據結構和通信機制實現集群命令的。

1. 數據分區方案

數據分區有順序分區、哈希分區等,其中哈希分區由于其天然的隨機性,使用廣泛;集群的分區方案便是哈希分區的一種。

哈希分區
哈希分區的基本思路是:對數據的特征值(如key)進行哈希,然后根據哈希值決定數據落在哪個節點。常見的哈希分區包括:哈希取余分區、一致性哈希分區、帶虛擬節點的一致性哈希分區等。

衡量數據分區方法好壞的標準有很多,其中比較重要的兩個因素是
(1) 數據分布是否均勻
(2) 增加或刪減節點對數據分布的影響。由于哈希的隨機性,哈希分區基本可以保證數據分布均勻;因此在比較哈希分區方案時,重點要看增減節點對數據分布的影響。

  • 哈希取余分區
    哈希取余分區思路非常簡單:計算key的hash值,然后對節點數量進行取余,從而決定數據映射到哪個節點上。該方案最大的問題是,當新增或刪減節點時,節點數量發生變化,系統中所有的數據都需要重新計算映射關系,引發大規模數據遷移。

  • 一致性哈希分區
    一致性哈希算法將整個哈希值空間組織成一個虛擬的圓環,如下圖所示,范圍為0-2^32-1;對于每個數據,根據key計算hash值,確定數據在環上的位置,然后從此位置沿環順時針行走,找到的第一臺服務器就是其應該映射到的服務器。


    hash-partition.png

與哈希取余分區相比,一致性哈希分區將增減節點的影響限制在相鄰節點。以上圖為例,如果在node1和node2之間增加node5,則只有node2中的一部分數據會遷移到node5;如果去掉node2,則原node2中的數據只會遷移到node4中,只有node4會受影響。

一致性哈希分區的主要問題在于,當節點數量較少時,增加或刪減節點,對單個節點的影響可能很大,造成數據的嚴重不平衡。還是以上圖為例,如果去掉node2,node4中的數據由總數據的1/4左右變為1/2左右,與其他節點相比負載過高。

  • 帶虛擬節點的一致性哈希分區
    該方案在一致性哈希分區的基礎上,引入了虛擬節點的概念。Redis集群使用的便是該方案,其中的虛擬節點稱為槽(slot)。槽是介于數據和實際節點之間的虛擬概念;每個實際節點包含一定數量的槽,每個槽包含哈希值在一定范圍內的數據。引入槽以后,數據的映射關系由數據hash->實際節點,變成了數據hash->槽->實際節點。
    在使用了槽的一致性哈希分區中,槽是數據管理和遷移的基本單位。槽解耦了數據和實際節點之間的關系,增加或刪除節點對系統的影響很小。仍以上圖為例,系統中有4個實際節點,假設為其分配16個槽(0-15); 槽0-3位于node1,4-7位于node2,以此類推。如果此時刪除node2,只需要將槽4-7重新分配即可,例如槽4-5分配給node1,槽6分配給node3,槽7分配給node4;可以看出刪除node2后,數據在其他節點的分布仍然較為均衡。
    槽的數量一般遠小于2^32,遠大于實際節點的數量;在Redis集群中,槽的數量為16384。
    下面這張圖很好的總結了Redis集群將數據映射到實際節點的過程:


    data-mapping.png

(1)Redis對數據的特征值(一般是key)計算哈希值,使用的算法是CRC16。

(2)根據哈希值,計算數據屬于哪個槽。

(3)根據槽與節點的映射關系,計算數據屬于哪個節點。

2. 節點通信機制

集群要作為一個整體工作,離不開節點之間的通信。

兩個端口
在哨兵系統中,節點分為數據節點和哨兵節點:前者存儲數據,后者實現額外的控制功能。在集群中,沒有數據節點與非數據節點之分:所有的節點都存儲數據,也都參與集群狀態的維護。為此,集群中的每個節點,都提供了兩個TCP端口:

  • 普通端口:即我們在前面指定的端口(7000等)。普通端口主要用于為客戶端提供服務(與單機節點類似);但在節點間數據遷移時也會使用。

  • 集群端口:端口號是普通端口+10000(10000是固定值,無法改變),如7000節點的集群端口為17000。集群端口只用于節點之間的通信,如搭建集群、增減節點、故障轉移等操作時節點間的通信;不要使用客戶端連接集群接口。為了保證集群可以正常工作,在配置防火墻時,要同時開啟普通端口和集群端口。

Gossip協議
節點間通信,按照通信協議可以分為幾種類型:單對單、廣播、Gossip協議等。重點是廣播和Gossip的對比。

廣播是指向集群內所有節點發送消息;優點是集群的收斂速度快(集群收斂是指集群內所有節點獲得的集群信息是一致的),缺點是每條消息都要發送給所有節點,CPU、帶寬等消耗較大。

Gossip協議的特點是:在節點數量有限的網絡中,每個節點都“隨機”的與部分節點通信(并不是真正的隨機,而是根據特定的規則選擇通信的節點),經過一番雜亂無章的通信,每個節點的狀態很快會達到一致。Gossip協議的優點有負載(比廣播)低、去中心化、容錯性高(因為通信有冗余)等;缺點主要是集群的收斂速度慢。

消息類型
集群中的節點采用固定頻率(每秒10次)的定時任務進行通信相關的工作:判斷是否需要發送消息及消息類型、確定接收節點、發送消息等。如果集群狀態發生了變化,如增減節點、槽狀態變更,通過節點間的通信,所有節點會很快得知整個集群的狀態,使集群收斂。

節點間發送的消息主要分為5種:meet消息、ping消息、pong消息、fail消息、publish消息。不同的消息類型,通信協議、發送的頻率和時機、接收節點的選擇等是不同的。

  • MEET消息:在節點握手階段,當節點收到客戶端的CLUSTER MEET命令時,會向新加入的節點發送MEET消息,請求新節點加入到當前集群;新節點收到MEET消息后會回復一個PONG消息。

  • PING消息:集群里每個節點每秒鐘會選擇部分節點發送PING消息,接收者收到消息后會回復一個PONG消息。PING消息的內容是自身節點和部分其他節點的狀態信息;作用是彼此交換信息,以及檢測節點是否在線。PING消息使用Gossip協議發送,接收節點的選擇兼顧了收斂速度和帶寬成本,具體規則如下:(1)隨機找5個節點,在其中選擇最久沒有通信的1個節點(2)掃描節點列表,選擇最近一次收到PONG消息時間大于cluster_node_timeout/2的所有節點,防止這些節點長時間未更新。

  • PONG消息:PONG消息封裝了自身狀態數據。可以分為兩種:第一種是在接到MEET/PING消息后回復的PONG消息;第二種是指節點向集群廣播PONG消息,這樣其他節點可以獲知該節點的最新信息,例如故障恢復后新的主節點會廣播PONG消息。

  • FAIL消息:當一個主節點判斷另一個主節點進入FAIL狀態時,會向集群廣播這一FAIL消息;接收節點會將這一FAIL消息保存起來,便于后續的判斷。

  • PUBLISH消息:節點收到PUBLISH命令后,會先執行該命令,然后向集群廣播這一消息,接收節點也會執行該PUBLISH命令。

3. 數據結構

節點需要專門的數據結構來存儲集群的狀態。所謂集群的狀態,是一個比較大的概念,包括:集群是否處于上線狀態、集群中有哪些節點、節點是否可達、節點的主從狀態、槽的分布……

節點為了存儲集群狀態而提供的數據結構中,最關鍵的是clusterNode和clusterState結構:前者記錄了一個節點的狀態,后者記錄了集群作為一個整體的狀態。

clusterNode
clusterNode結構保存了一個節點的當前狀態,包括創建時間、節點id、ip和端口號等。每個節點都會用一個clusterNode結構記錄自己的狀態,并為集群內所有其他節點都創建一個clusterNode結構來記錄節點狀態。

下面列舉了clusterNode的部分字段,并說明了字段的含義和作用:

typedef struct clusterNode {
    //節點創建時間
    mstime_t ctime;
 
    //節點id
    char name[REDIS_CLUSTER_NAMELEN];
 
    //節點的ip和端口號
    char ip[REDIS_IP_STR_LEN];
    int port;
 
    //節點標識:整型,每個bit都代表了不同狀態,如節點的主從狀態、是否在線、是否在握手等
    int flags;
 
    //配置紀元:故障轉移時起作用,類似于哨兵的配置紀元
    uint64_t configEpoch;
 
    //槽在該節點中的分布:占用16384/8個字節,16384個比特;每個比特對應一個槽:比特值為1,則該比特對應的槽在節點中;比特值為0,則該比特對應的槽不在節點中
    unsigned char slots[16384/8];
 
    //節點中槽的數量
    int numslots;
 
    …………
 
} clusterNode;

除了上述字段,clusterNode還包含節點連接、主從復制、故障發現和轉移需要的信息等。

clusterState
clusterState結構保存了在當前節點視角下,集群所處的狀態。主要字段包括:

typedef struct clusterState {
 
    //自身節點
    clusterNode *myself;
 
    //配置紀元
    uint64_t currentEpoch;
 
    //集群狀態:在線還是下線
    int state;
 
    //集群中至少包含一個槽的節點數量
    int size;
 
    //哈希表,節點名稱->clusterNode節點指針
    dict *nodes;
  
    //槽分布信息:數組的每個元素都是一個指向clusterNode結構的指針;如果槽還沒有分配給任何節點,則為NULL
    clusterNode *slots[16384];
 
    …………
     
} clusterState;

除此之外,clusterState還包括故障轉移、槽遷移等需要的信息。

4. 集群命令的實現

這一部分將以cluster meet(節點握手)、cluster addslots(槽分配)為例,說明節點是如何利用上述數據結構和通信機制實現集群命令的。

cluster meet
假設要向A節點發送cluster meet命令,將B節點加入到A所在的集群,則A節點收到命令后,執行的操作如下:

  1. A為B創建一個clusterNode結構,并將其添加到clusterState的nodes字典中

  2. A向B發送MEET消息

  3. B收到MEET消息后,會為A創建一個clusterNode結構,并將其添加到clusterState的nodes字典中

  4. B回復A一個PONG消息

  5. A收到B的PONG消息后,便知道B已經成功接收自己的MEET消息

  6. 然后,A向B返回一個PING消息

  7. B收到A的PING消息后,便知道A已經成功接收自己的PONG消息,握手完成

  8. 之后,A通過Gossip協議將B的信息廣播給集群內其他節點,其他節點也會與B握手;一段時間后,集群收斂,B成為集群內的一個普通節點

通過上述過程可以發現,集群中兩個節點的握手過程與TCP類似,都是三次握手:A向B發送MEET;B向A發送PONG;A向B發送PING。

cluster addslots
集群中槽的分配信息,存儲在clusterNode的slots數組和clusterState的slots數組中,兩個數組的結構前面已做介紹;二者的區別在于:前者存儲的是該節點中分配了哪些槽,后者存儲的是集群中所有槽分別分布在哪個節點。

cluster addslots命令接收一個槽或多個槽作為參數,例如在A節點上執行cluster addslots {0..10}命令,是將編號為0-10的槽分配給A節點,具體執行過程如下:

  1. 遍歷輸入槽,檢查它們是否都沒有分配,如果有一個槽已分配,命令執行失敗;方法是檢查輸入槽在clusterState.slots[]中對應的值是否為NULL。

  2. 遍歷輸入槽,將其分配給節點A;方法是修改clusterNode.slots[]中對應的比特為1,以及clusterState.slots[]中對應的指針指向A節點

  3. A節點執行完成后,通過節點通信機制通知其他節點,所有節點都會知道0-10的槽分配給了A節點

四、客戶端訪問集群

在集群中,數據分布在不同的節點中,客戶端通過某節點訪問數據時,數據可能不在該節點中;下面介紹集群是如何處理這個問題的。

1. redis-cli

當節點收到redis-cli發來的命令(如set/get)時,過程如下:

(1)計算key屬于哪個槽:CRC16(key) & 16383
集群提供的cluster keyslot命令也是使用上述公式實現,如:


key-slots.png

(2)判斷key所在的槽是否在當前節點:假設key位于第i個槽,clusterState.slots[i]則指向了槽所在的節點,如果clusterState.slots[i]==clusterState.myself,說明槽在當前節點,可以直接在當前節點執行命令;否則,說明槽不在當前節點,則查詢槽所在節點的地址(clusterState.slots[i].ip/port),并將其包裝到MOVED錯誤中返回給redis-cli。

(3)redis-cli收到MOVED錯誤后,根據返回的ip和port重新發送請求。

下面的例子展示了redis-cli和集群的互動過程:在7000節點中操作key1,但key1所在的槽9189在節點7001中,因此節點返回MOVED錯誤(包含7001節點的ip和port)給redis-cli,redis-cli重新向7001發起請求。


redirect.png

上例中,redis-cli通過-c指定了集群模式,如果沒有指定,redis-cli無法處理MOVED錯誤:


move-error.png

2. Smart客戶端

redis-cli這一類客戶端稱為Dummy客戶端,因為它們在執行命令前不知道數據在哪個節點,需要借助MOVED錯誤重新定向。與Dummy客戶端相對應的是Smart客戶端。

Smart客戶端(以Java的JedisCluster為例)的基本原理:
(1)JedisCluster初始化時,在內部維護slot->node的緩存,方法是連接任一節點,執行cluster slots命令,該命令返回如下所示:


cluster-slots.png

(2)此外,JedisCluster為每個節點創建連接池(即JedisPool)。

(3)當執行命令時,JedisCluster根據key->slot->node選擇需要連接的節點,發送命令。如果成功,則命令執行完畢。如果執行失敗,則會隨機選擇其他節點進行重試,并在出現MOVED錯誤時,使用cluster slots重新同步slot->node的映射關系。

下面代碼演示了如何使用JedisCluster訪問集群(未考慮資源釋放、異常處理等):

public static void test() {
   Set<HostAndPort> nodes = new HashSet<>();
   nodes.add(new HostAndPort("192.168.72.128", 7000));
   nodes.add(new HostAndPort("192.168.72.128", 7001));
   nodes.add(new HostAndPort("192.168.72.128", 7002));
   nodes.add(new HostAndPort("192.168.72.128", 8000));
   nodes.add(new HostAndPort("192.168.72.128", 8001));
   nodes.add(new HostAndPort("192.168.72.128", 8002));
   JedisCluster cluster = new JedisCluster(nodes);
   System.out.println(cluster.get("key1"));
   cluster.close();
}

注意事項如下:

  • JedisCluster中已經包含所有節點的連接池,因此JedisCluster要使用單例。

  • 客戶端維護了slot->node映射關系以及為每個節點創建了連接池,當節點數量較多時,應注意客戶端內存資源和連接資源的消耗。

  • Jedis較新版本針對JedisCluster做了一些性能方面的優化,如cluster slots緩存更新和鎖阻塞等方面的優化,應盡量使用2.8.2及以上版本的Jedis。

參考

https://www.cnblogs.com/kismetv/p/9853040.html

上一篇 Redis-Sentinel(五)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容