MyCat分库分表策略详解


声明:本文转载自https://my.oschina.net/zhangxufeng/blog/3097533,转载目的在于传递更多信息,仅供学习交流之用。如有侵权行为,请联系我,我会及时删除。

        在我们的项目发展到一定阶段之后,随着数据量的增大,分库分表就变成了一件非常自然的事情。常见的分库分表方式有两种:客户端模式和服务器模式,这两种的典型代表有sharding-jdbcMyCat。所谓的客户端模式是指在各个连接数据库的客户端中引用额外提供的jar包,以对连接数据库的过程进行封装,从而达到根据客户端的配置,将不同的请求分发到不同的数据库中的目的;而服务端模式是指,搭建一个数据库服务,这个服务只是架设在真实数据库集群前的一个代理层,其能够正常接收和解析客户端传入的SQL语句,然后根据其配置,将该SQL语句解析之后发送到各个真实的服务器执行,最终由代理层收集执行的结果并将该结果返回。服务器模式下,客户端所连接的服务完全就像是一个数据库服务,这种方式对于客户端的侵入性是非常小的。

        作为服务端模式的典型代表,MyCat不仅提供了丰富的分库分表策略,也提供了非常灵活的读写分离策略,并且其对客户端的侵入性是非常小的。本文主要讲解MyCat主要提供的分库分表策略,并且还会讲解MyCat如果自定义分库分表策略。

1. 配置格式介绍

        在讲解MyCat分库分表策略之前,我们首先介绍一下其配置文件的格式。在MyCat中,配置文件主要有两个:schema.xmlrule.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.xmlrule.xml两个配置文件的配置,我们可以看出,MyCat首先通过schema.xml指定了当前服务器中所虚拟的数据库,以及该数据库中所对应的表的配置,比如这里的mydbt_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>

        上面的配置中,比较好理解的是sBeginDatesEndDate,这两个参数指定了所有分区将会划分的总的分区时间段范围;而sPartitionDay则指定了每个分区所占用的时间段范围,比如这里sPartitionDay为20,sBeginDate2019-01-01sEndDate2019-02-02,也就是说根据20天一段来分,整个时间段将会被分为2019-01-01~2019-01-212019-01-21~2019-02-02。关于这里划分的分区数,这里需要说明两点:

  • 这里我们将整个时间段切割之后,只能得到两个分区,但我们的数据库节点配置了三个,此时,只有第一个节点和第二个节点将会被使用,第三个将始终不会用到;
  • 如果我们提供的数据库节点数比切割的分区数要小的话,那么就会有一部分分区没有与其指定的数据库节点,此时就会抛出异常;

        另外,我们需要着重强调的一点是,在正常使用的过程中,如果配置了结束时间,那么始终会有一天我们的时间会超出结束时间,但如果我们将结束时间配置得非常大,那么就会出现一个问题就是所需要的分区数会比我们的数据库节点数要多,此时就会抛出异常。在这一点上,MyCat允许我们的分区字段时间比结束时间要大,也就是说,插入的字段值可以是2019-02-02以后的日期。此时目标日期所在的分区计算方式如下:

int targetPartition = ((endTimeMills - sBeginDateMills) / (partitionDurationMills)) % nPartitons;
  • endTimeMills表示目标要计算的时间戳;
  • sBeginDateMills表示配置的开始时间戳;
  • partitionDurationMills表示每个分区时间段的时间戳时长,比如这里就是20 * 24 * 60 * 60 * 1000
  • nPartitons表示当前在sBeginDatesEndDate之间划分的分区数量,根据前面我们的演示知道其为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-255256-511512-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的整个范围都进行覆盖,否则对于没有覆盖的数据将会报错。

3. 小结

        本文首先对MyCat进行了简要介绍,并且讲解了其配置文件的配置方式。然后着重介绍了MyCatt提供的十种分区策略。

本文发表于2019年08月26日 12:00
(c)注:本文转载自https://my.oschina.net/zhangxufeng/blog/3097533,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责。如有侵权行为,请联系我们,我们会及时删除.

阅读 1408 讨论 0 喜欢 0

抢先体验

扫码体验
趣味小程序
文字表情生成器

闪念胶囊

你要过得好哇,这样我才能恨你啊,你要是过得不好,我都不知道该恨你还是拥抱你啊。

直抵黄龙府,与诸君痛饮尔。

那时陪伴我的人啊,你们如今在何方。

不出意外的话,我们再也不会见了,祝你前程似锦。

这世界真好,吃野东西也要留出这条命来看看

快捷链接
网站地图
提交友链
Copyright © 2016 - 2021 Cion.
All Rights Reserved.
京ICP备2021004668号-1