分布式系统遇到的常见问题及解决方案

3,073 阅读13分钟

参考: 分布式常见的十大坑,你了解几个?

CAP理论

  • 分布式系统在设计时只能在一致性(consistency)、可用性(availability)、分区容忍性(partition)中满足两种。

    • 一致性指所有节点访问同一份最新的数据副本,可用性指系统提供的服务一直处于可用状态,分区容错性指分布式系统在遇到任何网络分区故障的时候,仍需要保证对外提供一致性和可用性服务。在一个分布式系统中,不可能同时满足三个特性,最多满足两个。

    • CA放弃分区容忍性,关系数据库按照CA设计

    • AP放弃一致性,追求最终一致性,许多非关系型数据库按照AP进行设计。

    • CP放弃可用性,比如跨行转账,要求等待双方银行系统都完成整个事务才算完成。

BASE理论

  • BASE是基本可用(basically available)、软状态(soft state)和最终一致性(eventually consistent) 三个短语的缩写。

  • base理论是对CAP中AP的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但保证核心功能可用,允许数据在一段时间内是不一致的,但最终达到一致性状态。

    • 基本可用:允许损失部分可用功能,保证核心功能可用。

    • 软状态:允许存在中间状态,这个状态不影响系统可用性,如订单中的“支付中”,“数据同步中”等状态。待数据最终一致后,改为成功状态。

    • 最终一致性: 指经过一段时间后,所有节点数据都将会达到一致。如“支付中”状态最终会变为“支付成功”或者“支付失败”。

熔断、降级限流

参考:浅析降级、熔断、限流

降级

  • 降级也就是服务降级,当我们的服务器压力剧增,为了保证核心功能的可用性,而选择性的降低了一些功能的可用性,或直接关闭该功能。

  • 比如贴吧类型的网站,当服务器吃不消时,可以选择关闭发帖功能、用户服务相关的功能等,保证登录和浏览帖子这种核心功能。

熔断

  • 降级一般指我们自身的系统出了故障而降级。而熔断一般指依赖的外部接口出现故障,断绝和外部接口的关系。

  • 比如A服务中的一个功能依赖B服务,这时B服务出现了问题,返回很慢。此时就需要熔断。即当发现A要调用B,此时就直接返回错误。

限流

  • 限流指对某一时间窗口内的请求进行限制,保持系统可用性和稳定性,防止因流量暴增而导致系统运行缓慢或宕机。

  • 一般限制的指标是请求总量或某段时间内请求总量。

消息队列如何做分布式?

幂等性概念

  • 无论做多少次操作和第一次操作的结果一样,则为幂等。用于解决消息重复消费问题。

解决重复消费问题

  • 插入数据库场景:

    • 每次插入数据时,先检查数据库中是否有这条数据的主键id,如果没有,则进行更新操作。
  • 写redis场景:

    • redis的set操作天然幂等性
  • 其他场景:

    • 生产者发送每条数据时,增加一个全局唯一id,每次消费时,去redis中检查是否有这个id,如果没有,则进行正常消息处理。若有,则说明之前消费过,避免重复消费。

解决消息丢失问题

如果是订单下单、支付结果通知、扣费相关消息丢失,可能造成财务损失。

1.生产者存放消息的过程中丢失消息
  • 解决方案:

    • 确认机制。每次生产者发送的消息都会分配一个唯一的id,如果写到了消息队列中,则broker会回传一个ack消息,说明消息接收成功。否则采用回调机制,让生产者重发消息。
2.消息队列丢失消息
  • 解决方案:

    • broker在消息刷盘之后再给生产者响应。假设消息写入缓存中就返回响应,那么机器突然断电这消息就没了,而生产者以为已经发送成功了。

    • 如果broker是集群部署,有多副本机制,则消息不仅要写入当前broker,还需要写入副本机中。配置成至少写入两台机子后再给生产者响应,这样基本就能保证存储的可靠了。

3.消费者丢失消息
  • 解决方案: 消费者处理完消息,主动ack.

