电商-订单服务设计(万字长文分析)

197 阅读27分钟

文章内容已经收录在《面试进阶之路》,从原理出发,直击面试难点,实现更高维度的降维打击!

电商-订单服务设计

订单服务是电商系统中最核心的服务,对订单系统的要求是:订单数据不能出错

订单数据在什么场景下会出错?

  • 重复创建订单数据
  • 订单数据更新的 ABA 问题

接下来给出保证订单数据安全的解决方案

重复创建订单数据

  • 为什么会重复创建订单数据呢?

这个原因有很多,比如用户点击了多次提交,或者由于网络原因,第一次创建订单的请求还没有到达,用户再次点击提交,多次提交订单

如果不在服务端做 幂等控制 的话,就会出现重复的订单数据

  • 如何判断是重复请求呢?

直接根据订单的金额以及订单对应的商品信息来判断是否为重复订单可以吗?

肯定是不可以的,金额和商品信息相同并不一定就是重复订单,如果用户自己下了两笔相同的订单,就会出现误判

常用的解决方案为:通过提前创建订单号 + 唯一索引来解决

解决方案

在用户进入下单界面时,此时前端界面请求后端服务 提前创建唯一的订单号 ,之后当用户点击下单,会将该订单号传到后端服务,后端服务可以根据该订单号来判断是否为重复请求

在订单表中,将订单号设置为 唯一索引 ,这样当插入订单号重复的数据时,会抛出 DuplicatedKeyException ,之后捕获该异常,返回对应提示即可

  • 这里当发生幂等时,应该返回什么样的提示呢?

一般来说,幂等操作即执行多次结果相同,因此第一次创建订单返回什么样的信息,第二次就返回什么样的信息

这里的场景是前端直接调用后端服务的幂等操作

如果在分布式场景下,上游服务调用下游服务,此时下游服务做了幂等,当多次执行操作的返回结果不相同的话,上游服务就需要针对重复操作的响应做对应的处理;而且上游服务不可能只调用这一个下游服务,对于其他下游服务的幂等结果也需要做特殊处理,这样带来的协调、开发成本太大, 因此原则上幂等会保证多次操作返回的结果一致

image-20241030144331547

当然,除此之外还有其他的方式,比如说用户进入下单界面时,前端界面向后端服务申请一个 Token,之后创建订单的时候携带上 Token,将 Token 放入 Redis 控制唯一来保证幂等(原理都类似)

订单 ABA 问题

在订单数据更新时会存在 ABA 问题:订单支付完成之后,商家发货之后会有物流单号的写入,如果物流单号第一次写完之后又需要再次更新,比如初始写入为 单号 01 ,后需要修改为 单号 02 ,这个过程中就可能会出现 ABA 问题,如下:

  • 客户端给订单服务发送请求 1:更新物流单号为 单号 01
  • 订单服务成功修改订单为 单号 01 ,但是此时返回给客户端的响应超时了,客户端没有
  • 客户端给订单服务发送请求 2:更新物流单号为 单号 02 ,订单服务将订单修改为 单号 02
  • 由于客户端没有收到请求 1 的响应结果,因此给订单服务重新发送请求 1,订单服务将订单修改为 单号 01 ,此时就因为 ABA 问题导致订单数据错误

image-20241030145538656

解决方案

通过 版本机制来解决 ,在订单表中增加 version 字段,version 字段会不断 +1,因此通过比较 version 字段可以避免 ABA 问题

UPDATE orders SET ..., version = version + 1
WHERE order_id = 1 AND version = 1

分布式 ID 设计方案

一般情况下,会对订单服务进行分库分表,假如使用数据库自增 ID,那么多个分片上的订单数据就没有唯一标识来区分了,因此需要分布式 ID 作为唯一标识来区分不同的订单数据

分布式 ID 的生成有很多解决方案,但是一般都需要满足以下几个要求:

  • 全局唯一性
  • 高性能、高可用:支持大量 ID 的下发以及 ID 下发的可用性
  • 安全性:如果分布式 ID 是连续的,就需要考虑被用户恶意扒取数据来统计一天的下单量,因此在特殊场景下,需要保证分布式 ID 无规则以避免数据泄露

