Shopify 的基础设施支撑着数百万商家的创业历程。在本文记录的架构中,一组 MySQL 数据库分片共同保存着每家商店的关键数据,是基础设施的重要组成部分。随着流量模式变化、新商家不断加入,资源消耗较大的商店可能集中在同一分片,使不同分片的数据库利用率、商店流量和负载失去平衡。维持良好的平衡有助于降低数据库故障风险、提高整个基础设施的利用效率,并让买家始终能够访问喜爱的商店。本文介绍 Shopify 如何通过跨分片迁移商店来平衡 MySQL 分片:整个过程在线进行,消费者几乎感受不到停机。
Shopify 架构概览
要理解分片再平衡,先简要了解应用架构。Shopify 当时的应用运行环境由多个 pod 组成,这里的 pod 不要与 Kubernetes Pod 混淆。每个 pod 都是一个相互隔离的 Shopify 实例,包含独立的 MySQL 数据库分片,以及 Redis、Memcached 等其他数据存储。每个 pod 承载平台中一组互不重复的商店。商店的 Web 请求先由负载均衡器处理;负载均衡器查询路由表,根据商店将请求转发给正确的 pod。

coolcucumbers.com 的请求被转发至 Pod 42;该 pod 包含为 Cool Cucumbers 提供服务所需的全部数据。这种运行环境由分片数据库拓扑支撑:每个 pod 对应自己的分片。Shopify 的数据模型很适合这一拓扑,因为多数数据模型都能通过商店这一实体确定归属。可以给所有属于商店的表添加 shop_id,并将其作为分片键。把商店从一个分片迁到另一个分片,就是从所有表中选出具有目标 shop_id 的记录,将其复制到另一个 MySQL 分片。理解本文时,可以把每个 pod 看作一个 MySQL 分片。
制定分片再平衡策略
新商家注册并加入平台时,会被分配到某个分片。随着时间推移,商家发展成不同规模。一些资源消耗较大的商店可能落在同一分片,使部分分片的数据库使用量较高,另一些则较低。这种差异会从两方面削弱基础设施:高流量分片可能过载,故障风险更大;数据库使用量较低的分片则未被充分利用。

要让负载更均匀,就需要重新平衡分片。这里所说的平衡,是所有 pod 都保持健康,且其中的分片得到有效利用。为此,需要一种能够持续调整商店分布的策略。
制定这项策略时,有两个问题需要解决:
- 哪些商店应该位于哪些分片?
- 如何以尽可能少的停机时间,把商店从一个分片迁移到另一个分片?
哪些商店应该位于哪些分片?
仅按商店数量分配并不是好办法,因为不同商店的数据规模并不相同。此前采用过的一种策略,是分析各分片的历史数据库利用率和流量数据,再根据使用模式分组,例如 high_traffic、low_traffic 等。随后,按照某种规则在这些组之间迁移合适的商店,例如把高流量分片中每隔 N 家选出的商店迁到低流量分片。先模拟这些候选迁移,再根据预测效果验证假设。

这种策略有效,但并非唯一选择。放置策略可以很复杂,也可以优先优化不同指标,例如商店规模、商品交易总额(GMV)、迁移耗时、限时抢购活动等。通常会提出多个假设,并使用近期数据进行验证。确定理想的商店分布后,再生成一份迁移清单,使系统达到期望状态。
商店如何迁移?
明确商店应放在哪些分片之后,就可以开始迁移。把商店从源分片迁到目标分片可能涉及许多步骤,尤其需要满足以下约束。
- 可用性:迁移必须完全在线,不能让商家和整个平台遭遇可感知的停机。数据从源分片移向目标分片时,商店前台必须仍可访问和交互。
- 数据完整性:迁移期间不能丢失或损坏数据。既要复制迁移开始时已经存在的全部数据,也要复制迁移开始后发生在源数据库上的所有写入。
- 吞吐量:跨分片搬运数据必须及时,并保持合理吞吐量。商店大小不一,同时迁移多家商店不应给基础设施带来过重负担。
下面用一个虚构场景说明。Paarth 的 Peppy Peppers 和 Xiaoli 的 Xylophones 是平台上的两家高流量商店,目前都位于 Pod 1。数据科学与工程团队发现,把两家店放在同一个 pod 并不理想:数据库利用率极高,而且两家的突发流量会同时出现——它们似乎总在同一天进行限时抢购。团队建议把 Peppy Peppers 迁到 Pod 2。接下来逐步说明整个迁移过程。
迁移前,终端用户的 Web 请求大致沿着下面的路径流转。

一次商店迁移可以划分为三个主要阶段:
- 批量复制,并持续跟踪 MySQL 二进制日志(binlog)。
- 进入切换阶段(cutover)。
- 更新控制平面、恢复流量,并清理旧数据。
第一阶段:批量复制并跟踪 binlog
数据迁移使用 Ghostferry。它是 Shopify 内部开发并开源的工具,用来把数据从一个 MySQL 实例复制到另一个实例,最初源于 Shopify 迁往云端时的需求。Ghostferry 通过两个主要组件完成复制:批量复制,以及持续跟踪 binlog。

批量复制时,Ghostferry 遍历源实例上的一组表,根据商店 ID 选择相关行,再将这些行写入目标实例;每批写入都在自己的 MySQL 事务中完成。把一批行写入目标实例时,必须确保源实例上的同一批行没有同时发生变化,否则可能损坏数据。Ghostferry 使用 MySQL 的 SELECT...FOR UPDATE 实现这一点。
SELECT...FOR UPDATE 是一种锁定读取方式:事务持续期间,源实例中被选中的行会被加上写锁。Ghostferry 借此维护数据正确性,保证整个“先读再写”事务的原子性。因为源数据保持不变,它可以安全地在目标实例上提交这些行,避免潜在竞争条件引起的数据损坏。

