《MyCat分庫分表策略詳解》

《MyCat分庫分表策略詳解》

 

在我們的項目發展到一定階段之後,隨着數據量的增大,分庫分表就變成了一件非常自然的事情。常見的分庫分表方式有兩種:客戶端模式和服務器模式,這兩種的典型代表有sharding-jdbc和MyCat。所謂的客戶端模式是指在各個連接數據庫的客戶端中引用額外提供的jar包,以對連接數據庫的過程進行封裝,從而達到根據客戶端的配置,將不同的請求分發到不同的數據庫中的目的;而服務端模式是指,搭建一個數據庫服務,這個服務只是架設在真實數據庫集羣前的一個代理層,其能夠正常接收和解析客戶端傳入的SQL語句,然後根據其配置,將該SQL語句解析之後發送到各個真實的服務器執行,最終由代理層收集執行的結果並將該結果返回。服務器模式下,客戶端所連接的服務完全就像是一個數據庫服務,這種方式對於客戶端的侵入性是非常小的。

作爲服務端模式的典型代表,MyCat不僅提供了豐富的分庫分表策略,也提供了非常靈活的讀寫分離策略,並且其對客戶端的侵入性是非常小的。本文主要講解MyCat主要提供的分庫分表策略,並且還會講解MyCat如果自定義分庫分表策略。

1. 配置格式介紹

在講解MyCat分庫分表策略之前,我們首先介紹一下其配置文件的格式。在MyCat中,配置文件主要有兩個:schema.xml和rule.xml。顧名思義,這兩個配置文件分別指定了MyCat所代理的數據庫集羣的配置和分庫分表的相關策略。schema.xml中的典型配置如下:

<?xml version="1.0"?>
<!DOCTYPE mycat:schema SYSTEM "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
 <!-- 指定了對外所展示的數據庫名稱,也就是說,客戶端連接MyCat數據庫時,制定的database爲mydb
 而當前數據庫中的表的配置就是根據下面的配置而來的 -->
 <schema name="mydb" checkSQLschema="true" sqlMaxLimit="100">
 <!-- 定義了一個t_goods表,該表的主鍵是id,該字段是自增長的,並且該表的數據會被分配到dn1,dn2和
 dn3上,這三個指的是當前MyCat數據庫所代理的真實數據庫的節點名,每個節點的具體配置在下面的
 配置中。這裏rule屬性指定了t_goods表中的數據分配到dn1,dn2和dn3上的策略,mod-long指的是
 按照長整型取餘的方式分配,也就是按照id對節點數目進行取餘 -->
 <table name="t_goods" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" 
 rule="mod-long"/>
 </schema>
 <!-- 分別指定了dn1,dn2和dn3三個節點與對應的數據庫的關係,dataHost對應的就是下面的數據庫節點配置 -->
 <dataNode name="dn1" dataHost="dhost1" database="db1"/>
 <dataNode name="dn2" dataHost="dhost2" database="db2"/>
 <dataNode name="dn3" dataHost="dhost3" database="db3"/>
 <!-- 這裏分別指定了各個數據庫節點的配置 -->
 <dataHost name="dhost1" maxCon="1000" minCon="10" balance="0" writeType="0" 
 dbType="mysql" dbDriver="native">
 <heartbeat>select user()</heartbeat>
 <writeHost host="hostM1" url="localhost:3306" user="root" password="password"/>
 </dataHost>
 <dataHost name="dhost2" maxCon="1000" minCon="10" balance="0" writeType="0" 
 dbType="mysql" dbDriver="native">
 <heartbeat>select user()</heartbeat>
 <writeHost host="hostM2" url="localhost:3306" user="root" password="password"/>
 </dataHost>
 <dataHost name="dhost3" maxCon="1000" minCon="10" balance="0" writeType="0" 
 dbType="mysql" dbDriver="native">
 <heartbeat>select user()</heartbeat>
 <writeHost host="hostM3" url="localhost:3306" user="root" password="password"/>
 </dataHost>
</mycat:schema>

