分布式架构中常见问题汇总!详细解析分布式项目中的问题解决方案

这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战

分布式

基本概念

  • 分布式系统是一种分工合作的工作方式
  • 若干独立计算机的集合,这些计算机对于用户来说就相当于一个单个系统
  • 分布式是指分布在不同地方的系统或者服务,将不同的业务分布在不同的地方,彼此是相互关联的

分布式优点

  • 宏观层面: 将多个功能模块在一起的功能模块进行拆分,来解耦服务间的调用
  • 微观层面: 将模块提供的服务分布到不同的机器或者容器里,来扩大服务力度

分布式缺点

  • 架构设计异常复杂,学习成本高
  • 运维部署和维护成本显著增加
  • 多服务之间的链路变长,开发排查问题难度增大
  • 环境高可靠性问题
  • 数据幂等性问题
  • 数据顺序问题

CAP

  • CAP理论: CAP三个字母分别代表了分布式系统中三个相互矛盾的属性:
    • Consistency: 一致性 .CAP理论中的副本一致性特指强一致性
    • Availablity: 可用性. 系统在出现异常时已经可以提供服务
    • Partition Tolerance: 分区容错性. 系统可以对网络分区这种异常情况进行容错处理

BASE

  • BASE理论是指:
    • Basically Available - 基本可用
      • 分布式系统在出现故障时,允许损失部分功能,保证核心功能可用
      • 比如电商网站交易付款出现问题时,商品依然可以正常浏览
    • Sofe state - 软状态
      • 由于不要求强一致性,所以BASE理论允许系统中存在中间状态,即软状态.这个状态不影响系统可用性
      • 比如订单中的“支付中”,“数据同步中”等状态,待数据最终一致后状态改为“成功”状态
    • Eventually consistent - 最终一致性
      • 经过一段时间后,所有节点数据都会达到一致
      • 比如订单中的“支付中”状态,最终会变成“支付成功”或者“支付失败”状态,使订单状态与实际交易结果达成一致,但是需要一定的延迟和等待
  • BASE理论是对CAPAP的扩展,通过牺牲强一致性来获得可用性. 当出现系统故障时允许部分功能不可用但是要保证核心功能可用,允许数据在一段时间内是不一致的,但是最终要达到一致状态
  • 满足BASE理论的事务叫作柔性事务

分布式消息队列常见问题

  • 分布式消息队列: 将消息队列里面的消息分摊到多个节点机器或者容器上,所有节点的消息队列之和就包含了所有的消息

消息队列幂等性

幂等性概念

  • 幂等性: 无论多少次操作和第一次的操作结果一样

问题场景

  • RabbitMQ, RocketMQ, Kafka消息队列中间件都有可能出现消息重复消费问题.这个问题不是消息队列本身保证的,需要框架开发人员保证
  • Kafka保证消息队列的幂等性:
    • Kafka中有一个偏移量的概念,代表着消息的序号
    • 每条消息写到消息队列都会有一个偏移量
    • 消费者消费了数据之后,每过一段固定的时间,就会将消费过的消息的偏移量提交一下,表示已经消费过了,下次消费就从偏移量后面开始消费
  • 问题: 当消费完消息之后,还没来得及提交偏移量,系统就被关机了,那么未提交偏移量的消息则会被再次消费
  • 示例:
  • 队列中的数据A, B, C, 对应的偏移量分别为100, 101, 102, 都被消费者消费.但是只有数据A的偏移量100提交成功,另外两个偏移量因为系统重启而导致未能及时提交

在这里插入图片描述

  • 重启后,消费者又获取偏移量100以后的数据,从偏移量101开始获取消息,这样导致数据B和数据C被重复消费

在这里插入图片描述

