支付系统 - 使用 shardingsphere 分库分表实践

2,715 阅读8分钟

前言

本篇分享一些关于数据库分库分表的内容,是对现有知识的总结,希望对你有所帮助。

分库分表的背景

一句话,解决数据扩容以及高并发的问题。

互联网数据库架构演进

以支付系统的核心表支付流水表举例,常见的关键字如下:

列名 数据类型 描述
id BIGINT UNSIGNED 主键
serial_no BIGINT UNSIGNED 平台流水号
order_no VARCHAR2(64) 商户订单号
trans_unique_id BIGINT UNSIGNED 交易唯一 ID

单库

在业务初期,没有多少数据量,单库单表即可满足业务场景。

如果是对外部商户提供服务,常见的分层如下:

单库
单库

图中的open-api-web是对外提供的站点层,外部商户使用HTTP协议进行通讯。若不是对外的直接提供RPC接口也可,具体场景具体分析。本文的重点是探讨数据库,服务层也可能是多节点,为了方便简述,后面不再出现站点层并且以一个服务层来表示可能存在的多层服务。

分组

考虑到数据库部署的机器硬件可能会出故障,一般为了高可用需要做冗余设计。当然了,如果系统本身挂了丢数据或者业务中断也没多大影响单库也是可以的。在这里支付系统对系统可用性很高,一般对外部调用方而言要做到无限服务能力(吹牛)。那么常见的架构就如下所示:

一主多从
一主多从

这种叫主从分组。其中db-master提供写服务(单点),db-slave0db-slave1提供读服务。各数据库实例之间的表结构与数据完全一致,使用binlog进行数据同步,不可避免存在一定的时间窗口数据短暂不一致。不过这不是本文的重点,忽略。

有了它,我们即可以做读写分离减少数据库的读写冲突,也处理读性能瓶颈,所有的读请求飘到Slave节点。也可以做高可用,Master挂了Slave自己顶上来。

其中Master-Slave是一种典型的软件设计模式,在各大中间件多有使用,希望你看到该名称就明白其设计思路。这里有一个问题,所有的写请求只有Master提供,即它还是是单点瓶颈。

分片

接下来,我们来看看怎么解决写瓶颈问题。

水平切分

照着上面分组的思路,可以看到通过冗余做高可用。那么数据写瓶颈如果增加写节点自然也能解决问题。如下,很自然的就演变为如下的设计:

水平分库
水平分库

如上,db—{0..2}都可以提供写服务,这样写的瓶颈就解决了。当然了,上面的这个图太夸张了,直接将数据库水平扩了三份。实际业务中,出现分库更多是业务划分。因为不大可能整个库所有的表都出现了写瓶颈。出现写瓶颈的大数据表不会太多,我们只需要将其分表即可。也就是说把一张表拆分为多张。

上面的划分为水平切分,也称为sharding。如果你的业务需要进行水平切分,那么究竟分库还是分表,还是即分库也分表呢?这和你对未来的业务量预估有关系。如果在不考虑成本的情况下分库最好,因为即使分表单表 IO 瓶颈虽然被部分缓解,但他们依然共用的一个数据库存储文件,仍然受到数据量增大的影响。如果是分库,即使在一台机器上建多个实例使用的是不同的文件存储。或者豪华点直接上新的机器那扩展性就不是分表可以比的了。

垂直切分

将表中不常用的字段拆到另外一张扩展表中,这两张表联合起来可以构成完整的数据集。表之间使用一个关键字段进行关联。拆分的规则可考虑字段使用的频率/长度等。比如将较长的不常用的字段聚合在一张表中,常用的短的在一张表中。

常见的实践方案

上面已经说完了分库分表的缘由,下面开始聊分库分表的手段。大的方向上有离散/连续两种分法。

离散

以支付流水表举例,假设我们现在有 4 张表。一种离散的分发为:按照serial_no模4,得到0/1/2/3中的任意数字,就将这条数据插入进t_mer_pay_{0..3}中。缺点是如果需要再次扩容会存在数据迁移的问题,虽然我们有一致性哈希,但只能减少迁移量并不能杜绝。

另外这里会有一个数据平滑迁移的问题需要解决。

连续