可以看到,schema.xml指定的是各個數據庫節點與MyCat中虛擬數據庫和表的關聯關係,並且指定了當前表的分表策略,比如這裏的mod-long。在rule.xml中則指定了具體的分表策略及其所使用的算法實現類,如下是一個典型的rule.xml的配置:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mycat:rule SYSTEM "rule.dtd">
<mycat:rule xmlns:mycat="http://io.mycat/">
 <!-- 這裏的mod-long對應的就是上面schema.xml的表配置中rule屬性所使用的規則名稱,其columns節點
 指定了當前規則所對應的字段名,也就是id,algorithm節點則指定了當前規則所使用的算法,具體的
 算法對應於下面的function節點所指定的實現類-->
	<tableRule name="mod-long">
		<rule>
			<columns>id</columns>
			<algorithm>mod-long</algorithm>
		</rule>
	</tableRule>
 
 <!-- 這裏指定的是mod-long這個算法所使用的具體實現類,實現類需要使用全限定路徑,具體的代碼讀者朋友
 可以閱讀MyCat源碼,並且讀者也可以查看MyCat默認爲我們提供了哪些分表策略實現 -->
	<function name="mod-long" class="io.mycat.route.function.PartitionByMod">
		<!-- 指定了當前所使用的數據庫節點數 -->
		<property name="count">3</property>
	</function>
</mycat:rule>

結合schema.xml和rule.xml兩個配置文件的配置,我們可以看出,MyCat首先通過schema.xml指定了當前服務器中所虛擬的數據庫,以及該數據庫中所對應的表的配置,比如這裏的mydb和t_goods,實際上,我們在通過數據庫連接工具連接到MyCat數據庫時,看到的表定義都是通過該配置文件得來的,其本身並沒有通過讀取真實的數據庫節點來獲得這些配置。在指定了虛擬數據庫和虛擬表之後,在schema.xml中,通過表級別的配置,又分別指定了當前表所關聯的數據節點配置,以及該表是如何進行分庫分表的。而具體的分庫分表實現類則在rule.xml中進行了配置。另外,通過上面的配置,我們也可以看出,MyCat是不支持通過客戶端連接工具來創建表的,其所有的額表必須提前在配置文件中進行定義。

2. 分庫分表策略

1. 取餘

關於取餘的策略,這種方式上面已經進行了詳細的介紹,主要的策略就是根據指定的字段對數據庫節點數進行取餘,從而將其插入到對應的數據庫中,這裏不再贅述。

2. 按照範圍分片

按照範圍分片,顧名思義,就是首先對整體數據進行範圍劃分,然後將各個範圍區間分配到對應的數據庫節點上,當用戶插入數據時,根據指定字段的值,判斷其屬於哪個範圍,然後將數據插入到該範圍對應的數據庫節點上。需要注意的是,這裏會配置一個默認的範圍,當用戶插入的數據不再任何指定的範圍內時,該數據將會被插入到默認節點上。如下是按範圍分片的配置:

<!-- schema.xml -->
<table name="t_company" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="range-sharding-by-members-count"/>
<!-- rule.xml -->
<tableRule name="range-sharding-by-members-count">
 <rule>
 <!-- 指定了分片字段 -->
 <columns>members</columns>
 <algorithm>range-members-count</algorithm>
 </rule>
</tableRule>
<function name="range-members-count" class="io.mycat.route.function.AutoPartitionByLong">
 <!-- 指定了範圍分片的”範圍-節點“的對應策略 -->
 <property name="mapFile">files/company-range-partition.txt</property>
 <!-- 指定了超出範圍的數據將會被分配的數據節點 -->
 <property name="defaultNode">0</property>
</function>
<!-- 上面mapFile屬性指定的company-range-partition.txt文件內容,這裏指定了具體的範圍與數據節點的對應關係 
 -->
0-10=0
11-50=1
51-100=2
101-1000=0
1001-9999=1
10000-9999999=2

3. 按照日期進行分片

按照日期分片,這種方式相對來說理解稍微複雜一點,我們這裏直接展示一個配置示例:

<!-- schema.xml -->
<table name="t_order" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="order-sharding-by-date"/>
<!-- rule.xml -->
<tableRule name="order-sharding-by-date">
 <rule>
 <!-- 指定分區字段爲order_time -->
 <columns>order_time</columns>
 <!-- 指定分區算法爲sharding-by-date -->
 <algorithm>sharding-by-date</algorithm>
 </rule>
</tableRule>
<!-- 指定分區算法使用的實現類是io.mycat.route.function.PartitionByDate,這裏需要傳如四個屬性:
 dateFormat表示下面sBeginDate、sEndDate以及分區字段的數據值所使用的日期格式化方式;
 sBeginDate指定了分區範圍的開始時間;
 sEndDate指定了分區範圍的結束時間;
 sPartitionDay指定了每個分區間隔的時間範圍長度-->
<function name="sharding-by-date" class="io.mycat.route.function.PartitionByDate">
 <property name="dateFormat">yyyy-MM-dd</property>
 <property name="sBeginDate">2019-01-01</property>
 <property name="sEndDate">2019-02-02</property>
 <property name="sPartionDay">20</property>
</function>

上面的配置中,比較好理解的是sBeginDate和sEndDate,這兩個參數指定了所有分區將會劃分的總的分區時間段範圍;而sPartitionDay則指定了每個分區所佔用的時間段範圍,比如這裏sPartitionDay爲20,sBeginDate爲2019-01-01,sEndDate爲2019-02-02,也就是說根據20天一段來分,整個時間段將會被分爲2019-01-01~2019-01-21和2019-01-21~2019-02-02。關於這裏劃分的分區數,這裏需要說明兩點:

  • 這裏我們將整個時間段切割之後,只能得到兩個分區,但我們的數據庫節點配置了三個,此時,只有第一個節點和第二個節點將會被使用,第三個將始終不會用到;
  • 如果我們提供的數據庫節點數比切割的分區數要小的話,那麼就會有一部分分區沒有與其指定的數據庫節點,此時就會拋出異常;

另外,我們需要着重強調的一點是,在正常使用的過程中,如果配置了結束時間,那麼始終會有一天我們的時間會超出結束時間,但如果我們將結束時間配置得非常大,那麼就會出現一個問題就是所需要的分區數會比我們的數據庫節點數要多,此時就會拋出異常。在這一點上,MyCat允許我們的分區字段時間比結束時間要大,也就是說,插入的字段值可以是2019-02-02以後的日期。此時目標日期所在的分區計算方式如下:

int targetPartition = ((endTimeMills - sBeginDateMills) / (partitionDurationMills)) % nPartitons;
  • endTimeMills表示目標要計算的時間戳;
  • sBeginDateMills表示配置的開始時間戳;
  • partitionDurationMills表示每個分區時間段的時間戳時長,比如這裏就是20 * 24 * 60 * 60 * 1000;
  • nPartitons表示當前在sBeginDate和sEndDate之間劃分的分區數量,根據前面我們的演示知道其爲2;

上面的公式,從整體上來理解,其實比較簡單,本質上就是將目標時間與開始時間之間的差值除以分區長度,從而計算得出目標時間與開始時間之間的分區數,然後將該分區數與當前劃分的分區數進行取模,從而得出其所在的分區。下面我們以四條數據爲例,講解其將會落入的目標數據節點:

insert into t_order(`id`, `order_time`) values (1, '2019-01-05'); # 分區0,db1
insert into t_order(`id`, `order_time`) values (1, '2019-01-25'); # 分區1,db2
insert into t_order(`id`, `order_time`) values (1, '2019-02-05'); # 分區1,db2
insert into t_order(`id`, `order_time`) values (1, '2019-02-15'); # 分區2,db1

在上述配置中,我們是配置了分區的結束時間的,實際上,在這種分區策略下,我們也可以不配置結束時間,如果不配置結束時間,那麼需要注意的一點是,目標時間所在的分區計算公式如下:

int targetPartition = (endTimeMills - sBeginDateMills) / (partitionDurationMills);

相信讀者朋友已經看出來了,這就是計算目標時間與開始時間中間間隔了多少個分區,然後將該值作爲目標分區,也就是數據會落到目標數據庫節點上,此時,隨着時間的持續增長,如果數據庫節點的數目比當前計算得到的分區數要小,那麼就會拋出異常。

