写点什么

PolarDB-X 最佳实践系列(四):如何设计一张订单表

  • 2024-01-26
    陕西
  • 本文字数:3681 字

    阅读完需:约 12 分钟

作者:梦实

文章来源:PolarDB-X 知乎号


往期最佳实践系列:

PolarDB-X最佳实践系列(一):如何设计一张用户表

PolarDB-X最佳实践系列(二):如何使用DataWorks将数据同步到MaxCompute

PolarDB-X最佳实践系列(三):如何实现高效的分页查询


本文主要内容是如何使用全局索引与 CO_HASH 分区算法(CO_HASH),实现高效的多维度查询。

淘宝订单号中的秘密

有一个很有趣的事情。

打开你的淘宝客户端或者 PC 端的淘宝,点开订单列表,打开几个订单,查看他们的订单号,你会发现什么?



比如这是我最近的 3 个订单,和 10 年前的 3 个订单。其订单号分别是:124951106536441481012388229886564148101236446127134414810103698817404810107655289504810103719620094810

也许你会惊奇的发现,订单号的后几位好像是一样的。比如我的账号,从 10 年前到今天,订单号的后四位一直是 4810,那么为什么?这个其实和使用分布式数据库的一个最佳实践相关。

经典的买卖家例子

好多年前就流传着淘宝买卖家的案例...

淘宝中有一个非常重要的表,订单表,他里面存着订单的一些关键信息,例如订单号(order_id)、卖家 id(seller_id)、买家 id(buyer_id)、商品 id 等等。有两类查询是这个表上的高频查询:

select * from orders where buyer_id = ?select * from orders where seller_id = ?
复制代码


这两个 SQL 的业务含义一目了然,分别是买家查询自己的订单列表和卖家查询自己的订单列表。

如果我们在单机数据库中做这两条 SQL,都知道怎么做。嗯,在 buyer_id 和 seller_id 上分别建个建索引就可以了:

create index idx_buyer_id on orders (buyer_id);create index idx_seller_id on orders (seller_id);
复制代码


在数据库中,空间换时间是一个非常基本的思路,例如加索引。

如果你是用一些分库分表中间件,例如 MyCAT 之类的产品,对这个表做了分库分表,就需要面临一个跟单机数据库完全不一样的一个问题,该如何选择分库分表键?

一般此类中间件都会告诉你,你哪个列查的最多,就选哪个列做拆分键。

但问题来了,这两类 SQL 都很高频,选了 buyer_id 做分库分表键,那按 seller_id 查就会全库全表扫描;如果按 seller_id 做分库分表键,那按 buyer_id 查就会全库全表扫描。


难道鱼和熊掌不可兼得?


一般解决这类的问题的方案是,使用两套订单表,其中一套使用 buyer_id 做分库分表建,另一套使用 seller_id 做分库分表键,中间使用 binlog 来做同步,类似下面的样子:



这个方案是 OK 的,能够落地的,只不过做的过程会有些小痛苦要解决,例如:


  1. 这个同步怎么搞啊...,用开源的 binlog 订阅组件比如 canal 吗?那这个 canal 怎么运维啊...好烦

  2. 这个同步是有延迟的,延迟代表了数据死不一致的,应用需要有一些容错机制来避免不一致带来的影响,好烦+1

  3. 需要在业务里自己控制应该访问哪个表,好烦+2

  4. 分库分表下面有很多的 mysql,要同时同步这么多的 mysql...,好烦+3

  5. 做 DDL 要有些技巧,比如加列先加目标端,减列先减源端...,很多的潜规则,好烦+4

  6. 这才是一张表呢!我有一堆类似的场景怎么办!!好烦+10086


我们先不管这些缺点,假设我们已经这样做到了,我们成功的解决了买卖家订单问题。

买卖家问题进阶

我们现在又有了一个新的要考虑的 SQL:

select * from orders where order_id = ?
复制代码


这个 SQL 作用太简单了,根据订单 id 查订单详情嘛!

为了做这个 SQL,单机里给 order_id 建索引即可,分库分表应该怎么做?


抢答一个!把订单表再复制一份,使用 order_id 做分库分表键。

听起来可以,应该能解决问题。但是,这个表多复制一份,就是多一份的代价,比如空间,比如同步链路的维护。所以,有没有更好的方法?


我们能关注到一个事情,订单 id,是由我们程序控制生成的。在生成订单的时候,我们一定知道它的买家 id,假如我们将买家 id 隐藏在订单 id 里,有了订单 id 就能算出买家 id,有了买家 id 就可以去查买家维度的订单表了。

这样,我们还是只需要存两份数据(买家维度和卖家维度),就能同时解决三个维度的查询(买家维度、卖家维度、订单维度)。

所以现在应该明白了,为什么同一个人的淘宝订单后 4 位是相同的了吧。


小花絮:


我发现我 11 年 7 月 10 的订单号还是 4810 结尾,但 11 年 6 月 28 号及之前的订单并没有遵循这个规律。呃...这说明,淘宝应该是在 11 年的 6.28-7.10 之间做了这个优化。

PolarDB-X 中如何实现

OK,回到我们的云原生分布式数据库 PolarDB-X。

如果我们在 PolarDB-X 中要解决上述买卖家问题,应该怎么做?

答案是,我们只需执行以下几条 SQL 即可:

