分布式事务的处理(幼儿园版)

最近和同事讨论了不少信息系统中事务控制的问题,那么现在结合以前做过的项目,来稍微总结一下。

现在设想我们要山寨一下小米每周二的抢购模式,就叫“大米”网好了。那么现在放出特定数量的一些商品给用户来抢,每个用户只能买一定数量的某个商品。按照现有系统,大概有以下这么几步:

① 检查商品库存 → ② 检查用户购买限制 → ③ 扣款 → ④ 生成订单 → ⑤ 更新库存与购买限制

V1.0 版:假如这些信息(包括支付部分)的数据都在一个数据库中,那么很显然要把这些处理全部放在一个事务中。使用事务相当重要的一点就是隔离级别。一般数据库支持的隔离级别有四种(某些数据库还支持其他的,比如 SQL Server 还另外支持两种):

服务器有可能同时收到两个一样的请求,如果事务 A 已经执行到 ④ 生成订单 这一步了,而事务 B 正好开始执行 ① 检查商品库存,如果不把事务 B 阻塞掉,那么等 A 执行完后,可能此时已经库存不足或者已经达到购买上线了。

Solution 1:在 ① ② 步骤的 SELECT 语句中加入悲观锁(即 SELECT … FOR UPDATE),这样事务 B 就被阻塞了。但问题是系统的并发度急剧下降,客户端如果只是简单地来查询库存的请求也会被阻塞掉。此外索引覆盖扫描之类的优化在其他查询中也无法使用,而且还很容易导致锁竞争的问题。

Solution 2:先不管库存和限制直接 UPDATE,更新完后再检查库存和购买限制是否为负数,如果是则回滚,最后再做付款操作。由于在 UPDATE 语句执行时会添加行锁,这样事务 B 的更新请求会被阻塞掉直到 A 提交事务,并发度较方案 1 有所提高。

Solution 3:还是按照最初的顺序进行操作,但是在 ① 前增加一步:生成新的订单号(这里说的订单号是每个用户的订单互相独立),例如 SELECT MAX(UserOrderId) + 1 FROM Orders WHERE UserId = @userId。另外订单表还需要增加一个唯一索引,包括 UserOrderId, UserId 两个字段。如果 A 和 B 同时开始处理,那么 A 和 B 会生成同样的订单号,当 B 到第 ④ 步时会插入失败(因为事务 A 已经插入了相同订单号的商品了),那么 B 的事务就会被回滚。这样做的好处是只有 UPDATE 时才会给这行数据上锁,进一步提高了系统的并发度。但缺陷是事务 B 会失败,而方案 1 和 2 不会导致 B 失败,这种现象在我们的“抢购系统”中是可以接受的,可以“意外”地防止一个帐号在多个客户端上登录同时抢购的情况。(这个方案是 DP 同学想出来的)

V2.0 版:由于我们“大米手机”太火爆,全中国的人都在抢,服务器承受不了这么大的压力挂了,BOSS 要求立即整改。分析一下我们的网站,在同一时间有大量的客户端在获取库存信息,那么最好是把库存信息扔在 Redis 里面。改造后的流程如下:

① 检查商品库存 → ② 商品库存减一(临时冻结) → ③ 检查购买限制与扣款 → ④ 生成订单 → ⑤ 更新购买限制

但由于 Redis 里面的数据不在数据库的事务控制范围内,因此我们需要自己实现回滚的操作。

在这里需要特别注意的是,回滚也可能失败。例如在支付过程中余额不足而导致整个事务失败,我们先回滚了数据库中的相关信息,在回滚库存信息的时候 Redis 的服务器突然挂了,在 Redis 恢复后,就会出现库存量比实际商品少的情况。

实际上,这种做法失去了关系型数据库的“强一致性”,但由于库存信息也不是太重要,因此我们可以采取一定的策略实现最终一致性。例如在此系统中,当库存减为 0 时我们再扫描一次数据库,看看订单数有没有达到商品总数,如果没有更新库存值,此时用户可以继续抢购。

V3.0 版:现在我们的“大米手机”全世界的人都在抢,在数据库一层由于压力太大再次歇菜了。那么我们就要引入 Message Queue 来扛住压力。这样,前置服务器可以一直满足粉丝们的疯狂抢购,后置服务器则根据自己的处理能力依次处理,保证服务器不会由于用户太多而垮掉。

此时,我们的处理流程修改如下:

服务 A:① 检查商品库存 → ② 商品库存减一(临时冻结) → ③ 将待处理的订单插入 MQ 中