接下来会介绍常见的几种分布式 ID 生成方案,以及美团开源出来的 Leaf 分布式 ID 解决方案,Leaf 已经应用于美团众多业务线

方案一:UUID

UUID 的标准形式为 32 个 16 进制数字,也就是 36 个字符,JDK 自带了 UUID,使用如下:

public static void main(String[] args) {
    System.out.println(UUID.randomUUID());
}

优点:

  • 本地即可生成 UUID,性能高

缺点:

  • UUID 较长,通常使用长度为 36 的字符串表示,很多场景不适用(比如 MySQL 的主键要求尽可能短、并且有序,而 UUID 过长,且无序,因此不适合)
  • 信息不安全:基于 MAC 地址生成 UUID 的算法可能会造成 MAC 地址泄漏,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置

方案二:雪花算法

Snowflake(雪花算法)是 Twitter 开源的分布式 ID 生成算法,总共有 64 bit,分为 4 个部分,每部分的作用不同:

  • 第一部分(1 bit):始终为 0,不使用
  • 第二部分(41 bit):表示时间戳,单位:毫秒,可以支撑约 69 年
  • 第三部分(10 bit):一般情况下,前 5 位表示机房 ID,后 5 位表示机器 ID,用于区分不同的机器节点(也可以借助 ZK 来生成 workerID)
  • 第四部分(12 bit):表示序列号(自增),单台机器每毫秒可以生成 2^12=4096 个序列号
不使用时间戳workerID序列号
1bit41bit10bit12bit

优点:

  • 时间戳在高位,因此生成的 ID 是趋势递增的
  • 可以根据自身业务特性定制 bit 位,灵活

缺点:

  • 强依赖于机器时钟,如果机器时钟回拨,可能会导致出现重复 ID

方案三:数据库生成方案

借助 MySQL 的 主键自增 来生成分布式唯一 ID,MySQL 中对应表属性如下:

  • id:自增,即生成的分布式 ID
  • stub:该字段没有具体含义,主要插入一个数据让 ID 完成自增,可以在 stub 字段建立唯一索引,这样就避免在表里插入较多的元素了

表 sequence_id 如下:

FieldTypeNullKeyDefault
idbigint(20)NOPRIAUTO_INCREMENT
created_atTIMESTAMPNOCURRENT_TIMESTAMP

SQL 如下:

BEGIN;
REPLACE INTO sequence_id (stub) VALUES ('stub');
SELECT 2819996; # 查询上一个插入的 ID
COMMIT;

使用 REPLACE 代替 INSERT,REPLACE INTO 首先尝试插入数据到表中:

1、如果发现表中已经有此行数据(根据主键或者唯一索引判断)则先删除此行数据,然后插入新的数据

2、否则,直接插入新数据

为什么用 REPLACE 代替 INSERT 呢?

这样就不需要一直向表中插入数据了,比如每次插入的 stub 值都固定,可以保证表里只有一条数据,避免数据不断增长

优点:

  • 实现简单,借助数据库即可完成

缺点:

  • 支持并发量不大,存在数据库单点问题

针对数据库单点问题,也有优化策略,比如使用 5 个库来生成分布式 ID,每个库的起始 ID 不同,步长等于库的数量

比如第一个库起始 ID 为 1,第二个库起始 ID 为 2 ... 第五个库起始 ID 为 5,这样就可以 5 个库同时生成 ID

但是这样虽然解决了单点问题,却存在不易水平扩展的缺陷,如果要扩展为 6 个库,需要将第 6 个库的起始 ID 设置的比其他库都要大,并且保证扩容期间第 6 个库的起始 ID 不能被其他库追上,否则会出现重复 ID,之后调整所有库的步长为 6 即可

方案四:Leaf 介绍