问题解决

  • 微信支付结果通知场景:
    • 微信官方文档上说明了微信支付通知结果可能会推送多次,需要开发者自行保证幂等性
    • 第一次可以直接修改订单状态,比如将订单状态由“支付中”修改为“支付成功”
    • 第二次就根据订单状态来判断,如果不是“支付中”,则不进行订单处理逻辑
  • 插入数据库场景:
    • 每次插入数据时,先检查一下数据库中是否存在这条数据的主键ID, 如果主键ID已经存在,则进行更新操作
  • 写Redis场景:
    • RedisSet操作具有天然的幂等性,不用考虑Redis写数据的幂等性问题
  • 其余业务场景解决方案:
    • 生产者发送每条数据时,增加一个全局唯一ID. 每次消费时,先去Redis中查询是否存在这个ID, 如果没有,则进行正常的消息处理,并且将这个ID保存到Redis中. 如果Redis中已经有这个ID, 说明之前已经消费过,就不需要重复处理这条消息

消息丢失

  • 问题: 比如在订单下单,支付结果通知,扣费相关出现消息丢失问题,可能会造成大量损失
  • 消息队列不能保证不会发生消息丢失,主要有三种场景会导致消息丢失:
    • 在生产者存放消息的过程中丢失消息
    • 在消息队列传递消息的过程中丢失消息
    • 在消费者消费消息的过程中丢失消息
    -

生产者丢失消息

  • 生产这在存放消息的过程中丢失消息

在这里插入图片描述

问题解决

  • confirm机制: 异步方式,推荐使用
    • 可以使用confirm机制来解决同步机制的性能问题
      • 每次生产者发送的消息都会分配一个唯一的ID
      • 如果写入到了RabbitMQ中,则RabbitMQ会回传一个ack消息,说明这个消息接收成功
      • 如果RabbitMQ没有能够接收处理这个消息,则回调nack接口,说明需要重试发送消息
    • 也可以自定义超时时间和消息ID来实现超时等待后重试机制
      • 但是可能会出现调用ack接口失败
      • 消息会被发送两次,这个时候就需要保证消费者消费消息的幂等性问题
  • 事务机制: 同步方式,不推荐使用
    • 对于RabbitMQ来说,生产者发送数据之前开启RabbitMQ的事务机制channel.txSelect
    • 如果消息没有进入队列,则生产者产生异常报错,并进行回滚channel.txRollback, 然后重试发送消息
    • 如果收到了消息,则可以提交事务channel.txCommit
    • 这样可以很好解决消息丢失问题,但是是一个同步操作,会影响性能
  • confirm机制和事务机制的比较:
    • confirm机制是异步接收通知,可能会有接收不到通知的情况,需要考虑接收不到通知的场景
    • 事务机制是同步操作,提交事务后会被阻塞到直到提交事务完成为止

消息队列丢失消息

  • 消息队列的消息可以存放到内存中,或者将内存中的消息存放到硬盘,比如数据库中,一般情况下,都是内存和硬盘中都存有消息
    • 如果消息队列的消息只是存放在内存中,那么当机器重启,消息就全部消失
    • 如果消息队列的消息存放在硬盘中,那么可能会有一种极端情况: 将内存中的数据转换到硬盘的过程中,消息队列出现问题,未能消息持久化到硬盘
    在这里插入图片描述

问题解决

  • 创建Queue时将这个Queue设置为持久化
  • 发送消息的时候将消息的deliveryMode设置为2
  • 开启生产者confirm模式,重试发送消息

消费者丢失消息

  • 消费者获取到消息数据还未进行消息处理时,结果进程因为异常退出,这时,消费者没有机会再次拿到消息

在这里插入图片描述

问题解决

  • 关闭RabbitMQ的自动ack,RabbitMQ的自动ack会在每次生产者将消息队列写入消息队列后,就自动回传一个ack给生产者
  • 消费者处理完消息再主动ack, 通知消息队列处置完成
  • 问题: 这种主动ack有什么问题? 如果主动ack出现异常怎么处理?
    • 会存在消息重复消费问题,这时候需要做幂等处理
  • 问题: 如果这条消息一直被重复消费怎么处理?
    • 需要加上重试次数的监测,如果重试超过一定次数则将消息丢失,记录到异常表或者发送异常通知开发人员

