分库分表-ShardingSphere

39 篇文章 6 订阅
12 篇文章 1 订阅


本文通过实战来讲解分表、分库策略和具体实现,在阅读本文之前可以先读一下 《千万级并发架构下,如何进行关系型数据库的分库分表》

1.MyCate、ShardingSphere、Sharding-jdbc

有人就开始疑惑Sharding-JDBC和Sharding-Sphere有什么区别呢?
sharding-jdbc后续发展为Sharding-Sphere,包含sharding-jdbc、Sharding-Proxy、Sharding-Sidecar。

1.1 ShardingSphere

概述:ShardingSphere是一套开源的分布式数据库中间件解决方案组成的生态圈,它由Sharding-JDBC、Sharding-Proxy和Sharding-Sidecar(计划中)这3款相互独立的产品组成。 他们均提供标准化的数据分片、分布式事务和数据库治理功能,可适用于如Java同构、异构语言、容器、云原生等各种多样化的应用场景。
定位:ShardingSphere定位为关系型数据库中间件,旨在充分合理地在分布式的场景下利用关系型数据库的计算和存储能力,而并非实现一个全新的关系型数据库。
在这里插入图片描述

1.2 Sharding-JDBC

定位为轻量级Java框架,在Java的JDBC层提供的额外服务。 它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

1)适用于任何基于Java的ORM框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template或直接使用JDBC。

2)基于任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, Druid, HikariCP等。

3)支持任意实现JDBC规范的数据库。目前支持MySQL,Oracle,SQLServer和PostgreSQL。

在这里插入图片描述

当然还有Sharding-Proxy、Sharding-Sidecar,我简单介绍一下,就作为科普了。


Sharding-Proxy:
定位为透明化的数据库代理端,提供封装了数据库二进制协议的服务端版本,用于完成对异构语言的支持。

目前先提供MySQL版本,它可以使用任何兼容MySQL协议的访问客户端(如:MySQL Command Client, MySQL Workbench等)操作数据,对DBA更加友好。

1)向应用程序完全透明,可直接当做MySQL使用。

2)适用于任何兼容MySQL协议的客户端。


Sharding-Sidecar(TBD):
定位为Kubernetes或Mesos的云原生数据库代理,以DaemonSet的形式代理所有对数据库的访问。 通过无中心、零侵入的方案提供与数据库交互的的啮合层,即Database Mesh,又可称数据网格。

Database Mesh的关注重点在于如何将分布式的数据访问应用与数据库有机串联起来,它更加关注的是交互,是将杂乱无章的应用与数据库之间的交互有效的梳理。

使用Database Mesh,访问数据库的应用和数据库终将形成一个巨大的网格体系,应用和数据库只需在网格体系中对号入座即可,它们都是被啮合层所治理的对象。


他们三个的区别大致如下图所示:
在这里插入图片描述

本文将重点介绍Sharding-JDBC

1.3 Sharding-JDBC包含的一些核心概念

1)LogicTable。

数据分片的逻辑表,对于水平拆分的数据库(表),同一类表的总称。例:订单数据根据主键尾数拆分为10张表,分别是t_order_0到t_order_9,他们的逻辑表名为t_order。

2)ActualTable。

在分片的数据库中真实存在的物理表。即上个示例中的t_order_0到t_order_9。

3)DataNode。

数据分片的最小单元。由数据源名称和数据表组成,例:ds_1.t_order_0。配置时默认各个分片数据库的表结构均相同,直接配置逻辑表和真实表对应关系即可。如果各数据库的表结果不同,可使用ds.actual_table配置。

4)DynamicTable。

逻辑表和真实表不一定需要在配置规则中静态配置。比如按照日期分片的场景,真实表的名称随着时间的推移会产生变化。此类需求Sharding-JDBC是支持的,不过目前配置并不友好,会在新版本中提升。

5)BindingTable。

指在任何场景下分片规则均一致的主表和子表。例:订单表和订单项表,均按照订单ID分片,则此两张表互为BindingTable关系。BindingTable关系的多表关联查询不会出现笛卡尔积关联,关联查询效率将大大提升。

6)ShardingColumn。

分片字段。用于将数据库(表)水平拆分的关键字段。例:订单表订单ID分片尾数取模分片,则订单ID为分片字段。SQL中如果无分片字段,将执行全路由,性能较差。Sharding-JDBC支持多分片字段。

7)ShardingAlgorithm。

分片算法。Sharding-JDBC通过分片算法将数据分片,支持通过等号、BETWEEN和IN分片。分片算法目前需要业务方开发者自行实现,可实现的灵活度非常高。未来Sharding-JDBC也将会实现常用分片算法,如range,hash和tag等。

8)SQL Hint。

对于分片字段非SQL决定,而由其他外置条件决定的场景,可使用SQL Hint灵活的注入分片字段。例:内部系统,按照员工登录ID分库,而数据库中并无此字段。SQL Hint支持通过ThreadLocal和SQL注释(待实现)两种方式使用。

1.4 分布式数据库中间件、产品——sharding-jdbc、mycat、drds比较

目前国内比较成熟的开源数据库中间件有:sharding-jdbc、mycat;而drds是阿里云最近推出的商业产品,考虑到大部分公司都在使用阿里云,做一个全家桶,也是一个不错的选择。
Mycat和Sharding-jdbc 实现原理也是不同:

  • Mycat的原理中最重要的一个动词是“拦截”,它拦截了用户发送过来的SQL语句,首先对SQL语句做了一些特定的分析:如分库分表分析、路由分析、读写分离分析、缓存分析等,然后将此SQL发往后端的真实数据库,并将返回的结果做适当的处理,最终再返回给用户。
  • Sharding-JDBC的原理是接受到一条SQL语句时,会陆续执行SQL解析 => 查询优化 => SQL路由 => SQL改写 => SQL执行 => 结果归并 ,最终返回执行结果。