解决消息乱序问题

  • 生产者向消息队列按照顺序发送了 2 条消息,消息1:增加数据 A,消息2:删除数据 A。

  • 期望结果:数据 A 被删除。

  • 但是如果有两个消费者,消费顺序是:消息2、消息 1。则最后结果是增加了数据 A。

  • 解决方案:

    • 全局有序

      • 只能有一个生产者往topic发送消息,并且一个topic内部只能有一个队列。消费者也必须单线程消费这个队列。
    • 部分有序

      • 将topic内部拆分,创建多个内存queue,消息1和消息2进入同一个queue.

      • 创建多个消费者,每个消费者对应一个queue.

解决消息积压问题

  • 消息队列中很多消息来不及消费,场景如下:

    • 消费者都挂了

    • 消费者消费的速度太慢了

  • 解决方案:

    • 修复代码层面消费者的问题。

    • 停掉现有的消费者。

    • 临时建立好原先5倍的Queue数量

    • 临时建立好原先5倍的消费者。

    • 将堆积消息全部转入临时的Queue

解决消息过期失效

  • 解决方案:

    • 准备好批量重导的程序

    • 手动将消息闲时批量重导

分布式缓存的问题

异步复制数据导致数据丢失

  • 主节点异步同步数据给从节点过程中,主节点宕机了,导致部分数据未同步到从节点,而该从节点又被选举为主节点,这个时候就有部分数据丢失了。

脑裂导致数据丢失

  • 主节点所在机器脱离了集群网络,实际上自身还是运行着的。但哨兵选举出了备用节点作为主节点,这个时候就有两个主节点都在运行,相当于两个大脑在指挥这个集群干活,但到底听谁的呢?这个就是脑裂。

  • 发生脑裂后,客户端还没来得及切换到新的主节点,连的还是第一个主节点,那么有些数据还是写入到了第一个主节点中,新的主节点没有这些数据。等到第一个主节点恢复后,会被作为备用节点连接到集群环节,而且自身数据会被清空,重新从新的主节点复制数据。而新的主节点没有之前客户端写入的某些数据,导致数据丢失了一部分。

  • 解决方案:

    • 配置 min-slaves-to-write 1,表示至少有一个备用节点。

    • 配置 min-slaves-max-lag 10,表示数据复制和同步的延迟不能超过 10 秒。最多丢失 10 秒的数据。

分库分表的问题

分库、分表、垂直拆分、水平拆分

  • 分库: 因一个数据库支持的最高并发访问数是有限的,可以将一个数据库的数据拆分到多个库中,来增加最高并发访问数。

  • 分表: 因一张表的数据量太大,用索引来查询数据都搞不定了,所以可以将一张表的数据拆分到多张表,查询时,只用查拆分后的某一张表,SQL 语句的查询性能得到提升。

  • 分库分表优势:分库分表后,承受的并发增加了多倍;磁盘使用率大大降低;单表数据量减少,SQL 执行效率明显提升。

  • 水平拆分: 把一个表的数据拆分到多个数据库,每个数据库中的表结构不变。用多个库抗更高的并发。比如订单表每个月有500万条数据累计,每个月都可以进行水平拆分,将上个月的数据放到另外一个数据库。

  • 垂直拆分: 把一个有很多字段的表,拆分成多张表到同一个库或多个库上面。高频访问字段放到一张表,低频访问的字段放到另外一张表。利用数据库缓存来缓存高频访问的行数据。比如将一张很多字段的订单表拆分成几张表分别存不同的字段(可以有冗余字段)。

分库分表之唯一ID

  • 生成唯一ID的几种方式:

    • 数据库自增ID(不适合)

    • UUID (太长,不具有有序性)

    • 获取系统当前时间作为唯一ID(高并发时,1ms内可能具有多个相同的ID)

    • snowflake(雪花算法)

    • 百度的UIDGenerator算法

    • 美团的leaf-snowflake算法

分布式事务的问题

  • 分布式中,存在各个服务之间相互调用,链路可能很长,如果有任何一方执行出错,则需要回滚涉及到的其他服务的相关操作。

方案参考:两天,我把分布式事务搞完了

消息队列之事务消息,RocketMQ 和 Kafka是如何做的?