对于分布式 ID 的生成方案,美团基础研发平台推出了 Leaf 作为分布式 ID 生成服务,已经广泛应用于美团金融、美团外卖、美团酒旅等多个部门,因此直接学习美团的开源解决方案即可

Leaf 有两种分布式 ID 生成策略:

  • Leaf-segment:基于 MySQL 生成唯一 ID
  • Leaf-snowflake:基于雪花算法优化

Leaf-segment

Leaf-segment 方案是基于数据库做的优化,借助数据库来生成唯一的 ID,会导致每获取一次 ID 都需要读写一次数据库,因此在性能上存在瓶颈

针对性能问题的优化: Leaf 服务每读写一次数据库获取多个唯一 ID,这样就可以减少 IO 次数,并且如果数据库宕机,由于读取了多个 ID,也可以保证一段时间的ID 分发可用性

接下来看一下Leaf-segment 是如何做的, 数据库表 设计如下:

FieldTypeNullKeyDefaultExtra
biz_tagvarchar(128)NOPRI(none)(none)
max_idbigint(20)NO1(none)
stepint(11)NO(none)(none)
descvarchar(256)YES(none)(none)
update_timetimestampNOCURRENT_TIMESTAMPon update CURRENT_TIMESTAMP

biz_tag 用来区分业务,max_id 表示该 biz_tag 目前所被分配的ID号段的最大值,step 表示每次分配的号段长度。原来获取 ID 每次都需要写数据库,现在只需要把 step 设置得足够大,比如1000。

那么只有当 1000 个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从 1 减小到了 1/step

读写数据库的 SQL 如下:

Begin;
UPDATE table SET max_id = max_id + step WHERE biz_tag=xxx
SELECT tag, max_id, step FROM table WHERE biz_tag=xxx
Commit;

架构如下,Leaf 服务每次在 DB 中获取多个分布式 ID,比如第一个 Leaf 服务获取 [1, 1000] 这 1000 个分布式 ID,第二个 Leaf 服务获取 [1001, 2000] ,之后后端服务来请求 Leaf 服务获取分布式 ID 即可

这样就将性能瓶颈从 DB 转移到了分布式 Leaf 服务了:

image-20241101130122998

优点:

  • 性能瓶颈由 DB 转移到 Leaf 服务,而 Leaf 服务可以水平扩展,可以满足高性能的场景
  • 高可用:Leaf 服务中暂存了一部分 ID,可以在 DB 宕机的情况下保证一段时间的可用性
  • 通过自定义 max_id 可以很方便地从原有的 ID 方式迁移过来

缺点:

  • ID 号码连续,可能被恶意扒取 ID 信息从而泄露公司机密(如每日下单数量)
  • TP999 数据波动大(TP999 即 99.9%的请求都可以在特定时间完成响应),这是因为当 Leaf 服务使用完了自己的 ID 之后,会去请求 DB 获取新的一批 ID,此时就会导致请求阻塞在数据库 IO 上
  • 服务的可用性完全依赖于 DB(虽然 Leaf 服务可以保证一段时间的可用,但是当内部所有 ID 使用完之后,由于 DB 宕机,还是会导致分布式 ID 服务不可用)
双 Buffer 优化

针对上边问题,Leaf-segment 做了双 Buffer 的优化,先来分析一下 TP999 为什么会波动较大:

  • 当 Leaf 服务内部的 ID 使用完之后,会去请求 DB 获取新的 ID,此时如果有新请求需要获取分布式 ID 的话,就需要等待 Leaf 服务完成数据库 IO,如果此时读写 DB 时发生网络波动,就会阻塞获取 ID 的请求

双 Buffer优化流程如下:

  • 提前取出下一批 ID:当 Leaf 服务消耗当前的 ID 达到 10% 的时候,异步去加载下一批次的 ID,当前批次的 ID 下发完之后,就切换下一批次的 ID 下发,保证 Leaf 服务内部有两个批次的 ID
DB 高可用容灾

由于 Leaf-segment 服务强依赖于 DB,因此需要保证 DB 高可用,可以采用:一主两从方式部署,主从分机房部署,采用半同步复制方式