这种的比较自然,就是按照数据发生的时间进行分区。很容易做到扩容,时间快到了再创建几张新表就可以了。除了用时间还可以怎么分呢?还可以根据一个字段值,给定一个值范围进行分区。为了方便理解,我画了张图:

字段范围分表
字段范围分表

特别容易理解,找一个字段根据大小做判断即可。

缺点是:根据程序的局部性原理,程序总是趋向于使用最近使用过的数据和指令,即最近保存的数据会被频繁访问,其它老分区可能相对比较闲。没有很好的起到分散热点数据的效果。当然了,如果这不算系统瓶颈,你可以忽略。没有人把刀架在你脖子上做系统设计,只要满足业务场景技术方案能够自圆其说我觉得都是可以的。

如此,常见的方案就说完了。总结一下,分组解决可用性/读瓶颈问题;分片解决数据量大写瓶颈问题。能不能即分组又分片呢?YES:

分组分片
分组分片

如图示,写发往Master节点,同时做了水平拆分,解决数据量大的写瓶颈问题。读请求发往Slave节点,充分匹配互联网应用读多写少的场景。指的注意的是,支付系统做读写分离不太合理,因为对数据一致性要求比较高。这里只是示例,希望不会误导大家。

工程实践

千呼万唤始出来,到了本文的代码环节。本文没有介绍开源常见的分库分表组件,而是直接采用了去中心化模式的shardingsphere(沙丁思菲尔)

ShardingSphere-JDBC
ShardingSphere-JDBC

非常的简单,导入 JAR 包只需要简单几步配置就可以完成分库分表。需要注意的是官网的文档经常更新不及时,按照文档去配各种报错。这里我以支付流水表分表进行举例,演示一下步骤以及效果。

这里我使用的是Spring Bootshardingsphere支持以starter的形式进行自动配置。首先在pom.xml中增加依赖:

<sharding-sphere.version>4.0.0-RC1</sharding-sphere.version>
<!--shardingsphere start-->
      <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
            <version>${sharding-sphere.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>sharding-core-common</artifactId>
            <version>${sharding-sphere.version}</version>
        </dependency>
<!--shardingsphere end-->

application.properties的配置

# sharding-jdbc配置

#定义数据源
spring.shardingsphere.datasource.name=m1
spring.shardingsphere.datasource.m1.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.m1.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.m1.url=jdbc:mysql://xxxx:3306/compose-pay?serverTimezone=GMT%2B8&useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull
spring.shardingsphere.datasource.m1.username=
spring.shardingsphere.datasource.m1.password=
spring.shardingsphere.datasource.m1.initial-size=8
spring.shardingsphere.datasource.m1.min-idle=5
spring.shardingsphere.datasource.m1.max-active=10
spring.shardingsphere.datasource.m1.query-timeout=6000
spring.shardingsphere.datasource.m1.transaction-query-timeout=6000
spring.shardingsphere.datasource.m1.remove-abandoned-timeout=1800
spring.shardingsphere.datasource.m1.filter-class-names=stat
spring.shardingsphere.datasource.m1.filters=stat,config
spring.shardingsphere.datasource.m1.testOnBorrow=false

spring.shardingsphere.props.sql.show=true


# 分表后的表名
spring.shardingsphere.sharding.tables.t_mer_pay.actual-data-nodes=m1.t_mer_pay_$->{1..4}
# 复合字段分表
spring.shardingsphere.sharding.tables.t_mer_pay.table-strategy.complex.sharding-columns=serial_no,trans_unique_id
spring.shardingsphere.sharding.tables.t_mer_pay.table-strategy.complex.algorithm-class-name=io.github.pleuvoir.gateway.dao.sharding.MerPayTableShardingAlgorithm

# 未配置分片规则的表将走默认数据源
spring.shardingsphere.sharding.default-data-source-name=m1

配置看着很简单,但是如果你按照官网文档去配就全是报错。这些都是我扣源码一个个试出来的,不忍吐槽。我严重怀疑版本升级后是两个团队搞的,完全不兼容之前的配置。

其中自定义分表规则是根据多键分表:

@Slf4j
public class MerPayTableShardingAlgorithm implements ComplexKeysShardingAlgorithm<Long> {

    private final String KEY_SERIAL_NO = "serial_no";
    private final String KEY_TRANS_UNIQUE_ID = "trans_unique_id";

    @Override
    public Collection<String> doSharding(Collection<String> availableTargetNames, ComplexKeysShardingValue<Long> complexKeysShardingValue) {

        //当前实际表
        Object[] targetTables = availableTargetNames.toArray();

        Map<String, Collection<Long>> shardingValuesMap = complexKeysShardingValue.getColumnNameAndShardingValuesMap();

        Collection<Long> serialNoList = shardingValuesMap.get(KEY_SERIAL_NO);

        //如果平台流水号不为空
        if (CollectionUtils.isNotEmpty(serialNoList)) {
            Long serialNo = (Long) serialNoList.toArray()[0];
            String targetTable = String.valueOf(targetTables[(int) (serialNo % 4)]);
            log.info("serialNo={},targetTable={}", serialNo, targetTable);
            return Collections.singletonList(targetTable);
        }

        //如果唯一流水号不为空
        Collection<Long> transUniqueIdList = shardingValuesMap.get(KEY_TRANS_UNIQUE_ID);
        if (CollectionUtils.isNotEmpty(transUniqueIdList)) {
            Long transUniqueId = (Long) transUniqueIdList.toArray()[0];
            String targetTable = String.valueOf(targetTables[(int) (transUniqueId % 4)]);
            log.info("serialNo={},targetTable={}", transUniqueId, targetTable);
            return Collections.singletonList(targetTable);
        }

        return null;
    }

}

操作该表必须传入平台流水号或者唯一流水号。并且两个键值同时模 4 都是相等的值。这就要求两个键值生成规则之间满足一定的算法,相关的实现可以参考之前写的基因分表文章 雪花算法与多键分表

这里需要关注的是返回的索引值,如果返回 0,则对应m1.t_mer_pay_$->{1..4}中的表t_mer_pay_1,返回1则对应的t_mer_pay_2,希望大家明白。

然后写个单元测试验证下我们的配置是否生效。

先新建4张表,

支付流水分表
支付流水分表

表结构如下:

执行单元测试:

@RunWith(SpringJUnit4ClassRunner.class)
@ActiveProfiles("rd")
@SpringBootTest(classes = PayGatewayLauncher.class)
public class MerPayDaoTest extends BaseTest {

  @Resource
  private IMerPayDao dao;

  @Resource
  private ShardingDataSource dataSource;

  @Test
  public void createOrderTest() throws InterruptedException {

    Map<StringDataSourcedataSourceMap = dataSource.getDataSourceMap();

    DruidDataSource m1 = (DruidDataSource) dataSourceMap.get("m1");
    System.out.println(StringUtils.repeat("*"20));

    for (int i = 0; i < 4; i++) {
      MerPayPO merPayPO = new MerPayPO();
      merPayPO.setSerialNo((long) i);

      Integer ret = dao.insert(merPayPO);
      System.out.println(ret > 0);
    }

    Thread.currentThread().join();

  }
}

我使用的是Mybatis-plus会自动生成 ID,所以就没手动设置。这里的m1就是之前在配置文件中定义的数据源,在沙丁思菲尔里会被包装为ShardingDataSource,由于使用fastjson输出该对象会报错,所以我 DEBUG 验证了下设置的属性是否生效。

ShardingDataSource
ShardingDataSource

可以看到,和我们的配置一致。如果有需要的其它属性照猫画虎自己增加就可以。

另外,由于通过参数spring.shardingsphere.props.sql.show=true开启了日志,所以可以看到解析的过程。

解析日志
解析日志

和我们预想的一样,并且数据也成功的落到了相应的表里。

后语

实不相瞒在我准备连之前购买的腾讯云乞丐服务器时,发生的一件事情让我惊了。

被黑了
被黑了

这尼玛,不就是因为我把数据库连接和地址传 Git 了吗,个人用户的服务器就还有人黑。收费恢复数据?不存在的。不过我的第一反应不是这个数据库,而是去看了我的比特币账户,幸好没事,嗯。希望大家吃一堑长一智,不要手一抖把公司的代码上传了

相关的代码已上传至 compose-pay 欢迎指正。