面试官:海量订单数据如何存储和查询

共 23937字,需浏览 48分钟

 ·

2023-10-31 22:23

你知道的越多,不知道的就越多,业余的像一棵小草!

你来,我们一起精进!你不来,我和你的竞争对手一起精进!

编辑:业余草

来源:juejin.cn/post/7294841029681250319

推荐:https://t.zsxq.com/13WJ1INdw

自律才能自由

冷热数据架构

假设我们考虑 12306 单个假期的人流量为 2 亿人次,这一估算基于每年的三个主要假期:五一、国庆和春节。这些假期通常都有来回的流动,因此数据存储与计算的公式变为:2 * (3*2) = 12 亿,即每年的假期总人次达到了 12 亿。

考虑到假期订单数据以及日常购票数据的累积,随着多年的积累,数据量将会变得相当庞大。但我们需要再次审视一个关键问题:这些订单数据是否需要一直保留在数据库中呢?

经过详细分析 12306 车票订单购买查看逻辑,我们发现用户账号只能查看最近一个月内的订单购买记录。这一时间范围最多涵盖一个节假日周期,考虑往返车票等情况,大致数据量约为 4 亿。这样的数据规模相较之前大幅减少,有效降低了整体的存储压力。

上述的这种数据存储技术叫做「冷热数据」的架构方案,那什么叫做冷数据?什么又是热数据?

  • 「热数据」通常指经常被访问和使用的数据,如最近的交易记录或最新的新闻文章等。这些数据需要快速的读写速度和响应时间,因此通常存储在快速存储介质(如内存或快速固态硬盘)中,以便快速访问和处理。
  • 「冷数据」则指很少被访问和使用的数据,如过去的交易记录或旧的新闻文章等。这些数据访问频率较低,但需要长期保存,因此存储在较慢的存储介质(如磁盘或云存储)中,以便节省成本和存储空间。

如何实现这种冷热数据存储架构?比较简单的方案就是,「我们每天有个定时任务,把一个月前的数据从当前的数据库迁移到冷数据库中」。这里就涉及了分库分表操作。

这时需要注意一件事情,就是我们迁移到冷数据库不意味着不查询这些数据。如果遇到查询历史数据的需求,我们还是要能支持,比如支付宝的交易数据查询。

订单分片键选择

每每说到分库分表,最头疼的是莫过于如何选择分片键,用户名?订单号?还是创建时间?

先说我们的业务基本诉求,订单分库分表的基本查询条件有两种情况

  • 「用户要能查看自己的订单」
  • 「支持订单号精准查询。」

这样的话,我们就需要按照两个字段当做分片键,这也就意味着每次查询时需要带着用户和订单两个字段,非常的不方便。能不能通过一个字段分库分表,但是查询时两个字段任意传一个就能精准查询,而不导致读扩散问题?

基因法

这就需要用到咱们项目中使用的基因算法。那什么是分库分表基因算法?

说的通俗易懂点,就是我们通过把用户的后六位数据冗余到订单号里。这样的话,我们就可以按照用户 ID 后六位进行分库分表,并且将分片键定义为用户 ID 和订单号,只要查询中携带这两个字段,我们就取用户 ID 后六位进行查找分片表的位置。

这样我们就可以很好支持分库分表需求了,同时能满足用户和订单号两种查询逻辑,这也是大家热衷于使用基因算法的原因。

订单号生成

为了保证订单号生成递增,我们参考雪花算法自定义了一个 DistributedIdGenerator,生成后的分布式 ID 再拼接上用户的后六位。

@Component
@RequiredArgsConstructor
public final class OrderIdGeneratorManager implements InitializingBean {
 
    private static DistributedIdGenerator DISTRIBUTED_ID_GENERATOR;
 
    /**
     * 生成订单全局唯一 ID
     *
     * @param userId 用户名
     * @return 订单 ID
     */

    public static String generateId(long userId) {
        return DISTRIBUTED_ID_GENERATOR.generateId() + String.valueOf(userId % 1000000);
    }
}

这种将用户 ID 后六位拼接订单号后面的技术方案,是参考了淘宝的订单号设计。


订单分库分表代码实战

如果你没有使用过 ShardingSphere 分库分表操作,可以查看官网进行一些前置条件理解。

引入 ShardingSphere 依赖

<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>shardingsphere-jdbc-core</artifactId>
    <version>5.3.2</version>
</dependency>

定义分片规则

spring:
  datasource:
   # ShardingSphere 对 Driver 自定义,实现分库分表等隐藏逻辑
    driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
    # ShardingSphere 配置文件路径
    url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml

订单分片配置

为了避免繁琐,这里只分 2 个库以及对应业务 16 张表。