主从部署的问题:极端场景下,会产生数据不一致问题(主从延迟 + 主从切换)

但是数据不一致的概率很小,如果要实现完全的一致性,可以使用类 Paxos 算法实现强一致的 MySQL 阀杆,需要牺牲性能来保证,因此要在两方面做权衡

Leaf-snowflake 方案

Leaf-segment 方案存在一个 安全性 问题,即如果订单数据使用 Leaf-segment 方案的话,由于 ID 的连续的,如果截取两天中午订单 ID,就可以获取一天的订单量,造成公司内部数据泄露

因此,对安全性有要求的数据(如订单),就不可以使用 Leaf-segment 方案来生成分布式 ID 了,因此可以采用 Leaf-snowflage 方案

ID 号的组成和 snowflake 方案下相同,如下:

不使用时间戳workerID序列号
1bit41bit10bit12bit

如果 Leaf 服务规模较小,可以手动配置 workerID;当 Leaf 服务规模较大,使用 ZooKeeper 持久顺序节点的特性自动对 snowflake 节点配置 workerID,Leaf-snowflake 启动步骤如下:

1、启动 Leaf-snowflake 服务,连接 Zookeeper,在 leaf_forever 父节点下检查自己是否已经注册过(是否有该顺序子节点)

2、如果有注册过直接取回自己的 workerID(zk顺序节点生成的int类型ID号),启动服务。

3、如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。

如下图,如果采用在 ZooKeeper 中注册节点获取 workerID 方式的话,每个 Leaf 服务都会在 ZK 中创建一个节点,获取对应的 workerID

image-20241101140145672

减少对 ZK 的依赖

由于启动 Leaf 服务之后,需要去 ZK 中获取对应的 workerID,此时对 ZK 存在强依赖

为了提高 Leaf 服务的可用性,在查询 ZK 获取 workerID 之后,会将对应的 workerID 缓存到本地的文件系统,之后就直接读取本地文件系统中的 workerID 即可

如果本地文件系统没有 wokerID,再去 ZK 中获取

时钟问题

Leaf-snowflake 方案依赖于时间,如果机器的时钟发生了回拨,就有可能生成重复的 ID 号,常用的解决方案如下:

在 Leaf 服务启动时,会进行检查,如下:

1、节点启动时,会在 ZooKeeper 中的 leaf_forever 节点下边去注册自己,并且写入自身的系统时间

2、之后新节点对比其他 Leaf 节点的系统时间来判断自身系统时间是否准确,具体流程:

  • 获取所有运行中的 Leaf-snowflake 节点的 IP+Port(在 ZooKeeper 的 leaf_temporary 节点下存储了所有 Leaf-snowflake 节点的 IP+Port)

  • 拿到 IP+Port 之后,通过 RPC 请求获取对应节点的系统时间,计算 sum(time)/nodeSize,之后看本地时间和平均时间的差值是否在阈值内,来判断新节点的系统时间是否准确。如果准确,则启动服务;如果不准确,则启动失败,并报警

3、在运行过程中,Leaf-snowflake 节点每隔一段时间都会上报自身的系统时间写入到 ZooKeeper 中的 leaf_forever 节点下

Leaf 在美团中的使用

Leaf 在美团众多业务线中使用:金融、支付交易、餐饮、外卖、酒店旅游、猫眼电影等

Leaf 压测性能

4C8G 的服务器上 QPS 达到 5w/s,TPS999 为 1ms

订单服务分库分表方案设计

背景

电商中订单服务通常需要分库分表设计,为什么要分库分表, ShardingSphere 官方解释 如下:

传统的将数据集中存储至单一数据节点的解决方案,在性能、可用性和运维成本这三方面已经难于满足互联网的海量数据场景。

  • 从性能方面来说,由于关系型数据库大多采用B+树类型的索引,在数据量超过阈值的情况下,索引深度的增加也将使得磁盘访问的IO次数增加,进而导致查询性能的下降;同时,高并发访问请求也使得集中式数据库成为系统的最大瓶颈。

  • 从可用性的方面来讲,服务化的无状态型,能够达到较小成本的随意扩容,这必然导致系统的最终压力都落在数据库之上。而单一的数据节点,或者简单的主从架构,已经越来越难以承担。数据库的可用性,已成为整个系统的关键。

  • 从运维成本方面考虑,当一个数据库实例中的数据达到阈值以上,对于DBA的运维压力就会增大。数据备份和恢复的时间成本都将随着数据量的大小而愈发不可控。一般来讲,单一数据库实例的数据的阈值在1TB之内,是比较合理的范围。

在传统的关系型数据库无法满足互联网场景需要的情况下,将数据存储至原生支持分布式的NoSQL的尝试越来越多。 但NoSQL对SQL的不兼容性以及生态圈的不完善,使得它们在与关系型数据库的博弈中始终无法完成致命一击,而关系型数据库的地位却依然不可撼动。

数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果。 数据分片的有效手段是对关系型数据库进行分库和分表。分库和分表均可以有效的避免由数据量超过可承受阈值而产生的查询瓶颈。 除此之外,分库还能够用于有效的分散对数据库单点的访问量;分表虽然无法缓解数据库压力,但却能够提供尽量将分布式事务转化为本地事务的可能,一旦涉及到跨库的更新操作,分布式事务往往会使问题变得复杂。 使用多主多从的分片方式,可以有效的避免数据单点,从而提升数据架构的可用性。

通过分库和分表进行数据的拆分来使得各个表的数据量保持在阈值以下,以及对流量进行疏导应对高访问量,是应对高并发和海量数据系统的有效手段。

阿里巴巴开发手册 中,关于分库分表的推荐是:单表行数超过 500 万行或者单表容量超过 2GB,才推荐分库分表(如果预计三年后的数据量达不到这个级别,不要在创建时就分库分表)

具体操作中,分库分表数量根据经验或者压测结果来定,上边给出的只是参考范围

画外音:三层 B+ 树大约可以存储 2000w 条数据(具体计算过程捕猎觉)

分片键设定

存在问题

既然要分库分表,就需要确定 分片键 ,通常情况下会采用订单 id(order_id) 作为分片键,但是这样会存在一个问题:与业务不适配

当用户查询自己的订单列表时,此时的查询维度是用户 id(member_id),但是分片键是 order_id,因此无法根据 member_id 快速查找对应数据,只能全量查询所有数据分片

解决方案

这个问题通常有以下几种解决方案:

方案一

实现原理:生成 order_id 时,将 member_id 的后几位拼接在 order_id 后边,之后根据 order_id 进行分片的话,其实是根据 member_id 进行分片了。比如 order_id = 100,member_id = 20,拼接之后 order_id = 10020,加入分片算法是根据后两位进行取模,即 20 % table_size,是根据 member_id 进行数据分片

image-20241031151043701

优缺点:方案一的话,实现成本比较低,但是存在问题就是,只能从 member_id 的维度进行查询,如果商户(merchant)想要查询自己的订单列表,即从 merchant_id 维度查询的场景无法得到满足

如果需要多维度查询,可以考虑方案二和方案三

方案二

创建映射表(倒排索引),即建立 member_id -> order_id 的映射,当根据 member_id 查询订单时,先查询 member_id 对应的 order_id,再根据 order_id 查询对应的订单数据

方案三

使用 ES(ES 中使用倒排索引,可以通过多维度条件进行查询)

方案四

实现原理:设计订单冗余表,分别从 member_id 和 merchant_id 的维度存储订单数据,详细内容可见:《订单冗余表的设计》

优缺点:实现复杂性较低,但是需要两倍的存储空间,因为一份订单数据要写入买家表和卖家表,以空间换时间

总结

更加常用的方案是方案一和方案三

通过方案一可以满足从用户维度的订单查询

通过方案三可以实现更多维度的查询操作

方案一具体实现细节