4. 按照月份進行分片

按照月份進行分片,顧名思義,就是以月爲單位,判斷目標時間在哪個月內,然後就將數據分配到這個月對應的數據節點上。如下是按照月份進行分片的配置示例:

<!-- schema.xml -->
<table name="t_bank" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3,dn4,dn5,dn6,dn7,dn8,dn9,dn10,dn11,dn12" rule="sharding-by-month"/>
<!-- rule.xml -->
<tableRule name="sharding-by-month">
 <rule>
 <columns>create_time</columns>
 <algorithm>partbymonth</algorithm>
 </rule>
</tableRule>
<function name="partbymonth" class="io.mycat.route.function.PartitionByMonth">
 <property name="dateFormat">yyyy-MM-dd</property>
</function>

根據上面的配置,我們需要說明如下幾點:

  • 如果沒有配置開始時間和結束時間,那麼數據庫的節點數必須大於等於12,因爲一年有12個月,數據將會根據其所在的月份分配到對應的數據節點上;
  • 如果只配置了開始時間(字段名爲sBeginDate),此時需要注意兩點:
  • 如果目標時間小於開始時間,則會拋出異常,因爲目標時間與開始時間之間的分區數值爲負數;
  • 如果目標時間大於開始時間,則會計算目標時間與開始時間之間的月份數,此時該月份數就會作爲目標數據節點(其並不會對12取模之後存放),因而如果計算得到的數值比我們的數據庫節點數要大,就會拋出異常;
  • 如果配置了開始時間,也配置了結束時間(字段名爲sEndDate),需要注意的是,此時並不會按照目標時間所在的月份而將其放到對應的數據庫節點上,而是首先會計算開始時間和結束時間之間相隔的月份數nPartitions,然後計算目標時間與開始時間之間的月份數targetPartitions,然後將兩者取模,即targetPartitions % nPartitions,從而得到其所在的數據庫節點。另外,需要注意的是,如果目標時間小於開始時間,那麼targetPartitions就是一個負數,此時其所在的分區計算公式就爲nPartitions - targetPartitions % nPartitions,也就是說,目標分區會循環的從最大的分區值往下倒數。

5. 按照枚舉值分片

按照枚舉值分片比較適合於某個字段只有固定的幾個值的情況,比如省份。通過配置文件將每個枚舉值對應的數據庫節點進行映射,這樣對於指定類型的數據,就會被分配到同一個數據庫實例中。如下是按照枚舉值分片的一個示例:

<!-- schema.xml -->
<table name="t_customer" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="sharding-by-province"/>
<tableRule name="sharding-by-province">
 <rule>
 <!-- 指定了分區字段 -->
 <columns>province</columns>
 <!-- 指定分區算法 -->
 <algorithm>sharding-by-province-func</algorithm>
 </rule>
</tableRule>
<!-- 按照枚舉值分片的算法,這裏mapFile中保存了分區字段中各個枚舉值與目標數據庫實例的對應關係;
 type字段表示了分區字段的key的類型,0表示數字,非0表示字符串;defaultNode指定了沒有配置映射
 關係的數據其存儲的數據庫節點 -->
<function name="sharding-by-province-func" 
 class="io.mycat.route.function.PartitionByFileMap">
 <property name="mapFile">files/sharding-by-province.txt</property>
 <property name="type">0</property>
 <property name="defaultNode">0</property>
</function>
<!-- sharding-by-province.txt -->
1001=0
1002=1
1003=2
1004=0

6. 範圍取模

範圍取模分片的優點在於,既擁有範圍分片的固定範圍數據不做遷移的優點,也擁有了取模分片對於熱點數據均勻分佈的優點。首先我們還是以一個示例進行講解:

<!-- schema.xml -->
<table name="t_car" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="auto-sharding-rang-mod"/>
<!-- rule.xml -->
<tableRule name="auto-sharding-rang-mod">
 <rule>
 <!-- 指定id字段爲範圍取模分片的字段 -->
 <columns>id</columns>
 <algorithm>rang-mod</algorithm>
 </rule>
