这段时间面试,遇到了一道和实际生产相关的面试题:
现有一系统,积分使用
MongoDB
存储,点券存储在MySQL
。用户通过日常任务获取积分,点券则可以提现。
现推出一活动 1000 积分兑换 10 点券,怎么实现?
和缓存一致性的不同
对于这种跨数据库之间的操作,可能会误认为类似「如何实现 MySQL
和 Redis
的数据一致性?」,但两者之间其实很不同,Redis
在这种场景一般用于充当远程缓存或分布式缓存数据库,其本质上是将数据从支持持久化但慢速的磁盘中,搬到断电丢失但高速的内存中。也就是说,在实现 MySQL
、Redis
数据一致性时,我们操作的仍是同一份数据,考虑得比较多的是避免用户读取到脏数据(旧数据)。
而题目的要求其实是:如何解决分布式数据库在双写时的数据一致性?
分布式事务
面试的时候,笔者联想到了跨行转账,跨行转账业务有三种情况:
- 相同支行下的转账:同一支行内的转账(本地事务)
- 不同支行下的转账:相同银行,不同支行下的转账 (分布式事务)
- 跨行转账:和其他银行系统进行转账 (分布式事务)
假设银行以支行为最小单位,进行数据库部署
而这道题很像场景二,比如「用户A 是工行深圳支行的用户,用户B 是工行广州支行的用户,A向B转账100元」,虽然都是 A 和 B 都是工行用户,但是 A 和 B 的数据并不在同一个数据库,那么就需要两个数据库之间的进行数据交换,这时候无法通过本地数据库的事务实现 ACID,一般通过分布式事务解决的。
查询确认事务结果
场景二下,可以认为在工行这个大系统,内部有由深圳支行和广州支行的这样微服务组成,而微服务之间的业务流转相对简单一些:
- 深圳支行扣除用户A 的100元。
- 深圳支行通知广州支行,用户A要转账 100元 给用户B。
- 深圳支行定时向广州支行查询是否收到了转账,如果失败了那就回滚。
具体流程大概是这样:
- 深圳支行的用户A 发起转账请求,开启事务
- 深圳支行创建转账订单,记录用户A 的支出和转账前后点券,且状态设置为「支出中」
- 深圳支行通知广州支行,用户A 向 用户B 转账,广州支行收到通知后:
- 开启事务
- 创建转账订单,记录用户B 的收入和转账前后点券,且状态设置为「收入中」
- 更新订单状态,并返回转账结果给深圳支行
- 深圳支行收到广州支行的回复:
- 成功:转账订单状态更新为「成功」,提交事务
- 失败:转账订单状态更新为「失败」,回滚事务
%% 时序图 [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 为主
面试官表示分布式事务是更通用的解决方法,如果用到 MySQL
和 MongoDB
都是本地数据库这个条件,这题可以有更好的处理方法。面试结束后,伟大的互联网告诉笔者确实如此。
MySQL
存储点券,MongoDB
存储积分,很明显点券是核心数据,那么需要以 MySQL
为主,所以MySQL
中应该有一个字段 mongo_id
用于关联 MongoDB
的主键 _id
,而查询 MongoDB
存储的积分,只能通过 MySQL
里 mongo_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),实现 MySQL
与 MongoDB
的双写一致性:
- 开启
MySQL
事务,避免并发问题 - 先在
MongoDB
插入修改后的数据,而不是去更新MongoDB
- 再更新
MySQL
的mongo_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
系统 -->> - 用户: 兑换完成
在这个流程中,无论什么时候写入出错,都不会影响到数据的一致性。
- 如果在
MongoDB
插入新数据时出错,MySQL
中保存的仍为老数据。 - 如果在
MySQL
更新时出错,MySQL
中保存的仍为老数据。
不过这种方案会带来一个问题,MongoDB
会新增一条无效的垃圾数据,解决方法有两种:
- 异步删除。通过带有重试机制的消息队列,直到垃圾数据被删除。
- 定时器删除。通过定时器,查询出近段时间垃圾数据,并做删除。