如何实现本地 MySQL 和 MongoDB 双写时数据一致性

这段时间面试,遇到了一道和实际生产相关的面试题:

现有一系统,积分使用 MongoDB 存储,点券存储在 MySQL

用户通过日常任务获取积分,点券则可以提现。

现推出一活动 1000 积分兑换 10 点券,怎么实现?

和缓存一致性的不同

对于这种跨数据库之间的操作,可能会误认为类似「如何实现 MySQLRedis 的数据一致性?」,但两者之间其实很不同,Redis 在这种场景一般用于充当远程缓存或分布式缓存数据库,其本质上是将数据从支持持久化但慢速的磁盘中,搬到断电丢失但高速的内存中。也就是说,在实现 MySQLRedis 数据一致性时,我们操作的仍是同一份数据,考虑得比较多的是避免用户读取到脏数据(旧数据)。

而题目的要求其实是:如何解决分布式数据库在双写时的数据一致性?

分布式事务

面试的时候,笔者联想到了跨行转账,跨行转账业务有三种情况:

  1. 相同支行下的转账:同一支行内的转账(本地事务)
  2. 不同支行下的转账:相同银行,不同支行下的转账 (分布式事务)
  3. 跨行转账:和其他银行系统进行转账 (分布式事务)

假设银行以支行为最小单位,进行数据库部署

而这道题很像场景二,比如「用户A 是工行深圳支行的用户,用户B 是工行广州支行的用户,A向B转账100元」,虽然都是 A 和 B 都是工行用户,但是 A 和 B 的数据并不在同一个数据库,那么就需要两个数据库之间的进行数据交换,这时候无法通过本地数据库的事务实现 ACID,一般通过分布式事务解决的。

查询确认事务结果

场景二下,可以认为在工行这个大系统,内部有由深圳支行和广州支行的这样微服务组成,而微服务之间的业务流转相对简单一些:

  1. 深圳支行扣除用户A 的100元。
  2. 深圳支行通知广州支行,用户A要转账 100元 给用户B。
  3. 深圳支行定时向广州支行查询是否收到了转账,如果失败了那就回滚。

具体流程大概是这样:

  1. 深圳支行的用户A 发起转账请求,开启事务
  2. 深圳支行创建转账订单,记录用户A 的支出和转账前后点券,且状态设置为「支出中」
  3. 深圳支行通知广州支行,用户A 向 用户B 转账,广州支行收到通知后:
    1. 开启事务
    2. 创建转账订单,记录用户B 的收入和转账前后点券,且状态设置为「收入中」
    3. 更新订单状态,并返回转账结果给深圳支行
  4. 深圳支行收到广州支行的回复:
    • 成功:转账订单状态更新为「成功」,提交事务
    • 失败:转账订单状态更新为「失败」,回滚事务
