使用ShardingSphere实现Spring Boot分片


SpringBoot案例:专注于客户评论的简单业务场景,目的是说明各种用例。 该应用程序使用Spring Boot 框架实现,并通过 Spring Data JPA 与MySQL 数据库通信,代码使用 Kotlin 编写。它公开了一个简单的REST API,以对评论进行 CRUD 操作为特色。

评论应用程序涉及一个存储大量课程评论的数据库。随着课程和评论数量的增长,数据库中的评论表会变得非常大,难以管理并降低性能。

为了解决这个问题,我们可以实施数据 分片,这涉及将表reviews分解成更小、更易于管理的部分,称为分片。
每个分片都包含表reviews中数据的一个子集,每个分片负责基于分片键的特定范围的数据,例如课程 ID。 

数据表分片可以帮助评论应用程序更有效地管理其不断增长的评论表,提高性能和可扩展性,同时还使备份和维护任务更易于管理。

但是,总有一个但是——手动实现表分片可能是一项复杂且具有挑战性的任务。它需要对数据库设计和架构有深入的了解,以及所使用的特定分片实现的知识。在 Spring Boot 应用程序中实现表分片时会出现很多挑战。

是时候认识ShardingSphere了: 
Apache ShardingSphere 是一个生态系统,可将任何数据库转换为分布式数据库系统,并通过分片、弹性扩展、加密功能等对其进行增强。

Apache ShardingSphere 有两种形式:

  1. ShardingSphere-JDBC是一个轻量级的Java框架,在Java的JDBC层提供额外的服务。
  2. ShardingSphere-Proxy 是一个透明的数据库代理,提供了一个数据库服务器,封装了数据库二进制协议来支持异构语言。

ShardingSphere 为全链路在线负载测试场景提供数据分片、分布式事务、读写分离、高可用、数据迁移、查询联邦、数据加密、影子数据库等一系列特性,帮助管理海量数据并确保数据的完整性和安全性。 
现在,我们将专注于 ShardingSphere-JDBC 的数据分片,我们将使用以下依赖项: 

implementation("org.apache.shardingsphere:shardingsphere-jdbc-core:5.3.2")
implementation("org.apache.shardingsphere:shardingsphere-cluster-mode-core:5.3.2")
implementation("org.apache.shardingsphere:shardingsphere-cluster-mode-repository-zookeeper:5.3.2")
implementation("org.apache.shardingsphere:shardingsphere-cluster-mode-repository-api:5.3.2")

注意:ShardingSphere团队在5.1.x版本中有spring boot的启动器,但他们为了项目的一致性而放弃了启动器,现在推荐使用最新的版本(非启动器),其配置可以有一些不同,但仍然相当简单。在我的 repo 中,通过提交,你也可以找到 spring boot starter 配置的例子。

ShardingSphere-JDBC主要可以通过两种方式进行配置:YAML 配置和 Java 配置。在这篇文章中,我选择了YAML配置。

所以目前,我的数据源配置来自application.yaml,看起来像这样:

spring:
  datasource:
    username: my_user
    password: my_password
    url: jdbc:mysql://localhost:3306/reviews-db?allowPublicKeyRetrieval=true&useSSL=false
    tomcat:
      validation-query: "SELECT 1"
      test-while-idle: true
  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
    open-in-view: false
    hibernate:
      ddl-auto: none


为了启用 ShardingSphere JDBC,我们要让它看起来像这样:

spring:
  datasource:
    driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
    url: jdbc:shardingsphere:classpath:sharding.yaml
  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
    open-in-view: false
    hibernate:
      ddl-auto: none


我们指定数据源所使用的驱动是ShardingSphereDriver,并且应该根据这个文件sharding.yaml来选择URL。

好了,很简单,对吗?让我们继续;让我们创建 sharding.yaml

dataSources:
  master:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.jdbc.Driver
    jdbcUrl: jdbc:mysql://localhost:3306/reviews-db?allowPublicKeyRetrieval=true&useSSL=false
    username: my_user
    password: my_password
    connectionTimeoutMilliseconds: 30000
    idleTimeoutMilliseconds: 60000
    maxLifetimeMilliseconds: 1800000
    maxPoolSize: 65
    minPoolSize: 1

