关注公众号「Shopee技术团队」,探索更多Shopee技术实践
目录
1. 背景
2. ShopeePay 原始架构
3. 不同机房架构下的单元化方式
4. 基于数据复制的异地多活不可能定律
5. ShopeePay 数据模型分析
6. 实际实施的双活模型
7. 对业务场景的影响
8. 其他复杂问题ShopeePay 是 SeaMoney 旗下的数字钱包服务,为线上和线下的商家提供便利的收款与结算能力,为各行业的消费者提供支付与转账服务。作为具有金融属性的业务,ShopeePay 对业务连续性和数据安全性的要求都要高于普通互联网业务,而数据中心的可用性是其中最重要的部分。
谈起数据中心,传统的 Active-Standby 方式在灾难切换时天然会造成服务的不连续,不能满足 ShopeePay 未来发展的要求,数据中心多活是必须要考虑的。
而谈起数据中心多活,这几乎是架构层面最难啃的一块骨头。支付是典型的冰山模型,表面上是一个简单的支付动作,实际上背后从收单,到支付、结算、营销、风控、账户余额操作、对账都极为复杂。
多活不仅要将核心数据拆分到多个数据中心,还要确保重要的支付场景能够跨多个数据中心正常运行。不仅底层组件需要改造,部分业务逻辑也需要适配,从下到上都需要改动,同时还会涉及到多团队协作。总而言之,这是一个庞大而复杂的工程。

上图中,数据层就写操作来说是 Active-Standby 的模式。每个 DB 集群都是一主多从,两个集群之间使用 MySQL 原生复制。这种架构在机房故障时需要人工执行数据库主备切换,且需要预留一些空闲资源以便承接对端流量。
但其问题在于“确定 IDC0 故障且短期无法恢复”这个前提可能需要至少耗费 20 分钟时间,然后才开始执行切换,而执行 DB 切换也需要耗费数分钟时间。
如果 DB 之间是异步复制,还要先通过远程事务日志锁定异常账户,然后才能切换。整个执行下来,耗费的时间可能是几十分钟甚至数小时。在缺少例行演练的情况下,实际决策将面临“不切是等死,切了是找死”的困境。
所以,如果不是地震火灾等极端情况,一般不会启动切换,因为切换所承担的成本和风险可能大于原地恢复(可参考国内外各大银行对数据中心故障的处理方式)。
因此,从数据中心技术的长期演进方向来看,必须要考虑将鸡蛋(数据)放到多个篮子里面,即单元化拆分。确保任何一个单元的故障只会影响局部,不会造成整体业务瘫痪,而局部故障的切换成本和风险也会减少很多。
单元化就是实现数据中心多活的基本方案。

相互将 slave 数据库集群放在对方 IDC,故障时,后端 DB 切换,前端改流量指向,资源利用率约 50%(留一半承接对方流量)。

相互将 slave 数据库集群放在临近的 IDC,故障时,后端 DB 切换,前端改流量指向,需要留 30% 左右的资源冗余。



支付场景中,跨数据库的事务不可避免(比如转账)。如果访问数据库的延时过大,用户体验将非常糟糕。
同样,基于数据复制的数据库容灾也会受限于延时问题(无论是主备复制还是多数派复制),而远距离条件下光纤网络的延时问题是个基础物理学问题,现在还无法解决。除非有一天我们有更快的数据传输方式,或者业务场景变了,不再需要跨库的数据访问。
假设将“异地”定义为 1000km 以上,不算沿途交换机存储转发延时,仅计算光速的 RTT 就是 6.6ms,比同城 RTT 高一个数量级(北京到上海约 1000km,实际 RTT 40ms)。所以,异地的高延迟会对很多场景的一致性带来非常大的挑战,业界在这方面也有一定的实践,但仍然属于一个非常困难的问题。
对于 ShopeePay 来说,基于现阶段的实际情况,我们主要考虑同城双活的问题。
多活的核心是数据多活,ShopeePay 场景的数据可划分为如下几类:
| 数据分类 | 说明 | 处理方式 |
|---|---|---|
| 用户维度 | 用户账户余额 | 按 user ID 单元化拆分 |
| 公共信息 | 商户基础信息,读多写少 | 1 写 N 读 |
| 订单维度 | 可能按商户单号分表和查询 可能按平台单号分表和查询 | 按 order ID 单元化拆分(要将 unit ID 编码到 order ID) |
| 其它无法区分维度的 | 同公共信息,不做单元化 |
每个数据分类都有各自的处理方法,并按固定的方式完成拆分。

