支付系统:MySQL 分片

支付系统:MySQL 分片
Page content

I was working at a payment service team from 2019-2021. The blog is based on my experience at that time.

在支付系统中,数据库则是最容易产生性能瓶颈的组件。

在本文中,我们将讨论支付服务是如何在大规模下对MySQL存储的数据进行分库分表,以满足我们业务不断增长的流量需求。

背景

MySQL概况

stateDiagram state "Master DB" as mdb state "Slave DBs" as sdbs state "Payment Web Servers" as WS state "Redis" as cache [*] --> WS WS --> sdbs: Reads WS --> mdb: Writes WS --> cache: Reads state sdbs { S1: Slave DB 1 S2: Slave DB 2 S3: Slave DB 3 } note left of mdb: Master DB will be bottleneck

数据量

我们使用MySQL来存储关键数据,如交易流水、支付单、和支付渠道等数据。 多年来,MySQL的使用量在多个维度上都有所增长,包括表的数量、表的大小、每秒读取的查询量(QPS)、每秒写入的量TPS等。

存储在MySQL中的数据大小在数十TB的数量级,这还不包括副本。 我们每秒的查询量在数十万的数量级。顺便说一下,我们还在ES中存储了大量其他数据,用于批量查询。 如果MySQL运行缓慢或无响应,支付服务将受到严重影响。

缓存

为了提高性能,我们使用了Redis缓存。Redis有助降低数据库读取QPS是我们使用缓存系统的一个重要原因。 虽然添加缓存有助于应对我们不断增长的读取量,但数据大小和写入(TPS)的增长仍促使我们不得不对MySQL进行分片。

读写分离

我们的数据库采用了一主三从,读写分离

读写分离基本原理是让主数据库处理事务性增、改、删(INSERT、UPDATE、DELETE)操作,而从数据库专门负责处理查询(SELECT)操作。在数据库的后台会把事务性操作导致的主数据库中的数据变更同步到集群中的从数据库。

采用读写分离的方式,拓展了数据库对数据读的处理能力,整体上也大大提升了数据库的读写能力。 但这样的架构在主数据库的数据写入能力依然没法扩展,一旦数据库写压力比较大时,则对整个平台带来非常大的影响。而且数据库单表的数据量是有限制的,当单表数据量达到一定数量后数据库性能会出现显著下降的情况。

目标

随着交易量的持续上涨,支付业务量较大的地区 MySQL 集群的磁盘空间用量已突破 70% (大概7TB),剩余空间只能支撑很短的一段时间,仅通过日常的旧数据打包与数据压缩操作已经没有办法支持集群磁盘空间的未来使用了。 由于MySQL单库的数据体积暴涨,因此数据库进行分库势在必行。

我们期望新的Sharding方案可以:

  • 承载未来较长一段时间的数据存储需求。很多时候,数据库这一层的改造很难实施,风险极大;重新调整Sharding方案也是让 DBA 和业务研发人员非常痛苦的一件事情。
  • 稳定可靠。对核心业务链路上的项目而言,稳定性是最重要的。
  • 保留合理的可扩展性。

核心数据表

在说明具体方案前,我们先来看看支付系统中的核心数据表。在分库分表之前,这些表都在一个DB实例里面。

我们重点关注transaction_tab和paymet_tab两个表的分片。

交易流水表 - transaction_tab

  • transaction_tab 中包含了交易相关的数据,关联上游的产品线订单。
  • transaction_id 包含了分片规则;可以算出数据位于哪个逻辑数据库,哪个表中。
  • 通过uk_biz_order_no_biz_id保证幂等,防止上游一笔订单发起多次交易
  • 上游产品线可通过user_id & biz_order_no & biz_id查询到transaction
