Skip to content

Commit

Permalink
分布式事务
Browse files Browse the repository at this point in the history
  • Loading branch information
studeyang committed Aug 11, 2024
1 parent 46f9f75 commit 9e5a0d7
Showing 1 changed file with 202 additions and 0 deletions.
202 changes: 202 additions & 0 deletions C类/C09-数据存储/后端存储实战.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,208 @@ MySQL、Redis、ElasticSearch、HBase、Hive、MongoDB、RocksDB、CockroachDB

在海量数据篇,我们重点解决高并发、海量数据情况下的存储系统该如何设计的问题。比如,海量的埋点数据该怎么存储;如何在各种数据库之前实时迁移和同步海量数据,等等。

# ==创业篇==

# 01 | 创建和更新订单时,如何保证数据准确无误?





# 02 | 流量大、数据多的商品详情页系统该如何设计?

商品系统的存储:

![image-20240810102603160](https://technotes.oss-cn-shenzhen.aliyuncs.com/2024/202408101026240.png)



# 03 | 复杂而又重要的购物车系统,应该如何设计?

购物车系统的功能,主要的就三个:把商品加入购物车(后文称“加购”)、购物车列表
页、发起结算下单,再加上一个在所有界面都要显示的购物车小图标。

1. 用户没登录,在浏览器中加购,关闭浏览器再打开,刚才加购的商品还在不在?
2. 用户没登录,在浏览器中加购,然后登录,刚才加购的商品还在不在?
3. 关闭浏览器再打开,上一步加购的商品在不在?
4. 再打开手机,用相同的用户登录,第二步加购的商品还在不在呢?

**如何设计“暂存购物车”的存储?**

购物车的数据格式:

```json
{
"cart": [
{
"SKUID": 8888,
"timestamp": 1578721136,
"count": 1,
"selected": true
},
{
"SKUID": 6666,
"timestamp": 1578721138,
"count": 2,
"selected": false
}
]
}
```

**如何设计“用户购物车”的存储?**

![image-20240810210133817](https://technotes.oss-cn-shenzhen.aliyuncs.com/2024/202408102101863.png)

# 04 | 事务:账户余额总是对不上账,怎么办?

**为什么总是对不上账?**

对不上账的原因非常多,比如业务变化、人为修改了数据、系统之间数据交换失败等等。那
作为系统的设计者,我们只关注“如何避免由于技术原因导致的对不上账”就可以了,有哪
些是因为技术原因导致的呢?比如说:网络请求错误,服务器宕机、系统 Bug 等。

那从技术上,如何保证账户系统中流水和余额数据一致呢?

**使用数据库事务来保证数据一致性**

我们需要在实现交易功能的时候,同时记录流水并修改余额,并且要尽可能保证,在任何情况下,记录流水和修改余额这两个操作,要么都成功,要么都失败。

首先,它可以保证,记录流水和更新余额这两个操作,要么都成功,要么都失败。不会出现,只更新了一个表而另一个表没更新的情况。这是事务的原子性(Atomic)。

事务还可以保证,数据库中的数据总是从一个一致性状态(888 流水不存在,余额是 100元)转换到另外一个一致性状态(888 流水存在,余额是 200 元)。对于其他事务来说,不存在任何中间状态(888 流水存在,但余额是 100 元)。这是事务的一致性 (Consistency)。

数据库为了实现一致性,必须保证每个事务的执行过程中,中间状态对其他事务是不可见的。比如说我们在事务 A 中,写入了 888 这条流水,但是还没有提交事务,那在其他事务中,都不应该读到 888 这条流水记录。这是事务的隔离性 (Isolation)。

最后,只要事务提交成功,数据一定会被持久化到磁盘中,后续即使发生数据库宕机,也不会改变事务的结果。这是事务的持久性 (Durability)。

**理解事务的隔离级别**

我来简单说一下“幻读”。在实际业务中,很少能遇到幻读,即使遇到,也基本不会影响到数据准确性,所以你简单了解一下即可。

在 RR 隔离级别下,我们开启一个事务,之后直到这个事务结束,在这个事务内其他事务对数据的更新是不可见的。

比如我们在会话 A 中开启一个事务,准备插入一条 ID 为 1000 的流水记录。查询一下当前流水,不存在 ID 为 1000 的记录,可以安全地插入数据。

```sql
mysql> -- 会话 A
mysql> select log_id from account_log where log_id = 1000;
Empty set (0.00 sec)
```

这时候,另外一个会话抢先插入了这条 ID 为 1000 的流水记录。

```sql
mysql> -- 会话 B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into account_log values
-> (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
Query OK, 1 row affected (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.00 sec)
```

然后会话 A 再执行相同的插入语句时,就会报主键冲突错误,但是由于事务的隔离性,它执行查询的时候,却查不到这条 ID 为 1000 的流水,就像出现了“幻觉”一样,这就是幻读。

```sql
mysql> -- 会话 A
mysql> insert into account_log values
-> (1000, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
ERROR 1062 (23000): Duplicate entry '1000' for key 'account_log.PRIMARY'

mysql> select log_id from account_log where log_id = 1000;
Empty set (0.00 sec)
```

# 05 | 分布式事务:如何保证多个系统间的数据是一致的?

**到底什么是分布式事务?**

要解决分布式一致性问题,你必须掌握几种分布式事务的实现原理。

我们知道即使是数据库事务,它考虑到性能的因素,大部分情况下不能也不需要百分之百地实现 ACID,所以才有了事务四种隔离级别。

理论上,分布式事务也是事务,也需要遵从 ACID 四个特性,但实际情况是,在分布式系统中,因为必须兼顾性能和高可用,所以是不可能完全满足 ACID 的。我们常用的几种分布式事务的实现方法,都是“残血版”的事务,而且相比数据库事务,更加的“残血”。

分布式事务的解决方案有很多,比如:2PC、3PC、TCC、Saga 和本地消息表等等。这些方法,它的强项和弱项都不一样,适用的场景也不一样。其中,2PC 和本地消息表这两种分布式事务的解决方案,比较贴近于我们日常开发的业务系统。

**2PC:订单与优惠券的数据一致性问题**

2PC 也叫二阶段提交,是一种常用的分布式事务实现方法。我们用订单和优惠券的例子来说明一下。在我们购物下单时,如果使用了优惠券,订单系统和优惠券系统都要更新自己的数据,才能完成“在订单中使用优惠券”这个操作。

所谓的二阶段指的是准备阶段和提交阶段。在准备阶段,协调者分别给订单系统和促销系统发送“准备”命令,订单和促销系统收到准备命令之后,开始执行准备操作。准备阶段都需要做哪些事儿呢?你可以理解为,除了提交数据库事务以外的所有工作,都要在准备阶段完成。

“准备”工作完成后,订单系统和优惠券系统给事务协调者返回“准备成功”。协调者在收到两个系统“准备成功”的响应之后,开始进入第二阶段。

等两个系统都准备好了之后,进入提交阶段。提交阶段就比较简单了,协调者再给这两个系统发送“提交”命令,每个系统提交自己的数据库事务,然后给协调者返回“提交成功”响应,协调者收到所有响应之后,给客户端返回成功响应,整个分布式事务就结束了。

![image-20240811223154176](https://technotes.oss-cn-shenzhen.aliyuncs.com/2024/202408112235128.png)

这是正常情况,接下来才是重点:异常情况下怎么办?

我们还是分两个阶段来说明。在准备阶段,如果任何一步出现错误或者是超时,协调者就会给两个系统发送“回滚事务”请求。每个系统在收到请求之后,回滚自己的数据库事务。以下是异常情况的时序图:

![image-20240811223346708](https://technotes.oss-cn-shenzhen.aliyuncs.com/2024/202408112235823.png)

如果准备阶段成功,进入提交阶段,这个时候整个分布式事务只能成功,不能失败。

如果发生网络传输失败的情况,需要反复重试,直到提交成功为止。

如果这个阶段发生宕机,包括两个数据库宕机或者订单服务、促销服务所在的节点宕机,还是有可能出现订单库完成了提交,但促销库因为宕机自动回滚,导致数据不一致的情况。但是,因为提交的过程非常简单,执行很快,出现这种情况的概率非常小,所以,从实用的角度来说,2PC 这种分布式事务的方法,实际的数据一致性还是非常好的。

在实现 2PC 的时候,没必要单独启动一个事务协调服务,这个协调服务的工作最好和订单服务或者优惠券服务放在同一个进程里面,这样做有两个好处:

- 参与分布式事务的进程更少,故障点也就更少,稳定性更好;
- 减少了一些远程调用,性能也更好一些;

2PC 也有很明显的缺陷,即性能不会很高。

整个事务的执行过程需要阻塞服务端的线程和数据库的会话,并且,协调者是一个单点,一旦过程中协调者宕机,就会导致订单库或者促销库的事务会话一直卡在等待提交阶段,直到事务超时自动回滚。

==所以,只有在需要强一致、并且并发量不大的场景下,才考虑使用 2PC。==

**本地消息表:订单与购物车的数据一致性问题**

==2PC 它的适用场景其实是很窄的,更多的情况下,只要保证数据最终一致就可以了。==

比如说,在购物流程中,用户在购物车界面选好商品后,点击“去结算”按钮进入订单页面创建一个新订单。这个过程我们的系统其实做了两件事儿。

- 第一,订单系统需要创建一个新订单,订单关联的商品就是购物车中选择的那些商品。
- 第二,创建订单成功后,购物车系统需要把订单中的这些商品从购物车里删掉。

这也是一个分布式事务问题,创建订单和清空购物车这两个数据更新操作需要保证,要么都成功,要么都失败。但是,清空购物车这个操作,它对一致性要求就没有扣减优惠券那么高,订单创建成功后,晚几秒钟再清空购物车,完全是可以接受的。只要保证经过一个小的延迟时间后,最终订单数据和购物车数据保持一致就可以了。

本地消息表非常适合解决这种分布式最终一致性的问题。我们一起来看一下,如何使用本地消息表来解决订单与购物车的数据一致性问题。

本地消息表的实现思路是这样的,订单服务在收到下单请求后,正常使用订单库的事务去更新订单的数据,并且,在执行这个数据库事务过程中,在本地记录一条消息。这个消息就是一个日志,内容就是“清空购物车”这个操作。因为这个日志是记录在本地的,这里面没有分布式的问题,那这就是一个普通的单机事务,那我们就可以让订单库的事务,来保证记录本地消息和订单库的一致性。完成这一步之后,就可以给客户端返回成功响应了。

然后,我们再用一个异步的服务,去读取刚刚记录的清空购物车的本地消息,调用购物车系统的服务清空购物车。购物车清空之后,把本地消息的状态更新成已完成就可以了。异步清空购物车这个过程中,如果操作失败了,可以通过重试来解决。最终,可以保证订单系统和购物车系统它们的数据是一致的。

> 消息队列 RocketMQ 提供一种事务消息的功能,其实就是本地消息表思想的一个实现。使用事务消息可以达到和本地消息表一样的最终一致性,相比我们自己来实现本地消息表,使用起来更加简单,你也可以考虑使用。







# ==高速增长篇==







# ==海量数据篇==








Expand Down

0 comments on commit 9e5a0d7

Please sign in to comment.