mode:
  type: Standalone
  repository:
    type: JDBC

rules:
  - !SHARDING
    tables:
      reviews:
        actualDataNodes: master.reviews_$->{0..1}
        tableStrategy:
          standard:
            shardingColumn: course_id
            shardingAlgorithmName: inline
    shardingAlgorithms:
      inline:
        type: INLINE
        props:
          algorithm-expression: reviews_$->{course_id % 2}
          allow-range-query-with-inline-sharding: true
props:
  proxy-hint-enabled: true
  sql-show: true


现在我们来分析一下最重要的属性:

dataSources.master - 这里是我们主数据源的定义。
mode--可以是独立的JDBC类型,也可以是集群的Zookeeper类型(建议用于生产),它用于配置信息的持久化

rules--在这里,我们可以启用ShardingSphere的各种功能,比如--!SHARDING

  • tables.reviews - 在这里,我们根据内联语法规则描述实际的表,也就是说,我们将有两个表reviews_0和reviews_1通过course_id列进行分片。
  • shardingAlgorithms - 在这里,我们通过一个groovy表达式来描述手动内联分片算法,告诉大家reviews表根据course_id列分为两个表。

props - 这里,我们启用了拦截/格式化sql查询(p6spy可以被禁用/注释)。

重要提示:在启动我们的应用程序之前,我们需要确保我们定义的分片已经创建,所以我在数据库中创建了两个表:reviews_0和reviews_1(init.sql)。

现在我们准备启动我们的应用程序并做一些请求:

### POST a new review
POST http://localhost:8070/api/v1/reviews/
Content-Type: application/json

{
  "text": "This is a great course!",
  "author": "John Doe",
  "authorTelephone": "555-1234",
  "authorEmail": "johndoe@example.com",
  "invoiceCode": "ABC123",
  "courseId": 123
}

得到如下信息:

NFO 35412 --- [nio-8070-exec-2] ShardingSphere-SQL: Actual SQL: master ::: insert into reviews_1 (created_at, last_modified_at, author, author_email, author_telephone, course_id, invoice_code, text, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?) ::: [2023-04-17 15:42:01.8069745, 2023-04-17 15:42:01.8069745, John Doe, johndoe@example.com, 555-1234, 123, ABC123, This is a great course!, 4]


如果我们要再执行一个具有不同有效载荷的请求:

INFO 35412 --- [nio-8070-exec-8] ShardingSphere-SQL: Actual SQL: master ::: insert into reviews_1 (created_at, last_modified_at, author, author_email, author_telephone, course_id, invoice_code, text, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?) ::: [2023-04-17 15:43:47.3267788, 2023-04-17 15:43:47.3267788, Mike Scott, mikescott@example.com, 555-1234, 123, ABC123, This is an amazing course!, 5]

我们可以注意到,Mike和John的评论都进入了reviews_1表,如果我们把course_id改为124,并再次执行相同的POST请求,会怎么样呢?

INFO 35412 --- [nio-8070-exec-4] ShardingSphere-SQL: Actual SQL: master ::: insert into reviews_0 (created_at, last_modified_at, author, author_email, author_telephone, course_id, invoice_code, text, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?) ::: [2023-04-17 15:44:42.7133688, 2023-04-17 15:44:42.7133688, Mike Scott, mikescott@example.com, 555-1234, 124, ABC123, This is an amazing course!, 6]


我们可以看到,我们的新评论被保存在reviews_0表中。


现在,我们可以根据course_id执行两个GET请求

### GET reviews by course ID
GET http://localhost:8070/api/v1/reviews/filter?courseId=123

GET http://localhost:8070/api/v1/reviews/filter?courseId=124

并在日志中观察我们两个表之间的路由是如何发生的。