</tableRule>
<!-- 這裏defaultNode指的是,如果目標字段的值不在範圍內,則將其放置到默認節點上;mapFile指定了
 範圍與分片數的一個對應關係 -->
<function name="rang-mod" class="io.mycat.route.function.PartitionByRangeMod">
 <property name="defaultNode">0</property>
 <property name="mapFile">files/partition-range-mod.txt</property>
</function>
<!-- partition-range-mod.txt -->
0-5=1
6-10=2
11-15=1

關於範圍取模分片,這裏需要着重說明一下其概念:

  • 在最後的partition-range-mod.txt文件中,我們可以看到,其每一行在等號前指定了一個不想交的範圍,這個範圍表示的就是目標分區字段的值將會落在哪個範圍內;
  • 等號後面有一個數字,需要注意的是,這個數字並不是指數據庫節點id,而是當前範圍將會佔用的數據庫節點數目,比如這裏的範圍0-5內的數據將會被分配到1個數據庫節點上,而範圍6-10內的數據將會被分配到2個數據庫節點上;
  • 等號後面指定了當前範圍所需要使用的分片數,而該範圍的數據在這幾個數據庫節點的分佈方式是通過取模的方式來實現的,也就是說,在大的方向上,整體數據被切分爲多個範圍,然後在每個範圍內,數據根據取模的方式分配到不同的數據節點上。

這也就是範圍取模分片的概念的由來,這種分片方式的優點在於,在進行擴容和數據遷移的時候,不相關的範圍內的數據是不需要移動的。比如假設我們0-5範圍內的數據非常多,1個數據庫實例無法承受,此時就可以增加一個數據庫實例,然後將配置改爲0-5=2,接着將之前該範圍內的數據庫的數據導出,然後由重新導入,以平均分配到這兩個數據庫節點上。可以看出,這種方式擴容,對於其餘兩個範圍內的數據庫實例是沒有影響的。最後,需要着重強調的一點是,既然等號後面表示所需要的數據庫實例數量,那麼等號後面的數字加起來的和一定要小於我們所提供的真實數據庫實例的數量。

7. 二進制取模範圍分片

二進制取模分片的方式與範圍取模非常相似,但也有不同,其分片方式主要是根據目標分片字段的低10位的值來判斷其屬於哪個分片。我們首先還是以一個示例進行講解:

<!-- schema.xml -->
<table name="t_bike" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="rule1"/>
<!-- rule.xml -->
<tableRule name="rule1">
 <rule>
 <!-- 指定了分片字段 -->
 <columns>id</columns>
 <algorithm>func1</algorithm>
 </rule>
</tableRule>
<!-- 這裏分片算法中需要傳如兩個參數:partitionCount和partitionLength。這兩個字段的含義分別爲分區數量
 和分區長度,但是需要注意的是,這裏的分區數量和分區長度相乘之後加起來必須爲1024,比如這裏的
 2 * 256 + 1 * 512 = 1024。至於爲什麼必須爲1024,這主要是因爲二進制取模分片是取目標分區字段的
 低10位數據作爲其所在的槽,由於低10位最大爲1024,因而這裏配置的加和必須爲1024。 -->
<function name="func1" class="io.mycat.route.function.PartitionByLong">
 <property name="partitionCount">2,1</property>
 <property name="partitionLength">256,512</property>
</function>

關於上面的分片方式的分片效果,其總共有1+2 = 3個分片,而每個分片中所分配的範圍分別爲0-255,256-511和512-1023。圖示如下:

|----------------------------------1024----------------------------------|
|-------256-------|-------256--------|-----------------512---------------|
|---partition0----|----partition1----|-------------partition2------------|

8. 一致性hash分片

一致性hash分片方式上面的二進制取模方式非常相似,不過一致性hash的虛擬槽的概念更強,並且一致性hash分片的虛擬槽的數量是可配置的。如下是一個典型的一致性hash分片的配置方式:

<!-- schema.xml -->
<table name="t_house" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="sharding-by-murmur"/>
<!-- rule.xml -->
<tableRule name="sharding-by-murmur">
 <rule>
 <columns>id</columns>
 <algorithm>murmur</algorithm>
 </rule>