RabbitMQ消失丢失总结

  • RabbitMQ丢失消息的处理方案:
    • 生产者丢失消息
      • 开启confirm确认机制
      • 开启RabbitMQ事务
    • 消息队列丢失消息
      • 开启RabbitMQ持久化
    • 消费者丢失消息
      • 关闭RabbitMQ自动ack, 修改为手动ack

Kafka消息丢失

问题场景

  • Kafka的某个节点broker宕机了,重新选举写入的节点leader
  • 如果写入的节点leader宕机了,follwer还有些数据未同步完成,则follower成为leader后,消息队列会丢失一部分数据

问题解决

  • topic设置replication.factor参数,值必须大于1, 要求每个partion必须至少有2个副本
  • Kafka服务端设置min.insyc.replicas必须大于1, 表示一个leader至少同一个follower保持通信

消息乱序

  • 用户先下单成功,然后取消订单,如果顺序颠倒,那么最后数据库中还是会有一条下单成功的订单

RabbitMQ消息乱序

问题场景

  • 生产者向消息队列中按照顺序发送了2条消息.消息1是增加数据A, 消息2是删除数据A
  • 期望结果: 数据A被删除
  • 如果出现2个消费者,消费顺序为消息2和消息1, 那么最后的处理结果是增加了数据A

在这里插入图片描述

问题解决

  • RabbitMQ中的Queue进行拆分,创建多个内存Queue, 消息1和消息2进入同一个队列Queue
  • 创建多个消费者,每一个消费者对应一个Queue

在这里插入图片描述

Kafka消息乱序

问题场景

  • 创建一个topic,3partion
  • 创建一条订单记录,以订单ID作为key, 订单相关的消息都丢到同一个partion中,同一个生产者创建的消息,顺序是正确的
  • 为了快速消费消息,会创建多个消费者去处理消息,而为了提高效率,每个消费者可能会创建多个线程来并行获取消息以及处理消息,处理消息的顺序可能会因此错乱

在这里插入图片描述

问题解决

  • 线程获取消费者信息时创建多个队列Queue, 利用多个内存Queue, 每个线程消费一个Queue
  • 具有相同key的消息,进入同一个Queue

在这里插入图片描述

消息积压

  • 消息积压: 消息队列中有很多消息来不及消费

问题场景

  • 问题场景1: 消费端出了问题,比如消费者宕机,没有消费者消费,导致消息在队列中不断积压
  • 问题场景2: 消费端出了问题,比如消费者消费的速度太慢,导致消息不断积压
  • 示例:
    • 比如线上正在做订单活动,下单全部走消息队列,如果消息不断积压,订单都没有下单成功

在这里插入图片描述

问题解决

  • 重构消费者代码,确保后续消费速度恢复或者尽可能加快消费的速度
  • 关闭现有消费者,临时建立好原先5倍的队列Queue的数量,临时建立好原先5倍数量的消费者
  • 将堆积的消息全部转入临时的Queue, 让临时的消费者来消费这些Queue

在这里插入图片描述

消息过期失效

问题场景

  • RabbbitMQ可以设置消息过期时间,如果消息超过一定的时间还没有被消费,就会被RabbitMQ清理

在这里插入图片描述

问题解决

  • 准备好批量重导任务程序
  • 手动将消息闲时批量重导

在这里插入图片描述

消息队列写满

问题场景

  • 当消息队列因为消息积压的问题导致消息队列写满时,消息队列就不能接收更多的消息,此时生产者生产的消息将会被丢弃

问题解决

  • 判断哪些是无用的消息 .RabbitMQ中可以进行Purge Message操作
  • 如果是有用的消息,就要将消息快速消费,然后将消息内容保存到数据库中
  • 准备好重导任务程序将转存在数据库中的消息重导到消息队列
  • 手动将消息闲时重导