INFO 35412 --- [nio-8070-exec-9] ShardingSphere-SQL: Actual SQL: master ::: select review0_.id as id1_0_, review0_.created_at as created_2_0_, review0_.last_modified_at as last_mod3_0_, review0_.author as author4_0_, review0_.author_email as author_e5_0_, review0_.author_telephone as author_t6_0_, review0_.course_id as course_i7_0_, review0_.invoice_code as invoice_8_0_, review0_.text as text9_0_ from reviews_1 review0_ where review0_.course_id=? ::: [123]
INFO 35412 --- [nio-8070-exec-5] ShardingSphere-SQL: Actual SQL: master ::: select review0_.id as id1_0_, review0_.created_at as created_2_0_, review0_.last_modified_at as last_mod3_0_, review0_.author as author4_0_, review0_.author_email as author_e5_0_, review0_.author_telephone as author_t6_0_, review0_.course_id as course_i7_0_, review0_.invoice_code as invoice_8_0_, review0_.text as text9_0_ from reviews_0 review0_ where review0_.course_id=? ::: [124]


第一个选择被指向reviews_1表,第二个选择被指向reviews_0表 - Sharding in action!


读写分离
现在让我们设想另一个问题,审查应用时间在高峰期可能会遇到高压力,导致响应时间慢,用户体验下降。为了解决这个问题,我们可以实现读/写分流,以平衡负载,提高性能。

而我们是多么幸运,ShardingSphere为我们提供了一个读/写拆分的解决方案。读写拆分是指将读取查询引向副本数据库,将写入查询引向主数据库,确保读取请求不干扰写入请求,优化数据库性能。

在配置读写分离解决方案之前,我们必须对我们的docker-compose做一些修改,以便为我们的主数据库提供一些副本(这要归功于bitnami提供的):

mysql-master:
  image: 'bitnami/mysql:latest'
  ports:
    - '3306:3306'
  volumes:
    - 'mysql_master_data:/bitnami/mysql/data'
    - ./init.sql:/docker-entrypoint-initdb.d/init.sql
  environment:
    - MYSQL_REPLICATION_MODE=master
    - MYSQL_REPLICATION_USER=repl_user
    - MYSQL_REPLICATION_PASSWORD=repl_password
    - MYSQL_ROOT_PASSWORD=master_root_password
    - MYSQL_USER=my_user
    - MYSQL_PASSWORD=my_password
    - MYSQL_DATABASE=reviews-db
mysql-slave:
  image: 'bitnami/mysql:latest'
  ports:
    - '3306'
  depends_on:
    - mysql-master
  environment:
    - MYSQL_USER=my_user
    - MYSQL_PASSWORD=my_password
    - MYSQL_REPLICATION_MODE=slave
    - MYSQL_REPLICATION_USER=repl_user
    - MYSQL_REPLICATION_PASSWORD=repl_password
    - MYSQL_MASTER_HOST=mysql-master
    - MYSQL_MASTER_PORT_NUMBER=3306
    - MYSQL_MASTER_ROOT_PASSWORD=master_root_password

让我们像这样开始我们的容器(一个主容器和两个副本):

docker-compose up --detach --scale mysql-master=1 --scale mysql-slave=2 

现在,我们需要为我们的从属服务器提供映射的端口。

$ docker port infra-mysql-slave-1 

3306/tcp -> 0.0.0.0:49923 

$ docker port infra-mysql-slave-2 

3306/tcp -> 0.0.0.0:49922 


好了,现在我们有了主站和它的副本,我们准备配置两个新的数据源,像这样:

dataSources:
  master:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.jdbc.Driver
    jdbcUrl: jdbc:mysql://localhost:3306/reviews-db?allowPublicKeyRetrieval=true&useSSL=false
    username: my_user
    password: my_password
    connectionTimeoutMilliseconds: 30000
    idleTimeoutMilliseconds: 60000
    maxLifetimeMilliseconds: 1800000
    maxPoolSize: 65
    minPoolSize: 1

  slave0:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.jdbc.Driver
    jdbcUrl: jdbc:mysql://localhost:49922/reviews-db?allowPublicKeyRetrieval=true&useSSL=false
    username: my_user
    password: my_password
    connectionTimeoutMilliseconds: 30000
    idleTimeoutMilliseconds: 60000
    maxLifetimeMilliseconds: 1800000
    maxPoolSize: 65
    minPoolSize: 1

  slave1:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.jdbc.Driver
    jdbcUrl: jdbc:mysql://localhost:49923/reviews-db?allowPublicKeyRetrieval=true&useSSL=false
    username: my_user
    password: my_password
    connectionTimeoutMilliseconds: 30000
    idleTimeoutMilliseconds: 60000
    maxLifetimeMilliseconds: 1800000
    maxPoolSize: 65
    minPoolSize: 1