SELECT FROM orders WHERE shop_id = 1 FOR UPDATE ,随后将选出的记录插入 Pod 2。与此同时,Ghostferry 使用 MySQL 的 binlog 跟踪源实例上发生的变化,并在目标实例上重放。binlog 记录描述数据库变化的事件,因此可以作为数据库变更的事实依据。启用基于行的复制后,binlog 包含对数据库行执行的各项操作。Ghostferry 持续读取这些变化,只保留与目标商店有关的事件,并将其应用到目标数据库。

shop_id = 2)有关的事件会被过滤;保留下来的变化则应用或重放到 Pod 2。为了提高吞吐量,Ghostferry 可以并发工作,用不同线程同时复制多个表的数据。复制在后台进行,不干扰商店运营。因此在这个阶段,Peppy Peppers 的前台仍然可用,流量继续由 Pod 1 处理。
第二阶段:进入切换
批量复制完成后,迁移开始时属于 Peppy Peppers 的全部数据都已经位于 Pod 2 的 MySQL 分片中。此时 Ghostferry 继续复制新写入,确保它们也被同步到 Pod 2。

当等待重放的 binlog 事件队列足够小时,Ghostferry 会进入切换阶段。这里的“足够小”,是指新产生的事件与正在重放的事件之间仅有秒级差距,基本实现实时同步。
进入切换后,必须停止对源数据库的写入,确保不再产生新的 binlog 事件。Ghostferry 此时把源实例最后的 binlog 位置记作停止位置,继续处理队列中的剩余事件,直到抵达这个位置。到这里,复制才被视为完成。

避免数据丢失是整个再平衡策略的一项关键约束。为了安全切换,必须确保所有可能修改商店数据的工作单元——即 Web 请求和作业——都不再运行。为此,系统使用应用层的多读单写锁(MRSW)。这种由 Redis 支撑的锁,让商店迁移器能够获得对某家商店的独占控制权。
在商店迁移开始之前,所有归属于该商店的工作单元,都必须持有 MRSW 锁的读锁,也就是共享锁。只要没有人持有写锁,也就是独占锁,就可以有任意数量的请求持有读锁。无法归属于某个具体商店的作业,则必须持有类似的全局锁。要进入切换阶段,商店迁移器会等待读锁释放,再获取写锁,从而保证该 pod 上不会继续执行这家商店的写入。如果无法及时获得写锁,本次迁移就会失败。
第三阶段:更新控制平面、恢复流量并清理旧数据
确认没有数据丢失后,商店迁移器更新控制平面。路由表中的映射被改为商店的新 pod;这份配置保存在一个独立、未分片的数据库中。

路由表更新完成后,商店迁移器立即释放独占锁,让工作单元继续执行并恢复写入,只是这次写入的是新的 pod。整个过程中可能产生停机的窗口只有切换阶段。由于可用性至关重要,切换被设计成尽可能短的过程。
现在,商店已经由新 pod 提供服务,但旧 pod 仍保留着这家商店的数据。系统随后开始验证,确认迁移按预期完成,而且旧 pod 上没有出现新的写入。验证包括:在商店成功迁移后的一段时间内,确保没有查询再被路由到原来的源分片。只有确认迁移成功后,才会清除旧 pod 上过时的数据。

至此,Peppy Peppers 完成了从 Pod 1 到 Pod 2 的迁移。平台的数据库利用率更加均衡,两家高流量商店也在 pod 层面实现了隔离。
高风险工作:验证与正确性
实时、在线的数据迁移是一项有风险的工作。前面介绍了应用运行环境和数据库方面的一些要求,也梳理了 Ghostferry 的主要阶段:批量复制、跟踪 binlog、执行切换,以及更新控制平面。但 Ghostferry 的并发支持、中断后恢复迁移等更复杂的功能还没有展开。功能越多,确保代码始终正确就越重要。因此,Shopify 特别重视正确性、安全性和验证,以提高对迁移过程的信心。
Ghostferry 及其外围系统包含一套验证器,会在迁移前、迁移中和迁移后运行。验证关注数据是否损坏、传输是否完整,以及数据的真实性。此外,Ghostferry 的核心算法已经被建模,并以形式化规格表示,用于分析其正确性;这份规格使用 TLA+ 编写。
把商店从一个分片迁到另一个分片,需要围绕庞大且相互关联的系统制定工程方案。灵活迁移商店的能力,让 Shopify 能为商家提供稳定、均衡的基础设施。商家依靠平台经营生计,因此平台必须保持坚实可靠;高可信度的分片再平衡,就是实现这一目标的一种方式。
作者与补充资料
原文作者 Paarth Madan 在文章发表时是 Dev Degree 实习生,正在 Rails Infrastructure 团队工作。他于 2018 年加入 Shopify,此后两年参与 Shop 应用的后端(Ruby on Rails)和移动端(React Native)开发;文章发表前的八个月,他在数据库工程团队工作,并逐渐对数据库、云基础设施和多租户产生浓厚兴趣。
原文还介绍了数据库工程团队的开发经理 Xiaoli Liang。她致力于通过自动化的数据放置、组织与开发工具,打造可扩展、稳健、高效的数据库平台。原文当时附有 Lead/Staff Production Engineer 的远程招聘信息,并介绍了 Shopify 的 Digital by Default 工作方式;这是 2021 年文章中的历史信息,不表示相关职位今天仍在招聘。











暂无评论内容