</tableRule>
<!-- 下面的屬性中,count指定了要分片的數據庫節點數量,必須指定,否則沒法分片;virtualBucketTimes指的是
 一個實際的數據庫節點被映射爲這麼多虛擬節點,默認是160倍,也就是虛擬節點數是物理節點數的160倍;
 weightMapFile指定了節點的權重,沒有指定權重的節點默認是1。以properties文件的格式填寫,
 以從0開始到count-1的整數值也就是節點索引爲key,以節點權重值爲值。所有權重值必須是正整數,
 否則以1代替;bucketMapPath用於測試時觀察各物理節點與虛擬節點的分佈情況,如果指定了這個屬性,
 會把虛擬節點的murmur hash值與物理節點的映射按行輸出到這個文件,沒有默認值,如果不指定,
 就不會輸出任何東西-->
<function name="murmur" class="io.mycat.route.function.PartitionByMurmurHash">
		<property name="seed">0</property><!-- 默認是0 -->
		<property name="count">3</property>
		<property name="virtualBucketTimes">160</property><!-- -->
		<!-- <property name="weightMapFile">weightMapFile</property> -->
		<property name="bucketMapPath">
 /Users/zhangxufeng/xufeng.zhang/mycat/bucketMap.txt</property>
	</function>

9. 按照目標字段前綴指定的進行分區

按照目標字段前綴進行分片,這種方式就比較好理解,其會獲取到指定分區字段的前綴值,然後將其轉換爲十進制數字,將其作爲分區值,如果該數字超過了分區數量,則會將當前數據放在默認分區。如下是按照字符串前綴方式分區的配置示例:

<!-- schema.xml -->
<table name="t_community" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="sharding-by-substring"/>
<!-- rule.xml -->
<tableRule name="sharding-by-substring">
 <rule>
 <!-- 指定按照id字段進行分區 -->
 <columns>id</columns>
 <algorithm>sharding-by-substring</algorithm>
 </rule>
</tableRule>
<function name="sharding-by-substring" 
 class="io.mycat.route.function.PartitionDirectBySubString">
 <!-- 指定前綴的開始位置 -->
 <property name="startIndex">0</property>
 <!-- 指定前綴的大小 -->
 <property name="size">2</property>
 <!-- 指定了分區數量 -->
 <property name="partitionCount">3</property>
 <!-- 指定了默認分區 -->
 <property name="defaultPartition">0</property>
</function>

10. 按照前綴ASCII碼和值進行取模範圍分片

按照前綴ASCII碼和值進行取模,顧名思義,就是取了前綴之後,將其轉換爲ASCII碼值,然後對取模基數進行取模,最後將求得的餘數按照配置文件中的範圍分配到對應的數據庫節點上。如下是該分區方式的配置示例:

<!-- schema.xml -->
<table name="t_phone" primaryKey="id" autoIncrement="true" dataNode="dn1,dn2,dn3" rule="sharding-by-prefixpattern"/>
<!-- rule.xml -->
<tableRule name="sharding-by-prefixpattern">
 <rule>
 <!-- 指定id爲分區字段 -->
 <columns>id</columns>
 <algorithm>sharding-by-prefixpattern</algorithm>
 </rule>
</tableRule>
<!-- 這裏的patternValue指定的是取模基數;prefixLength表示對指定字段的前多少位進行截取計算ASCII碼;
 mapFile中指定了取模的餘數範圍與目標數據庫節點的對應關係 -->
<function name="sharding-by-prefixpattern" 
 class="io.mycat.route.function.PartitionByPrefixPattern">
 <property name="patternValue">256</property>
 <property name="prefixLength">5</property>
 <property name="mapFile">files/partition-pattern.txt</property>
</function>
<!-- partition-pattern.txt -->
0-100=0
101-200=1
201-256=2

這裏的範圍對應關係需要注意的是,其需要將我們設置的取模基數patternValue的整個範圍都進行覆蓋,否則對於沒有覆蓋的數據將會報錯。

完結:

本文首先對MyCat進行了簡要介紹,並且講解了其配置文件的配置方式。然後着重介紹了MyCatt提供的十種分區策略。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章