如图模型,任意单元不可用,只会影响一半用户,不需要人工介入。但从初始的一个单元变成两个单元,需要对部分用户维度数据做搬迁,这是一项危险而繁杂的工作。
针对这个场景,ShopeePay 在用户维度路由表之外额外增加了表维度路由,这样对于同一个用户来说,数据的搬迁也就变成了一个逐步灰度的过程,这就确保了搬迁过程灰度可控。同时,数据备份、结果校验以及对 DB 操作日志的常态化分析,进一步降低了搬迁的风险。
对于路由表,我们可以使用单一用户为主键来构建,即每一个用户都有一个唯一的路由指向。优点是简单方便,实施风险小,并且实际也只需要存储 unit1 的用户。缺点是新增用户没有路由,只能默认新增到 unit0,这就需要定期在单元之间搬迁调平。
而如果使用哈希/取模等规则划分数据集来构建路由表,除了难以获取数据集中的具体用户外,搬迁过程中的新增用户也会导致数据集变化,需要针对读写构建非常复杂的路由规则或者补充搬迁的机制,这显然过于复杂。最终用户维度路由表数据模型如下:


公共信息只写主单元 unit0,异步复制到其它所有单元提供本地读取。例如商户类别、结算银行、结算费率、结算周期,这些数据在支付时只需要读取使用,并不需要实时写入。
订单是指下单这个动作所涉及到的一系列相关的数据结构,是资金流的原始凭据。一个典型的支付模型如下:

多活架构中任意 unit 都可以支持下单,但需要将 unit ID 编码到 order ID 以便确定订单归属。
为确保下单高可用,需要引入跳单逻辑:unit0 下单失败,重新生成 order ID,换 unit1 下单,反之亦然。
最终只要有一个 unit 存活,就可以完成下单动作。当然,下单之后通过何种方式支付以及是否可以支付成功就是后续的事情了。

订单只是一个支付行为的状态机凭证,初始未支付的订单不一定有用户属性。理论上任何单元都可以接受商户订单,但如果商户使用同一个商户单号同时下单到多个单元,而支付平台又不具备跨单元的全局幂等能力,则可能造成重复下单,尤其是机房故障、商户重试、单元之间流量转移的瞬间。
也就是说,商户下单实际是按 at least once 原则,而重复订单可能会造成重复支付或重复扣款。如果不能 100% 排重,则必须考虑对账兜底和退款的措施。如果要 100% 排重,对于同城多活来说必须有基于三机房的强一致 KV 存储。对于异地多活来说则无法实现,只能尽可能快地同步订单映射表到其它单元,尽最大可能排重。
解决这个问题的另一个思路是在 wallet(用户资金账户)根据商户 ID + 商户订单号做幂等防重,这样即使有重复订单,也只有第一笔单可以操作资金账户,但也仅限于操作 wallet 资金账户的订单,并且需要全链路存储和转发商户单号,对 ShopeePay 来说,实施的成本偏高,收益有限。
为了以较低的成本解决此问题,ShopeePay 开发了 Global Cache 组件,实现两个单元之间的数据共享,进而解决跨单元下单的全局幂等问题。但由于 cache 天然不可靠,其一致性无法保证,少数情况下可能造成幂等失效,所以必须增加对账修复机制来兜底,在业务逻辑层面消除重复订单的影响。