CREATE TABLE `transaction_tab_0000` (
  `transaction_id` bigint(20) unsigned NOT NULL, -- "{version:01}{userid:04}{seq:014}"
  `transaction_type` tinyint(3) unsigned NOT NULL,
  `transaction_status` tinyint(3) unsigned NOT NULL,
  `biz_id` int(11) unsigned NOT NULL, -- 产品线id
  `biz_order_no` varchar(64) DEFAULT NULL, -- 产品线订单号
  `user_id` bigint(20) unsigned NOT NULL,
  `amount` bigint(20) NOT NULL,
  `create_time` int(11) unsigned NOT NULL,
  `update_time` int(11) unsigned NOT NULL,
  `ext_data` JSON,
  PRIMARY KEY (`transaction_id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_create_time` (`create_time`),
  UNIQUE KEY `uk_biz_order_no_biz_id` (`biz_order_no_biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

支付表 - payment_tab

  • payment_tab 中包含了支付相关的数据,关联下游的支付渠道。
  • payment_id 也包含了分片规则;可以算出数据位于哪个逻辑数据库,哪个表中。
  • 一笔交易理论上可以有多笔支付。例如amount=100的交易,可以是微信支付50,Alipay支付50;联合起来完成100交易的支付。
CREATE TABLE `payment_tab_0000` (
  `payment_id` bigint(20) unsigned NOT NULL, -- "{version:01}{transaction_id:04}{seq:014}"
  `transaction_id` bigint(20) unsigned NOT NULL, -- transaction_db.transaction_id
  `payment_type` tinyint(3) unsigned NOT NULL, -- 支付类型 payment|refund
  `amount` bigint(20) unsigned NOT NULL, -- 金额
  `payment_status` tinyint(3) unsigned NOT NULL, -- 支付状态 init|success|fail|pending
  `channel_id` int(11) unsigned NOT NULL, -- 支付渠道的id
  `channel_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, -- 支付渠道的单号
  `channel_order_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, -- 渠道侧支付状态
  `create_time` int(11) unsigned NOT NULL,
  `update_time` int(11) unsigned NOT NULL,
  `ext_data` JSON,
  PRIMARY KEY (`payment_id`),
  KEY `idx_transaction_id` (`transaction_id`),
  KEY `idx_channel_order_no` (`channel_order_no`),
  KEY `idx_update_time` (`update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

支付索引表 - payment_index_tab

  • payment_index_tab是一个异构索引表,支持根据channel_order_no查询payment_id/transaction_id的数据。
  • 下游支付渠道callback的时候,可根据channel_order_no查询到关联的payment,然后进行更新。
CREATE TABLE `payment_index_tab` (
  `payment_id` bigint(20) NOT NULL PRIMARY KEY,
  `transaction_id` bigint(20) unsigned NOT NULL, 
  `channel_id` int(11) unsigned NOT NULL,
  `channel_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, -- shard key
  PRIMARY KEY (`payment_id`),
  KEY `idx_transaction_id` (`transaction_id`),
  UNIQUE KEY `idx_channel_order_no_channel_id` (`channel_order_no`, `channel_id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci

基本方案

Payment Service中有两个核心的DB: transaction_dbpayment_db

最开始,这两个DB在一个MySQL实例上。所有的写操作都落到一个Master DB。

我们首先把这两个DB进行了分库,transaction_dbpayment_db使用各自的MySQL实例。

随后,我们进行水平分片,把transaction_db 一分为10个逻辑DB,payment_db一分为10个逻辑DB。

  • transaction_db -> transaction_db_[00-09]
  • payment_db -> payment_db_[00-09]

最终实现通过增加数据库实例节点,系统性能得以线性提升。

垂直拆分

最开始是1拆2:从单MySQL实例到2个MySQL实例。 transaction_dbpayment_db使用独立的MySQL实例,这一步相对简单,只需要调整配置文件中的db settings。

垂直分片也意味着放弃了我们一部分的事务功能。

如果两个表需要在MySQL中JOIN,它们需要位于同一分区内。 在代码库中强烈不鼓励在MySQL内部进行JOIN,以便我们在选择要移动哪些表以进行扩展时有更多自由。 我们基本没有使用MySQL中JOIN;往往是通过多次查询,在业务代码里面来实现JOIN的功能。

水平拆分

基本思路

当出现单个表的数据量很大的情况,则需要采用水平分区的方式对数据进行拆分,即将同一个表中的不同数据拆分到不同的数据库中。

不管是采用何种分库分表方案,其核心的思路都是将原本保存在单表中太大的数据进行拆分,将这些数据分散保存到多个数据库的多个表中,避免因为单表数据太大给数据的访问带来读写性能的问题。

所以在分库分表场景下,最重要的一个原则就是被拆分的数据尽可能的平均拆分到后端的数据库中。 如果拆分得不均匀,导致某个DB的数据特别多,还会产生数据访问热点,同样存在热点数据因为增长过快而又面临数据单表数据过大的问题。

而对于数据以什么样的维度进行拆分,大家看到很多场景中都是对业务数据的ID(大部分场景此ID是以自增的方式)进行哈希取模的方式将数据进行平均拆分,这个简单的方式确实在很多场景下都是非常合适的拆分方法,但并不是在所有的场景中这样拆分的方式都是最优选择。

数据如何拆分更多的是需要结合业务数据的结构和业务场景来决定。

单单对数据进行拆分的操作本身不复杂,但在很多实际的业务场景中,不可避免会出现跨库的表JOIN、事务操作,以及数据的统计、排序等情况,而且数据进行了拆分后,对于数据库的运维管控也提出了更高的要求。

在这一部分中,我们将讨论在实现水平分片时所做的关键决策以及关键组件的设计。具体的点将包括:

  • 基于范围的分片 vs 基于哈希的分片
  • 分片键(Shard Key)列和分片数量
  • 逻辑数据库级别分片 vs 表级别分片
  • 访问分片表的API
  • 如何降低全表扫描频率
  • 从未分片的表迁移到分片表
  • 如果一个分片变得非常大该怎么办

基于范围的分片 vs 基于哈希的分片

当一个逻辑表被拆分成多个物理表时,一个重要的问题是如何将原始表中的数据分配到新的分片中。 两个流行的选择是基于范围的分片和基于哈希的分片。

两种方案做一下对比:

  • transaction_tabuser_id% base进行分库分表

    • 某些用户的交易比较多,导致数据分布不均。
    • 查询某一时间段之内的交易就比较麻烦,需要扫描全部的1000个表,才能取到全部的数据。
  • transaction_tab也可采用基于范围的分片,

    • 一种常见的方式是:每个表存1000w行数据,分表的规则为 (trx_id/1000w); 表名可以从transaction_tab_000, 001, … 依次增加。
    • 好处是数据分布均衡,旧数据打包和移动比较方便
    • 新增的数据落在一个DB上面,没有办法充分利用多数据库实例,依然是单分片在工作,热点依然异常集中。
    • 查询单个用户的交易列表不方便,需要依赖一个辅助的索引表来避免扫描全部的表。

基于范围的分片算法在分布式数据库架构中,是一种不理想的算法。 因为对于分布式架构,通常希望能解决传统单实例数据库两个痛点:

  • 性能可扩展,通过增加数据库实例,性能和负载可以线性提升;
  • 存储容量可扩展,通过增加数据库实例,解决单点存储容量的数据瓶颈。

那么如果交易表按范围分库,无论按1000w的行数,还是按时间范围,或者日期范围分片,以上两点都无法实现,因为实时写入的数据依然落在在一个分片上(即热点还是存在于一个数据库实例上)。

而对海量并发的 OLTP 业务来说,一般推荐用 HASH 的分区算法。这样分片的每个节点都可以有实时的读写,每个节点负载都能相对平衡,从而实现性能和存储层的线性可扩展。

我们在wallet子系统中有一段时间采用了这种基于范围的分片方式,后面也切换到了hash分片。 在支付系统中,我们最开始采用的就是hash分片。

对于payment_tab,我们按 transaction_id % base 进行分库分表

  • payment数据就分的比较均匀
  • 查询某段时间的交易数据可能也需要依赖辅助索引表,或者全表扫描。

分片列和数量

在对表中的数据进行分片时,首先要选出一个分片键(Shard Key),即用户可以通过这个字段进行数据的水平拆分。创建多少个分片的问题与分片表的大小密切相关,一般我们希望单表的数据不要超过2kw行。

如果分片列是一个自增列,且按范围分片,那么表的大部分写入将出现在“最后”一个分片中,分表的数量随着自增列增加而增加。 如果分片列按hash分片,分片总数固定,那么表的所有分片大小都可能随着时间的推移而增长。

在Payment Service中,我们采用的分片规则如下:

  • transaction_tab 我们按照 transaction_tab.user_id % 1000 进行hash取模的方式,这样就可以保证用户数据在分库分表后,同一个用户的交易数据都保存在同一个逻辑数据库中。
  • payment_tab 根据 payment_tab.transaction_id % 1000 分1000个表。

当我们按user_id%1000分1000个表时;这样单个表的数据,单表随着时间增长最终可能突破2kw,并导致慢查询。为了避免在线交易数据库的数据的增大带来数据库性能问题,可以将12个月内的交易数据保存进在线交易数据库中,超过12个月的交易会归档到后端专门的归档数据库。

逻辑数据库级别分片 vs 表级别分片

在Payment Service,我们两种都有采用。

假设有1亿用户,支付的交易数据随时间累积可能到100亿以上,MySQL单个表,甚至单个实例都放不下这么多数据。 对用户数据按照userID进行hash取模的方式实现用户数据平均分布在10个数据库中,确保了单个数据库中保存的数据量在单机数据库能提供良好读写性能的范围之内。

transaction_tab, 我们按(user_id % 1000)/100分10个逻辑库,这10个逻辑库可分布在1-10个MySQL实例上,可根据实际情况给MySQL扩容。user_id % 1000分1000个表,每个逻辑库放100个表;这样单个表的数据可以控制在1kw以内,MySQL就可以很好支撑了。

访问分片表的API

如果API参数携带有sharding key (user_id|trx_id):

  • 例如:user_id = 123456, 数据位于 transaction_db_04 and transaction_tab_0456. DB查询对应的SQL:
-- user_id 包含分片信息  
SELECT *
  FROM transaction_db_04.transaction_tab_0456 
  WHERE user_id = 123456 AND ...
ORDER BY create_time
LIMIT 10
  • 例如:transaction_id = 2-3456-00000000001234, 数据位于 transaction_db_04 and transaction_tab_0456. DB查询对应的SQL:
-- transaction_id 包含分片信息  
SELECT *
  FROM transaction_db_04.transaction_tab_0456 
  WHERE transaction_id = 2-3456-00000000001234

如果API参数携没有user_id/transaction_id/payment_id;这种情况比较麻烦,使用非分片列进行查找的查询可能需要检查多个分片。有两种方式改进查询:

  • 借助索引表来辅助查询;比如payment_index_tab可以用来帮助查询payment
  • 如果索引表也不能用,扫描全部的1000个表,才能取到数据。系统设计时应避免这种情况出现。

如何降低全表扫描频率

payment_tab按照transaction_id取模分库分表,虽然很好地满足了交易数据均匀地保存在后端数据库中。 但在支付渠道回调更新payment的业务场景中,因为渠道侧往往没有user_id 或者 payment_id;因此回调接口的参数没有Shard Key; 可能就出现需要全表扫描的情况,而且这种回调更新是很频繁的,必然给数据库带来扩展或性能的问题。

针对这类场景问题,最常用的是采用异构索引表的方式解决,即采用异步机制将原表内的每一次创建或更新,都换另一个维度保存一份完整的数据表或索引表。

为了减少检查多个分片所带来的性能损失,我们创建了一个payment_index_tab索引表来辅助查询。 也就是应用在创建或更新一条按照交易transaction_id为分库分表键的payment数据时,也会再保存一份按照payment_id & transaction_id & channel_order_no 的索引数据。

本质上是:“拿空间换时间”。

erDiagram transaction_tab { int user_id int transaction_id int biz_id int create_time int update_time int amount int transaction_status } transaction_tab ||--o{ payment_tab : places payment_tab { int transaction_id int payment_id int channel_id int create_time int update_time int amount int payment_status } payment_tab ||--|{ payment_index_tab : contains transaction_tab ||--o{ payment_index_tab : places payment_index_tab { int payment_id int transaction_id int channel_order_no }

比如我们需要根据channel_order_no查询一个payment,可以通过payment_index_tab来查询,而不需要扫描全部的1000个表。

-- step 1
select payment_id from payment_index_tab where channel_order_no = x;

-- setp 2
select * from payment_tab where payment_id = y;

对于常见的电商业务,卖家也需要查看交易。为了避免seller查看自己的交易时频繁进行全表扫描,实际中还可以用seller_id的维度进行异构索引表的建立,所以采用这样数据全复制的方法会带来大量的数据冗余,从而增加不少数据库存储成本。

通常为了节省DB成本,建议采用仅仅做异构索引表,而不是数据全复制,同时采用两次SQL请求的方式解决出现全表扫描的问题。

实现对数据的异步索引创建有多种实现方式,一种是从数据库层采用数据复制的方式实现;另一种是在应用层实现,在这一层实现异构索引数据的创建,就必然会带来分布式事务的问题。

为了确保索引能构建成功,2种方式我们都有采用。

从未分片的表迁移到分片表

通常在数据库扩容时,我们需要将原有数据进行迁移,让其平均分布在各个新数据库中。

transaction_db迁移扩容

基于我们的分库分表方案,transaction_db数据库的逻辑扩容需要迁移数据。

10个transaction_db,每个逻辑DB可以放100个table,需要DBA把1000表的数据切分并迁移到10个逻辑DB中。

  • transaction_db_00 holds tab_[0000-0099]
  • transaction_db_01 holds tab_[0100-0199]
  • transaction_db_09 holds tab_[0900-0999]

payment_db无迁移扩容

基于我们的分库分表方案,payment_db数据库的逻辑扩容无需进行数据迁移。

payment_db 数据库数量从 1 个扩至 10 个逻辑DB为例,通过修改 transaction_id 规则(让版本号+1),让新交易根据 % 10求路由,老交易继续使用原来的DB,那么每个数据库的数据增长速度为总交易量增速的 1/10,是原来的 10%。

  • v1: 1-3456-00000000001234 -> payment_db
  • v2: 2-3456-00000000001235 % 10 -> payment_db_05

随着老数据不断归档,原有的数据库中数据量会逐步从总交易量的 100% 降低至 1/10,而新增的 9 个数据库数据量也会从 0 增长至 1/10。

通过这种无数据迁移(Rebalance)的扩容,可以避免实施双写方案的复杂性和不确定性,且手动操作和人工检查双写正确性的环节更少;代价则是无法在扩容后马上降低原有数据库的压力,因为仍有老交易读写落在老数据库中,压力需要随时间缓慢降低。

我们可以综合这些因素,预留足够多的时间进行扩容、接受缓慢的数据重平衡。

如果一个分片变得非常大该怎么办

最后但同样重要的问题是,如果一个分片变得过大该如何处理。分割一个分片比最初的表分片基本一样。

实际生产环境中,因为可以打包旧数据,我们并没有真的遇到单个分片过大的瓶颈。

小结和展望

目前,我们已经对数个大型生产表进行了分库分表。 对于大表分片有关的挑战,我们一直在寻找简化和优化其他更更好的方法。我们正在考虑将一些使用MySQL的场景迁移到TiDB和ES上。我们在MySQL上实现的分片表大部分场景应该可用TiDB取代。

数据库分片设计没有十全十美的方案,每一次的重构调整背后都是综合考量和权衡取舍的结果。