在方案一中,将 member_id 拼接在了 order_id 后边,如果直接指定 order_id 作为分片键,在需要以 member_id 查询的场景下,无法支持,因此需要自定义分片算法,可以获取到 member_id 和 order_id,对这两种 id 都进行分片处理,来路由到对应的分片

如下:

@Slf4j
public class OmsOrderShardingAlgorithm implements ComplexKeysShardingAlgorithm<String> {

    /* 订单编号列名 */
    private static final String COLUMN_ORDER_SHARDING_KEY = "id";
    /* 客户id列名*/
    private static final String COLUMN_CUSTOMER_SHARDING_KEY = "member_id";

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

        /*处理 = 以及 in */
        if (!complexKeysShardingValue.getColumnNameAndShardingValuesMap().isEmpty()) {
            Map<String, Collection<String>> columnNameAndShardingValuesMap
                    = complexKeysShardingValue.getColumnNameAndShardingValuesMap();
            if(columnNameAndShardingValuesMap.containsKey(COLUMN_ORDER_SHARDING_KEY)
                    ||columnNameAndShardingValuesMap.containsKey(COLUMN_CUSTOMER_SHARDING_KEY)){
                 /*获取订单编号*/
                Collection<String> orderSns = complexKeysShardingValue.getColumnNameAndShardingValuesMap()
                        .getOrDefault(COLUMN_ORDER_SHARDING_KEY, new ArrayList<>(1));
                /* 获取客户id*/
                Collection<String> customerIds = complexKeysShardingValue.getColumnNameAndShardingValuesMap()
                        .getOrDefault(COLUMN_CUSTOMER_SHARDING_KEY, new ArrayList<>(1));

                /*合并订单id和客户id到一个容器中*/
                List<String> ids = new ArrayList<>(16);
                if (Objects.nonNull(orderSns)) ids.addAll(ids2String(orderSns));
                if (Objects.nonNull(customerIds)) ids.addAll(ids2String(customerIds));

                return ids.stream()
                         /*截取 订单号或客户id的后2位*/
                        .map(id -> id.substring(id.length() - 2))
                        /* 去重*/
                        .distinct()
                        /* 转换成int*/
                        .map(Integer::new)
                        /* 对可用的表名求余数,获取到真实的表的后缀*/
                        .map(idSuffix -> idSuffix % availableTargetNames.size())
                         /*转换成string*/
                        .map(String::valueOf)
                        /* 获取到真实的表*/
                        .map(tableSuffix -> availableTargetNames.stream().
                                filter(targetName -> targetName.endsWith(tableSuffix)).findFirst().orElse(null))
                        .filter(Objects::nonNull)
                        /**
                         * 对于 SELECT * FROM order WHERE id = 1 AND member_id = 2
                         * 可能 id = 1 和 member_id = 2 不在一个分片上,这个 SQL 完全是有可能的,因为用户可能想要查询某个 order_id 是否存在自己的订单列表中
                         * 因此这里需要将结果收集为一个集合
                         */
                        .collect(Collectors.toList());
            }
        }
        /*处理类似between and 范围查询*/
        /**
         * 示例 SQL:SELECT * FROM order WHERE order_id BETWEEN 100, 200 AND member_id BETWEEN 200, 300
         */
        else if(!complexKeysShardingValue.getColumnNameAndRangeValuesMap().isEmpty()){
            log.info("[MyTableComplexKeysShardingAlgorithm] complexKeysShardingValue: [{}]", complexKeysShardingValue);
            Set<String> tableNameResultList = new LinkedHashSet<>();
            int tableSize = availableTargetNames.size();
            /* 提取范围查询的范围*/
            Range<String> rangeUserId = complexKeysShardingValue.getColumnNameAndRangeValuesMap().get(COLUMN_ORDER_SHARDING_KEY);
            Long lower = Long.valueOf(rangeUserId.lowerEndpoint());
            Long upper = Long.valueOf(rangeUserId.lowerEndpoint());
            /*根据order_sn选择表*/
            for (String tableNameItem : availableTargetNames) {
                if (tableNameItem.endsWith(String.valueOf(lower % (tableSize -1 )))
                        || tableNameItem.endsWith(String.valueOf(upper % (tableSize -1 )))) {
                    tableNameResultList.add(tableNameItem);
                }
                if (tableNameResultList.size() >= tableSize) {
                    return tableNameResultList;
                }
            }
            return tableNameResultList;
        }
        log.warn("无法处理分区,将进行全路由!!");
        return availableTargetNames;
    }

    private List<String> ids2String(Collection<?> ids) {
        List<String> result = new ArrayList<>(ids.size());
        for(Object id : ids){
            String strId = Objects.toString(id);
            String idFact = strId.length()==1 ? "0"+strId : strId;
            result.add(idFact);
        }
        return result;
    }
}