分布式缓存常见问题

  • 在频繁访问数据库的场景中,因为访问磁盘I/O的速度是很慢的,通常会在业务层和数据层之间加入一套缓存机制,来分担数据库的访问压力
  • 在高并发的情况下,数据库还会对数据进行加锁,导致数据库的访问速度更慢
  • 分布式缓存通常使用Redis, 可以提供分布式缓存服务

Redis数据丢失

  • 哨兵机制:Redis中,可以使用哨兵机制实现集群的高可用
    • 集群监控: 负责主副进程的正常工作
    • 消息通知: 负责将故障信息报警给运维人员
    • 故障转移: 负责将主节点转移到备用节点上
    • 配置中心: 通知客户端更新主节点地址
    • 分布式: 有多个哨兵分布在每个主备节点上,互相协同工作
    • 分布式选举: 需要大部分哨兵同意,才可以进行切换
    • 高可用: 即使部分哨兵节点宕机了,哨兵集群还可以正常工作

问题场景

  • 当主节点发生故障时,需要进行主备切换,可能会导致数据丢失
    • 异步复制数据: 主节点异步同步数据到备用节点的过程中,主节点宕机,会导致有部分数据未同步到备用节点. 此时备用节点又被选举为主节点,这就导致有部分数据丢失
    • 脑裂:
      • 脑裂是指主节点所在的机器脱离了集群网络.这时哨兵选举了新的备用节点作为主节点.这时候就导致有两个不同的主节点在同时工作,导致副节点数据不一致
      • 脑裂导致数据丢失是指在脑裂问题发生后,客户端还没有来得及切换到新的主节点,连的还是第一个主节点,这样有部分数据还是写入到了第一个主节点中,新的主节点中没有这部分数据.等到第一个主节点恢复后,会清空自身的数据然后作为备用节点连接到集群环境中,重新从主节点复制数据. 这就导致有部分数据丢失

问题解决

  • 配置min-slave-to-write=1, 表示至少有一个备用节点
  • 配置min-slave-max-lag=10, 表示数据的复制和同步的延迟不能超过10s. 这样保证最多丢失10s的数据

分布式分库分表常见问题

扩容

  • 扩容包括分库,分表,水平拆分和垂直拆分
  • 分库:
    • 因为一个数据库支持的最高并发访问数是有限的,可以将一个数据库的数据拆分到多个数据库中,这样可以增加最高并发访问数
  • 分表:
    • 因为一张表的数据量太大,即使使用索引查询性能也很低.这时可以将一张表的数据拆分成多张表.在查询时,只用拆分后的某一张表进行查询,这样可以明显提高SQL的查询性能
  • 水平拆分:
    • 将一个表的数据拆分到多个数据库,每个数据库中的表结构不变,使用多个数据库承担更高的并发
    • 比如订单表中每个月都会有大量数据,每个月都可以进行水平拆分,将上个月的数据存放到另一个数据库中
  • 垂直拆分:
    • 将一个有很多字段的表,拆分成多张表到同一个库或者多个库上面
    • 高频访问字段放到一张表,低频访问字段放到另外一张表
    • 利用数据库缓存来缓存高频访问的行数据
    • 比如将一张有很多字段的订单表拆分成几张表分别存放不同的字段,每个表中可以存在冗余字段
  • 分库分表的方式:
    • 根据租户来分库分表
    • 利用时间范围来分库分表
    • 利用ID取模来分库分表
  • 分库分表的优点:
    • 增加了数据库承受的并发量
    • 磁盘的使用率大大降低
    • 单表的数据量大大减少
    • SQL的执行效率明显提升

问题场景

  • 分库分表是运维层面需要完成的事情,有时候会采取凌晨宕机来进行升级,可能会出现升级失败,则需要回滚

问题解决

  • 双写迁移方案: 迁移时,新数据的增删改操作在新数据和老数据都做一遍
  • 使用分库分表工具Sharding-jdbc来完成分库分表的工作
  • 使用程序来对比两个数据库的数据是否一致,直到数据一致