而对于消费 MQ 的服务器,处理流程如下:服务 B:④ 扣款 → ⑤ 生成订单与更新购买限制

在扣款与生成订单的过程中,有可能事务失败需要回滚。此时与 V2.0 版中类似。在使用 MQ 处理业务时,会遇到如下问题:

  • 服务 A 向消息队列发送失败,此时直接回滚即可,告知客户端下单失败。
  • MQ 向服务 B 投递失败,例如 B 挂了或者没有响应,此时 MQ 会向 B 不断尝试重发。
  • B 可能会重复收到 MQ 发来的消息(例如网络延时导致 MQ 认为 B 没有收到而重发),此时 B 不能因此导致扣款了多次(不然就等着客户来喷客服或者砸大楼吧)。解决办法是 B 需要对业务做幂等性(即同一个参数的业务不论执行多少次结果都一样),不过最简单的办法时把执行过的业务 id 存起来,在操作之前检查一下这个 id 有没有被消费过,如果有直接忽略掉。
  • B 有可能会执行失败:例如账户上没有这么多钱,那么即使重试 99999999 次都不会成功。

对于最后一种情况,处理办法之一是让 A 的操作回滚。但是这个很难做到,比如你怎么通知 A 回滚?回滚失败了怎么办?等等一些问题。此外,这里是一个最简化的系统,实际上真实系统的模块相当多,当下层业务需要回滚时,代价相当大。既然不好弄,那我们就拆分一下业务吧。

V4.0 版:我们将购买流程相关的业务划分如下:

业务一(订单业务):① 检查商品库存 → ② 商品库存减一(临时冻结) → ③ 生成预处理订单

业务二(付款业务):

服务器 A:④ 支付 → ⑤ 将支付结果发布到 MQ

服务器 B(从 MQ 中取出支付成功的订单):  ⑥ 检查是否已经处理过此订单 → ⑦ 将订单状态更改为支付完毕

将订单业务与付款业务拆开,用户下单后需要再次发起支付请求,能够有效缓解后端服务器的压力(因为用户可能一直在抢购),如果付款业务失败,不需要更新订单的状态,用户可以尝试再次支付(另外也可以规定一个超时时间,超时后订单自动失效,释放库存),就回避掉了需要回滚订单业务的情况。

然而此时系统仍然会有意外状况,例如用户已经付款成功了,但是支付成功的消息可能发不到服务器 B (例如使用第三方支付时网络坏了等情况),更有可能是程序在某些情况下有 bug,那么 MQ 无论怎么重试都不能送达消息。

此时我们只好搬出最后一道防线。

V5.0 版:在 4.0 版的基础上,对重要的逻辑(比如支付结果)增加后台日志记录,然后:雇佣一些人工客服。当运维人员在日志中发现有这种情况时,或者客户打电话来喷客服人员时,人工处理这种情况即可。

从我们“开发”的“大米手机”网站可以看出:

  • 要对复杂的业务进行合理的划分。

再举一个例子吧,比如我们要做一个转账系统,银行 A 处理客户请求时把客户的钱从账户上先扣掉,之后把转账请求发送到 MQ 中等待银行 B 处理。此时银行 B 收到处理请求后有可能发现目标账户不存在,此时应该把款项退还给 A 账户。从上面那个简单的“大米网站”可以看出要回滚银行 A 的业务相当复杂,那么可以考虑把业务更改为:入账失败时走另一个退款业务,向 MQ 中添加一条退款业务的请求等待 A 处理。这样做有效地降低了系统的实现难度以及出 bug 的概率。

  • 分布式系统是为了应付访问量超大的情况,根据 CAP 原理,强一致性、可用性与分区容错性不可能同时满足。

所以我们上面所做的工作都是牺牲了系统的强一致性,而使用其他手段(比如退款业务、人工处理等)使系统达到最终一致性的状态。

  • MQ 的可靠性与日志功能相当重要。

假设 MQ 不可靠,在收到 A 的消息后突然挂了而消息又丢失了,那么就会出现明明付款了客户开始喷客服而又找不到付款记录的杯具状况(只能去 A 系统确认了)。

需要说明的是上面举的例子都相当简单,对于复杂的业务,有更多东西需要处理,例如客户端可能生成多个 MQ 的消息、需要按顺序消费等等。那么在以后的博客中老夫再来讨论这些复杂的情况吧。 ^_^

✏️ 有任何想法?欢迎发邮件告诉老夫:daozhihun@outlook.com