之后,需要在 yaml 配置文件中指定自定义的分片算法:

spring:
  shardingsphere:
    datasource:
      names: ds-master # 指定数据源
      ds-master:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://127.0。0.1:3306/order?serverTimezone=UTC&useSSL=false&useUnicode=true&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
        initialSize: 5
        minIdle: 10
        maxActive: 30
        validationQuery: SELECT 1 FROM DUAL
        username: tlmall
        password: tlmall123
    rules:
      sharding:
        tables:
          oms_order:
            actual-data-nodes: ds-master.oms_order_$->{0..31}
            table-strategy:
              complex:
                sharding-columns: id,member_id
                sharding-algorithm-name: oms_order_table_alg
        sharding-algorithms:
          oms_order_table_alg:
            type: CLASS_BASED
            props:
              # 指定自定义分片算法
              algorithmClassName: com.tuling.tulingmall.ordercurr.sharding.OmsOrderShardingAlgorithm
              strategy: COMPLEX
    props:
      sql-show: true

分库分表限制

一旦使用分库分表之后,在数据库查询功能上会有很多限制,很多单机数据库上可以执行的 SQL 查询在分布式数据库上无法执行

如果使用 ShardingSphere 作为分库分表中间件,可以查看官网对应使用规范:shardingsphere.apache.org/document/le…

这里列举部分不支持的 SQL:

SQL不支持原因
INSERT INTO tbl_name (col1, col2, …) VALUES(1+2, ?, …)VALUES语句不支持运算表达式
INSERT INTO tbl_name (col1, col2, …) SELECT col1, col2, … FROM tbl_name WHERE col3 = ?INSERT .. SELECT
SELECT COUNT(col1) as count_alias FROM tbl_name GROUP BY col1 HAVING count_alias > ?HAVING
SELECT * FROM tbl_name1 UNION SELECT * FROM tbl_name2UNION
SELECT * FROM tbl_name1 UNION ALL SELECT * FROM tbl_name2UNION ALL
SELECT * FROM ds.tbl_name1包含schema
SELECT SUM(DISTINCT col1), SUM(col1) FROM tbl_name详见DISTINCT支持情况详细说明
SELECT * FROM tbl_name WHERE to_date(create_time, ‘yyyy-mm-dd’) = ?会导致全路由

历史订单数据的归档

归档处理介绍

订单的数据量是很大的,数据量越大,数据库中 B+ 树索引的层级越深,查找数据需要的 IO 次数就越多,导致数据的查询速度很慢

因此通常会对订单数据进行分库分表,但是在分库分表中,由于数据量上升没有上限,随着业务地发展,数据量不断增加,DBA 的运维压力也不断增加,总不能一直扩张分库分表的数量(分库分表的扩容也是非常麻烦的),因此就需要考虑对历史数据进行 归档处理

这样可以保证当前数据的存储系统中不会存储大量数据,将大部分的历史数据转移到其他存储系统,来解决数据量不断增加所带来的压力

归档处理的优点:

通过将数据归档处理,可以将大量的历史数据归档在其他库表或者其他的存储系统中,由于老数据访问的频率不高,因此查询慢一点影响不大(例如,京东就对历史订单数据进行归档)