2PC方案

  • 角色:参与者和协调者。 阶段:准备阶段和提交阶段。

  • 准备阶段:由事务协调者给每个参与者发送准备命令,每个参与者收到命令之后会执行相关事务操作。但不会提交事务。

  • 提交阶段:协调者收到每个参与者的响应后进入第二阶段,只要有一个参与者准备失败,那么协调者就向所有参与者发送回滚命令,反之发送提交命令。

  • 协调者在第一阶段中未收到个别参与者的响应,则等待一定时间就会认为事务失败,会发送回滚命令,所以在2PC中事务协调者有超时机制。

  • 优点:

    • 利用数据库自身功能进行本地事务的提交和回滚,不会入侵业务逻辑。
  • 缺点:

    • 同步阻塞:在第一阶段执行了准备命令后,每个本地资源都处于锁定状态,因为除了事务提交啥都做了。

    • 单点故障:协调者出现问题,整个事务就执行不下去了。

    • 数据不一致: 由于网络可能会出现异常,那么某些参与者无法收到协调者的请求,某些收到了。比如第二阶段的提交请求,此时就产生了数据不一致问题。

TCC方案

  • TCC通过业务代码来实现事务的提交和回滚,对业务的侵入较大,是一种业务层面或应用层的两阶段提交。

  • Try阶段:对各个服务的资源做检测以及对资源进行锁定或预留。

  • Confirm阶段:各个服务中执行实际的操作。

  • Cancel阶段:如果任何一个服务的业务方法执行出错,需要将之前操作成功的步骤进行回滚。

  • 优点:没有资源的阻塞,每个方法都是直接提交事务的。

  • 缺点:对业务有很大的侵入。

  • 注意点:

    • 幂等问题:因为网络调用无法保证请求一定能够到达,都会有重调机制,因此对于Try、Confirm、Cancel三个方法都需要幂等实现,避免重复执行产生错误。

    • 空回滚问题:try方法由于网络问题阻塞超时了,此时事务管理器就会发出Cancel命令。那么需要支持 Cancel 在未执行 Try 的情况下能正常的 Cancel。

    • 悬挂问题:try方法由于网络问题阻塞超时了,触发了事务管理器的Cancel命令。但执行之后try请求到了。此时冻结操作就被悬挂了,所以空回滚之后还得记录一下,防止 Try 的再调用。

事务消息方案

  • 主要适用于异步更新的场景,且对数据实时性要求不高的地方。目的是为了解决消息生产者和消费者之间的数据一致性问题。

  • 基本原理:利用RocketMQ来实现消息事务。保证下单和发消息这两个步骤要么都成功要么都失败。

  • 第一步:A 系统发送一个半消息到 brokerbroker将消息状态标记为 prepared,该消息对consumer是不可见的。

  • 第二步:broker 响应 A 系统,告诉 A 系统已经接收到消息了。

  • 第三步:A 系统执行本地事务。

  • 第四步:若 A 系统执行本地事务成功,将 prepared 消息改为 commit(提交事务消息),B 系统就可以订阅到消息了。

  • 第五步:broker也会定时轮询所有 prepared的消息,回调 A 系统,让 A 系统告诉 broker 本地事务处理得怎么样了,是继续等待还是回滚。

  • 第六步:A 系统检查本地事务的执行结果。

  • 第七步:若 A 系统执行本地事务失败,则 broker收到 Rollback 信号,丢弃消息。若执行本地事务成功,则 broker收到 Commit 信号。

  • B 系统收到消息后,开始执行本地事务,如果执行失败,则自动不断重试直到成功。或 B 系统采取回滚的方式,同时要通过其他方式通知 A 系统也进行回滚。

  • B 系统需要保证幂等性。

最大努力通知方案

  • 基本原理:

    • 系统A执行本地事务后,发送消息到broker

    • broker将消息持久化。

    • 系统B如果执行本地事务失败,则最大努力服务会定时尝试重新调用系统B,尽自己最大的努力让系统 B 重试,重试多次后,还是不行就只能放弃了。转到开发人员去排查以及后续人工补偿。

方案选择

  • 跟支付、交易打交道,优先 TCC

  • 大型系统,但要求不那么严格,考虑 消息事务方案。

  • 单体应用,建议 XA两阶段提交就可以了。(XA2PC的落地实现)

  • 最大努力通知方案建议都加上,毕竟不可能一出问题就交给开发排查,先重试几次看能不能成功。