问题总结

  • 分库分表引入的新问题:
    • 水平拆分:
      • 跨库的关联查询性能差
      • 数据库多次扩容,维护量大
      • 跨分片的事务一致性难以保证
    • 垂直拆分:
      • 依然存在单表的数据量过大的问题
      • 部分表无法关联查询,只能通过接口聚合的方式解决.提升的开发的复杂度
      • 分布式事务处理复杂

唯一ID

  • 如果要做分库分表,则必须做到表的主键ID是全局唯一的
    • 比如有一张订单表,被分到A库和B库,如果两张订单表的ID都是从1开始递增,就会导致很多订单ID都是重复的,那么查询订单数据时就会发生错乱
  • 分库的一个期望结果就是将访问数据库的次数分摊到其余的数据库
    • 有些场景是需要均匀分摊的,那么数据插入到多个数据库时就需要交替生成唯一的ID来保证请求均匀分摊到所有数据库

生产唯一ID的原则

  • 全局唯一性
  • 趋势递增
  • 单调递增
  • 信息安全

生成唯一ID的方式

数据库自增ID

  • 数据库自增ID: 数据库每次生成一条记录,数据的ID自增1
  • 缺点:
    • 多个数据库的ID可能重复. 不可以作为分库分表的数据ID生成方式
    • 信息不安全

UUID

  • 适用于UUID的唯一ID生成方式
  • 缺点:
    • UUID太长,占用空间大
    • 不具备有序性. 作为主键,在写入数据时,不能产生有顺序的append操作,只能进行insert操作,导致读取整个B+树的节点到内存,插入记录后将整个节点写回磁盘,当记录占用的空间很大的时候,性能很差

获取系统当前时间戳

  • 获取当前系统的时间戳作为数据库数据的主键ID
  • 缺点:
    • 在高并发的环境下 ,1ms的时间内可能会生成多个重复的ID, 导致数据主键ID重复
    • 信息不安全

Twitter的Snowflake算法

  • 雪花算法Snowflake: 分布式ID生成算法 ,64位的longID, 分为4个部分
    • 使用1位作为符号位,确定为0, 表示正
    • 使用41位作为毫秒数
    • 使用10位作为机器的ID :5位是数据中心ID,5位是机器ID
    • 使用12位作为毫秒内的序列号, 意味着每个节点每秒可以产生4096(2^12^)ID

在这里插入图片描述

  • 优点:
    • 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的
    • 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也非常高
    • 可以根据自身业务特性分配字节bit位数,灵活性高
  • 缺点:
    • 强依赖机器时钟,如果机器上时钟回拨,会导致生成重复ID或者服务处于不可用状态

UIDGenerator算法

  • UIDGenerator算法:
    • 基于Snowflake优化的分布式ID生成算法
    • 借用未来时间和双Buffer来解决雪花算法Snowflake生成ID的时钟回拨和生成性能问题,同时结合MySQL来进行ID分配

在这里插入图片描述

Leaf-Snowflake算法

  • Leaf-Snowflake算法:
    • 首先通过代理服务访问数据库获取一批雪花算法Snowflake生成的ID
    • 然后使用双缓冲机制,当前一批ID使用10% 时,再访问数据库获取新的一批ID缓存起来,等上一批ID使用完毕后立即使用
    -
  • 优点:
    • Leaf服务可以很方便地进行线性扩展,性能能够完全支撑大部分的业务场景
    • 生成的唯一ID是趋势递增的8字节的64位数字,满足数据库存储的主键要求
    • 容灾性高 ,Leaf服务内有号段缓存,即使数据库短时间宕机 ,Leaf仍能正常对外提供服务
    • 可以自定义max_id的大小,可以很方便的将业务从原有的ID上迁移过来
    • 即使数据库宕机 ,Leaf仍能持续发送一段时间的号段,偶尔的网络波动不会影响下个号段的更新
  • 缺点:
    • 信息不安全,生成的唯一ID是趋势递增的,不具备随机性,容易暴露发号数量的信息