shardingsphere-config.yaml

# 数据源集合,也就是咱们刚才说的分两个库
dataSources:
  ds_0:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3306/12306_order_0?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
    username: root
    password: root
 
  ds_1:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3306/12306_order_1?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
    username: root
    password: root
 
rules:
   # 分片规则
    - !SHARDING
    # 分片表
    tables:
      # 订单表
      t_order:
        # 真实的数据节点,也对应着在数据库中存储的真实表
        actualDataNodes: ds_${0..1}.t_order_${0..15}
        # 分库策略
        databaseStrategy:
          # 复合分库策略(多个分片键)
          complex:
            # 用户 ID 和订单号
            shardingColumns: user_id,order_sn
            # 搜索 order_database_complex_mod 下方会有分库算法
            shardingAlgorithmName: order_database_complex_mod
        # 分表策略
        tableStrategy:
          # 复合分表策略(多个分片键)
          complex:
            # 用户 ID 和订单号
            shardingColumns: user_id,order_sn
            # 搜索 order_table_complex_mod 下方会有分表算法
            shardingAlgorithmName: order_table_complex_mod
      # 订单明细表,规则同订单表
      t_order_item:
        actualDataNodes: ds_${0..1}.t_order_item_${0..15}
        databaseStrategy:
          complex:
            shardingColumns: user_id,order_sn
            shardingAlgorithmName: order_item_database_complex_mod
        tableStrategy:
          complex:
            shardingColumns: user_id,order_sn
            shardingAlgorithmName: order_item_table_complex_mod
    # 分片算法
    shardingAlgorithms:
      # 订单分库算法
      order_database_complex_mod:
        # 通过加载全限定名类实现分片算法,相当于分片逻辑都在 algorithmClassName 对应的类中
        type: CLASS_BASED
        props:
          algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonDataBaseComplexAlgorithm
          # 分库数量
          sharding-count: 2
          # 复合(多分片键)分库策略
          strategy: complex
      # 订单分表算法
      order_table_complex_mod:
        # 通过加载全限定名类实现分片算法,相当于分片逻辑都在 algorithmClassName 对应的类中
        type: CLASS_BASED
        props:
          algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonTableComplexAlgorithm
          # 分表数量
          sharding-count: 16
          # 复合(多分片键)分表策略
          strategy: complex
      order_item_database_complex_mod:
        type: CLASS_BASED
        props:
          algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonDataBaseComplexAlgorithm
          sharding-count: 2
          strategy: complex
      order_item_table_complex_mod:
        type: CLASS_BASED
        props:
          algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonTableComplexAlgorithm
          sharding-count: 16
          strategy: complex
props:
  sql-show: true

分片算法解析

调试的话可以分为两种,一种是创建订单,一种是查看订单,控制台都有现成的功能,Debug 到分片算法方法上就可以。

因为订单和订单明细表都是按照用户和订单号进行的分片,分片算法规则一致,所以就进行了复用。

订单分库分片算法代码如下:

/**
 * 订单数据库复合分片算法配置
 * ComplexKeysShardingAlgorithm 是 ShardingSphere 预留出来的可扩展分片算法接口
 * 注意:不同版本的 ShardingSphere 可能包路径、类名或者方法名不一致
 */

public class OrderCommonDataBaseComplexAlgorithm implements ComplexKeysShardingAlgorithm {
 
    @Getter
    private Properties props;
 
    // 分库数量,读取的配置中定义的分库数量
    private int shardingCount;
 
    private static final String SHARDING_COUNT_KEY = "sharding-count";
 