用于查询用户的 unit 地址。由于查询比较频繁,实际设计会在 DB 之上增加 Route Cache 层来保护 DB,并在 DAL 和 Gateway 内部增加 local cache,同时外部访问者可以短时间内 cache 这个结果。
理论上最小搬迁单位是一个用户,但实际上用户维度会涉及到多张表,对某个用户的实际搬迁会按表逐个进行,并修改基于表的子路由。用户所有表搬迁完成后,修改用户主路由。
流量入口,对外提供用户的 unit 地址查询,提供错误流量的重定向。但这些不是必须的,最终所有错误流量都会被 DAL 兜底,路由到正确位置。
双活模型下,为了实现用户维度数据的分布,我们需要将 50% 的用户搬迁到 unit1。搬迁过程需要安全地锁定数据,安全地修改 Unit Route DB,安全地更新所有 cache,并设计回滚策略。
由于是明确按用户搬迁,新增的用户没有路由,默认只能创建到 unit0,待累积一定数量的新用户后,就需要搬迁调平,根据新增用户的速度计算,这个频率可能是 1-2 次/年。
解析目标表的 SQL 语句得到 user ID,查询 Route Cache 获得用户单元地址,发起数据库访问。对上可以屏蔽用户的 unit 分布。
实际上用户可以随意在单元之间搬迁,业务模块无感知。用户维度的流量无论从哪个单元进入,都会被 DAL 路由到正确单元的数据库中。当然,这期间必然会涉及到跨 IDC 的访问。
实现两个单元之间的数据共享,主要解决两个问题:
理论上商户可以找任意可用的 unit 下单,但实际上如果商户尽可能将与用户关联的订单下到用户所在的 unit,则一方面可以提升性能(避免跨 IDC 操作账户),另一方面可以在单元故障时尽可能保证存活用户的使用体验不受影响(比如针对原订单的退款操作)。
为了支持这种场景,Gateway 提供了根据 user ID 查询 unit ID 的服务。另外,如果商户只能使用一个域名与支付平台交互,则这个域名必须具备 IP 容灾的能力,即在 unit0 入口 IP 故障时将商户流量引入 unit1 的入口 IP,类似于 GSLB。如果是内部商户,则可以识别两个 unit,并在两个 unit 之间采用更加快速的流量调度策略。
由于 ShopeePay 单元之间的 MySQL 主从同步仍然使用异步复制,必须增加远程事务日志才能确保数据安全。

如果没有第三方机房可用,则必须相互在对方机房部署远程事务日志。这样即使 slave 并未追平 master,只要锁定了可能异常的资金账户,就可以执行切换并放开流量。但同时要避免远程事务日志的可用性影响到核心账户操作的可用性。
实际部署中,由于受到双机房的限制,我们会在每个机房部署至少三个远程日志服务提供给对端机房。同时增加熔断策略,即单位滑动窗口时间内 write log 失败率过高时,对执行失败或超时的 write log 进行忽略。
实际上,多活并不能保证所有的业务场景都不受损,比如结算、营销、查询用户订单列表这些场景可能无法使用。但我们只要保证最核心的场景就可以了。
对于 ShopeePay 来说,最核心的场景是如下几个:Payment(线上支付),Scan(C scan B 扫码),Pay(B scan C 被扫),Top up(充值)等。
其中,Top up 被列入是因为东南亚人民习惯于支付前才充值,所以其业务价值等价于支付。而其余的结算、营销、查询用户订单列表可以等故障恢复后再使用。
另外,ShopeePay 的内部 service 也需要针对多活架构做出调整,确保上述场景涉及的数据表和访问模式能够在多活架构下工作(不涉及的数据表无需改动):

其他还涉及到一些业务改造工作,比如通过 DAL 访问用户账户 DB;消灭跨单元数据读取场景;单点 ID 生成器变成多点 ID 生成器,订单 ID 的结构改造等等。
DAL 是其中最复杂的组件,需要解决诸多问题:
除此之外,还有其它诸多困难问题,包括外部 URL 回调,对端单元的缓存数据预热(写触发、读触发),跨单元重复下单的兜底措施,跨单元搬迁的数据校验,这其中的每一个问题都非常复杂。
ShopeePay 多活之路已经启程,我们目前完成了所有组件的开发工作,并启动了业务场景的改造,各项落地工作也在逐步展开。
本文作者
Chaoyu,来自 ShopeePay 团队。