在这里插入图片描述

从使用方式来看:sharding-jdbc作为一个组件集成在应用内,而mycat则作为一个独立的应用需要单独部署,drds则是阿里云的一个独立产品,不过需要结合rds一起使用。

下面我具体介绍一下这些中间件的优缺点:
sharding-jdbc
优点:1.理论性能最高。从架构上看sharding-jdbc更符合分布式架构的设计,直连数据库,没有中间应用,理论性能是最高的(实际性能需要结合具体的代码实现,理论性能可以理解为上限,通过不断优化代码实现,逐渐接近理论性能)。

缺点:1.开发成本相对较高。由于作为组件存在,需要集成在应用内,意味着作为使用方,必须要集成到代码里,使得开发成本相对较高;2.开发场景限定为Java。由于需要集成在应用内,使得需要针对不同语言有不同的实现,事实上sharding-jdbc目前只支持java,这样组件本身的维护成本也会很高。

mycat
mycat是支持SQL92标准,遵守Mysql原生协议,跨语言,跨平台,跨数据库的通用中间件代理。

优点:作为对比可以参考上表中的Sharding-Proxy,需要单独部署,由于遵守Mysql原生协议,应用时不需要特殊处理,和使用MySQL是一样的,所以应用场景不受限制;

缺点:需要维护自身的连接池。但是mycat不支持二维路由,仅支持单库多表或多库单表,同时由于自定义连接池,这样就会存在mycat自身维护一个连接池,MySQL也有一个连接池,任何一个连接池上限都会成为性能的瓶颈,而mycat的连接池设计也略显粗暴,当请求链接数大于设置连接池上限时直接抛出异常,因此在配置mycat连接池的大小是,需要结合场景做合理设置。

总的来说,mycat以逻辑表的形式屏蔽掉应用处理分库分表的复杂逻辑,遵守Mysql原生协议,跨语言,跨平台,有着更为通用的应用场景。

DRDS
DRDS 兼容 MySQL 协议和语法,支持分库分表、平滑扩容、服务升降配、透明读写分离和分布式事务等特性,具备分布式数据库全生命周期的运维管控能力。

可以看成mycat的商业化产品,也就是mycat所有的优点它都有,而且作为一个商业化产品使用上更为简单透明,功能也更为丰富;如果不差钱而且正准备对数据做重构,那么drds是一个不错的选择,之所以说准备做数据重构时考虑用drds,是因为drds不是一个简单的做sharding路由,即使原来使用的是rds,也无法通过drds做路由,唯一的办法新建drds实例,定义路由规则(drds支持二维路由),导入历史数据,然后就可以开心的使用drds了。

在这里插入图片描述

上面长篇大论的介绍了很多基础理论,下面我们去具体实战感受一下。

2.SpringBoot整合Sharding实现水平分表

不管你具体有没有读过《千万级并发架构下,如何进行关系型数据库的分库分表》,都没关系,我简单在说一下分库分表的方式:

  • 垂直分表: 将一张宽表(字段很多的表), 按照字段的访问频次进行拆分,就是按照表单结构进行拆分。
  • 垂直分库: 根据不同的业务,将表进行分类, 拆分到不同的数据库. 这些库可以部署在不同的服 务器,分摊访问压力.
  • 水平分表: 将一张表的数据 ( 按照数据行) , 分配到同一个数据库的多张表中,每个表都只有一部 分数据.
  • 水平分库: 将一张表的数据 ( 按照数据行) 分到多个不同的数据库.每个库的表结构相同

简而言之,不管是分库还是分表,都可以分为水平和垂直拆分。

2.1 搭建Sharding环境完成分库分表

为了方便测试,我们新建一个数据库:sharding-order(ps:名字随意,自己知道表示啥就行)
在数据库里新建两张表:order_1、order_2