image-20241103132138562

归档处理改动成本:

将订单数据分为当前数据和历史数据,那么对于订单的读和写的业务逻辑也需要做修改,仔细分析之后,发现需要修改的业务逻辑并不多:

  • 对于订单的写操作,往往都是直接写往当前订单数据的,因此并不需要修改订单的写逻辑

  • 对于订单的查询操作,只需要根据时间范围来判断,是将查询落在当前数据的存储系统、或者落在历史数据的存储系统中

可以看到,整个归档处理中的代码改动成本较小

归档处理细节

历史订单的去处

对于历史订单数据,要归档起来,肯定需要从当前的存储系统转移到其他的存储系统,可以选择其他的 MySQL 数据库,也可以选择归档到 MongDB 数据库,这里是将历史数据归档到了 MongoDB 中去

为何选择 MongoDB?

这主要是基于 MongDB 的特性来选择的,如下:

  • 天然支持高可用

MySQL 高可用以来第三方组件实现;而 MongoDB 副本集内部多副本通过 raft 协议天然支持高可用,相比 MySQL 减少了第三方组件的依赖

  • 分布式数据库

MongoDB 是分布式数据库,可以解决海量数据存储的痛点,实现扩容过程业务无感知。使用之前不需要评估数据量来提前分库分表,MongoDB 对业务来说就是一个无限大的表

  • 高性能

MongoDB 在线程模型、并发控制、高性能存储引擎等方面做了很多细致化的优化

  • 节省存储成本

MongoDB 支持不同的压缩算法,对数据进行压缩,节省存储空间

  • 天然机房多活容灾支持

可以将节点部署在不同机房,来满足同城、异地多活容灾需求,实现

MongoDB 的选择:

如果业务场景符合下边的任意一个条件,可以考虑 MongoDB

功能特性
应用不需要编写复杂的 join 语句
新用户、需求会急增,数据增加迅猛,性能快速扩展
应用需要2000-3000以上的高并发QPS(国内也可以)
应用需要处理TB甚至 PB 级别数据存储
应用承载延迟,需要能快速水平扩展
应用要求存储的数据准不丢失
应用要求99.999%高可用
应用需要与国外的地理位置高同步,又本高同步

数据迁移 - 数据一致性

要对历史数据进行归档,需要将这些数据从当前存储系统删除,并且存储到 MongoDB 中,在数据迁移期间如何保证数据一致性?

数据迁移的过程是否需要分布式事务来保证呢?

数据的迁移分为两个步骤:

  • 写数据:将需要迁移的数据写入到 MongoDB 中
  • 删数据:将迁移的数据从原有的存储系统删除

只需要保证这两个步骤的数据一致性即可,如果同时写数据、删数据,那分布式事务是无法避免的,为了减少操作的复杂度,保证数据的一致性,在 MongoDB 内部执行本地事务:

  • 写数据:将需要迁移的数据写入到 MongoDB 汇总
  • 更新已迁移数据的最大 ID

这样通过本地事务记录已经迁移的订单数据的最大 ID,之后根据这个 ID 去 MySQL 中删除已经迁移走的数据即可

image-20241103144003810

数据迁移 - 删除数据

在将数据从 MySQL 迁移到 MongoDB 之后,还需要将数据从 MySQL 中删除,如何删除数据呢?

迁移数据是按照时间范围进行迁移的,但是删除数据根据 ID 来删除的话,速度比较快;

并且删除数据需要控制每次删除的数量,避免 删除数据的事务 运行时间过长,容易造成失败,且会影响其他查询事务的性能;并且尽量将删除数据的事务放在业务低峰期运行,避免占用系统资源,对线上其他业务造成影响

扩展:

长删除事务为什么要放在业务低峰期运行?

对于长删除事务来说,如果事务没有结束,被删除的数据都会在 undo log 版本链中插入一条数据,此时就会导致其他普通查询过慢,甚至出现慢查询,因此长删除事务尽量放在业务低峰期运行!