然后,我们可以在规则中加入读写分割规则:

- !READWRITE_SPLITTING
  dataSources:
    readwrite_ds:
      staticStrategy:
        writeDataSourceName: master
        readDataSourceNames:
          - slave0
          - slave1
      loadBalancerName: readwrite-load-balancer
  loadBalancers:
    readwrite-load-balancer:
      type: ROUND_ROBIN

在这里,我认为一切都不言自明:我们已经指定写入的数据源名称为主站,读取的数据源指向我们的从站:slave0和slave1;并且我们选择了一个轮回的负载平衡算法。

重要的是:最后要做的一个改变是关于分片规则,它对新配置的读写分片规则一无所知,直接指向主站:

- !SHARDING
  tables:
    reviews:
      actualDataNodes: readwrite_ds.reviews_$->{0..1}


现在我们的分片也会被读写分割规则所包裹,数据源的决定会在挑选正确的表之前做出(注意readwrite_ds.reviews_$->{0..1})。

好了,我们可以启动我们的应用程序,运行同样的POST请求并观察日志:

INFO 22860 --- [nio-8070-exec-1] ShardingSphere-SQL: Actual SQL: master ::: insert into reviews_0 (created_at, last_modified_at, author, author_email, author_telephone, course_id, invoice_code, text, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?) ::: [2023-04-17 16:12:07.25473, 2023-04-17 16:12:07.25473, Mike Scott, mikescott@example.com, 555-1234, 124, ABC123, This is an amazing course!, 7]

这里没有什么令人惊讶的,分片仍然在工作,查询发生在主数据源(写数据源)。但如果我们要运行几个GET请求,我们会观察到以下情况:

INFO 22860 --- [nio-8070-exec-2] ShardingSphere-SQL: Actual SQL: slave0 ::: select review0_.id as id1_0_, review0_.created_at as created_2_0_, review0_.last_modified_at as last_mod3_0_, review0_.author as author4_0_, review0_.author_email as author_e5_0_, review0_.author_telephone as author_t6_0_, review0_.course_id as course_i7_0_, review0_.invoice_code as invoice_8_0_, review0_.text as text9_0_ from reviews_0 review0_ where review0_.course_id=? ::: [124]
INFO 22860 --- [nio-8070-exec-4] ShardingSphere-SQL: Actual SQL: slave1 ::: select review0_.id as id1_0_, review0_.created_at as created_2_0_, review0_.last_modified_at as last_mod3_0_, review0_.author as author4_0_, review0_.author_email as author_e5_0_, review0_.author_telephone as author_t6_0_, review0_.course_id as course_i7_0_, review0_.invoice_code as invoice_8_0_, review0_.text as text9_0_ from reviews_0 review0_ where review0_.course_id=? ::: [124]

你可以观察到读写分离的动作;我们的写查询发生在主数据源中,但我们的读查询发生在主数据源的副本(slave0和slave1)中,这同时保持了正确的分片规则。


数据屏蔽
关于我们的应用程序的另一个假想问题。想象一下,敏感信息,如客户的电子邮件、电话号码和发票代码,可能需要被某些用户或应用程序访问,而由于数据隐私法规,对其他人来说是隐藏的。

为了解决这个问题,我们可以实施一个数据屏蔽解决方案,在映射结果或在SQL层面上屏蔽敏感数据。但正如你所猜测的,何必呢?ShardingSphere在这里用另一个容易实现的功能--数据屏蔽来拯救这一切。

详细点击标题

本文源码:here