分布式事务常见问题

  • 事务:
    • 事务可以大概的理解为一件事情要么全部做完,要么这件事情一点都没做,没有发生一样
    • 在分布式环境中,存在着各个服务之间的相互调用,链路可能很长,如果有任何一方执行出错,那么需要回滚涉及到的其余服务的相关操作
    • 比如订单下单成功,然后需要调用发送的代用劵接口,如果此时支付接口调用失败,则需要退回调用的代用劵,并且将订单的状态设置为异常状态

问题场景

  • 如何保证分布式事务正确有效执行?

问题解决

  • 分布式事务的几种方式:
    • TCC方案
    • SAGA方案
    • 可靠消息最终一致性方案
    • 最大努力通知方案
    • XA方案

TCC方案

  • TCC方案: Try-Confirm-Cancel
    • Try阶段: 对各个服务的资源做检测以及对资源进行锁定或者预留
    • Confirm阶段: 各个服务中执行实际的操作
    • Cancel阶段: 如果任何一个服务的业务方法执行出错,需要将之前操作成功的步骤进行回滚
  • 应用场景:
    • 和支付以及交易相关,必须保证资金正确的场景
    • 对于一致性要求很高的场景
  • 缺点:
    • 因为需要编写很多补偿逻辑的代码,并且代码难以维护,不建议在其余场景中使用

Saga方案

  • Saga方案: 如果业务流程中的每个步骤只要有一个失败了,就要补偿前面操作成功的步骤
  • 应用场景:
    • 业务流程长,业务流程多的场景
    • 参与者包含其余公司或者遗留系统服务的场景
  • 优点:
    • 第一阶段提交本地事务,无锁,高性能
    • 参与者可以异步执行,高吞吐
    • 补偿服务易于实现
  • 缺点:
    • 不保证事务的隔离性

可靠消息最终一致性方案

  • 利用消息中间件RabbitMQ来实现消息事务

在这里插入图片描述

  • 第一步: A系统发送一个消息到消息队列,消息队列将消息状态标记为preapred准备状态的半消息,该消息无法被订阅
  • 第二步: 消息队列响应A系统,回复A系统已经收到消息
  • 第三步: A系统执行本地事务
  • 第四步: 如果A系统执行本地事务成功,就会将prepared消息状态修改为commit提交事务消息状态 ,B系统就可以订阅该消息
  • 第五步: 消息队列也会定时轮询所有的prepared准备状态的消息,回调A系统,获取A系统中对应消息的本地事务的执行情况,决定继续等待还是回滚
  • 第六步: A系统检查本地事务的执行结果
  • 第七步: 根据A系统本地事务的执行结果给消息队列发送信号: 如果A系统执行本地事务成功,那么消息队列接收到commit信号;如果A系统执行本地事务失败,那么消息队列接收到Rollback信号
  • B系统接收到消息后,开始执行本地事务,如果执行失败,就自动不断重试直到成功. 或者B系统也可以采取回滚的方式,同时也要采取其余的方式通知A系统也要进行回滚. 并且B系统要保证幂等性

最大努力通知方案

  • 最大努力通知方案:
    • A系统执行本地事务完成后,发送消息到消息队列
    • 消息队列将消息持久化
    • 如果B系统执行本地事务失败后,最大努力服务会定时尝试重新调用B系统,尽最大努力让B系统重试,如果重试多次还是失败就放弃

XA方案

在这里插入图片描述

  • 事务管理器负责协调多个数据库的事务,先查询各个数据库是否准备完成:
    • 如果准备完成,就在数据库中执行操作
    • 如果存在任何一个数据库没有准备完成,就对事务进行回滚
  • 适合单体应用,不适合微服务架构. 因为每个服务只能访问自己的数据库,不允许交叉访问其余微服务的数据库

分布式事务总结

  • 在支付,交易的场景中,优先使用TCC方案
  • 在大型系统中,可以尝试使用消息事务和Saga方案
  • 单体应用使用XA两阶段提交就可以
  • 最大努力通知方案是先进行重试,看是否成功,如果没有成功就放弃

猜你喜欢

转载自juejin.im/post/7036209454014005284