create database ms1 mode=auto;use ms1;create table orders(    order_id varchar(128) primary key,    buyer_id varchar(128),    seller_id varchar(128),    index idx_buyer_id(buyer_id),    index idx_seller_id(seller_id))partition by hash(order_id);create clustered index gsi_buyer_id on orders (buyer_id) partition by hash(buyer_id);create clustered index gsi_seller_id on orders (seller_id) partition by hash(seller_id);
复制代码


然后?没有然后了啊,这就可以了。

真的可以了,你不用改 SQL,不用研究 Canal(其实 canal 作者就坐我旁边,要考虑下来玩玩吗),不用维护同步链路,不用担心数据不一致,就 O!K!了!

至于按订单 id 查?订单 id 本来就是 orders 的主键,默认就是 orders 表的分区键,所以没问题的。

这么简单就 OK 了?为什么?

PolarDB-X 中的全局索引

这两条语句发生了什么?实际上,他们在 orders 表上,创建了两条全局索引。全局索引和单机索引的原理差不多,也是空间换时间的思想,只不过它的数据以索引的 key 分布在整个集群中。

全局索引的创建、维护,都在 PolarDB-X 内核中完成的,完全不需要用户去操心。

有一个小问题,建索引的语句里面,有个 clustered 关键字,这是什么意思?


我们先看,如果不加 clustered,会发生什么,例如:

create global index gsi_buyer_id on orders (buyer_id) partition by hash(buyer_id);
复制代码


主表:


PolarDB-X 会定义这样的一个索引结构:


这个索引中,会包含索引的 key 以及主键两个列,也即 order_id 与 buyer_id。

PolarDB-X 在执行 select * from orders where buyer_id = ? 的时候,会先根据 buyer_id 在索引 idx_buyer_id 上扫描出 order_id,再使用 order_id 到主表上进行回表操作。

听起来好像没有什么问题。

但是,有一点需要考虑。请打开你的订单列表,看一下,你有多少订单:


呃...,我有 126 页订单,数了下,每页 15 个,也就是大约 1800 个订单。

淘宝的订单表的分区数大约是数千这个量级,你会发现,这 1800 个订单的回表,要覆盖相当比例的分区,似乎跟全表扫描的代价没有什么太大的差异了。


怎么办?


我们为什么要回表?其是是因为,我们的查询是 SELECT *,需要这个表所有的列,而我们的索引里只包含了索引 key 和主键,因此需要到主表中找到剩下的列。

所以为了不回表,我们想到的一个办法,是在索引表中冗余主表的所有列,用更多的时间来换取空间。

所以,一个合格的分布式数据库,不仅需要有全局索引,还需要有聚簇的(clustered)全局索引

Clustered index 就是 PolarDB-X 中的概念,它相对于普通的全局索引的区别就是,它包含了表的所有列,可以避免回表的代价。

PolarDB-X 中的 CO_HASH

如果如上文所说,订单 ID 的后四位与买家 ID 的后四位相同,如何使用 PolarDB-X 实现此类路由逻辑呢。

PolarDB-X 提供了名为 CO_HASH 的分区算法,可以完成这个功能:

create database ms1 mode=auto;use ms1;create table orders(    order_id varchar(128) primary key,    buyer_id varchar(128),    seller_id varchar(128),    index idx_buyer_id(buyer_id),    index idx_seller_id(seller_id))partition by co_hash(right(order_id,4), right(buyer_id,4));
create clustered index gsi_seller_id on orders (seller_id) partition by hash(seller_id);
select * from orders where buyer_id=? //主表select * from orders where order_id=? //主表select * from orders where seller_id=? //gsi_seller_id
复制代码


通过使用 CO_HASH,可以省略掉 buyer_id 上的全局索引。

CO_HASH 的更多用法参考:如何使用DDL语句创建分区表_云原生数据库 PolarDB(PolarDB)-阿里云帮助中心


CO_HASH 的核心理念是:

  • 有些信息来源用户的输入,例如用户系统的手机号、邮箱,订单系统中的 buyer_id、seller_id;

  • 有些信息是由“业务系统”生成的,例如用户系统中的 user_id,订单系统中的 order_id。


我们有时可以通过控制“业务系统”的生成逻辑,将其生成的内容与用户的输入关联起来,来达到降维的目的。


例如:

  • user_id 中可以携带手机号的某几位,或者携带邮箱的 hash 值的某几位;

  • order_id 可以携带 seller_id,或者 buyer_id 的某几位。


CO_HASH 是一种非常有用的小技巧,合理使用可以有效的减少 GSI 的数目。

但 CO_HASH 也不是万能的,它只能减少两种存在关联的维度中的一维,如果完全不存在关联,就必须使用 GSI 了。

小结

OK,总结几条这个例子告诉我们的 PolarDB-X 的最佳实践:


  1. 使用全局索引来解决类似买卖家问题的多维度查询的问题

  2. 当索引与主表是一对多的关系的时候,考虑使用 clustered index 来消灭回表的代价

  3. 对订单 ID 等做巧妙的设计,结合 CO_HASH,可以省略部分全局索引。


怎么样,PolarDB-X 用起来是不是非常简单,欢迎持续关注我们。

用户头像

还未添加个人签名 2022-01-10 加入

还未添加个人简介

评论

发布
暂无评论
PolarDB-X最佳实践系列(四):如何设计一张订单表_数据库_阿里云数据库开源_InfoQ写作社区