按照一定的分片规则,主键为偶数的落入order_1表 ,为奇数的落入order_2表, 再通过Sharding-Jdbc 进行查询.

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for tb_order_1
-- ----------------------------
DROP TABLE IF EXISTS `tb_order_1`;
CREATE TABLE `tb_order_1` (
  `order_id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) DEFAULT NULL,
  `product_name` varchar(128) COLLATE utf8_bin DEFAULT NULL,
  `COUNT` int(11) DEFAULT NULL,
  PRIMARY KEY (`order_id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

-- ----------------------------
-- Table structure for tb_order_2
-- ----------------------------
DROP TABLE IF EXISTS `tb_order_2`;
CREATE TABLE `tb_order_2` (
  `order_id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) DEFAULT NULL,
  `product_name` varchar(128) COLLATE utf8_bin DEFAULT NULL,
  `COUNT` int(11) DEFAULT NULL,
  PRIMARY KEY (`order_id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

接下来新增一个SpringBoot项目,集成MyBatis-plus。不会?点击这里试试
添加以下相关依赖:

        <!--lombok-->
        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!-- hu tool -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.10</version>
        </dependency>
        <!-- 连接池 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>

        <!-- json -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.69</version>
        </dependency>
        <!-- sharding jdbc -->
        <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
            <version>4.1.0</version>
        </dependency>

springBoot版本:2.7.0

修改application.yml

spring:
  main:
    allow-bean-definition-overriding: true
  application:
    name: shardingjdbc
  #分库分表配置
  shardingsphere:
    datasource:
      names: db0
      db0:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        username: root
        password: root
        jdbc-url: jdbc:mysql://localhost:3306/sharding-order?autoReconnect=true&useUnicode=true&characterEncoding=utf-8&&zeroDateTimeBehavior=CONVERT_TO_NULL&&serverTimezone=GMT%2B8
    sharding:
      tables:
        tb_order:
          actual-data-nodes: db0.tb_order_$->{1..2}
          table-strategy: #策略就是根据id对2取余
            inline: #行表达式
              sharding-column: order_id        #列名称,多个列以逗号分隔
              algorithm-expression: tb_order_$->{order_id % 2 + 1}
    props:
      sql:
        show: true

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
  #    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  mapper-locations: classpath*:mapper/*Mapper.xml
  global-config:
    db-config:
      id-type: none

Dao层

package com.example.shardingjdbc.mapper;

import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

public interface OrderMapper {
    /**
     * 新增订单
     */
    @Insert("INSERT INTO tb_order(order_id,user_id,product_name,COUNT) VALUES(#{order_id},#{user_id},#{product_name},#{count})")
    int insertOrder(@Param("order_id") int order_id, @Param("user_id") int user_id, @Param("product_name") String product_name, @Param("count") int count);
}

注意:记得在主配置类加@MapperScan注解
在这里插入图片描述

接下来写个接口测试一下新增数据

@RestController
@Slf4j
public class OrderController {
    @Autowired
    OrderMapper orderMapper;

    @GetMapping("testInsertOrder")
    public Object testInsertOrder() {
        for (int i = 0; i < 10; i++) {

            orderMapper.insertOrder(i + 1, 100 + i, "大冰箱" + i, 10);
        }
        return null;

    }
}

可以看到测试结果如下:
在这里插入图片描述

这里值的我们注意的是:主键Id不是自动生成,若你设置为自增,则shardingsphere无法解析拦截。

我们通过主键查询数据,看看是否能够正常获取数据
在Dao层新增方法:

@Select("select * from tb_order where order_id=#{orderId}")
Map<String, Object> findById(@Param("orderId") int orderId);

在Controller层新增:

    @GetMapping("testSelectOrder/{orderId}")
    public Object testSelectOrder(@PathVariable("orderId") Integer orderId) {
        Map<String, Object> result = orderMapper.findById(orderId);
        return result;
    }

结果如下:
在这里插入图片描述

这个分表的过程目前就已经走完了,当然还有更多的分表方式,比如你还可以处理用户Id,把用户id为偶数的分开,道理和方法是一样的,同时我们在写sql语句时你也注意到我们写的都是tb_order,这个是我们的逻辑表,即要和我们yml配置中写的逻辑表对应,如下图所示:
在这里插入图片描述

下面我继续讨论分库是怎么进行的,分库涉及到很多分库策略,主要有四种分片策略:单分片键,多分片键的复合分片,行表达式分片,Hint分片策略,我们具体讨论一下。

3.SpringBoot整合Sharding水平分库

3.1 项目实战

通过上面的内容我们了解到分库也有两种:水平分库和垂直分库。但是呢,垂直分库一般是按照业务进行划分,如:

本文就不在讨论,重点说一下如何进行水平拆分

水平分库是把同一个表的数据按一定规则拆到不同的数据库中,每个库可以放在不同的服务器上。

我们在上一章节中已经创建了数据库sharding-order,我们在创建一个数据库:sharding-order2,同时在每一个数据库中在新增一个表:order_info

DROP TABLE IF EXISTS `order_info`;
CREATE TABLE `order_info` (
  `order_info_id` int(11) NOT NULL,
  `user_id` int(11) DEFAULT NULL,
  `product_name` varchar(255) COLLATE utf8_bin DEFAULT NULL,
  `count_num` int(11) DEFAULT NULL,
  PRIMARY KEY (`order_info_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

在这里插入图片描述

下面我们就要实现将order_info表里的数据按照我们指定的规则分发到不同的数据里。

按照以user_id为分片键,分片策略为user_id % 2 + 1,user_id为偶数操作db1数据源,否则操作db2。

修改application.yml

spring:
  main:
    allow-bean-definition-overriding: true
  application:
    name: shardingjdbc
  #分库分表配置
  shardingsphere:
    datasource:
      names: db0,db1
      db0:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        username: root
        password: root
        jdbc-url: jdbc:mysql://localhost:3306/sharding-order?autoReconnect=true&useUnicode=true&characterEncoding=utf-8&&zeroDateTimeBehavior=CONVERT_TO_NULL&&serverTimezone=GMT%2B8
      db1:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        username: root
        password: root
        jdbc-url: jdbc:mysql://localhost:3306/sharding-order2?autoReconnect=true&useUnicode=true&characterEncoding=utf-8&&zeroDateTimeBehavior=CONVERT_TO_NULL&&serverTimezone=GMT%2B8
    sharding:
      tables:
        tb_order:
          actual-data-nodes: db0.tb_order_$->{1..2}
          table-strategy: #策略就是根据id对2取余
            inline: #行表达式
              sharding-column: order_id        #列名称,多个列以逗号分隔
              algorithm-expression: tb_order_$->{order_id % 2 + 1}
        order_info: ## 分库策略,以user_id为分片键,分片策略为user_id % 2 + 1,user_id为偶数操作db1数据源,否则操作db2。
          actual-data-nodes: db$->{0..1}.order_info
          database-strategy:
            inline:
              sharding-column: user_id
              algorithm-expression: db$->{user_id % 2}

    props:
      sql:
        show: true

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
  #    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  mapper-locations: classpath*:mapper/*Mapper.xml
  global-config:
    db-config:
      id-type: none


Dao层:

public interface OrderInfoMapper {
    /**
     * 新增订单
     */
    @Insert("INSERT INTO order_info(order_info_id,user_id,product_name,count_num) VALUES(#{order_info_id},#{user_id},#{product_name},#{count_num})")
    int insertOrder(@Param("order_info_id") int order_info_id, @Param("user_id") int user_id, @Param("product_name") String product_name, @Param("count_num") int count_num);
}

Controlle层 :

@RestController
public class OrderInfoController {
    @Autowired
    OrderInfoMapper orderInfoMapper;

    @GetMapping("orderInfo/testInsert")
    public Object testInsert() {
        for (int i = 0; i < 10; i++) {
            orderInfoMapper.insertOrder(i + 1, i + 1, "冰箱" + i,
                    i);
        }
        return null;
    }
}

结果如下:
在这里插入图片描述

我们可以看到水平分库也实现了。

接下来我们尝试着通过分片键即userId进行查询。

    @Select("select * from order_info where user_id=#{userId}")
    Map<String, Object> findByUserId(@Param("userId") int userId);
    @GetMapping("orderInfo/testSelect/{userId}")
    public Object testSelect(@PathVariable("userId") Integer userId) {
        return orderInfoMapper.findByUserId(userId);
    }

结果如下:
在这里插入图片描述

自己也可以尝试一下非分片键,效果也是一样的。

3.2 绑定表、广播表

创建表:t_order_item_1,t_order_item_2

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for t_order_item_1
-- ----------------------------
DROP TABLE IF EXISTS `t_order_item_1`;
CREATE TABLE `t_order_item_1` (
  `item_id` int(11) NOT NULL,
  `order_id` int(11) DEFAULT NULL,
  `item_name` varchar(255) COLLATE utf8_bin DEFAULT NULL,
  `price` double DEFAULT NULL,
  PRIMARY KEY (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

t_order_0 与 t_order_item_0 互为关联表
创建广播表: t_config

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for t_config
-- ----------------------------
DROP TABLE IF EXISTS `t_config`;
CREATE TABLE `t_config` (
  `id` int(11) NOT NULL,
  `remark` varchar(255) COLLATE utf8_bin DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `last_modify_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

在这里插入图片描述

设置t_order_item分表策略

        t_order_item: #要和tb_order进行绑定,避免笛卡尔积查询
          actual-data-nodes: db0.t_order_item_$->{1..2}
          table-strategy: #策略就是根据id对2取余
            inline: #行表达式
              sharding-column: order_id        #必须要和tb_order的分片键一直,否则绑定不成功
              algorithm-expression: t_order_item_$->{order_id % 2 + 1}

在这里插入图片描述

绑定表关系

spring.shardingsphere.sharding.binding-tables[0]= tb_order,t_order_item

在这里插入图片描述

必须按相同分片健进行分片的表才能互为成绑定表,在联合查询时就能避免出现笛卡尔积查询。
配置广播表

spring.shardingsphere.sharding.broadcast-tables=t_config

在这里插入图片描述

我们向广播表中插入一条数据,看看是什么效果。

public interface TConfigMapper {
    /**
     * 新增订单
     */
    @Insert("INSERT INTO t_config(id,remark,create_time,last_modify_time) VALUES(#{id},#{remark},#{create_time},#{last_modify_time})")
    int insertConfig(@Param("id") int id, @Param("remark") String remark, @Param("create_time") Date create_time, @Param("last_modify_time") Date last_modify_time);
}

@RestController
public class TConfigController {
    @Autowired
    TConfigMapper tConfigMapper;

    @GetMapping("tConfig/testInsert")
    public Object testInsert() {
        return tConfigMapper.insertConfig(1, "我是广播表", new Date(), new Date());
    }
}

在这里插入图片描述

发现所有库中 t_config 表都执行了这条SQL,广播表和 MQ广播订阅的模式很相似,所有订阅的客户端都会收到同一条消息。

接下来在试试复杂一点的联合查询,前边我们已经把 t_order 、t_order_item 表设为绑定表,直接联表查询执行一下。
在连表查询之前,我们需要录入一些数据,方便我们联合查询
在这里插入图片描述

在OrderMapper中新增方法:

    @Select("select * from tb_order o left join t_order_item i " +
            "on o.order_id=i.order_id " +
            "where o.user_id=#{user_id}")
    List<Map<String, Object>> selectWithOrderItemByUserId(@Param("user_id") int user_id);

在OrderController中新增方法:

    @GetMapping("selectWithOrderItemByUserId/{userId}")
    public Object selectWithOrderItemByUserId(@PathVariable("userId") Integer userId) {
        return orderMapper.selectWithOrderItemByUserId(userId);
    }

在这里插入图片描述

日志输出如下:
在这里插入图片描述

接下来我们把绑定去掉
在这里插入图片描述

结果如下:
在这里插入图片描述

很明显已经产生了笛卡尔积查询(ps:后面会详细说明什么是笛卡尔积查询)
我们在看看日志输出的是什么:
在这里插入图片描述

至此实战部分就讲完了,你以为就结束啦?别着急,我们在把上面提到的相关概念,如:绑定表,广播表,笛卡尔积查询,四种分片策略等在继续往下唠唠,之所以把实战放在前面是怕你读完感觉太枯燥,相信你通过实践已经略有小感,那么我就ShardingSphere-jdbc的执行流程,分片、分库策略和算法来讨论一下。

ps:如果你只是暂时的需要用一下ShardingSphere进行分库分表,可以先去尝试在自己的项目去使用了,下面的内容较长,需要花费些时间去阅读和理解




4.分片算法与分片策略

上边我们提到可以用分片健取模的规则分片,但这只是比较简单的一种,在实际开发中我们还希望用 >=、<=、>、<、BETWEEN 和 IN 等条件作为分片规则,自定义分片逻辑,这时就需要用到分片策略与分片算法。

从执行 SQL 的角度来看,分库分表可以看作是一种路由机制,把 SQL 语句路由到我们期望的数据库或数据表中并获取数据,分片算法可以理解成一种路由规则。

我们先明确一下分片算法与分片策略之间的关系:

我们需要将sql语句路由到相应的数据库和相应的表中,至于怎么路由 是由一定的规则的,这个规则就是我们所说的分片算法,而分片策略只是抽象出的概念,它是由分片算法分片健组合而成,分片算法做具体的数据分片逻辑。

分库、分表的分片策略配置是相对独立的,可以各自使用不同的策略与算法,每种策略中可以是多个分片算法的组合,每个分片算法可以对多个分片健做逻辑判断。分片算法和分片策略的关系如下图所示:
在这里插入图片描述

值得我们注意的是:sharding-jdbc 并没有直接提供分片算法的实现,需要开发者根据业务自行实现。

4.1 分片算法

sharding-jdbc 提供了4种分片算法:
1、精确分片算法
精确分片算法(PreciseShardingAlgorithm)用于单个字段作为分片键,SQL中有 = 与 IN 等条件的分片,需要在标准分片策略(StandardShardingStrategy )下使用。

2、范围分片算法
范围分片算法(RangeShardingAlgorithm)用于单个字段作为分片键,SQL中有 BETWEEN AND、>、<、>=、<= 等条件的分片,需要在标准分片策略(StandardShardingStrategy )下使用。

3.复合分片算法
复合分片算法(ComplexKeysShardingAlgorithm)用于多个字段作为分片键的分片操作,同时获取到多个分片健的值,根据多个字段处理业务逻辑。需要在复合分片策略(ComplexShardingStrategy )下使用。

4、Hint分片算法
Hint分片算法(HintShardingAlgorithm)稍有不同,上边的算法中我们都是解析SQL 语句提取分片键,并设置分片策略进行分片。但有些时候我们并没有使用任何的分片键和分片策略,可还想将 SQL 路由到目标数据库和表,就需要通过手动干预指定SQL的目标数据库和表信息,这也叫强制路由。

4.2 分片策略

正如我上面所说分片策略是一种抽象的概念,实际分片操作的是由分片算法和分片健来完成的。

1、标准分片策略
标准分片策略适用于单分片键,此策略支持 PreciseShardingAlgorithm 和 RangeShardingAlgorithm 两个分片算法。

其中 PreciseShardingAlgorithm 是必选的,用于处理 = 和 IN 的分片。RangeShardingAlgorithm 是可选的,用于处理BETWEEN AND, >, <,>=,<= 条件分片,如果不配置RangeShardingAlgorithm,SQL中的条件等将按照全库路由处理。

#分片列名称
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.standard.sharding-column= 
#精确分片算法类名称,用于=和IN。该类需实现PreciseShardingAlgorithm接口并提供无参数的构造器
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.standard.precise-algorithm-class-name= 
#范围分片算法类名称,用于BETWEEN,可选。该类需实现RangeShardingAlgorithm接口并提供无参数的构造器
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.standard.range-algorithm-class-name= 

2、复合分片策略
复合分片策略,同样支持对 SQL语句中的 =,>, <, >=, <=,IN和 BETWEEN AND 的分片操作。不同的是它支持多分片键,具体分配片细节完全由应用开发者实现。

#分片列名称,多个列以逗号分隔
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.complex.sharding-columns= 
#复合分片算法类名称。该类需实现ComplexKeysShardingAlgorithm接口并提供无参数的构造器
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.complex.algorithm-class-name= 

3、行表达式分片策略
行表达式分片策略,支持对 SQL语句中的 = 和 IN 的分片操作,但只支持单分片键。这种策略通常用于简单的分片,不需要自定义分片算法,可以直接在配置文件中接着写规则。

如:t_order_$->{t_order_id % 4} 代表 t_order 对其字段 t_order_id取模,拆分成4张表,而表名分别是t_order_0 到 t_order_3。

在我们上面的实战中均是采用的该策略

#分片列名称
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.inline.sharding-column= 
#分片算法行表达式,需符合groovy语法
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.inline.algorithm-expression= 

4、Hint分片策略
Hint分片策略,对应上边的Hint分片算法,通过指定分片健而非从 SQL中提取分片健的方式进行分片的策略。

#Hint分片算法类名称。该类需实现HintShardingAlgorithm接口并提供无参数的构造器
spring.shardingsphere.sharding.tables.<logic-table-name>.database-strategy.hint.algorithm-class-name= 

有了分片策略,我们知道怎么将SQL和分片键进行联系和路由,但是还有一个棘手的问题就是不同数据节点⽣成全局唯⼀主键。

5.分布式主键

⼀个逻辑表(t_order)内的不同真实表(t_order_n)之间的⾃增键由于⽆法互相感知而产⽣重复主键。

尽管可通过设置⾃增主键 初始值 和 步⻓ 的⽅式避免ID碰撞,但这样会使维护成本加大,乏完整性和可扩展性。如果后去需要增加分片表的数量,要逐一修改分片表的步长,运维成本非常高,所以不建议这种方式。

当然目前实现分布式主键⽣成器的方式很多,此处不再探讨,感兴趣的可以读一下:

当然我们的shardingsphere中内置了UUIDSNOWFLAKE 两种分布式主键⽣成器,默认使⽤雪花算法(snowflake)⽣成64bit的⻓整型数据。
不仅如此它还抽离出分布式主键⽣成器的接口,⽅便我们实现⾃定义的⾃增主键⽣成算法。

6.广播表和绑定表

6.1 广播表

存在于所有的分片数据源中的表,表结构和表中的数据在每个数据库中均完全一致。一般是为字典表或者配置表 t_config,某个表一旦被配置为广播表,只要修改某个数据库的广播表,所有数据源中广播表的数据都会跟着同步。

6.2 绑定表

那些分片规则一致的主表和子表。比如:t_order 订单表和 t_order_item 订单服务项目表,都是按 order_id 字段分片,因此两张表互为绑定表关系。

那绑定表存在的意义是啥呢?

通常在我们的业务中都会使用 t_order 和 t_order_item 等表进行多表联合查询,但由于分库分表以后这些表被拆分成N多个子表。如果不配置绑定表关系,会出现笛卡尔积关联查询,将产生如下四条SQL。

SELECT * FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id 
SELECT * FROM t_order_0 o JOIN t_order_item_1 i ON o.order_id=i.order_id 
SELECT * FROM t_order_1 o JOIN t_order_item_0 i ON o.order_id=i.order_id 
SELECT * FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id 

简单对笛卡尔积查询做出解释:

sql中笛卡尔积表现为冗余,通常我们做多表关联查询时,很容易就会查出笛卡尔积的结果。
例如stu表和dept表:
有以下两张表:
在这里插入图片描述

在这里插入图片描述

笛卡尔积查询: select * from dept,emp;
在这里插入图片描述

此图为笛卡尔积现象,对dept表有5种选择,对emp表有5种选择,所以笛卡尔积的结果查询显示25条。

下面以一张图来概括一下:
在这里插入图片描述

而配置绑定表关系后再进行关联查询时,只要对应表分片规则一致产生的数据就会落到同一个库中,那么只需 t_order_0 和 t_order_item_0 表关联即可。

SELECT * FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id 
SELECT * FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id 

进行绑定表设定后,绑定关系则变为:
在这里插入图片描述

注意:在关联查询时 t_order 它作为整个联合查询的主表。所有相关的路由计算都只使用主表的策略,t_order_item 表的分片相关的计算也会使用 t_order 的条件,所以要保证绑定表之间的分片键要完全相同。

6.3 配置绑定表和广播表

binding-tables和broadcast-tables为集合

#绑定表规则列表
spring.shardingsphere.sharding.binding-tables[0]= 
#绑定表规则列表
spring.shardingsphere.sharding.binding-tables[1]= 
#绑定表规则列表
spring.shardingsphere.sharding.binding-tables[x]= 
#广播表规则列表
spring.shardingsphere.sharding.broadcast-tables[0]= 
#广播表规则列表
spring.shardingsphere.sharding.broadcast-tables[1]= 
#广播表规则列表
spring.shardingsphere.sharding.broadcast-tables[x]= 

7.Sharding-JDBC 和 JDBC的联系

从名字上不难看出,Sharding-JDBC 和 JDBC有很大关系,我们知道 JDBC 是一种 Java 语言访问关系型数据库的规范,其设计初衷就是要提供一套用于各种数据库的统一标准,不同厂家共同遵守这套标准,并提供各自的实现方案供应用程序调用。
在这里插入图片描述

但其实对于开发人员而言,我们只关心如何调用 JDBC API 来访问数据库,只要正确使用 DataSource、Connection、Statement 、ResultSet 等 API 接口,直接操作数据库即可。所以如果想在 JDBC 层面实现数据分片就必须对现有的 API 进行功能拓展,而 Sharding-JDBC 正是基于这种思想,重写了 JDBC 规范并完全兼容了 JDBC 规范。
在这里插入图片描述

对原有的 DataSource、Connection 等接口扩展成 ShardingDataSource、ShardingConnection,而对外暴露的分片操作接口与 JDBC 规范中所提供的接口完全一致,只要你熟悉 JDBC 就可以轻松应用 Sharding-JDBC 来实现分库分表。
在这里插入图片描述

因此它适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate,Mybatis,Spring JDBC Template 或直接使用的 JDBC。
完美兼容任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP,Druid, HikariCP 等,几乎对主流关系型数据库都支持。

那 Sharding-JDBC 又是如何拓展这些接口的呢?

我们快速过一下源码,来探其究竟。

下边我们以 JDBC API 中的 DataSource 为例看看它是如何被重写扩展的。

数据源 DataSource 接口的核心作用就是获取数据库连接对象 Connection,我们看其内部提供了两个获取数据库连接的方法 ,并且继承了 CommonDataSource 和 Wrapper 两个接口。
在这里插入图片描述

其中 CommonDataSource 是定义数据源的根接口这很好理解,而 Wrapper 接口则是拓展 JDBC 分片功能的关键。

由于数据库厂商的不同,他们可能会各自提供一些超越标准 JDBC API 的扩展功能,但这些功能非 JDBC 标准并不能直接使用,而 Wrapper 接口的作用就是把一个由第三方供应商提供的、非 JDBC 标准的接口包装成标准接口,也就是适配器模式

提到了适配器模式,我就简单的说一下吧,更详细的描述,可以参考《设计模式07—适配器模式与外观模式》

适配器模式是种比较常用的设计模式,它的作用是将某个类的接口转换成客户端期望的另一个接口,使原本因接口不匹配(或者不兼容)而无法在一起工作的两个类能够在一起工作。
比如用耳机听音乐,我有个圆头的耳机,可手机插孔却是扁口的,如果我想要使用耳机听音乐就必须借助一个转接头才可以,这个转接头就起到了适配作用。

而 Sharding-JDBC 提供的正是非 JDBC 标准的接口,所以它也提供了类似的实现方案,也使用到了 Wrapper 接口做数据分片功能的适配。除了 DataSource 之外,Connection、Statement、ResultSet 等核心对象也都继承了这个接口。

下图是继承关系流程图
在这里插入图片描述

ShardingDataSource 类它在原 DataSource 基础上做了功能拓展,初始化时注册了分片SQL路由包装器、SQL重写上下文和结果集处理引擎,还对数据源类型做了校验,因为它要同时支持多个不同类型的数据源。到这好像也没看出如何适配,那接着向上看 ShardingDataSource 的继承类 AbstractDataSourceAdapter

@Getter
public class ShardingDataSource extends AbstractDataSourceAdapter {
    
    private final ShardingRuntimeContext runtimeContext;

    /**
     * 注册路由、SQl重写上下文、结果集处理引擎
     */
    static {
        NewInstanceServiceLoader.register(RouteDecorator.class);
        NewInstanceServiceLoader.register(SQLRewriteContextDecorator.class);
        NewInstanceServiceLoader.register(ResultProcessEngine.class);
    }

    /**
     * 初始化时校验数据源类型 并根据数据源 map、分片规则、数据库类型得到一个分片上下文,用来获取数据库连接
     */
    public ShardingDataSource(final Map<String, DataSource> dataSourceMap, final ShardingRule shardingRule, final Properties props) throws SQLException {
        super(dataSourceMap);
        checkDataSourceType(dataSourceMap);
        runtimeContext = new ShardingRuntimeContext(dataSourceMap, shardingRule, props, getDatabaseType());
    }

    private void checkDataSourceType(final Map<String, DataSource> dataSourceMap) {
        for (DataSource each : dataSourceMap.values()) {
            Preconditions.checkArgument(!(each instanceof MasterSlaveDataSource), "Initialized data sources can not be master-slave data sources.");
        }
    }

    /**
     * 数据库连接
     */
    @Override
    public final ShardingConnection getConnection() {
        return new ShardingConnection(getDataSourceMap(), runtimeContext, TransactionTypeHolder.get());
    }
}

AbstractDataSourceAdapter 抽象类内部主要获取不同类型的数据源对应的数据库连接对象。
实现 AutoCloseable 接口是为在使用完资源后可以自动将这些资源关闭(调用 close方法),那再看看继承类 AbstractUnsupportedOperationDataSource 。

@Getter
public abstract class AbstractDataSourceAdapter extends AbstractUnsupportedOperationDataSource implements AutoCloseable {
    
    private final Map<String, DataSource> dataSourceMap;
    
    private final DatabaseType databaseType;
    
    public AbstractDataSourceAdapter(final Map<String, DataSource> dataSourceMap) throws SQLException {
        this.dataSourceMap = dataSourceMap;
        databaseType = createDatabaseType();
    }
    
    public AbstractDataSourceAdapter(final DataSource dataSource) throws SQLException {
        dataSourceMap = new HashMap<>(1, 1);
        dataSourceMap.put("unique", dataSource);
        databaseType = createDatabaseType();
    }
    
    private DatabaseType createDatabaseType() throws SQLException {
        DatabaseType result = null;
        for (DataSource each : dataSourceMap.values()) {
            DatabaseType databaseType = createDatabaseType(each);
            Preconditions.checkState(null == result || result == databaseType, String.format("Database type inconsistent with '%s' and '%s'", result, databaseType));
            result = databaseType;
        }
        return result;
    }
    
    /**
     * 不同数据源类型获取数据库连接
     */
    private DatabaseType createDatabaseType(final DataSource dataSource) throws SQLException {
        if (dataSource instanceof AbstractDataSourceAdapter) {
            return ((AbstractDataSourceAdapter) dataSource).databaseType;
        }
        try (Connection connection = dataSource.getConnection()) {
            return DatabaseTypes.getDatabaseTypeByURL(connection.getMetaData().getURL());
        }
    }
    
    @Override
    public final Connection getConnection(final String username, final String password) throws SQLException {
        return getConnection();
    }
    
    @Override
    public final void close() throws Exception {
        close(dataSourceMap.keySet());
    }
}

AbstractUnsupportedOperationDataSource 实现DataSource 接口并继承了 WrapperAdapter 类,它内部并没有什么具体方法只起到桥接的作用,但看着是不是和我们前边讲适配器模式的例子方式有点相似。

WrapperAdapter 是一个包装器的适配类,实现了 JDBC 中的 Wrapper 接口,其中有两个核心方法 recordMethodInvocation 用于添加需要执行的方法和参数,而 replayMethodsInvocation 则将添加的这些方法和参数通过反射执行。仔细看不难发现两个方法中都用到了 JdbcMethodInvocation类。

public abstract class WrapperAdapter implements Wrapper {
    
    private final Collection<JdbcMethodInvocation> jdbcMethodInvocations = new ArrayList<>();
 
    /**
     * 添加要执行的方法
     */
    @SneakyThrows
    public final void recordMethodInvocation(final Class<?> targetClass, final String methodName, final Class<?>[] argumentTypes, final Object[] arguments) {
        jdbcMethodInvocations.add(new JdbcMethodInvocation(targetClass.getMethod(methodName, argumentTypes), arguments));
    }
    
    /**
     * 通过反射执行 上边添加的方法
     */
    public final void replayMethodsInvocation(final Object target) {
        for (JdbcMethodInvocation each : jdbcMethodInvocations) {
            each.invoke(target);
        }
    }
}

JdbcMethodInvocation 类主要应用反射通过传入的 method 方法和 arguments 参数执行对应的方法,这样就可以通过 JDBC API 调用非 JDBC 方法了。

@RequiredArgsConstructor
public class JdbcMethodInvocation {
    
    @Getter
    private final Method method;
    
    @Getter
    private final Object[] arguments;
    
    /**
     * Invoke JDBC method.
     * 
     * @param target target object
     */
    @SneakyThrows
    public void invoke(final Object target) {
        method.invoke(target, arguments);
    }
}

说了那么多,Sharding-JDBC 拓展 JDBC API 接口后,在新增的分片功能里又做了哪些事情呢?

一张表经过分库分表后被拆分成多个子表,并分散到不同的数据库中,在不修改原业务 SQL 的前提下,Sharding-JDBC 就必须对 SQL进行一些改造才能正常执行。

8.Sharding-JDBC执行流程

大致的执行流程:SQL 解析 -> 执⾏器优化 -> SQL 路由 -> SQL 改写 -> SQL 执⾏ -> 结果归并 六步组成。
在这里插入图片描述

8.1 SQL 解析

SQL解析过程分为词法解析语法解析两步,比如下边这条查询用户订单的SQL

SELECT order_no,price FROM t_order_ where user_id = 10086 and order_status > 0

先用词法解析将SQL拆解成不可再分的原子单元。

在根据不同数据库方言所提供的字典,将这些单元归类为关键字,表达式,变量或者操作符等类型。

接着语法解析会将拆分后的SQL转换为抽象语法树,通过对抽象语法树遍历,提炼出分片所需的上下文,上下文包含查询字段信息(Field)、表信息(Table)、查询条件(Condition)、排序信息(Order By)、分组信息(Group By)以及分页信息(Limit)等,并标记出 SQL中有可能需要改写的位置。
在这里插入图片描述

8.2 执⾏器优化

执⾏器优化对SQL分片条件进行优化,处理像关键字OR这种影响性能的坏sql语句。

8.3 SQL 路由

SQL 路由通过解析分片上下文,匹配到用户配置的分片策略,并生成路由路径。

简单点理解就是可以根据我们配置的分片策略计算出 SQL该在哪个库的哪个表中执行,而SQL路由又根据有无分片健区分出 分片路由 和 广播路由。
在这里插入图片描述

上图我们可以看出有分⽚键的路由叫分片路由,细分为直接路由、标准路由和笛卡尔积路由这3种类型。

8.3.1 标准路由

标准路由是最推荐也是最为常⽤的分⽚⽅式,它的适⽤范围是不包含关联查询或仅包含绑定表之间关联查询的SQL。

当 SQL分片健的运算符为 = 时,路由结果将落⼊单库(表),当分⽚运算符是BETWEEN 或IN 等范围时,路由结果则不⼀定落⼊唯⼀的库(表),因此⼀条逻辑SQL最终可能被拆分为多条⽤于执⾏的真实SQL。

  • 逻辑sql
SELECT * FROM t_order  where t_order_id in (1,2)
  • SQL路由处理后
SELECT * FROM t_order_0  where t_order_id in (1,2)
SELECT * FROM t_order_1  where t_order_id in (1,2)

8.3.2 直接路由

直接路由是通过使用 HintAPI 直接将 SQL路由到指定⾄库表的一种分⽚方式,而且直接路由可以⽤于分⽚键不在SQL中的场景,还可以执⾏包括⼦查询、⾃定义函数等复杂情况的任意SQL。

比如根据 t_order_id 字段为条件查询订单,此时希望在不修改SQL的前提下,加上 user_id作为分片条件就可以使用直接路由。

8.3.4 笛卡尔积路由

笛卡尔路由是由⾮绑定表之间的关联查询产生的,查询性能较低尽量避免走此路由模式。


同时我们还可以从上图中看到无分⽚键的路由又叫做广播路由,可以划分为全库表路由、全库路由、 全实例路由、单播路由和阻断路由这 5种类型。

8.3.5 全库表路由

全库表路由针对的是数据库 DQL和 DML,以及 DDL等操作,当我们执行一条逻辑表 t_order SQL时,在所有分片库中对应的真实表 t_order_0 ··· t_order_n 内逐一执行。

  • 数据操作语言(DML),例如:INSERT(插入)、UPDATE(修改)、DELETE(删除)语句。
  • 数据定义语言(DDL),例如:CREATE、DROP、ALTER等语句。
  • 数据查询语言(DQL),例如:SELECT语句。(一般不会单独归于一类,因为只有一个语句)。
  • 数据控制语言(DCL),例如:GRANT、REVOKE等语句。
  • 事务控制语言(TCL),例如:COMMIT、ROLLBACK等语句。

8.3.6 全库路由

全库路由主要是对数据库层面的操作,比如数据库 SET 类型的数据库管理命令,以及 TCL 这样的事务控制语句。

对逻辑库设置 autocommit 属性后,所有对应的真实库中都执行该命令。

SET autocommit=0;

8.3.6 全实例路由

全实例路由是针对数据库实例的 DCL 操作(设置或更改数据库用户或角色权限),比如:创建一个用户 order ,这个命令将在所有的真实库实例中执行,以此确保 order 用户可以正常访问每一个数据库实例。

CREATE USER order@127.0.0.1 identified BY 'ninesun';

8.3.7 单播路由

单播路由用来获取某一真实表信息,比如获得表的描述信息:

DESCRIBE t_order; 

t_order 的真实表是 t_order_0 ···· t_order_n,他们的描述结构相完全同,我们只需在任意的真实表执行一次就可以。

8.3.8 阻断路由

⽤来屏蔽SQL对数据库的操作,例如:

USE order_db;

这个命令不会在真实数据库中执⾏,因为 ShardingSphere 采⽤的是逻辑 Schema(数据库的组织和结构) ⽅式,所以无需将切换数据库的命令发送⾄真实数据库中。

8.4 SQL 改写

将基于逻辑表开发的SQL改写成可以在真实数据库中可以正确执行的语句。比如查询 t_order 订单表,我们实际开发中 SQL是按逻辑表 t_order 写的。

SELECT * FROM t_order

但分库分表以后真实数据库中 t_order 表就不存在了,而是被拆分成多个子表t_order_0,t_order_1,…, t_order_n 分散在不同的数据库内,还按原SQL执行显然是行不通的,这时需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称。

SELECT * FROM t_order_n

8.5 SQL执⾏

将路由和改写后的真实 SQL 安全且高效发送到底层数据源执行。但这个过程并不是简单的将 SQL 通过JDBC 直接发送至数据源执行,而是平衡数据源连接创建以及内存占用所产生的消耗,它会自动化的平衡资源控制与执行效率。

8.6 结果归并

将从各个数据节点获取的多数据结果集,合并成一个大的结果集并正确的返回至请求客户端,称为结果归并。而我们SQL中的排序、分组、分页和聚合等语法,均是在归并后的结果集上进行操作的。

至此,从实战到理论的诠释就真的到此结束啦,再见!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZNineSun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值