分布式系统遇到的十个问题
1.前言
我们都在讨论分布式,特别是面试的时候,不管是招初级软件工程师还是高级,都会要求懂分布式,甚至要求用过。传得沸沸扬扬的分布式到底是什么东东,有什么优势?
风遁·螺旋手里剑
看过火影
的同学肯定知道漩涡鸣人
的招牌忍术:多重影分身之术
。
- 这个术有一个特别厉害的地方,
过程和心得
:多个分身的感受和经历都是相通的。比如 A 分身去找卡卡西(鸣人的老师)请教问题,那么其他分身也会知道 A 分身问的什么问题。 漩涡鸣人
有另外一个超级厉害的忍术,需要由几个影分身完成:风遁·螺旋手里剑。
这个忍术是靠三个鸣人一起协作完成的。
这两个忍术和分布式有什么关系?
- 分布在不同地方的系统或服务,是彼此相互关联的。
- 分布式系统是分工合作的。
案例:
- 比如 Redis 的
哨兵机制
,可以知道集群环境下哪台Redis
节点挂了。 - Kafka/zookeeper的
Leader 选举机制
,如果某个节点挂了,会从follower
中重新选举一个 leader 出来。(leader 作为写数据的入口,follower 作为读的入口)
那多重影分身之术
有什么缺点?
- 会消耗大量的查克拉。分布式系统同样具有这个问题,需要几倍的资源来支持。
1.1对分布式的理解
要理解分布式系统,主要需要明白一下2个方面:
1.分布式系统一定是由多个节点组成的系统。其中,节点指的是计算机服务器,而且这些节点一般不是孤立的,而是互通的。
2.这些连通的节点上部署了我们的节点,并且相互的操作会有协同。
不同的业务模块部署在不同的服务器上或者同一个业务模块分拆多个子业务,部署在不同的服务器上,解决高并发的问题,提供可扩展性以及高可用性,业务中使用分布式的场景主要有分布式存储以及分布式计算。分布式存储中可以将数据分片到多个节点上,不仅可以提高性能(可扩展性),同时也可以使用多个节点对同一份数据进行备份。
3.简单概括
是一种工作方式
若干独立计算机的集合,这些计算机对于用户来说就像单个相关系统
将不同的业务分布在不同的地方
1.2.1分布式环境的特点
1.分布性:服务部署空间具有多样性
2.并发性:程序运行过程中,并发性操作是很常见的。比如同一个分布式系统中的多个节点,同时访问一个共享资源。数据库、分布式存储
3.无序性:进程之间的消息通信,会出现顺序不一致问题
1.2.2集群与分布式的区别
集群:复制模式,每台机器做一样的事。
分布式:两台机器分工合作,每台机器做的不一样。
集群相关tips:无论你是 web容器集群还是db集群,按照所要解决的问题可以分为如下几种:
高可用集群,提升可用性,(容灾、故障转移), 部署方式有以下三种
- 主从方式:主机工作,备机监控。此方式不能有效的利用服务器资源
- 互为主从:两服务器同时在线,一台服务器故障可切换到另一台上。此 方式有效的利用服务器资源,但当服务器故障时候,将导致一台服务器上运行多个业务。
- 多台服务器主从:大部分服务器在线使用,小部分监控;若有部分服务器故障,可切换到指定的小部分服务器上。此方式为前两种方式的综合。然后多台服务器群集,也增加了管理的复杂度。
高可用集群的另外一个特点是共享资源,多个节点服务器共享一个存储资源,该存储可在不同节点之间转移。
负载均衡集群,LoadBalance, 提升吞吐量
- 不同节点之间相互独立,不共享任何资源(硬件,网络, 但应用的会话保持需要一定的策略)。
- 通过一定算法将客户端的访问请求分配到群集的各个节点上,充分利用每个节点的资源。
- 负载均衡扩展了网络设备和服务器带宽,增加吞吐量,加强网络数据处理能。
- 每个节点的性能和配置可能不同,根据算法,可以分配不同的权重到不同节点上,以实现不同节点的资源利用。
并行计算群集,Performance, 减少应答时间
- 并行计算或称平行计算是相对于串行计算来说的。
- 并行计算的能力的目的是提高计算速度。
并行计算分为时间计算和空间计算:
- 时间计算既是流水线技术,一个处理器分为多个单元,每个单元负责不同任务,这些单元可并行计算。
- 空间计算利用多个处理器并发的执行计算。目前PC机的计算能力越来越强,将大量低廉的PC机互联起来,组成一个”大型计算机”以解决复杂的计算任务。Beowulf computers为最典型的空间并行计算。
1.2.3分布式的架构演进
1.2.3.1原始分布式时代
这个时代(20世纪70年代末期到80年代初)的计算机计算能力非常有限(内存只有几百K),所以科学家就寻求通过多台计算机来完成一个功能,这个时代被称为原始分布式时代。
但是这个时代对分布式系统的尝试并没有取得太大的成绩。但也不是一无所成,这个时代的探索,出现了远程调用,分布式文件系统的雏型,为后续计算机学科的发展奠定了基础。
IBM院士Kyle Brown事后曾评价道,“这次尝试最大的收获就是对RPC、DFS等概念的开创,以及得到了一个价值千金的教训:某个功能能够进行分布式,并不意味着它就应该进行分布式,强行追求透明的分布式操作,只会自寻苦果。”
无论是DCE还是稍后出现的CORBA,从结果来看,都不能称得上成功,因为将一个系统拆分到不同的机器中运行,为解决这样做带来的服务发现、跟踪、通信、容错、隔离、配置、传输、数据一致性和编码复杂度等方面的问题所付出的代价已远远超过了分布式所取得的收益。
1.2.3.1.1关于DCE的一点介绍
DCE是当时业界主流的计算机厂商一起参与,共同制订了名为“分布式运算环境[2]”(Distributed Computing Environment,DCE)的分布式技术体系。
DCE包含一套相对完整的分布式服务组件规范与参考实现,譬如源自NCA(惠普公司)的远程服务调用规范(Remote Procedure Call,RPC),当时被称为DCE/RPC,它与后来Sun公司向互联网工程任务组(Internet Engineering Task Force,IETF)提交的基于通用TCP/IP协议的远程服务标准ONC RPC被认为是现代RPC的共同鼻祖。
源自AFS(卡内基梅隆大学提出)的分布式文件系统(Distributed File System,DFS)规范,当时被称为DCE/DFS;
源自Kerberos(麻省理工大学提出)的服务认证规范;
还有时间服务、命名与目录服务,甚至现在程序中很常用的通用唯一识别符(Universally Unique Identifier,UUID)也是在DCE中发明出来的。
1.2.3.1.2UNIX分布式设计哲学
保持接口与实现的简单性,比系统的任何其他属性,包括准确性、一致性和完整性,都来得更加重要。
但是基于那个时代的计算机技术,想要发展符合UNIX设计哲学的分布式技术不太可能,只能是一种美好的愿景。
1.2.3.2 单体应用时代
20世纪80年代正是摩尔定律开始稳定发挥作用的黄金时期,微型计算机的性能以每两年增长一倍的惊人速度提升,硬件算力束缚软件规模的链条很快变得松动,信息系统进入以单台或少量几台计算机即可作为服务器来支撑大型信息系统运作的单体时代,且在很长的一段时间内,单体都将是软件架构的绝对主流。
在很多书中都将单体架构作为一种反派角色出现,我甚至在面试过程中遇到过面试官在还没了解项目需求的情况下就直接说你们这种单体架构是有问题的。其实没有放之四海而皆准的架构,单体架构在某些情况下可能是最优选择,单体架构更不应该被打上反派角色。
比如,对于小型系统,单台机器就足以支撑其良好运行的系统,不仅易于开发、测试、部署,且由于系统中各个功能、模块、方法的调用过程都是进程内调用,不会发生进程间通信(Inter-ProcessCommunication,IPC[1]),因此连运行效率也是最高的。
那些不顾需求现状,为了微服务而微服务的开发者才是真正的“反派”。
单体系统的不足,必须在软件的性能需求超过了单机、软件的开发人员规模明显超过了“2 Pizza Team”(6~12人)范畴的前提下才有讨论的价值。
上面讲到的是单体架构的优点,在互联网时代(复杂系统),单体架构存在两点明显的缺点:
- 单机性能难以保证:一个应用中功能越堆越多,单台机器已经难以满足这些功能,摩尔定律失效让堆硬件的做法变成了奢望;
- 缺乏自治隔离能力:比如说一部分的代码出现Bug可能会导致整个服务不可用,比如说一小个改动点需要上线,整个系统必须上线;
- 还有一个缺点就是单体架构需要技术栈统一,比如说所有功能模块都需要用Java语言来编写。
单体架构的缺点不在于不可拆分、难以扩展(这种想法不完全正确)。
- 从纵向拆分角度来看,现代的单体应用都是会拆分成controller,service和dao层的,不存在不能拆分的说法;
- 从横向拆分的角度,单体应用完全可以拆分成多个模块,每个模块负责不同的功能,但是最后还是打成一个包,运行在一个进程中;
- 从横向扩展的角度,可以通过负载均衡机制同时部署若干个相同的单体系统副本,以达到分摊流量压力的效果。
微服务取代单体系统成为潮流趋势的根本原因,笔者认为最重要的原因是:单体系统很难兼容“Phoenix”的特性。这种架构风格潜在的要求是希望系统的每一个部件、每一处代码都尽量可靠,尽量不出或少出缺陷。然而战术层面再优秀,也很难弥补战略层面的不足。单体系统靠高质量来保证高可靠性的思路,在小规模软件上还能运作良好,但当系统规模越来越大时,交付一个可靠的单体系统就变得越来越具有挑战性。如本书前言所说,正是随着软件架构演进,构建可靠系统的观念从“追求尽量不出错”到正视“出错是必然”的转变,才是微服务架构得以挑战并逐步取代单体架构的底气所在。
1.2.3.3 SOA架构时代
SOA架构,面向服务的架构。其包含的许多概念、思想都能在今天的微服务中找到对应的身影了,譬如服务之间的松散耦合、注册、发现、治理,隔离、编排等。
SOA不能简单视为一种架构风格,而是一套软件设计的基础平台。
- 它拥有领导制定技术标准的组织Open CSA;
- 有清晰的软件设计的指导原则,譬如服务的封装性、自治、松耦合、可重用、可组合、无状态,等等;
- 明确了采用SOAP作为远程调用协议,依靠SOAP协议族(WSDL、UDDI和WS-*协议)来完成服务的发布、发现和治理;
- 利用企业服务总线(Enterprise Service Bus,ESB)的消息管道来实现各个子系统之间的交互,令各服务在ESB的调度下无须相互依赖就能相互通信,实现了服务松耦合,也为以后进一步实施业务流程编排(Business Process Management,BPM)提供了基础
- 使用服务数据对象(Service Data Object,SDO)来访问和表示数据,使用服务组件架构(Service Component Architecture,SCA)来定义服务封装的形式和服务运行的容器
但是SOA技术最终还是偃旗息鼓了,最主要的原因还是SOA基于SOAP协议,SOAP协议过于严格的规范定义带来过度的复杂性,而构建在SOAP基础之上的ESB、BPM、SCA、SDO等诸多上层建筑,进一步加剧了这种复杂性。
SOA自诞生的那一天起,就已经注定只能是少数系统阳春白雪式的精致奢侈品,它可以实现多个异构大型系统之间的复杂集成交互,却很难作为一种具有广泛普适性的软件架构风格来推广。SOA最终没有获得成功的致命伤与当年的EJB如出一辙,尽管有Sun和IBM等一众巨头在背后力挺,EJB仍然败于以Spring、Hibernate为代表的“草根框架”,可见一旦脱离人民群众,终究会淹没在群众的海洋之中,连信息技术也不曾例外。
SAO的设计理念和简单透明相悖甚远。
1.2.3.4微服务架构时代
微服务架构是一种通过多个小型服务组合来构建单个应用的架构风格,这些服务围绕业务能力而非特定的技术标准来构建。各个服务可以采用不同的编程语言、不同的数据存储技术,运行在不同的进程之中。服务采取轻量级的通信机制和自动化的部署机制实现通信与运维。
微服务和SOA的区别
从以上微服务的定义和特征中,你应该可以明显地感觉到微服务追求的是更加自由的架构风格,摒弃了几乎所有SOA里可以抛弃的约束和规定,提倡以“实践标准”代替“规范标准”。
可是,如果没有了统一的规范和约束,以前SOA解决的那些分布式服务的问题,不也就一下子都重新出现了吗?的确如此,对于服务的注册发现、跟踪治理、负载均衡、故障隔离、认证授权、伸缩扩展、传输通信、事务处理等问题,微服务中将不再有统一的解决方案。
即使只讨论Java范围内会使用到的微服务,仅一个服务间远程调用问题,可以列入解决方案的候选清单的就有RMI(Sun/Oracle)、Thrift(Facebook)、Dubbo(阿里巴巴)、gRPC(Google)、Motan2(新浪)、Finagle(Twitter)、brpc(百度)、Arvo(Hadoop)、JSON-RPC、REST,等等;仅一个服务发现问题,可以选择的就有Eureka(Netflix)、Consul(HashiCorp)、Nacos(阿里巴巴)、ZooKeeper(Apache)、etcd(CoreOS)、CoreDNS(CNCF),等等。其他领域也与此类似。
微服务所带来的自由是一把双刃开锋的宝剑,当软件架构者拿起这把宝剑,一刃指向SOA定下的复杂技术标准,将选择的权力夺回的同一时刻,另外一刃也正朝着自己映出冷冷的寒光。
在微服务时代,软件研发本身的复杂度确实有所降低。一个简单服务,并不见得会同时面临分布式中的所有问题,也就没有必要背上SOA那百宝袋般沉重的技术包袱。需要解决什么问题,就引入什么工具;团队熟悉什么技术,就使用什么框架。此外,像Spring Cloud这样胶水式的全家桶工具集,通过一致的接口、声明和配置,进一步屏蔽了源自具体工具、框架的复杂性,降低了在不同工具、框架之间切换的成本,所以,作为一个普通的服务开发者,作为一个“螺丝钉”式的程序员,微服务架构是友善的。
可是,微服务对架构者却是满满的“恶意”,对架构能力的要求已提升到史无前例的程度。技术架构者的第一职责就是决策权衡,有利有弊才需要决策,有取有舍才需要权衡,如果架构者本身的知识面不足以覆盖所需要决策的内容,不清楚其中利弊,恐怕将无可避免地陷入选择困难症的境遇之中。微服务时代充满着自由的气息,微服务时代充斥着迷茫的选择。
1.2.3.5后微服务时代(云原生时代)
容器技术给分布式架构提供了新思路。
当虚拟化的基础设施从单个服务的容器扩展至由多个容器构成的服务集群、通信网络和存储设施时,软件与硬件的界限便已模糊。一旦虚拟化的硬件能够跟上软件的灵活性,那些与业务无关的技术性问题便有可能从软件层面剥离,悄无声息地在硬件基础设施之内解决,让软件得以只专注业务,真正围绕业务能力构建团队与产品。
从软件层面独立应对分布式架构所带来的各种问题,发展到应用代码与基础设施软、硬一体,合力应对架构问题,这个新的时代现在常被媒体冠以“云原生”这个颇为抽象的名字加以宣传。云原生时代追求的目标与此前微服务时代追求的目标并没有本质改变,都是在服务架构演进的历史进程中,所以笔者更愿意称云原生时代为“后微服务时代”。
Kubernetes成为容器战争胜利者标志着后微服务时代的开启,但Kubernetes仍然没能完美解决全部的分布式问题。
微服务A调用了微服务B的两个服务,称为B1和B2,假设B1表现正常但B2出现了持续的500错,那在达到一定阈值之后就应该对B2进行熔断,以避免产生雪崩效应。如果仅在基础设施层面来处理,这会遇到一个两难问题,切断A到B的网络通路会影响B1的正常调用,不切断则会持续受B2的错误影响。
为了解决这一类问题,虚拟化的基础设施很快完成了第二次进化,引入了今天被称为“服务网格”(Service Mesh)的“边车代理模式”(Sidecar Proxy)。
在虚拟化场景中的边车指的是由系统自动在服务容器(通常是指Kubernetes的Pod)中注入一个通信代理服务器,相当于那个挎斗,以类似网络安全里中间人攻击的方式进行流量劫持,在应用毫无感知的情况下,悄然接管应用所有对外通信。这个代理除了实现正常的服务间通信外(称为数据平面通信),还接收来自控制器的指令(称为控制平面通信),根据控制平面中的配置,对数据平面通信的内容进行分析处理,以实现熔断、认证、度量、监控、负载均衡等各种附加功能。通过边车代理模式,便实现了既不需要在应用层面加入额外的处理代码,也提供了几乎不亚于程序代码的精细管理能力。
1.2.3.6无服务架构时代
无服务现在还没有一个特别权威的“官方”定义,但它的概念并没有前面提到的各种架构那么复杂,本来无服务也是以“简单”为主要卖点的,它只涉及两块内容:后端设施(Backend)和函数(Function)。
- 后端设施是指数据库、消息队列、日志、存储等这类用于支撑业务逻辑运行,但本身无业务含义的技术组件,这些后端设施都运行在云中,在无服务中将它们称为“后端即服务”(Backend as a Service,BaaS)。
- 函数是指业务逻辑代码,这里函数的概念与粒度都已经很接近于程序编码角度的函数了,其区别是无服务中的函数运行在云端,不必考虑算力问题,也不必考虑容量规划(从技术角度可以不考虑,从计费的角度还是要掂量一下的),在无服务中将其称为“函数即服务”(Function as a Service,FaaS)。
1.2.4分布式的优势与劣势
1.2.4.1优势
- 多个功能模块揉合在一起的系统进行服务拆分,来解耦服务间的调用,每个项目复杂度降低,团队的职责更加明确;
- 扩展更加灵活,复用性高,只需扩容流量特别大项目;
- 将模块提供的服务分布到不同的机器或容器里,部署更加灵活,不会因为一个小功能部署就部署所有系统,让整个团队留下来验证;
- 独立部署,错误隔离
1.2.4.2分布式带来的问题
- 需要更多优质人才懂分布式,人力成本增加
- 运维部署和维护成本显著增加
- 多服务间链路变长,开发排查问题难度加大
- 网络通信:网络本身的不可靠性,因此会涉及到一些网络通信问题.例如数据幂等性问题、数据的顺序问题等。
- 网络分区(脑裂):当网络发生异常导致分布式系统中部分节点之间的网络延时不断增大,最终导致组成分布式架构的所有节点,只有部分节点能够正常通信,引发了环境高可靠性问题。
- 三态:在分布式架构里面多了个状态:超时,所以有三态: 成功、失败、超时,架构设计变得复杂,学习成本高
- 分布式事务:ACID(原子性、一致性、隔离性、持久性)
- 中心化和去中心化:冷备或者热备
讲到分布式
不得不知道 CAP
定理和 Base
理论,这里给不知道的同学做一个扫盲。
1.3CAP 定理
在理论计算机科学中,CAP 定理指出对于一个分布式计算系统来说,不可能同时满足以下三点:
一致性(Consistency)
- 所有节点访问同一份最新的数据副本。
可用性(Availability)
- 每次请求都能获取到非错的响应(只要收到用户的请求,服务器就必须给出回应),但不保证获取的数据为最新数据
分区容错性(Partition tolerance)
- 不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在 C 和 A 之间做出选择)
Consistency 和 Availability 的矛盾
CAP原则的精髓就是要么AP,要么CP,要么AC,但是不存在CAP。如果在某个分布式系统中数据无副本, 那么系统必然满足强一致性条件, 因为只有独一数据,不会出现数据不一致的情况,此时C和P两要素具备,但是如果系统发生了网络分区状况或者宕机,必然导致某些数据不可以访问,此时可用性条件就不能被满足,即在此情况下获得了CP系统,但是CAP不可同时满足。
1.4BASE 理论
BASE
是 Basically Available
(基本可用)、Soft state
(软状态)和 Eventually consistent
(最终一致性)三个短语的缩写。BASE
理论是对 CAP
中 AP
的一个扩展,通过牺牲强一致性来获得可用性,当出现故障允许部分不可用但要保证核心功能可用,允许数据在一段时间内是不一致的,但最终达到一致状态。满足 BASE
理论的事务,我们称之为柔性事务
。
- 基本可用 : 分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。如电商网址交易付款出现问题来,商品依然可以正常浏览。
- 软状态: 由于不要求强一致性,所以BASE允许系统中存在中间状态(也叫软状态),这个状态不影响系统可用性,如订单中的“支付中”、“数据同步中”等状态,待数据最终一致后状态改为“成功”状态。
- 最终一致性: 最终一致是指的经过一段时间后,所有节点数据都将会达到一致。如订单的“支付中”状态,最终会变为“支付成功”或者“支付失败”,使订单状态与实际交易结果达成一致,但需要一定时间的延迟、等待。
2.分布式的坑
2.1分布式消息队列的坑
消息队列如何做分布式?
将消息队列里面的消息分摊到多个节点(指某台机器或容器)上,所有节点的消息队列之和就包含了所有消息。
2.1.1 消息队列的坑之非幂等
2.1.1.1幂等性概念
所谓幂等性就是无论多少次操作和第一次的操作结果一样。如果消息被多次消费,很有可能造成数据的不一致。
幂等性详细可以看我的另一篇文章:https://beaumon.cf/2022/09/21/liao-liao-mi-deng-xing/
2.1.1.2场景分析
RabbitMQ
、RocketMQ
、Kafka
消息队列中间件都有可能出现消息重复消费问题。这种问题并不是 MQ 自己保证的,而是需要开发人员来保证。
这几款消息队列中间都是是全球最牛的分布式消息队列,那肯定考虑到了消息的幂等性。我们以 Kafka 为例,看看 Kafka 是怎么保证消息队列的幂等性。
Kafka 有一个 偏移量
的概念,代表着消息的序号,每条消息写到消息队列都会有一个偏移量,消费者消费了数据之后,每过一段固定的时间,就会把消费过的消息的偏移量提交一下,表示已经消费过了,下次消费就从偏移量后面开始消费。
坑:
当消费完消息后,还没来得及提交偏移量,系统就被关机了,那么未提交偏移量的消息则会再次被消费。
如下图所示,队列中的数据 A、B、C,对应的偏移量分别为 100、101、102,都被消费者消费了,但是只有数据 A 的偏移量 100 提交成功,另外 2 个偏移量因系统重启而导致未及时提交。
系统重启,偏移量未提交
重启后,消费者又是拿偏移量 100 以后的数据,从偏移量 101 开始拿消息。所以数据 B 和数据 C 被重复消费。
如下图所示:
重启后,重复消费消息
2.1.1.3避坑指南
微信支付结果通知场景
- 微信官方文档上提到微信支付通知结果可能会推送多次,需要开发者自行保证幂等性。第一次我们可以直接修改订单状态(如支付中 -> 支付成功),第二次就根据订单状态来判断,如果不是支付中,则不进行订单处理逻辑。
插入数据库场景
- 每次插入数据时,先检查下数据库中是否有这条数据的主键 id,如果有,则进行更新操作。
写 Redis 场景
- Redis 的
Set
操作天然幂等性,所以不用考虑 Redis 写数据的问题。
- Redis 的
其他场景方案
- 生产者发送每条数据时,增加一个全局唯一 id,类似订单 id。每次消费时,先去 Redis 查下是否有这个 id,如果没有,则进行正常处理消息,且将 id 存到 Redis。如果查到有这个 id,说明之前消费过,则不要进行重复处理这条消息。
- 不同业务场景,可能会有不同的幂等性方案,大家选择合适的即可,上面的几种方案只是提供常见的解决思路。
2.1.2消息队列的坑之消息丢失
坑:
消息丢失会带来什么问题?如果是订单下单、支付结果通知、扣费相关的消息丢失,则可能造成财务损失,如果量很大,就会给甲方带来巨大损失。
那消息队列是否能保证消息不丢失呢?答案:否。主要有三种场景会导致消息丢失。
消息队列之消息丢失
2.1.2.1生产者存放消息的过程中丢失消息
生产者丢失消息
解决方案
- 事务机制(不推荐,异步方式)
对于 RabbitMQ 来说,生产者发送数据之前开启 RabbitMQ 的事务机制channel.txselect
,如果消息没有进队列,则生产者收到异常报错,并进行回滚 channel.txRollback
,然后重试发送消息;如果收到了消息,则可以提交事务 channel.txCommit
。但这是一个同步的操作,会影响性能。
- confirm 机制(推荐,异步方式)
我们可以采用另外一种模式:confirm
模式来解决同步机制的性能问题。每次生产者发送的消息都会分配一个唯一的 id,如果写入到了 RabbitMQ 队列中,则 RabbitMQ 会回传一个 ack
消息,说明这个消息接收成功。如果 RabbitMQ 没能处理这个消息,则回调 nack
接口。说明需要重试发送消息。
也可以自定义超时时间 + 消息 id 来实现超时等待后重试机制。但可能出现的问题是调用 ack 接口时失败了,所以会出现消息被发送两次的问题,这个时候就需要保证消费者消费消息的幂等性。
事务模式
和 confirm
模式的区别:
- 事务机制是同步的,提交事务后会被阻塞直到提交事务完成后。
- confirm 模式异步接收通知,但可能接收不到通知。需要考虑接收不到通知的场景。
2.1.2.2消息队列丢失消息
消息队列丢失消息
消息队列的消息可以放到内存中,或将内存中的消息转到硬盘(比如数据库)中,一般都是内存和硬盘中都存有消息。如果只是放在内存中,那么当机器重启了,消息就全部丢失了。如果是硬盘中,则可能存在一种极端情况,就是将内存中的数据写到硬盘的期间,消息队列出问题了,未能将消息持久化到硬盘。
解决方案
- 创建
Queue
的时候将其设置为持久化。 - 发送消息的时候将消息的
deliveryMode
设置为 2 。 - 开启生产者
confirm
模式,可以重试发送消息。
2.1.2.3消费者丢失消息
消费者丢失消息
消费者刚拿到数据,还没开始处理消息,结果进程因为异常退出了,消费者没有机会再次拿到消息。
解决方案
- 关闭 RabbitMQ 的自动
ack
,每次生产者将消息写入消息队列后,就自动回传一个ack
给生产者。 - 消费者处理完消息再主动
ack
,告诉消息队列我处理完了。
问题: 那这种主动 ack
有什么漏洞?如果 主动 ack
的时候挂了,怎么办?
则可能会被再次消费,这个时候就需要幂等处理了。
问题: 如果这条消息一直被重复消费怎么办?
则需要有加上重试次数的监测,如果超过一定次数则将消息丢失,记录到异常表或发送异常通知给值班人员。
2.1.2.4RabbitMQ 消息丢失总结
RabbitMQ 丢失消息的处理方案
2.1.2.5Kafka 消息丢失
场景:Kafka
的某个 broker(节点)宕机了,重新选举 leader (写入的节点)。如果 leader 挂了,follower 还有些数据未同步完,则 follower 成为 leader 后,消息队列会丢失一部分数据。
解决方案
- 给 topic 设置
replication.factor
参数,值必须大于 1,要求每个 partition 必须有至少 2 个副本。 - 给 kafka 服务端设置
min.insyc.replicas
必须大于 1,表示一个 leader 至少一个 follower 还跟自己保持联系。
2.1.3消息队列的坑之消息乱序
坑:
用户先下单成功,然后取消订单,如果顺序颠倒,则最后数据库里面会有一条下单成功的订单。
RabbitMQ 场景:
- 生产者向消息队列按照顺序发送了 2 条消息,消息1:增加数据 A,消息2:删除数据 A。
- 期望结果:数据 A 被删除。
- 但是如果有两个消费者,消费顺序是:消息2、消息 1。则最后结果是增加了数据 A。
RabbitMQ消息乱序场景
RabbitMQ 消息乱序场景
RabbitMQ 解决方案:
- 将 Queue 进行拆分,创建多个内存 Queue,消息 1 和 消息 2 进入同一个 Queue。
- 创建多个消费者,每一个消费者对应一个 Queue。
RabbitMQ 解决方案
Kafka 场景:
- 创建了 topic,有 3 个 partition。
- 创建一条订单记录,订单 id 作为 key,订单相关的消息都丢到同一个 partition 中,同一个生产者创建的消息,顺序是正确的。
- 为了快速消费消息,会创建多个消费者去处理消息,而为了提高效率,每个消费者可能会创建多个线程来并行的去拿消息及处理消息,处理消息的顺序可能就乱序了。
Kafka 消息丢失场景
Kafka 解决方案:
- 解决方案和 RabbitMQ 类似,利用多个 内存 Queue,每个线程消费 1个 Queue。
- 具有相同 key 的消息 进同一个 Queue。
Kafka 消息乱序解决方案
2.1.4消息队列的坑之消息积压
消息积压:消息队列里面有很多消息来不及消费。
场景 1: 消费端出了问题,比如消费者都挂了,没有消费者来消费了,导致消息在队列里面不断积压。
场景 2: 消费端出了问题,比如消费者消费的速度太慢了,导致消息不断积压。
坑:比如线上正在做订单活动,下单全部走消息队列,如果消息不断积压,订单都没有下单成功,那么将会损失很多交易。
消息队列之消息积压
解决方案:解铃还须系铃人
- 修复代码层面消费者的问题,确保后续消费速度恢复或尽可能加快消费的速度。
- 停掉现有的消费者。
- 临时建立好原先 5 倍的 Queue 数量。
- 临时建立好原先 5 倍数量的 消费者。
- 将堆积的消息全部转入临时的 Queue,消费者来消费这些 Queue。
消息积压解决方案
2.1.5消息队列的坑之消息过期失效
坑:
RabbitMQ 可以设置过期时间,如果消息超过一定的时间还没有被消费,则会被 RabbitMQ 给清理掉。消息就丢失了。
消息过期失效
解决方案:
- 准备好批量重导的程序
- 手动将消息闲时批量重导
消息过期失效解决方案
2.1.6消息队列的坑之队列写满
坑:
当消息队列因消息积压导致的队列快写满,所以不能接收更多的消息了。生产者生产的消息将会被丢弃。
解决方案:
- 判断哪些是无用的消息,RabbitMQ 可以进行
Purge Message
操作。 - 如果是有用的消息,则需要将消息快速消费,将消息里面的内容转存到数据库。
- 准备好程序将转存在数据库中的消息再次重导到消息队列。
- 闲时重导消息到消息队列。
2.2分布式缓存的坑
在高频访问数据库的场景中,我们会在业务层和数据层之间加入一套缓存机制,来分担数据库的访问压力,毕竟访问磁盘 I/O 的速度是很慢的。比如利用缓存来查数据,可能5ms就能搞定,而去查数据库可能需要 50 ms,差了一个数量级。而在高并发的情况下,数据库还有可能对数据进行加锁,导致访问数据库的速度更慢。
分布式缓存我们用的最多的就是 Redis了,它可以提供分布式缓存服务。
2.2.1Redis 数据丢失的坑
哨兵机制
Redis 可以实现利用哨兵机制
实现集群的高可用。那什么是哨兵机制呢?
- 英文名:
sentinel
,中文名:哨兵
。 - 集群监控:负责主副进程的正常工作。
- 消息通知:负责将故障信息报警给运维人员。
- 故障转移:负责将主节点转移到备用节点上。
- 配置中心:通知客户端更新主节点地址。
- 分布式:有多个哨兵分布在每个主备节点上,互相协同工作。
- 分布式选举:需要大部分哨兵都同意,才能进行主备切换。
- 高可用:即使部分哨兵节点宕机了,哨兵集群还是能正常工作。
坑:
当主节点发生故障时,需要进行主备切换,可能会导致数据丢失。
2.2.1.1异步复制数据导致的数据丢失
主节点异步同步数据给备用节点的过程中,主节点宕机了,导致有部分数据未同步到备用节点。而这个从节点又被选举为主节点,这个时候就有部分数据丢失了。
2.2.1.2脑裂导致的数据丢失
主节点所在机器脱离了集群网络,实际上自身还是运行着的。但哨兵选举出了备用节点作为主节点,这个时候就有两个主节点都在运行,相当于两个大脑在指挥这个集群干活,但到底听谁的呢?这个就是脑裂。
那怎么脑裂怎么会导致数据丢失呢?如果发生脑裂后,客户端还没来得及切换到新的主节点,连的还是第一个主节点,那么有些数据还是写入到了第一个主节点里面,新的主节点没有这些数据。那等到第一个主节点恢复后,会被作为备用节点连到集群环境,而且自身数据会被清空,重新从新的主节点复制数据。而新的主节点因没有客户端之前写入的数据,所以导致数据丢失了一部分。
避坑指南
- 配置 min-slaves-to-write 1,表示至少有一个备用节点。
- 配置 min-slaves-max-lag 10,表示数据复制和同步的延迟不能超过 10 秒。最多丢失 10 秒的数据
注意:缓存雪崩
、缓存穿透
、缓存击穿
并不是分布式所独有的,单机的时候也会出现。所以不在分布式的坑之列。
2.3分库分表的坑
2.3.1分库分表的坑之扩容
分库、分表、垂直拆分和水平拆分
分库: 因一个数据库支持的最高并发访问数是有限的,可以将一个数据库的数据拆分到多个库中,来增加最高并发访问数。
分表: 因一张表的数据量太大,用索引来查询数据都搞不定了,所以可以将一张表的数据拆分到多张表,查询时,只用查拆分后的某一张表,SQL 语句的查询性能得到提升。
分库分表优势:分库分表后,承受的并发增加了多倍;磁盘使用率大大降低;单表数据量减少,SQL 执行效率明显提升。
水平拆分: 把一个表的数据拆分到多个数据库,每个数据库中的表结构不变。用多个库扛更高的并发。比如订单表每个月有500万条数据累计,每个月都可以进行水平拆分,将上个月的数据放到另外一个数据库。
垂直拆分: 把一个有很多字段的表,拆分成多张表到同一个库或多个库上面。高频访问字段放到一张表,低频访问的字段放到另外一张表。利用数据库缓存来缓存高频访问的行数据。比如将一张很多字段的订单表拆分成几张表分别存不同的字段(可以有冗余字段)。
分库、分表的方式:
- 根据租户来分库、分表。
- 利用时间范围来分库、分表。
- 利用 ID 取模来分库、分表。
坑:
分库分表是一个运维层面需要做的事情,有时会采取凌晨宕机开始升级。可能熬夜到天亮,结果升级失败,则需要回滚,其实对技术团队都是一种煎熬。
怎么做成自动的来节省分库分表的时间?
- 双写迁移方案:迁移时,新数据的增删改操作在新库和老库都做一遍。
- 使用分库分表工具 Sharding-jdbc 来完成分库分表的累活。
- 使用程序来对比两个库的数据是否一致,直到数据一致。
坑:
分库分表看似光鲜亮丽,但分库分表会引入什么新的问题呢?
垂直拆分带来的问题
- 依然存在单表数据量过大的问题。
- 部分表无法关联查询,只能通过接口聚合方式解决,提升了开发的复杂度。
- 分布式事处理复杂。
水平拆分带来的问题
- 跨库的关联查询性能差。
- 数据多次扩容和维护量大。
- 跨分片的事务一致性难以保证。
2.3.2分库分表的坑之唯一 ID
为什么分库分表需要唯一 ID
- 如果要做分库分表,则必须得考虑表主键 ID 是全局唯一的,比如有一张订单表,被分到 A 库和 B 库。如果 两张订单表都是从 1 开始递增,那查询订单数据时就错乱了,很多订单 ID 都是重复的,而这些订单其实不是同一个订单。
- 分库的一个期望结果就是将访问数据的次数分摊到其他库,有些场景是需要均匀分摊的,那么数据插入到多个数据库的时候就需要交替生成唯一的 ID 来保证请求均匀分摊到所有数据库。
坑:
唯一 ID 的生成方式有 n 种,各有各的用途,别用错了。
生成唯一 ID 的原则
- 全局唯一性
- 趋势递增
- 单调递增
- 信息安全
生成唯一 ID 的几种方式
数据库自增 ID。每个数据库每增加一条记录,自己的 ID 自增 1。
- 多个库的 ID 可能重复,这个方案可以直接否掉了,不适合分库分表后的 ID 生成。
- 信息不安全
- 缺点
适用
UUID
唯一 ID。- UUID 太长、占用空间大。
- 不具有有序性,作为主键时,在写入数据时,不能产生有顺序的 append 操作,只能进行 insert 操作,导致读取整个
B+
树节点到内存,插入记录后将整个节点写回磁盘,当记录占用空间很大的时候,性能很差。 - 缺点
获取系统当前时间作为唯一 ID。
- 高并发时,1 ms内可能有多个相同的 ID。
- 信息不安全
- 缺点
Twitter 的
snowflake
(雪花算法):Twitter 开源的分布式 id 生成算法,64 位的 long 型的 id,分为 4 部分snowflake 算法
基本原理和优缺点:
1 bit:不用,统一为 0
41 bits:毫秒时间戳,可以表示 69 年的时间。
10 bits:5 bits 代表机房 id,5 个 bits 代表机器 id。最多代表 32 个机房,每个机房最多代表 32 台机器。
12 bits:同一毫秒内的 id,最多 4096 个不同 id,自增模式。
优点:
- 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。
- 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。
- 可以根据自身业务特性分配bit位,非常灵活。
缺点:
- 强依赖机器时钟,如果机器上时钟回拨(可以搜索 2017 年闰秒 7:59:60),会导致发号重复或者服务会处于不可用状态。
百度的
UIDGenerator
算法。UIDGenerator 算法
- 基于 Snowflake 的优化算法。
- 借用未来时间和双 Buffer 来解决时间回拨与生成性能等问题,同时结合 MySQL 进行 ID 分配。
- 优点:解决了时间回拨和生成性能问题。
- 缺点:依赖 MySQL 数据库。
美团的
Leaf-Snowflake
算法。获取 id 是通过代理服务访问数据库获取一批 id(号段)。
双缓冲:当前一批的 id 使用 10%时,再访问数据库获取新的一批 id 缓存起来,等上批的 id 用完后直接用。
优点:
- Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景。
- ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。
- 容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。
- 可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。
- 即使DB宕机,Leaf仍能持续发号一段时间。
- 偶尔的网络抖动不会影响下个号段的更新。
缺点:
- ID号码不够随机,能够泄露发号数量的信息,不太安全。
怎么选择:一般自己的内部系统,雪花算法足够,如果还要更加安全可靠,可以选择百度或美团的生成唯一 ID 的方案。
2.4分布式事务的坑
怎么理解事务?
- 事务可以简单理解为要么这件事情全部做完,要么这件事情一点都没做,跟没发生一样。
- 在分布式的世界中,存在着各个服务之间相互调用,链路可能很长,如果有任何一方执行出错,则需要回滚涉及到的其他服务的相关操作。比如订单服务下单成功,然后调用营销中心发券接口发了一张代金券,但是微信支付扣款失败,则需要退回发的那张券,且需要将订单状态改为异常订单。
坑
:如何保证分布式中的事务正确执行,是个大难题。
分布式事务的几种主要方式
- XA 方案(两阶段提交方案)
- TCC 方案(try、confirm、cancel)
- SAGA 方案
- 可靠消息最终一致性方案
- 最大努力通知方案
XA 方案原理
XA 方案
- 事务管理器负责协调多个数据库的事务,先问问各个数据库准备好了吗?如果准备好了,则在数据库执行操作,如果任一数据库没有准备,则回滚事务。
- 适合单体应用,不适合微服务架构。因为每个服务只能访问自己的数据库,不允许交叉访问其他微服务的数据库。
TCC 方案
- Try 阶段:对各个服务的资源做检测以及对资源进行锁定或者预留。
- Confirm 阶段:各个服务中执行实际的操作。
- Cancel 阶段:如果任何一个服务的业务方法执行出错,需要将之前操作成功的步骤进行回滚。
应用场景:
- 跟支付、交易打交道,必须保证资金正确的场景。
- 对于一致性要求高。
缺点:
- 但因为要写很多补偿逻辑的代码,且不易维护,所以其他场景建议不要这么做。
Sega 方案
基本原理:
- 业务流程中的每个步骤若有一个失败了,则补偿前面操作成功的步骤。
适用场景:
- 业务流程长、业务流程多。
- 参与者包含其他公司或遗留系统服务。
优势:
- 第一个阶段提交本地事务、无锁、高性能。
- 参与者可异步执行、高吞吐。
- 补偿服务易于实现。
缺点:
- 不保证事务的隔离性。
可靠消息一致性方案
可靠消息一致性方案
基本原理:
- 利用消息中间件
RocketMQ
来实现消息事务。 - 第一步:A 系统发送一个消息到 MQ,MQ将消息状态标记为
prepared
(预备状态,半消息),该消息无法被订阅。 - 第二步:MQ 响应 A 系统,告诉 A 系统已经接收到消息了。
- 第三步:A 系统执行本地事务。
- 第四步:若 A 系统执行本地事务成功,将
prepared
消息改为commit
(提交事务消息),B 系统就可以订阅到消息了。 - 第五步:MQ 也会定时轮询所有
prepared
的消息,回调 A 系统,让 A 系统告诉 MQ 本地事务处理得怎么样了,是继续等待还是回滚。 - 第六步:A 系统检查本地事务的执行结果。
- 第七步:若 A 系统执行本地事务失败,则 MQ 收到
Rollback
信号,丢弃消息。若执行本地事务成功,则 MQ 收到Commit
信号。 - B 系统收到消息后,开始执行本地事务,如果执行失败,则自动不断重试直到成功。或 B 系统采取回滚的方式,同时要通过其他方式通知 A 系统也进行回滚。
- B 系统需要保证幂等性。
最大努力通知方案
基本原理:
- 系统 A 本地事务执行完之后,发送消息到 MQ。
- MQ 将消息持久化。
- 系统 B 如果执行本地事务失败,则
最大努力服务
会定时尝试重新调用系统 B,尽自己最大的努力让系统 B 重试,重试多次后,还是不行就只能放弃了。转到开发人员去排查以及后续人工补偿。
几种方案的选择
- 跟支付、交易打交道,优先 TCC。
- 大型系统,但要求不那么严格,考虑 消息事务或 SAGA 方案。
- 单体应用,建议 XA 两阶段提交就可以了。
- 最大努力通知方案建议都加上,毕竟不可能一出问题就交给开发排查,先重试几次看能不能成功。