    @Override
    public Collection<String> doSharding(Collection availableTargetNames, ComplexKeysShardingValue shardingValue) {
        Map<String, Collection<Comparable<Long>>> columnNameAndShardingValuesMap = shardingValue.getColumnNameAndShardingValuesMap();
        Collection<String> result = new LinkedHashSet<>(availableTargetNames.size());
        if (CollUtil.isNotEmpty(columnNameAndShardingValuesMap)) {
            String userId = "user_id";
            // 首先判断 SQL 是否包含用户 ID,如果包含直接取用户 ID 后六位
            Collection<Comparable<Long>> customerUserIdCollection = columnNameAndShardingValuesMap.get(userId);
            if (CollUtil.isNotEmpty(customerUserIdCollection)) {
                // 获取到 SQL 中包含的用户 ID 对应值
                Comparable<?> comparable = customerUserIdCollection.stream().findFirst().get();
                // 如果使用 MybatisPlus 因为传入时没有强类型判断,所以有可能用户 ID 是字符串,也可能是 Long 等数值
                // 比如传入的用户 ID 可能是 1683025552364568576 也可能是 '1683025552364568576'
                // 根据不同的值类型,做出不同的获取后六位判断。字符串直接截取后六位,Long 类型直接通过 % 运算获取后六位
                if (comparable instanceof String) {
                    String actualOrderSn = comparable.toString();
                    // 获取真实数据库的方法其实还是通过 HASH_MOD 方式取模的,shardingCount 就是咱们配置中的分库数量
                    result.add("ds_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 60))) % shardingCount);
                } else {
                    String dbSuffix = String.valueOf(hashShardingValue((Long) comparable % 1000000) % shardingCount);
                    result.add("ds_" + dbSuffix);
                }
            } else {
                // 如果对订单中的 SQL 语句不包含用户 ID 那么就要从订单号中获取后六位,也就是用户 ID 后六位
                // 流程同用户 ID 获取流程
                String orderSn = "order_sn";
                Collection<Comparable<Long>> orderSnCollection = columnNameAndShardingValuesMap.get(orderSn);
                Comparable<?> comparable = orderSnCollection.stream().findFirst().get();
                if (comparable instanceof String) {
                    String actualOrderSn = comparable.toString();
                    result.add("ds_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 60))) % shardingCount);
                } else {
                    result.add("ds_" + hashShardingValue((Long) comparable % 1000000) % shardingCount);
                }
            }
        }
        // 返回的是表名,
        return result;
    }
 
    @Override
    public void init(Properties props) {
        this.props = props;
        shardingCount = getShardingCount(props);
    }
 
    private int getShardingCount(final Properties props) {
        Preconditions.checkArgument(props.containsKey(SHARDING_COUNT_KEY), "Sharding count cannot be null.");
        return Integer.parseInt(props.getProperty(SHARDING_COUNT_KEY));
    }
 
    private long hashShardingValue(final Comparable<?> shardingValue) {
        return Math.abs((long) shardingValue.hashCode());
    }
}

订单分表算法逻辑基本与订单分库算法一致,大家查看代码也基本上都能清楚,就不再过多赘述。

/**
 * 订单表相关复合分片算法配置
 */

public class OrderCommonTableComplexAlgorithm implements ComplexKeysShardingAlgorithm {
    @Getter
    private Properties props;
    private int shardingCount;
    private static final String SHARDING_COUNT_KEY = "sharding-count";
    @Override
    public Collection<String> doSharding(Collection availableTargetNames, ComplexKeysShardingValue shardingValue) {
        Map<String, Collection<Comparable<?>>> columnNameAndShardingValuesMap = shardingValue.getColumnNameAndShardingValuesMap();
        Collection<String> result = new LinkedHashSet<>(availableTargetNames.size());
        if (CollUtil.isNotEmpty(columnNameAndShardingValuesMap)) {
            String userId = "user_id";
            Collection<Comparable<?>> customerUserIdCollection = columnNameAndShardingValuesMap.get(userId);
            if (CollUtil.isNotEmpty(customerUserIdCollection)) {
                Comparable<?> comparable = customerUserIdCollection.stream().findFirst().get();
                if (comparable instanceof String) {
                    String actualOrderSn = comparable.toString();
                    result.add(shardingValue.getLogicTableName() + "_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 60))) % shardingCount);
                } else {
                    String dbSuffix = String.valueOf(hashShardingValue((Long) comparable % 1000000) % shardingCount);
                    result.add(shardingValue.getLogicTableName() + "_" + dbSuffix);
                }
            } else {
                String orderSn = "order_sn";
                Collection<Comparable<?>> orderSnCollection = columnNameAndShardingValuesMap.get(orderSn);
                Comparable<?> comparable = orderSnCollection.stream().findFirst().get();
                if (comparable instanceof String) {
                    String actualOrderSn = comparable.toString();
                    result.add(shardingValue.getLogicTableName() + "_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 60))) % shardingCount);
                } else {
                    String dbSuffix = String.valueOf(hashShardingValue((Long) comparable % 1000000) % shardingCount);
                    result.add(shardingValue.getLogicTableName() + "_" + dbSuffix);
                }
            }
        }
        return result;
    }
 
    @Override
    public void init(Properties props) {
        this.props = props;
        shardingCount = getShardingCount(props);
    }
 
    private int getShardingCount(final Properties props) {
        Preconditions.checkArgument(props.containsKey(SHARDING_COUNT_KEY), "Sharding count cannot be null.");
        return Integer.parseInt(props.getProperty(SHARDING_COUNT_KEY));
    }
 
    private long hashShardingValue(final Comparable<?> shardingValue) {
        return Math.abs((long) shardingValue.hashCode());
    }
}

架构的设计没有终点,架构的尽头是架构师,架构师的尽头是找到最佳平衡点!

浏览 702
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报