支付系统:数据库拆分

在电商和支付系统中,数据库则是最容易产生性能瓶颈的组件。
本章详细介绍如何基于数据库分库分表的方式,利用分布式数据库解决数据库瓶颈问题,包括在进行数据库改造的过程中如何进行数据分库分表设计的最佳实践。
读写分离
数据库的读写分离。 读写分离基本原理是让主数据库处理事务性增、改、删(INSERT、UPDATE、DELETE)操作,而从数据库专门负责处理查询(SELECT)操作。 在数据库的后台会把事务性操作导致的主数据库中的数据变更同步到集群中的从数据库。
采用读写分离的方式,拓展了数据库对数据读的处理能力,整体上也大大提升了数据库的读写能力。
但这样的架构在主数据库的数据写入能力依然没法扩展,一旦数据库写压力比较大时,则对整个平台带来非常大的影响。 而且数据库单表的数据量是有限制的,当单表数据量达到一定数量后数据库性能会出现显著下降的情况。
分库分表 - DB Sharding
水平分区
当出现单个表的数据量很大的情况,则需要采用水平分区的方式对数据进行拆分,即将同一个表中的不同数据拆分到不同的数据库中。
假设有1亿用户,支付的订单数据可能在10亿以上,MySQL单个表,甚至单个实例都放不下这么多数据。 如果按
user_id%10
分10个库,user_id%1000
分1000个表;这样单个表的数据可以控制在1kw以内,MySQL就可以支撑了。
对用户数据按照用户ID进行hash取模的方式实现用户数据平均分布在10个数据库中,确保了单个数据库中保存的数据量在单机数据库能提供良好读写性能的范围之内。
单单对数据进行拆分的操作本身不复杂,但在很多实际的业务场景中,不可避免会出现跨库的表join、事务操作,以及数据的统计、排序等情况,而且数据进行了拆分后,对于数据库的运维管控也提出了更高的要求。
分库和分表的方案
Notion采用的方案:
After crunching the numbers, we settled on an architecture consisting of 480 logical shards evenly distributed across 32 physical databases. Why 480 shards?
The point is, 480 is divisible by a lot of numbers — which provides flexibility to add or remove physical hosts while preserving uniform shard distribution. For example, in the future we could scale from 32 to 40 to 48 hosts, making incremental jumps each time.
By contrast, suppose we had 512 logical shards. The factors of 512 are all powers of 2, meaning we’d jump from 32 to 64 hosts if we wanted to keep the shards even. Any power of 2 would require us to double the number of physical hosts to upscale. Pick values with a lot of factors!

Notion DB Sharding
Shopee LCS 无迁移扩容
通常在数据库扩容时,我们需要将原有数据进行迁移,让其平均分布在各个新数据库中。但有了归档基础后,我们可以发现在预留充足时间的情况下,数据库的逻辑扩容无需进行数据迁移。
以数据库数量从 16 个扩至 64 个为例,通过修改订单 ID 规则,让新订单根据 % 64求路由,老订单继续使用 % 16,那么每个数据库的数据增长速度为总订单量增速的 1/64,是原来 1/16 的 25%。
在增速减缓,归档迁出速度不变的情况下,原有的 16 个数据库中数据量会逐步从总订单量的 1/16 降低至 1/64,而新增的 48 个数据库数据量也会从 0 增长至 1/64。
通过这种无数据迁移(Rebalance)的扩容,可以避免实施双写方案的复杂性和不确定性,且手动操作和人工检查双写正确性的环节更少;代价则是无法在扩容后马上降低原有数据库的压力,因为仍有大量老订单读写落在老数据库中,压力需要随时间缓慢降低。在监控机制完善且业务增长有一定规律的情况下,我们可以综合这些因素,预留足够多的时间进行扩容、接受缓慢的数据重平衡。
ShopeePay 双写扩容
TODO
数据尽可能平均拆分
不管是采用何种分库分表框架或平台,其核心的思路都是将原本保存在单表中太大的数据进行拆分,将这些数据分散保存到多个数据库的多个表中,避免因为单表数据太大给数据的访问带来读写性能的问题。
所以在分库分表场景下,最重要的一个原则就是被拆分的数据尽可能的平均拆分到后端的数据库中。
如果拆分得不均匀,导致某个DB的数据特别多,还会产生数据访问热点,同样存在热点数据因为增长过快而又面临数据单表数据过大的问题。
而对于数据以什么样的维度进行拆分,大家看到很多场景中都是对业务数据的ID(大部分场景此ID是以自增的方式)进行哈希取模的方式将数据进行平均拆分,这个简单的方式确实在很多场景下都是非常合适的拆分方法,但并不是在所有的场景中这样拆分的方式都是最优选择。 数据如何拆分更多的是需要结合业务数据的结构和业务场景来决定。
- 如果按user_id%x进行分库分表
- 查询某一时间段之内的订单就比较麻烦,需要扫描全部的1000个表,才能取到全部的数据。
- user_id(123) 转账给 user_id(456)就不好搞,涉及跨DB的交易,不能在一个DB事务中完成。
- 某些用户的交易比较多,导致数据分布不均。
- 如果按payment_id%x进行分库分表
- 数据就分的比较均匀。
- 查询某个user_id的订单数据,查询某段时间的订单数据需要全表扫描。
如何降低全表扫描频率
按照payment_id取模分库分表,虽然很好地满足了订单数据均匀地保存在后端数据库中。 但在用户查看自己订单的业务场景中,就出现了全表扫描的情况,而且用户查看自己订单的请求是非常频繁的,必然给数据库带来扩展或性能的问题。
针对这类场景问题,最常用的是采用“异构索引表”的方式解决,即采用异步机制将原表内的每一次创建或更新,都换另一个维度保存一份完整的数据表或索引表。
也就是应用在创建或更新一条按照订单payment_id为分库分表键的订单数据时,也会再保存一份按照user_id为分库分表键的订单索引数据。 本质上是:“拿空间换时间”。
对于常见的电商业务,卖家也需要查看订单。 为了避免seller查看自己的订单时频繁进行全表扫描,实际中还可以用seller_id的维度进行异构索引表的建立,所以采用这样数据全复制的方法会带来大量的数据冗余,从而增加不少数据库存储成本。 通常为了节省DB成本,建议采用仅仅做异构索引表,而不是数据全复制,同时采用两次SQL请求的方式解决出现全表扫描的问题。
数据归档 - Data Archive
为了避免在线交易数据库的数据的增大带来数据库性能问题,可以将6个月内的订单数据保存进在线交易数据库中,超过6个月的订单会归档到后端专门的归档数据库。
基于binlog的数据同步
通常,binlog数据单线程处理比较安全; 在对binlog数据进行多线程并行处理后,就不能保证在源数据库中执行的SQL语句在目标数据库的顺序一致,这样在某些业务场景中一定会出现数据不一致性的问题。