%% 时序图 [Markdown 进阶技能:用代码画时序图](https://zhuanlan.zhihu.com/p/70261692)
%% ->>实线箭头 代表主动发出消息;-->虚线代表响应;末尾带「X」代表异步消息,无需等待回应。
sequenceDiagram
    participant 用户A
    participant 深圳支行
    participant 广州支行
    participant 用户B
    用户A ->> + 深圳支行: 向用户B 转账
    深圳支行 ->> 深圳支行: 创建转账订单,状态 PAYOUT
    深圳支行 ->> 深圳支行: 事务 BEGIN
    深圳支行 ->> + 广州支行: A向B转账
    广州支行 ->> 用户B: 执行转账操作
    用户B -->> 广州支行: 转账结果
    alt 转账成功
  		用户B -->> 广州支行: 订单状态 SUCC
  	else 转账失败
  		用户B -->> 广州支行: 订单状态 FAIL
    end    
    广州支行 -->> - 深圳支行: 转账结果
    alt 转账成功
  		深圳支行 -->> 深圳支行: 订单状态 SUCC
  		深圳支行 -->> 深圳支行: 事务 COMMIT
  	else 转账失败
  		深圳支行 -->> 深圳支行: 订单状态 FAIL
  		深圳支行 -->> 深圳支行: 事务 ROLLBAK
    end
    深圳支行 -->> - 用户A: 收到转账结果
  

在上述流程是同步的,用户需要在转账界面等待转账结果,如果转账耗时过久,会影响到用户体验。可以将转账结果改成异步事件。

在用户提交转账请求后,返回提示:「转账请求已提交,请稍后查看转账结果」,用户就可以先浏览其他页面,等待转账结果的推送。

%% 时序图 [Markdown 进阶技能:用代码画时序图](https://zhuanlan.zhihu.com/p/70261692)
%% ->>实线箭头 代表主动发出消息;-->虚线代表响应;末尾带「X」代表异步消息,无需等待回应。
sequenceDiagram
    participant 用户A
    participant 深圳支行
    participant 广州支行
    participant 用户B
    用户A ->> + 深圳支行: 向用户B转账
    深圳支行 ->> 深圳支行: 创建转账订单,状态 PAYOUT
    深圳支行 -->> - 用户A: 稍后查看转账结果
    深圳支行 ->> + 深圳支行: 事务 BEGIN
    深圳支行 ->> 广州支行: A向B转账
    广州支行 ->> 用户B: 执行转账操作
    用户B -->> 广州支行: 转账结果
    
	loop 定时查询
        深圳支行 ->> 广州支行: 转账订单状态
        广州支行 -->> 深圳支行: 转账订单结果
    end
    
    alt 转账成功
  		深圳支行 -->> 深圳支行: 订单状态 SUCC
  		深圳支行 -->> 深圳支行: 事务 COMMIT
  	else 转账失败
  		深圳支行 -->> 深圳支行: 订单状态 FAIL
  		深圳支行 -->> - 深圳支行: 事务 ROLLBACK
    end
    深圳支行 ->> 用户A: 转账结果  

二阶段提交协议

2PC 与 MySQL单机中的 2PL 有相似点,都有两个阶段,但适用的目标是不一样的,不能混淆。

  • 二阶段加锁协议(Two-Phase Locking, 2PL):一种用于管理数据库事务并发控制的协议,主要目的是防止多个事务同时修改同一数据项,以避免数据不一致的问题,实现可序列化的隔离等级。
  • 二阶段提交协议(Two-Phase Commit, 2PC) :一种用于实现分布式系统中的原子性操作的协议,确保所有的事务参与者要么全部提交,要么全部回滚,从而保证分布式事务的完整性。

2PC 的基本流程如下:

2PC的相关内容,见设计密集型应用(Designing Data-Intensive Applications, DDIA)「第九章:一致性与共识」,笔者就不赘述了。

依赖关系:MySQL 为主

面试官表示分布式事务是更通用的解决方法,如果用到 MySQLMongoDB 都是本地数据库这个条件,这题可以有更好的处理方法。面试结束后,伟大的互联网告诉笔者确实如此。

MySQL 存储点券,MongoDB 存储积分,很明显点券是核心数据,那么需要以 MySQL 为主,所以MySQL 中应该有一个字段 mongo_id 用于关联 MongoDB 的主键 _id,而查询 MongoDB 存储的积分,只能通过 MySQLmongo_id 字段。

假设 MySQL 表结构如下:

CREATE TABLE wallet (
    user_id INT PRIMARY KEY COMMENT 'userId',
    balance DECIMAL(10, 0) DEFAULT NULL COMMENT '点券',
    mongo_id VARCHAR(64) NOT NULL COMMENT 'MongoDB 主键id'
);

MongoDB 文档结构如下:

{
    "_id": "661bcb98cd3500008c007b5c",
    "score": 200
}

主要思想是借鉴预写日志(Write Ahead Log, WAL),实现 MySQLMongoDB 的双写一致性:

  1. 开启 MySQL 事务,避免并发问题
  2. 先在 MongoDB 插入修改后的数据,而不是去更新 MongoDB
  3. 再更新 MySQLmongo_id

具体流程如下:

%% 时序图 [Markdown 进阶技能:用代码画时序图](https://zhuanlan.zhihu.com/p/70261692)
%% ->>实线箭头 代表主动发出消息;-->虚线代表响应;末尾带「X」代表异步消息,无需等待回应。
sequenceDiagram
    participant 用户
    participant 系统
    participant MySQL
    participant MongoDB
    用户 ->> + 系统: 积分兑换点券
    系统 ->> MySQL: 事务 BEGIN
    系统 ->> MySQL: 查询 user_id
    MySQL -->> 系统: 返回 blance, mongo_id
    系统 ->> MongoDB: 查询 mongo_id
    MongoDB -->> 系统: 返回 document
    系统 ->> 系统: document.score -= 100
    系统 ->> MongoDB: 插入修改后的 document
    MongoDB -->> 系统: 返回 mongo_id
    系统 ->> MySQL: 更新 blance, mongo_id
    MySQL -->> 系统: 更新成功
    MySQL -->> 系统: 事务 COMMIT
    系统 -->> - 用户: 兑换完成

  

在这个流程中,无论什么时候写入出错,都不会影响到数据的一致性。

  1. 如果在 MongoDB 插入新数据时出错,MySQL 中保存的仍为老数据。
  2. 如果在 MySQL 更新时出错,MySQL 中保存的仍为老数据。

不过这种方案会带来一个问题,MongoDB 会新增一条无效的垃圾数据,解决方法有两种:

  1. 异步删除。通过带有重试机制的消息队列,直到垃圾数据被删除。
  2. 定时器删除。通过定时器,查询出近段时间垃圾数据,并做删除。

参考资料

发表了8篇文章 · 总计7.29k字