first commit
This commit is contained in:
101
专栏/中间件核心技术与实战/00开篇词为什么中间件对分布式架构体系来说这么重要?.md
Normal file
101
专栏/中间件核心技术与实战/00开篇词为什么中间件对分布式架构体系来说这么重要?.md
Normal file
@ -0,0 +1,101 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 为什么中间件对分布式架构体系来说这么重要?
|
||||
你好,我是丁威。
|
||||
|
||||
一名奋战在 IT 一线十多年的技术老兵,现任中通快递技术平台部资深架构师,也是 Apache RocketMQ 社区的首席布道师,《RocketMQ 技术内幕》一书的作者。
|
||||
|
||||
不知道你有没有发现这样一个现状,深度实践分布式架构体系还得看大厂,他们所提供的高并发、大数据等应用场景更是众多研发工程师的练兵地,给出的薪资、待遇、发展潜力也远超小平台。但说句现实点的,绝大多数 Java 从业人员其实都在干着 CRUD 的工作,并没有机会去实践高并发。一边是大厂牛人岗位的稀缺,一边是研发工程师的晋升无门,怎么打破这个死循环,自开一扇窗呢?
|
||||
|
||||
结合我自己的经历,加上这些年我对研发工程师的职场发展的思考,我觉得中间件这个细分赛道或许可以奋力一搏。甚至可以说,学习它已经是进入大厂的必备条件了。
|
||||
|
||||
第一阶段:高效工作
|
||||
|
||||
对于刚开始接触系统架构的人来说,熟练掌握中间件是高效工作的前提。因为中间件是互联网分布式架构设计必不可少的部分,几乎每一个分布式系统都有一种乃至几种中间件在系统中发挥作用。
|
||||
|
||||
中间件的这种持续发展和系统的内部结构有关。可以结合你们公司的业务想一下,为了追求高并发、高性能、高可用性还有扩展性,是不是在对软件架构进行部署时,通常会采用分层架构思想,将系统架构分为接入层、基础层、服务层、数据存储层和运行环境,而每一层需要解决的问题各不相同。就像这样一个系统架构模型。
|
||||
|
||||
|
||||
|
||||
但单凭这个架构并不能解决所有问题。试想一下,如果一家公司每做一个项目都要自己去实现一套事务管理、一套定时任务调度框架,那么他们的业务交付效率一定会很低。这不但会给开发编码带来极大的技术挑战,同时系统也需要面临高并发、大流量的冲击。在这么多未知的挑战和不可控的因素当中,要想交付一套稳定的系统可以说是困难重重。
|
||||
|
||||
好在随着分布式架构体系的不断演变,越来越多的优秀中间件应运而生。我们无需再重复造轮子,可以直接在项目中使用这些优秀的中间件,把更多精力放在业务功能的开发上,在提高交付效率的同时也使得系统更加稳定,一举多得。
|
||||
|
||||
中间件的种类非常多,不可能尽数列举。但我把各个领域主流的中间件汇总在一起,做了一张思维导图,供你随时查看:
|
||||
|
||||
|
||||
|
||||
那随着中间件的逐渐增多,必然会出现一个现象:各个项目基本都会用到一个或多个中间件。为了更加出色地完成工作,掌握这些中间件的使用方法、设计理念,了解它们的设计缺陷就成了我们的必修课。
|
||||
|
||||
第二阶段:突破高并发
|
||||
|
||||
入行一段时间之后,认识高并发、突破高并发就成了我们每个人都要面对的问题。
|
||||
|
||||
中间件和高并发密切相关,这是因为每一款优秀的中间件几乎都是由各个行业中的头部企业贡献的。中间件的诞生几乎无一例外都是为了解决特定业务领域的技术挑战,需要满足高并发、高性能、高可用三大功能。也就是说,每一款中间件的设计理念、代码编码都会遵循高并发领域的一些常见理论。
|
||||
|
||||
例如,我们非常熟悉的消息中间件 Apache RocketMQ,它承载了阿里“双十一”巨大的流量,那它具体是如何应对这一场景的?又采用了什么“牛逼”的技术架构?
|
||||
|
||||
尽管我们暂时没有机会参与阿里双十一这样的大流量场景,没法从第一现场了解这些问题,但我们可以通过深入学习和研究 Apache Apache 项目去体会高并发编程的魅力,让 Apache RocketMQ 中的编程技巧成为我们的“经验”。这样一来,我们不就可以用最低成本轻松拿下高并发场景了吗?
|
||||
|
||||
再说回职场晋升,我相信你也和我一样,在准备面试时总会先背诵一下“零拷贝”相关的理论知识,因为它是一个非常高频的面试题。但你知道怎么在项目中实际运用零拷贝技术来提升系统的性能吗?
|
||||
|
||||
听到这个问题是不是没了思路?其实,RocketMQ 作为一款文件存储领域非常知名的消息中间件,就运用了“零拷贝”技术,这部分内容也会在我的专栏中体现。我们要做的只是翻阅对应的源码,进行相应的练习和总结,就可以真正掌握“零拷贝”了。
|
||||
|
||||
讲到这里你应该也发现了,中间件是我们突破高并发的利器。它能够最大程度弥补我们缺少的高并发场景实战经验,为我们提供最优秀的项目实践机会。
|
||||
|
||||
第三阶段:防患于未然
|
||||
|
||||
那是不是只要能够熟练使用这些技术、框架就够了呢?
|
||||
|
||||
我认为,中间件的学习进程到这里还远没有结束。由于中间件在分布式互联网架构体系中占据着非常重要的位置,因此,很多故障都和中间件的使用不当有关。只有深入中间件的底层设计原理,读懂源码,才能将很多问题扼杀在摇篮中。
|
||||
|
||||
相反,如果故障已经发生了,哪怕你的故障排查能力和处理能力再强,一旦出了问题,就会对业务造成重大影响或者给公司带来资金损失,这些都是无法挽回的。
|
||||
|
||||
为了尽可能避免这类问题,很多公司都设置了故障追责机制。例如,阿里巴巴就有“325”,意思是,如果你的系统出现了一次比较大的故障,那么绩效得分为 325,全年绩效为 0。这样的问题我想是大家都不愿意看到的。
|
||||
|
||||
不过,只要我们加强对中间件工作机制的了解,提前发现系统的“病灶”,及时规避掉风险,就能防止公司和个人面临不可估量的损失。
|
||||
|
||||
课程设计
|
||||
|
||||
总结一下,学好中间件可以提高我们的工作效率、突破高并发瓶颈,还能防患于未然,极大地减少公司和个人的损失。如果你对这些问题感兴趣,那我的专栏就是为你打造的。
|
||||
|
||||
《中间件核心技术与实战》共分为六个模块。
|
||||
|
||||
|
||||
|
||||
在全局认知篇,我会介绍中间件在互联网分布式架构体系中的整体面貌,并重点对数据库、缓存等中间件的发展和选型依据做详细的介绍,帮助你更快掌握技术架构的发展方向,合理选择中间件。
|
||||
|
||||
在基础篇,我会系统讲解中间件必备的基础知识,主要包括 Java 常用数据结构、并发编程与网络编程。通过图解的方式,你可以更好地吸收这些原理,不再像背诵八股文一样学习理论知识,而是通过技术背后的设计理念,做到一通百通。
|
||||
|
||||
实战篇是我们全专栏最核心的内容,它分为微服务体系 Dubbo、消息中间件和定时调度任务三个部分。我会按照设计理念、选型标准、实战演练的顺序展开。带你从理论到实践,解决实际生产中遇到的问题。
|
||||
|
||||
最后是综合案例篇,我给你提供了一个全链路压测的落地项目,方便你全方位地串起各个主流中间件,完成对中间件的综合应用。
|
||||
|
||||
学完这个专栏,你应该能够对中间件的主要分类有更宏观地了解,掌握微服务、消息中间件、定时调度框架的设计场景,灵活应对高并发场景。
|
||||
|
||||
写在最后
|
||||
|
||||
最后我想说,中间件是分布式架构绕不开的话题,对于主流的中间件,你可能早就听说或者使用过,但是,中间件始终在发展和迭代,为了适应未来的变化、从容应对庞大的数据量,我们应该走得更深、更扎实一些,打造自己难以被撼动的职场竞争力。
|
||||
|
||||
回想我自己 10 余年的奋斗经历,正是不断的学习让我实现了职位和技能的突破。在我职业生涯的前几年,因为没有良好的教育背景,又长期在传统行业从事电子政务相关系统的开发,我无缘接触高并发,成为了一名“CRUD 工程师”。
|
||||
|
||||
好在,2017 年我迎来了自己职业生涯的转折点。这一年,RocketMQ 正式成为 Apache 顶级开源项目,通过研读 RocketMQ 的架构设计、编程技巧,我彻底突破了高并发门槛,找到了向大厂晋升的那扇窗。
|
||||
|
||||
在这期间,我也总结出了一套学习中间件的基本方法论,学完这些内容,如果你对其他类型的中间件也很感兴趣,可以用这个方法持续深挖,更高效、透彻地掌握其他类型的中间件。
|
||||
|
||||
阅读官方架构设计文档,从整体上把握这款中间件的架构、设计理念、工作机制。
|
||||
|
||||
阅读官方用户手册文档,初步了解如何使用这款中间件。
|
||||
|
||||
搭建自己的开发调试环境,运行官方 Demo 示例,进一步掌握这款中间件的使用方法。
|
||||
|
||||
结合中间件的架构设计文档、亮点技术追溯源码,掌握落地细节并举一反三,结合使用场景进行理解。这是彻底掌握中间件的关键。
|
||||
|
||||
好了,说了这么多,我想最重要的还是迈出学习的第一步。如果你对中间件有所困惑,或者希望在高并发场景中游刃有余,那就和我一起开启这次学习之旅吧,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
227
专栏/中间件核心技术与实战/01中间件生态(上):有哪些类型的中间件?.md
Normal file
227
专栏/中间件核心技术与实战/01中间件生态(上):有哪些类型的中间件?.md
Normal file
@ -0,0 +1,227 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 中间件生态(上):有哪些类型的中间件?
|
||||
你好,我是丁威。
|
||||
|
||||
最近十年是互联网磅礴发展的十年,IT 系统从单体应用逐渐向分布式架构演变,高并发、高可用、高性能、分布式等话题变得异常火热,中间件也在这一时期如雨后春笋般涌现出来,那到底什么是中间件呢?存在哪些类型的中间件呢?同一类型的中间件,我们该怎么选择?接下来的两节课,我们就来聊聊这些问题。
|
||||
|
||||
中间件的种类很多,我们无法把所有类型和产品列出来逐一讲解。但是每个类别的中间件在设计原理、使用上有很多共同的考量标准,只要了解了最重要、最主流的几种中间件,我们就可以方便地进行知识迁移,举一反三了,然后学习其他中间件将变得非常简单。
|
||||
|
||||
所以呢,你可以把这两节课看作是提纲挈领的知识清单。下面我们讲到的中间件你不一定都能够用上,但在需要的时候,可以帮你从更加高屋建瓴的角度迅速决策。
|
||||
|
||||
什么是中间件?
|
||||
|
||||
先来说说什么是中间件,我认为中间件是游离于业务需求之外,专门为了处理项目中涉及高可用、高性能、高并发等技术需求而引入的一个个技术组件。它的一个重要作用就是能够实现业务代码与技术功能之间解耦合。
|
||||
|
||||
这么说是不是还有点抽象?在这里定义里,我提到了业务需求和技术需求,关于这两个词我需要再解释一下。
|
||||
|
||||
业务需求,笼统地说就是特定用户的特定诉求。以我们快递行业为例:人与人之间需要跨城市传递物品,逢年过节我们需要给远方的亲人寄礼物,这就是所谓的业务需求。
|
||||
|
||||
技术需求,就是随着业务的不断扩展,形成规模效应后带来的使用上的需求。例如上面提到的寄件服务,原先只需要服务 1 万个客户,用户体验非常好,但现在需要服务几个亿的用户,用户在使用的过程中就会出现卡顿、系统异常等问题,因此产生可用性、稳定性方面的技术诉求。
|
||||
|
||||
为了解决各式各样的业务和技术诉求,代码量会越来越多。如果我们任凭业务代码与技术类代码没有秩序地纠缠在一起,系统会变得越来越不可维护,运营成本也会成指数级增加,故障频发,最终直接导致项目建设失败。
|
||||
|
||||
怎么解决这个问题呢?计算机领域有一个非常经典的分层架构思想,还有这样一句话“计算机领域任何一个问题都可以通过分层来解决,如果不行,那就再增加一层。”要想让系统做得越来越好,我们通常会基于分层的架构思想引入一个中间层,专门来解决可用性、稳定性、高性能方面的技术类诉求,这个中间层就是中间件,这也正是“中间件”这个词的来源。
|
||||
|
||||
中间件生态漫谈
|
||||
|
||||
明白了中间件的内涵,我们再来看看市面上有哪些中间件。我在开篇词中已经提到过了,中间件的种类繁多,我整理了一版分布式架构体系中常见的中间件,你可以先打开图片仔细看一看。
|
||||
|
||||
|
||||
|
||||
结合我 10 多年的从业经验,特别是对互联网主流分布式架构体系的研读,我发现微服务中间件、消息中间件、定时调度的使用频率极高,在解决分布式架构相关问题中是排头兵,具有无可比拟的普适性。这三者的设计理念和案例能对分布式、高可用和高并发等理念实现全覆盖。
|
||||
|
||||
所以,在专栏的第三章到第五章,我会深度剖析微服务、消息中间件和定时调度这三个方向,结合生产级经典案例深入剖析它们的架构设计理念,带你扎实地掌握分布式架构设计相关的基本技能。
|
||||
|
||||
|
||||
微服务
|
||||
|
||||
|
||||
具体而言,作为软件架构从单体应用向分布式演进出现的第一个新名词,微服务涉及分布式领域中服务注册、服务动态发现、RPC 调用、负载均衡、服务聚合等核心技术,而 Dubbo 在微服务领域是当仁不让的王者。所以在微服务这一部分,我们会以 Dubbo 为例进行实战演练。
|
||||
|
||||
|
||||
消息中间件
|
||||
|
||||
|
||||
随着微服务的蓬勃发展,系统的复杂度越来越高,加上互联网秒杀、双十一、618 等各种大促活动层出不穷,我们急切需要对系统解耦和应对突发流量的解决办法,这时候消息中间件应运而生了,它同样成为我们架构设计工作中最常用的工具包。常用的消息中间件包括 RocketMQ、Kafka,它们在适用性上有所不同,如何保障消息中间件的稳定性是一大挑战。
|
||||
|
||||
|
||||
定时调度
|
||||
|
||||
|
||||
而定时调度呢?我们既可以认为它是个技术需求,也可以认为它是一个业务类需求,通过研读 ElasticJob、XXL-Job 等定时调度框架,可以很好地提升我们对业务需求的架构设计能力。
|
||||
|
||||
这三部分我们会在后面的模块中重点展开,所以这一模块不做深入讲解。接下来,为了让你对主流中间件有一个更全面的认知,我会分两节课对另外的几类中间件(数据库、缓存、搜索、日志等)进行简要阐述,以补全你的中间件知识图谱,帮助你更加有底气、有效率地进行决策。这节课,我们先来看看数据库中间件。
|
||||
|
||||
数据库中间件
|
||||
|
||||
数据库中间件应该是我们接触得最早也是最为常见的中间件,在引入数据库中间件之前,由于单体应用向分布式架构演进的过程中单表日数据急速增长,单个数据库的节点很容易成为系统瓶颈,无法提供稳定的服务。因此,为了解决可用性问题,在技术架构领域通常有如下两种解决方案:
|
||||
|
||||
|
||||
读写分离
|
||||
分裤分表
|
||||
|
||||
|
||||
我们先分别解析下这两个方案。最后再来看一看,引入数据库中间件给技术带来的简化。
|
||||
|
||||
读写分离
|
||||
|
||||
这是我在没有接触中间件之前,在一个项目中使用过的方案:
|
||||
|
||||
|
||||
|
||||
这个方案的实现要点有三个。
|
||||
|
||||
第一,在编写业务接口时,要通过在接口上添加注解来指示运行时应该使用的数据源。例如,@SlaveofDB 表示使用 Slave 数据库,@MasterOfDB 表示使用主库。
|
||||
|
||||
第二,当用户发起请求时,要先经过一个拦截器获取用户请求的具体接口,然后使用反射机制获取该方法上的注解。举个例子,如果存在 @SlaveofDB,则往线程上下文环境中存储一个名为 dbType 的变量,赋值为 slave,表示走从库;如果存在 @MasterOfDB,则存储为 master,表示走主库。
|
||||
|
||||
第三,在 Dao 层采用 Spring 提供的路由选择机制,继承自 AbastractRoutingDataSource。应用程序启动时自动注入两个数据源 (master-slave),采用 key-value 键值对的方式存储。在真正需要获取链接时,根据上下文环境中存储的数据库类型,从内部持有的 dataSourceMap 中获取对应的数据源,从而实现数据库层面的读写分离。
|
||||
|
||||
总结一下,读写分离的思路就是通过降低写入节点的负载,将耗时的查询类请求转发到从节点,从而有效提升写入的性能。
|
||||
|
||||
但是,当业务量不断增加,单个数据库节点已无法再满足业务需求时,我们就要对数据进行切片,分库分表的技术思想就应运而生了。
|
||||
|
||||
分库分表
|
||||
|
||||
分库分表是负载均衡在数据库领域的应用,主要的原理你可以参考下面这张图。
|
||||
|
||||
|
||||
|
||||
简单说明一下。分库分表主要是通过引入多个写入节点来缓解数据压力的。因此,在接受写入请求后,负载均衡算法会将数据路由到其中一个节点上,多个节点共同分担数据写入请求,降低单个节点的压力,提升扩展性,解决单节点的性能瓶颈。
|
||||
|
||||
不过,要实现数据库层面的分库分表还是存在一定技术难度的。因为分库分表和读写分离一样,最终要解决的都是如何选择数据源的问题。所以在分库分表方案中,首先我们要有两个算法。
|
||||
|
||||
|
||||
一个分库字段和分库算法,即在进行数据查询、数据写入时,根据分库字段的值算出要路由到哪个数据库实例上;
|
||||
|
||||
一个分表字段和分表算法,即在进行数据查询、数据写入时,根据分表字段的值算出要路由到哪个表上。
|
||||
|
||||
|
||||
不管是上面的分库、还是分表都需要解决一个非常关键的问题:SQL 解析。你可以看下面这张图。
|
||||
|
||||
|
||||
|
||||
如果订单库的分库字段设置为 order_no,要想正确执行这条 SQL 语句,我们首先要解析这条 SQL 语句,提取 order_no 的字段值,再根据分库算法 (负载均衡算法) 计算应该发送到哪一个具体的库上执行。
|
||||
|
||||
SQL 语句语法非常复杂,要实现一套高性能的 SQL 解析引擎绝非易事,如果按照上面我提供的解决方案,将会带来几个明显的弊端。
|
||||
|
||||
|
||||
技术需求会污染业务代码,维护成本高
|
||||
|
||||
|
||||
在业务控制器中需要使用注解来声明读写分离按相关的规则进行,随着业务控制的不断增加、或者读写分离规则的变化,我们需要对系统所有注解进行修改,但业务逻辑其实并没有改变。这就造成两者之间相互影响,后期维护成本较高。
|
||||
|
||||
|
||||
技术实现难度大,极大增加开发成本
|
||||
|
||||
|
||||
由于 SQL 语句的格式太复杂、太灵活,如果不是数据库专业人才,很难全面掌握 SQL 语法。在这样的情况下,你写出的 SQL 解析引擎很难覆盖所有的场景,容易出现遗漏最终导致故障的发生;这也给产品的性能带来极大挑战。
|
||||
|
||||
那怎么办呢?其实,我们完全可以使用业界大神的开源作品来解决问题,这就要说到数据库中间件了。
|
||||
|
||||
引进数据库中间件
|
||||
|
||||
技术类诉求往往是相通的,极具普适性,为了解决上面的通病,根据分层的架构理念,我们通常会引入一个中间层,专门解决数据库方面的技术类需求。
|
||||
|
||||
MyCat 和 ShardingJDBC/ShardingSphere 是目前市面最主流的两个数据库中间件,二者各有优势。
|
||||
|
||||
MyCat 服务端代理模式
|
||||
|
||||
先来看下 MyCat 代理数据库。它的工作模式可以用下面这张图概括:
|
||||
|
||||
|
||||
|
||||
面对应用程序,MyCat 会伪装成一个数据库服务器 (例如 MySQL 服务端)。它会根据各个数据库的通信协议,从二进制请求中根据协议进行解码,然后提取 SQL,并根据配置的分库分表、读写分离规则计算出需要发送到哪个物理数据库。
|
||||
|
||||
随后,面对真实的数据库资源,MyCat 会伪装成一个数据库客户端。它会根据通信协议将 SQL 语句封装成二进制流,发送请求到真实的物理资源,真实的物理数据库收到请求后解析请求并进行对应的处理,再将结果层层返回到应用程序。
|
||||
|
||||
这种架构的优势是它对业务代码无任何侵入性,应用程序只需要修改项目中数据库的连接配置就可以了,而且使用简单,易于推广。同时它也有劣势:
|
||||
|
||||
|
||||
存在性能损耗
|
||||
|
||||
|
||||
数据库中间件需要对应用程序发送过来的请求进行解码并计算路由,随后它还要再次对请求进行编码并转发到真实的数据库,这就增加了性能开销。
|
||||
|
||||
|
||||
高度中心化,数据库中间件容易成为性能瓶颈
|
||||
|
||||
|
||||
数据库中间件需要处理所有的数据库请求,返回结果都需要在数据库中进行聚合,虽然减少了后端数据库的压力,但中间件本身很容易成为系统的瓶颈,扩展能力受到一定制约。
|
||||
|
||||
|
||||
代理层实现复杂,普适性差
|
||||
|
||||
|
||||
数据库中间件本身的实现比较复杂,需要适配市面上各主流数据库,例如 MySQL、Oracle 等,通用性大打折扣。
|
||||
|
||||
ShardingJDBC 客户端代理模式
|
||||
|
||||
下面我们再来看下 ShardingJDBC 客户端代理数据库。ShardingJDBC 的工作模式如下图所示:
|
||||
|
||||
|
||||
|
||||
ShardingJDBC 主要实现的是 JDBC 协议。实现 JDBC 协议,其实主要是面向 java.sql.Datasource、Connection、ResultSet 等对象编程。它通常以客户端 Jar 包的方式嵌入到业务系统中,ShardingjJDBC 根据分库分表的配置信息,初始化一个 ShardingJdbcDatasource 对象,随后解析 SQL 语句来提取分库、分表字段值,再根据配置的路由规则选择正确的后端真实数据库,最后,ShardingJDBC 用各种类型数据库的驱动包将 SQL 发送到真实的物理数据库上。
|
||||
|
||||
我们同样来分析一下这个方案的优缺点。
|
||||
|
||||
主要的优势有如下几点:
|
||||
|
||||
|
||||
无性能损耗
|
||||
|
||||
|
||||
ShardingJDBC 使用的是基于客户端的代理模式,不需要对 SQL 进行编码解码等操作,只要根据 SQL 语句进行路由选择就可以了,没有太多性能损耗。
|
||||
|
||||
|
||||
无单点故障,扩展性强
|
||||
|
||||
|
||||
ShardingJDBC 以 Jar 包的形式存在于项目中,其分布式特性随着应用的增加而增加,扩展性极强。
|
||||
|
||||
|
||||
基于JDBC协议,可无缝支持各主流数据库
|
||||
|
||||
|
||||
JDBC 协议是应用程序与关系型数据库交互的业界通用标准,市面上所有关系型数据库都天然支持 JDBC,故不存在兼容性问题。
|
||||
|
||||
当然缺点也很明显,对于分库分表,它没有一个统一的视图,运维类成本较高。举个例子,如果订单表被分成了 1024 个表,这时候如果你想根据订单编号去查询数据,必须人为计算出这条数据存在于哪个库的哪个表中,然后再去对应的库上执行 SQL 语句。
|
||||
|
||||
为了解决 ShardingJDBC 存在的问题,官方提供了 ShardingSphere,其工作机制基于代理模式,与 MyCat 的设计理念一致,作为数据库的代理层,提供统一的数据聚合层,可以有效弥补 ShardingJDBC 在运维层面的缺陷,因此项目通常采用 ShardingDBC 的编程方式,然后再搭建一套 ShardingSphere 供数据查询。
|
||||
|
||||
在没有 ShardingSphere 之前,使用 MyCat 也有一定优势。MyCat 对业务代码无侵入性,接入成本也比较低。但 ShardingSphere 弥补了 ShardingJDBC 对运维的不友好,而且它的性能损耗低、扩展性强、支持各类主流数据库,可以说相比 MyCat 已经占有明显的优势了。
|
||||
|
||||
所以如果要在实践生产中选择数据库中间件,我更加推荐 ShardingJDBC。
|
||||
|
||||
除了上面的原因,从资源利用率和社区活跃度的角度讲,首先,MyCat 的“前身”是阿里开源的 Cobar,是数据库中间件的开山鼻祖,技术架构稍显古老,而 ShardingJDBC 在设计之初就可以规避 MyCat 的固有缺陷,摒弃服务端代理模式。代理模式需要额外的机器搭建 MyCat 进程,引入了新的进程,势必需要增加硬件资源的投入。
|
||||
|
||||
其次,ShardingJDBC 目前已经是 Apache 的顶级项目,它的社区活跃度也是 MyCat 无法比拟的。一个开源项目社区越活跃,寻求帮助后问题得到解决的概率就会越大,越多人使用,系统中存在的 Bug 也更容易被发现、被修复,这就使得中间件本身的稳定性更有保障。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里,我们来做个小结。通过刚才的学习,我们知道了中间件的概念,它是为了解决系统中的技术需求,将技术需求与业务需求进行解耦,让我们专注于业务代码开发的一个个技术组件。中间件的存在,就是为了解决高并发、高可用性、高性能等各领域的技术难题。
|
||||
|
||||
在项目中,合理引用中间件能极大提升我们系统的稳定性、可用性,但同时也会提升系统维护的复杂度,对我们的技术能力提出了更高的要求,我们必须要熟练掌握项目中引用的各种中间件,深入理解其工作原理、实现细节,提高对中间件的驾驭能力,否则一旦运用不当,很可能给系统带来灾难性的故障。
|
||||
|
||||
为了让你对中间件有一个更加宏观的认识,我给你列举了市面最为常用的中间件。虽然现在新的中间件层出不穷,但在我看来,大都不超过我列的这几类。这节课我们重点讲了两个主流的数据库中间件,下节课,我们再来解读缓存、全文索引、分布式日志这几类中间件。
|
||||
|
||||
课后题
|
||||
|
||||
学完这节课,我也给你出两道课后题吧!
|
||||
|
||||
|
||||
从数据库中间件的演变历程中,你能提炼出哪些分布式架构设计理念?
|
||||
请你以订单业务场景,搭建一个 2 库 2 表的 ShardingSphere 集群,实现数据的插入、查询功能。
|
||||
|
||||
|
||||
如果你想要分享你的修改或者想听听我的意见,可以提交一个 GitHub的 push 请求或 issues,并把对应地址贴到留言里。我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
202
专栏/中间件核心技术与实战/02中间件生态(下):同类型的中间件如何进行选型?.md
Normal file
202
专栏/中间件核心技术与实战/02中间件生态(下):同类型的中间件如何进行选型?.md
Normal file
@ -0,0 +1,202 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 中间件生态(下):同类型的中间件如何进行选型?
|
||||
你好,我是丁威。
|
||||
|
||||
这节课,我们继续中间件生态的讲解。
|
||||
|
||||
缓存中间件
|
||||
|
||||
纵观整个计算机系统的发展历程,不难得出这样一个结论:缓存是性能优化的一大利器。
|
||||
|
||||
我们先一起来看一个用户中心查询用户信息的基本流程:
|
||||
|
||||
|
||||
|
||||
这时候,如果查找用户信息这个 API 的调用频率增加,并且在整个业务流程中,同一个用户的信息会多次被调用,那么我们可以引入缓存机制来提升性能:
|
||||
|
||||
|
||||
|
||||
也就是说,在 UserService 中引入一个 LinkedHashMap 结构的内存容器,用它存储已经查询到的数据。如果新的查询请求能命中缓存,那么我们就不需要再查询数据库了,这就降低了数据库的压力,将网络 IO、磁盘 IO 转变为了直接访问内存,性能自然而然也提升了。
|
||||
|
||||
但上面这个方案实在算不上一个优秀的方案,因为它考虑得非常不全面,存在下面这几个明显的缺陷:内存容量有限、容易引发内存溢出,缓存在节点之间不一致,数据量非常庞大。
|
||||
|
||||
上面每一个问题都会带来巨大的影响,如果我们每做一个业务系统,都需要花这么多精力去解决这些技术问题,那这个成本也是不可估量的。为了解决与缓存相关的技术诉求,市面上也涌现出了一些非常优秀的中间件。缓存中间件经历了从本地缓存到分布式缓存的演变历程,我们先来看本地缓存中间件。
|
||||
|
||||
本地缓存中间件
|
||||
|
||||
本地缓存与应用属于同一个进程,主要的优势是没有网络访问开销,其中 Ehcache、Guava Cache 与 Caffeine 是 Java 领域当下比较知名的本地缓存框架。由于 Ehcache 比较耗磁盘空间,并且在进程宕机后容易造成缓存数据结构破坏,只能通过重建索引的方式进行修复,所以目前我们主要使用 Guava Cache 和 Caffeine,他们之间并没有明显的优劣势。
|
||||
|
||||
尽管内部实现细节不同,但本地缓存中间件基本都需要包含下面三个功能。
|
||||
|
||||
|
||||
支持大容量。
|
||||
|
||||
|
||||
它们基本都会采取内存 + 磁盘两级存储模型,其中内存存放热数据,磁盘存放全量数据。
|
||||
|
||||
|
||||
过期 / 淘汰机制。
|
||||
|
||||
|
||||
评估缓存对性能提升程度的一个重要依据就是缓存的命中率。如果用户每次访问都无法命中缓存,相当于缓存没有起到效果,存储的数据都是“无用”的数据,只会带来存储空间的浪费。所以,必须引入缓存过期机制,删除不常用的数据。
|
||||
|
||||
|
||||
基本的数据统计功能。
|
||||
|
||||
|
||||
监控数据的主要目的是检测当前缓存的工作状态是否健康,需要检测的内容包括缓存命中率、内存空间使用情况、磁盘空间使用情况等。
|
||||
|
||||
总的来说,本地缓存对单体应用非常友好,但对分布式应用就会显得有点浪费资源,为什么这么说呢?你可以先看看下面这张图。
|
||||
|
||||
|
||||
|
||||
在这张图中,当连续两次查询用户 ID 为 1 的用户信息时,受到负载均衡组件的影响,其中一个请求会转发到 192.168.3.100,另外一个请求会转发到 192.168.3.101。这样,同一个用户的信息会在两台机器上分别缓存一份数据。
|
||||
|
||||
而且,如果数据发生变化,也需要通知多台机器同时刷新缓存,这就造成了资源浪费。因此,本地缓存更适合存储一些变化频率极低,数据量较小的场景,诸如基础数据、配置了类型的数据缓存等。
|
||||
|
||||
分布式缓存中间件
|
||||
|
||||
本地缓存属于单进程管理的范畴,存在单点故障与资源瓶颈,无法应对数据的持续增长。为了适应分布式架构的特点,市面上也出现了一批基于内存存储的分布式存储框架。
|
||||
|
||||
由于分布式缓存与应用进程分属不同的进程,存在网络访问开销,所以几乎各个缓存中间件都是基于内存存储的系统,它们的存储容量受限于机器内存容量。
|
||||
|
||||
为了解决存储方面的瓶颈,各个分布式缓存中间件都支持集群部署。分布式缓存中间件中比较出名的非 Redis 与 Memcached 莫属。我们以 Redis 为例,来看一下经典的分布式缓存部署架构:
|
||||
|
||||
|
||||
|
||||
从这张图中,我们可以提取出下面几个要点。
|
||||
|
||||
首先,客户端通常会使用一致性哈希算法进行负载均衡,主要是为了提高节点扩容、缩容时的缓存命中率。
|
||||
|
||||
第二,Redis 采用主从同步模式,这可以提升数据的存储可靠性。如果是像 Memcache 这种不能持久化的中间件,进程一旦退出,存储在内存中的数据将会丢失,就要重新从数据库加载数据,这会让大量流量在短时间内穿透到数据库,造成数据库层面不稳定。
|
||||
|
||||
第三,单台 Redis 受限于机器内存的容量限制,通常会采用集群部署,即每一个节点存储部分数据。
|
||||
|
||||
第四,为了提升 Redis 的 master-slave 高可用性能,降低由于 master 节点宕机导致的集群写入节点数量减少问题,通常会引入哨兵集群,使 master-slave 主从自动切换,进一步提升缓存中间件的高可用性。
|
||||
|
||||
那么,同为分布式缓存中间件,Redis 和 Memcached 又有什么区别与联系呢?二者的共同点是,它们都是基于内存访问的高性能缓存存储系统,具有高并发、低延迟特性。
|
||||
|
||||
但它们的不同点也很多,我总结为了以下四点。
|
||||
|
||||
|
||||
数据类型:Redis 支持丰富的数据类型,不仅支持 key-value 的存储结构,还支持 List、Set 等复杂数据结构,而 Memcache 只支持简单的数据类型。
|
||||
|
||||
数据持久化:Redis 支持基于 AOF、快照两种数据持久机制,持久化带来的好处便是进程重启后数据不会丢失,能有效防止缓存被击穿的风险;Memcache 不支持数据持久化。
|
||||
|
||||
分布式存储:Redis 自身支持 master-slave、Cluster 两种分布式存储架构,而 Memcache 自身并不支持集群部署,需要使用一致性哈希算法来构建集群。
|
||||
|
||||
线程模型:Redis 命令执行采用单线程,故 Redis 不适合大 Value 值的存储,但借助 Redis 单线程模型可以非常方便地实现分布式锁等功能;Memcache 基于多线程运行模型,可以充分利用多核 CPU 的并发优势,提升资源的利用率。
|
||||
|
||||
|
||||
讲了这么多,要一下记住可能有点难度,我给你画了两张图,总结了刚才不同中间件的差异、适用场景,你可以保存下来随时回顾:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
一句话总结,缓存框架是不断在演进的,在项目中引入缓存相关的中间件技术绝对是一个明智之举。在数据量较少,并且变更不频繁时,我建议你采用本地缓存,其他情况建议使用分布式缓存。
|
||||
|
||||
那如何在 Redis 与 Memcache 中进行选型呢?虽然技术选型我们需要结合业务场景来看,但从上述功能的对比来看,Redis 基本在各个对比项中对 Memcache 呈“压制”态势,所以多数情况下,我建议你使用 Redis。
|
||||
|
||||
全文索引中间件
|
||||
|
||||
Elasticsearch 是一个基于 Apache Lucene 的开源且支持全文搜索的搜索引擎。
|
||||
|
||||
Lucene 被公认为迄今为止性能最强、功能最齐全的搜索引擎库。但 Lucene 只是一个类库,只提供单机版本的搜索功能,无法与分布式计算、分布式存储等协调展开工作。为了适应分布式的架构体系,Elasticsearch 应运而生。
|
||||
|
||||
Elasticsearch 提供了强大的分布式文件存储能力、分布式实时分析搜索能力、实时全文搜索能力、强大的集群扩展能力,PB 级别的结构化和非结构化数据处理能力。
|
||||
|
||||
Elasticsearch 在分布式架构中有两个最常见的应用场景,一个是宽表、解决跨库 Join,另一个就是全文搜索。接下来我们分别展开介绍。
|
||||
|
||||
在数据库领域,如果一个表的数据量庞大,我们通常会引入分库分表技术以提高可用性。但这会带来一个新的问题,就是数据关联、报表等查询会变得无比复杂,性能也无法得到保障。
|
||||
|
||||
我们以订单场景为例。在一个订单中通常会包含多个商品,一个非常经典的设计策略是会创建 t_order 与 t_order_item 表,其中 t_order_item 是 torder 的子表。但如果我们使用了分库分表技术,关联查询将变得非常复杂:
|
||||
|
||||
|
||||
|
||||
看一下上面这张图片,想象一下,如果应用程序发送一条 Join 语句给数据库,会发生什么事情呢?
|
||||
|
||||
由于订单编号为 1 的订单信息存储在 order_db_00 中,但与这条订单关联的订单字表却存储在 order_db_01 中,而 Join 操作需要的笛卡尔积操作存在于不同的数据库实例中,所以我们就要将多个数据库中的数据统一加载到内存中。这就需要创建众多对象,如果需要加载的数据庞大,无疑会导致内存竞争,垃圾回收加剧,性能将直线下降。
|
||||
|
||||
我相信你一定能想到这个问题的解法:用 ER 分库思想,让具有关联性的表使用字段相同的分片算法。例如上面的示例,我们可以将 t_order、t_order_item 两个表的分库字段都设置为订单 ID,这样一来,同一订单 id 的父子数据都在同一个数据库实例中,就避免了跨库 Join,可以让性能得到很大提升。
|
||||
|
||||
但真实的应用场景比这个要复杂很多,面对的用户不同,他们的诉求也不一样。
|
||||
|
||||
我们还是说回订单系统。
|
||||
|
||||
|
||||
从买家的角度出发,我们希望同一个买家的订单数据(父子关联表)能够采用同样的分库策略,以此保证同一个买家的订单关联数据存储在同一个库中,这样买家在查询订单时不必跨库。
|
||||
|
||||
但是如果采用这种策略,从商家的角度出发就会发现,商家在查询商家订单信息、商家日订单报表、月订单报表时要查询多个数据库,甚至可能产生跨库 Join 的风险。这无疑会降低性能,严重时会使整个数据库变得不可用。
|
||||
|
||||
|
||||
用一句话概述就是,分库分表在面对多维度查询时将变得力不从心,那该如何解决呢?
|
||||
|
||||
我们通常会引入数据异构 + 宽表的设计方案:
|
||||
|
||||
|
||||
|
||||
我们需要引入 Canal 数据同步工具,订阅 MySQL 的 Binglog,将增量数据同步到 Elasticsearch 中,实现数据访问层面的读写分离。
|
||||
|
||||
ElasticSearch 另外一个场景就是全文搜索。
|
||||
|
||||
我们以电商场景为例,用户在购买商品之前通常需要输入一些关键字搜索出符合自己期望的数据,例如商品表的表结构如下图所示:
|
||||
|
||||
|
||||
|
||||
如果我们要查询关键字为“苹果电脑”,基于关系型数据库,我们通常会写出这样的 SQL 语句:
|
||||
|
||||
select * from goods a where a.goods_decribe like '%苹果电脑%';
|
||||
|
||||
|
||||
运行上述代码,如果商品数量少那倒没关系,但如果是淘宝、天猫、京东等一线电商平台,需要存储海量商品信息,在商品库中运行上述 SQL,对数据库来说就是一个“噩梦”,因为上述语句并不会走索引,容易很快耗尽数据库链接而导致系统不可用。
|
||||
|
||||
这个时候,使用 Elasticsearch 就是一个非常明智的选择。因为 Elasticsearch 的底层是 Lucene,可以对需要查找的字段建立索引,中间还会进行分词处理,进行更智能的匹配。由于 Elasticsearch 底层会为字段建立倒排索引,根据关键字查询可以轻松命中缓存,从而能极大提升访问性能,实现低延迟访问。
|
||||
|
||||
分布式日志中间件
|
||||
|
||||
随着微服务的兴起、业务量的增长,每一个服务在生产环境都会部署多台机器。例如,在我们公司,光是订单中心的“创建订单”服务就部署了四十多台机器。当遇到生产问题时,如果我们想要查看服务器日志,就会异常困难,因为我们根本不知道发生错误的请求具体在哪台机器上。
|
||||
|
||||
在机器数量较少(10 台机器以内)的时候,通常我们可以使用 Ansibe 同时向所有需要采集的服务端执行日志检索命令,其工作示意图如下:
|
||||
|
||||
|
||||
|
||||
这种方式对于用户来说就像是操作单机模式一样,但是它的缺陷也是显而易见的。
|
||||
|
||||
基于 Ansibe 这种命令行等批量运维工具,需要保存目标机器的用户名与密码,安全性会受到影响。
|
||||
|
||||
如果要管理的目标机器有成百上千台,这种方式的系统开销会很大,搜索的响应时间很长,几乎是不太可能顺畅使用的。
|
||||
|
||||
为了进一步解决这个问题,我们通常需要采集每台服务器的日志,并将它存储在一个集中的地方,再提供一个可视化界面供用户查询。那么问题来了,市面上有这样的中间件吗?
|
||||
|
||||
我的回答是,必须得有,它就是大名鼎鼎的 ELK。我们可以先看下这张 ELK 的工作架构图:
|
||||
|
||||
|
||||
|
||||
我们需要在需要进行日志采集的机器上安装一个 filebeat 工具,用来采集服务器的日志,并将它们存储到消息中间件中。然后,在需要采集的机器中安装 Logstash 进程,通过 Logstash 将日志数据存储到 Elasticsearch 服务器,用户可以通过 Kibana 查询存储在 Elasticsearch 中的日志数据,这样,我们就可以有针对性地查询所需要的日志了。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。这节课,我们重点介绍了缓存、全文索引、分布式日志三类中间件。
|
||||
|
||||
缓存是性能优化的一柄利器,我们重点阐述了缓存技术从本地缓存到分布式缓存的演进之路,各种技术引入的背景以及解决方案,你可以根据自身情况,选择适合自己的缓存中间件。
|
||||
|
||||
另外,搜索相关技术也是应用系统必不可少的一环。随着微服务技术和数据库分库分表技术的兴起,数据写入效率大大提高,但与此同时,数据查询也面临更大的挑战,而基于 Elasticsearch 的数据异构架构方式能非常方便地解决数据查询的性能问题。
|
||||
|
||||
在分布式环境下,传统的应用日志查询方式也变得越来越难使用,ELK 日志技术则为日志搜索带来了新气象,是分布式日志中间件的不二之选。
|
||||
|
||||
课后题
|
||||
|
||||
学完这节课,我也给你出一道课后题吧。
|
||||
|
||||
数据异构是一种非常经典的架构方式,请你尝试使用 Canal 或者 Flink-CDC,将数据从 MySQL 同步到 Elasticsearch 中。
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
477
专栏/中间件核心技术与实战/03数组与链表:存储设计的基石有哪些?.md
Normal file
477
专栏/中间件核心技术与实战/03数组与链表:存储设计的基石有哪些?.md
Normal file
@ -0,0 +1,477 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 数组与链表:存储设计的基石有哪些?
|
||||
你好,我是丁威。
|
||||
|
||||
从这节课开始,我们就要进行基础篇的学习了。想要熟练使用中间件解决各种各样的问题,首先需要掌握中间件的基础知识。
|
||||
|
||||
我认为,中间件主要包括如下三方面的基础:数据结构、JUC 和 Netty,接下来的两节课,我们先讲数据结构。
|
||||
|
||||
数据结构主要解决的是数据的存储方式问题,是程序设计的基座。
|
||||
|
||||
按照重要性和复杂程度,我选取了数组和链表、键值对 (HashMap)、红黑树、LinkedHashMap 和 PriorityQueue 几种数据结构重点解析。其中,数组与链表是最底层的两种结构,是后续所有数据结构的基础。
|
||||
|
||||
我会带你分析每种结构的存储结构、新增元素和搜索元素的方式、扩容机制等,让你迅速抓住数据结构底层的特性。当然,我还会结合一些工业级实践,带你深入理解这些容器背后蕴含的设计理念。
|
||||
|
||||
说明一下,数据结构其实并不区分语言,但为了方便阐述,这节课我主要基于 Java 语言进行讲解。
|
||||
|
||||
数组
|
||||
|
||||
我们先来看下数组。
|
||||
|
||||
数组是用于储存多个相同类型数据的集合,它具有顺序性,并且也要求内存空间必须连续。高级编程语言基本都会提供数组的实现。
|
||||
|
||||
为了更直观地了解数组的内存布局,我们假设从操作系统申请了 128 字节的内存空间,它的数据结构可以参考下面这张图:
|
||||
|
||||
|
||||
|
||||
结合这张图我们可以看到,在 Java 中,数组通常包含下面几个部分。
|
||||
|
||||
|
||||
引用:每一个变量都会在栈中存储数组的引用,我们可以通过引用对数组进行操作,对应上图的 array1、array2。
|
||||
|
||||
容量:数组在创建时需要指定容量,一旦创建,无法修改,也就是说,数组并不能自动扩容。
|
||||
|
||||
下标:数组可以通过下标对数组中的元素进行随机访问,例如 array1[0]表示访问数组中的第一个元素,下标从 0 开始,其最大值为容量减一。
|
||||
|
||||
|
||||
在后面的讲解中,你能看到很多数据结构都是基于数组而构建的。
|
||||
|
||||
那么数组有哪些特性呢?这里我想介绍两个我认为最重要的点:内存连续性和随机访问效率高。
|
||||
|
||||
我们先来看下内存连续性。
|
||||
|
||||
内存连续性的意思是,数组在向操作系统申请内存时,申请的必须是连续的内存空间。我们还是继续用上面这个例子做说明。我们已经创建了 array1、array2 两个数组,如果想要再申请一个拥有五个 int 元素的数组,能把这五个元素拆开,分别放在数组 1 的前面和后面吗?你可以看看下面这张示意图。
|
||||
|
||||
|
||||
|
||||
答案当然是不可以。
|
||||
|
||||
虽然当前内存中剩余可用空间为 32 个字节,乍一看上去有充足的内存。但是,因为不存在连续的 20 字节的空间,所以不能直接创建 array3。
|
||||
|
||||
当我们想要创建 20 字节长度的 array3 时,在 Java 中会触发一次内存回收,如果垃圾回收器支持整理特性,那么垃圾回收器对内存进行回收后,我们就可以得到一个新的布局:
|
||||
|
||||
|
||||
|
||||
经过内存整理后就能创建数组 3 了。也就是说,如果内存管理不当,确实容易产生内存碎片,从而影响性能。
|
||||
|
||||
那我们为什么要把内存设计为连续的呢?换句话说,连续内存有什么好处呢?
|
||||
|
||||
这就不得不提到数组一个无可比拟的优势了:数组的随机访问性能极好。连续内存确保了地址空间的连续性,寻址非常简单高效。
|
||||
|
||||
举个例子,我们创建一个存放 int 数据类型的数组,代码如下:
|
||||
|
||||
int[] array1 = new int[10];
|
||||
|
||||
|
||||
然后我们看下 JVM 中的布局:
|
||||
|
||||
|
||||
|
||||
可以看到,首先内存管理器在栈空间会分配一段空间,用它存储数组在物理内存的起始地址,这个起始地址我们用 baseOffset 表示。如果是 64 位操作系统,默认一个变量使用 8 字节,如果采用了指针压缩技术,可以减少到 4 字节。
|
||||
|
||||
数组能够高效地随机访问数组中的元素,主要原因是它能够根据下标快速计算出真实的物理地址,寻找算法为“baseOffset + index * size”。
|
||||
|
||||
其中,size 为数组中单个元素的长度,是一个常量。在上面这个数组中,存储的元素是 int 类型的数据,所以 size 为 4。因此,我们根据数组下标就可以迅速找到对应位置存储的数据。
|
||||
|
||||
数组这种高效的访问机制在中间件领域有着非常广泛的应用,大名鼎鼎的消息中间件 RocketMQ 在它的文件设计中就灵活运用了这个特性。
|
||||
|
||||
RocketMQ 为了追求消息写入时极致的顺序写,会把所有主题的消息全部顺序写入到 commitlog 文件中。也就是说,commitlog 文件中混杂着各个主题的消息,但消息消费时,需要根据主题、队列、消费位置向消息服务器拉取消息。如果想从 commitlog 文件中读取消息,则需要遍历 commitlog 文件中的所有消息,检索性能非常低下。
|
||||
|
||||
一开始,为了提高检索效率,RocketMQ 引入了 ConsumeQueue 文件,可以理解为 commitlog 文件按照主题创建索引。
|
||||
|
||||
为了在消费端支持消息按 tag 进行消息过滤,索引数据中需要包含消息的 tag 信息,它的数据类型是 String,索引文件遵循{topic}/{queueId},也就是按照主题、队列两级目录存储。单个索引文件的存储结构设计如下图所示:
|
||||
|
||||
|
||||
|
||||
索引文件中,每一条消息都包含偏移量、消息长度和 tag 内容 3 个字段。
|
||||
|
||||
|
||||
commitlog 偏移量
|
||||
|
||||
|
||||
可以根据该值快速从 commitlog 文件中找到消息,这也是索引文件的意义。
|
||||
|
||||
|
||||
消息长度
|
||||
|
||||
|
||||
消息的长度,知道它可以方便我们快速提取一条完整的消息。
|
||||
|
||||
|
||||
tag 内容
|
||||
|
||||
|
||||
由于消息的 tag 是由用户定义的,例如 tagA、createorder 等,它的长度可变。在文件存储领域,一般存储可变长的数据,通常会采用“长度字段 + 具体内容”的存储方式。其中用来存储内容的长度使用固定长度,它是用来记录后边内容的长度。
|
||||
|
||||
回到消息消费这个需求,我们根据主题、消费组,消息位置 (队列中存储的第 N 条消息),能否快速找到消息呢?例如输入 topic:order_topic、queueId:0,offset:2,能不能马上找到第 N 条消息?
|
||||
|
||||
答案是可以找到,但不那么高效。原因是,我们根据 topic、queueid,能非常高效地找到对应的索引文件。我们只需要找到对应的 topic 文件夹,然后在它的子目录中找到对应的队列 id 文件夹就可以了。但要想从索引文件中找到具体条目,我们还是必须遍历索引文件中的每一个条目,直到到达 offset 的条目,才能取出对应的 commitlog 偏移量。
|
||||
|
||||
那是否有更高效的索引方式呢?
|
||||
|
||||
当然有,我们可以将每一个条目设计成固定长度,然后按照数组下标的方式进行检索。
|
||||
|
||||
为了实现每一个条目定长,我们在这里不存储 tag 的原始字符串,而是存储原始字符串的 hashCode,这样就可以确保定长了。你可以看看下面这张设计图:
|
||||
|
||||
|
||||
|
||||
基于这种设计,如果给定一个 offset,我们再想快速提取一条索引就变得非常简单了。
|
||||
|
||||
首先,根据 offset * 20(每一个条目的长度),定位到需要查找条目的起始位置,用 startOffset 表示。
|
||||
|
||||
然后,从 startOffset 位置开始读取 20 个字节的长度,就可以得到物理偏移量、消息长度和 tag 的 hashCode 了。
|
||||
|
||||
接着,我们可以通过 hashCode 进行第一次过滤,如果遇到 hash 冲突,就让客户端再根据消息的 tag 字符串精确过滤一遍。
|
||||
|
||||
这种方式,显然借鉴了数组高效访问数据的设计理念,是数组实现理念在文件存储过程中的经典运用。
|
||||
|
||||
总之,正是由于数组具有内存连续性,具有随机访问的特性,它在存储设计领域的应用才非常广泛,我们后面介绍的 HashMap 也引入了数组。
|
||||
|
||||
ArrayList
|
||||
|
||||
不过,数组从严格意义上来说是面向过程编程中的产物,而 Java 是一门面向对象编程的语言,所以,直接使用数组容易破坏面向对象的编程范式,故面向对象编程语言都会对数组进行更高级别的抽象,在 Java 中对应的就是 ArrayList。
|
||||
|
||||
我会从数据存储结构、扩容机制、数据访问特性三个方面和你一起来探究一下 ArrayList。
|
||||
|
||||
首先我们来看一下 ArrayList 的底层存储结构,你可以先看下这个示意图:
|
||||
|
||||
|
||||
|
||||
从图中可以看出,ArrayList 的底层数据直接使用了数组,是对数组的抽象。
|
||||
|
||||
ArrayList 相比数组,增加了一个特性,它支持自动扩容。其扩容机制如下图所示:
|
||||
|
||||
|
||||
|
||||
扩容的实现有三个要点。
|
||||
|
||||
|
||||
扩容后的容量 = 原容量 +(原容量)/ 2,以 1.5 倍进行扩容。
|
||||
|
||||
内部要创建一个新的数组,数组长度为扩容后的新长度。
|
||||
|
||||
需要将原数组中的内容拷贝到新的数组,即扩容过程中存在内存复制等较重的操作。
|
||||
|
||||
|
||||
注意,只在当前无剩余空间时才会触发扩容。在实际的使用过程中,我们要尽量做好容量评估,减少扩容的发生。因为扩容的成本还是比较高的,存储的数据越多,扩容的成本越高。
|
||||
|
||||
接下来,我们来看一下 ArrayList 的数据访问特性。
|
||||
|
||||
|
||||
顺序添加元素的效率高
|
||||
|
||||
|
||||
ArrayList 顺序添加元素,如果不需要扩容,直接将新的数据添加到 elementData[size]位置,然后 size 加一即可(其中,size 表示当前数组中存储的元素个数)。
|
||||
|
||||
ArrayList 添加元素的时间复杂度为 O(1),也就是说它不会随着存储数据的大小而改变,是非常高效的存储方式。
|
||||
|
||||
|
||||
中间位置插入 / 删除元素的效率低
|
||||
|
||||
|
||||
|
||||
|
||||
在插入元素时,我们将需要插入数据的下标用 index 表示,将 index 之后的依次向后移动 (复制到 index + 1),然后将新数据存储在下标 index 的位置。
|
||||
|
||||
删除操作与插入类似,只是一个数据是往后移,而删除动作是往前移。
|
||||
|
||||
ArrayList 在中间位置进行删除的时间复杂度为 O(n),这是一个比较低效的操作。
|
||||
|
||||
|
||||
随机访问性能高
|
||||
|
||||
|
||||
由于 ArrayList 的底层就是数组,因此它拥有高效的随机访问数据特性。
|
||||
|
||||
LinkedList
|
||||
|
||||
除了 ArrayList,在数据结构中,还有一种也很经典的数据结构:链表。LinkedList 就是链表的具体实现。
|
||||
|
||||
我们先来看一下 LinkedList 的底层存储结构,最后再对比一下它和 ArrayList 的差异。
|
||||
|
||||
|
||||
|
||||
从上面这张图你可以看到,一个 LinkedList 对象在内存中通常由两部分组成:LinkedList 对象和由 Node 节点组成的链条。
|
||||
|
||||
一个 LinkedList 对象在内存中主要包含 3 个字段。
|
||||
|
||||
|
||||
int size:链表中当前存在的 Node 节点数,主要用来判断是否为空、判断随机访问位点是否存在;
|
||||
|
||||
Node first:指向链表的头节点;
|
||||
|
||||
Node last:指向链表的尾节点。
|
||||
|
||||
|
||||
再来说说由 Node 节点组成的链条。Node 节点用于存储真实的数据,并维护两个指针。分别解释一下。
|
||||
|
||||
|
||||
E item:拥有存储用户数据;
|
||||
|
||||
Node prev:前驱节点,指向当前节点的前一个指针;
|
||||
|
||||
Node last:后继节点,指向当前节点的下一个节点。
|
||||
|
||||
|
||||
由这两部分构成的链表具有一个非常典型的特征:内存的申请无须连续性。这就减少了内存申请的限制。
|
||||
|
||||
接下来我们来看看如何操作链表。对于链表的操作主要有两类,一类是在链表前后添加或删除节点,一类是在链表中间添加或删除数据。
|
||||
|
||||
当你想要在链表前后添加或删除节点时,因为我们在 LinkedList 对象中持有链表的头尾指针,可以非常快地定位到头部或尾部节点。也就是说,这时如果我们想要增删数据,都只需要更新相关的前驱或后继节点就可以了,具体操作如下图所示:
|
||||
|
||||
|
||||
|
||||
举个例子,如果我们向尾部节点添加节点,它的代码是这样的:
|
||||
|
||||
Node oldLastNode = list.last; //添加数据之前原先的尾部节点
|
||||
|
||||
Node newNode = new Node();
|
||||
|
||||
newNode.item = 4;//设置用户的值
|
||||
|
||||
oldLastNode.next = newNode; // 将原先尾部节点的next指针更新为新添加的节点
|
||||
|
||||
newNode.prev = oldLastNode; // 新添加的节点的prev指向源尾部节点,通过这两步,使新加入的节点添加到链表中
|
||||
|
||||
list.last = newNode; // 更新LinkedList的尾部节点为新添加节点
|
||||
|
||||
|
||||
在链表的尾部、头部添加和删除数据,时间复杂度都是 O(1),比 ArrayList 在尾部添加节点效率要高。因为当 ArrayList 需要扩容时,会触发数据的大量复制,而 LinkedList 是一个无界队列,不存在扩容问题。
|
||||
|
||||
如果要在链表的中间添加或删除数据,我们首先需要遍历链表,找到操作节点。因为链表是非连续内存,无法像数组那样直接根据下标快速定位到内存地址。
|
||||
|
||||
例如,在下标 index 为 1 的后面插入新的数据,它的操作示例图如下:
|
||||
|
||||
|
||||
|
||||
我们从上往下看。插入新节点的第一步是需要从头节点开始遍历,找到下标为 i=1 的节点,然后在该节点的后面插入节点,最后执行插入节点的逻辑。
|
||||
|
||||
插入节点的具体实现主要是为了维护链表中相关操作节点的前驱与后继节点。
|
||||
|
||||
遍历链表、查询操作节点的时间复杂度为 O(n),然后基于操作节点进行插入与删除动作的时间复杂度为 O(1)。
|
||||
|
||||
关于链表的知识点就讲到这里。由于链表与数组是数据结构中两种最基本的存储结构,为了让你更直观地了解二者的差异,我也给你画了一个表格,对两种数据结构做了对比:
|
||||
|
||||
|
||||
|
||||
HashMap
|
||||
|
||||
无论是链表还是数组都是一维的,在现实世界中有一种关系也非常普遍:关联关系。关联关系在计算机领域主要是用键值对来实现,HashMap 就是基于哈希表 Map 接口的具体实现。
|
||||
|
||||
JDK1.8 版本之前,HashMap 的底层存储结构如下图所示:
|
||||
|
||||
|
||||
|
||||
HashMap 的存储结构主体是哈希槽与链表的组合,类似一个抽屉。
|
||||
|
||||
我们向 HashMap 中添加一个键值对,用这个例子对 HashMap 的存储结构做进一步说明。
|
||||
|
||||
HashMap 内部持有一个 Map.Entry[]的数组,俗称哈希槽。当我们往 HashMap 中添加一个键值对时,HashMap 会根据 Key 的 hashCode 与槽的总数进行取模,得出槽的位置 (也就是数组的下标),然后判断槽中是否已经存储了数据。如果未存储数据,则直接将待添加的键值对存入指定的槽;如果槽中存在数据,那就将新的数据加入槽对应的链表中,解决诸如哈希冲突的问题。
|
||||
|
||||
在 HashMap 中,单个键值对用一个 Map.Entry 结构表示,具体字段信息如下。
|
||||
|
||||
|
||||
K key:存储的 Key,后续可以用该 Key 进行查找
|
||||
|
||||
V value:存储的 Value;
|
||||
|
||||
int hash:Key 的哈希值;
|
||||
|
||||
Ma.Entry :next 链表。
|
||||
|
||||
|
||||
到这里,你可以停下来思考一下,当哈希槽中已经存在数据时,新加入的元素是存储在链表的头部还是尾部呢?
|
||||
|
||||
答案是放在头部。代码如下:
|
||||
|
||||
//假设新放入的槽位下标用 index 表示,哈希槽用 hashArray 表示
|
||||
|
||||
Map.Entry newEntry = new Map.Entry(key,value);
|
||||
|
||||
newEntry.next = hashArray[index];
|
||||
|
||||
hashArray[index] = newEntry;
|
||||
|
||||
|
||||
我们将新增加的元素放到链表的头部,也就是直接放在哈希槽中,然后用 next 指向原先存在于哈希槽中的元素。
|
||||
|
||||
|
||||
|
||||
这种方式的妙处在于,只涉及两个指针的修改。如果我们把新增加的元素放入链表的头部,链表的复杂度为 O(1)。相反,如果我们把新元素放到链表的尾部,那就需要遍历整条链表,写入复杂度会有所提高,随着哈希表中存储的数据越来越多,那么新增数据的性能将随着链表长度的增加而逐步降低。
|
||||
|
||||
介绍完添加元素,我们来看一下元素的查找流程,也就是如何根据 Key 查找到指定的键值对。
|
||||
|
||||
首先,计算 Key 的 hashCode,然后与哈希槽总数进行取模,得到对应哈希槽下标。
|
||||
|
||||
然后,访问哈希槽中对应位置的数据。如果数据为空,则返回“未找到元素”。如果哈希槽对应位置的数据不为空,那我们就要判断 Key 值是否匹配了。如果匹配,则返回当前数据;如果不匹配,则需要遍历哈希槽,如果遍历到链表尾部还没有匹配到任何元素,则返回“未找到元素”。
|
||||
|
||||
说到这里,我们不难得出这样一个结论:如果没有发生哈希槽冲突,也就是说如果根据 Key 可以直接命中哈希槽中的元素,数据读取访问性能非常高。但如果需要从链表中查找数据,则性能下降非常明显,时间复杂度将从 O(1) 提升到 O(n),这对查找来说就是一个“噩梦”。
|
||||
|
||||
一旦出现这种情况,HashMap 的结构会变成下面这个样子:
|
||||
|
||||
|
||||
|
||||
怎么解决这个问题呢?JDK 的设计者们给出了两种优化策略。
|
||||
|
||||
第一种,对 Hash 槽进行扩容,让数据尽可能分布到哈希槽上,但不能解决因为哈希冲突导致的链表变长的问题。
|
||||
|
||||
第二种,当链表达到指定长度后,将链表结构转换为红黑树,提升检索性能 (JDK8 开始引入)。
|
||||
|
||||
我们先来通过源码深入探究一下 HashMap 的扩容机制。HashMap 的扩容机制由 resize 方法实现,该方法主要分成两个部分,上半部分处理初始化或扩容容量计算,下半部分处理扩容后的数据复制 (重新布局)。
|
||||
|
||||
上半部分的具体源码如下:
|
||||
|
||||
|
||||
/**
|
||||
* Initializes or doubles table size. If null, allocates in
|
||||
* accord with initial capacity target held in field threshold.
|
||||
* Otherwise, because we are using power-of-two expansion, the
|
||||
* elements from each bin must either stay at same index, or move
|
||||
* with a power of two offset in the new table.
|
||||
*
|
||||
* @return the table
|
||||
*/
|
||||
final Node<K,V>[] resize() {
|
||||
Node<K,V>[] oldTab = table;
|
||||
int oldCap = (oldTab == null) ? 0 : oldTab.length;
|
||||
int oldThr = threshold;
|
||||
int newCap, newThr = 0;
|
||||
if (oldCap > 0) {
|
||||
if (oldCap >= MAXIMUM_CAPACITY) {
|
||||
threshold = Integer.MAX_VALUE;
|
||||
return oldTab;
|
||||
}
|
||||
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
|
||||
oldCap >= DEFAULT_INITIAL_CAPACITY)
|
||||
newThr = oldThr << 1; // double threshold
|
||||
}
|
||||
else if (oldThr > 0) // initial capacity was placed in threshold
|
||||
newCap = oldThr;
|
||||
else { // zero initial threshold signifies using defaults
|
||||
newCap = DEFAULT_INITIAL_CAPACITY;
|
||||
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
|
||||
}
|
||||
if (newThr == 0) {
|
||||
float ft = (float)newCap * loadFactor;
|
||||
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
|
||||
(int)ft : Integer.MAX_VALUE);
|
||||
}
|
||||
threshold = newThr;
|
||||
@SuppressWarnings({"rawtypes","unchecked"})
|
||||
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
|
||||
table = newTab;
|
||||
//此处省略数据复制相关代码
|
||||
}
|
||||
|
||||
|
||||
为了方便你对代码进行理解,我画了一个与之对应的流程图:
|
||||
|
||||
|
||||
|
||||
总结一下扩容的要点。
|
||||
|
||||
|
||||
HashMap 的容量并无限制,但超过 2 的 30 次幂后不再扩容哈希槽。
|
||||
|
||||
哈希槽是按倍数扩容的。
|
||||
|
||||
HashMap 在不指定容量时,默认初始容量为 16。
|
||||
|
||||
|
||||
HashMap 并不是在无容量可用的时候才扩容。它会先设置一个扩容临界值,当 HashMap 中的存储的数据量达到设置的阔值时就触发扩容,这个阔值用 threshold 表示。
|
||||
|
||||
我们还引入了一个变量 loadFactor 来计算阔值,阔值 = 容量 *loadFactor。其中,loadFactor 表示加载因子,默认为 0.75。
|
||||
|
||||
加载因子的引入与 HashMap 哈希槽的存储结构与存储算法有关。
|
||||
|
||||
HashMap 在出现哈希冲突时,会引入一个链表,形成“数组 + 链表”的存储结构。这带来的效果就是,如果 HashMap 有 32 个哈希槽,当前存储的数据也刚好有 32 个,这些数据却不一定全会落在哈希槽中,因为可能存在 hash 值一样但是不同 Key 的数据,这时,数据就会进入到链表中。
|
||||
|
||||
前面我们也提到过,数据放入链表就容易引起查找性能的下降,所以,HashMap 的设计者为了将数据尽可能地存储到哈希槽中,会提前进行扩容,用更多的空间换来检索性能的提高。
|
||||
|
||||
我们再来看一下扩容的下半部分代码。
|
||||
|
||||
我们先来看下这段代码:
|
||||
|
||||
|
||||
@SuppressWarnings({"rawtypes","unchecked"})
|
||||
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
|
||||
table = newTab;
|
||||
if (oldTab != null) {
|
||||
for (int j = 0; j < oldCap; ++j) {
|
||||
Node<K,V> e;
|
||||
if ((e = oldTab[j]) != null) {
|
||||
oldTab[j] = null;
|
||||
if (e.next == null)
|
||||
newTab[e.hash & (newCap - 1)] = e;
|
||||
else if (e instanceof TreeNode)
|
||||
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
|
||||
else { // preserve order
|
||||
Node<K,V> loHead = null, loTail = null;
|
||||
Node<K,V> hiHead = null, hiTail = null;
|
||||
Node<K,V> next;
|
||||
do {
|
||||
next = e.next;
|
||||
if ((e.hash & oldCap) == 0) {
|
||||
if (loTail == null)
|
||||
loHead = e;
|
||||
else
|
||||
loTail.next = e;
|
||||
loTail = e;
|
||||
}
|
||||
else {
|
||||
if (hiTail == null)
|
||||
hiHead = e;
|
||||
else
|
||||
hiTail.next = e;
|
||||
hiTail = e;
|
||||
}
|
||||
} while ((e = next) != null);
|
||||
if (loTail != null) {
|
||||
loTail.next = null;
|
||||
newTab[j] = loHead;
|
||||
}
|
||||
if (hiTail != null) {
|
||||
hiTail.next = null;
|
||||
newTab[j + oldCap] = hiHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这段代码不难理解,就是按照扩容后的容量创建一个新的哈希槽数组,遍历原先的哈希槽 (数组),然后将数据重新放入到新的哈希槽中,为了保证链表中数据的顺序性,在扩容时采用尾插法。
|
||||
|
||||
除了扩容,JDK8 之后的版本还有另外一种提升检索能力的措施,那就是在链表长度超过 8 时,将链表演变为红黑树。这时的时间复杂度为 O(2lgN),可以有效提升效率。
|
||||
|
||||
关于红黑树,我会在下节课详细介绍。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们介绍了数组、ArrayList、LinkedList、HashMap 这几种数据结构。
|
||||
|
||||
数组,由于其内存的连续性,可以通过下标的方式高效随机地访问数组中的元素。
|
||||
|
||||
数组与链表可以说是数据结构中两种最基本的数据结构,这节课,我们详细对比了两种数据结构的存储特性。
|
||||
|
||||
|
||||
|
||||
哈希表是我们使用得最多的数据结构,它的底层的设计也很具技巧性。哈希表充分考虑到数组与链表的优劣,扬长避短,HashMap 就是这两者的组合体。为了解决链表检索性能低下的问题,HashMap 内部又引入了扩容与链表树化两种方式进行性能提升,提高了使用的便利性,降低了使用门槛。
|
||||
|
||||
课后题
|
||||
|
||||
最后,我也给你留两道思考题吧!
|
||||
|
||||
1、业界在解决哈希冲突时除了使用链表外,还有其他什么方案?请你对这两者的差异进行简单的对比。
|
||||
|
||||
2、HashMap 中哈希槽的容量为什么必须为 2 的倍数?如果不是很理解,推荐你先学习一下位运算,然后在留言区告诉我你的答案。
|
||||
|
||||
我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
432
专栏/中间件核心技术与实战/04红黑树:图解红黑树的构造过程与应用场景.md
Normal file
432
专栏/中间件核心技术与实战/04红黑树:图解红黑树的构造过程与应用场景.md
Normal file
@ -0,0 +1,432 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 红黑树:图解红黑树的构造过程与应用场景
|
||||
你好,我是丁威。
|
||||
|
||||
这节课,我们继续 Java 中常用数据结构的讲解。我会重点介绍 TreeMap、LinkedHashMap 和 PriorityQueue 这三种数据结构。
|
||||
|
||||
TreeMap
|
||||
|
||||
先来看 TreeMap。TreeMap 的底层数据结构是一棵红黑树,这是一种比较复杂但也非常重要的数据结构。它是由树这种基础的数据结构演化而来的。
|
||||
|
||||
我们知道,在计算机领域,树指的就是具有树状结构的数据的集合。把它叫做“树”,是因为它看起来像一棵自上而下倒挂的树。一棵树通常有下面几个特点:
|
||||
|
||||
|
||||
每个节点都只有有限个子节点或无子节点;
|
||||
|
||||
没有父节点的节点称为根节点;
|
||||
|
||||
每一个非根节点有且只有一个父节点;
|
||||
|
||||
除了根节点外,每个子节点可以分为多个不相交的子树;
|
||||
|
||||
树里面没有环路(cycle)。
|
||||
|
||||
|
||||
如果一棵树的每个节点最多有两个子树,那它就是一棵二叉树。二叉树是“树”的一个重要分支,我们可以通过文稿中这张图来直观感受一下:
|
||||
|
||||
|
||||
|
||||
但是如果数据按照这样的结构存储,想要新增或者查找数据就需要沿着根节点去遍历所有的节点,这时的效率为 O(n),可以看出性能非常低下。作为数据结构的设计者,肯定不能让这样的事情发生。
|
||||
|
||||
这时候,我们就需要对数据进行排序了,也就是使用所谓的二叉排序树(二叉查找树)。它有下面几个特点:
|
||||
|
||||
|
||||
若任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值;
|
||||
|
||||
若任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值;
|
||||
|
||||
没有键值相等的节点。
|
||||
|
||||
|
||||
如果上图这棵二叉树变成一棵二叉排序树,可能长成下面这个样子:
|
||||
|
||||
|
||||
|
||||
基于排序后的数据存储结构,我们来尝试一下查找数字 30:
|
||||
|
||||
|
||||
从根节点 37 开始查找,判断出 37 比 30 大,然后尝试从 37 的左子树继续查找;
|
||||
|
||||
37 的左子节点为 26,判断出 26 比 30 小,所以需要从 26 的右子树继续查找;
|
||||
|
||||
26 的右子节点为 32,由于 32 比 30 大,所以从 32 的左子树继续查找;
|
||||
|
||||
32 的左子节点为 30,命中,结束。
|
||||
|
||||
|
||||
你应该已经发现了,每次查找,都可以排除掉一半的数据。我们可以将它类比作二分查找算法,其时间复杂度为 O(logN),也就是对数级。所以说,二叉排序树是一种比较高效的查找算法。
|
||||
|
||||
不过,二叉排序树也有缺陷。一个最主要的问题就是,在查找之前我们需要按照二叉排序树的存储特点来构建它。我们还是用上面这个例子,将节点按照从小到大的顺序构建二叉排序树,构建过程如下图所示:
|
||||
|
||||
|
||||
|
||||
根据排序二叉树的构建规则,如果数据本身是顺序的,那么二叉排序树会退化成单链表,时间复杂度飙升到 O(n),我们显然不能接受这种情况。
|
||||
|
||||
对比这两棵二叉排序树,第一棵左右子树比较对称,两边基本能保持平衡,但第二棵严重地向右边倾斜,这会导致每遍历新的一层,都无法有效过滤一半的数据,也就意味着性能的下降。
|
||||
|
||||
那有没有一种办法能够自动调整二叉排序树的平衡呢?这就是红黑树要解决的问题了。
|
||||
|
||||
红黑树是一种每个节点都带有颜色属性(红色或黑色)的二叉查找树,它可以实现树的自平衡,查找、插入和删除节点的时间复杂度都为 O(logn)。
|
||||
|
||||
除了要具备二叉排序树的特征外,红黑树还必须具备下面五个特性。
|
||||
|
||||
性质 1:节点是红色或黑色。
|
||||
|
||||
性质 2:根是黑色。
|
||||
|
||||
性质 3:所有叶子都是黑色(叶子是 NIL 节点)。
|
||||
|
||||
性质 4:每个红色节点必须有两个黑色的子节点。也就是说,从每个叶子到根的所有路径上不能有两个连续的红色节点。
|
||||
|
||||
性质 5:从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
|
||||
|
||||
由于插入、删除节点都有可能破坏红黑树的这些特性,所以我们需要进行一些操作,也就是通过树的旋转让它重新满足这些特点。
|
||||
|
||||
树的旋转又分为右旋和左旋两种:右旋指的是旋转后需要改变支点节点的右子树,左旋指的是旋转后需要改变支点节点的左节点。 这个通过旋转重新满足特性的过程就是自平衡。树越平衡,数据的查找效率越高。
|
||||
|
||||
为了让你直观地看到“红黑树的魅力”,我们还是沿用上面的例子,将节点按照从小到大的顺序依次插入到一棵红黑树中,最终产生的红黑树为如下图所示:
|
||||
|
||||
|
||||
|
||||
这是一棵地地道道的二叉排序树。
|
||||
|
||||
但是我们刚才说,在查找元素时,时间复杂度从 O(n) 飙升到了 O(logN),这棵树是如何做到节点顺序插入时没有退化成链表的呢?我们一起来看下红黑树的构建过程。
|
||||
|
||||
提前说明一下,由于从小到大排序是一种特殊情况,不能覆盖建构红黑树的多种情况,所以为了更好地说明红黑树的工作机制,我们把节点的插入顺序变更为 50、37、70、35、25、30、26、80、90、100、20、18、32、75、85。
|
||||
|
||||
1. 按照这个顺序,首先我们连续插入节点 50、节点 37、节点 70,其初始状态如下图所示:
|
||||
|
||||
|
||||
|
||||
2. 然后,继续插入节点 35:
|
||||
|
||||
|
||||
|
||||
这个时候,新插入的节点 0035 的父节点 (00037) 和叔叔节点 (0070) 都是红色,所以我们需要将 0035 的祖父节点的颜色传递到它的两个子节点,这样也就到了图里的第二个状态。由于根节点的颜色为红色,不符合红黑树的特点,我们再将根节点的颜色变更为黑色。
|
||||
|
||||
3. 继续插入节点 25:
|
||||
|
||||
|
||||
|
||||
可以看到,初始状态的当前节点、父节点和祖先节点的形状为一条斜线。这时红色节点 0025 与 0035 都是红色,违背了红黑树的性质 4,这种情况可以使用右旋来解决,具体操作是:
|
||||
|
||||
|
||||
让当前节点 (0025) 的祖先节点 (0037) 下沉,作为当前节点的父节点 (0035) 的右子节点。同时,当前父节点(0025)的祖先节点(0050)的左节点指向当前节点的父节点,这样,0050 的左节点就直接指向了 0035。本轮操作后变成图里的第二个状态。
|
||||
|
||||
旋转之后 0035 节点的右子树路径多了一个黑色的节点 0037,为了符合红黑树的特性,我们需要将 0037 父节点的颜色进行翻转,变成图里的第三个状态。
|
||||
|
||||
|
||||
总结一下,右旋的第一个触发条件:当前节点与父亲节点为红色,并且都是左节点。
|
||||
|
||||
4. 继续插入节点 30:
|
||||
|
||||
|
||||
|
||||
当前节点 (0030)、父节点 (0025) 和叔叔节点 (0037) 都为红色,所以可以将当前节点的祖先 (0035) 的状态传递给子节点,变成上图第二个状态。
|
||||
|
||||
5. 继续插入节点 26:
|
||||
|
||||
|
||||
|
||||
可以看到,现在的状态是,当前节点 (0026) 和父节点 (0030) 为红色,当前节点为左子树,父节点为右子树,并且叔叔节点并不为红色(组成一个大于号)。
|
||||
|
||||
这时候我们也需要右旋,以当前节点为支点,将其父节点作为当前节点的右节点,当前节点重新充当其祖父节点的右节点,状态从图一转为图二。
|
||||
|
||||
这是右旋的第二个触发条件:当前节点、父节点、祖父节点的形状为大于号,而且当前节点的父节点为支点。
|
||||
|
||||
状态变为图二之后,当前节点 (0030) 与父节点 (0026) 都是红色,并且都是右节点,所以应该执行一次左旋。以父节点 0026 为支点,将当前节点(0030)的祖父节点(0025)变为父节点(0026)的左子节点,经过这个动作后,状态从图二转为图三。
|
||||
|
||||
左旋之后,黑色节点 0025 变成了节点 0026 的左子树,左子树的黑色节点数量变多,所以我们需要将黑色传递到父节点,也就是要把节点 0025 变为红色,0026 变为黑色,变成图中的第四个状态。
|
||||
|
||||
6. 我们接着插入节点 80,此时不会改变红黑色特性,再插入节点 90:
|
||||
|
||||
|
||||
|
||||
由于当前节点与父节点都是红色,并且都是右节点,需要执行左旋。
|
||||
|
||||
其实,到底什么时候需要左旋,什么时候需要右旋你没有必要死记硬背。因为左旋、右旋的最终目的是要满足树的平衡,也就是降低树的层级。只要确保旋转后的最终效果满足二叉排序树的定义(根节点比左子树大,比右子数小)就可以了。
|
||||
|
||||
7. 继续插入节点 100、20:
|
||||
|
||||
|
||||
|
||||
到这里我们就需要说明一下了。
|
||||
|
||||
这一步和步骤 2 一样,当前节点、父节点和叔叔节点都是红色,只需要将当前节点的祖父节点的颜色传递到祖父节点的两个子节点就可以了,这就到了图中的第二个状态。
|
||||
|
||||
但这个时候,0026 和它的父节点 0035 同为红色,并且叔叔节点也是红色,我们需要再像上面一样传递颜色,调整后变成图里的第三个状态。
|
||||
|
||||
最后,由于根节点是红色,我们需要将根节点转为黑色。
|
||||
|
||||
这里重点强调的是,无论是左旋、右旋还是变色,都需要再次向上递归进行验证。
|
||||
|
||||
8. 继续插入 18、32、75、85 等节点:
|
||||
|
||||
|
||||
|
||||
到这一步基本没有什么新的知识点了,按照我们前面所讲过的方法进行调整,就可以得到上面这棵红黑树了。
|
||||
|
||||
红黑树的构建过程就介绍到这里。红黑树的主要过程就是通过为节点引入颜色、左旋、右旋、变色等手段实现树的平衡,保证查询功能高效有序进行。
|
||||
|
||||
聊完数据结构,我们再来看看它的应用。其实,TreeMap 在中间件开发领域的运用非常广泛,其中最出名的估计要属使用 TreeMap 实现一致性哈希算法了。
|
||||
|
||||
下面是一致性哈希算法的示意图:
|
||||
|
||||
|
||||
|
||||
其中,Node1、Node2、Node3 是真实存储的有效数据,每一个节点需要存储一些关联信息,很适合 key-value 的存储形式。一致性哈希算法的查询规则是:查询第一个大于目标哈希值的节点。
|
||||
|
||||
例如,如果输入 key1,key2,需要命中 Node2,如果输入 key3,则需要命中 Node3。
|
||||
|
||||
这种情况其实就是需要将数据按照 key 进行排序,而 TreeMap 中的数据本身就是顺序的,所以非常适合这个场景。
|
||||
|
||||
在 RocketMQ 中,就使用了一致性哈希算法来实现消费组队列的负载均衡。
|
||||
|
||||
|
||||
|
||||
TreeMap 的 TailMap 是返回大于等于 key 的子树,然后调用子树的 firstKey 获取 TreeMap 中最小的元素,符合一致性哈希算法的命中规则。又因为 TreeMap 是一棵排序树,所以得到最小、最大值会非常容易。
|
||||
|
||||
在 TreeMap 中实现 firstkey 方法时,内部会先获取 TreeMap 中的键值对,也就是 Entry 对象:
|
||||
|
||||
|
||||
|
||||
然后从根节点开始遍历,查找到节点的左子树,再一直遍历到树的最后一个左节点,时间复杂度为 O(logN)。
|
||||
|
||||
LinkedHashMap
|
||||
|
||||
红黑树就介绍到这里了,接下来我们再来看一个与 LRU 相关的数据结构 LinkedHashMap。
|
||||
|
||||
LinkedHashMap 是 LinkedList 和 HashMap 的结合体,它内部的存储结构可以简单表示为下面这样:
|
||||
|
||||
|
||||
|
||||
LinkedHashMap 内部存储的 Entry 在 HashMap 的基础上增加了两个指针:before 和 after。这两个节点可以对插入的节点进行链接,以此来维护顺序性。同时,链表结构为了方便插入,也会持有“头尾节点”这两个指针。
|
||||
|
||||
那引入链表有什么好处呢?
|
||||
|
||||
我认为大概有下面两个优点。
|
||||
|
||||
一个是降低了遍历实现的复杂度。我们对比一下,HashMap 的遍历是首先遍历哈希槽,然后遍历链表;但 LinkedHashMap 则可以基于头节点遍历,复杂度明显降低。引入链表的第二个优点则是提供了顺序性。接下来,我们就来看看 LinkedHashMap 的顺序性和使用场景。
|
||||
|
||||
LinkedHashMap 提供了两种顺序性机制:
|
||||
|
||||
|
||||
按节点插入顺序,是 LinkedHashMap 的默认行为;
|
||||
|
||||
按节点的访问性顺序,最新访问的节点将被放到链表的末尾。
|
||||
|
||||
|
||||
它的使用场景也很常见,有一种知名的淘汰算法叫 LRU。顾名思义,LRU 就是要淘汰最近没有使用的数据。在 Java 领域,实现 LRU 的首选就是 LinkedHashMap,因为 LinkedHashMap 能够按访问性排序。
|
||||
|
||||
在 LinkedHashMap 中,如果顺行性机制选择“按访问顺序”,那么当元素被访问时,元素会默认被放到链表的尾部,并且在向 LinkedHashMap 添加元素时会调用 afterNodeInsertion 方法。这个方法的具体实现代码如下:
|
||||
|
||||
|
||||
|
||||
从代码中可以看出,如果 removeEldestEntry 函数返回 true,则会删除 LinkedHashMap 中的第一个元素,这样就淘汰了旧的数据,实现了 LRU 的效果。removeEledestEntry 方法的代码如下:
|
||||
|
||||
|
||||
|
||||
可以看到,默认返回的是 false,表示 LinkedHashMap 并不会启用节点的淘汰机制。为了实现 LRU 算法,我们需要继承 LinkedHashMap 并重写该方法,具体实现代码如下:
|
||||
|
||||
|
||||
package net.codingw.datastruct;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
public class LRUCache<K,V> extends LinkedHashMap<K,V> {
|
||||
private int maxCapacity;
|
||||
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
|
||||
//如果超过了最大容量,则启动剔除机制
|
||||
return size() >= maxCapacity;
|
||||
}
|
||||
public void setMaxCapacity(int maxCapacity) {
|
||||
this.maxCapacity = maxCapacity;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LinkedHashMap 就介绍到这里了,我们再来看一种特殊的队列——优先级队列,它是实现定时调度的核心数据结构。
|
||||
|
||||
PriorityQueue
|
||||
|
||||
我们知道,普通队列都是先进先出的,但优先级队列不同,它可以为元素设置优先级,优先级高的元素完全可以后进先出。
|
||||
|
||||
我们先来看一下 PriorityQueue 的类图:
|
||||
|
||||
|
||||
|
||||
优先级队列的底层结构是数组,可是怎么在数组的基础上排列优先级呢?原来,PriorityQueue 的底层是基于最小堆实现的堆排序。
|
||||
|
||||
所谓最小堆指的是一棵经过排序的完全二叉树,根结点的键值是所有堆结点键值中最小者。无论是最大堆还是最小堆,都只固定根节点与子节点的关系,两个子节点之间的关系并不做强制要求。
|
||||
|
||||
我们采用数组作为最小堆的底层数据结构,将最小堆用一棵二叉树来表示,这时的数据是按照从上到下、从左到右的方式依次存储在数组中的:
|
||||
|
||||
|
||||
|
||||
这种存储方式有两个特点:
|
||||
|
||||
|
||||
假设一个节点在数组中的下标为 n,则它的左子节点的下标为 2n+1,它的右子节点的下标为 2n+2;
|
||||
|
||||
假设一个节点在数组中的下标为 n,那么它的父节点下标为 (n -1) >>> 1。
|
||||
|
||||
|
||||
有些最小堆的存储方式是将数组的第一个元素空出来,把根节点存储在下标为 1 的位置。如果基于这种方式,存储有下面两个特点:
|
||||
|
||||
|
||||
假设一个节点在数组中的下标为 n,则它的左子节点的下标为 2n,它的右子节点的下标为 2n+1;
|
||||
|
||||
假设一个节点在数组中的下标为 n,则它的父节点下标为 (n) >>> 1。
|
||||
|
||||
|
||||
但在实践场景中,数据不可能按顺序插入,既然如此,要实现优先级队列,该怎么对这棵树进行排序呢?
|
||||
|
||||
PriorityQueue 队列的实现中采用了堆排序。我们还是用图解的方式来看一下构建规则。
|
||||
|
||||
首先我们连续插入节点 500,600,700,800,其内部结构如下图所示:
|
||||
|
||||
|
||||
|
||||
由于首先插入了根节点为 500,后续 600,700 比根节点都小,所以 600 和 700 可以直接成为根节点的左右子树。
|
||||
|
||||
继续插入 800,由于比根节点大,同时比 600 大,则直接放入到 600 的子节点即可。
|
||||
|
||||
继续插入 490,插入过程如下图所示:
|
||||
|
||||
|
||||
|
||||
解释一下。我们首先将新元素插入到数组的最后,下标为 n=5,队列是图中的第一个状态。
|
||||
|
||||
根据公式 n >>> 1 ,可以算出它的父节点的下标为 2,比较两者的大小,如果新插入的节点比父节点少,那么交换两者的值,变化到图中的第二个状态。
|
||||
|
||||
这时候,我们再通过公式 n>>>1 算出父节点的下标为 1,比较两者的值,发现子节点的值比父节点的值低,则继续交换两者的值,成为图中的第三个状态。
|
||||
|
||||
要实现上面的步骤,我们相应的代码是:
|
||||
|
||||
private void siftUp(int k, E x) {
|
||||
if (comparator != null)
|
||||
siftUpUsingComparator(k, x);
|
||||
else
|
||||
siftUpComparable(k, x);
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
private void siftUpComparable(int k, E x) {
|
||||
Comparable<? super E> key = (Comparable<? super E>) x;
|
||||
while (k > 0) {
|
||||
int parent = (k - 1) >>> 1;
|
||||
Object e = queue[parent];
|
||||
if (key.compareTo((E) e) >= 0)
|
||||
break;
|
||||
queue[k] = e;
|
||||
k = parent;
|
||||
}
|
||||
queue[k] = key;
|
||||
}
|
||||
|
||||
|
||||
在这段代码里,我们首先使用 while(k>0) 实现递归,因为最小堆是将新插入的节点放在叶子结点,然后不断与其父节点进行比较,直到到达根节点。
|
||||
|
||||
然后,我们要根据当前节点的序号,计算其父节点的序号 (这里的算法与图解方式不一样,是因为 PriorityQueue 是将根节点的下标定为 0),然后比较大小:
|
||||
|
||||
|
||||
如果当前节点比父节点的值大,则跳出循环,符合最小堆的要求;
|
||||
|
||||
如果当前节点比父节点的值小,则交换两者的值,将 k 的值赋值为父节点 (k = parent),然后继续向上递归做判断。
|
||||
|
||||
|
||||
构建好堆之后,我们再来看看怎么从堆中获取数据。要注意的是,访问数据只能从堆的根节点开发方法,具体做法就是删除根节点,并将根节点的值返回。我们先尝试删除根节点 490:
|
||||
|
||||
|
||||
|
||||
删除根节点和删除其他任何节点的算法是一样的:
|
||||
|
||||
|
||||
首先,我们将待删除的位置的值清除,状态为图一。
|
||||
|
||||
然后,将数组最后的元素移动到待删除位置,我们用下标 n 表示。删除根节点,n 为 0,状态转为图二。
|
||||
|
||||
接下来,根据下标算法分别算出其子节点的下标为 2n、2n+1,从左右节点中挑选最小值,如图三。
|
||||
|
||||
用父节点的值与左右子节点中最小的值进行对比,如图四。如果父节点比最小子节点大,则交换两者的值,如图五。
|
||||
|
||||
我们要一直往下递归,直到节点没有子节点,或者没有父节点比子节点小为止。
|
||||
|
||||
|
||||
结合这张图,我们同样来看一下 PriorityQueue 中删除元素的代码:
|
||||
|
||||
|
||||
private void siftDown(int k, E x) {
|
||||
if (comparator != null)
|
||||
siftDownUsingComparator(k, x);
|
||||
else
|
||||
siftDownComparable(k, x);
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
private void siftDownComparable(int k, E x) {
|
||||
Comparable<? super E> key = (Comparable<? super E>)x;
|
||||
int half = size >>> 1; // loop while a non-leaf
|
||||
while (k < half) {
|
||||
int child = (k << 1) + 1; // assume left child is least
|
||||
Object c = queue[child];
|
||||
int right = child + 1;
|
||||
if (right < size &&
|
||||
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
|
||||
c = queue[child = right];
|
||||
if (key.compareTo((E) c) <= 0)
|
||||
break;
|
||||
queue[k] = c;
|
||||
k = child;
|
||||
}
|
||||
queue[k] = key;
|
||||
}
|
||||
|
||||
|
||||
可以看到,在这段代码中,我们设定 half 为 size 的一半,如果下标大于 half,则下标对应的位置不会再有子节点,可以跳出循环。
|
||||
|
||||
代码的第 12 行是计算左右节点下标的公式,我们可以按照公式算出左右节点的下标,并比较两者的大小,挑选更小的值与父节点进行对比。
|
||||
|
||||
最后,我们再来看一下优先级队列的应用场景。其实,JUC 中的定时调度线程池 ScheduledExecutorService 的底层就使用了优先级队列。
|
||||
|
||||
定时任务调度线程池的基本实现原理是:
|
||||
|
||||
|
||||
在将调度任务提交到线程池之前,首先计算出下一次需要执行的时间戳,通过时间戳来计算优先级,将其存入最小堆中,这样就确保了最先需要执行的调度任务位于最小堆的顶部 (也就是根节点)。
|
||||
|
||||
然后开一个定时任务,拿队列中第一个元素和当前时间进行比较:
|
||||
|
||||
|
||||
如果下一次执行时间大于等于当前时间,则将队列中第一个元素 (调度任务) 从队列中移除,投入线程池中执行。
|
||||
如果下一次执行时间小于当前时间,则不处理,因为队列中最小的待执行任务都还没有到执行时间,其他任务一定也是这样。
|
||||
|
||||
|
||||
|
||||
可以看到,定时调度场景的关键是找到第一个需要触发的任务,类似 SQL 中的 min 语义,重在优先二字,而优先级队列的实现原理同样注重优先。理念上的契合让定时任务调度和优先级队列经常绑定在一起出现。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。内容比较多,但是把脉络拎出来,其实我们主要讲了三种数据结构。
|
||||
|
||||
其中,树是数据结构中比较难但同时也非常常见的一种数据结构。我们从二叉排序树的优劣势出发,引出了红黑树,并用图解的方式详细介绍了红黑树的构建过程,介绍了红黑树的左旋、右旋、变色方法,还列举了红黑树的经典应用场景。
|
||||
|
||||
紧接着我们介绍了 LinkedHashMap,它是链表与 HashMap 的结合体。LinkedHashMap 既拥有 HashMap 快速的检索能力,还引入了节点顺序性,可以基于它实现 LRU 缓存淘汰算法。
|
||||
|
||||
最后,我们还通过图解认识了优先级队列,看到了用数组存储树的高阶用法,以及堆排序的工作机制和应用场景。
|
||||
|
||||
希望你能够借这个机会再巩固一下自己的基础知识,有所收获。同时,我也建议你在学完这些数据结构基本原理之后,有针对性地阅读一下源码,提炼出自己的学习方法。
|
||||
|
||||
课后题
|
||||
|
||||
最后我还是照例给你留两道课后题吧!
|
||||
|
||||
1、请你根据红黑树的特性,实现一棵红黑树(插入、删除、查找)。
|
||||
|
||||
2、红黑树和最小堆之间有什么区别,各自适用于什么场景?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
506
专栏/中间件核心技术与实战/05多线程:多线程编程有哪些常见的设计模式?.md
Normal file
506
专栏/中间件核心技术与实战/05多线程:多线程编程有哪些常见的设计模式?.md
Normal file
@ -0,0 +1,506 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 多线程:多线程编程有哪些常见的设计模式?
|
||||
你好,我是丁威。
|
||||
|
||||
从这节课开始,我们开始学习 Java 多线程编程。
|
||||
|
||||
多线程是很多人在提升技术能力的过程中遇到的第一个坎,关于这部分的资料在网络上已经很多了,但是这些资料往往只重知识点的输出,很少和实际的生产实践相挂钩。但是我不想给你机械地重复“八股文”,接下来的两节课,我会结合这些年来在多线程编程领域的经验,从实际案例出发,带你掌握多线程编程的要领,深入多线程的底层运作场景,实现理解能力的跃升。
|
||||
|
||||
如何复用线程?
|
||||
|
||||
线程是受操作系统管理的最核心的资源,反复创建和销毁线程会给系统层面带来比较大的开销。所以,为了节约资源,我们需要复用线程,这也是我们在多线程编程中遇到的第一个问题。那怎么复用线程呢?
|
||||
|
||||
我们先来看一小段代码:
|
||||
|
||||
Thread t = new Thread(new UserTask());
|
||||
|
||||
|
||||
请你思考一下,这段代码会创建一个操作系统线程吗?
|
||||
|
||||
答案是不会。这段代码只是创建了一个普通的 Java 对象,要想成为一个真实的线程,必须调用线程的 start 方法,让线程真正受操作系统调度。而线程的结束和 run 方法的执行情况有关,一旦线程的 run 方法结束运行,线程就会进入消亡阶段,相关资源也会被操作系统回收。
|
||||
|
||||
所以要想复用线程,一个非常可行的思路就是,不让 run 方法结束。
|
||||
|
||||
通常我们会想到下面这种办法:
|
||||
|
||||
class Task implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
while(true) {
|
||||
if( shouldRun() ) {// 符合业务规则就运行
|
||||
doSomething();
|
||||
} else {
|
||||
try {
|
||||
//休眠1s,继续去判断是否可运行
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
private void doSomething() {
|
||||
}
|
||||
private boolean shouldRun() {
|
||||
//根据具体业务规则进行判断
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
通过一个 while(true) 死循环确保 run 方法不会结束,然后不断地判断当前是否可以执行业务逻辑;如果不符合执行条件,就让线程休眠一段时间,然后再次进行判断。
|
||||
|
||||
这个方法确实可以复用线程,但存在明显的缺陷。因为一旦不满足运行条件,就会进行反复无意义的判断,造成 CPU 资源的浪费。另外,在线程处于休眠状态时,就算满足执行条件,也需要等休眠结束后才能触发检测,时效性会大打折扣。
|
||||
|
||||
那我们能不能一有任务就立马执行,没有任务就阻塞线程呢?毕竟,如果线程处于阻塞状态,就不会参与 CPU 调度,自然也就不会占用 CPU 时间了。
|
||||
|
||||
答案当然是可以的,业界有一种非常经典的线程复用模型:while 循环 + 阻塞队列,下面是一段示范代码:
|
||||
|
||||
|
||||
class Task implements Runnable {
|
||||
private LinkedBlockingQueue taskQueue = new LinkedBlockingQueue();
|
||||
private AtomicBoolean running = new AtomicBoolean(true);
|
||||
|
||||
public void submitTask(Object task) throws InterruptedException {
|
||||
taskQueue.put(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while(running.get()) {
|
||||
try {
|
||||
Object task = taskQueue.take(); // 如果没有任务,会使线程阻塞,一旦有任务,会被唤醒
|
||||
doSomething(task);
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
if(running.compareAndSet(true, false)) {
|
||||
System.out.println(Thread.currentThread() + " is stoped");
|
||||
}
|
||||
}
|
||||
|
||||
private void doSomething(Object task) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们来解读一下。这里,我们用 AtomicBoolean 变量来标识线程是否在运行中,用 while(running.get()) 替换 while(true),方便优雅地退出线程。
|
||||
|
||||
线程会从阻塞队列中获取待执行任务,如果当前没有可执行的任务,那么线程处于阻塞状态,不消耗 CPU 资源;一旦有任务进入到阻塞队列,线程会被唤醒执行任务,这就很好地保证了时效性。
|
||||
|
||||
那怎么停止一个线程呢?调用线程的 shutdown 方法一定能停止线程吗?
|
||||
|
||||
答案是不一定。 如果任务队列中没有任务,那么线程会一直处于阻塞状态,不能被停止。而且,Java 中 Thread 对象的 stop 方法被声明为已过期,直接调用并不能停止线程。那怎么优雅地停止一个线程呢?
|
||||
|
||||
原来,Java 中提供了中断机制,在 Thread 类中与中断相关的方法有三个。
|
||||
|
||||
|
||||
public void interrupt():Thread 实例方法,用于设置中断标记,但是不能立即中断线程。
|
||||
|
||||
public boolean isInterrupted():Thread 实例方法,用于获取当前线程的中断标记。
|
||||
|
||||
public static boolean interrupted():Thread 静态方法,用于获取当前线程的中断标记,并且会清除中断标记。
|
||||
|
||||
|
||||
如果调用线程对象的 interrupt() 方法,会首先设置线程的中断位,这时又会出现两种情况:
|
||||
|
||||
|
||||
如果线程阻塞在支持中断的方法上,会立即结束阻塞,并向外抛出 InterruptedException(中断异常);
|
||||
|
||||
如果线程没有阻塞在支持中断的方法上,则该方法不能立即停止线程。
|
||||
|
||||
|
||||
不过要说明的是,JUC 类库中的所有阻塞队列、锁、Object 的 wait 等方法都支持中断。
|
||||
|
||||
通常,我们需要在代码中添加显示的中断检测代码,我还是用前面的例子给出示例代码,你可以看一下:
|
||||
|
||||
|
||||
static class Task implements Runnable {
|
||||
private LinkedBlockingQueue taskQueue = new LinkedBlockingQueue();
|
||||
private AtomicBoolean running = new AtomicBoolean(true);
|
||||
|
||||
public void submitTask(Object task) throws InterruptedException {
|
||||
taskQueue.put(task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while(running.get()) {
|
||||
try {
|
||||
Object task = taskQueue.take(); // 如果没有任务,会使线程阻塞,一旦有任务,会被唤醒
|
||||
doSomething(task);
|
||||
|
||||
if(Thread.currentThread().isInterrupted()) {
|
||||
//线程被中断,跳出循环,线程停止
|
||||
break;
|
||||
}
|
||||
|
||||
//这是一个耗时很长的方法
|
||||
doSomething2(task);
|
||||
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
if(running.compareAndSet(true, false)) {
|
||||
System.out.println(Thread.currentThread() + " is stoped");
|
||||
}
|
||||
}
|
||||
|
||||
private void doSomething(Object task) {
|
||||
}
|
||||
|
||||
private void doSomething2(Object task) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
我们继续说回线程的复用。JUC 框架提供了线程池供我们使用。关于线程池相关的基础知识,你可以参考我之前的文章《如何评估一个线程池需要设置多少个线程》,这里我就不过多展开了。接下来,我就结合自己的工作经验分享一下怎么在实战中使用线程池。
|
||||
|
||||
我非常不建议你直接使用 Executors 相关的 API 来创建线程池,因为通过这种方式创建的线程池内部会默认创建一个无界的阻塞队列,一旦使用不当就会造成内存泄露。
|
||||
|
||||
我更推荐你使用 new 的方式创建线程,然后给线程指定一个可阅读的名称:
|
||||
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.MILLISECONDS,
|
||||
new LinkedBlockingQueue<>(), new ThreadFactory() {
|
||||
private AtomicInteger threadNum = new AtomicInteger(0);
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r);
|
||||
t.setName("pull-service-" + threadNum.incrementAndGet());
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
这样,当系统发生故障时,如果我们想要分析线程栈信息,就能很快定位各个线程的职责。例如,RocketMQ 的消费线程我就会以“ConsumeMessageThread_”开头。
|
||||
|
||||
使用线程池另一个值得关注的问题是怎么选择阻塞队列,是使用无界队列还是有界队列。
|
||||
|
||||
通常,我们可以遵循这样的原则:对于 Request-Response 等需要用户交互的场景,建议使用有界队列,避免内存溢出;对于框架内部线程之间的交互,可以根据实际情况加以选择。
|
||||
|
||||
我们通过几个例子来看一下具体的场景。
|
||||
|
||||
项目开发中通常会遇到文件下载、DevOps 的系统发布等比较耗时的请求,这类场景就非常适合使用线程池。基本的工作方式如图:
|
||||
|
||||
|
||||
|
||||
在与用户交互的场景中,如果几十万个文件下载请求同时提交到线程池,当线程池中的所有线程都在处理任务时,无法及时处理的请求就会存储到线程池中的阻塞队列中。这就很容易使内存耗尽,从而触发 Full-GC,导致系统无法正常运作。
|
||||
|
||||
因此,这类场景我建议使用有界队列,直接拒绝暂时处理不了的请求,并给用户返回一条消息“请求排队中,请稍后再试”,这就保证了系统的可用性。
|
||||
|
||||
在一个线程或多个线程向一个阻塞队列中添加数据时,通常也会使用有界队列。记得我在开发数据同步产品时,为了实现源端与目标端线程,就采用了阻塞队列,下面是一张示意图:
|
||||
|
||||
|
||||
|
||||
为了实现 MySQL 增量同步,Canal 线程源源不断地将 MySQL 数据写入到阻塞队列,然后目标端线程从队列中读取数据并写入到 MQ。如果写入端的写入速度变慢,阻塞队列中的数据就变得越来越多,一旦不加以控制就可能导致内存溢出。所以,为了避免由于写入端性能瓶颈造成的整个系统的不可用,这时候需要引入有界阻塞队列。这样,队列满了之后,我们就能让源端线程处于阻塞状态,从而对源端进行限流。
|
||||
|
||||
但在选择阻塞队列时还可能有另外一种情况,那就是一个线程对应多个阻塞队列,这时我们一般会采用无界阻塞队列 +size 的机制,实现细粒度限流。当时,我设计的 RocketMQ 消费模型是下面这样:
|
||||
|
||||
|
||||
|
||||
一个拉取线程轮流从 Broker 端队列 (q0、q1) 中拉取消息,然后根据队列分别放到不同的阻塞队列中,每一个阻塞队列会单独分配单个或多个线程去处理。
|
||||
|
||||
这个时候,采用有界队列可能出现问题。如果我们采用有界队列,一旦其中一个阻塞队列对应的下游消费者处理性能降低,阻塞队列中没有剩余空间存储消息,就会阻塞消息发送线程,最终造成另外一个任务也无法拉取新的消息。显然,这会让整体并发度降低,影响性能。
|
||||
|
||||
那如果采用无界队列呢?单纯使用无界队列容易导致内存泄露,触发更严重的后果,好像也不是一个好的选择。
|
||||
|
||||
其实我们可以在无界队列的基础上额外引入一个参数,用它来控制阻塞队列中允许存放的消息条数。当阻塞队列中的数据大于允许存放的阔值时,新的消息还可以继续写入队列,不会阻塞消息发送线程。但我们需要给消息拉取线程一个反馈,暂时停止从对应队列中拉取消息,从而实现限流。
|
||||
|
||||
阻塞队列是多线程协作的核心纽带,除了清楚它的使用方法,我们还应该理解它的使用原理,也就是 “锁 + 条件等待与唤醒”。我们来看一下 LinkedBlockingQueue 的 put 的实现代码:
|
||||
|
||||
public void put(E e) throws InterruptedException {
|
||||
if (e == null) throw new NullPointerException();
|
||||
|
||||
int c = -1;
|
||||
Node<E> node = new Node<E>(e);
|
||||
final ReentrantLock putLock = this.putLock;
|
||||
final AtomicInteger count = this.count;
|
||||
putLock.lockInterruptibly(); // @1
|
||||
try {
|
||||
while (count.get() == capacity) { // @2
|
||||
// private final ReentrantLock putLock = new ReentrantLock();
|
||||
// private final Condition notFull = putLock.newCondition();
|
||||
notFull.await(); // @3
|
||||
}
|
||||
enqueue(node); // @4
|
||||
c = count.getAndIncrement();
|
||||
if (c + 1 < capacity)
|
||||
notFull.signal();
|
||||
} finally {
|
||||
putLock.unlock();
|
||||
}
|
||||
if (c == 0)
|
||||
signalNotEmpty();
|
||||
}
|
||||
|
||||
|
||||
这里,我重点解读一下关键代码。
|
||||
|
||||
第 8 行:我们要申请锁,获取队列内部数据存储结构 (LinkedBlockingQueue 底层结构为链表) 的修改控制权,也就是让一个阻塞队列同一时刻只能操作一个线程。
|
||||
|
||||
第 10 行:判断队列中元素的数量是否等于其最大容量,如果是,则线程进入到条件等待队列 (第 13 行),调用 put 的线程会释放锁进入到阻塞队列。当队列中存在空闲空间时,该线程会得到通知,从而结束阻塞状态进入到可调度状态。
|
||||
|
||||
队列中有可用空间之后,线程被唤醒,但是不能立即执行代码(第 15 行),它需要重新和其他线程竞争锁,获得锁后将数据存储到底层数据结构中。关于锁的底层原理,我们会在下节课详细介绍。
|
||||
|
||||
这里也请你思考一下:为什么上面的代码我们要采用 while(count.get() == capacity) 而不使用 if(count.get() == capacity) 呢?
|
||||
|
||||
多线程编程常用的设计模式
|
||||
|
||||
如果你刚开始学习多线程编程,可能会觉得这个问题很难。不过不用担心,业界大佬早就总结出了很多和多线程编程相关的设计模式。接下来,我就带你一起看看其中应用最广的几个。
|
||||
|
||||
Future 模式
|
||||
|
||||
多线程领域一个非常经典的设计模式是 Future 模式。它指的是主线程向另外一个线程提交任务时,无须等待任务执行完毕,而是立即返回一个凭证,也就是 Future。这时主线程还可以做其他的事情,不会阻塞。等到需要异步执行结果时,主线程调用 Future 的 get 方法,如果异步任务已经执行完毕,则立即获取结果;如果任务还没执行完,则主线程阻塞,等待执行结果。
|
||||
|
||||
Future 模式的核心要领是将多个请求进行异步化处理,并且可以得到返回结果。我们来看一个示例:
|
||||
|
||||
|
||||
|
||||
当一个请求在处理时,需要发起多个远程调用,并且返回多个请求,再根据结果进行下一步处理。它的伪代码如下:
|
||||
|
||||
Object result1 = sendRpcToUserCenter(); // @1
|
||||
Object result2 = sendRpcToOrgCenter(0); // @2
|
||||
Object result = evalBusiness(result1,result2);
|
||||
|
||||
|
||||
说明一下,在不使用 Future 模式的情况下,两个远程 RPC 调用是串行执行的。例如,第一个请求需要 1s 才能返回,第二个请求需要 1.5s 才能返回,这两个过程就需要 2.5s。为了提高性能,我们可以将这两个请求进行异步处理,然后分别得到处理结果。这就到了 Future 模式发挥作用的时候了。
|
||||
|
||||
业务开发领域通常会采用线程池的方式来实现 Future 模式,你可以看下具体的实现代码:
|
||||
|
||||
|
||||
package net.codingw.jk02;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
public class FutureTask {
|
||||
static class Rpc2UserCenterTask implements Callable<Object> {
|
||||
@Override
|
||||
public Object call() throws Exception {
|
||||
return sendRpcToUserCenter();
|
||||
}
|
||||
private Object sendRpcToUserCenter() {
|
||||
// 具体业务逻辑省略
|
||||
return new Object();
|
||||
}
|
||||
}
|
||||
|
||||
static class Rpc2OrgCenterTask implements Callable<Object> {
|
||||
@Override
|
||||
public Object call() throws Exception {
|
||||
return sendRpcToOrgCenter();
|
||||
}
|
||||
private Object sendRpcToOrgCenter() {
|
||||
// 具体业务逻辑省略
|
||||
return new Object();
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// 生产环境建议使用 new ThreadPoolExecutor方式创建线程池
|
||||
ExecutorService executorService = Executors.newFixedThreadPool(5);
|
||||
// 发起
|
||||
Future<Object> userRpcResultFuture = executorService.submit(new Rpc2UserCenterTask()); //异步执行
|
||||
Future<Object> orgRpcResultFuture = executorService.submit(new Rpc2OrgCenterTask()); // 异步执行
|
||||
Object userRpcResult = userRpcResultFuture.get(); // 如果任务未执行完成,则该方法会被阻塞,直到处理完成
|
||||
Object orgRpcResult = orgRpcResultFuture.get(); // 如果任务未执行完成,则该方法会被阻塞,直到处理完成
|
||||
doTask(userRpcResult, orgRpcResult);
|
||||
}
|
||||
|
||||
private static void doTask(Object userRpcResult, Object orgRpcResult) {
|
||||
// doSomeThing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们还是解读一下这段代码的要点。
|
||||
|
||||
|
||||
首先,我们需要创建一个线程池。
|
||||
|
||||
接着,要将需要执行的具体任务进行封装,并实现 java.util.concurrent.Callable 接口 (如上述代码中的 Rpc2UserCenterTask),并重写其 Call 方法。
|
||||
|
||||
然后将一个具体的任务提交到线程池中去执行,返回一个 Future 对象。
|
||||
|
||||
在想要获取异步执行结果时,可以调用 Future 的 get 方法。如果任务已经执行成功,则直接返回;否则就会进入阻塞状态,直到任务执行完成后被唤醒。
|
||||
|
||||
|
||||
因为线程池是一个较重的资源,而中间件领域的开发追求极致的性能,所以在中间件开发领域通常不会直接使用线程池来实现 Future 模式。
|
||||
|
||||
RocketMQ 会使用 CountDownLatch 来实现 Future 模式,它的设计非常精妙,我们先一起来看一下它的序列图:
|
||||
|
||||
|
||||
|
||||
可以看到,SendMessageThread 会首先创建一个 GroupCommitRequest 请求对象,并提交到刷盘线程,然后发送线程阻塞,等待刷盘动作完成。刷盘线程在执行具体刷盘逻辑后,会调用 request 的通知方法,唤醒发送线程。
|
||||
|
||||
乍一看,主线程提交刷盘任务之后并没有返回一个 Future,那为什么说这是 Future 模式呢?这就是 RocketMQ 的巧妙之处了。它其实是把请求对象当作 Future 来使用了。我们来看一下 GroupCommitRequest 的实现代码:
|
||||
|
||||
|
||||
public static class GroupCommitRequest {
|
||||
private final long nextOffset;
|
||||
private final CountDownLatch countDownLatch = new CountDownLatch(1);
|
||||
private volatile boolean flushOK = false;
|
||||
|
||||
public GroupCommitRequest(long nextOffset) {
|
||||
this.nextOffset = nextOffset;
|
||||
}
|
||||
|
||||
public long getNextOffset() {
|
||||
return nextOffset;
|
||||
}
|
||||
|
||||
public void wakeupCustomer(final boolean flushOK) {
|
||||
this.flushOK = flushOK;
|
||||
this.countDownLatch.countDown();
|
||||
}
|
||||
|
||||
public boolean waitForFlush(long timeout) {
|
||||
try {
|
||||
this.countDownLatch.await(timeout, TimeUnit.MILLISECONDS);
|
||||
return this.flushOK;
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这里,GroupCommitRequest 中的 waitForFlush 方法相当于 Future 的 get 方法。具体实现是,调用 CountDownLatch 的 await 方法使自己处于阻塞状态,然后当具体的刷盘线程完成刷盘之后,通过调用 wakeupCustomer 方法,实际上调用了 CountDownLatch 的 countDown 方法,实现唤醒主线程的目的。
|
||||
|
||||
基于 CountDownLatch 实现的 Future 模式非常巧妙,更加得轻量级,性能也会更好。不过要说明的是,在业务开发领域,直接使用线程池将获得更高的开发效率和更低的使用成本。
|
||||
|
||||
生产者 - 消费者模式
|
||||
|
||||
Future 模式就说到这里,我们再来看看多线程编程领域中最常见的设计模式:生产者 - 消费者模式。
|
||||
|
||||
程序设计中一个非常重要的思想是解耦合,在 Java 设计领域也有一条重要的设计原则就是要职责单一。基于这些原则,通常一个功能需要多个角色相互协作才能正常完成。
|
||||
|
||||
生产者 - 消费者模式正是这种思想的体现,它的理论也很简单,我们这里不会深入介绍。但我想用 RocketMQ 举一个实操的例子。
|
||||
|
||||
在 RocketMQ 消费线程模型中,应用程序在启动消费者时,首先需要根据负载算法进行队列负载,然后消息拉取线程会根据负载线程计算的结果有针对性地拉取消息。交互流程如下图所示:
|
||||
|
||||
|
||||
|
||||
Rebalace 线程作为生产者,会根据业务逻辑生成消息拉取任务,然后 Pull 线程作为消费者会从队列中获取任务,执行对应的逻辑;如果当前没有可执行的逻辑,Pull 线程则会阻塞等待,当生产者将新的任务存入到阻塞队列中后,Pull 线程会再次被唤醒。
|
||||
|
||||
系统的运行过程中会存在很多意料之外的突发事件,在高并发领域更是这样。所以在进行系统架构设计时,我们必须具备底线思维,对系统进行必要的兜底,防止最坏的情况发生,这里最常见的做法就是采用限流机制。
|
||||
|
||||
所以在这节课的最后,我们一起来看看并发编程领域如何实现限流。
|
||||
|
||||
线程池自带一定的限流效果,因为工作线程数量是一定的,线程池允许的最大并发也是确定的。一旦达到最大并发,新的请求就会进入到阻塞队列,或者干脆被拒绝。不过这节课我想给你介绍另一种限流的方法:使用信号量。
|
||||
|
||||
我们先来看一个具体的示例:
|
||||
|
||||
public static void main(String[] args) {
|
||||
Semaphore semaphore = new Semaphore(10);
|
||||
for(int i = 0; i < 100; i++) {
|
||||
Thread t = new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
doSomething(semaphore);
|
||||
}
|
||||
});
|
||||
t.start();
|
||||
}
|
||||
}
|
||||
private static void doSomething(Semaphore semaphore) {
|
||||
boolean acquired = false;
|
||||
try {
|
||||
acquired = semaphore.tryAcquire(3000, TimeUnit.MILLISECONDS);
|
||||
if(acquired) {
|
||||
System.out.println("执行业务逻辑");
|
||||
} else {
|
||||
System.out.println("信号量未获取执行的逻辑");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if(acquired) {
|
||||
semaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这段代码非常简单,其实就是通过信号量来控制 doSomething 方法的并发度,使用了信号量的两个主要的方法。
|
||||
|
||||
|
||||
tryAcquire:这种方法是尝试获取一个信号,如果当前没有剩余的许可,过了指定等待时间之后会返回 false,表示未获取许可;
|
||||
|
||||
release:归还许可,该方法必须在 tryAcquire 方法返回 true 时调用,不然会发生“许可超发”。
|
||||
|
||||
|
||||
但是如果场景再复杂一点,比如 doSomething 是一个异步方法,前面这段代码的效果就会大打折扣了。如果 doSomething 的分支非常多,或者遇到异步调用等复杂情况下,归还许可将变得非常复杂。
|
||||
|
||||
因为在使用信号量时,如果多次调用 release,应用程序实际的并发数量会超过设置的许可值。所以避免重复调用 release 方法显得非常关键。RocketMQ 给出的解决方案如下:
|
||||
|
||||
public class SemaphoreReleaseOnlyOnce {
|
||||
private final AtomicBoolean released = new AtomicBoolean(false);
|
||||
private final Semaphore semaphore;
|
||||
|
||||
public SemaphoreReleaseOnlyOnce(Semaphore semaphore) {
|
||||
this.semaphore = semaphore;
|
||||
}
|
||||
public void release() {
|
||||
if (this.semaphore != null) {
|
||||
if (this.released.compareAndSet(false, true)) {
|
||||
this.semaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
public Semaphore getSemaphore() {
|
||||
return semaphore;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这套方案的核心思想是对 Semaphore 进行一次包装,然后将包装对象(SemaphoreReleaseOnlyOnce)传到业务方法中。就像上面这段代码,其中的 doSomething 方法无论调用 release 多少次都可以保证底层的 Semaphore 只会被释放一次。
|
||||
|
||||
SemaphoreReleaseOnlyOnce 的 release 方法引入了 CAS 机制,如果 release 方法被调用,就使用 CAS 将 released 设置为 true。下次其他线程再试图归还许可时,由于状态为 true,所以不会再次调用 Semaphore 的 release 方法,这样就可以有效控制并发数量了。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。
|
||||
|
||||
这节课一开始,我们就讲了一个大家在多线程编程中常会遇到的问题:如何复用线程?我们重点介绍了线程池这种复用方法。它的内部的原理是采用 while + 阻塞队列的机制,确保线程的 run 方法不会结束。在有任务执行时运行任务,无任务运行时则通过阻塞队列阻塞线程。我们还顺便讲了讲怎么通过中断技术优雅地停止线程。
|
||||
|
||||
使用线程池时,还有一个常见的问题就是怎么选择阻塞队列,我总结了下面三个小窍门:
|
||||
|
||||
|
||||
Request-Response 等需要用户交互的场景,建议使用有界队列,避免内存溢出;
|
||||
|
||||
如果一个线程向多个队列写入消息,建议使用“无界队列 +size”机制,不阻塞队列;
|
||||
|
||||
如果一个线程向一个队列写入消息,建议使用有界队列,避免内存溢出。
|
||||
|
||||
|
||||
这节课的后半部分,我们详细介绍了多线程领域 Future 模式、生产者 - 消费者模式的工作原理和使用场景。我还提到了高并发架构设计中的底线思维:限流机制。基于信号量来实现限流,在多线程环境中避免信号量的超发可以防止你踩到很多坑。
|
||||
|
||||
课后题
|
||||
|
||||
在课程的最后,我还是照例给你留两道思考题。
|
||||
|
||||
你是怎么理解 Future 模式的?又会怎么实现它呢?
|
||||
|
||||
场景题:有一家主要生产面包的工厂,但是工厂的仓库容量非常有限。一旦仓库存满面包,就没法生产新的面包了。顾客来购买面包后,仓库容量会得到释放。请你用 Java 多线程相关的技术实现这个场景。
|
||||
|
||||
完成这个场景可以让我们迅速理解多线程编程的要领,所以请你一定要重视第二题。如果你想要分享你的修改或者想听听我的意见,可以提交一个 GitHub的 push 请求或 issues,并把对应地址贴到留言里。我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
387
专栏/中间件核心技术与实战/06锁:如何理解锁的同步阻塞队列与条件队列?.md
Normal file
387
专栏/中间件核心技术与实战/06锁:如何理解锁的同步阻塞队列与条件队列?.md
Normal file
@ -0,0 +1,387 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 锁:如何理解锁的同步阻塞队列与条件队列?
|
||||
你好,我是丁威。
|
||||
|
||||
这节课,我们重点介绍并发编程中的基石:锁。
|
||||
|
||||
锁的基本存储结构
|
||||
|
||||
我们先通过一个简单的场景来感受一下锁的使用场景。一家三口在一起生活,家里只有一个卫生间,大家早上起床之后都要去厕所。这时候,一个人在卫生间,其他人就必须排队等待。
|
||||
|
||||
|
||||
|
||||
这个场景用 IT 术语可以表述为下面两点。
|
||||
|
||||
|
||||
洗手间作为一个资源在同一时间只能被一个人使用,它具备排他性。
|
||||
|
||||
一个人用完洗手间(资源)之后会归还锁,然后排队者重新开始竞争洗手间的使用权。
|
||||
|
||||
|
||||
我们可以对这个场景进行建模。
|
||||
|
||||
|
||||
资源:更准确地说是公共资源或共享资源需要被不同的操作者使用,但它不能同时被使用。
|
||||
|
||||
资源使用者:共享资源的使用者。
|
||||
|
||||
锁:用来保护资源的访问权。锁对象的归属权为共享资源,但当资源使用者向资源申请操作时,可以将锁授予资源使用者。这时候,资源使用者叫做锁的占有者,在此期间它有权操作资源。操作者不再需要操作资源之后,主动将锁归还。
|
||||
|
||||
排队队列:我们可以更专业地称之为阻塞队列,它可以存储需要访问资源但还没获取锁的资源使用者,其归属权通常为锁对象。
|
||||
|
||||
这里我之所以强调归属权,主要是因为它可以帮助我们理解锁的基本结构和资源的关系。
|
||||
|
||||
|
||||
那锁的结构是什么呢?我们通过上节课的课后题来理解这个问题。
|
||||
|
||||
上节课的第二道课后题是问你怎么用多线程实现面包厂的生产和销售。我在这里也给你写了一段示例代码:
|
||||
|
||||
|
||||
|
||||
面包仓库的职能是为面包厂存储面包,它需要提供两个基本的方法:存储面包和获取面包。面包仓库内部使用 ArrayList 来存储面包,但是因为 ArrayList 是一个线程不安全的存储容器,它不允许多个使用者同时存储数据,所以我们需要对资源进行保护。体现在代码上,我们可以通过 synchronized(资源对象)来创建一把锁,保护多线程对资源对象的串行访问。
|
||||
|
||||
我们结合 put 方法的流程来看一下锁的基本存储结构。
|
||||
|
||||
假设 t1、t2 两个生产者(线程)同时调用 Bakery 的 put 方法。那么 synchronized(breads) 在编译的时候,就会在资源 breads 对象上创建锁相关的结构,即锁对象。
|
||||
|
||||
t1,t2 在执行 synchronized(breads) 时,只有一个线程可以获取锁,另外一个线程需要等待,所以这里需要引入一个存储结构(通常为队列)来存储这些排队的线程,我们通常会使用阻塞队列。
|
||||
|
||||
首先获取到锁的线程 t1 在向仓库中存放面包之前需要先进行判断,如果存储空间足够,执行上图中的代码 step2。但是如果仓库没有足够的空间存储面包,就要执行代码 step3,调用锁对象的 wait 方法,让获得锁的线程 t1 阻塞,并且释放锁。
|
||||
|
||||
但是,被阻塞的 t1 和 t2 还是有所不同。因为 t1 被阻塞的原因是条件不满足,当面包仓库有额外的存储空间时,t1 就会被唤醒。所以我们还要引入一个条件队列,用来存放因条件不满足而被阻塞的线程。
|
||||
|
||||
t1 线程如果因为条件不满足而存储在条件等待队列,当存在剩余空间后,就能被其他线程唤醒继续执行后续的代码了。在这里是将面包存储到 ArrayList,那此时面包工厂中存储了面包,需要通知那些因为仓库中没有面包而阻塞的线程,调用锁的 notifyAll 方法唤醒在等待的线程。
|
||||
|
||||
线程 t1 因为存储空间不足在 step3 被阻塞,进入到条件等待队列。等到面包被卖出,仓库有足够的容量之后,t1 线程将被唤醒。
|
||||
|
||||
这里我想给你提个问题,t1 线程可以立马继续执行 step3 之后的代码 step4 吗?
|
||||
|
||||
答案是不能,它需要先去尝试竞争锁,成功获得锁之后才能开始执行 step4,否则就会进入到阻塞队列。
|
||||
|
||||
从上面这个过程中,我们可以归纳出锁的基本存储结构,它包括锁的持有者线程、锁的重入次数、阻塞队列和条件等待队列四个部分。
|
||||
|
||||
锁的底层实现机制 -AQS 实现原理剖析
|
||||
|
||||
在 Java 中使用锁通常有两种编程方式。一种是使用 JVM 虚拟机(Java 规范)层面提供的 synchronized 关键字;另一种是使用 JUC 类库,也就是大名鼎鼎的 AbstractQueuedSynchronizer,简称 AQS。
|
||||
|
||||
其中,synchronized 是在 JVM 虚拟机层面实现的,涉及很多底层知识,直接研读源码难度太大。相比较而言,JUC 并发编程遵从 JSR-166 规范,提供了锁的另外一种实现方式,也就是大家所熟知的 AQS 类库,更加常用和易学。
|
||||
|
||||
接下来,我会基于 JUC 框架,带你从代码层面近距离观摩锁的实现原理,掌握锁的本质。
|
||||
|
||||
在 JUC 框架中,ReentrantLock 对标 synchronized,它实现了可重入互斥锁的全部语义。语义主要包括两个方面:一个是 lock(申请锁)和 unlock(释放锁);另一个是条件等待,对标 Object 的 wait/notify。
|
||||
|
||||
我们先来看下 ReentrantLock 和 AQS 的类图:
|
||||
|
||||
|
||||
|
||||
简单介绍一下类图中各个类的含义。
|
||||
|
||||
|
||||
AbstractQueuedSynchronizer
|
||||
|
||||
|
||||
它是 AQS 体系的核心基类,使用的是类模版设计模式。这个类实现了锁的基本存储结构,定义了锁的基本行为。AQS 的内部数据结构为链表,持有链表的头尾节点,每一个节点用 Node 表示,可以实现阻塞队列和条件等待队列。其中,Node prev、next 用于构建阻塞队列,而 Node nextWatier 用于构建条件等待队列。
|
||||
|
||||
AQS 方法的修饰符也很有规律,其中,使用 protected 修饰的方法为抽象方法,通常需要子类去实现,从而实现不同特性的锁(例如互斥、共享锁、读写锁等);而用 public 修饰的方法基本可以认为是模板方法,不建议子类直接覆盖。
|
||||
|
||||
AQS 还额外提供了很多有用的方法,我给你列了个表格,方便你在有需要的时候随时查看。
|
||||
|
||||
|
||||
|
||||
|
||||
AbstractOwnableSynchronizer
|
||||
|
||||
|
||||
它是 AQS 核心基类的父类,用于记录锁当前的持有者线程。
|
||||
|
||||
|
||||
ReentrantLock
|
||||
|
||||
|
||||
可重入互斥锁的具体实现。由于 Java 不支持多继承,所以由 ReentrantLock 继承抽象类 Lock,用内部类的方式继承 AQS。所以说,ReentrantLock 在具体实现锁时基本都是委托内部类 Sync,而 Sync 又继承自 AQS。Sync 内部有两个子类,分别是 FairSync(公平锁)与 NonfairSync(非公平锁)。
|
||||
|
||||
锁的申请
|
||||
|
||||
接下来我们结合 ReentrantLock 的部分关键源码来看看怎么实现锁的申请与释放。先看锁的申请。
|
||||
|
||||
ReentrantLock 支持带超时时间的锁申请,具体实现方法是 tryLock,时序图如下:
|
||||
|
||||
|
||||
|
||||
AQS 的 tryAcquireNanos 代码如下图所示,该方法是在 AQS 中定义的。
|
||||
|
||||
|
||||
|
||||
解读一下核心要点。
|
||||
|
||||
如果线程的中断位标记为 true,表示应用方主动放弃锁的申请,可以直接抛出中断异常,结束锁的申请。
|
||||
|
||||
否则,调用 Sync 的 tryAcquire 尝试获取锁。如果返回 true,表示成功获取锁,可以直接返回;不然就调用 doAcquireNanos,进入锁等待队列。
|
||||
|
||||
Sync 的 tryAcquire 方法,代码如下:
|
||||
|
||||
|
||||
|
||||
尝试实现锁有几个要点。
|
||||
|
||||
首先我们要确保获取当前申请锁的线程。
|
||||
|
||||
我们还要获取锁的当前状态,也就是 state 值(state 字段的含义是当前锁的重入次数,如果 state 为 0,表示当前锁并没有被占用)。这又包括两种情况。
|
||||
|
||||
情况一:如果 state 的值为 0,表示当前锁并没有被占用。根据申请锁的公平与否,会有不同的处理逻辑。
|
||||
|
||||
|
||||
如果是公平锁,那么我们需要判断阻塞队列中有没有其他线程在排队。如果有,公平锁此时无法竞争锁,返回 false,尝试获取锁失败。这个线程最终会调用 doAcquireNanos,进入到同步阻塞队列。
|
||||
|
||||
但是如果是非公平锁,则会首先和阻塞队列中的线程竞争,如果竞争成功,可以直接获取锁,如果竞争失败,则同样进入到阻塞队列。
|
||||
|
||||
竞争锁的代码使用的是 CAS 机制,尝试更新 state 的值为 acquires,如果更新成功,则占有锁。成功占有锁之后,需要设置锁的拥有者为当前线程。
|
||||
|
||||
|
||||
情况二:如果 state 的值不为 0,表示锁已经被占用。我们需要判断当前线程是不是锁的持有者。如果是,则只需要更新 state 的值(ReentrantLock 支持可重入);否则就进入阻塞队列,排队获取锁。
|
||||
|
||||
为什么在竞争锁时需要使用 CAS 呢?什么是 CAS 呢?
|
||||
|
||||
我们知道,申请锁时要先查询 state 的值,然后更新 state 的值。但这两步在多线程环境中并不是一个安全的操作。如下图所示:
|
||||
|
||||
|
||||
|
||||
这很容易导致 t1,t2 都获取到了锁。根本原因是这个步骤包括两个 CPU 指令,无法做到原子更新。
|
||||
|
||||
为了解决这个问题,操作系统提供了一个新的 CPU 指令(CAS),用它来实现“比较 - 和 - 更新”。具体的原理是在更新一个值之前,首先比较这个值是否发生了变化,如果确实发生了变化,那么就会更新失败,否则更新成功。
|
||||
|
||||
如果没有成功获取锁,当前申请锁的线程还会继续调用 AQS 的 doAcquireNanos:
|
||||
|
||||
|
||||
|
||||
这是 AQS 机制中非常重要的一个方法,它的实现比较复杂。我们先来看一张流程图:
|
||||
|
||||
|
||||
|
||||
我们可以把这个流程归结为五步。
|
||||
|
||||
第一步:判断获取锁是不是已经超时,如果是,返回 false(ReentrantLock 支持锁获取超时)。
|
||||
|
||||
第二步:调用 addWaiter 方法把当前节点加入到阻塞队列中。
|
||||
|
||||
第三步:获取节点的前驱节点。
|
||||
|
||||
第四步:如果节点的前驱节点为头节点,再次调用 tryAcquire 方法尝试获取锁。如果成功获取锁,则将当前节点设置为 Head,表示当前它是锁的持有者。
|
||||
|
||||
第五步:如果当前节点不是头节点,或者没有成功获取锁,调用 shouldParkAfterFailedAcquire 判断当前线程是否需要阻塞,如果需要阻塞,则调用 LockSupport.parkNanos 阻塞线程。
|
||||
|
||||
接下来,我们对上面流程中的关键代码进行详细的解读。
|
||||
|
||||
先看第二步里 addWaiter 的具体实现:
|
||||
|
||||
|
||||
|
||||
因为 AQS 内部不管是阻塞队列还是条件等待队列都是基于链表实现的,所以入队列的实现比较容易理解,这里主要关注三点。
|
||||
|
||||
|
||||
需要创建一个 Node 节点,将线程对象存储在 Node 节点中,方便后续对线程进行阻塞或唤醒。
|
||||
|
||||
链表在多线程环境中操作并不安全,所以在更新链表相关指针时要引入 CAS 机制。首先将 if 和 CAS 组合进行一次测试,如果更新成功,直接结束操作;不然就要使用 for 和 CAS 的组合进行多次重试,一直到更新成功为止。这背后的原理是,多线程在更新 Head 或者 Tail 时,只有一个能更新成功,如果更新失败,则重新获取 Head 或者 Tail 再进行更新,直到节点安全地加入链表为止。
|
||||
|
||||
在入队的过程中,如果队列为空时,会创建一个空的 Node 节点,但是不持有任何线程信息。
|
||||
|
||||
|
||||
等到节点成功加入到阻塞队列后,需要判断节点的前驱节点是否为头节点,如果是,表示成功获取锁。成功获得锁的线程对应的节点将成为头节点,设置头节点的代码如下:
|
||||
|
||||
|
||||
|
||||
头节点持有的线程对象为什么为空呢?
|
||||
|
||||
这是因为锁的持有者被记录在了 AbstractOwnableSynchronizer 的 Thread exclusiveOwnerThread 属性中。这样做的好处是,我们可以认为头节点是锁的持有者,但头节点却并不维护线程对象。在实现非公平锁时,如果锁被新线程抢占,不需要更新头节点。
|
||||
|
||||
相反,如果节点的前驱节点不是头节点,则需要判断申请锁的线程是否需要阻塞。我们可以通过 shouldParkAfterFailedAcquire 方法来实现它:
|
||||
|
||||
|
||||
|
||||
解读一下,如果前驱节点的状态是 Node.SIGNAL,则当前线程直接进入到阻塞队列,排队获取锁。
|
||||
|
||||
这里再对 Node.SIGNAL 补充说明一下:Node.SIGNAL 的含义是节点需要一个信号来唤醒自己,如果前驱节点的状态为 Node.SIGNAL,说明前驱节点在等待被唤醒,那作为前驱节点的后继节点,自然而然也需要等待被唤醒。
|
||||
|
||||
如果前驱节点的状态大于 0,需要删除当前节点之前连续的节点。因为当前节点的状态只有 Node.CANCELLED 大于 0,所以如果前驱节点的状态大于 0 说明是已取消的节点,需要被删除。示例图如下:
|
||||
|
||||
|
||||
|
||||
这里以当前节点为基准(状态为 -1)向前删除。注意,只删除连续的 1,也就是说遇到非取消节点立即停止删除。基于分段思想,我们不会删除前面所有的已取消节点,因为删除节点的方向是从后向前的,而且 shouldParkAfterFailedAcquire 这个方法会在多个线程获取锁之后被多个线程调用,但后续的节点在执行删除时,遇到当前线程,会被切割成段,段与段之间并不会有多线程执行,从而可以安全地操作各自的段。
|
||||
|
||||
如果前驱节点的状态为 0 或 Propagate,需要尝试把前驱节点的状态变更为 Node.SIGNAL。也就是说,不阻塞线程,而是再次试图获取锁相关的逻辑。
|
||||
|
||||
如果需要阻塞线程,先判断本次获取锁的剩余时间是否大于等于 spinForTimeoutThreshold,如果是,则通过自旋方式进行循环,否则将使线程阻塞。其中 spinForTimeoutThreshold 默认为 1s,这样做的目的主要是如果本次锁申请距超时还剩不到 1s,就没有必要再阻塞线程了,避免线程切换带来的额外开销。
|
||||
|
||||
如果需要阻塞线程,我们可以调用 LockSupport.parkNanos 方法使线程阻塞,这个方法同样支持设置超时时间。
|
||||
|
||||
锁的释放
|
||||
|
||||
申请完锁之后,我们还会面临锁的释放。我们可以通过 ReentrantLock 的 unlock 方法释放锁,并最终调用 AQS 的模版方法:release 方法,代码如图所示:
|
||||
|
||||
|
||||
|
||||
在详细地介绍具体的方法之前,我们先来看一张整体的时序图,理解一下释放锁的实现机制。
|
||||
|
||||
|
||||
|
||||
锁的释放主要包含如下几个步骤:
|
||||
|
||||
第一步:释放锁,必须先判断当前线程是否是锁的持有者,如果不是,抛出 IllegalMonitorStateException 异常。
|
||||
|
||||
第二步:判断锁的剩余占有次数,如果为 0,表示锁已释放,需要唤醒阻塞队列中的其他排队线程。
|
||||
|
||||
我们看一下释放锁的关键代码。具体定义在 ReentrantLock$Sync 的 tryRelease 中:
|
||||
|
||||
|
||||
|
||||
这段代码有两个要点。
|
||||
|
||||
|
||||
如果当前锁的占有者不是申请释放锁的线程,那就不能释放锁,只有持有者线程才能释放锁。这个时候需要抛出监视器错误。
|
||||
|
||||
如果一个锁被同一个线程重入 n 次,那对应也要释放 n 次。当持有次数为 0 时,表示可以释放锁。
|
||||
|
||||
|
||||
尝试释放锁后,返回“成功”,接下来要做的是唤醒阻塞队列中的下一个线程。当然,如果你使用的是非公平锁,新来的线程在这个时候是可以直接获取锁的,这样唤醒的线程如果没能获取锁,就又会进入到阻塞队列中。
|
||||
|
||||
从阻塞队列中查找下一个待唤醒的线程需要使用 AQS 的 unparkSuccessor 方法,代码如下图所示:
|
||||
|
||||
|
||||
|
||||
这个过程主要包括四个要点,分别对应上图的 step1、step2、step3 和 step4。
|
||||
|
||||
step1:因为这个方法的参数是头节点,头节点是当前锁的持有者,所以在释放锁时,我们要找头节点的下一个未取消的节点。
|
||||
|
||||
step2:确认头节点的状态。如果头节点的状态不为 0,则更新为 0。
|
||||
|
||||
step3:从链表的尾部开始寻找,找到头节点后面的第一个非取消节点。这里说明一下,因为我们在维护节点的前驱节点时使用了 CAS,通过前驱节点遍历是可靠的,不会遗漏节点。
|
||||
|
||||
step4:找到对应的节点,调用 LockSupport.unpark 方法唤醒线程。线程被唤醒后会继续去竞争锁。这里唤醒的是申请锁时用 LockSupport.park 阻塞的线程,因为这样可以让锁的申请和释放形成闭环通道。
|
||||
|
||||
锁的条件等待队列
|
||||
|
||||
理解了锁的申请和释放,接下来我们再来看看 ReentrantLock 是怎么实现 Object.wait 和 Object.notify 语义的,这是线程之间协作的基石。
|
||||
|
||||
线程调用锁对象的 wait 方法时会进入到条件等待队列,而线程调用锁对象的 notify 方法,会唤醒条件队列中的一个线程,具有下面三个特征。
|
||||
|
||||
|
||||
Object 的 wait 与 notify 必须在临界区中调用。
|
||||
|
||||
Object 的 wait 和 notify 的使用场景为条件等待。例如,一个线程获取锁后,需要等待某一个条件满足后才能继续执行。这时,为了节省 CPU 资源,线程可以调用锁的 wait 方法使自己阻塞,等待条件满足后被别的线程唤醒。
|
||||
|
||||
Object 的 wait 方法会释放当前锁。
|
||||
|
||||
|
||||
在 AQS 中,实现 Object 的 notify 和 wait 功能的主要类为 Condition,类图如下:
|
||||
|
||||
|
||||
|
||||
Condition 的接口对标 Object 的 wait 与 notify 方法,底层的存储结构为一个链表(条件阻塞队列)。链表中的节点为 Node,条件阻塞队列为单链表,链表通过 Node nextWaiter 指针维护链表。
|
||||
|
||||
因为前面在介绍 lock 语义的时候我们用的是带超时时间的方法,所以为了覆盖更多的 AQS 方法,这里我们就变一变,用不带超时时间的方法来解读 await 语言。不过这两者在本质上并没有差别。
|
||||
|
||||
为了帮助你更快掌控 await 的整体实现思路,可以先看一下时序图:
|
||||
|
||||
|
||||
|
||||
Condition 的 wait() 方法对标 Object.wait(),我们来看一下它的具体实现逻辑:
|
||||
|
||||
|
||||
|
||||
我们结合 Object.wait 的语义来体会一下 await 方法中最关键的六个步骤。
|
||||
|
||||
step1:如果当前线程被中断,要直接抛出中断异常。
|
||||
|
||||
step2:将节点加入条件等待队列中。
|
||||
|
||||
step3:释放锁,并保存释放之前锁的状态,等到条件满足线程被唤醒后,需要重新申请指定数量的锁。
|
||||
|
||||
step4:如果节点存在于条件队列而不在阻塞队列中,说明未收到 signal 信号,线程会被阻塞;如果线程被中断,就结束条件队列的等待。
|
||||
|
||||
step5:再次尝试申请锁,并检查唤醒的原因,看看是因为收到 signal 信号而被唤醒,还是因为收到了中断信号。
|
||||
|
||||
step6:如果先收到 signal 信号,再收到中断信号,那就要重新设置线程中断位,等待下一次中断检查点;如果是先收到中断信号,后收到 signal 信号,就直接抛出中断异常;如果正常收到 signal 信号,await 方法结束阻塞,则继续执行后续逻辑。
|
||||
|
||||
其中,第二步中的加入条件队列,具体的代码实现是将节点接入到链表的尾部,如果有取消的节点就把它删除。这里线程是安全的,因为执行 await 方法的前提条件是要获取锁。
|
||||
|
||||
第四步是用 await 方法阻塞和唤醒线程的关键。核心代码如下图所示:
|
||||
|
||||
|
||||
|
||||
我们来看一下怎么判断线程是否在同步队列中(用 isOnSyncQueue 方法实现)。
|
||||
|
||||
|
||||
如果节点的状态为 Node.CONDITION,或者 node.prev 为空,表示线程在等待条件被触发。为什么节点的前驱节点不为空就可以认为线程在同步阻塞队列中呢?这是因为进入同步队列时是用 CAS 机制来更新前驱节点的。
|
||||
|
||||
如果 Node 的 next 指针不为空,表示线程在同步阻塞队列中,返回 true。
|
||||
|
||||
如果不满足上述条件,则从尾部节点再查找一遍,如果能找到,返回 true,否则返回 false。
|
||||
|
||||
|
||||
因为节点如果在条件等待队列中,说明条件不满足,线程需要阻塞并等待条件触发。线程可以通过下面几种方式被唤醒:
|
||||
|
||||
|
||||
由于正常收到 signal 信号被唤醒;
|
||||
|
||||
先收到 signal 信号,然后收到中断信号;
|
||||
|
||||
先收到中断信号,再收到 signal 信号。
|
||||
|
||||
|
||||
那怎么判断唤醒方式呢?
|
||||
|
||||
我们可以通过 checkInterruptWhileWaiting 来实现它。也就是检测线程的中断标志位,如果线程并没有设置中断位,则返回 0。如果检测到了中断位,则用 transferAfterCancelledWait 方法来判断中断信号和 signal 的先后顺序。
|
||||
|
||||
transferAfterCancelledWait 的核心实现逻辑是,如果成功将节点的状态从 Node.CONDITION 更新为 0,就表示先收到了中断标记,否则就是先收到了 signal 信号。因为如果是先收到 signal 信号,节点的状态应该是 NODE.SIGNAL,而且节点会进入同步阻塞队列。这样做可以有效避免 signal 信号丢失。
|
||||
|
||||
线程被唤醒后需要重新申请锁,调用 acquireQueued 方法来实现,这一步和前面我们提到的申请流程类似,这里就不再重复了。
|
||||
|
||||
当条件满足后,线程被唤醒,这时候我们就要用到 Condition 的 signal() 方法了。signal 方法的时序图如下:
|
||||
|
||||
|
||||
|
||||
这部分就是从条件队列中找到第一个没有取消的节点,然后唤醒它。实现 transferForSignal 方法的具体代码如下:
|
||||
|
||||
|
||||
|
||||
这个方法有三个要点。
|
||||
|
||||
第一,要使用 CAS 尝试将节点状态从 CONDITION 转化为 0。如果更新失败,说明节点已取消,需要返回 false,继续通知下一个等待线程。
|
||||
|
||||
第二,将线程从条件阻塞队列放入到同步阻塞队列,这一步非常关键,可以防止 signal 信号丢失。
|
||||
|
||||
第三,如果线程加入同步队列后,其前置节点的状态为已取消,或者将其设置为 signal 失败,则直接唤醒线程。
|
||||
|
||||
signal 方法内部的实现机制就是确保线程要么在同步队列中,要么在条件等待队列中。这样可以有效防止通知信号丢失,避免线程一直被阻塞。
|
||||
|
||||
到这里,Condition 的 await 和 signal 方法就都介绍完了。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们首先通过一个简单的生活场景,并结合生产者 - 消费者模型引出了锁的基本结构,它包括:锁要保护的资源、锁的拥有者、同步阻塞队列和条件等待队列。
|
||||
|
||||
紧接着,我们以源码分析为主要手段,辅助流程图、时序图,一步一步地实现了锁的申请和释放。
|
||||
|
||||
同步阻塞队列存放的都是竞争锁失败的线程,主要表征的是线程之间的竞争、互斥,而条件等待队列中存储的是因为某一个条件不满足而需要阻塞的线程,通常需要被其他线程主动唤醒,主要表征的是线程协作。
|
||||
|
||||
我们可以使用 LockSupport.parkNanos 来阻塞线程,并通过 LockSupport.unpark 方法来唤醒线程。
|
||||
|
||||
如果你对中间件感兴趣,对锁的语义的理解必不可少。它虽然有一定难度,但是只要攻下了源码,读懂 AQS,对锁的理解与认知能力会有一个质的提升,对多线程协作开发大有裨益。
|
||||
|
||||
JUC 的体系非常庞大,这节课不能全面覆盖,但是只要掌握了 AQS,后面再去学习 CountDownLatch、信号量、CAS 等知识会变得非常容易。如果你有兴趣,也可以读一读我写过的和锁相关的文章:《锁的优化思路》和《disruptor 无锁化设计实践》,应该可以给你更多的启发。
|
||||
|
||||
课后题
|
||||
|
||||
最后,还是给你留一道课后题。
|
||||
|
||||
请你尝试写一篇文章,分析 JUC 读写锁的源码,重点剖析读锁的申请与释放还有写锁的申请与释放。我也给你提供一篇文章供你参考:《Java 并发锁 ReentrantReadWriteLock 读写锁源码分析》。
|
||||
|
||||
关于这节课,如果你还有不理解的问题,也欢迎你在留言区留言。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
631
专栏/中间件核心技术与实战/07NIO:手撸一个简易的主从多Reactor线程模型.md
Normal file
631
专栏/中间件核心技术与实战/07NIO:手撸一个简易的主从多Reactor线程模型.md
Normal file
@ -0,0 +1,631 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 NIO:手撸一个简易的主从多Reactor线程模型
|
||||
你好,我是丁威。
|
||||
|
||||
中间件是互联网发展的产物,而互联网有一个非常显著的特点:集群部署、分布式部署。当越来越多的服务节点分布在不同的机器上,高效地进行网络传输就变得更加迫切了。在这之后,一大批网络编程类库如雨后春笋般出现,经过不断的实践表明,Netty 框架几乎成为了网络编程领域的不二之选。
|
||||
|
||||
接下来的两节课,我们会通过对 NIO 与 Netty 的详细解读,让你对网络编程有一个更直观的认识。
|
||||
|
||||
NIO 和 BIO 模型的工作机制
|
||||
|
||||
NIO 是什么呢?简单来说,NIO 就是一种新型 IO 编程模式,它的特点是同步、非阻塞。
|
||||
|
||||
很多资料将 NIO 中的“N”翻译为 New,即新型 IO 模型,既然有新型的 IO 模式,那当然也存在中老型的 IO 模型,这就是 BIO,同步阻塞 IO 模型。
|
||||
|
||||
定义往往是枯燥的,我们结合实际场景看一下 BIO 和 NIO 两种 IO 通讯模式的工作机制,更直观地感受一下它们的差异。
|
||||
|
||||
MySQL 的客户端 (mysql-connector-java) 采用的就是 BIO 模式,它的工作机制如下图所示:
|
||||
|
||||
|
||||
|
||||
我们模拟场景,向 MySQL 服务端查询表中的数据,这时会经过四个步骤。
|
||||
|
||||
第一步,应用程序拼接 SQL,然后 mysql-connector-java 会将 SQL 语句按照 MySQL 通讯协议编码成二进制,通过网络 API 将数据写入到网络中进行传输。底层最终是使用 Socket 的 OutputStream 的 write 与 flush 这两个方法实现的。
|
||||
|
||||
第二步,调用完 write 方法后,再调用 Socket 的 InputStream 的 read 方法,读取服务端返回数据,此时会阻塞等待。
|
||||
|
||||
第三步,服务端在收到请求后会解析请求,从请求中提取出对应的 SQL 语句,然后按照 SQL 抽取数据。服务端在处理这些业务逻辑时,客户端阻塞,不能做其他事情,我把这个阶段称之为等待数据阶段。
|
||||
|
||||
第四步,服务端执行完指定逻辑,抽取到合适的数据后,会调用 Socket 的 OutputStream 的 write 将响应结果通过网络传递到客户端。此时,客户端用 read 方法从网卡中把数据读取到应用程序的内存中,此阶段我称之为数据传输阶段。
|
||||
|
||||
BIO 的 IO 模型在等待数据阶段、数据传输阶段都会阻塞。其实,“IO 模型”的名称基本就是这两个阶段的特质决定的。
|
||||
|
||||
在等待数据阶段,如果发起网络调用后,在服务端数据没有准备好的情况下客户端会阻塞,我们称为阻塞 IO;如果数据没有准备好,但网络调用会立即返回,我们称之为非阻塞 IO。
|
||||
|
||||
在数据传输阶段,如果发起网络调用的线程还可以做其他事情,我们称之为异步,否则称之为同步。
|
||||
|
||||
这样看来,BIO 的完整名称叫做“同步阻塞 IO”也就不足为奇了。
|
||||
|
||||
从 JDK1.4 开始,Java 又引入了另外一种 IO 模型:NIO。
|
||||
|
||||
虽然 MySQL 客户端主要使用的是 BIO 模型,但是我们可以演示一下 MySQL Client 采用 NIO 与 MySQL 服务端通信的样子:
|
||||
|
||||
|
||||
|
||||
NIO 与 BIO 的不同点在于,在调用 read 方法时,如果服务端没有返回数据,该方法不会阻塞当前调用线程,read 方法的返回值会为本次网络调用实际读取到的字节数量。也就是说,客户端调用一次 read 方法,如果本次没有读取到数据,线程可以继续处理其他事情,然后在需要数据的时候再次调用,但是在数据返回的过程中同样会阻塞线程。这也是 NIO 全名的由来:同步非阻塞 IO。
|
||||
|
||||
NIO 提供了在数据等待阶段的灵活性,但如果需要客户端反复调用读相关的 API 进行测试,编程体验也极不友好,为了改进 NIO 网络模型的缺陷,又引入了“事件就绪选择机制”。
|
||||
|
||||
事件就绪选择机制指的是,应用程序只需要在通道(网络连接)上注册感兴趣的事件(如网络读事件),客户端向服务端发送请求后,无须立即调用 read 方法去尝试读取响应结果,而是等服务端将准备好的数据通过网络传输到客户端的网卡。这时,操作系统会通知客户端“数据已到达”,此时客户端再调用读取 API,从中读取响应结果。其实我们现在说 NIO,说的就是“NIO + 事件就绪选择”的合体。
|
||||
|
||||
NIO 和 BIO 模型的使用场景
|
||||
|
||||
那 BIO 与 NIO 相比,有什么优劣势呢?它们对应的使用场景是什么?为了直观地展示两种编程模型的优缺点,我们用网络游戏这个场景来举例。
|
||||
|
||||
一个简易的网络游戏分为服务端与客户端(玩家)两个端口,我们一起来思考一下,如果游戏服务端分别使用 BIO 技术和 NIO 技术进行架构设计,结果会是怎样的。
|
||||
|
||||
BIO 领域一种经典的设计范式是每个请求对应一个线程。我们就用这种思想设计一下游戏的服务端,设计图如下:
|
||||
|
||||
|
||||
|
||||
游戏服务端后端的设计思想是:采用长连接模式。每当一个客户端上线,服务端就会为请求创建一个线程,在独立的线程中和客户端进行网络读写,一旦客户端下线,就会关闭对应的线程。
|
||||
|
||||
但是一台服务器能创建的线程个数是有限的,所以基于 BIO 模式构建的优秀服务端一个非常明显的弊端:在线用户数越多,需要创建的线程数就越多,支撑的并发在线用户数量受到明显制约。更加严重的问题是,服务端与其中某些客户端并不是一直在通信,大量线程的网络连接处于阻塞状态,线程资源无法得到有效利用。
|
||||
|
||||
为了防止因为线程急剧膨胀、线程资源耗尽影响到服务端的设计,这时候我们通常会引入线程池。因为引入线程池就相当于是在限流,超过线程池规定的线程数量,服务器就会拒绝连接。
|
||||
|
||||
对于需要支持大量在线并发用户(连接)的服务器来说,BIO 的网络 IO 模型绝对不是一个好的选择。
|
||||
|
||||
我们再来看下 NIO 模式。基于 NIO 模式设计的游戏服务端模型如下图所示:
|
||||
|
||||
|
||||
|
||||
基于 NIO,在业界有一种标准的线程模型 Reactor,在这节课的后半部分我们还会详细介绍,这里我们先说明一下 NIO 的优势。
|
||||
|
||||
首先,服务端会创建一个线程池专门处理网络读写,我们称之为 IO 线程池。IO 线程池内会内置 NIO 的事件选择器。当游戏服务端监听到一个客户端时,会从 IO 线程池中根据负载均衡算法选择一个 IO 线程,将其注册到事件选择器中。
|
||||
|
||||
事件选择器会定时进行事件轮询,挑选出数据进行传输(读取或写入),执行事件选择,然后在 IO 线程中按连接分别读取数据。在将请求解码后,丢到业务线程中执行对应的业务逻辑,它的主要功能是分担 IO 线程的压力,做到尽量不阻塞 IO 线程。
|
||||
|
||||
使用 NIO 可以做到用少量线程来服务大量连接,哪怕客户端连接数增长,也不会造成服务端线程膨胀。这个优势的关键点在于,基于事件选择机制,IO 线程都在进行有效的读写,而不像 BIO 那样,在没有数据传输时还得占用线程资源。
|
||||
|
||||
也正是因此,NIO 非常适合需要同时支持大量客户端在线的场景。在 NIO 模型下,单个请求的数据包建议不要太大。
|
||||
|
||||
值得注意的是,一个 IO 线程在一次事件就绪选择可能会有多个网络连接具备了读或写的准备,但此时对这些网络通道是串行执行的,所以如果每一个网络通道需要读或写的数据比较大,这就必然导致其他连接的延时。
|
||||
|
||||
既然 NIO 这么优秀,那为什么 MySQL 数据访问客户端还是采用 BIO 模式呢?为啥不改造成 NIO 呢?
|
||||
|
||||
其实在进行技术选型时,并不是越新的技术就越好,我们还是要结合具体问题具体分析。
|
||||
|
||||
我们再回过头来看 MySQL 客户端的场景。目前在应用层面,我们会为每一个应用配置一个数据库连接池。当业务线程需要进行数据库操作时,它会尝试从数据库连接池获取一个数据库连接(底层是一条 TCP 连接,负责与服务端进行网络的读与写),然后使用这条连接发送 SQL 语句并获取 SQL 结果。任务结束之后,业务线程会把数据库连接归还给连接池,从而实现数据库连接的复用。
|
||||
|
||||
与此同时,我们为了保证数据库服务端的可用性,通常需要强制限制客户端能使用的连接数量。这就注定了数据库客户端没有需要支持大量连接的诉求,在这个场景下,客户端使用阻塞型 IO 对保护数据库服务端更有优势。
|
||||
|
||||
|
||||
|
||||
简单说明一下。假设业务代码存在缺陷,导致需要执行一条 SQL 语句来获取大量数据。这时,我们要尝试从数据库连接池中获取连接,并通过这个连接向 MySQL 服务端发送 SQL 语句。由于这条 SQL 语句的执行性能很差,这条连接在客户端一直被阻塞,无法继续发送更多的 SQL。另外如果数据库连接池中没有空闲连接,再尝试获取连接时还需要等待连接被释放,服务器缓慢的执行速度确保了客户端不能持续发送新的请求,对保护数据库服务器大有裨益。
|
||||
|
||||
这种情况下如果使用 NIO 模型,客户端会无节制地用一条连接发送大量请求,导致服务端出现完全不可用的情况。
|
||||
|
||||
总结一下就是,NIO 模型更适合需要大量在线活跃连接的场景,常见于服务端;BIO 模型则适合只需要支持少量连接的场景,常常用于客户端,这也是 MySQL 数据访问客户端会在网络 IO 模型方面使用 BIO 的原因。
|
||||
|
||||
Reactor 线程模型
|
||||
|
||||
学习 NIO 的理论知识非常枯燥,而且很难做到透彻地理解,我们需要一个实例来深入进去。结合我的学习经验,我觉得学习 Reactor 经典线程模型,尝试编写一个 Reactor 线程模型对提升 NIO 的理解非常有帮助。
|
||||
|
||||
为什么这么说呢?因为在编写网络通信相关的功能模块时,建立一套线程模型是非常重要的一环,经过各位前辈不断的实践,Reactor 线程模型已成为 NIO 领域的事实标准,无论是网络编程类库 NIO,还是 Kafka、Dubbo 等主流中间件的底层网络模型都是直接或间接受到了 Reactor 模型的影响。
|
||||
|
||||
那什么是 Reactor 线程模型?怎么使用 NIO 来实现 Reactor 模型?这两个问题,就是我们这节课后半部分的重点。
|
||||
|
||||
什么是 Reactor 线程模型?
|
||||
|
||||
Reactor 主从多 Reactor 模型的架构设计如下图所示:
|
||||
|
||||
|
||||
|
||||
说明一下各个角色的职责。
|
||||
|
||||
|
||||
Acceptor:请求接收者,作用是在特定端口建立监听。
|
||||
|
||||
Main Reactor Thread Pool:主 Reactor 模型,主要负责处理 OP_ACCEPT 事件(创建连接),通常一个监听端口使用一个线程。在具体实践时,如果创建连接需要进行授权校验(Auth)等处理逻辑,也可以直接让 Main Reactor 中的线程负责。
|
||||
|
||||
NIO Thread Group( IO 线程组):在 Reactor 模型中也叫做从 Reactor,主要负责网络的读与写。当 Main Reactor Thread 线程收到一个新的客户端连接时,它会使用负载均衡算法从 NIO Thread Group 中选择一个线程,将 OP_READ、OP_WRITE 事件注册在 NIO Thread 的事件选择器中。接下来这个连接所有的网络读与写都会在被选择的这条线程中执行。
|
||||
|
||||
NIO Thread:IO 线程。负责处理网络读写与解码。IO 线程会从网络中读取到二进制流,并从二进制流中解码出一个个完整的请求。
|
||||
|
||||
业务线程池:通常 IO 线程解码出的请求将转发到业务线程池中运行,业务线程计算出对应结果后,再通过 IO 线程发送到客户端。
|
||||
|
||||
|
||||
我们再通过一个网络通信图进一步理解 Reactor 线程模型。
|
||||
|
||||
|
||||
|
||||
网络通信的交互过程通常包括下面六个步骤。
|
||||
|
||||
|
||||
启动服务端,并在特定端口上监听,例如,web 应用默认在 80 端口监听。
|
||||
|
||||
客户端发起 TCP 的三次握手,与服务端建立连接。这里以 NIO 为例,成功建立连接后会创建 NioSocketChannel 对象。
|
||||
|
||||
服务端通过 NioSocketChannel 从网卡中读取数据。
|
||||
|
||||
根据请求执行对应的业务操作,例如,Dubbo 服务端接受了请求,并根据请求查询用户 ID 为 1 的用户信息。
|
||||
|
||||
将业务执行结果返回到客户端(通常涉及到协议编码、压缩等)。
|
||||
|
||||
线程模型需要解决的问题包括:连接监听、网络读写、编码、解码、业务执行等,那如何运用多线程编程优化上面的步骤从而提升性能呢?
|
||||
|
||||
|
||||
主从多 Reactor 模型是业内非常经典的,专门解决网络编程中各个环节问题的线程模型。各个线程通常的职责分工如下。
|
||||
|
||||
|
||||
Main Reactor 线程池,主要负责连接建立(OP_ACCEPT),即创建 NioSocketChannel 后,将其转发给 SubReactor。
|
||||
|
||||
SubReactor 线程池,主要负责网络的读写(从网络中读字节流、将字节流发送到网络中),即监听 OP_READ、OP_WRITE,并且同一个通道会绑定一个 SubReactor 线程。
|
||||
|
||||
|
||||
编码、解码和业务执行则具体情况具体分析。通常,编码、解码会放在 IO 线程中执行,而业务逻辑的执行会采用额外的线程池。但这不是绝对的,一个好的框架通常会使用参数来进行定制化选择,例如 ping、pong 这种心跳包,直接在 IO 线程中执行,无须再转发到业务线程池,避免线程切换开销。
|
||||
|
||||
怎么用 NIO 实现 Reactor 模型?
|
||||
|
||||
理解了 Reactor 线程模型的内涵,接下来就到了实现这一步了。
|
||||
|
||||
我建议你在学习这部分内容时,同步阅读一下《Java NIO》这本电子书的前四章。这本书详细讲解了 NIO 的基础知识,是我学习 Netty 的老师,相信也会给你一些帮助。
|
||||
|
||||
我们先来看一下 Reactor 模型的时序图,从全局把握整体脉络:
|
||||
|
||||
|
||||
|
||||
这里核心的流程有三个。
|
||||
|
||||
|
||||
服务端启动,会创建 MainReactor 线程池,在 MainReactor 中创建 NIO 事件选择器,并注册 OP_ACCEPT 事件,然后在指定端口监听客户端的连接请求。
|
||||
|
||||
客户端向服务端建立连接,服务端 OP_ACCEPT 对应的事件处理器被执行,创建 NioSocketChannel 对象,并按照负载均衡机制将其转发到 SubReactor 线程池中的某一个线程上,注册 OP_READ 事件。
|
||||
|
||||
客户端向服务端发送具体请求,服务端 OP_READ 对应的事件处理器被执行,它会从网络中读取数据,然后解码、转发到业务线程池执行具体的业务逻辑,最后将返回结果返回到客户端。
|
||||
|
||||
|
||||
我们解读下核心类的核心代码。
|
||||
|
||||
NioServer 的代码如下:
|
||||
|
||||
|
||||
private static class Acceptor implements Runnable {
|
||||
// main Reactor 线程池,用于处理客户端的连接请求
|
||||
private static ExecutorService mainReactor = Executors.newSingleThreadExecutor(new ThreadFactory() {
|
||||
private AtomicInteger num = new AtomicInteger(0);
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r);
|
||||
// 为线程池中的名称进行命名,方便分析线程栈
|
||||
t.setName("main-reactor-" + num.incrementAndGet());
|
||||
return t;
|
||||
}
|
||||
});
|
||||
public void run() {
|
||||
// NIO中服务端对应的Channel
|
||||
ServerSocketChannel ssc = null;
|
||||
try {
|
||||
// 通过静态方法创建一个ServerSocketChannel对象
|
||||
ssc = ServerSocketChannel.open();
|
||||
//设置为非阻塞模式
|
||||
ssc.configureBlocking(false);
|
||||
//绑定端口
|
||||
ssc.bind(new InetSocketAddress(SERVER_PORT));
|
||||
|
||||
//转发到 MainReactor反应堆
|
||||
dispatch(ssc);
|
||||
System.out.println("服务端成功启动。。。。。。");
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
private void dispatch(ServerSocketChannel ssc) {
|
||||
mainReactor.submit(new MainReactor(ssc));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
启动服务端会创建一个 Acceptor 线程,它的职责就是绑定端口,创建 ServerSocketChannel,然后交给 MainReactor 去处理接收连接的逻辑。
|
||||
|
||||
MainReactor 的具体实现如下:
|
||||
|
||||
public class MainReactor implements Runnable{
|
||||
// NIO 事件选择器
|
||||
private Selector selector;
|
||||
// 子ReactorThreadGroup 即IO线程池
|
||||
private SubReactorThreadGroup subReactorThreadGroup;
|
||||
// IO线程池默认线程数量
|
||||
private static final int DEFAULT_IO_THREAD_COUNT = 4;
|
||||
// IO线程个数
|
||||
private int ioThreadCount = DEFAULT_IO_THREAD_COUNT;
|
||||
|
||||
public MainReactor(ServerSocketChannel channel) {
|
||||
try {
|
||||
// 创建事件选择器
|
||||
selector = Selector.open();
|
||||
// 为通道注册OP_ACCEPT 事件,客户端发送数据后,服务端通过该事件进行数据的读取
|
||||
channel.register(selector, SelectionKey.OP_ACCEPT);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
// IO线程池,里面包含负载均衡算法
|
||||
subReactorThreadGroup = new SubReactorThreadGroup(ioThreadCount);
|
||||
}
|
||||
public void run() {
|
||||
System.out.println("MainReactor is running");
|
||||
while (!Thread.interrupted()) {
|
||||
Set<SelectionKey> ops = null;
|
||||
try {
|
||||
// 进行事件选择
|
||||
selector.select(1000);
|
||||
// 经过事件选择后已经就绪的事件
|
||||
ops = selector.selectedKeys();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
// 处理相关事件
|
||||
for (Iterator<SelectionKey> it = ops.iterator(); it.hasNext();) {
|
||||
SelectionKey key = it.next();
|
||||
it.remove();
|
||||
try {
|
||||
// 如果有客户端尝试建立连接
|
||||
if (key.isAcceptable()) {
|
||||
System.out.println("收到客户端的连接请求。。。");
|
||||
//获取服务端的ServerSocketChannel对象, 这里其实,可以直接使用ssl这个变量
|
||||
ServerSocketChannel serverSc = (ServerSocketChannel) key.channel();
|
||||
// 调用ServerSocketChannel的accept方法,创建SocketChannel
|
||||
SocketChannel clientChannel = serverSc.accept();
|
||||
// 设置为非阻塞模式
|
||||
clientChannel.configureBlocking(false);
|
||||
// 转发到IO线程,由对应的IO线程去负责网络读写
|
||||
subReactorThreadGroup.dispatch(clientChannel); // 转发该请求
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
System.out.println("客户端主动断开连接。。。。。。。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SubReactorThreadGroup 内部包含一个 SubReactorThread 数组,并提供负载均衡机制,供 MainReactor 线程选择具体的 SubReactorThread 线程,具体代码如下:
|
||||
|
||||
public class SubReactorThreadGroup {
|
||||
private static final AtomicInteger requestCounter = new AtomicInteger(); //请求计数器
|
||||
// 线程池IO线程的数量
|
||||
private final int ioThreadCount;
|
||||
// 业务线程池大小
|
||||
private final int businessTheadCout;
|
||||
private static final int DEFAULT_NIO_THREAD_COUNT;
|
||||
// IO线程池数组
|
||||
private SubReactorThread[] ioThreads;
|
||||
//业务线程池
|
||||
private ExecutorService businessExecutePool;
|
||||
|
||||
static {
|
||||
DEFAULT_NIO_THREAD_COUNT = 4;
|
||||
}
|
||||
|
||||
public SubReactorThreadGroup() {
|
||||
this(DEFAULT_NIO_THREAD_COUNT);
|
||||
}
|
||||
public SubReactorThreadGroup(int ioThreadCount) {
|
||||
if(ioThreadCount < 1) {
|
||||
ioThreadCount = DEFAULT_NIO_THREAD_COUNT;
|
||||
}
|
||||
//暂时固定为10
|
||||
businessTheadCout = 10;
|
||||
//初始化代码
|
||||
businessExecutePool = Executors.newFixedThreadPool(businessTheadCout, new ThreadFactory() {
|
||||
private AtomicInteger num = new AtomicInteger(0);
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r);
|
||||
t.setName("bussiness-thread-" + num.incrementAndGet());
|
||||
return t;
|
||||
}
|
||||
});
|
||||
this.ioThreadCount = ioThreadCount;
|
||||
this.ioThreads = new SubReactorThread[ioThreadCount];
|
||||
for(int i = 0; i < ioThreadCount; i ++ ) {
|
||||
this.ioThreads[i] = new SubReactorThread(businessExecutePool);
|
||||
this.ioThreads[i].start(); //构造方法中启动线程,由于nioThreads不会对外暴露,故不会引起线程逃逸
|
||||
}
|
||||
System.out.println("Nio 线程数量:" + ioThreadCount);
|
||||
}
|
||||
public void dispatch(SocketChannel socketChannel) {
|
||||
//根据负载算法转发到具体IO线程
|
||||
if(socketChannel != null ) {
|
||||
next().register(new NioTask(socketChannel, SelectionKey.OP_READ));
|
||||
}
|
||||
}
|
||||
protected SubReactorThread next() {
|
||||
return this.ioThreads[ requestCounter.getAndIncrement() % ioThreadCount ];
|
||||
}
|
||||
|
||||
|
||||
SubReactorThread IO 线程的具体实现如下:
|
||||
|
||||
|
||||
public class SubReactorThread extends Thread{
|
||||
// 事件选择器
|
||||
private Selector selector;
|
||||
//业务线程池
|
||||
private ExecutorService businessExecutorPool;
|
||||
//任务列表
|
||||
private List<NioTask> taskList = new ArrayList<NioTask>(512);
|
||||
// 锁
|
||||
private ReentrantLock taskMainLock = new ReentrantLock();
|
||||
/**
|
||||
* 业务线程池
|
||||
* @param businessExecutorPool
|
||||
*/
|
||||
public SubReactorThread(ExecutorService businessExecutorPool) {
|
||||
try {
|
||||
this.businessExecutorPool = businessExecutorPool;
|
||||
//创建事件选择器
|
||||
this.selector = Selector.open();
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 接受读写任务
|
||||
*
|
||||
*/
|
||||
public void register(NioTask task) {
|
||||
if (task != null) {
|
||||
try {
|
||||
taskMainLock.lock();
|
||||
taskList.add(task);
|
||||
} finally {
|
||||
taskMainLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 此处的reqBuffer处于可写状态
|
||||
* @param sc
|
||||
* @param reqBuffer
|
||||
*/
|
||||
private void dispatch(SocketChannel sc, ByteBuffer reqBuffer) {
|
||||
businessExecutorPool.submit( new Handler(sc, reqBuffer, this) );
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void run() {
|
||||
while (!Thread.interrupted()) {
|
||||
Set<SelectionKey> ops = null;
|
||||
try {
|
||||
//执行事件选择
|
||||
selector.select(1000);
|
||||
// 获取已就绪的事件集合
|
||||
ops = selector.selectedKeys();
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
continue;
|
||||
}
|
||||
// 处理相关事件
|
||||
for (Iterator<SelectionKey> it = ops.iterator(); it.hasNext();) {
|
||||
SelectionKey key = it.next();
|
||||
it.remove();
|
||||
try {
|
||||
// 通道写事件就绪,说明可以继续往通道中写数据
|
||||
if (key.isWritable()) {
|
||||
SocketChannel clientChannel = (SocketChannel) key.channel();
|
||||
// 获取上次未写完的数据
|
||||
ByteBuffer buf = (ByteBuffer) key.attachment();
|
||||
// 将其写入到通道中。
|
||||
// 这里实现比较粗糙,需要采用处理taskList类似的方式,因为此时通道缓冲区有可能已写满
|
||||
clientChannel.write(buf);
|
||||
System.out.println("服务端向客户端发送数据。。。");
|
||||
// 重新注册读事件
|
||||
clientChannel.register(selector, SelectionKey.OP_READ);
|
||||
} else if (key.isReadable()) { // 接受客户端请求
|
||||
System.out.println("服务端接收客户端连接请求。。。");
|
||||
SocketChannel clientChannel = (SocketChannel) key.channel();
|
||||
ByteBuffer buf = ByteBuffer.allocate(1024);
|
||||
System.out.println(buf.capacity());
|
||||
/**
|
||||
* 这里其实实现的非常不优雅,需要对读取处理办关闭,而且一次读取,并不一定能将一个请求读取
|
||||
* 一个请求,也不要会刚好读取到一个完整对请求,
|
||||
* 这里其实是需要编码,解码,也就是通信协议 @todo
|
||||
* 这里如何做,大家可以思考一下,后面我们可以体验netty是否如何优雅处理的。
|
||||
*/
|
||||
int rc = clientChannel.read(buf);//解析请求完毕
|
||||
//转发请求到具体的业务线程;当然,这里其实可以向dubbo那样,支持转发策略,如果执行时间短,
|
||||
//,比如没有数据库操作等,可以在io线程中执行。本实例,转发到业务线程池
|
||||
dispatch(clientChannel, buf);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
System.out.println("客户端主动断开连接。。。。。。。");
|
||||
}
|
||||
}
|
||||
|
||||
// 处理完事件后,我们还需要处理其他任务,这些任务通常来自业务线程需要IO线程执行的任务
|
||||
if (!taskList.isEmpty()) {
|
||||
try {
|
||||
taskMainLock.lock();
|
||||
for (Iterator<NioTask> it = taskList
|
||||
.iterator(); it.hasNext();) {
|
||||
NioTask task = it.next();
|
||||
try {
|
||||
SocketChannel sc = task.getSc();
|
||||
if(task.getData() != null ) { // 写操作
|
||||
ByteBuffer byteBuffer = (ByteBuffer)task.getData();
|
||||
byteBuffer.flip();
|
||||
// 如果调用通道的写函数,如果写入的字节数小于0,并且待写入还有剩余空间,说明缓存区已满
|
||||
// 需要注册写事件,等缓存区空闲后继续写入
|
||||
int wc = sc.write(byteBuffer);
|
||||
System.out.println("服务端向客户端发送数据。。。");
|
||||
if(wc < 1 && byteBuffer.hasRemaining()) { // 说明写缓存区已满,需要注册写事件
|
||||
sc.register(selector, task.getOp(), task.getData());
|
||||
continue;
|
||||
}
|
||||
byteBuffer = null;//释放内存
|
||||
} else {
|
||||
sc.register(selector, task.getOp());
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();// ignore
|
||||
}
|
||||
it.remove();
|
||||
}
|
||||
|
||||
} finally {
|
||||
taskMainLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
IO 线程负责从网络中读取二进制并将其解码成具体请求,然后转发到业务线程池执行。
|
||||
|
||||
接下来,业务线程池会执行业务代码并将响应结果通过 IO 线程写入到网络中,我们对业务进行简单的模拟:
|
||||
|
||||
|
||||
public class Handler implements Runnable{
|
||||
|
||||
// 模拟业务处理
|
||||
private static final byte[] b = "hello,服务器收到了你的信息。".getBytes(); // 服务端给客户端的响应
|
||||
// 网络通道
|
||||
private SocketChannel sc;
|
||||
// 请求报文
|
||||
private ByteBuffer reqBuffer;
|
||||
// IO线程
|
||||
private SubReactorThread parent;
|
||||
|
||||
public Handler(SocketChannel sc, ByteBuffer reqBuffer,
|
||||
SubReactorThread parent) {
|
||||
super();
|
||||
this.sc = sc;
|
||||
this.reqBuffer = reqBuffer;
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
public void run() {
|
||||
System.out.println("业务在handler中开始执行。。。");
|
||||
// TODO Auto-generated method stub
|
||||
//业务处理
|
||||
reqBuffer.put(b);
|
||||
// 业务处理完成后,通过向IO线程提交任务
|
||||
parent.register(new NioTask(sc, SelectionKey.OP_WRITE, reqBuffer));
|
||||
System.out.println("业务在handler中执行结束。。。");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们再来看一下客户端创建连接的代码:
|
||||
|
||||
public class NioClient {
|
||||
public static void main(String[] args) {
|
||||
// socket
|
||||
SocketChannel clientClient;
|
||||
// 事件选择器
|
||||
Selector selector = null;
|
||||
try {
|
||||
// 创建网络通道
|
||||
clientClient = SocketChannel.open();
|
||||
// 设置为非阻塞模型
|
||||
clientClient.configureBlocking(false);
|
||||
selector = Selector.open();
|
||||
// 注册连接成功事件,在与服务端通过tcp三次握手建立连接后可以收到该事件
|
||||
clientClient.register(selector, SelectionKey.OP_CONNECT);
|
||||
//建立连接,该方法会立即返回
|
||||
clientClient.connect(new InetSocketAddress("127.0.0.1",9080));
|
||||
Set<SelectionKey> ops = null;
|
||||
while(true) {
|
||||
try {
|
||||
// 执行事件选择
|
||||
selector.select();
|
||||
ops = selector.selectedKeys();
|
||||
for (Iterator<SelectionKey> it = ops.iterator(); it.hasNext();) {
|
||||
SelectionKey key = it.next();
|
||||
it.remove();
|
||||
if(key.isConnectable()) //连接事件
|
||||
System.out.println("client connect");
|
||||
SocketChannel sc = (SocketChannel) key.channel();
|
||||
// 判断此通道上是否正在进行连接操作。
|
||||
// 完成套接字通道的连接过程。
|
||||
if (sc.isConnectionPending()) {
|
||||
sc.finishConnect();
|
||||
System.out.println("完成连接!");
|
||||
|
||||
// 完成连接后,向服务端发送请求包
|
||||
ByteBuffer buffer = ByteBuffer.allocate(1024);
|
||||
buffer.put("Hello,Server".getBytes());
|
||||
buffer.flip();
|
||||
sc.write(buffer);
|
||||
}
|
||||
// 注册读事件,等待服务端响应包到达
|
||||
sc.register(selector, SelectionKey.OP_READ);
|
||||
} else if(key.isWritable()) {
|
||||
System.out.println("客户端写");
|
||||
SocketChannel sc = (SocketChannel)key.channel();
|
||||
//这里是NIO ByteBuffer的基本API
|
||||
ByteBuffer buffer = ByteBuffer.allocate(1024);
|
||||
buffer.put("hello server.".getBytes());
|
||||
buffer.flip();
|
||||
sc.write(buffer);
|
||||
} else if(key.isReadable()) {
|
||||
System.out.println("客户端收到服务器的响应....");
|
||||
SocketChannel sc = (SocketChannel)key.channel();
|
||||
ByteBuffer buffer = ByteBuffer.allocate(1024);
|
||||
int count = sc.read(buffer);
|
||||
if(count > 0 ) {
|
||||
buffer.flip();
|
||||
byte[] response = new byte[buffer.remaining()];
|
||||
buffer.get(response);
|
||||
System.out.println(new String(response));
|
||||
}
|
||||
// 再次发送消息,重复输出
|
||||
buffer = ByteBuffer.allocate(1024);
|
||||
buffer.put("hello server.".getBytes());
|
||||
buffer.flip();
|
||||
sc.write(buffer);
|
||||
}
|
||||
}
|
||||
} catch(Throwable e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这样,一个 Reactor 模型就搭建好了。如果你想完整地学习这个 Reactor 模型的详细代码,可以到我的 GitHub上查看。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。
|
||||
|
||||
这节课,我们先结合场景介绍了 BIO 与 NIO 两种网络编程模型和它们的优缺点。
|
||||
|
||||
根据等待数据阶段和数据传输阶段这两个阶段的特质,我们可以得到 BIO 的全称同步阻塞 IO,还有 NIO 的全称同步非阻塞 IO。NIO 模型更适合需要大量在线活跃连接的场景,常见于服务端;BIO 模型则适合只需要支持少量连接的场景。
|
||||
|
||||
我们还了解了一个业内非常经典的线程模型:主从多 Reactor 模型。它的核心设计理念是让线程分工明确,相互协作。Main Reactor 线程池主要负责连接建立,SubReactor 线程池主要负责网络的读写,而编码、解码和业务执行则需要具体情况具体分析。
|
||||
|
||||
最后,我还带你使用 NIO 技术实现了主从多 Reactor 模型,给你推荐了一本学习 NIO 必备的电子书《Java NIO》,这本书非常详细介绍了 NIO 的三大金刚:缓存、通道和选择器的各类基础知识。我建议你在阅读完本电子书后,再来反复看看这个 Reactor 示例,相信可以在你进修 NIO 的基础上助你一臂之力。
|
||||
|
||||
课后题
|
||||
|
||||
学完这节课,我也给你出两道课后题。
|
||||
|
||||
|
||||
为什么 NIO 不适合请求体很大的场景?
|
||||
请你详细阅读《Java NIO》这本书中 Reactor 模型的示例子代码,尝试实现一个简易的 RPC Request-Response 模型。例如,模拟 Dubbo 服务调用需要传入基本的参数:包名、方法名,参数。客户端发送这些数据后,服务端根据接收的数据,在服务端要正确打印包名、方法名、参数,并向客户端返回 “hello, 收到请求” + 包名 + 方法名。
|
||||
|
||||
|
||||
欢迎你在留言区留下你的思考结果,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
601
专栏/中间件核心技术与实战/08Netty:如何优雅地处理网络读写,制定网络通信协议?.md
Normal file
601
专栏/中间件核心技术与实战/08Netty:如何优雅地处理网络读写,制定网络通信协议?.md
Normal file
@ -0,0 +1,601 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 Netty:如何优雅地处理网络读写,制定网络通信协议?
|
||||
你好,我是丁威。
|
||||
|
||||
上一节课,我们介绍了中间件领域最经典的网络编程模型 NIO,我也在文稿的最后给你提供了用 NIO 模拟 Reactor 线程模型的示例代码。如果你真正上手了,你会明显感知到,如果代码处理得过于粗糙,只关注正常逻辑却对一些异常逻辑考虑不足,就不能成为一个生产级的产品。
|
||||
|
||||
这是因为要直接基于 NIO 编写网络通讯层代码,需要开发者拥有很强的代码功底和丰富的网络通信理论知识。所以,为了降低网络编程的门槛,Netty 框架就出现了,它能够对 NIO 进行更高层级的封装。
|
||||
|
||||
从这之后,开发人员只需要关注业务逻辑的开发就好了,网络通信的底层可以放心交给 Netty,大大降低了网络编程的开发难度。
|
||||
|
||||
这节课,我们就来好好谈谈 Netty。
|
||||
|
||||
我会先从网络编程中通信协议、线程模型这些网络编程框架的共性问题入手,然后重点分析 Netty NIO 的读写流程,最后通过一个 Netty 编程实战,教会你怎么使用 Netty 解决具体问题,让你彻底掌握 Netty。
|
||||
|
||||
通信协议
|
||||
|
||||
如果你不从事中间件开发工作,那估计网络编程对你来说会非常陌生,为了让你对它有一个直观的认知,我给你举一个例子。
|
||||
|
||||
假如我们在使用 Dubbo 构建微服务应用,Dubbo 客户端在向服务提供者发起远程调用的过程中,需要告诉服务提供者服务名、方法名和参数。但这些参数是怎么在网络中传递的呢?服务提供者又怎么识别出客户端的意图呢?
|
||||
|
||||
你可以先看看我画的这张图:
|
||||
|
||||
|
||||
|
||||
客户端在发送内容之前需要先将待发送的内容序列化为二进制流。例如,上图发送了两个包,第一个包的二进制流是 0110,第二个包的二进制流是 00110011。这时,服务端读取数据的情形可能有两种。
|
||||
|
||||
|
||||
经过多次读取:在上面这张图中,服务端调用了 3 次 read 方法才把数据全部读取出来,分别读取到的包是 011、000、110011。
|
||||
|
||||
调用一次 read 就读取到所有数据:例如 011000110011。
|
||||
|
||||
|
||||
这里我插播一个小知识,一次 read 方法能读取到的数据量,要取决于网卡中可读数据和接收缓冲区的大小。
|
||||
|
||||
那服务端是如何正确识别出 0110 就是第一个请求包,00110011 是第二个请求包的呢?它为什么不会将 011 当成第一个请求包,000 当成第二个请求包,110011 当成第三个包,或者直接将 011000110011 当成一个请求包呢?其实这种现象叫做粘包。
|
||||
|
||||
常用的解决方案是客户端与服务端共同制定一个通信规范(也称通信协议),用它来定义请求包 / 响应包的具体格式。这样,客户端发送请求之前,需要先将内容按照通信规范序列化成二进制流,这个过程称之为编码;同样,服务端会按照通信规范将收到的二进制流进行反序列化,这个过程称之为解码。
|
||||
|
||||
从这里你也可以看出,网络编程中通常涉及编码、往网络中写数据(Write)、从网络中读取数据(Read)、解码、业务逻辑处理、发送响应结果和接受响应结果等步骤,你可以看下下面这张图,加深理解:
|
||||
|
||||
|
||||
|
||||
那如何制订通信协议呢?
|
||||
|
||||
通信协议的制订方法有很多,有的是采用特殊符号来标记一个请求的结束,但如果请求体中也包含这个分隔符就会使协议破坏,还有一种方法是使用固定长度来表述一个请求包,定义一个请求包固定包含多少字节,如果请求体内存不足,就使用填充符合进行填充,但这种方式会造成空间的浪费。
|
||||
|
||||
业界最为经典的协议设计方法是协议头 +Body 的设计理念,如图所示:
|
||||
|
||||
|
||||
|
||||
这里有几个关键点,你需要注意一下:
|
||||
|
||||
|
||||
协议头的长度是固定的,通常为识别出一个业务的最小长度;
|
||||
|
||||
协议头中会包含一个长度字段,用来标识一个完整包的长度,用来表示长度字段的字节位数直接决定了一个包的最大长度;
|
||||
|
||||
消息体中存储业务数据。
|
||||
|
||||
|
||||
为了更直观地给你展示,我直接以一个简单的 RPC 通信场景为例,实现类似 Dubbo 服务远程服务调用,通信协议设计如下图所示:
|
||||
|
||||
|
||||
|
||||
这里我们演示的是基于 Header+Body 的设计模式,接受端从网络中读取到字节后解码的流程。接受端将读取到的数据存储在一个接收缓冲区,在 Netty 中称为累积缓冲区。
|
||||
|
||||
首先我们要判断累积缓存区中是否包含一个完整的 Head,例如上述示例中,一个包的 Header 的长度为 6 个字节,那首先判断累积缓存中可读字节数是否大于等于 6,如果不足 6 个字节,跳过本次处理,等待更多数据到达累积缓存区。
|
||||
|
||||
如果累积缓存区中包含一个完整的 Header,就解析头部,并且提取长度字段中存储的数值,即包长度,然后判断累积缓存区中可读字节数是否大于或等于整个包的长度。如果累积缓存区不包含一个完整的数据包,则跳过本次处理,等待更多数据到达累积缓存区。如果累积缓存区包含一个完整的包,则按照通信协议的格式按顺序读取相关的内容。
|
||||
|
||||
通过上面这种方式,我们就可以完美解决粘包问题了。
|
||||
|
||||
我们前面也说了,网络编程中包含编码、解码、网络读取、业务逻辑等多个步骤,所以如何使用多线程提升并发度,合理处理多线程之间的高效协作就显得尤为重要,接下来我们来看一下 Netty 的线程模型是怎么做的。
|
||||
|
||||
Netty 的线程模型采取的是业界的主流线程模型,也就是主从多 Reactor 模型:
|
||||
|
||||
|
||||
|
||||
它的设计重点主要包括下面这几个方面。
|
||||
|
||||
|
||||
Netty Boss Group 线程组
|
||||
|
||||
|
||||
主要处理 OP_ACCEPT 事件,用于处理客户端链接,默认为 1 个线程。当 Netty Boss Group 线程组接收到一个客户端链接时,会创建 NioSocketChannel 对象,并封装成 Channel 对象,在 Channel 对象内部会创建一个缓冲区。这个缓冲区可以接收需要通过这个通道写入到对端的数据,然后从 Netty Work Group 线程组中选择一个线程并注册读事件。
|
||||
|
||||
|
||||
Nettty Work Group 线程组
|
||||
|
||||
|
||||
主要处理 OP_READ、OP_WRITE 事件,处理网络的读与写,所以也称为 IO 线程组,线程组中线程个数默认为 CPU 的核数。由于注册了读事件,所以当客户端发送请求时,读事件就会触发,从网络中读取请求,进入请求处理流程。
|
||||
|
||||
|
||||
扩展机制采用责任链设计模式
|
||||
|
||||
|
||||
编码、解码等功能对应一个独立的 Handler,这些 Handler 默认在 IO 线程中执行,但 Netty 支持将 Handler 的执行放在额外的线程中执行,实现与 IO 线程的解耦合,避免 IO 线程阻塞。
|
||||
|
||||
|
||||
Business Thread Group
|
||||
|
||||
|
||||
经过解码后得到一个完整的请求包,根据请求包执行业务逻辑,通常会额外引入一个独立线程池,执行业务逻辑后会将结果再通过 IO 线程写入到网络中。
|
||||
|
||||
业务线程在处理完业务逻辑后,通过调用通道将数据发送到目标端。但它并不能当下直接发送,而是要将数据放入到 Channel 中的写缓存区,并向 IO 线程提交一个写入任务。这里涉及到线程切换,因为所有的读写操作都需要在 IO 线程中执行(即一个通道的 IO 操作都是同一个线程触发的),避免了多线程编程的复杂性。
|
||||
|
||||
说到这里,我建议你停下来,尝试用 NIO 实现 Netty 的线程模型,检验一下自己对 NIO 的掌握程度。
|
||||
|
||||
理解了 Netty 的线程模型,接下来我们继续学习 Netty 是怎样处理读写流程的。在进入下面的学习之前,我有几个问题希望你先思考一下:
|
||||
|
||||
|
||||
如何处理连接半关闭?
|
||||
什么时候应该注册读事件?
|
||||
写数据之前一定要先注册写事件吗?
|
||||
|
||||
|
||||
Netty 如何处理网络读写事件?
|
||||
|
||||
Netty IO 读事件由 AbstractNioByteChannel 内部类 AbstractNioUnsafe 的 read 方法实现,接下来我们就来重点剖析一下这个方法,从中窥探 Netty 是如何实现 IO 读事件的。
|
||||
|
||||
由于 AbstractNioUnsafe 的 read 方法代码很长,我们分步进行解读。
|
||||
|
||||
第一步,如果没有开启自动注册读事件,在每一次读时间处理过后会取消读事件,代码片段如下:
|
||||
|
||||
final ChannelConfig config = config();
|
||||
if (!config.isAutoRead() && !isReadPending()) {
|
||||
// ChannelConfig.setAutoRead(false) was called in the meantime
|
||||
removeReadOp();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
这段代码背后蕴含的知识点是,事件注册是一次性的。例如,为通道注册了读事件,然后经事件选择器选择触发后,选择器不再监听读事件,再出来完成一次读事件后需要再次注册读事件。Netty 中默认每次读取处理后会自动注册读事件,如果通道没有注册读事件,则无法从网络中读取数据。
|
||||
|
||||
第二步,为本次读取创建接收缓冲区,临时存储从网络中读取到的字节,代码片段如下:
|
||||
|
||||
final ByteBufAllocator allocator = config.getAllocator();
|
||||
final int maxMessagesPerRead = config.getMaxMessagesPerRead();
|
||||
RecvByteBufAllocator.Handle allocHandle = this.allocHandle;
|
||||
if (allocHandle == null) {
|
||||
this.allocHandle = allocHandle = config.getRecvByteBufAllocator().newHandle();
|
||||
}
|
||||
|
||||
|
||||
创建接收缓存区需要考虑的问题是,该创建多大的缓存区呢?如果缓存区创建大了,就容易造成内存浪费;如果分配少了,在使用过程中就可能需要进行扩容,性能就会受到影响。
|
||||
|
||||
Netty 在这里提供了扩展机制,允许用户自定义创建策略,只需实现 RecvByteBufAllocator 接口就可以了。它又包括两种实现方式:
|
||||
|
||||
|
||||
分配固定大小,待内存不够时扩容;
|
||||
|
||||
动态变化,根据历史的分配大小,动态调整接收缓冲区的大小。
|
||||
|
||||
|
||||
第三步,循环从网络中读取数据,代码片段如下:
|
||||
|
||||
do {
|
||||
byteBuf = allocHandle.allocate(allocator);
|
||||
int writable = byteBuf.writableBytes();
|
||||
int localReadAmount = doReadBytes(byteBuf);
|
||||
// 省略代码
|
||||
} while (++ messages < maxMessagesPerRead);
|
||||
|
||||
|
||||
为什么要循环读取呢?为什么不一次性把通道中需要读取到的数据全部读完再继续下一个通道呢?
|
||||
|
||||
其实,这主要是为了避免单个通道占用太多时间,导致其他链接没有机会去读取数据。所以 Netty 会限制在一次读事件处理过程中调用底层读取 API 的次数,这个次数默认为 16 次。
|
||||
|
||||
接下来我们进行第四步。这里要提醒一下,第四步和第五步都是位于第三步的循环之中的。
|
||||
|
||||
第四步,调用底层 SokcetChannel 的 read 方法从网络中读取数据,代码片段如下:
|
||||
|
||||
byteBuf = allocHandle.allocate(allocator);
|
||||
int writable = byteBuf.writableBytes();
|
||||
int localReadAmount = doReadBytes(byteBuf);
|
||||
if (localReadAmount <= 0) {
|
||||
// not was read release the buffer
|
||||
byteBuf.release();
|
||||
byteBuf = null;
|
||||
close = localReadAmount < 0;
|
||||
break;
|
||||
}
|
||||
pipeline.fireChannelRead(byteBuf);
|
||||
|
||||
|
||||
解释一下,首先用 writable 存储接收缓存区可写字节数,然后通过调用底层 NioSocketChannel 从网络中读取数据,并返回本次读取的字节数。
|
||||
|
||||
那在什么情况下读取的字节数小于 0 呢?原来,TCP 是全双工通信模型,任意一端都可以关闭接收或者写入,如果对端连接调用了关闭(半关闭),那么我们尝试从网络中读取字节时就会返回 -1,跳出循环。
|
||||
|
||||
然后,我们要将读取到的内容传播到事件链中,事件链中各个事件处理器会依次对这些数据进行处理。
|
||||
|
||||
如果你也在使用 Netty 进行应用代码开发,请特别注意 byteBuf 的释放问题。自定义的事件处理器中要尽量继续调用 fireChannelRead 方法,Netty 内置了一个 HeadContext,它在实现时会主动释放 ByteBuf。但如果自定义的事件处理器阻断了事件传播,请记得一定要释放 ByteBuf,否则会造成内存泄露。
|
||||
|
||||
第五步,判断是否要跳出读取:
|
||||
|
||||
if (totalReadAmount >= Integer.MAX_VALUE - localReadAmount) {
|
||||
// Avoid overflow.
|
||||
totalReadAmount = Integer.MAX_VALUE;
|
||||
break;
|
||||
}
|
||||
totalReadAmount += localReadAmount;
|
||||
// stop reading
|
||||
if (!config.isAutoRead()) {
|
||||
break;
|
||||
}
|
||||
if (localReadAmount < writable) {
|
||||
// Read less than what the buffer can hold,
|
||||
// which might mean we drained the recv buffer completely.
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
这里需要关注的一个点是,本次读取到的字节数如果小于接收缓冲区的可写大小,说明通道中已经没有数据可读了,结束本次读取事件的处理。
|
||||
|
||||
第六步,完成网络 IO 读取后,进行善后操作。具体代码片段如下:
|
||||
|
||||
pipeline.fireChannelReadComplete();
|
||||
allocHandle.record(totalReadAmount);
|
||||
if (close) {
|
||||
closeOnRead(pipeline);
|
||||
close = false;
|
||||
}
|
||||
|
||||
|
||||
操作结束后,会触发一次读完成事件,并向整个事件链传播。这时候如果对端已经关闭了,则主动关闭链接。
|
||||
|
||||
就像我们在上节课提到的,事件机制触发后将失效,需要再次注册,所以 Netty 支持自动注册读事件。在每一次读事件完成后会主动调用下面这段代码实现读事件的自动注册,具体实现在 HeadContext 的 fireChannelReadComplete 方法中,代码片段如下:
|
||||
|
||||
|
||||
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
|
||||
ctx.fireChannelReadComplete();
|
||||
readIfIsAutoRead();//该方法最终会调用Channel的read方法,注册读事件
|
||||
}
|
||||
|
||||
|
||||
这里还涉及另一问题,那就是 Netty 的 channelRead、channelReadComplete 等事件是怎么传播的呢?我建议你查看我的另一篇文章《Netty4 事件处理传播机制》。
|
||||
|
||||
Netty 网络读流程就讲到这里了,我们用一张流程图结束网络读取部分的讲解:
|
||||
|
||||
|
||||
|
||||
接下来,我们一起看看 Netty 的网络写入流程。
|
||||
|
||||
基于 Netty 网络模型,通常会使用一个业务线程池来执行业务操作,业务执行完成后,需要通过网络将响应结果提交给对应的 IO 线程,再通过 IO 线程将数据返回给客户端,其过程大致如下:
|
||||
|
||||
|
||||
|
||||
那在代码实现层面,业务线程与 IO 线程是怎么协作的呢?我们带着这个问题,继续深入研究 Netty 的网络写入流程。
|
||||
|
||||
在 Netty 中,一眼就能看到写事件的处理入口,也就是 NioEventLoop(IO 线程)的 processSelectedKey 方法,代码片段如下所示:
|
||||
|
||||
// Process OP_WRITE first as we may be able to write some queued buffers and so free memory.
|
||||
if ((readyOps & SelectionKey.OP_WRITE) != 0) {
|
||||
// Call forceFlush which will also take care of clear the OP_WRITE once there is nothing left to write
|
||||
ch.unsafe().forceFlush();
|
||||
}
|
||||
|
||||
|
||||
查看 processSelectedKey 方法的调用链,我们看到这个方法最终会调用 AbstractUnsafe 的 flush0 方法,代码片段如下所示:
|
||||
|
||||
|
||||
protected void flush0() {
|
||||
if (inFlush0) {
|
||||
return;
|
||||
}
|
||||
final ChannelOutboundBuffer outboundBuffer = this.outboundBuffer; // @1
|
||||
if (outboundBuffer == null || outboundBuffer.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
inFlush0 = true;
|
||||
// Mark all pending write requests as failure if the channel is inactive.
|
||||
if (!isActive()) { // @2
|
||||
try {
|
||||
if (isOpen()) {
|
||||
outboundBuffer.failFlushed(FLUSH0_NOT_YET_CONNECTED_EXCEPTION, true);
|
||||
} else {
|
||||
outboundBuffer.failFlushed(FLUSH0_CLOSED_CHANNEL_EXCEPTION, false);
|
||||
}
|
||||
} finally {
|
||||
inFlush0 = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
doWrite(outboundBuffer); // @3
|
||||
} catch (Throwable t) {
|
||||
if (t instanceof IOException && config().isAutoClose()) {
|
||||
close(voidPromise(), t, FLUSH0_CLOSED_CHANNEL_EXCEPTION, false);
|
||||
} else {
|
||||
outboundBuffer.failFlushed(t, true);
|
||||
}
|
||||
} finally {
|
||||
inFlush0 = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
flush0 方法的核心要点主要包括下面三点。
|
||||
|
||||
|
||||
获取写缓存队列。如果写缓存队列为空,则跳过本次写事件。每一个通道 Channel 内部维护一个写缓存区,其他线程调用 Channel 向网络中写数据时,首先会写入到写缓存区,等到写事件被触发时,再将写缓存区中的数据写入到网络中。
|
||||
|
||||
如果通道处于未激活状态,需要清理写缓存区,避免数据污染。
|
||||
|
||||
通过调用 doWrite 方法将写缓存中的数据写入网络通道中。
|
||||
|
||||
|
||||
这里的 doWrite 方法比较重要,我们重点介绍一下。
|
||||
|
||||
doWrite 方法主要使用 NIO 完成数据的写入,具体由 NioSocketChannel 的 doWrite 实现,由于这一方法代码较长,我们还是分段来进行讲解。
|
||||
|
||||
第一步,如果通道的写缓存区中没有可写数据,需要取消写事件,也就是说,这时候不必关注写事件。具体代码如下:
|
||||
|
||||
int size = in.size();
|
||||
if (size == 0) {
|
||||
// All written so clear OP_WRITE
|
||||
clearOpWrite();
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
这背后的逻辑是,如果注册写事件,每次进行事件就绪选择时,只要底层 TCP 连接的写缓存区不为空,写就会就绪,它会继续通知上层应用程序可以往通道中就绪了。但这种情况下,如果上层应用无数据可写,写事件就绪就变得没有意义了。所以,为了避免出现这种情况,如果没有数据可写,建议直接取消写事件。
|
||||
|
||||
第二步,尝试将缓存区数据写入到网络中:
|
||||
|
||||
switch (nioBufferCnt) {
|
||||
case 0:
|
||||
super.doWrite(in);
|
||||
return;
|
||||
case 1:
|
||||
ByteBuffer nioBuffer = nioBuffers[0];
|
||||
for (int i = config().getWriteSpinCount() - 1; i >= 0; i --) {
|
||||
final int localWrittenBytes = ch.write(nioBuffer);
|
||||
if (localWrittenBytes == 0) {
|
||||
setOpWrite = true;
|
||||
break;
|
||||
}
|
||||
expectedWrittenBytes -= localWrittenBytes;
|
||||
writtenBytes += localWrittenBytes;
|
||||
if (expectedWrittenBytes == 0) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
for (int i = config().getWriteSpinCount() - 1; i >= 0; i --) {
|
||||
final long localWrittenBytes = ch.write(nioBuffers, 0, nioBufferCnt);
|
||||
if (localWrittenBytes == 0) {
|
||||
setOpWrite = true;
|
||||
break;
|
||||
}
|
||||
expectedWrittenBytes -= localWrittenBytes;
|
||||
writtenBytes += localWrittenBytes;
|
||||
if (expectedWrittenBytes == 0) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
也就是说,这一步要根据缓存区中的数据进行区分写入,各个分支的情况有所不同:
|
||||
|
||||
|
||||
如果缓存区 nioBufferCnt 的个数为 0,说明待写入数据为 FileRegion(Netty 零拷贝实现关键点),需要调用父类 NIO 相关方法完成数据写入。
|
||||
|
||||
如果数据是 Buffer 类型,且只有 1 个,则直接调用父类的 doWrite 方法,它的底层逻辑是基于 NIO 通道写入数据。
|
||||
|
||||
如果数据是 Buffer 类型而且有多个,那就要使用 NIO Gather 机制了,这可以避免数据复制。
|
||||
|
||||
|
||||
写入端的处理逻辑也是差不多的。我们可以通过底层 NIO SocketChannel 的 write 方法将数据写入到 Socket 缓存区,有三种情况需要分别考虑。
|
||||
|
||||
|
||||
如果返回值为0,表示 Socket 底层的缓存区已满,需要暂停写入。具体做法是,注册写事件,等 Socket 底层写缓存区空闲后再继续写入。
|
||||
|
||||
如果写缓存区的数据写入到了网络,那就需要取消注册写事件,避免毫无意义的写事件就绪。
|
||||
|
||||
如果写缓存区中的数据很大,为了避免单个通道对其他通道的影响,默认设置单次写事件最多调用底层 NIO SocketChannel 的 write 方法的次数为 16。
|
||||
|
||||
|
||||
第三步,如果底层缓存区已写满,重新注册写事件;如果需要写入的数据太多,则需要创建一个 Task 放入到 IO 线程中,待就绪事件处理完毕后继续处理。代码片段如下:
|
||||
|
||||
if (!done) {
|
||||
// Did not write all buffers completely.
|
||||
incompleteWrite(setOpWrite);
|
||||
break;
|
||||
}
|
||||
protected final void incompleteWrite(boolean setOpWrite) {
|
||||
// Did not write completely.
|
||||
if (setOpWrite) {
|
||||
setOpWrite();
|
||||
} else {
|
||||
// Schedule flush again later so other tasks can be picked up in the meantime
|
||||
Runnable flushTask = this.flushTask;
|
||||
if (flushTask == null) {
|
||||
flushTask = this.flushTask = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
flush();
|
||||
}
|
||||
};
|
||||
}
|
||||
eventLoop().execute(flushTask);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
注意,这里是处理写入的第二个触发点。将写入请求添加到 IO 线程的任务列表中,就可以继续执行数据写入。也就是说,并不一定要注册写事件才能进行写入。
|
||||
|
||||
Task 的触发点在 NioEventLoop 的 run 方法,代码片段如下:
|
||||
|
||||
if (ioRatio == 100) {
|
||||
try {
|
||||
processSelectedKeys();
|
||||
} finally {
|
||||
// Ensure we always run tasks.
|
||||
runAllTasks();
|
||||
}
|
||||
} else {
|
||||
final long ioStartTime = System.nanoTime();
|
||||
try {
|
||||
processSelectedKeys();
|
||||
} finally {
|
||||
// Ensure we always run tasks.
|
||||
final long ioTime = System.nanoTime() - ioStartTime;
|
||||
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
其中,processSelectedKeys 就是 NIO 事件的就绪执行入口。IO 线程在执行完事件就绪选择后,会继续执行任务列表中的任务。
|
||||
|
||||
在实际开发中,通常是在完成业务逻辑后,往网络中写入数据,调用 Channel 的 writeAndFlush 方法。在 Channel 内部会分别调用 write 和 flush 方法。write 方法是将数据写入到通道(Channel)对象的写缓存区,而调用 flush 方法是将通道缓存中的数据写入到网络(Socket 的写缓存区),继而通过网络传输到接收端。
|
||||
|
||||
Netty 为了避免 IO 线程与多个业务线程之间的并发问题,业务线程不能直接调用 IO 线程的数据写入方法,只能是向 IO 线程提交写入任务,具体代码定义在 AbstractChannelHandlerContext 的 write 方法中。
|
||||
|
||||
private void write(Object msg, boolean flush, ChannelPromise promise) {
|
||||
AbstractChannelHandlerContext next = findContextOutbound();
|
||||
EventExecutor executor = next.executor();
|
||||
if (executor.inEventLoop()) {
|
||||
if (flush) {
|
||||
next.invokeWriteAndFlush(msg, promise);
|
||||
} else {
|
||||
next.invokeWrite(msg, promise);
|
||||
}
|
||||
} else {
|
||||
AbstractWriteTask task;
|
||||
if (flush) {
|
||||
task = WriteAndFlushTask.newInstance(next, msg, promise);
|
||||
} else {
|
||||
task = WriteTask.newInstance(next, msg, promise);
|
||||
}
|
||||
safeExecute(executor, task, promise, msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
为了方便你深入阅读 Netty 相关源码,我还给你整理了 Netty 写入的流程图:
|
||||
|
||||
|
||||
|
||||
Netty 编程实战
|
||||
|
||||
好了,关于 Netty 网络读写的理解就介绍到这里了,但是只有理论是不行的,在这节课的最后,我们来看一个 Netty 的实战案例。
|
||||
|
||||
Netty 通常会用在中间件开发、即时通信(IM)、游戏服务器、高性能网关服务器等领域,阿里巴巴的高性能消息中间件 RocketMQ 就是用 Netty 进行网络层开发的。
|
||||
|
||||
为了方便你学习,我将 RocketMQ 网络层代码单独抽取成了一个网络编程框架,并上传到了GitHub,你可以拷贝下来跟我一起操作。
|
||||
|
||||
在深入 RocketMQ 网络层具体实践之前,我们先来看一下 RocketMQ 的网络交互流程:
|
||||
|
||||
|
||||
|
||||
基于 Netty 进行网络编程,我们通常需要编写客户端代码、服务端代码和通信协议。
|
||||
|
||||
我们先来看 Netty 客户端编程的通用示例:
|
||||
|
||||
|
||||
|
||||
这里我们需要注意五个关键点。
|
||||
|
||||
|
||||
需要创建 Handler 执行线程池,让 IO 线程只负责网络读写,而且创建线程池一定要使用线程工厂,同时要记得为线程命名。
|
||||
使用 Bootstrap 的 Group 方法指定 Work 线程组。
|
||||
通过 Option 方法设置网络参数。
|
||||
通过 Handler 方法创建事件调用链。
|
||||
将编码、解码、业务逻辑处理相关的事件处理器加入到事件执行链条。
|
||||
|
||||
|
||||
再来看一下客户端如何创建连接,其代码片段如下:
|
||||
|
||||
|
||||
|
||||
客户端通过调用 Bootstrap 的 connect 方法尝试与服务端建立连接,该方法会立即返回一个 Future 而无须等待连接建立,所以该方法调用结束后并不一定成功创建了连接。但是连接只有在创建成功之后才能被用来发送和读取数据,所以这里我们需要再调用 Future 对象的 awaitUninterruptibly 方法等待连接成功建立。
|
||||
|
||||
客户端与服务端建立连接后,就可以通过连接向服务端发送请求了:
|
||||
|
||||
|
||||
|
||||
这里主要有四个实现要点。
|
||||
|
||||
|
||||
为每一个请求创建一个唯一的请求序号。也就是为每一个请求创建一个响应结果 Future,并建立 RequestId 到响应结果的映射 Map,这样在收到服务端响应结果时,就可以准确地知道具体是哪一个请求的结果了。这是多线程共同使用单一连接发送请求的核心要点。为了更进一步理解,你可以再看一下这张示意图:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
通过调用 Channel 的 writeAndFlush 方法,将数据写入到网络中。也就是说,不需要在发送数据之前先注册写事件。然后基于 Future 模式添加事件监听器,在收到返回结构后,ResponseFuture 中的状态会被更新。
|
||||
|
||||
同步发送的实现模板,通过调用 ResponseFuture 获取等待结果,如果使用异步发送模式,就在第三步执行用户定义的回调函数。
|
||||
|
||||
处理完一个请求后,删除 requestId-ResponseFuture 的映射关系。
|
||||
|
||||
|
||||
介绍完客户端编程范例后,接下来我们看一下如何使用 Netty 编写服务端程序。
|
||||
|
||||
首先,创建相关线程组,代码片段如下:
|
||||
|
||||
|
||||
|
||||
这里分别创建了 3 个线程组。
|
||||
|
||||
|
||||
eventLoopGroupBoss 线程组,默认使用 1 个线程,对应 Netty 线程模型中的主 Reactor。
|
||||
|
||||
eventLoopGroupSelector 线程组,对应 Netty 线程模型中的从 Reactor 组,俗称 IO 线程池。
|
||||
|
||||
defaultEventExecutorGroup 线程组,在 Netty 中,可以为编解码等事件处理器单独指定一个线程池,从而使 IO 线程只负责数据的读取与写入。
|
||||
|
||||
|
||||
下一步,使用 Netty 提供的 ServerBootstrap 对象创建 Netty 服务端,示例代码如下所示:
|
||||
|
||||
|
||||
|
||||
上面的代码基本都是模版代码,少数不同点就是需要自己实现编码器、解码器和业务处理 Handler。其中,编码器、解码器其实就是实现通信协议,而 ServerHandler 就是服务端业务处理器。
|
||||
|
||||
再下一步,服务端在指定接口建立监听,等待客户端连接:
|
||||
|
||||
|
||||
|
||||
ServerBootstrap 的 bind 方法是一个非阻塞方法,调用 sync() 方法会变成阻塞方法,它需要等待服务端启动完成。
|
||||
|
||||
最后一步就是编写服务端业务处理 Handler 了:
|
||||
|
||||
|
||||
|
||||
服务端处理器需要接收客户端请求,所以通常需要实现 channelRead 事件。通常业务 Handler 是在解码器之后执行,所以业务 Handler 中 channelRead 方法接收到的参数已经是通信协议中定义的具体模型,也就是请求对象了。后面就是根据该请求对象中的内容,执行对应的业务逻辑了。业务 Handler 会在 defaultEventExecutorGroup 线程组中执行,为了提高解码的性能,避免业务逻辑与 IO 操作相互影响,通常会将业务执行派发到业务线程池。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。
|
||||
|
||||
这节课,我们从一个简单的 RPC 请求 - 响应模式说起,串起了网络编程中编码、网络写、网络传输、网络读取、解码、业务逻辑执行等步骤,并引出了网络粘包问题,最终通过制定通信协议解决了粘包问题。
|
||||
|
||||
通信协议看似是一个非常高大上的名词,它其实是一种发送端和接收端共同制定的通信格式。我在这里介绍了一种通用的设计方法:Header(请求头) + Body(消息体)的经典设计方法。
|
||||
|
||||
接下来,我们还讲解了 Netty 的线程模型,也是主从多 Reactor 模型。但我们要知道业务 Handler 默认是在 IO 线程池中执行的,我们改变这种行为,让 Handler 在一个独立的线程池中执行,主要是为了提升 IO 线程的执行效率。
|
||||
|
||||
在讲解 Netty 读写流程之前,我给你提了下面几个问题。只有真正理解了这些问题,才能算是真正理解了 NIO 编程。在这里,我也给出我的答案,你可以对照思考一下。
|
||||
|
||||
|
||||
如何处理连接半关闭?
|
||||
|
||||
|
||||
在调用 SocketChannel 方法的 read 方法时,如果其返回值为 -1,则表示对端已经关闭了连接,接受端也需要同样关闭连接,释放相关资源。
|
||||
|
||||
|
||||
什么时候应该注册读事件?
|
||||
|
||||
|
||||
接受端通常在创建好 NioSocketChannel 后就应该注册读事件。这样才能接受发送端的数据,如果服务端感觉到有压力时,可以暂时取消关注读事件,达到限流的效果。
|
||||
|
||||
|
||||
写数据之前一定要先注册写事件吗?
|
||||
|
||||
|
||||
写数据之前不需要注册写事件,写事件一般是底层 NioSocketChannel 的底层缓存区满了,无法再往网络中写入数据时,再注册通道的写事件,等待缓冲区空闲时通知应用程序继续将剩余数据写入到网络中。
|
||||
|
||||
在课程的最后,我们以消息中间件 RocketMQ 是如何使用 Netty 开发网络通信模块,进行 Netty 网络编程实战,做到理论与实践相结合。
|
||||
|
||||
Netty 是一个庞大的体系,如果你想进一步提升高并发编程能力,我建议你体系化地学习一下它,我也非常推荐这本《Netty 源码分析与实战 - 网络通道篇》,希望可以让你在学习 Netty 的过程中少走一些弯路。
|
||||
|
||||
课后题
|
||||
|
||||
学完这节课,你应该已经掌握了 NIO 的读写处理过程,那我也给你留一道课后题。
|
||||
|
||||
请你尝试重构[上节课]的代码:实现一个简易的 RPC Request-Response 模型,确保这个模型支持同步请求、异步请求两种请求发送模式。
|
||||
|
||||
如果你想要分享你的代码想听听我的意见,可以提交一个 [GitHub]的 push 请求或 issues,并把对应地址贴到留言里。我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
258
专栏/中间件核心技术与实战/08加餐中间件底层的通用设计理念.md
Normal file
258
专栏/中间件核心技术与实战/08加餐中间件底层的通用设计理念.md
Normal file
@ -0,0 +1,258 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 加餐 中间件底层的通用设计理念
|
||||
你好,我是丁威。
|
||||
|
||||
我们都知道,开发中间件的技术含量是比较高的,如果能参加中间件的开发,可以说是朝“技术大神”迈了一大步。
|
||||
|
||||
但是,中间件开发并不是遥不可及的。通过对各主流中间件的研究,我发现了中间件底层的一些通用设计理念,它们分别是数据结构、多线程编程 (并发编程)、网络编程 (NIO、Netty)、内存管理、文件编程和相关领域的知识。
|
||||
|
||||
|
||||
|
||||
其中,数据结构、多线程编程和网络编程是中间件的必备基础,在前面的课程中,我也做了详细介绍。这节课,我会重点介绍内存管理和文件编程相关的知识,带你了解开发中间件的核心要点。
|
||||
|
||||
你可能会问,六大技能,那最后一个技能是什么呢?最后这个技能就是相关领域的知识,它和中间件的类型有很大关系,和你需要解决的问题密切相连。
|
||||
|
||||
举个例子,数据库中间件的出现就是为了解决分库分表、读写分离等与数据库相关的问题。那如果要开发一款数据库中间件,你就必须对数据库有一个较为深入且体系化的理解。想要开发出一款 MyCat 这样基于代理模式的数据库,就必须了解 MySQL 的通信协议。我们甚至可以将相关领域的知识类比为我们要开发的业务系统的功能需求,这个是非常重要的。不过这部分我没有办法展开细讲,需要你自己去慢慢积累。我们还是说回内存管理。
|
||||
|
||||
内存管理
|
||||
|
||||
Java 并不像 C 语言或者其他语言一样需要自己管理内存,因为 JVM 内置了内存管理机制(垃圾回收机制),所以在编写业务代码的过程中,我们只需要创建对象,而不需要关注对象在什么时候被回收。
|
||||
|
||||
垃圾回收机制(GC)对于业务开发来说无疑是非常方便的,但对于中间件开发来说就有点力不从心了。因为垃圾回收器执行回收时会出现停顿现象 (Stop-World),不同垃圾回收器只是停顿的时间长短不同。不可控的垃圾回收对中间件的性能、可用性带来了比较大的影响。为了应对这一问题,通常的做法是,从操作系统申请一块内存,由中间件本身来管理这块内存的使用。
|
||||
|
||||
我们用 Netty 的内存管理机制来进一步说明一下。之前在讲解 NIO 读事件处理流程时我们说,IO 线程需要将网卡中读取到的字节存储到累积缓存区。这里就要注意了,累积缓存区是需要使用内存的。我们用一张图来说明内存管理的一些需求:
|
||||
|
||||
|
||||
|
||||
在这张图中,我们从 JVM 内存模型视角创建了 3 个累积缓存区,然后,我们需要在栈内存创建 3 个指针,并分别在堆空间中创建 3 个 ByteBuf 对象。每个 ByteBuf 对象内部都有一块连续内存,用于存储从网卡中接收到的内容。
|
||||
|
||||
那 Netty 框架会接管哪部分内存呢?
|
||||
|
||||
我们来简单思考一下,如果 Netty 直接使用 Java 的垃圾回收机制,那 ByteBuf 对象还有内部持有的内存 (byte[]) 就会频繁地创建与销毁,而且这些对象都是朝生暮死的,这会导致频繁的 GC,高性能、高并发基本就成为奢望了。
|
||||
|
||||
为了有效降低垃圾回收发生的频率,减少需要回收的对象,Netty 采用了下面两个解决手段。
|
||||
|
||||
首先,Netty 会单独管理 ByteBuf 内部持有的内存,在启动进程时就向操作系统申请指定大小的内存。这样,这部分内存会被独占,并且在 JVM 存活期间一直可达。垃圾回收器不需要关注这部分内存的回收,由 Netty 负责管理内存的释放和分配。
|
||||
|
||||
其次,对 ByteBuf 对象本身采用对象池技术,避免频繁创建与销毁 ByteBuf 对象本身。
|
||||
|
||||
提到内存管理,你不妨回忆一下自己最开始接触到的操作系统是什么样子。 目前流行的操作系统的内存管理基本都是段页式思想,笼统地说就是系统会对内存进行分段管理,每一段又包含多个页。
|
||||
|
||||
我们这节课主要通过学习 Netty 的内存管理机制来学习内存编程的通用设计理念。
|
||||
|
||||
在 Netty 中,内存的管理采取区 (Area)- 块 (chunk)- 页 (page) 的管理方式。每个区包含一定数量的块,而块又由多个页构成。Netty 的内存结构如下图所示:
|
||||
|
||||
|
||||
|
||||
之所以划分成区、块,主要是为了提高系统的并发能力。这里简单解释一下,因为内存是所有线程共享的,线程从块中申请内存或释放内存时必须加锁。否则容易导致一个块的内存同时分配给多个线程,造成数据错乱,程序异常。
|
||||
|
||||
也就是说,恰当地管理块中的内存是内存管理的重中之重。在上面这张示意图中,一个块包含了 8 页,那怎么对这些页进行管理,怎么表示这些页是已分配还是未分配,怎么根据分配情况快速找到合适的连续内存呢?
|
||||
|
||||
Netty 的解决之道是将这些页映射到一颗完全二叉树上。它的映射方式大致是下图的样子。
|
||||
|
||||
|
||||
|
||||
在这里,一个块的内存是 8 页,也就是 2 的 3 次幂,我们把 3 称作 maxOrder,maxOrder 的值越大,一个块中包含的页就越多,管理的内存就越多。
|
||||
|
||||
Netty 为了能够高效管理 maxOrder 的页,会将其映射到一颗完全二叉树上,每一个叶子节点代表一页,二叉树最后会完全存储在数组中。
|
||||
|
||||
具体的映射方法是:创建一个数组,长度为叶子节点的 2 倍,然后将完成二叉树按照每一层从左到右的顺序依次存储在数组中。注意,第一个节点要空出来,这样做的好处是根据数组下标能很方便地计算出父节点和两个子节点的下标。
|
||||
|
||||
具体的计算方法是:
|
||||
|
||||
|
||||
如果节点的下标为 n,则父节点的下标 n>>1,即 n/2。
|
||||
|
||||
如果父节点的下标为 n,则其左节点下标 n << 1,即 2n,右节点下标 n << 1 + 1,即 2n+1。
|
||||
|
||||
|
||||
完全二叉树映射到数组中,每一个元素存储的内容为该节点在二叉树中的深度,也就是上图中的 order 信息。为了统一深度的定义,我们默认根节点的深度为 0(order=0)。也就是说,根节点存储在 array[1] = 0 中,根节点的两个子节点分别存在 array[2]和 array[3]中,并且它们的值都为 1,其他节点以此类推。
|
||||
|
||||
这种存储方式能够清晰地让我们看到这个节点能一次分配的最大内存。如果一个节点在数组中存储的值为 n,那么从该节点出发,能找到的叶子节点的个数为 2 的 (maxOrder-n) 的幂,而每一个叶子节点表示一页,所以能分配到的最大内存是 2 的 (maxOrder-n) 的幂 * 每页大小(pageSize)。
|
||||
|
||||
基于这个存储结构,Netty 就可以方便地进行内存分配了。我们来看下具体的步骤。
|
||||
|
||||
首先,我们需要申请 8K 的内容,从根节点出发,找到第一个可分配的节点,整个查找过程如下图。
|
||||
|
||||
|
||||
|
||||
从根节点开始,优先遍历左子树,然后再遍历右子树,找到满足当前分配需求的最小节点,即从根节点,一直可以遍历到左边第一个叶子节点,将对应数组中的值 array[8] 更新为 array[8] + 1 。在上面这个例子中,这个值从 3 变为了 4。一旦存储的值大于 maxOrder,表示该节点已被占用,无法继续分配内存。
|
||||
|
||||
与此同时,我们还要从当前节点向上遍历二叉树,依次通过 n >> 1 找到当前节点的父节点的下标, 将 array[4]、array[2]、array[1]中存储的值依次加一。
|
||||
|
||||
这样,我们就完成了一次内存申请,在此基础上,如果我们需要再申请 16K 也就是 2 页的内存大小。查找过程如下图所示:
|
||||
|
||||
|
||||
|
||||
这里,我们还是从根节点开始遍历,当遍历到第一个左节点时,其存储的值为 2,并且它的左节点存储 3,右节点为 2,因为这一次我们需要申请 2 页内存,左节点存储的值为 3,能分配到的内存为 2 的 (maxOrder[3]-order[3]),最终得出为 1,即左节点只能分配 1 页的大小,故最终会定位它到右节点。从而将其右节点设置为 (maxOrder+1),表示已分配,然后依次遍历父节点,其值加 1。如果想要申请更多内存的话,重复上述步骤即可。
|
||||
|
||||
从这里也可以看出,内存的申请流程,基本都是从根节点开始遍历,先遍历左子树、然后遍历右子树,找到第一个大于指定内存最小内存的节点,把它设置为已分配,然后依次找到父节点并将相应在数组中的值减 1。
|
||||
|
||||
内存的释放和内存的申请刚好相反。我们要从释放节点向上遍历,给数组中存储的值减 1,因为这部分内容比较简单,这里我们就不展开讨论了。
|
||||
|
||||
文件编程
|
||||
|
||||
介绍完内存管理,我们再来看看如何基于文件进行高效编程。
|
||||
|
||||
之所以要讲文件编程,是因为文件可以比内存提供更加廉价、更大容量的存储。而且内存存储的时效性比较短,电脑关机后数据就会丢失。不过,虽然我说了文件存储这么多优点,但我还是得客观一点,毕竟它还是有缺点的。比方说,在性能上文件存储就远低于内存。
|
||||
|
||||
我们来看看怎么基于文件存储写出高性能的程序。
|
||||
|
||||
首先,我们需要为存储的内容设计存储协议。以消息中间件为例,RokcetMQ 中消息的存储格式如下图所示:
|
||||
|
||||
|
||||
|
||||
从这张图里,我们可以看到文件存储设计的三个要点,也就是长度字段、魔数和 CRC 校验码。
|
||||
|
||||
|
||||
长度字段
|
||||
|
||||
|
||||
指的是存储一条消息的长度。这个字段通常使用一个定长字段来存储。比方说,一个字段有 4 个字节,那一条消息的最大长度为 2 的 32 次幂。有了长度字段,就能标识一条消息总共包含多少个字节,用 len 表示,然后我们在查找消息时只需要从消息的开始位置连续读取 len 个字节就可以提取一条完整独立的消息。
|
||||
|
||||
|
||||
魔数
|
||||
|
||||
|
||||
魔数不是强制的设计,设计它的目的是希望能够快速判断我们是否需要这些文件,通常情况下,魔数会取一个不太常用的值。
|
||||
|
||||
|
||||
CRC 校验码
|
||||
|
||||
|
||||
它是一种循环冗余校验码,用于校验数据的正确性。消息存储到磁盘之前,对消息的主体内容计算 CRC,然后存储到文件中。当从磁盘读取一条消息时,可以再次对读取的内容计算一次 CRC,如果两次计算的结果不一样,说明数据已被破坏。
|
||||
|
||||
学到这里我猜你已经发现了,这个文件存储协议的设计理念和网络编程领域的通信协议设计有着异曲同工之妙。对头,这个文件存储协议的设计基本也遵循 Header+Body 的结构,并且 Header 长度固定,并且包含长度字段。
|
||||
|
||||
不过,文件存储协议和通信协议有一个非常关键的区别,那就是:文件存储协议必须设计校验和字段,但通信协议不需要。数据存储在这是因为磁盘文件中,数据并不可靠,发生错误的概率比较大。而网络通讯协议在网络传输底层有相应的应对机制,能够及时发现错误并重发,从而确保数据传输的正确性。
|
||||
|
||||
好了,说回文件存储。解决了存储格式的问题,接下来就要考虑怎么从文件中检索消息了。
|
||||
|
||||
就像关系型数据库会为每一条数据引入一个 ID 字段一样,基于文件编程的模型也会为每条数据引入一个身份标志:起始偏移量,也就是数据存储在文件的起始位置。
|
||||
|
||||
起始偏移量的设计如下图所示:
|
||||
|
||||
|
||||
|
||||
通过起始偏移量 + SIZE,要从文件中提取一条完整的消息就轻而易举了。
|
||||
|
||||
我们在查询数据时,往往需要从多维度展开。以数据库查询为例,一个 order 表包含主键 ID、订单编号、创建时间等字段,我们不仅可以通过主键 ID 进行查询,还可以通过订单编号进行检索。
|
||||
|
||||
但是,如果订单表中的数据不断增加,根据订单编号查询订单数据变得越来越慢,这个时候我们该怎么优化呢?
|
||||
|
||||
答案是,为订单编号建立索引。
|
||||
|
||||
所谓的索引,就是将需要检索的内容 (订单编号) 与主键 ID 进行关联,在检索时,我们是先找到主键 ID,然后根据主键 ID 就能快速定位到内容,从而提升性能了。它的原理你可以参考一下下面这张图片。
|
||||
|
||||
|
||||
|
||||
数据库作为一个基于文件编程的系统,就可以通过建立索引来提升检索性能。但由于是用 C 语言编程的,深入探讨比较困难。这时候,RocketMQ 的存储就给我们演示了索引的设计方法。
|
||||
|
||||
在 RocketMQ 中,所有的原始消息会按照它们到达 RocketMQ 服务器的顺序存储到 Commitlog 文件中,但消息消费时需要根据主题进行消费。也就是说,我们需要按照主题查找消息。上面我们也说过了,包括 RocketMQ 在内,基于文件的编程模型只有根据起始偏移量才能快速找到消息,为了提升根据主题检索消息的效率,需要为主题建立索引。
|
||||
|
||||
RocketMQ 具体的做法是,为每一个主题、队列创建不同的文件夹,例如 /topic/queue。然后,在该文件夹下再创建多个索引文件,每一个索引文件中存储数据的格式为:8 字节的起始偏移量、4 字节的数据长度、8 字节的 tag 哈希值。如下图所示:
|
||||
|
||||
|
||||
|
||||
结合 RocketMQ 索引文件的构建规则,我们可以得出下面两个设计索引的关键点。
|
||||
|
||||
|
||||
为了通过索引项快速查询到数据,索引项中包含了起始偏移量,并且为了支持快速根据 tag 进行过滤,索引项中也包含了 tag 的信息。
|
||||
|
||||
为了保证索引项的检索效率,索引项本身的查找机制必须非常高效。RocketMQ 是根据主题、队列、消费进度三者快速找到消息的,所以索引项的设计借鉴了数组思想,将主题索引项设计为固定长度。
|
||||
|
||||
|
||||
索引机制解决了文件层面的检索问题,但索引最后也是存储在文件中,索引自身的性能是没法提升的。为了提升访问文件的性能,我们还会使用另外一种优化手段:内存映射机制。
|
||||
|
||||
什么是内存映射机制呢?一言以蔽之,就是将文件直接映射到内存中,将直接操作文件的方式用操作内存的方式进行替换,从而提升性能。
|
||||
|
||||
|
||||
|
||||
在写入数据时,我们不是直接调用文件 API,而是将数据先写入到内存,然后再根据不同的策略,将数据从内存中再刷写到文件。
|
||||
|
||||
在 Java 中,我们可以通过下面这段代码启动内存映射机制:
|
||||
|
||||
FileChannel fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
|
||||
MappedByteBuffer mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
|
||||
|
||||
|
||||
在 Linux 操作系统中,MappedByteBuffer 基本可以看成是页缓存(PageCache)。Linux 操作系统的内存使用策略是,最大可能地利用机器的物理内存并常驻在内存中,这就是所谓的页缓存。
|
||||
|
||||
只有当操作系统的内存不够时,我们才会采用缓存置换算法。例如,LRU 会将不常用的页缓存回收,也就是说操作系统会自动管理这部分内存,无须使用者关心。如果从页缓存查询数据时未命中,会产生缺页中断,这时候操作系统自动将文件中的内容加载到页缓存。
|
||||
|
||||
将文件映射到内存中,数据写入时只是先将数据存储到内存,但这部分数据还没有真正写入到磁盘,需要采取一定的策略将内存中的数据同步刷写到磁盘中。我们知道,机器重启后会造成数据丢失,在平衡性能和数据可靠性时,通常会衍生出下面两种不同的策略。
|
||||
|
||||
|
||||
同步刷盘。数据写入到内存后,需要立即将内存数据写入到磁盘,然后才向客户端返回“写入成功”。这会牺牲性能,但可以保证数据不丢失。
|
||||
|
||||
异步刷盘。数据写入到内存后,会立即向客户端返回“写入成功”,然后异步将内存中的数据刷写到磁盘。
|
||||
|
||||
|
||||
“刷盘”这个名词是不是听起来很高大上,其实它并不是一个什么神秘高深的词语。所谓刷盘,就是将内存中的数据同步到磁盘,在代码层面其实是调用了 FileChannel 或 MappedBytebuffer 的 force 方法。
|
||||
|
||||
RocketMQ 中实现 MappedFile 的刷盘的代码如下:
|
||||
|
||||
public int flush(final int flushLeastPages) {
|
||||
if (this.isAbleToFlush(flushLeastPages)) {
|
||||
if (this.hold()) {
|
||||
int value = getReadPosition();
|
||||
|
||||
try {
|
||||
//We only append data to fileChannel or mappedByteBuffer, never both.
|
||||
if (writeBuffer != null || this.fileChannel.position() != 0) {
|
||||
this.fileChannel.force(false);
|
||||
} else {
|
||||
this.mappedByteBuffer.force();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
log.error("Error occurred when force data to disk.", e);
|
||||
}
|
||||
|
||||
this.flushedPosition.set(value);
|
||||
this.release();
|
||||
} else {
|
||||
log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
|
||||
this.flushedPosition.set(getReadPosition());
|
||||
}
|
||||
}
|
||||
return this.getFlushedPosition();
|
||||
}
|
||||
|
||||
|
||||
总之,无论是同步刷盘还是异步刷盘,最终都是调用文件存储设备的写入 API。目前,文件的存储媒介还是以机械硬盘为主,机械硬盘在写入数据之前,需要先进行磁道寻址,如果写入磁盘的数据位置比较随机,那么寻址需要花费的时间也会相应增多,所以业界又引入了一种设计思想:文件顺序写机制。
|
||||
|
||||
文件顺序写的设计理念应用非常广泛,数据库领域的 redo 日志的底层就运用了顺序写机制。你可以先通过这张图理解一下它的运作机制。
|
||||
|
||||
|
||||
|
||||
一个数据库中有很多表,每一张表都存储了很多数据,这些数据分布在磁盘不同的区域,而且用户在更新数据时也很分散。例如他们会时而更新订单表,时而更新用户表,那该怎么优化呢?
|
||||
|
||||
我们以 MySQL 为例。MySQL 中的 InnoDB 引擎是首先维护一个内存池,同样使用内存映射机制将磁盘中的文件映射到内存,用户的更新操作会首先更新内存。但我们知道,对于关系数据库来说,数据不丢失是一个硬性需求,但如果为了确保这一点采用同步刷盘将数据写入到磁盘,又必然是一个随机写的过程,无法满足性能要求。
|
||||
|
||||
为此,InnoDB 引入了一个 redo 文件。这样,数据写入到内存后,会先同步刷盘到 redo 文件,但写入 redo 文件是一个不断追加的过程,也就是说先顺序写入 redo 文件,然后再异步将内存中的数据刷写到各个表中。这样,就算 MySQL 宕机等原因导致内存中的数据丢失,还是可以通过回放 redo 文件将数据恢复回来。这就在保证数据库不丢失的情况下,提升了性能。
|
||||
|
||||
总结
|
||||
|
||||
这节课就讲到这里。刚才,我们从中间件开发的视角入手,简单介绍了中间件开发工程师必须具备的基础技能。我还重点介绍了内存管理、文件管理的一些编程技巧。
|
||||
|
||||
内存管理主要包括内存的分配与内存的回收,我们可以通过进程管理内存,从而减少垃圾回收的发生频率,提升系统运行稳定性。
|
||||
|
||||
文件编程领域首先要解决的就是数据以什么格式存储在文件中,然后再通过引入索引、内存映射、同步刷盘、异步刷盘、顺序写等手段优化文件访问的性能。
|
||||
|
||||
希望学完这节课,你能更深入地认识一些业务开发领域平时不怎么关注,甚至看起来有点高大上的功能。当然更重要的是,以此为起点,展开对中间件更深的探索。如果你有什么其他的疑问和发现,也欢迎随时跟我沟通。
|
||||
|
||||
课后题
|
||||
|
||||
最后,给你留一道思考题吧。
|
||||
|
||||
有人说,在 Netty 的内存分配机制中,数组中存储的值代表该节点当前拥有的剩余内存。你觉得这句话对吗,为什么?
|
||||
|
||||
欢迎在留言区写下你的想法。我们下节课再见。
|
||||
|
||||
|
||||
|
||||
|
161
专栏/中间件核心技术与实战/09技术选型:如何选择微服务框架和注册中心?.md
Normal file
161
专栏/中间件核心技术与实战/09技术选型:如何选择微服务框架和注册中心?.md
Normal file
@ -0,0 +1,161 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 技术选型:如何选择微服务框架和注册中心?
|
||||
你好,我是丁威。
|
||||
|
||||
从这节课开始,我们正式进入微服务领域中间件的学习。我们会从微服务框架的诞生背景、服务注册中心的演变历程还有 Dubbo 微服务框架的实现原理出发,夯实基础。然后,我会结合自己在微服务领域的实践经验,详细介绍 Dubbo 网关的设计与落地方案,以及蓝绿发布的落地过程。
|
||||
|
||||
这节课,我们先从基础学起。
|
||||
|
||||
微服务框架的诞生背景
|
||||
|
||||
分布式架构体系是伴随着互联网的发展而发展的,它经历了单体应用和分布式应用两个阶段。记得我在 2010 年入职了一家经营传统行业的公司,公司主要负责政府采购和招投标系统的开发与维护工作,那是我第一次真正见识了庞大的单体应用架构的样子。
|
||||
|
||||
当时公司的架构体系是下面这个样子:
|
||||
|
||||
|
||||
|
||||
所有的业务组件、业务模块都耦合在一个工程里,最终部署的时候会打成一个统一的 War 包然后部署在一台 Web 容器中,所有的业务模块都访问同一个数据库。
|
||||
|
||||
在传统行业,这种架构的优势也很明显。因为部署结构单一,所以管理非常方便,而且一般情况下,政府采购等行为的流量变化不大,不会像互联网那样,随着平台的搭建造成业务体量的指数型增长。
|
||||
|
||||
我们设想一下,如果某一天国家发布政策,想要做一个全国的统一的政府采购平台,假设这家公司中标了,他们会怎么改造系统呢?通常的做法就是对系统进行拆分,单独部署和扩展各个子系统,拆分后的系统架构如下图所示:
|
||||
|
||||
|
||||
|
||||
由于单个子系统只部署一个节点已经无法满足要求了,所以他们需要部署多个进程,并且需要根据业务的体量进行动态的增加与减少,这样维护调用关系就会变得非常复杂而且容易出错。
|
||||
|
||||
在上面这张架构图中,基础资料子系统被其他所有模块调用,如果我们想要增加新的部署节点,或者由于一些机器老化需要更换设备,导致服务对应的 IP 地址发生变化,这时候应该怎么维护信息呢?
|
||||
|
||||
你可能会说这不就是负载均衡吗。我们可以通过 Nginx 来实现负载均衡,而调用方不需要维护调用者列表。它的架构是下面这样:
|
||||
|
||||
|
||||
|
||||
没错,通过引入 Nginx 可以实现负载均衡,并且在节点发生变化时,只需要修改 Nginx 的配置,不需要去修改调用方的代码。但是一旦部署了新的节点,我们还是需要手动在 Nginx 中添加路由信息,也就是说,这个操作只能是人工完成的。随着系统的膨胀,路由配置会变得越来越不可维护,容易出错甚至引发严重的故障。
|
||||
|
||||
这个问题代表着一系列与微服务相关的共性需求,如服务注册与自动发现机制、高性能 RPC 调用、服务治理等。
|
||||
|
||||
为了解决这些共性需求,很多微服务中间件如雨后春笋般涌现出来,其中要数 Dubbo 和 Spring Cloud 最为突出。
|
||||
|
||||
如何选择微服务框架?
|
||||
|
||||
Dubbo 和 Spring Cloud 是什么?怎么在 Dubbo 和 SpringCloud 之间进行选择呢?
|
||||
|
||||
Dubbo 是阿里巴巴开源的优秀的微服务框架,它开源之后迅速成为了互联网程序员们的首选微服务框架,我认为 Dubbo 有下面几个核心优势。
|
||||
|
||||
|
||||
易用性
|
||||
|
||||
|
||||
微服务框架通常包含服务注册与自动发现、高性能的 RPC 远程调用、服务治理等众多复杂的功能需求,框架内部非常复杂。但用户操作这种框架却非常简单,不需要太多专业知识,仅仅是通过 Dubbo 提供的 dubbo:service、dubbo:reference、dubbo:registry 等几个配置命令就可以轻松构建自己的微服务体系。
|
||||
|
||||
而且,这些配置命令拥有众多配置参数(涵盖服务发现、服务治理、性能调优等维度),而且都根据经验提供了默认值,用户几乎不需要对任何参数进行调优,就能保证项目的稳定运行。
|
||||
|
||||
|
||||
可扩展性制
|
||||
|
||||
|
||||
Dubbo 通过 SPI 提供了高度灵活的扩展机制,Dubbo 内部几乎所有的核心特性都提供了扩展点,Dubbo 官方文档中给出的 SPI 扩展点有下面这些:
|
||||
|
||||
|
||||
|
||||
|
||||
高性能
|
||||
|
||||
|
||||
Dubbo RPC 协议运行在传输层,并基于 TCP 协议实现了私有协议栈,支持多种序列化协议,包含 protocuf、kryo 等高性能序列化协议。
|
||||
|
||||
Dubbo 的易用性、可扩展机制和高性能让它在一段时间内备受拥护,但也许是 Dubbo 发展得已经非常成熟了,又或者是阿里巴巴在部署其他的战略,Dubbo 竟然“断更了”。我们知道持续迭代、持续创新是开源项目的生命源泉,停止更新的 Dubbo 也就无法继续高歌猛进了。这也给了其他微服务框架更多的生存空间,SpringCloud 技术栈就在这个时候崛起了。
|
||||
|
||||
Spring Cloud 技术栈由各个不同的子项目构成,每一个项目解决微服务架构领域的一个问题,我把 SpringCloud 和微服务架构相关的技术组件列了个表格:
|
||||
|
||||
|
||||
|
||||
SpringCloud 技术栈和 Dubbo 都是非常优秀的微服务框架,并且随着互联网分布式架构正式拥抱云原生,Dubbo 也顺应云原生发展浪潮,重新开始维护。那这两个框架我们该如何选择呢?
|
||||
|
||||
技术选项要考虑框架本身的特性,同时也需要结合公司的技术栈、使用的开发语言等因素综合考虑,这节课我们重点从框架本身这个维度来考量,也会顺便提一提如何结合公司自身的情况去进行选型。
|
||||
|
||||
从功能的丰富程度上讲,SpringCloud 体系更占优势,但并不是说使用 Dubbo 来构建微服务体系就无法实现链路监控、服务网关这些功能。Dubbo 的设计理念是职责分明,链路跟踪功能完全可以选择业界主流的链路跟踪开源项目,所以从功能维度我也给你列了一张表格,分别对比了用 Spring Cloud 和 Dubbo 搭建的微服务架构体系采用的技术栈:
|
||||
|
||||
|
||||
|
||||
从表格中我们也能看出,在微服务架构必备的注册中心、服务调用、负载均衡、熔断等基础功能上,Dubbo 都是内置的,不需要用户关注太多技术细节,而 Spring Cloud 需要单独进行学习,入门成本偏高。
|
||||
|
||||
Dubbo 的设计理念是提供对应的扩展点,供用户根据需要自行扩展。而 Spring Cloud 中各个技术组件都是单独发展的,最终 SpringBoot 体系将第三方的开源项目进行了整合,省去了用户的整合成本。
|
||||
|
||||
从性能的角度,Dubbo 要明显优于 SpringCloud。
|
||||
|
||||
Spring Cloud 的 RPC 调用是基于 HTTP 协议开发的,它处于网络模型的应用层,而 Dubbo 的 RPC 调用的底层是 TCP 协议,它处于网络模型的传输层。所以说,在底层网络通讯方面,Dubbo 就天然地占据了优势。
|
||||
|
||||
由于 Dubbo 是基于 TCP 编程的,这就比直接使用 HTTP 进行数据传输具有更大的灵活度。直接基于 TCP 网络进行编程,对网络通讯中各个环节可以灵活进行定制化开发,例如 Dubbo 在序列化、反序列化、IO 线程、业务线程等方面的设置具有高度配置化,性能的提升非常明显,而 Spring Cloud 在这方面显得就有些吃力了。阿里、腾讯、美团、拼多多等一线互联网企业的微服务框架都是基于 TCP 来构建的。
|
||||
|
||||
Dubbo、SpringCloud 都是主流的微服务,你可以根据实际情况加以选择。不过,结合目前我所处的行业和公司的技术栈,我倾向于采用 Dubbo 来构建微服务架构体系。
|
||||
|
||||
如何选择微服务注册中心?
|
||||
|
||||
在这节课的最后,我想结合生产中遇到的一个故障,和你聊聊注册中心的选型问题。
|
||||
|
||||
在微服务架构体系相当长的一段发展时间里,ZooKeeper 都占领着微服务注册中心的头把交椅,几乎成为注册中心唯一的选择。这是为什么呢?接下来我们就重点解读一下 ZooKeeper 的 CP 设计理念。下节课,我们还会对微服务注册中心的设计理念做详细介绍。
|
||||
|
||||
ZooKeeper 是一个分布式协调组件,符合 CAP 分布式理论中的 CP。
|
||||
|
||||
CAP 理论指的是,在一个分布式集群中存储同一份数据,无法同时实现 C(一致性)、A(可用性) 和 P(持久性),只能同时满足其中两个。由于 P 在数据存储领域是必须要满足的,所以通常需要在 C 与 A 之间做权衡。ZooKeeper 是保住了一致性和持久性,选择性地牺牲了可用性。
|
||||
|
||||
ZooKeeper 的数据写入流程如下:
|
||||
|
||||
|
||||
|
||||
在 ZooKeeper 集群中,首先会进行 Leader 选举,根据 ZAB 协议选举出一个 Leader 节点用来处理写请求,然后将数据复制给从节点:
|
||||
|
||||
|
||||
当集群内超过半数节点写入成功,则返回“数据写入成功”;
|
||||
|
||||
如果集群内还没有成功选举出 Leader,则 ZooKeeper 集群无法向外提供数据写入与读取服务。
|
||||
|
||||
|
||||
在 Leader 选举期间,集群是不可用的(牺牲了可用性)。但在正常生产实践过程中,ZooKeeper 集群内部选举 Leader 节点的耗时在毫秒级别,并不会影响使用。然而,一旦遇到异常情况就很难说了。
|
||||
|
||||
我在生产过程中就出现了由于 ZooKeeper 集群内存溢出导致频繁 Full GC 的情况。当时的情况是,公司内部的 Dubbo 专用 ZooKeeper 地址被业务方用做分布式锁,但他们在使用过程中频繁创建节点,加上遇到 Bug,节点数据没有及时删除,这就导致占用的内存越来越大,最终频繁 Full GC,使得 ZooKeeper 会话超时,所有注册在 ZooKeeper 注册中心的服务全部被删除,所有客户端服务调用都出现“No Provider”警告,酿成一场严重的生产级故障。
|
||||
|
||||
经过这次故障,我也开始重新审视 ZooKeeper 和 CP 模式的合理性。注册中心是微服务体系的大脑,一旦出现问题会带来不可估量的损失,其可用性尤为重要。
|
||||
|
||||
也正是因为 CP 模型存在严重的可用性问题,以 AP 为设计思想的注册中心开始逐渐涌现出来。AP 的核心指导思想是容忍分布式集群中多个节点之间的数据短暂不一致,但最终能达到一致性。EureKa 就是典型的基于 AP 的注册中心。
|
||||
|
||||
由于基于 AP 的注册中心不需要保证强一致性,所以集群内节点的地位通常都是平等的。客户端在同一时间与集群中一个节点保持长连接,当出现错误后,客户端再从注册中心集群中选择另外一个节点,并且客户端可以向集群中任何一个节点写入数据后立即返回“写入成功”,然后让数据异步在集群内部复制,最终实现数据的一致性。EureKa 集群的写入流程如下:
|
||||
|
||||
|
||||
|
||||
由于集群内部节点的地位是平等的,客户端在其中一个节点不可用时,可以快速切换到另外的节点,这样可用性就得到了保障。那么问题来了,节点之间路由信息不一致会带来什么问题呢?这些问题我们可不可以接受?
|
||||
|
||||
在回答这个问题之前我们不妨来看看一个注册中心各个节点数据不一致的例子,如下所示:
|
||||
|
||||
|
||||
|
||||
在这里,由于某种异常,Eureka 集群中各个节点存储的数据并不一致,在节点 1 和 2 中关于 /user/saveUser 接口有三个服务提供者,但在节点 3 中只有两个服务提供者。但无论是三个服务提供者也好,还是两个服务提供者也好,都会造成负载不均衡,如果节点出现类似 Full GC 的问题,节点无法对外提供服务,这时候客户端会从集群中选择其他节点重试,并不会对系统带来致命影响。
|
||||
|
||||
综合来看,服务注册中心这种场景,AP 模式显然比 CP 模式更佳。这也是为什么现在很多原先使用 CP 模式的注册中心都开始尝试向 AP 转化,而像 Eureka、Nacos 这种注册中心基本都同时提供了 AP 和 CP 两种工作模式,用户可以按照场景进行选择。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。这节课我们主要从微服务框架诞生背景、微服务框架选型和注册中心框架的演变三个方面介绍了微服务。
|
||||
|
||||
微服务框架的基本诉求主要包括:服务注册与自动发现机制、高性能 RPC 调用和服务治理,它致力于让分布式架构中的服务治理变得简单高效。
|
||||
|
||||
我们还分析了市面上两种最主流的微服务研发框架:Dubbo 和 Spring Cloud,Dubbo 具有易用性、灵活的扩展机制和更好的性能,Spring Cloud 则具有更加丰富的功能。你可以根据实际情况加以选择,结合目前我所处的行业,公司的技术栈,我倾向于采用 Dubbo 来构建微服务架构体系。
|
||||
|
||||
最后,我还结合自己在实践过程中发生的一起故障,介绍了注册中心从 CP 向 AP 架构演进的原因。总的来说,以 Eureka 和 Nacos 为代表的注册中心,正在逐渐取代采用 CP 模式的 ZooKeeper,成为注册中心的优先选项。
|
||||
|
||||
思考题
|
||||
|
||||
最后,我也给你留一道思考题。
|
||||
|
||||
我们刚才讲了一个我在生产实践中经历的一次事故。基于 Zookeeper 搭建的 Dubbo 服务注册中心,由于 ZooKeeper 节点的内存使用不当导致频繁触发 Full GC,最终导致 ZooKeeper 会话超时,在注册中心的服务提供者会全部被删除,所有的消费者调用都感知不到服务提供者,进而导致服务调用雪崩。这时候我们应该怎么做呢?难道要重启所有服务提供者,让他们重新注册吗?你有什么快速恢复的方法?
|
||||
|
||||
欢迎你在评论区留下自己的看法,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
221
专栏/中间件核心技术与实战/10设计原理:Dubbo核心设计原理剖析.md
Normal file
221
专栏/中间件核心技术与实战/10设计原理:Dubbo核心设计原理剖析.md
Normal file
@ -0,0 +1,221 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 设计原理:Dubbo核心设计原理剖析
|
||||
你好,我是丁威。
|
||||
|
||||
这节课,我们来剖析一下 Dubbo 中一些重要的设计理念。这些设计理念非常重要,在接下来的 11 和 12 讲 Dubbo 案例中也都会用到,所以希望你能跟上我的节奏,好好吸收这些知识。
|
||||
|
||||
微服务架构体系包含的技术要点很多,我们这节课没法覆盖 Dubbo 的所有设计理念,但我会带着你梳理 Dubbo 设计理念的整体脉络,把生产实践过程中会频繁用到的底层原理讲透,让你轻松驾驭 Dubbo 微服务。
|
||||
|
||||
我们这节课的主要内容包括服务注册与动态发现、服务调用、网络通信模型、高度灵活的扩展机制和泛化调用五个部分。
|
||||
|
||||
服务注册与动态发现
|
||||
|
||||
我们首先来看一下 Dubbo 的服务注册与动态发现机制。
|
||||
|
||||
Dubbo 的服务注册与发现机制如下图所示:
|
||||
|
||||
|
||||
|
||||
Dubbo 中主要包括四类角色,它们分别是注册中心(Registry)、服务调用者 & 消费端(Consumer)、服务提供者(Provider)和监控中心(Monitor)。
|
||||
|
||||
在实现服务注册与发现时,有三个要点。
|
||||
|
||||
|
||||
服务提供者 (Provider) 在启动的时候在注册中心 (Register) 注册服务,注册中心 (Registry) 会存储服务提供者的相关信息。
|
||||
|
||||
服务调用者 (Consumer) 在启动的时候向注册中心订阅指定服务,注册中心将以某种机制(推或拉)告知消费端服务提供者列表。
|
||||
|
||||
当服务提供者数量变化(服务提供者扩容、缩容、宕机等因素)时,注册中心需要以某种方式 (推或拉) 告知消费端,以便消费端进行正常的负载均衡。
|
||||
|
||||
|
||||
Dubbo 官方提供了多种注册中心,我们选择使用最为普遍的 ZooKeeper 进一步理解注册中心的原理。
|
||||
|
||||
我们先来看一下 Zookeeper 注册中心中的数据存储目录结构。
|
||||
|
||||
|
||||
|
||||
可以看到,它的目录组织结构为 /dubbo/{ServiceName},其中,ServiceName 表示一个具体的服务,通常用包名 + 类名表示,在每一个服务名下又会创建四个目录,它们分别是:
|
||||
|
||||
|
||||
providers,服务提供者列表;
|
||||
|
||||
consumers,消费者列表;
|
||||
|
||||
routers,路由规则列表(一个服务可以设置多个路由规则);
|
||||
|
||||
configurators,动态配置条目。
|
||||
|
||||
|
||||
要说明的是,在 Dubbo 中,我们可以在不重启消费者、服务提供者的前提下动态修改服务提供者、服务消费者的配置,配置信息发生变化后会存储在 configurators 子节点中。此时,服务提供者、消费者会动态监听配置信息的变化,变化一旦发生就使用最新的配置重构服务提供者和服务消费者。
|
||||
|
||||
基于 Zookeeper 注册中心的服务注册与发现有下面三个实现细节。
|
||||
|
||||
|
||||
服务提供者启动时会向注册中心进行注册,具体是在对应服务的 providers 目录下增加一条记录(临时节点),记录服务提供者的 IP、端口等信息。同时服务提供者会监听 configurators 节点的变化。
|
||||
|
||||
服务消费者在启动时会向注册中心订阅服务,具体是在对应服务的 consumers 目录下增加一条记录(临时节点),记录消费者的 IP、端口等信息,同时监听 configurators、routers 目录的变化,所谓的监听就是利用 ZooKeeper 提供的 watch 机制。
|
||||
|
||||
当有新的服务提供者上线后, providers 目录会增加一条记录,注册中心会将最新的服务提供者列表推送给服务调用方(消费端),这样消费者可以立刻收到通知,知道服务提供者的列表产生了变化。如果一个服务提供者宕机,因为它是临时节点,所以 ZooKeeper 会把这个节点移除,同样会触发事件,消费端一样能得知最新的服务提供者列表,从而实现路由的动态注册与发现。
|
||||
|
||||
|
||||
服务调用
|
||||
|
||||
接下来我们再来看看服务调用。Dubbo 的服务调用设计十分优雅,其实现原理图如下:
|
||||
|
||||
|
||||
|
||||
服务调用重点阐述的是客户端发起一个 RPC 服务调用时的所有实现细节,它包括服务发现、故障转移、路由转发、负载均衡等方面,是 Dubbo 实现灰度发布、多环境隔离的理论指导。
|
||||
|
||||
刚才,我们已经就服务发现做了详细介绍,接下来我们重点关注负载均衡、路由、故障转移这几个方面。
|
||||
|
||||
客户端通过服务发现机制,能动态发现当前存活的服务提供者列表,接下来要考虑的就是如何从服务提供者列表中选择一个服务提供者发起调用,这就是所谓的负载均衡(LoadBalance)。
|
||||
|
||||
Dubbo 默认提供了随机、加权随机、最少活跃连接、一致性 Hash 等负载均衡算法。
|
||||
|
||||
值得注意的是,Dubbo 不仅提供了负载均衡机制,还提供了智能路由机制,这是实现 Dubbo 灰度发布的重要理论基础。
|
||||
|
||||
所谓路由机制,是指设置一定的规则对服务提供者列表进行过滤。负载均衡时,只在经过了路由机制的服务提供者列表中进行选择。为了更好地理解路由机制的工作原理,你可以看看下面这张示意图:
|
||||
|
||||
|
||||
|
||||
我们为查找用户信息服务设置了一条路由规则,即“查询机构 ID 为 102 的查询用户请求信息将被发送到新版本(192.168.3.102)上。具体的做法是,在进行负载均衡之前先执行路由选择,按照路由规则对原始的服务提供者列表进行过滤,从中挑选出符合要求的提供者列表,然后再进行负载均衡。
|
||||
|
||||
接下来,客户端就要向服务提供者发起 RPC 请求调用了。远程服务调用通常涉及到网络等因素,因此并不能保证 100% 成功,当调用失败时应该采用什么策略呢?
|
||||
|
||||
Dubbo 提供了下面五种策略:
|
||||
|
||||
|
||||
failover,失败后选择另外一台服务提供者进行重试,重试次数可配置,通常适合实现幂等服务的场景;
|
||||
|
||||
failfast,快速失败,失败后立即返回错误;
|
||||
|
||||
failsafe,调用失败后打印错误日志,返回成功,通常用于记录审计日志等场景;
|
||||
|
||||
failback,调用失败后,返回成功,但会在后台定时无限次重试,重启后不再重试;
|
||||
|
||||
forking,并发调用,收到第一个响应结果后返回给客户端。通常适合实时性要求比较高的场景。但这一策略浪费服务器资源,通常可以通过 forks 参数设置并发调用度。
|
||||
|
||||
|
||||
如果将服务调用落到底层,就不得不说说网络通信模型了,这部分包含了很多性能调优手段。
|
||||
|
||||
网络通信模型
|
||||
|
||||
我们先看看 Dubbo 的网络通信模型,如下图所示:
|
||||
|
||||
|
||||
|
||||
Dubbo 的网络通信模型主要包括网络通信协议和线程派发机制(Dispatcher)两部分。
|
||||
|
||||
网络传输通常需要自定义通信协议,我们常用的协议设计方式是 Header + Body, 其中 Header 长度固定,包含一个长度字段,用于记录整个协议包的大小。
|
||||
|
||||
同时,为了提高传输效率,我们一般会对传输数据也就是 Body 的内容进行序列化与压缩处理。
|
||||
|
||||
Dubbo 支持目前支持 java、compactedjava、nativejava、fastjson、fst、hessian2、kryo 等序列化协议,生产环境默认为 hessian2。
|
||||
|
||||
网络通信模型的另一部分是线程派发机制。Dubbo 中会默认创建 200 个线程处理业务,这时候就需要线程派发机制来指导 IO 线程与业务线程如何分工。
|
||||
|
||||
Dubbo 提供了下面几种线程派发机制:
|
||||
|
||||
|
||||
all,所有的请求转发到业务线程池中执行(IO 读写、心跳包除外,因为在 Dubbo 中这两种请求都必须在 IO 线程中执行,不能通过配置修改);
|
||||
|
||||
message,只有请求事件在线程池中执行,其他请求在 IO 线程上执行;
|
||||
|
||||
connection ,求事件在线程池中执行,连接和断开连接的事件排队执行(含一个线程的线程池);
|
||||
|
||||
direct,所有请求直接在 IO 线程中执行。
|
||||
|
||||
|
||||
为什么线程派发机制有这么多种策略呢?其实这主要是考虑到线程切换带来的开销问题。也就是说,我们希望通过多种策略让线程切换带来的开销小于多线程处理带来的提升。
|
||||
|
||||
我举个例子,Dubbo 中的心跳包都必须在 IO 线程中执行。在处理心跳包时,我们只需直接返回 PONG 包(OK)就可以了,逻辑非常简单,处理速度也很快。如果将心跳包转换到业务线程池,性能不升反降,因为切换线程会带来额外的性能损耗,得不偿失。
|
||||
|
||||
网络编程中需要遵循一条最佳实践:IO 线程中不能有阻塞操作,通常将阻塞操作转发到业务线程池异步执行。
|
||||
|
||||
与网络通信协议相关的参数定义在 dubbo:protocol,关键的设置属性如下。
|
||||
|
||||
|
||||
threads,业务线程池线程个数,默认为 200。
|
||||
|
||||
queues,业务线程池队列长度,默认为 0,表示不支持排队,如果线程池满,则直接拒绝。该参数与 threads 配合使用,主要是对服务端进行限流,一旦超过其处理能力,就拒绝请求,快速失败,引导客户端重试。
|
||||
|
||||
iothreads:默认为 CPU 核数再加一,用于处理网络读写。在生产实践中,通常的瓶颈在于业务线程池,如果业务线程无明显瓶颈(jstack 日志查询到业务线程基本没怎么干活),但吞吐量已经无法继续提升了,可以考虑调整 iothreads,增加 IO 线程数量,提高 IO 读写并发度。该值建议保持在“2*CPU 核数”以下。
|
||||
|
||||
serialization:序列化协议,新版本支持 protobuf 等高性能序列化机制。
|
||||
|
||||
dispatcher:线程派发机制,默认为 all。
|
||||
|
||||
|
||||
高度灵活的扩展机制
|
||||
|
||||
Dubbo 出现之后迅速成为微服务领域最受欢迎的框架,除操作简单这个原因外,还有扩展机制的功劳。Dubbo 高度灵活的扩展机制堪称“王者级别的设计”。
|
||||
|
||||
Dubbo 的扩展设计主要是基于 SPI 设计理念,我们来看下具体的实现方案。
|
||||
|
||||
Dubbo 所有的底层能力都通过接口来定义。用户在扩展时只需要实现对应的接口,定义一个统一的扩展目录(META-INF.dubbo.internal)存放所有的扩展定义即可。要注意的是,目录下的文件名是需要扩展的接口的全名,像下图这样:
|
||||
|
||||
|
||||
|
||||
在初次使用对应接口实例时,可以扫描扩展目录中的文件,并根据文件中存储的 key-value 初始化具体的实例。
|
||||
|
||||
我们以 RPC 模块为例看一下 Dubbo 强悍的扩展能力。众所周知,目前 gRPC 协议以优异的性能表现正在逐步成为 RPC 领域的王者,很多人误以为 gRPC 是来革 Dubbo 的“命”的。其实不然,我们可以认为 Dubbo 是微服务体系的完整解决方案,而 RPC 只是微服务体系中的重要一环,Dubbo 完全可以吸收 gRPC,让 gRPC 成为 Dubbo 的远程调用方式。
|
||||
|
||||
具体的做法只需要在 dubbo-rpc 模块中添加一个 dubbo-rpc-grpc 模块,然后使用 gRPC 实现 org.apache.dubbo.rpc.protocol 接口,并将其配置在扩展目录中:
|
||||
|
||||
|
||||
|
||||
面对 gRPC 这么强大的功能扩展机制,绝大部分人应该和我一样,都是作为中间件的应用人员,不需要使用模块级别的扩展机制。我们通常只是结合应用场景来进行功能扩展。
|
||||
|
||||
Dubbo 在业务功能级别的扩展可以通过 Filter 机制来实现。Filter 的工作机制如下:
|
||||
|
||||
|
||||
|
||||
这里,过滤器链的执行时机是在服务消费者发起远程 RPC 请求之前。最先执行的是消费端的过滤器链,每一个过滤器可以设置执行顺序。服务端在解码之后、执行业务逻辑之前,也会首先调用过滤器链。
|
||||
|
||||
在专栏的最后一讲,我还会通过一个全链路压测方案讲解如何利用 Filter 机制来解决实际问题。
|
||||
|
||||
泛化调用
|
||||
|
||||
在这节课的最后,我们再来介绍一下 Dubbo 的泛化调用机制,它也是实现 Dubbo 网关的理论基础。
|
||||
|
||||
我们在开发 Dubbo 应用时通常会包含 API、Consumer、Provider 三个子模块。
|
||||
|
||||
其中 API 模块通常定义统一的服务接口,而 Consumer、Provider 模块都需要显示依赖 API 模块。这种设计理念虽然将 Provider 与 Consumer 进行了解耦合,但对 API 模块形成了强依赖,如果 API 模块发生改变,Provider 和 Consumer 必须同时改变。也就是说,一旦 API 模块发生变化,服务调用方、服务消费方都需要重新部署,这对应用发布来说非常不友好。特别是在网关领域,几乎是不可接受的,如下图所示:
|
||||
|
||||
|
||||
|
||||
公司的微服务在不停地演进,如果网关需要跟着 API 模块不停地发布新版本,网关的可用性和稳定性都将受到极大挑战。怎么解决这个问题呢?
|
||||
|
||||
这就要说到 Dubbo 的机制了。泛化调用具体实现原理如下:
|
||||
|
||||
|
||||
|
||||
当服务消费端发生调用时,我们使用 Map 来存储一个具体的请求参数对象,然后传输到服务提供方。由于服务提供方引入了模型相关的 Jar,服务提供方在执行业务方法之前,需要将 Map 转化成具体的模型对象,然后再执行业务逻辑。
|
||||
|
||||
Dubbo 的泛化调用在服务提供方的转化是通过 Filter 机制统一处理的,服务端并不需要关注消费方采取何种方式进行调用。
|
||||
|
||||
通过泛化调用机制,客户端不再需要依赖服务端的 Jar 包,服务端可以不断地演变,而不会影响客户端已有服务的运行。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。我们这节课主要介绍了 Dubbo 的服务注册与发现、服务调用、网络通信模型、扩展机制还有泛化调用等核心工作机制,了解这些内容可以指导我们更好实践微服务。
|
||||
|
||||
另外,Dubbo 框架算是阿里巴巴开源的所有框架中文档最为齐全的框架了,非常值得我们深入学习与研究。如果你想要进一步掌握 Dubbo,建议你看看Dubbo 官方文档。
|
||||
|
||||
课后题
|
||||
|
||||
我们将在下节课和你一起聊聊 Dubbo 的网关设计方案,其中泛化调用是其理论设计基础,所以我们的第一道课后题就是,请你试着先编写一个 Dubbo 泛化调用的示例。
|
||||
|
||||
提示一下,Dubbo 提供了 dubbo-demo 模块,你可以在官方提供的示例中进行泛化调用编写,节省搭建基础项目的时间。
|
||||
|
||||
请你尝试通过 dubbo-admin 运维管理工具动态修改参数,看看它是否可以动态生效。你知道它背后是如何实现的么?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
222
专栏/中间件核心技术与实战/11案例:如何基于Dubbo进行网关设计?.md
Normal file
222
专栏/中间件核心技术与实战/11案例:如何基于Dubbo进行网关设计?.md
Normal file
@ -0,0 +1,222 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 案例:如何基于Dubbo进行网关设计?
|
||||
你好,我是丁威。
|
||||
|
||||
这节课我们通过一个真实的业务场景来看看 Dubbo 网关(开放平台)的设计要领。
|
||||
|
||||
设计背景
|
||||
|
||||
要设计一个网关,我们首先要知道它的设计背景。
|
||||
|
||||
2017 年,我从传统行业脱身,正式进入物流行业。说来也非常巧,我当时加入的是公司的网关项目组,主要解决泛化调用与协议转换代码的开发问题。刚进公司不久,网关项目组就遇到了技术难题。快递物流行业的业务量可以比肩互联网,从那时候开始,我的传统技术思维开始向互联网技术思维转变。
|
||||
|
||||
当时网关项目组的核心任务就是确保能够快速接入各个电商平台。我来简单说明一下具体的场景。
|
||||
|
||||
|
||||
|
||||
解释一下上面这个图。
|
||||
|
||||
物流公司内部已经基于 Dubbo 构建了订单中心微服务域,其中创建订单接口的定义如下:
|
||||
|
||||
|
||||
|
||||
外部电商平台众多,每一家电商平台内部都有自己的标准,并不会遵循统一的标准。例如在淘宝中,当用户购买商品后,淘宝内部会定义一个统一的订单外派接口。它的请求包可能是这样的:
|
||||
|
||||
{
|
||||
"seller_id":189,
|
||||
"buyer":"dingwei",
|
||||
"order":[
|
||||
{
|
||||
"goods_name":"华为笔记本",
|
||||
"num":1,
|
||||
"price":500000
|
||||
},
|
||||
{
|
||||
"goods_name":"华为手表",
|
||||
"num":1,
|
||||
"price":200000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
但拼多多内部定义的订单外派接口,它的请求包可能是下面这样的:
|
||||
|
||||
<order>
|
||||
<seller_uid>189</seller_uid>
|
||||
<buyer_uid>dingwei</buyer_uid>
|
||||
<order_items>
|
||||
<order_item>
|
||||
<goods_name>华为笔记本</goods_name>
|
||||
<num>1</num>
|
||||
<price>500000</price>
|
||||
</order_item>
|
||||
<order_item>
|
||||
<goods_name>华为手表</goods_name>
|
||||
<num>1</num>
|
||||
<price>200000</price>
|
||||
</order_item>
|
||||
</order_items>
|
||||
</order>
|
||||
|
||||
|
||||
当电商的快递件占据快递公司总业务量的大半时,电商平台的话语权是高于快递公司的。也就是说,电商平台不管下游对接哪家物流公司,都会下发自己公司内部定义的订单派发接口,适配工作需要由物流公司自己来承担。
|
||||
|
||||
那站在物流公司的角度,应该怎么做呢?总不能每接入一个电商平台就为它们开发一套下单服务吧?那样的话,随着越来越多的电商平台接入,系统的复杂度会越来越高,可维护性将越来越差。
|
||||
|
||||
设计方案
|
||||
|
||||
正是在这样的背景下,网关平台被立项开发出来了。这个网关平台是怎么设计的呢?在设计的过程中需要解决哪些常见的问题?
|
||||
|
||||
我认为,网关的设计至少需要包括三个方面,分别是签名验证、服务配置和限流。
|
||||
|
||||
先说签名验证。保证请求的安全是系统设计需要优先考虑的。业界有一种非常经典的通信安全校验机制:验证签名。
|
||||
|
||||
这种机制的做法是,客户端与服务端会首先采用 HTTPS 进行通信,确保传输过程的私密性。
|
||||
|
||||
客户端在发送请求时,先将请求参数按参数名称进行排序,然后按顺序拼接成字符串,格式为 key1=a & key2=b。接下来,客户端使用一个约定的密钥对拼接出来的参数字符串进行签名,生成签名字符串(我们用 sign 表示签名字符串)并追加到 URL。通常,还会在 URL 中追加一个发送时间戳(时间戳不参与签名验证)。
|
||||
|
||||
服务端在接收到客户端的请求后,先从请求中解析出所有的参数,同样按照参数名对参数进行排序,然后使用同样的密钥对参数进行签名。得到的签名字符串需要与客户端计算的签名字符串进行对比,如果两者不同,则请求无效。与此同时,通常我们还需要将服务端当前的时间戳与客户端时间戳进行对比,如果相差超过一定的时间,同样认为请求无效,这个操作主要是为了避免使用同一个连接对网络进行连续攻击。
|
||||
|
||||
这整个过程里有一个非常重要的点,就是密钥自始至终并没有在网络上进行过传播,它的安全性可以得到十足的保证。签名验证的流程大概可以用下面这张图表示:
|
||||
|
||||
|
||||
|
||||
如果要对验证签名进行产品化设计,我们通常需要:
|
||||
|
||||
|
||||
为不同的接入端(电商平台)创建不同的密钥,并通过安全的方式告知他们;
|
||||
|
||||
在确保能够安全通信后,接下来就是网关设计最核心的部分了:服务接口配置化。它主要包括两个要点:微服务调用协议(Dubbo 服务描述)和接口定义与参数映射。
|
||||
|
||||
|
||||
我们先来看一下微服务调用协议的配置,设计的原型界面如下图所示:
|
||||
|
||||
|
||||
|
||||
将所有的微服务(细化到方法级名称)维护到网关系统中,网关应用就可以使用 Dubbo 提供的编程 API,根据这些元信息动态构建一个个消费者(服务调用者),进而通过创建的服务调用客户端发起 RPC 远程调用,最终实现网关应用的 Dubbo 服务调用。
|
||||
|
||||
基于这些元信息构建消费者对象的关键代码如下:
|
||||
|
||||
public static GenericService getInvoker(String serviceInterface, String version, List<String> methods, int retry, String registryAddr ) {
|
||||
ReferenceConfig referenceConfig = new ReferenceConfig();
|
||||
// 关于消费者通用参数,可以从配置文件中获取,本示例取消
|
||||
ConsumerConfig consumerConfig = new ConsumerConfig();
|
||||
consumerConfig.setTimeout(3000);
|
||||
consumerConfig.setRetries(2);
|
||||
referenceConfig.setConsumer(consumerConfig);
|
||||
//应用程序名称
|
||||
ApplicationConfig applicationConfig = new ApplicationConfig();
|
||||
applicationConfig.setName("GateWay");
|
||||
referenceConfig.setApplication(applicationConfig);
|
||||
// 注册中心
|
||||
RegistryConfig registry = new RegistryConfig();
|
||||
registry.setAddress(registryAddr);
|
||||
registry.setProtocol("zookeeper");
|
||||
referenceConfig.setRegistry(registry);
|
||||
// 设置服务接口名称
|
||||
referenceConfig.setInterface(serviceInterface);
|
||||
// 设置服务版本
|
||||
referenceConfig.setVersion(version);
|
||||
referenceConfig.setMethods(new ArrayList<MethodConfig>());
|
||||
for(String method : methods) {
|
||||
MethodConfig methodConfig = new MethodConfig();
|
||||
methodConfig.setName(method);
|
||||
referenceConfig.getMethods().add(methodConfig);
|
||||
}
|
||||
referenceConfig.setGeneric("true");// 开启dubbo的泛化调用
|
||||
return (GenericService) referenceConfig.get();
|
||||
}
|
||||
|
||||
|
||||
通过 getInvoker 方法发起调用远程 RPC 服务,这样,网关应用就成为了对应服务的消费者。
|
||||
|
||||
因为网关应用引入服务规约(API 包)不太现实,所以这里使用的是泛化调用,这样方便网关应用不受约束地构建消费者对象。
|
||||
|
||||
值得注意的是,ReferenceConfig 实例很重,它封装了与注册中心的连接以及所有服务提供者的连接,需要被缓存起来。因此,在真实的生产实践中,我们需要将 ReferenceConfig 对象存储到缓存中。否则,重复生成的 ReferenceConfig 可能造成性能问题并伴随着内存和连接泄漏。
|
||||
|
||||
除了 ReferenceConfig,其实 getInvoker 生成对象也可以进行缓存,缓存的 key 通常为接口名称、版本和注册中心。
|
||||
|
||||
那如果配置信息动态发生了变化,例如需要添加新的服务,这时候网关应用如何做到动态感知呢?我们通常可以用基于 MQ 的方式来解决这个问题。具体的解决方案如下:
|
||||
|
||||
|
||||
|
||||
也就是说,用户如果在网关运营平台上修改原有服务协议(Dubbo 服务)或者添加新的服务协议,变动后的协议会首先存储到数据库中,然后运营平台发送一条消息到 MQ,紧接着 Gateway 的后台进程以广播模式进行订阅。这样,所有后台网关进程都可以感知。
|
||||
|
||||
如果是对已有服务协议进行修改,在具体实践时有一个小细节请你一定注意。我们先看看这段代码:
|
||||
|
||||
Map<String /* 缓存key */,GenericService> invokerCache;
|
||||
GenericService newInvoker = getInvoker(...);//参数省略
|
||||
GenericService oldInvoker = invokerCache.get(key);
|
||||
invokerCache.put(newInvoker);//先缓存新的invoker
|
||||
// 然后再销毁旧的invoker对象
|
||||
oldInvoker.destory();
|
||||
|
||||
|
||||
如果已经存在对应的 Invoker 对象,为了不影响现有调用,应该先用新的 Invoker 对象去更新缓存,然后再销毁旧的 Invoker 对象。
|
||||
|
||||
上面的方法解决了网关调用公司内部的 Dubbo 微服务问题,但还有另外一个非常重要的问题,怎么配置服务接口相关参数呢?
|
||||
|
||||
联系这节课前面的场景,我们需要在页面上配置公司内部 Dubbo 服务与外部电商的接口映射。
|
||||
|
||||
|
||||
|
||||
为此,我们专门建立了一条参数映射协议:
|
||||
|
||||
|
||||
|
||||
参数映射设计的说明如下。
|
||||
|
||||
|
||||
请求类型:主要分为请求参数与响应参数;
|
||||
|
||||
字段名称:Dubbo 服务对应的字段名称;
|
||||
|
||||
字段类型:Dubbo 服务对应字段的属性;
|
||||
|
||||
字段所属类:Dubbo 服务对应字段所属类型;
|
||||
|
||||
节点名称:外部请求接口对应的字段名称;
|
||||
|
||||
显示顺序:排序字段。
|
||||
|
||||
|
||||
由于网关采取了泛化调用,在编写转换代码时,主要是遍历传入的参数,根据每一个字段查询对应的转换规则,然后转换为 Map,返回值则刚好相反,是将 Map 转换为 XML 或者 JSON。
|
||||
|
||||
在真正请求调用时,根据映射规则构建出请求参数 Map 后,通过 Dubbo 的泛化调用执行真正的调用:
|
||||
|
||||
GenericService genericService = (GenericService) invokeBean;
|
||||
Map invokerPams;//省略转换过程
|
||||
// 参数类型数组
|
||||
String[] paramTypes = new String[1];
|
||||
paramTypes[0]="java.util.Map";
|
||||
// 参数值数组
|
||||
Object[] paramValues = new Object[1];
|
||||
|
||||
invokerPams.put("class", "net.codingw.oms.vo.OrderItemVo");
|
||||
paramValues[0] = invokerPams;
|
||||
//由于我们已经转化为java.util.Map,并且Map中,需要有一个key为class的,表示服务端需要转化的类型,这个从协议转换器中获取
|
||||
Object result = genericService.$invoke(this.getInvokeMethod(), paramTypes, paramValues);
|
||||
|
||||
|
||||
这样,网关就具备了高扩展性和稳定性,可以非常灵活地支撑业务的扩展,为不同的电商平台配置不同的参数转换,从而在内部只需要开发一套接口就可以非常灵活地支撑业务的扩展,基本做到网关代码零修改。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我通过一个真实的场景,详细介绍了网关设计的需求背景,然后针对网关设计的痛点给出了设计方案。通过对这个方案中关键代码的解读,你应该能够更加深刻地理解 Dubbo 泛化调用背后的逻辑,真正做到理论与实际相结合。
|
||||
|
||||
值得注意的是,我们这节课提到的转换协议也是一绝,它使用中括号来定义多层嵌套结构,使得该协议具有普适性。
|
||||
|
||||
课后题
|
||||
|
||||
检测对知识的掌握程度最好的方式是自己写出来。所以,我建议你将我们这节课所讲的方案落到实处,尝试自己实现一个 demo 级的网关设计。
|
||||
|
||||
如果你想听听我的意见,可以提交一个 [GitHub]的 push 请求或 issues,并把对应地址贴到留言里。我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
354
专栏/中间件核心技术与实战/12案例:如何实现蓝绿发布?.md
Normal file
354
专栏/中间件核心技术与实战/12案例:如何实现蓝绿发布?.md
Normal file
@ -0,0 +1,354 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 案例:如何实现蓝绿发布?
|
||||
你好,我是丁威。
|
||||
|
||||
前面,我们讲了服务的注册与发现机制,它是微服务体系的基石,这节课,我想聊聊微服务的另外一个重要课题:服务治理。
|
||||
|
||||
随着微服务应用的不断增加,各个微服务之间的依赖关系也变得比较复杂,各个微服务的更新、升级部署给整个服务域的稳定性带来很大挑战。怎么以不停机的方式部署升级微服务呢?
|
||||
|
||||
这就是我们这节课的任务,我们来看看如何在生产环境用蓝绿发布来满足不停机升级的要求。
|
||||
|
||||
设计背景
|
||||
|
||||
在进行技术方案的设计之前,我们先来了解一下生产环境的基本部署情况,如下图所示:
|
||||
|
||||
|
||||
|
||||
用户在面向用户端(下文通称 C 端)下单后,C 端订单系统需要远程调用订单域中的“创建订单“接口。同时,订单域、运单域相关服务都需要调用基础服务域,进行基础数据的查询服务。
|
||||
|
||||
从这里也可以看出,基础服务的稳定运行对整个微服务体系至关重要。那如何确保基础服务域不受版本的影响,始终能够提供稳定可控的服务呢?
|
||||
|
||||
设计方案
|
||||
|
||||
我们公司为了解决这个问题实现了蓝绿发布。那什么是蓝绿发布呢?
|
||||
|
||||
蓝绿发布指的是在蓝、绿两套环境中分别运行项目的两个版本的代码。但是在进行版本发布时只更新其中一个环境,这样方便另一个环境快速回滚。
|
||||
|
||||
接下来我们看一下蓝绿发布的基本流程。
|
||||
|
||||
如果系统采取蓝绿发布,在下一个版本(base-service v1.2.0)发布之前,会这样部署架构:
|
||||
|
||||
|
||||
|
||||
当前订单域调用流量进入基础服务域 GREEN 环境。团队计划在 12:00 发布新版本(base-service v1.2.0),这时我们通常会执行下面几个操作。
|
||||
|
||||
|
||||
将新版本 1.2.0 全部发布在 BLUE 环境上。因为此时 BLUE 环境没有任何流量,对运行中的系统无任何影响。
|
||||
|
||||
在请求入口对流量进行切分。通常可以按照百分比分配流量,待系统运行良好后,再逐步将流量全部切换到新版本。
|
||||
|
||||
如果发现新版本存在严重问题,可以将流量全部切换到原来的环境,实现版本快速回滚。
|
||||
|
||||
|
||||
这个过程可以用下面这张图表示:
|
||||
|
||||
|
||||
|
||||
这个思路听起来很简单,但是怎么实现呢?
|
||||
|
||||
这就不得不提到上节课专门提到的路由选择(Router)了,它是 Dubbo 服务调用中非常重要的一步。路由选择的核心思想是在客户端进行负载均衡之前,通过一定的过滤规则,只在服务提供者列表中选择符合条件的提供者。
|
||||
|
||||
我们再看上面的实例图,从订单域消费者的视角,服务提供者列表大概是下面这个样子:
|
||||
|
||||
|
||||
|
||||
然后呢,我们按照比例对入口流量进行分流。例如,80% 的请求颜色为 BLUE,20% 的请求颜色为 GREEN。那些颜色为 BLUE 的请求,在真正执行 RPC 服务调用时,只从服务提供者列表中选择“color=BLUE”的服务提供者。同样,颜色为 GREEN 的请求只选择“color=GREEN”的服务提供者,这就实现了流量切分。
|
||||
|
||||
具体的操作是,在 Dubbo 中为这个场景引入 Tag 路由机制。
|
||||
|
||||
首先,服务提供者在启动时需要通过“-Dubbo.provider.tag”系统参数来设置服务提供者所属的标签。
|
||||
|
||||
例如,在 192.168.3.100 和 192.168.3.101 这两台机器上启动 base-service 程序时,需要添加“-Dubbo.provider.tag=BLUE”系统参数;而在 192.168.4.100 和 192.168.4.101 这两台机器上启动 base-service 程序时,则要添加“-Dubbo.provider.tag=GREEN”系统参数,通过这个操作完成对服务提供者的打标。服务提供者启动后,生成的服务提供者 URL 连接如下所示:
|
||||
|
||||
dubbo://192.168.3.100:20880/net.codingw.demo.BaseUser?dubbo.tag=BLUE
|
||||
|
||||
|
||||
下一步,在服务入口对流量进行染色,从而实现流量切分。
|
||||
|
||||
蓝绿发布的流量通常是在流量入口处进行染色的。例如,我们可以使用随机加权来实现流量切分算法,用它对流量进行染色,具体示范代码如下:
|
||||
|
||||
public static String selectColor(String[] colorArr, int[] weightArr) {
|
||||
int length = colorArr.length;
|
||||
boolean sameWeight = true;
|
||||
int totalWeight = 0;
|
||||
for (int i = 0; i < length; i++) {
|
||||
int weight = weightArr[i];
|
||||
totalWeight += weight;
|
||||
if (sameWeight && totalWeight != weight * (i + 1)) {
|
||||
sameWeight = false;
|
||||
}
|
||||
}
|
||||
if (totalWeight > 0 && !sameWeight) {
|
||||
int offset = ThreadLocalRandom.current().nextInt(totalWeight);
|
||||
System.out.println("offset:" + offset);
|
||||
for (int i = 0; i < length; i++) {
|
||||
if (offset < weightArr[i]) {
|
||||
return colorArr[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return colorArr[ThreadLocalRandom.current().nextInt(length)];
|
||||
}
|
||||
|
||||
//测试代码
|
||||
public static void main(String[] args) {
|
||||
String[] colorArr = new String[]{"GREEN","BLUE"};
|
||||
int[] weightArr = new int[] {100,50};
|
||||
for(int i = 0; i < 20; i ++) {
|
||||
System.out.println(selectColor(colorArr, weightArr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
根据流量切分算法计算得到流量标识后,怎么在消费端跟进流量标识从而进行路由选择呢?我们通常会将染色标记放在 ThreadLocal 中,然后再编写 Filter,获取或者传递路由标签。
|
||||
|
||||
但这个只是一个流量的切分算法,那如何动态设置蓝绿的比例或者说权重呢?其实,我们可以为发布系统提供一个设置权重的页面,用户设置完权重后写入到配置中心 (ZooKeeper、Apollo),然后应用程序动态感知到变化,利用最新的权重进行流量切分。
|
||||
|
||||
通过流量切分算法计算出一个请求的流量标识后,通常会存储在 ThreadLocal 中,实现代码如下:
|
||||
|
||||
public class ThreadLocalContext {
|
||||
private static final ThreadLocal<String> tagContext = new ThreadLocal<>();
|
||||
|
||||
public static void setTag(String tag) {
|
||||
tagContext.set(tag);
|
||||
}
|
||||
|
||||
public static String getTag() {
|
||||
return tagContext.get();
|
||||
}
|
||||
|
||||
public static void resetTag() {
|
||||
tagContext.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//在整个请求的入口
|
||||
String color = selectColor(colorArr, weightArr);
|
||||
try {
|
||||
ThreadLocalContext.setTag(color);
|
||||
|
||||
//执行第一个远程调用
|
||||
invokeRpc1();
|
||||
|
||||
//执行另外一个远程调用
|
||||
invokeRpc2();
|
||||
|
||||
} finally {
|
||||
ThreadLocalContext.reset();
|
||||
}
|
||||
|
||||
|
||||
将请求的流量标识存储到线程本地变量之后,还需要将流量标识附加到 RPC 请求调用中,这样才能触发正确的路由选择,具体代码示例如下:
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.dubbo.common.extension.Activate;
|
||||
import org.apache.dubbo.common.logger.Logger;
|
||||
import org.apache.dubbo.common.logger.LoggerFactory;
|
||||
import org.apache.dubbo.rpc.*;
|
||||
import org.apache.dubbo.rpc.cluster.router.tag.TagRouter;
|
||||
|
||||
import static org.apache.dubbo.common.constants.CommonConstants.CONSUMER;
|
||||
import static org.apache.dubbo.rpc.Constants.ACCESS_LOG_KEY;
|
||||
|
||||
@Activate(group = CONSUMER, value = "tagConsumerFilter")
|
||||
public class TagConsumerContextFilter implements Filter {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TagConsumerContextFilter.class);
|
||||
|
||||
@Override
|
||||
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
|
||||
try {
|
||||
String tag = ThreadLocalContext.getTag();
|
||||
if(StringUtils.isNotEmpty(tag)) {
|
||||
invocation.setAttachment(TagRouter.NAME, tag );
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
logger.warn("Exception in TagConsumerContextFilter of service(" + invoker + " -> " + invocation + ")", t);
|
||||
}
|
||||
|
||||
// 调用链传递
|
||||
return invoker.invoke(invocation);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这样在 RPC 调用的过程中,服务调用者就能根据本地线程变量中存储的流量标记,选择不同机房的服务提供者,从而实现蓝绿发布了。
|
||||
|
||||
同时,在实际生产环境中,一个调用链条中往往会存在多个 RPC 调用,那第一个 RPC 中的路由标签能自动传递到第二个 RPC 调用吗?
|
||||
|
||||
|
||||
|
||||
答案是不可以,我们需要再写一个服务端生效的 Filter,示例代码如下:
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.dubbo.common.logger.Logger;
|
||||
import org.apache.dubbo.common.logger.LoggerFactory;
|
||||
import org.apache.dubbo.common.extension.Activate;
|
||||
import org.apache.dubbo.rpc.*;
|
||||
import org.apache.dubbo.rpc.cluster.router.tag.TagRouter;
|
||||
|
||||
import static org.apache.dubbo.common.constants.CommonConstants.PROVIDER;
|
||||
|
||||
@Activate(group = PROVIDER, value = "tagProviderFilter")
|
||||
public class TagProviderContextFilter implements Filter {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TagProviderContextFilter.class);
|
||||
|
||||
@Override
|
||||
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
|
||||
try {
|
||||
String tag = invocation.getAttachment(TagRouter.NAME);
|
||||
if(StringUtils.isNotEmpty(tag)) {
|
||||
ThreadLocalContext.setTag(tag);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
logger.warn("Exception in TagProviderContextFilter of service(" + invoker + " -> " + invocation + ")", t);
|
||||
}
|
||||
// 调用链传递
|
||||
return invoker.invoke(invocation);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
也就是将调用链中的 tag 存储到服务端的线程本地上下文环境中,当服务端调用其他服务时,可以继续将 tag 传递到下一个 RPC 调用链中。
|
||||
|
||||
这样,我们的蓝绿发布就基本完成了。但这里还有一个问题。规模较大的公司的生产环境往往会运行很多微服务,我们无法将蓝绿机制一下引入到所有微服务当中,必然会存在一部分应用使用蓝绿发布,但其他应用没有使用蓝绿的情况。怎么做到兼容呢?
|
||||
|
||||
比方说,我们公司目前核心业务域的蓝绿部署情况如下:
|
||||
|
||||
|
||||
|
||||
这里,订单域接入了蓝绿发布;C 端应用需要调用订单域相关接口,因此也接入了蓝绿发布;但运单中心并未接入蓝绿发布。这时候,运单中心能调用订单域的服务吗?
|
||||
|
||||
要回答这个问题,我们要先看看 Dubbo 官方的降级策略。
|
||||
|
||||
|
||||
如果消费者侧设置了标签,那么如果集群中没有对应标签的服务提供者,默认可以选择不带任何标签的服务提供者进行服务调用。该行为可以通过设置 request.tag.force=true 来禁止,这就是说如果 request.tag.force 为 true,一旦没有对应标签的服务提供者,就会跑出“No Provider”异常。
|
||||
|
||||
如果消费者侧没有设置标签,那就只能向集群中没有设置标签的服务提供者发起请求,如果不存在没有标签的服务提供者,则报“No Provider”异常。
|
||||
|
||||
|
||||
回到上面的问题,运单中心由于未接入蓝绿发布,所以不带任何标签,它无法调用订单域的服务。为了解决这个问题,订单域还需要部署一些不带标签的服务。订单域最终的部署大概如下图所示:
|
||||
|
||||
|
||||
|
||||
也就是说,订单域为了兼容那些还没接入蓝绿发布的应用需要部署 3 套环境,一套为不设置标签的服务提供者,一套为蓝颜色的服务提供者,另一套为绿颜色的服务提供者。
|
||||
|
||||
蓝绿发布实践就介绍到这里了,在这节课的最后,我们再来学习一下蓝绿发布底层依托的原理。
|
||||
|
||||
实现原理
|
||||
|
||||
先来看一下 Dubbo 服务调用的基本时序图:
|
||||
|
||||
|
||||
|
||||
我建议你按照这张时序图跟踪一下源码,更加详细地了解 Dubbo 服务调用的核心流程与实现关键点,我在这里总结了几个要点:
|
||||
|
||||
|
||||
Dubbo 的服务调用支持容错,对应的抽象类为 AbstractClusterInvoker,它封装了服务调用的基本流程。Dubbo 内置了 failover、failfast、failsafe、failback、forking 等失败容错策略,每一个策略对应 AbstractClusterInvoker 的一个实现;
|
||||
|
||||
在调用 AbstractClusterInvoker 服务的时候,首先需要获取所有的服务提供者列表,这个过程我们称之为服务动态发现(具体实现类为 DynamicDirectory)。在获取路由信息之前,需要调用 RouterChain 的 route 方法,执行路由选择策略,筛选出服务动态发现的服务提供者列表。我们这一课的重点,标签路由的具体实现类 TagRouter 就是在这里发挥作用的。
|
||||
|
||||
|
||||
我们也详细拆解一下 TagRouter 的 route 方法。因为这个方法的实现代码比较多,我们还是分步讲解。
|
||||
|
||||
第一步,执行静态路由过滤机制,代码如下:
|
||||
|
||||
final TagRouterRule tagRouterRuleCopy = tagRouterRule;
|
||||
if (tagRouterRuleCopy == null || !tagRouterRuleCopy.isValid() || !tagRouterRuleCopy.isEnabled()) {
|
||||
return filterUsingStaticTag(invokers, url, invocation);
|
||||
}
|
||||
|
||||
|
||||
如果路由规则为空,则根据 tag 进行过滤。我们顺便也看一下基于 tag 的静态过滤机制是如何实现的:
|
||||
|
||||
private <T> List<Invoker<T>> filterUsingStaticTag(List<Invoker<T>> invokers, URL url, Invocation invocation) {
|
||||
List<Invoker<T>> result;
|
||||
String tag = StringUtils.isEmpty(invocation.getAttachment(TAG_KEY)) ? url.getParameter(TAG_KEY)
|
||||
:invocation.getAttachment(TAG_KEY)
|
||||
if (!StringUtils.isEmpty(tag)) {
|
||||
result = filterInvoker(invokers, invoker -> tag.equals(invoker.getUrl().getParameter(TAG_KEY)));
|
||||
if (CollectionUtils.isEmpty(result) && !isForceUseTag(invocation)) {
|
||||
result = filterInvoker(invokers, invoker ->
|
||||
StringUtils.isEmpty(invoker.getUrl().getParameter(TAG_KEY)));
|
||||
}
|
||||
} else {
|
||||
result = filterInvoker(invokers, invoker ->
|
||||
StringUtils.isEmpty(invoker.getUrl().getParameter(TAG_KEY)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
尝试从 Invocation(服务调用上下文)中或者从 URL 中获取 tag 的值,根据 tag 是否为空,执行两种不同的策略:
|
||||
|
||||
|
||||
如果 tag 不为空,首先按照 tag 找到服务提供者列表中打了同样标签的服务提供者列表,如果 dubbo.force.tag 的设置为 false,则查找服务提供者列表,筛查出没有打标签的服务提供者列表。
|
||||
|
||||
如果 tag 为空,则直接查找没有打标签的服务提供者列表。
|
||||
|
||||
|
||||
我们继续回到 TagRouter 的 route 方法。第二步操作是,按照路由规则进行筛选,具体代码如下:
|
||||
|
||||
// if we are requesting for a Provider with a specific tag
|
||||
if (StringUtils.isNotEmpty(tag)) {
|
||||
List<String> addresses = tagRouterRuleCopy.getTagnameToAddresses().get(tag);
|
||||
if (CollectionUtils.isNotEmpty(addresses)) {
|
||||
result = filterInvoker(invokers, invoker -> addressMatches(invoker.getUrl(), addresses));
|
||||
if (CollectionUtils.isNotEmpty(result) || tagRouterRuleCopy.isForce()) {
|
||||
return result;
|
||||
}
|
||||
} else {
|
||||
result = filterInvoker(invokers, invoker -> tag.equals(invoker.getUrl().getParameter(TAG_KEY)));
|
||||
}
|
||||
if (CollectionUtils.isNotEmpty(result) || isForceUseTag(invocation)) {
|
||||
return result;
|
||||
} else {
|
||||
List<Invoker<T>> tmp = filterInvoker(invokers, invoker -> addressNotMatches(invoker.getUrl(),
|
||||
tagRouterRuleCopy.getAddresses()));
|
||||
return filterInvoker(tmp, invoker -> StringUtils.isEmpty(invoker.getUrl().getParameter(TAG_KEY)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
上面这段代码比较简单,它的过滤思路和静态 tag 过滤是相似的。不同点是,这里可以通过 YAML 格式配置单个服务的路由规则。具体的配置格式如下:
|
||||
|
||||
force: true
|
||||
enabled: true
|
||||
priority: 1
|
||||
key: demo-provider(服务名称)
|
||||
tags:
|
||||
- name: tag1
|
||||
addresses: [ip1, ip2]
|
||||
- name: tag2
|
||||
addresses: [ip3, ip4]
|
||||
|
||||
|
||||
这些数据都会记录在注册中心,并在发生变化后实时通知 TagRouter,从而实现路由规则的动态配置。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。刚才,我们从微服务不停机发布这个需求谈起,引出了蓝绿发布机制。
|
||||
|
||||
蓝绿发布的实现要点是对应用分别部署蓝、绿两套环境,在版本稳定后由一套环境对外提供服务,当需要发布新版本时,将新版本一次性部署到没有流量的环境,待部署成功后再逐步将流量切换到新版本。如果新版本在验证阶段遇到严重的问题,可以直接将流量切回老版本,实现应用发布的快速回滚。
|
||||
|
||||
然后,我们借助蓝绿发布的指导思想,一步一步实现了基于 Dubbo 的蓝绿发布。
|
||||
|
||||
蓝绿发布的底层原理是借助 Dubbo 内置的标签路由功能,其核心思路是,当服务发起调用时,经过服务发现得到一个服务提供者列表,但是并不直接使用这些服务提供者进行负载均衡,而是在进行负载均衡之前,先按照路由规则对这些提供者进行过滤,挑选符合路由规则的服务提供者列表进行服务调用,从而实现服务的动态分组。
|
||||
|
||||
课后题
|
||||
|
||||
最后,我还是照例给你留一道思考题。
|
||||
|
||||
你认为蓝绿发布和灰度发布的共同点是什么,这两者又有什么区别?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
161
专栏/中间件核心技术与实战/13技术选型:如何根据应用场景选择合适的消息中间件?.md
Normal file
161
专栏/中间件核心技术与实战/13技术选型:如何根据应用场景选择合适的消息中间件?.md
Normal file
@ -0,0 +1,161 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 技术选型:如何根据应用场景选择合适的消息中间件?
|
||||
你好,我是丁威。
|
||||
|
||||
随着微服务技术的兴起,消息中间件也成为了分布式架构体系的必备组件,所以从这节课开始,我们一起来学习消息中间件。
|
||||
|
||||
我们的课程还是会将理论和实践相结合,将重点落在实战。
|
||||
|
||||
我会分别介绍消息中间件的应用场景与技术选型、两种消息中间件(Kafka 和 RocketMQ)分别是如何实现高性能的。紧接着,我会结合自己的工作经验,带你看看消息中间件如何实现蓝绿发布、如何提升 RocketMQ 顺序消费能力;最后,我们会一起认识消息中间件优雅的生产环境运维能力,搞清如何排查消息发送、消息消费相关的故障。
|
||||
|
||||
我们这节课主要来看消息中间件应用场景与技术选型。
|
||||
|
||||
消息中间件的应用场景
|
||||
|
||||
消息中间件的应用场景主要有两个:异步解耦与削峰填谷。
|
||||
|
||||
我们首先通过电商平台用户注册送积分、送优惠券这个场景来理解异步解耦合。如果不使用消息中间件,电商平台送积分的实现也许是下图这个样子:
|
||||
|
||||
|
||||
|
||||
我们简单看一下这个流程。
|
||||
|
||||
|
||||
用户在网站前端注册页面填写相关信息,然后调用账号中心服务,注册账号。
|
||||
|
||||
账户中心首先执行用户注册逻辑处理(例如判断用户是否已注册、是否符合注册条件等),然后写入到数据库。
|
||||
|
||||
注册成功后,需要调用积分中心(赠送积分接口)给用户送积分。
|
||||
|
||||
送完积分后,再调用优惠券相关接口,为用户赠送优惠券。
|
||||
|
||||
成功送完积分、优惠券后,向用户返回“注册成功”
|
||||
|
||||
|
||||
从架构角度看,上面这个实现方法有一个非常严重的问题,那就是可扩展性低。
|
||||
|
||||
例如,如果要在春节期间调整活动策略,在发送积分的同时,还需要额外发送新春大礼包,开发人员为了实现这一功能,就不得不修改用户注册流程,并重新部署用户注册模块。
|
||||
|
||||
从功能维度来看,这次需求的变更集中在活动相关的内容。用户注册本身的逻辑并未发生变化,但由于用户注册逻辑与活动模块存在耦合,两个模块必须一起调整和发布,这就对系统稳定性造成了影响。
|
||||
|
||||
另外,调用积分、优惠券两个远程 RPC 请求让用户注册主流程变长,在高并发场景下,用户注册这一环容易成为系统瓶颈。
|
||||
|
||||
要解决上面这两个明显的设计缺陷,常用的方案是引入消息中间件,让用户注册主流程和商家活动异步解耦合。改造后的时序图如下:
|
||||
|
||||
|
||||
|
||||
账户中心完成用户注册相关逻辑后,会向 MQ 发送一条消息到 MQ 服务器,然后就直接给用户返回“注册成功”。赠送优惠券、积分等与活动相关的需求我们可以异步执行,这样,无论后续互动逻辑发生什么变化,账户中心都不需要发布新版本。
|
||||
|
||||
引入送积分服务(MQ 消费者应用)和送优惠券服务(MQ 消费者应用)会订阅消息,并根据消息调用积分中心、优惠券中心的服务。如果后续活动发生变化,例如取消送积分活动但开始赠送新春大礼包,那我们只需停止送积分服务应用,增加送新春大礼包的消费者应用,就可以真正做到对新增开放,对修改关闭。
|
||||
|
||||
消息中间件的另外一个常用场景是削峰填谷。我们来看一个外卖骑手送餐的场景。它的设计架构图如下:
|
||||
|
||||
|
||||
|
||||
我们分别说明一下“创建订单流程”和“查询订单信息”两个流程,探究一下这个方案的精髓。
|
||||
|
||||
先来看创建订单流程。
|
||||
|
||||
|
||||
用户在 App 中下单,App 会调用网关相关接口创建订单,网关接收到请求后,并不是直接调用内部商户订单中心来创建订单接口,而是先发送一条消息到 MQ。
|
||||
|
||||
商户接单模块(Consumer)订阅 MQ 中的消息,处理消息的时候调用内部商户订单中心创建订单接口,创建一条真正的订单数据到数据库。
|
||||
|
||||
创建订单后,商户订单中心将再发送一条消息到 MQ 服务器。然后骑手分配模块(Consumer)订阅消息,调用派单服务相关接口,引导骑手进行外卖配送。
|
||||
|
||||
同时,数据同步组件(Canal)将数据库中的数据准实时同步到 Es 服务器。
|
||||
|
||||
|
||||
为什么网关不直接调用外部的创建订单接口,而是将数据先写入到 MQ 中呢?
|
||||
|
||||
我们不妨设想一下,商户订单中心支持的最大并发为 1w/tps。如果某一个业务高峰期,从网关进入的流量突然飙升到 1.5w/tps,而且持续了 10 分钟,商户订单系统会直接崩溃,造成服务不可用等严重故障!
|
||||
|
||||
那该如何解决呢?
|
||||
|
||||
有人可能会说,我们可以使用限流机制保护商户订单系统。例如,我们只允许 9000TPS 的流量从网关进入到商户订单中心,直接拒绝多余的流量,让客户端重试。这确实可以解决问题,但会带来经济损失和糟糕的用户体验。
|
||||
|
||||
这个时候我们有一个更加友好的解决方案:引入消息中间件。
|
||||
|
||||
引入消息中间件的目的是让它来扛住海量流量,流量先进入到消息队列中,然后消费端下游系统可以慢慢消费消息中间件中的数据,这样能有效保护下游系统不被瞬时的流量击破。这种方案可能带来的最坏结果就是,消费这些消息会存在延迟。但这些订单都可以成功创建,真正的交易行为已经产生了。接下来要做的就是根据实际情况扩容或者缩容,尽快将积压的数据处理掉。
|
||||
|
||||
不过我们这个时候引入消息中间件,其实潜台词是它们的性能必须满足下面几个基本要求:高吞吐量、低延迟,还要具体消息堆积能力。
|
||||
|
||||
我们再看一下订单查询流程:
|
||||
|
||||
|
||||
用户在 App 端发起订单查询,App 会调用网关的订单查询接口,网关再将请求转发到内部的订单查询服务;
|
||||
|
||||
订单查询服务不是在 MySQL 数据库,而是直接查询 Es 中的数据。
|
||||
|
||||
|
||||
这里一个设计的亮点是,引入了数据同步组件 Canal,将 MySQL 数据库中的数据实时同步到了 Es。这样查询订单时只查 Es 就可以了,实现了订单写入与订单查询在异构数据源的读写分离。
|
||||
|
||||
消息中间件的技术选型
|
||||
|
||||
在这节课的最后,我们来看看如何选择消息中间件。
|
||||
|
||||
目前消息中间件领域主要的中间件包括 RocketMQ、Kafka 和 RabbitMQ,我们先来看一下这张功能对比图:
|
||||
|
||||
|
||||
|
||||
结合上面这张图,我们再对比分析一下。
|
||||
|
||||
首先,我认为功能级别不具备一票否决权。
|
||||
|
||||
例如,RabbitMQ 支持优先级队列,而 RocketMQ、Kafka 不支持,那么如果我们的项目中有优先级队列的使用诉求,我们就必须将 Kafka、RocketMQ 排除掉,选择使用 RabbitMQ 吗?我是不建议这样做的,任何涉及到功能的短板,都可以通过其他方式实现。
|
||||
|
||||
但我也并不是说功能特性就一点都不重要。这一点我在后面讨论 RocketMQ 与 Kafka 的选型时会再次谈到。
|
||||
|
||||
其次,我认为在选型时要特别注意中间件的性能和扩展性。
|
||||
|
||||
因为随着业务不断地发展,性能问题会越来越突出,而且性能问题都具有隐蔽性,一旦发生,破坏性大,影响程度深,让人防不胜防。
|
||||
|
||||
例如,RabbitMQ 的消息堆积能力不强,一旦消费端无法及时将消息处理掉,会极大影响消息服务器发送消息的性能。这一点是非常致命的,因为引入消息中间件的目的就是抵挡住洪峰流量,如果消息中间件因为积压问题影响了消息的发送,那是万万不可取的。
|
||||
|
||||
因此,从性能的角度来看,RocketMQ 和 Kafka 比 RabbitMQ 的表现更好。
|
||||
|
||||
另外一个重要的因素也不得不加以考虑,那就是中间件使用的编程语言。
|
||||
|
||||
在使用中间件时一般都会遇到很多问题,一个非常行之有效的方法就是深入研究源码。这时候,如果中间件的编写语言和团队技术栈不匹配,将会极大地增加深入研究这款中间件的难度。如果团队对中间件的掌控能力很弱,自然很难保持中间件的稳定运行。
|
||||
|
||||
在进行具体的选型时,我们可以结合自己团队的实际情况。
|
||||
|
||||
|
||||
如果公司或团队的技术栈以 Golang 为主,建议选择 RabbitMQ,RabbitMQ 在性能上的缺陷可以通过搭建多套集群加以规避。
|
||||
|
||||
如果公司或团队的技术栈以 Java 为主,我建议使用 Kafka 或 RocketMQ。RocketMQ 和 Kafka 都是性能优秀的中间件,在这两者之间进行选择时可以更多地关注功能特性。RocketMQ 提供了消息重试、消息过滤、消息轨迹、消息检索等功能特性,特别是 RocketMQ 的消息检索功能,因此 RocketMQ 很适合核心业务场景。而 kafka 更加擅长于日志、大数据计算、流式计算等场景。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。
|
||||
|
||||
刚才,我们结合案例学习了消息中间件的两大经典使用场景:异步解耦与削峰填谷。最后重点阐述了消息中间件的选型问题。
|
||||
|
||||
在选择消息中间件时,需要格外注意以下三点:
|
||||
|
||||
|
||||
功能级别不具备一票否决权;
|
||||
|
||||
选型时要特别注意中间件的性能与扩展性;
|
||||
|
||||
需要注重团队技术栈与中间件编程语言的匹配度。
|
||||
|
||||
|
||||
在这三点之上,我们就可以根据实际情况选择一款适合自己团队的消息中间件了。
|
||||
|
||||
课后题
|
||||
|
||||
最后,我还是照例给你留一道思考题。
|
||||
|
||||
刚才我们说异步解耦是消息中间件的常见使用场景。在电商注册送积分这个场景中,引入消息中间件能在活动需求不断变化的同时,保证用户注册主流程的稳定性。但你知道这会带来哪些问题吗?我们又该如何解决它们呢?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
149
专栏/中间件核心技术与实战/14性能之道:RocketMQ与Kafka高性能设计对比.md
Normal file
149
专栏/中间件核心技术与实战/14性能之道:RocketMQ与Kafka高性能设计对比.md
Normal file
@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 性能之道:RocketMQ与Kafka高性能设计对比
|
||||
你好,我是丁威。
|
||||
|
||||
RocketMQ 和 Kafka 是当下最主流的两款消息中间件,我们这节课就从文件布局、数据写入方式、消息发送客户端这三个维度对比一下实现 kafka 和 RocketMQ 的差异,通过这种方式学习高性能编程设计的相关知识。
|
||||
|
||||
文件布局
|
||||
|
||||
我们首先来看一下 Kafka 与 RocketMQ 的文件布局。
|
||||
|
||||
Kafka 的文件存储设计在宏观上的布局如下图所示:
|
||||
|
||||
|
||||
|
||||
我们解析一下它的主要特征。
|
||||
|
||||
|
||||
文件的组织方式是“ topic + 分区”,每一个 topic 可以创建多个分区,每一个分区包含单独的文件夹。
|
||||
|
||||
分区支持副本机制,即一个分区可以在多台机器上复制数据。topic 中每一个分区会有 Leader 与 Follow。Kafka 的内部机制可以保证 topic 某一个分区的 Leader 与 Follow 不在同一台机器上,并且每一台 Broker 会尽量均衡地承担各个分区的 Leade。当然,在运行过程中如果 Leader 不均衡,也可以执行命令进行手动平衡。
|
||||
|
||||
Leader 节点承担一个分区的读写,Follow 节点只负责数据备份。
|
||||
|
||||
|
||||
Kafka 的负载均衡主要取决于分区 Leader 节点的分布情况。分区的 Leader 节点负责读写,而从节点负责数据同步,如果 Leader 分区所在的 Broker 节点宕机,会触发主从节点的切换,在剩下的 Follow 节点中选举一个新的 Leader 节点。这时数据的流入流程如下图所示:
|
||||
|
||||
|
||||
|
||||
分区 Leader 收到客户端的消息发送请求后,可以有两种数据返回策略。一种是将数据写入到 Leader 节点后就返回,还有一种是等到它的从节点全部写入完成后再返回。这个策略选择非常关键,会直接影响消息发送端的时延,所以 Kafka 提供了 ack 这个参数来进行策略选择:
|
||||
|
||||
|
||||
当 ack = 0 时,不等 Broker 端确认就直接返回,即客户端将消息发送到网络中就返回“发送成功”;
|
||||
|
||||
当 ack = 1 时,Leader 节点接受并存储消息后立即向客户端返回“成功”;
|
||||
|
||||
当 ack = -1 时,Leader 节点和所有的 Follow 节点接受并成功存储消息,再向客户端返回“成功”。
|
||||
|
||||
|
||||
我们再来看一下 RocketMQ 的文件布局:
|
||||
|
||||
|
||||
|
||||
RocketMQ 所有主题的消息都会写入到 commitlog 文件中,然后基于 commitlog 文件构建消息消费队列文件(Consumequeue),消息消费队列的组织结构按照 /topic/{queue} 来组织。从集群的视角来看如下图所示:
|
||||
|
||||
|
||||
|
||||
RocketMQ 默认采取的是主从同步架构,即 Master-Slave 方式,其中 Master 节点负责读写,Slave 节点负责数据同步与消费。
|
||||
|
||||
值得注意的是,RocketMQ4.5 引入了多副本机制,RocketMQ 的副本机制与 kafka 的多副本两者之间的不同点是 RocketMQ 的副本维度是 Commitlog 文件,而 kafka 是主题分区级别。
|
||||
|
||||
我们来看看 Kafka 和 RocketMQ 在文件布局上的异同。
|
||||
|
||||
Kafka 中文件的布局是以 Topic/partition 为主 ,每一个分区拥有一个物理文件夹,Kafka 在分区级别实现文件顺序写。如果一个 Kafka 集群中有成百上千个主题,每一个主题又有上百个分区,消息在高并发写入时,IO 操作就会显得很零散,效果相当于随机 IO。也就是说,Kafka 在消息写入时的 IO 性能,会随着 topic 、分区数量的增长先上升,后下降。
|
||||
|
||||
而 RocketMQ 在消息写入时追求极致的顺序写,所有的消息不分主题一律顺序写入 commitlog 文件, topic 和 分区数量的增加不会影响写入顺序。
|
||||
|
||||
根据我的实践经验,当磁盘是 SSD 时,采用同样的配置,Kafka 的吞吐量要超过 RocketMQ,我认为这里的主要原因是单文件顺序写入很难充分发挥磁盘 IO 的性能。
|
||||
|
||||
除了在磁盘顺序写方面的差别,Kafka 和 RocketMQ 的运维成本也不同。由于粒度的原因,Kafka 的 topic 扩容分区会涉及分区在各个 Broker 的移动,它的扩容操作比较重。而 RocketMQ 的数据存储主要基于 commitlog 文件,扩容时不会产生数据移动,只会对新的数据产生影响。因此,RocketMQ 的运维成本相对 Kafka 更低。
|
||||
|
||||
不过,Kafka 和 RocketMQ 也有一些共同点。Kafka 的 ack 参数可以类比 RocketMQ 的同步复制、异步复制。
|
||||
|
||||
|
||||
Kafka 的“ack 参数 =1”时,对标 RocketMQ 的异步复制,有数据丢失的风险;
|
||||
|
||||
kafka 的“ack 参数 =-1”时,对标 RocketMQ 的同步复制;
|
||||
|
||||
Kafka 的“ack 参数 =0”时,对标 RocketMQ 消息发送方式的 oneway 模式,适合日志采集场景。
|
||||
|
||||
|
||||
在业务领域通常是不容许数据丢失的。但如果这些数据容易重推,就可以使用 ack=1,而不使用 ack=-1,因为 ack=-1 时的性能较低。
|
||||
|
||||
例如,我们在公司开发数据同步中间件时,都是基于数据库 Binlog 日志同步到 Es、MySQL、Oracle 等目标端,由于同步任务支持回溯,故通常将 ack 设置为 1。
|
||||
|
||||
数据写入方式
|
||||
|
||||
聊完数据文件布局,我们再来看一下 Kafka、和 RocketMQ 的服务端是如何处理数据写入的。
|
||||
|
||||
我们还是先来看 Kafka。
|
||||
|
||||
Kafka 服务端处理消息写入的代码定义在 MemoryRecords 的 writeTo 方法中,具体代码截图如下(具体是调用入口 LogSegment 的 append 方法):
|
||||
|
||||
|
||||
|
||||
Kafka 服务端写入消息时,主要是调用 FileChannel 的 transferTo 方法,该方法底层使用了操作系统的 sendfile 系统调用。
|
||||
|
||||
而 RocketMQ 的消息写入支持内存映射与 FileChannel 两种写入方式,如下图所示:
|
||||
|
||||
|
||||
|
||||
也就是说,如果将参数 tranisentStorePoolEnable 设置为 false,那就先将消息写入到页缓存,然后根据刷盘机制持久化到磁盘中。如果将参数设置为 true,数据会先写入到堆外内存,然后批量提交到 FileChannel,并最终根据刷盘策略将数据持久化到磁盘中。
|
||||
|
||||
值得注意的是,RocketMQ 与 Kafka 都支持通过 FileChannel 方式写入,但 RocketMQ 基于 FileChannel 写入时,调用的 API 并不是 transferTo,而是先调用 writer,然后定时 flush 刷写到磁盘,具体调用入口为 MappedFile。代码截图如下:
|
||||
|
||||
|
||||
|
||||
直接调用 FileChannel 的 transferTo 方法比 write 方法性能更优,因为 transferTo 底层使用了操作系统的 sendfile 系统调用,能充分发挥块设备的优势。
|
||||
|
||||
根据我的实践经验,sendfile 系统调用相比内存映射多了一个从用户缓存区拷贝到内核缓存区的步骤,但当内存写入超过 64K 时, sendfile 的性能往往更高,故 Kafka 在服务端的写入比 RocketMQ 会有更好的表现。
|
||||
|
||||
消息发送
|
||||
|
||||
最后我们再从客户端消息发送这个角度看一下两款中间件的差异。
|
||||
|
||||
Kafka 消息发送客户端采用的是双端队列,还引入了批处理思想,它的消息发送机制如下图所示:
|
||||
|
||||
|
||||
|
||||
当客户端想要调用 Kafka 的消息发送者发送消息时,消息会首先存入到一个双端队列中,双端队列中单个元素为 ProducerBatch,表示一个发送批次,其最大值受参数 batch.size 控制,默认为 16K。
|
||||
|
||||
然后,Kafka 客户端会单独开一个 Send 线程,从双端队列中获取发送批次,将消息按批发送到 Kafka 集群中。Kafka 还引入了 linger.ms 参数来控制 Send 线程的发送行为,代表批次要在双端队列中等待的最小时长。
|
||||
|
||||
如果将 linger.ms 设置为 0,表示立即发送消息;如果将参数设置为大于 0,那么发送线程在发送消息时只会从双端队列中获取等待时长大于该值的批次。 注意,linger.ms 参数会延长响应时间,但有利于增加吞吐量。有点类似于 TCP 领域的 Nagle 算法。
|
||||
|
||||
Kafka 的消息发送,在写入 ProducerBatch 时会按照消息存储协议组织数据,在服务端可以直接写入到文件中。
|
||||
|
||||
RocketMQ 的消息发送在客户端主要是根据路由选择算法选择一个队列,然后将消息发送到服务端。消息会在服务端按照消息的存储格式进行组织,然后进行持久化等操作。
|
||||
|
||||
Kafka 相比 RocketMQ 有一个非常大的优势,那就是它的消息格式是在客户端组装的,这就节约了 Broker 端的 CPU 压力,这两款中间件在架构方式上的差异有点类似 ShardingJDBC 与 MyCat 的区别。
|
||||
|
||||
Kafka 在消息发送端的另外一个特点就是,引入了双端缓存队列。可以看出,Kafka 的设计始终在追求批处理,这能够提高消息发送的吞吐量,但与之相对的问题是,消息的响应时间延长了,消息丢失的可能性也加大(因为 Kafka 追加到消息缓存后会返回“成功”,但是如果消息发送方异常退出,会导致消息丢失)。
|
||||
|
||||
我们可以将 Kafka 中 linger.ms=0 的情况类比 RocketMQ 消息发送的效果。但 Kafka 通过调整 batch.size 与 linger.ms 两个参数来适应不同场景,这种方式比 RocketMQ 更为灵活。例如,日志集群通常会调大 batch.size 与 linger.ms 参数,充分发挥消息批量发送带来的优势,提高吞吐量;但如果有些场景对响应时间比较敏感,就可以适当调低 linger.ms 的值。
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。刚才,我们从文件布局、服务端数据写入方式、客户端消息发送方式三个维度,对比了 Kafka 和 RocketMQ 各自在追求高性能时所采用的技术。综合对比来看,在同等硬件配置一下,Kafka 的综合性能要比 RocketMQ 更为强劲。
|
||||
|
||||
RocketMQ 和 Kafka 都使用了顺序写机制,但相比 Kafka,RocketMQ 在消息写入时追求极致的顺序写,会在同一时刻将消息全部写入一个文件,这显然无法压榨磁盘的性能。而 Kafka 是分区级别顺序写,在分区数量不多的情况下,从所有分区的视角来看是随机写,但这能重复发挥 CPU 的多核优势。因此,在磁盘没有遇到瓶颈时,Kafka 的性能要优于 RocketMQ。
|
||||
|
||||
同时,Kafka 在服务端写入时使用了 FileChannel 的 transferTo 方法,底层使用 sendfile 系统调用,比普通的 FileChannel 的 write 方法更有优势。结合压测效果来看,如果待写入的消息体大小超过 64K,使用 sendfile 的块写入方式甚至比内存映射拥有更好的性能。
|
||||
|
||||
在消息发送方面,Kafka 的客户端则充分利用了批处理思想,比 RocketMQ 拥有更高的吞吐率。
|
||||
|
||||
课后题
|
||||
|
||||
最后,我还是给你留一道思考题。
|
||||
|
||||
通过了解 RocketMQ 和 Kafka 的实现机制,我们知道 RocketMQ 还有很大的进步空间。你认为应该如何优化 RocketMQ?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
210
专栏/中间件核心技术与实战/15案例:消息中间件如何实现蓝绿?.md
Normal file
210
专栏/中间件核心技术与实战/15案例:消息中间件如何实现蓝绿?.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 案例:消息中间件如何实现蓝绿?
|
||||
你好,我是丁威。
|
||||
|
||||
我们这节课结合一个真实的生产环境案例,来看看消息中间件如何实现蓝绿发布。我们会提到消息中间件的设计背景和隔离机制,在此基础上探究基于消息属性和消息主题分别如何实现蓝绿发布。
|
||||
|
||||
设计背景
|
||||
|
||||
消息中间件在分布式架构体系中的应用非常广泛,要想实现蓝绿发布,只在微服务调用层面实现还远远不够。
|
||||
|
||||
在进行具体的方案设计之前,我们还是先来看一下我们这个项目中消息中间件的部署情况:
|
||||
|
||||
|
||||
|
||||
这里有四个应用,简单解释一下。
|
||||
|
||||
|
||||
应用 1 支持蓝绿发布,并且处理完业务后,需要向消息中间件中的 topic_A 主题发送消息。
|
||||
|
||||
应用 2 不支持蓝绿发布,但同样需要在处理完业务后,向消息中间件中的 topic_A 发送消息。
|
||||
|
||||
应用 3 不支持蓝绿发布,需要处理完业务逻辑后,向消息中间件中的主题 topic-B 发送消息。
|
||||
|
||||
应用 4 中创建了两个消费组,其中 consumer_group_a 订阅 topicA,支持接入蓝绿;而 consumer_group_b 没有接入蓝绿。
|
||||
|
||||
|
||||
这就是在设计蓝绿发布方案之前,我们这个项目的现状。
|
||||
|
||||
消息中间件隔离机制
|
||||
|
||||
那么怎么基于这一条件来设计和实施蓝绿方案呢?这又涉及到一个隔离机制的问题。因为无论是蓝绿发布还是全链路压测,需要着重解决的一个问题就是消息的隔离性。蓝绿发布的本质就是对消息进行分类,蓝颜色的消息只能被蓝颜色的消费者消费,绿颜色的消息只能被绿颜色的消费者消费。
|
||||
|
||||
消息中间件领域通常有“基于消息主题”和“基于消息属性”两种隔离机制。我们先来看第一种隔离机制,基于消息主题的物理隔离机制:
|
||||
|
||||
|
||||
|
||||
基于主题的隔离机制在消息服务端是分开存储的,属于物理层面的隔离。在消息消费端,由于应用使用不同的消费组进行消费,每一个消费组在物理层面也是互不影响的,每一个消费组有独立的线程池、消费进度等。
|
||||
|
||||
消息中间件中的另外一种隔离机制是基于消息属性的。例如,蓝绿两种颜色的消息使用的是同一个主题,但我们可以在消息中添加一个属性,标识这条消息的颜色。其存储示意图如下:
|
||||
|
||||
|
||||
|
||||
这样,不同属性的消息就可以共用一个主题了。消息发送端在发送消息时,会为消息设置相应的属性,将它存储到消息的属性中。然后单个消费端应用会创建蓝绿两个消费组,都订阅同一个主题。消费组拉取到消息后,需要先解码找到对应的消息属性,蓝颜色消费者只真正处理属性为 BLUE 的消息,那些属性为 GREEN 的消息会默认向服务端返回“消费成功”。这样就在客户端实现了消息过滤机制。
|
||||
|
||||
目前主流消息中间件的隔离机制都是基于消息属性的。在消息发送端为消息指定属性的示例代码如下:
|
||||
|
||||
//RocketMQ示例
|
||||
DefaultMQProducer producer = new DefaultMQProducer("dw_test_mq_producer_group");
|
||||
//这里省略producer对象的初始化代码
|
||||
Message msg = new Message("TOPIC_A", "Hello Topic A".getBytes());
|
||||
//设置用户定义的扩展属性,这里是RocketMQ提供的消息属性扩展机制
|
||||
msg.putUserProperty("color", "BLUE");
|
||||
producer.send( msg);
|
||||
|
||||
//Kafka示例
|
||||
//kafka的生产者构建代码省略
|
||||
Map<String, String> producerConfig = new HashMap<>();
|
||||
KafkaProducer kafkaProducer = new KafkaProducer(producerConfig);
|
||||
List<RecordHeader> recordHeaders = new ArrayList<>();
|
||||
RecordHeader colorHeader = new RecordHeader("color", "GREEN".getBytes());
|
||||
recordHeaders.add(colorHeader);
|
||||
ProducerRecord record = new ProducerRecord("TOPIC_A", 0, null, "Hello Topic A".getBytes(),
|
||||
recordHeaders.iterator());
|
||||
kafkaProducer.send(record);
|
||||
|
||||
|
||||
接下来我会基于这两种隔离机制分别给出蓝绿发布的设计方案。
|
||||
|
||||
基于消息属性的蓝绿设计方案
|
||||
|
||||
我们这个方案是基于 RocketMQ 展开的,Kafka 的设计方案类似。所以如果你使用的是 Kafka,完全可以进行知识迁移。
|
||||
|
||||
基于消息属性的隔离机制的一个显著的特点是,蓝绿消息使用的是同一个主题。因此我们需要在不同环境的生产者发送消息时,为消息设置不同的颜色。
|
||||
|
||||
和在微服务领域实现蓝绿发布一样,我们通过系统参数为应用设置所属环境:
|
||||
|
||||
|
||||
|
||||
通常每一家公司都会有一个统一的开发框架,会基于目前主流的 RocketMQ、Kafka 客户端进行封装,或者使用类似 rocketmq-spring 这样的开源类库。为了防止对业务代码进行侵入,通常会采用拦截器机制,拦截消息发送 API,然后在拦截器中根据系统参数,为消息设置对应的属性。从系统参数中获取颜色值的示例代码如下:
|
||||
|
||||
private static final String COLOR_SYS_PROP = "color";
|
||||
private static final String COLOR_ENV = System.getProperty(COLOR_SYS_PROP, "");
|
||||
|
||||
|
||||
当不同环境的消息发送者将消息发送到消息服务器后,消费端就要按颜色将消费分开了。
|
||||
|
||||
虽然消费端的隔离机制是通过不同的消费组来实现的,每一个消费组拥有自己独立的消费者线程池、消费进度,组与组之间互不影响。但是消费端不能简单粗暴地用系统参数来区分消费组的颜色,因为一个应用中可能存在多个消费组,这些消费组并不都开启了蓝绿机制。
|
||||
|
||||
所以基于消费组的蓝绿定义,首先需要在消费者的元信息中定义。例如,我们公司在申请消费组时,可以根据环境为消费组设置是否启用蓝绿机制。如下图所示:
|
||||
|
||||
|
||||
|
||||
蓝绿发布状态可选择:蓝、绿、所有。这里的“所有”表示消费组未开启蓝绿,选择“蓝”或“绿”都表示消费组开启蓝绿。
|
||||
|
||||
消费组是如何进行消息过滤的呢?我们来看下部署示意图:
|
||||
|
||||
|
||||
|
||||
我们看应用 3 会部署在蓝、绿两个环境,但是在原始的镜头项目代码中我们只会定义一个基本的消费组,例如 dw_test_consumer_group,蓝绿发布要求我们这套代码用不同的系统属性定义后,就能分别实现消息的过滤。
|
||||
|
||||
例如,我们在代码中定义一个消费组,示例代码如下(这段代码来源于中通快递开源的消息中间件运维平台,封装了 Kafka/RocketMQ 的消息发送与消息消费、可视化监控与告警):
|
||||
|
||||
public void testSubscribe() {
|
||||
Zms.subscribe("dw_test_consumer_group", new MessageListener() {
|
||||
@Override
|
||||
public MsgConsumedStatus onMessage(ConsumeMessage msg) {
|
||||
System.out.println(new String(msg.getPayload()));
|
||||
return MsgConsumedStatus.SUCCEED;
|
||||
}
|
||||
});
|
||||
try {
|
||||
Thread.sleep(1000 * 1000 * 1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
那我们如何动态开启蓝绿发布机制呢?我总结了下面两个实现要点。
|
||||
|
||||
|
||||
应用启动时,首先获取系统参数 color 的值(如果有设置),并根据设置的值改写原消费组的名称。如果 color 的值为 BLUE,那我们在调用 RocketMQ 底层 DefaultMqPushConsumer 时,传入的消费组名称为 _BLUE_dw_test_consumer_group;如果 color 的值为 GREEN,那最终会创建的消费组名称就是 _GREEN_dw_test_consumer_group。
|
||||
|
||||
消费者启动后开始处理消费,在真正调用用户定义的消息业务处理器(MessageListener)之前,需要将消息进行解码,然后提取消息属性中 color 的值,用 mqProColor 表示,如果 mqProColor 的值与系统参数 color 中的值相等,就调用用户定义的消息业务处理器。否则就认为消费成功,直接给 MQ 服务器返回“成功”,相当于跳过这条消息的处理。
|
||||
|
||||
|
||||
这么乍一看,蓝颜色的消费者消费 color=BLUE 的消息,绿颜色的消费者消费 color=GREEN 的消息,这不是很“完美”地解决了蓝绿发布的问题了吗?
|
||||
|
||||
事实不是这样的。因为 topic 中发送的消息有可能不带颜色,例如应用 -1 需要发送消息到 TOPIC_A 中, 这个应用接入了蓝绿,会发送蓝色或者绿颜色的消息。但应用 -2 没有接入蓝绿,所以应用 -2 发送的消息是不包含颜色的。按照上面的方案,这部分消息将无法被消费,最终结果就是:消息丢失。
|
||||
|
||||
那怎么解决消息消费丢失的问题呢?我们可以在消费组元信息中定义不带颜色的消息由哪个环境来消费。
|
||||
|
||||
我在公司实践时,消费者的蓝绿发布状态有下面三个值。
|
||||
|
||||
|
||||
所有: 表示该消费组未接入蓝绿。
|
||||
|
||||
蓝:表示该消费组接入蓝绿,并且消息属性中未带颜色的消息由蓝环境的消费者进行消费。
|
||||
|
||||
绿:表示该消费组接入蓝绿,并且消息属性中未带颜色的消息由绿环境的消费者进行消费。
|
||||
|
||||
|
||||
这样定义了之后,应用启动时,如果消费者的蓝绿状态为蓝,我们会同时启动两个消费组,一个消费组为 _BLUE_dw_test_consumer_group,用来专门消费蓝颜色的消费者;另外一个消费组为 dw_test_consumer_group,用来消费不带颜色的消息。蓝环境的应用在启动时只会创建一个消费组,那就是 _GREEN_dw_test_consumer_group。
|
||||
|
||||
同时,我们还支持在蓝绿之间进行切换。如果将消费组的蓝绿状态由 BLUE 变为 GREEN,我们会将原本在蓝环境的 dw_test_consumer_group 关闭,然后在绿环境中新增一个 dw_test_consumer_group 消费组。这样,我们就在消息中间件层面实现了蓝绿发布。
|
||||
|
||||
基于消息主题的蓝绿设计方案
|
||||
|
||||
不过,基于消息属性的蓝绿发布机制存在一个比较严重的问题,那就是一旦开启了蓝绿发布,一份消息就会被多次拉取,这无形中增加了消息服务器的读取请求。示意图如下:
|
||||
|
||||
|
||||
|
||||
原本代码中只声明了一个消费组 dw_test_consumer_group,但我们引入蓝绿发布机制之后,会创建三个消费组,读取流量是原来的三倍,这会给服务端带来较大压力。
|
||||
|
||||
造成读流量放大的主要原因是,蓝绿消息在物理存储上并未实现真正隔离,仍然需要在消费端进行过滤。既然如此,如果我们在发送消息的时候就对消息进行隔离,是不是可以避免这种情况?
|
||||
|
||||
这就要说到另外一种蓝绿设计方案了,它使用的是基于主题的消息隔离机制。
|
||||
|
||||
这种机制在发送消息时,就根据发送者所在的环境将消息发送到不同的主题中。示意图如下:
|
||||
|
||||
|
||||
|
||||
在代码层面,要在发送端改变消息发送的主题名称非常简单。只需要拦截消息发送方法,根据系统变量 color 的值改写主题的名称就可以了。但是在实践过程中,我们还要避免发送方法的嵌套调用,避免主题名称在一次发送过程中多次被改写,所以在改写主题名称之前,我们还要对代码进行判断:
|
||||
|
||||
public static String renameTopicName(String topicName) {
|
||||
String color = System.getProperty("color", "");
|
||||
if("BLUE".equals(color) && !topicName.startsWith("_BLUE_")) {
|
||||
return "_BLUE_" + topicName;
|
||||
} else if("GREEN".equals(color) && !topicName.startsWith("GREEN")) {
|
||||
return "GREEN" + topicName;
|
||||
}
|
||||
return topicName;
|
||||
}
|
||||
|
||||
|
||||
之后,消费端的隔离机制仍然是为不同的环境创建不同的消费组:
|
||||
|
||||
|
||||
|
||||
这样,每一个消费组就只会拉取符合条件的消息。因为所有的消息拉取都是有效拉取,所以基于消息隔离而产生的弊端就解决了。
|
||||
|
||||
总结
|
||||
|
||||
我们这节课首先结合消息中间件在生产环境的部署情况,引出了蓝绿设计需要解决的具体问题,然后介绍了实现蓝绿的两种方案。
|
||||
|
||||
我认为,实现蓝绿的关键其实最终都落在了“如何有效隔离消息”这个问题上。
|
||||
|
||||
基于消息属性的隔离,是在发送端使用一个主题,在每一条消息中添加一个属性 color 来存储消息的颜色,而消费端采取不同的消费组来消费消息。其中,蓝颜色的消息由蓝消费组消费,绿颜色的消息由绿消费组消费,没有颜色的消息由默认消费组来消费。这本质上是在消费端将数据从服务端全量拉取下来,然后在消费端进行了一层过滤,各个消费组都会读取到很多无效数据,无形中放大了拉取消息的调用次数。
|
||||
|
||||
而基于主题的隔离机制,是在消息发送时就将消息分别发送到不同的主题中,在消费端对各个消费组进行分工。蓝颜色的消费组只订阅蓝颜色主题,绿颜色的消费者只订阅绿颜色的主题,这就实现了有针对性的消费,效率更高。
|
||||
|
||||
课后题
|
||||
|
||||
学完今天的内容,请你思考下面两个问题。
|
||||
|
||||
|
||||
基于消息属性的蓝绿发布机制,支持从“蓝”或“绿”切换到“所有”吗?也就是说,如果原本消费组开启了蓝绿发布,现在又想抛弃蓝绿发布,能不能行呢?这样做存在什么问题?
|
||||
|
||||
基于主题的过滤机制可以避免读流量的放大,但这个方案也不是完美的,你认为基于主题来实现蓝绿发布存在什么问题?哪些场景适合使用基于主题的蓝绿发布?
|
||||
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
405
专栏/中间件核心技术与实战/16案例:如何提升RocketMQ顺序消费性能?.md
Normal file
405
专栏/中间件核心技术与实战/16案例:如何提升RocketMQ顺序消费性能?.md
Normal file
@ -0,0 +1,405 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 案例:如何提升RocketMQ顺序消费性能?
|
||||
你好,我是丁威。
|
||||
|
||||
在课程正式开始之前,我想先分享一段我的经历。我记得 2020 年双十一的时候,公司订单中心有一个业务出现了很大程度的延迟。我们的系统为了根据订单状态的变更进行对应的业务处理,使用了 RocketMQ 的顺序消费。但是经过排查,我们发现每一个队列都积压了上千万条消息。
|
||||
|
||||
当时为了解决这个问题,我们首先决定快速扩容消费者。因为当时主题的总队列为 64 个,所以我们一口气将消费者扩容到了 64 台。但上千万条消息毕竟还是太多了。还有其他办法能够加快消息的消费速度吗?比较尴尬的是,没有,我们当时能做的只有等待。
|
||||
|
||||
作为公司消息中间件的负责人,在故障发生时没有其他其他补救手段确实比较无奈。事后,我对顺序消费模型进行了反思与改善。接下来,我想和你介绍我是如何优化 RocketMQ 的顺序消费性能的。
|
||||
|
||||
RocketMQ 顺序消费实现原理
|
||||
|
||||
我们先来了解一下 RocketMQ 顺序消费的实现原理。RocketMQ 支持局部顺序消息消费,可以保证同一个消费队列上的消息顺序消费。例如,消息发送者向主题为 ORDER_TOPIC 的 4 个队列共发送 12 条消息, RocketMQ 可以保证 1、4、8 这三条按顺序消费,但无法保证消息 4 和消息 2 的先后顺序。
|
||||
|
||||
|
||||
|
||||
那 RocketMQ 是怎么做到分区顺序消费的呢?我们可以看一下它的工作机制:
|
||||
|
||||
|
||||
|
||||
顺序消费实现的核心要点可以细分为三个阶段。
|
||||
|
||||
第一阶段:消费队列负载。
|
||||
|
||||
RebalanceService 线程启动后,会以 20s 的频率计算每一个消费组的队列负载、当前消费者的消费队列集合(用 newAssignQueueSet 表),然后与上一次分配结果(用 oldAssignQueueSet 表示)进行对比。这时候会出现两种情况。
|
||||
|
||||
|
||||
如果一个队列在 newAssignQueueSet 中,但并不在 oldAssignQueueSet 中,表示这是新分配的队列。这时候我们可以尝试向 Broker 申请锁:
|
||||
|
||||
|
||||
如果成功获取锁,则为该队列创建拉取任务并放入到 PullMessageService 的 pullRequestQueue 中,以此唤醒 Pull 线程,触发消息拉取流程;
|
||||
如果未获取锁,说明该队列当前被其他消费者锁定,放弃本次拉取,等下次重平衡时再尝试申请锁。
|
||||
|
||||
|
||||
|
||||
这种情况下,消费者能够拉取消息的前提条件是,在 Broker 上加锁成功。
|
||||
|
||||
|
||||
如果一个队列在 newAssignQueueSet 中不存在,但存在于 oldAssignQueueSet 中,表示该队列应该分配给其他消费者,需要将该队列丢弃。但在丢弃之前,要尝试申请 ProceeQueue 的锁:
|
||||
|
||||
|
||||
如果成功锁定 ProceeQueue,说明 ProceeQueue 中的消息已消费,可以将该 ProceeQueue 丢弃,并释放锁;
|
||||
如果未能成功锁定 ProceeQueue,说明该队列中的消息还在消费,暂时不丢弃 ProceeQueue,这时消费者并不会释放 Broker 中申请的锁,其他消费者也就暂时无法消费该队列中的消息。
|
||||
|
||||
|
||||
|
||||
这样,消费者在经历队列重平衡之后,就会创建拉取任务,并驱动 Pull 线程进入到消息拉取流程。
|
||||
|
||||
第二阶段:消息拉取。
|
||||
|
||||
PullMessageService 线程启动,从 pullRequestQueue 中获取拉取任务。如果该队列中没有待拉取任务,则 Pull 线程会阻塞,等待 RebalanceImpl 线程创建拉取任务,并向 Broker 发起消息拉取请求:
|
||||
|
||||
|
||||
如果未拉取到消息。可能是 Tag 过滤的原因,被过滤的消息其实也可以算成被成功消费了。所以如果此时处理队列中没有待消费的消息,就提交位点(当前已拉取到最大位点 +1),同时再将拉取请求放到待拉取任务的末尾,反复拉取,实现 Push 模式。
|
||||
|
||||
如果拉取到一批消息。首先要将拉取到的消息放入 ProceeQueue(TreeMap),同时将消息提交到消费线程池,进入消息消费流程。再将拉取请求放到待拉取任务的末尾,反复拉取,实现 Push 模式。
|
||||
|
||||
|
||||
第三阶段:顺序消费。
|
||||
|
||||
RocketMQ 一次只会拉取一个队列中的消息,然后将其提交到线程池。为了保证顺序消费,RocketMQ 在消费过程中有下面几个关键点:
|
||||
|
||||
|
||||
申请 MessageQueue 锁,确保在同一时间,一个队列中只有一个线程能处理队列中的消息,未获取锁的线程阻塞等待。
|
||||
|
||||
获取 MessageQueue 锁后,从处理队列中依次拉取一批消息(消息偏移量从小到大),保证消费时严格遵循消息存储顺序。
|
||||
|
||||
申请 MessageQueue 对应的 ProcessQueue,申请成功后调用业务监听器,执行相应的业务逻辑。
|
||||
|
||||
|
||||
经过上面三个关键步骤,RocketMQ 就可以实现队列(Kafka 中称为分区)级别的顺序消费了。
|
||||
|
||||
RocketMQ 顺序消费设计缺陷
|
||||
|
||||
回顾上面 RocketMQ 实现顺序消费的核心关键词,我们发现其实就是加锁、加锁、加锁。没错,为了实现顺序消费,RocketMQ 需要进行三次加锁:
|
||||
|
||||
|
||||
进行队列负载平衡后,对新分配的队列,并不能立即进行消息拉取,必须先在 Broker 端获取队列的锁;
|
||||
|
||||
消费端在正式消费数据之前,需要锁定 MessageQueue 和 ProceeQueue。
|
||||
|
||||
|
||||
上述三把锁的控制,让并发度受到了队列数量的限制。在互联网、高并发编程领域,通常是“谈锁色变”,锁几乎成为了性能低下的代名词。试图减少锁的使用、缩小锁的范围几乎是性能优化的主要手段。
|
||||
|
||||
RocketMQ 顺序消费优化方案
|
||||
|
||||
而 RocketMQ 为了实现顺序消费引入了三把锁,极大地降低了并发性能。那如何对其进行优化呢?
|
||||
|
||||
破局思路:关联顺序性
|
||||
|
||||
我们不妨来看一个金融行业的真实业务场景:银行账户余额变更短信通知。
|
||||
|
||||
当用户的账户余额发生变更时,金融机构需要发送一条短信,告知用户余额变更情况。为了实现余额变更和发送短信的解耦,架构设计时通常会引入消息中间件,它的基本实现思路你可以参考这张图:
|
||||
|
||||
|
||||
|
||||
基于 RocketMQ 的顺序消费机制,我们可以实现基于队列的顺序消费,在消息发送时只需要确保同一个账号的多条消息(多次余额变更通知)发送到同一个队列,消费端使用顺序消费,就可以保证同一个账号的多次余额变更短信不会顺序错乱。
|
||||
|
||||
q0 队列中依次发送了账号 ID 为 1、3、5、3、9 的 5 条消息,这些消息将严格按照顺序执行。但是,我们为账号 1 和账号 3 发送余额变更短信,时间顺序必须和实际的时间顺序保持一致吗?
|
||||
|
||||
答案是显而易见的,没有这个必要。
|
||||
|
||||
例如,用户 1 在 10:00:01 发生了一笔电商订单扣款,而用户 2 在 10:00:02 同样发生了一笔电商订单扣款,那银行先发短信告知用户 2 余额发生变更,然后再通知用户 1,并没有破坏业务规则。
|
||||
|
||||
不过要注意的是,同一个用户的两次余额变更,必须按照发生顺序来通知,这就是所谓的关联顺序性。
|
||||
|
||||
显然,RocketMQ 顺序消费模型并没有做到关联顺序性。针对这个问题,我们可以看到一条清晰的优化路线:并发执行同一个队列中不同账号的消息,串行执行同一个队列中相同账号的消息。
|
||||
|
||||
RocketMQ 顺序模型优化
|
||||
|
||||
基于关联顺序性的整体指导思路,我设计出了一种顺序消费改进模型:
|
||||
|
||||
|
||||
|
||||
详细说明一下。
|
||||
|
||||
|
||||
消息拉取线程(PullMeessageService)从 Broker 端拉取一批消息。
|
||||
|
||||
遍历消息,获取消息的 Key(消息发送者在发送消息时根据 Key 选择队列,同一个 Key 的消息进入同一个队列)的 HashCode 和线程数量,将消息投递到对应的线程。
|
||||
|
||||
消息进入到某一个消费线程中,排队单线程执行消费,遵循严格的消费顺序。
|
||||
|
||||
|
||||
为了让你更加直观地体会两种设计的优劣,我们来看一下两种模式针对一批消息的消费行为对比:
|
||||
|
||||
|
||||
|
||||
在这里,方案一是 RocketMQ 内置的顺序消费模型。实际执行过程中,线程三、线程四也会处理消息,但内部线程在处理消息之前必须获取队列锁,所以说同一时刻一个队列只会有一个线程真正存在消费动作。
|
||||
|
||||
方案二是优化后的顺序消费模型,它和方案一相比最大的优势是并发度更高。
|
||||
|
||||
方案一的并发度取决于消费者分配的队列数,单个消费者的消费并发度并不会随着线程数的增加而升高,而方案二的并发度与消息队列数无关,消费者线程池的线程数量越高,并发度也就越高。
|
||||
|
||||
代码实现
|
||||
|
||||
在实际生产过程中,再好看的架构方案如果不能以较为简单的方式落地,那就等于零,相当于什么都没干。
|
||||
|
||||
所以我们就尝试落地这个方案。接下来我们基于 RocketMQ4.6 版本的 DefaultLitePullConsumer 类,引入新的线程模型,实现新的 Push 模式。
|
||||
|
||||
为了方便你阅读代码,我们先详细看看各个类的职责(类图)与运转主流程(时序图)。
|
||||
|
||||
类图设计
|
||||
|
||||
|
||||
|
||||
|
||||
DefaultMQLitePushConsumer
|
||||
|
||||
|
||||
基于 DefaultMQLitePullCOnsumer 实现的 Push 模式,它的内部对线程模型进行了优化,对标 DefaultMQPushConsumer。
|
||||
|
||||
|
||||
ConsumeMessageQueueService
|
||||
|
||||
|
||||
消息消费队列消费服务类接口,只定义了 void execute(List< MessageExt > msg) 方法,是基于 MessageQueue 消费的抽象。
|
||||
|
||||
|
||||
AbstractConsumeMessageService
|
||||
|
||||
|
||||
消息消费队列服务抽象类,定义一个抽象方法 selectTaskQueue 来进行消息的路由策略,同时实现最小位点机制,拥有两个实现类:
|
||||
|
||||
|
||||
顺序消费模型(ConsumeMessageQueueOrderlyService),消息路由时按照 Key 的哈希与线程数取模;
|
||||
|
||||
并发消费模型(ConsumerMessageQueueConcurrentlyService),消息路由时使用默认的轮循机制选择线程。
|
||||
|
||||
|
||||
|
||||
AbstractConsumerTask定义消息消费的流程,同样有两个实现类,分别是并发消费模型(ConcurrentlyConsumerTask) 和顺序消费模型(OrderlyConsumerTask)。
|
||||
|
||||
|
||||
定义消息消费的流程,同样有两个实现类,分别是并发消费模型(ConcurrentlyConsumerTask) 和顺序消费模型(OrderlyConsumerTask)。
|
||||
|
||||
时序图
|
||||
|
||||
类图只能简单介绍各个类的职责,接下来,我们用时序图勾画出核心的设计要点:
|
||||
|
||||
|
||||
|
||||
这里,我主要解读一下与顺序消费优化模型相关的核心流程:
|
||||
|
||||
|
||||
调用 DefaultMQLitePushConsumer 的 start 方法后,会依次启动 Pull 线程(消息拉取线程)、消费组线程池、消息处理队列与消费处理任务。这里的重点是,一个 AbstractConsumerTask 代表一个消费线程,一个 AbstractConsumerTask 关联一个任务队列,消息在按照 Key 路由后会放入指定的任务队列,从而被指定线程处理。
|
||||
|
||||
Pull 线程每拉取一批消息,就按照 MessageQueue 提交到对应的 AbstractConsumeMessageService。
|
||||
|
||||
AbstractConsumeMessageService 会根据顺序消费、并发消费模式选择不同的路由算法。其中,顺序消费模型会将消息 Key 的哈希值与任务队列的总个数取模,将消息放入到对应的任务队列中。
|
||||
|
||||
每一个任务队列对应一个消费线程,执行 AbstractConsumerTask 的 run 方法,将从对应的任务队列中按消息的到达顺序执行业务消费逻辑。
|
||||
|
||||
AbstractConsumerTask 每消费一条或一批消息,都会提交消费位点,提交处理队列中最小的位点。
|
||||
|
||||
|
||||
关键代码解读
|
||||
|
||||
类图与时序图已经强调了顺序消费模型的几个关键点,接下来我们结合代码看看具体的实现技巧。
|
||||
|
||||
创建消费线程池
|
||||
|
||||
创建消费线程池部分是我们这个方案的点睛之笔,它对应的是第三小节顺序消费改进模型图中用虚线勾画出的线程池。为了方便你回顾,我把这个图粘贴在下面。
|
||||
|
||||
|
||||
|
||||
代码实现如下所示:
|
||||
|
||||
// 启动消费组线程池
|
||||
private void startConsumerThreads() {
|
||||
//设置线程的名称
|
||||
String threadPrefix = isOrderConsumerModel ? "OrderlyConsumerThreadMessage_" : "ConcurrentlyConsumerThreadMessage_";
|
||||
AtomicInteger threadNumIndex = new AtomicInteger(0);
|
||||
//创建消费线程池
|
||||
consumerThreadGroup = new ThreadPoolExecutor(consumerThreadCount, consumerThreadCount, 0, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), r -> {
|
||||
Thread t = new Thread(r);
|
||||
t.setName(threadPrefix + threadNumIndex.incrementAndGet() );
|
||||
return t;
|
||||
});
|
||||
//创建任务阻塞线程数组
|
||||
msgByKeyBlockQueue = new ArrayList(consumerThreadCount);
|
||||
consumerRunningTasks = new ArrayList<>(consumerThreadCount);
|
||||
for(int i =0; i < consumerThreadCount; i ++ ) {
|
||||
msgByKeyBlockQueue.add(new LinkedBlockingQueue());
|
||||
AbstractConsumerTask task = null;
|
||||
//根据是否是顺序消费,创建对应的消费实现类
|
||||
if(isOrderConsumerModel) {
|
||||
task = new OrderlyConsumerTask(this, msgByKeyBlockQueue.get(i), this.messageListener);
|
||||
} else {
|
||||
task = new ConcurrentlyConsumerTask(this, msgByKeyBlockQueue.get(i), this.messageListener);
|
||||
}
|
||||
consumerRunningTasks.add(task);
|
||||
//启动消费线程
|
||||
consumerThreadGroup.submit(task);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这段代码有三个实现要点。
|
||||
|
||||
|
||||
第 7 行:创建一个指定线程数量的线程池,消费线程数可以由 consumerThreadCont 指定。
|
||||
|
||||
第 12 行:创建一个 ArrayList < LinkedBlockingQueue > taskQueues 的任务队列集合,其中 taskQueues 中包含 consumerThreadCont 个队列。
|
||||
|
||||
第 13 行:创建 consumerThreadCont 个 AbstractConsumerTask 任务,每一个 task 关联一个 LinkedBlockingQueue 任务队列,然后将 AbstractConsumerTask 提交到线程池中执行。
|
||||
|
||||
|
||||
以 5 个消费线程池为例,从运行视角来看,它对应的效果如下:
|
||||
|
||||
|
||||
|
||||
消费线程内部执行流程
|
||||
|
||||
将任务提交到提交到线程池后,异步运行任务,具体代码由 AbstractConsumerTask 的 run 方法来实现,其 run 方法定义如下:
|
||||
|
||||
public void run() {
|
||||
try {
|
||||
while (isRunning) {
|
||||
try {
|
||||
//判断是否是批量消费
|
||||
List<MessageExt> msgs = new ArrayList<>(this.consumer.getConsumeBatchSize());
|
||||
//这里是批消费的核心,一次从队列中提前多条数据,一次提交到用户消费者线程
|
||||
while(msgQueue.drainTo(msgs, this.consumer.getConsumeBatchSize()) <= 0 ) {
|
||||
Thread.sleep(20);
|
||||
}
|
||||
//执行具体到消费代码,就是调用用户定义的消费逻辑,位点提交
|
||||
doTask(msgs);
|
||||
} catch (InterruptedException e) {
|
||||
LOGGER.info(Thread.currentThread().getName() + "is Interrupt");
|
||||
break;
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("consume message error", e);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("consume message error", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这段代码中,消费线程从阻塞队列中抽取数据进行消费。顺序消费、并发消费模型具体的重试策略不一样,根据对应的子类实现即可。
|
||||
|
||||
Pull 线程
|
||||
|
||||
这段代码对标的是改进方案中的 Pull 线程,它负责拉取消息,并提交到消费线程。Pull 线程的核心代码如下:
|
||||
|
||||
private void startPullThread() {
|
||||
{
|
||||
//设置线程的名称,方便我们在分析线程栈中准确找到PULL线程
|
||||
String threadName = "Lite-Push-Pull-Service-" + this.consumer + "-" + LocalDateTime.now();
|
||||
Thread litePushPullService = new Thread(() -> {
|
||||
try {
|
||||
while (isRunning) {
|
||||
//待超时时间的消息拉取
|
||||
List<MessageExt> records = consumer.poll(consumerPollTimeoutMs);
|
||||
//将拉取到的消息提交到线程池,从而触发消费
|
||||
submitRecords(records);
|
||||
//为需要限流的队列开启限流
|
||||
consumerLimitController.pause();
|
||||
//为需要解除限流的队列解除限流
|
||||
consumerLimitController.resume();
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
LOGGER.error("consume poll error", ex);
|
||||
} finally {
|
||||
stopPullThread();
|
||||
}
|
||||
}, threadName);
|
||||
litePushPullService.start();
|
||||
LOGGER.info("Lite Push Consumer started at {}, consumer group name:{}", System.currentTimeMillis(), this.consumerGroup);
|
||||
}
|
||||
}
|
||||
|
||||
private void submitRecords(List<MessageExt> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
MessageExt firstMsg = records.get(0);
|
||||
MessageQueue messageQueue = new MessageQueue(firstMsg.getTopic(), firstMsg.getBrokerName(), firstMsg.getQueueId());
|
||||
// 根据队列获取队列级别消费服务类
|
||||
ConsumeMessageQueueService tempConsumeMessageService = ConsumeMessageQueueServiceFactory.getOrCreateConsumeMessageService(this, messageQueue, isOrderConsumerModel, lastAssignSet);
|
||||
// 提交具体的线程池
|
||||
tempConsumeMessageService.execute(records);
|
||||
}
|
||||
|
||||
|
||||
Pull 线程做的事情比较简单,就是反复拉取消息,然后按照 MessageQueue 提交到对应的 ConsumeMessageQueueService 去处理,进入到消息转发流程中。
|
||||
|
||||
消息路由机制
|
||||
|
||||
此外,优化后的线程模型还有一个重点,那就是消息的派发,它的实现过程如下:
|
||||
|
||||
public void execute(List<MessageExt> consumerRecords) {
|
||||
if (consumerRecords == null || consumerRecords.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 将消息放入到待消费队列中,这里实际是一个TreeMap结构,用于进行最小位点计算
|
||||
putMessage(consumerRecords);
|
||||
|
||||
if (isNeedPause()) {
|
||||
consumer.getConsumerLimitController().addPausePartition(messageQueue);
|
||||
}
|
||||
|
||||
for (MessageExt msg : consumerRecords) {
|
||||
int taskIndex = selectTaskQueue(msg, consumer.getTaskQueueSize());
|
||||
try {
|
||||
consumer.submitMessage(taskIndex, msg);
|
||||
} catch (Throwable e) {
|
||||
// ignore e
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class ConsumeMessageQueueOrderlyService extends AbstractConsumeMessageService{
|
||||
private final String NO_KEY_HASH = "__nokey";
|
||||
public ConsumeMessageQueueOrderlyService(DefaultMQLitePushConsumer consumer, MessageQueue messageQueue) {
|
||||
super(consumer, messageQueue);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int selectTaskQueue(MessageExt msg, int taskQueueTotal) {
|
||||
String keys = msg.getKeys();
|
||||
if(StringUtils.isEmpty(keys)) {
|
||||
keys = NO_KEY_HASH;
|
||||
}
|
||||
return Math.abs( keys.hashCode() ) % taskQueueTotal;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里,顺序消费模型按照消息的 Key 选择不同的队列,而每一个队列对应一个线程,即实现了按照 Key 来选择线程,消费并发度与队列个数无关。
|
||||
|
||||
完整代码
|
||||
|
||||
这节课我们重点展示了顺序消费线程模型的改进方案。但实现一个消费者至少需要涉及队列自动负载、消息拉取、消息消费、位点提交、消费重试等几个部分。因为这一讲我们聚焦在顺序消费模型的处理上,其他内部机制都蕴含在 DefaultMQLitePushConsumer 类库的底层代码中,所以我们这里只是使用,就不再发散了。不过我把全部代码都放到了GitHub,你可以自行查看。
|
||||
|
||||
总结
|
||||
|
||||
好了,总结一下。
|
||||
|
||||
这节课,我们首先通过一个我经历过的真实案例,看到了 RocketMQ 顺序消费模型的缺陷。RocketMQ 只是实现了分区级别的顺序消费,它的并发度受限于主题中队列的个数,不仅性能低下,在遇到积压问题时,除了横向扩容也几乎没有其他有效的应对手段。
|
||||
|
||||
在高并发编程领域,降低锁的粒度是提升并发性能屡试不爽的绝招。本案例中通过对业务规则的理解,找到了降低锁粒度的办法,那就是处于同一个消息队列中的消息,只有具有关系的不同消息才必须确保顺序性。
|
||||
|
||||
基于这一思路,并发度从队列级别降低到了消息级别,性能得到显著提升。
|
||||
|
||||
课后题
|
||||
|
||||
学完今天的内容,请你思考一个问题。
|
||||
|
||||
RocketMQ 在消息拉取中使用了长轮询机制,你知道这样设计目的是什么吗?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
365
专栏/中间件核心技术与实战/17运维:如何运维日均亿级的消息集群?.md
Normal file
365
专栏/中间件核心技术与实战/17运维:如何运维日均亿级的消息集群?.md
Normal file
@ -0,0 +1,365 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 运维:如何运维日均亿级的消息集群?
|
||||
你好,我是丁威。
|
||||
|
||||
得益于我所处的平台,依托快递行业巨大的业务流量,我所在的公司的日均消息流转量(消息发送、消息消费)已经达到万亿级别,消息中间件在公司的使用也非常广泛。这节课,我会结合自己的实践经验和你一起来看看如何在生产环境中运维消息集群。
|
||||
|
||||
集群部署
|
||||
|
||||
尽管消息集群都可以灵活地扩缩容,但我们在运维集群时还是不应该搭建太大的集群。因为一旦集群受影响,影响范围会很大。合理规划消息集群尤为重要,结合我的集群规划实践,我提炼出了下面几条经验供你参考。
|
||||
|
||||
|
||||
业务场景
|
||||
|
||||
|
||||
核心业务要按业务域进行规划,并且通常采用 RocketMQ。例如我们可以划分出订单、运单、财金等业务域。业务域内尽量独占。
|
||||
|
||||
日志采集类通常采用 Kafka,并且也要搭建几套日志集群,做好拆分,控制好影响的范围。
|
||||
|
||||
|
||||
应用特点
|
||||
|
||||
|
||||
消息集群的客户端通常使用长连接。但大数据领域很多数据抽取都是批处理任务,而批处理任务使用的是短连接,所以大数据领域这种我们会规划到单独的集群;另外在定时消息、大消息等场景下,也要规划专属集群。
|
||||
|
||||
规划了这么多的集群,集群的管理就成了难点。我们专门开发一个消息运维平台 ZMS,它支持在线安装 RocketMQ、Kafka、ZooKeeper 等中间件,安装原理如下:
|
||||
|
||||
|
||||
|
||||
我们对集群部署设计原理中的关键角色一一做个说明。
|
||||
|
||||
|
||||
service instance
|
||||
|
||||
|
||||
服务实例,它是服务中的一个节点。在同一时刻,一个服务实例只能有一个正在主机中运行的进程。一个服务可能包含多个服务实例。
|
||||
|
||||
|
||||
zms-agent
|
||||
|
||||
|
||||
zms-agent(ZMS 代理)是 zms-portal 与主机中的服务实例进行交互的桥梁。它可以实现服务实例的启动、停止和重启操作,还能够监控服务实例进程状态。
|
||||
|
||||
|
||||
supervisor
|
||||
|
||||
|
||||
zms-agent 通过 supervisor 对主机上的进程进行管理,可实现进程状态监控、异常退出、重启等功能。
|
||||
|
||||
顺便说一句,ZMS 是通过在主机上安装代理,来实现对主机上服务的控制的,这种控制包括服务启动、停止、重启等操作。同时,我们还可以通过 agent 把服务进程和主机状态上报到 zms-portal,实现对主机和服务进程的监控。
|
||||
|
||||
ZMS 目前已开源,可以点击“开源地址”下载。
|
||||
|
||||
集群扩容
|
||||
|
||||
从运维角度解决了集群的安装部署问题,接下来我们来看看在生产环境中,一般是怎么运维消息中间件的。
|
||||
|
||||
中间件的运维必须遵循一个最基本的原则:中间件所做的变更要对业务无感知。即,中间件做的任何变更不需要业务方配合,也不会影响正在运行的业务,当然为了安全起见,还是需要将变更操作通知业务方,做一些必要的检查工作。
|
||||
|
||||
我们先来看如何优雅地对集群进行扩容。
|
||||
|
||||
“双十一”、618 等大促活动时,各快递公司的业务量往往是平时的几倍。所以,在大促来临之前,我们都会对现有系统进行压测,评估容量,压测后通常会采取扩容等手段以扛住大促前后的巨大流量。那怎么对消息集群进行扩容呢?
|
||||
|
||||
我们分别讨论 RocketMQ、Kafka 这两种中间件。
|
||||
|
||||
先说 RocketMQ。例如现在已经有一个两主的集群了,部署如下图所示:
|
||||
|
||||
|
||||
|
||||
现在需要扩容到 3 个主节点,我们首先要在新添加的机器 192.168.3.106 上也安装一个 Broker,命名为 broker-c。扩容后的部署图为:
|
||||
|
||||
|
||||
|
||||
这样就把 broker-c 扩容到集群了。但这个时候你会发现,新增加的 Broker 并没有任何流量,这是因为 broker-c 上目前没有创建任何主题,自然就没有消息写入。
|
||||
|
||||
为了快速让 broker-c 上拥有集群内其他节点中的主题定义,我们通常可以拷贝集群内其他节点的主题定义文件,具体要复制的文件路径为:{ROCKETMQ_HOME}/store/config/topics.json 文件。其中,ROCKETMQ_HOME 表示集群的主目录,具体的文件存储如下图所示:
|
||||
|
||||
|
||||
|
||||
如果 Broker 关闭了自动创建消费组(autoCreateSubscriptionGroup=false),还需要拷贝 subscriptionGroup.json 文件。
|
||||
|
||||
这样,再次重启新加入的机器,就可以承担读写流量,实现负载均衡了。
|
||||
|
||||
我们再来说一下 Kafka 中集群节点的扩容。
|
||||
|
||||
第一步和 RocketMQ 一样,也就是在新节点上安装一个 Kafka,并与原先节点使用相同的 ZooKeeper 集群。这时,节点会扩容到集群中,但是与 RocketMQ 相同,这个节点暂时也不会有任何流量进来。那要如何使新节点承担数据的读写呢?
|
||||
|
||||
我们需要进行分区重分配,手动将部分主题的分区分配到新的节点。
|
||||
|
||||
在介绍具体的分配方式之前,我们先来看一下 dw_test_topic_0709003 的分区分布情况:
|
||||
|
||||
|
||||
|
||||
你可以重点关注一下 Leader 这一项,它表示分区所在的 Broker 节点。
|
||||
|
||||
好了,下面我们具体来看一下怎么对分区进行重分配。这里总共有三个步骤。
|
||||
|
||||
第一步:挑选出一部分重要主题,或者是当前 TPS 排名靠前的主题,整理成 JSON 文件。
|
||||
|
||||
{"topics":
|
||||
[
|
||||
{"topic":"dw_test_topic_0709003"}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
|
||||
|
||||
第二步:使用 Kafka 提供的 kafka-reassign-partitions.sh 命令生成执行计划。具体命令如下:
|
||||
|
||||
./kafka-reassign-partitions.sh --bootstrap-server 127.0.0.1:9092 --topics-to-move-json-file ./tmp/dw_test_topic_0709003-topics-to-move.json --broker-list "0,1,2,4" --generate --zookeeper 127.0.0.1:2181
|
||||
|
||||
|
||||
该命令运行后的截图如下:
|
||||
|
||||
|
||||
|
||||
执行命令后会输出下面两部分内容。
|
||||
|
||||
|
||||
Current partition replica assignment:表示主题分区迁移之前的结果,通常把这部分内容保存在一个文件中,用于回滚操作。
|
||||
|
||||
Proposed partition reassignment configuration:分区重新分配后的执行计划。
|
||||
|
||||
|
||||
第三步:把上一步生成的执行计划存储到一个 JSON 文件中,然后执行如下命令:
|
||||
|
||||
./kafka-reassign-partitions.sh --bootstrap-server 127.0.0.1:9092 --reassignment-json-file ./tmp/dw_test_topic_0709003-reassignment-json-file.json --execute --zookeeper 127.0.0.1:2181/kafka_cluster_01
|
||||
|
||||
|
||||
该命令的执行结果如下图所示:
|
||||
|
||||
|
||||
|
||||
响应结果还会返回迁移之前的分区情况,可用作回滚操作。值得注意的是,这个操作只会触发分区重分配,不会影响客户端的写入和读取。但如果分区的数据比较多的话,由于分区数据需要在节点之间进行迁移,所以需要一个过程。
|
||||
|
||||
如果在紧急情况下, 通常在修改操作之前会首先修改主题的存储时间,适当降低存储数据量,这样可以加快数据的迁移。
|
||||
|
||||
分区重分配成功后,结果如下:
|
||||
|
||||
|
||||
|
||||
可以看到,新扩容的节点 4 上已经有主分区了,这样它就可以接受数据的读写请求了。
|
||||
|
||||
集群缩容
|
||||
|
||||
大促结束后,为了节省资源,通常需要对集群进行缩容处理。将节点从集群中移除的基本原则是,存储在这些节点上的消息必须完成消费,否则会造成消息消费丢失。
|
||||
|
||||
首先我们来看一下 RocketMQ 节点的缩容。
|
||||
|
||||
双十一过后,我们需要将 192.168.3.106 的节点下线,但是,直接把节点从集群中摘除是不可行的。我们通常要先关闭写权限,避免新的数据再写入该节点,然后等消息过期再下线。具体有两个步骤。
|
||||
|
||||
第一步:关闭节点的写权限。具体命令如下:
|
||||
|
||||
sh ./mqadmin updateBrokerConfig -b 127.0.0.1:10911 -n 127.0.0.1:9876 -k brokerPermission -v 4
|
||||
|
||||
|
||||
第二步:为了保守起见,通常要等待消息过期后,再关闭 Broker。如果消息的存储时间为 72 小时,那要在关闭写权限 3 天之后才可以下线该节点。在此期间,该节点还是可以提供读取服务,也就是说,存在这个节点的消息仍然可以被消费端消费。
|
||||
|
||||
Kafka 的缩容需要分情况处理。
|
||||
|
||||
如果 Kafka 集群中所有主题都是多副本的话,这样每一个分区都会有多个副本,并且这些副本会分布在不同的节点上,缩容的时候直接停止一个机器即可。
|
||||
|
||||
但如果 Kafka 中有些主题是采取的单副本,要想缩容,就需要将这些单副本的主题再次进行分区重分配,把这些单副本主题的分片转移到其他节点。然后就可以直接停掉机器了。
|
||||
|
||||
分区扩容
|
||||
|
||||
除了在集群维度扩容和缩容外,无论是 RocketMQ 还是 Kafka 都支持分区级别的扩容。
|
||||
|
||||
在 RocketMQ 中为主题进行队列扩容比较简单,只需要执行一条命令:
|
||||
|
||||
sh ./mqadmin updateTopic -n 127.0.0.1:9876 -c DefaultCluster -t dw_test_01 -r 8 -w 8
|
||||
|
||||
|
||||
-w 、-r 分别指定扩容后的队列数。其中 -w 表示写队列个数,-r 表示读队列个数,在进行主题扩容时,它们必须一致。
|
||||
|
||||
在 Kafka 中扩容分区同样只需要执行一条命令:
|
||||
|
||||
./kafka-topics.sh --bootstrap-server 127.0.0.1:9092 --topic dw_test_topic_0709003 --partitions 8 --alter
|
||||
|
||||
|
||||
其中,“–partitions”表示要扩容后的分片数量。
|
||||
|
||||
分区缩容
|
||||
|
||||
再来看分区缩容。
|
||||
|
||||
Kafka 目前不支持分区缩容,也就是说,一个主题的分区数量只能增加不能减少。而 RocketMQ 可以无缝实现缩容。
|
||||
|
||||
在 RocketMQ 要减少主题的分区数量,通常需要经过两步。
|
||||
|
||||
第一步:将主题的写队列更改为缩容后的队列,例如 dw_test_01 这个主题原本有 8 个队列,现在要缩容为 4,就将主题的写队列改为 4。具体的命令如下:
|
||||
|
||||
sh ./mqadmin updateTopic -n 127.0.0.1:9876 -c DefaultCluster -t dw_test_01 -r 8 -w 4
|
||||
|
||||
|
||||
第二步:等消息达到过期时间后,再将读队列数量变更为缩容后的队列。命令如下:
|
||||
|
||||
sh ./mqadmin updateTopic -n 127.0.0.1:9876 -c DefaultCluster -t dw_test_01 -r 4 -w 4
|
||||
|
||||
|
||||
位点重置
|
||||
|
||||
在生产实践中,还有一个非常高频的动作是位点重置(回溯)。
|
||||
|
||||
RocketMQ 不需要停止消费组就可以进行位点回溯,只需要运维人员执行如下命令:
|
||||
|
||||
sh ./mqadmin resetOffsetByTime -g dw_test_mq_consuemr_test_01 -n 127.0.0.1:9876 -t dw_zms_test_topic -s '2022-07-10#10:00:00:000'
|
||||
|
||||
|
||||
这里重点说一下 -s 参数,它表示回溯时间。其中:
|
||||
|
||||
|
||||
now 或者 currentTimeMillis 表示当前时间;
|
||||
|
||||
yyyy-MM-dd#HH:mm:ss:SSS 表示具体的时间戳。在执行命令时,需要严格按照格式,否则会抛出空指针异常,这个错误会让人看得莫名其妙。
|
||||
|
||||
|
||||
运行的结果如下:
|
||||
|
||||
|
||||
|
||||
我们再来看一下 Kafka 的位点回溯。
|
||||
|
||||
kafka 中在进行位点重置之前,首先需要停止该消费组内所有的消费者,然后执行如下命令:
|
||||
|
||||
./kafka-consumer-groups.sh --bootstrap-server 127.0.0.1:9092 --group dw_test_consumer_20220710001 --reset-offsets --to-datetime '2022-07-10T00:00:00.000' --topic dw_test_topic_0709003 --execute
|
||||
|
||||
|
||||
命令的运行结果如下:
|
||||
|
||||
|
||||
|
||||
其中,NEW-OFFSET 表示当时的位点,消费组启动时会从该位点开始消费。
|
||||
|
||||
RocketMQ NameServer 的扩容与下线
|
||||
|
||||
在生产环境中,RocketMQ 还有一个重要组件是 NameServer。它的扩容与缩容也需要特别注意,避免操作过程造成人为的数据不一致。
|
||||
|
||||
举个例子,如果现在我们需要将 2 个节点的 NameServer 扩容为 3 个节点,需求如下图所示:
|
||||
|
||||
|
||||
|
||||
首先要在新的机器上安装好 NameServer。
|
||||
|
||||
然后更新两台 Broker 的配置文件,让 Broker 能够感知 NameServer 的存在,具体的配置项:
|
||||
|
||||
namesrvAddr=192.168.3.100:9876;192.168.3.101:9876;192.168.3.107:9876
|
||||
|
||||
|
||||
紧接着,依次重启 Broker。
|
||||
|
||||
这样,NameServer 就扩容完成了。
|
||||
|
||||
乍一看这个过程很简单,但你一定要注意的是,集群内的 Broker 没有全部重启时,新加入集群的 NameServer 地址是不能让消息发送 / 消息消费客户端使用的。因为这时候新的 NameServer 上的路由信息会和集群内其他 NamServer 存储的信息不一致。
|
||||
|
||||
NameServer 的下线就比较简单了。直接先 kill 掉 NameServer 进程,这时,无论是 Broker、还是消息发送、消息消费客户端都会抛出错误,但这个错误不影响使用。
|
||||
|
||||
然后依次更新 Broker 配置文件中的 namesrvAddr,移除已下线的 NameServer 地址并依次重启。
|
||||
|
||||
在生产实践中,NameServer 的扩容还是比较少见的,更多的是更换机器。举个例子,192.168.3.100 这台机器由于内存、磁盘等故障,需要被下线。但为了保证 NameServer 节点数量不受影响,我们通常还会在一台新机器上部署一台新的 NameServer。同时,为了避免客户端或 Broker 需要更新 NameServer 列表,更换机器时还要 IP 保持不变。
|
||||
|
||||
运维技巧
|
||||
|
||||
最后,我们再来看看运维命令。
|
||||
|
||||
无论是 RocketMQ 还是 Kafka 都提供了丰富的运维命令,这可以让运维人员更好地管理集群。但是,运维命令这么多,而且每一个命令的参数也很多,我们应该怎么学习这些命令呢?
|
||||
|
||||
其实不需要死记硬背,这些运维命令自带帮助手册,运维命令的安装目录就是中间件的 bin 目录。
|
||||
|
||||
通过下面的命令,我们可以快速查看 RocketMQ 拥有哪些运维命令:
|
||||
|
||||
sh ./mqadmin
|
||||
|
||||
|
||||
该命令的输出结果如下:
|
||||
|
||||
|
||||
sh ./mqadmin
|
||||
# 该命令的输出结果如下:
|
||||
The most commonly used mqadmin commands are:
|
||||
updateTopic Update or create topic
|
||||
deleteTopic Delete topic from broker and NameServer.
|
||||
updateSubGroup Update or create subscription group
|
||||
deleteSubGroup Delete subscription group from broker.
|
||||
updateBrokerConfig Update broker's config
|
||||
updateTopicPerm Update topic perm
|
||||
topicRoute Examine topic route info
|
||||
topicStatus Examine topic Status info
|
||||
topicClusterList get cluster info for topic
|
||||
brokerStatus Fetch broker runtime status data
|
||||
queryMsgById Query Message by Id
|
||||
queryMsgByKey Query Message by Key
|
||||
queryMsgByUniqueKey Query Message by Unique key
|
||||
queryMsgByOffset Query Message by offset
|
||||
QueryMsgTraceById query a message trace
|
||||
printMsg Print Message Detail
|
||||
printMsgByQueue Print Message Detail
|
||||
sendMsgStatus send msg to broker.
|
||||
brokerConsumeStats Fetch broker consume stats data
|
||||
producerConnection Query producer's socket connection and client version
|
||||
consumerConnection Query consumer's socket connection, client version and subscription
|
||||
producerConnectionAll Query all producer's socket connection and client version
|
||||
consumerProgress Query consumers's progress, speed
|
||||
consumerStatus Query consumer's internal data structure
|
||||
cloneGroupOffset clone offset from other group.
|
||||
clusterList List all of clusters
|
||||
topicList Fetch all topic list from name server
|
||||
updateKvConfig Create or update KV config.
|
||||
deleteKvConfig Delete KV config.
|
||||
wipeWritePerm Wipe write perm of broker in all name server
|
||||
resetOffsetByTime Reset consumer offset by timestamp(without client restart).
|
||||
updateOrderConf Create or update or delete order conf
|
||||
cleanExpiredCQ Clean expired ConsumeQueue on broker.
|
||||
cleanUnusedTopic Clean unused topic on broker.
|
||||
startMonitoring Start Monitoring
|
||||
statsAll Topic and Consumer tps stats
|
||||
allocateMQ Allocate MQ
|
||||
checkMsgSendRT check message send response time
|
||||
clusterRT List All clusters Message Send RT
|
||||
getNamesrvConfig Get configs of name server.
|
||||
updateNamesrvConfig Update configs of name server.
|
||||
getBrokerConfig Get broker config by cluster or special broker!
|
||||
queryCq Query cq command.
|
||||
sendMessage Send a message
|
||||
consumeMessage Consume message
|
||||
updateAclConfig Update acl config yaml file in broker
|
||||
deleteAccessConfig Delete Acl Config Account in broker
|
||||
clusterAclConfigVersion List all of acl config version information in cluster
|
||||
updateGlobalWhiteAddr Update global white address for acl Config File in broker
|
||||
getAccessConfigSubCommand List all of acl config information in cluster
|
||||
|
||||
|
||||
查看每一个命令的具体使用方法,可以使用如下命令:
|
||||
|
||||
sh ./mqadmin updateTopic -h
|
||||
|
||||
|
||||
同样 Kafka 的运维命令也在 bin 目录下:
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课就讲到这里。
|
||||
|
||||
中间件的稳定性大于一切,一旦发生故障,影响范围也比较大。所以我们不能把所有的鸡蛋放到一个“篮子”中,而是应该按照使用场景、应用特性等维度对集群进行合理规划,规划出一个一个的小集群。
|
||||
|
||||
中间件的运维必须遵循一个最基本的原则,那就是中间件做的变更要对业务无感知,对现有业务的运行无任何影响。
|
||||
|
||||
刚才,我结合我的运维实践经验,对集群扩容、缩容、分区扩容、缩容、位点重置、NameServer 下线等常见场景做了演练,你可以对比自己的实际经验进行总结与归纳。
|
||||
|
||||
课后题
|
||||
|
||||
学完今天的内容,请你思考下面这个问题。
|
||||
|
||||
在进行消费位点回溯时,我们说 Kafka 必须先停掉消费者,但 RocketMQ 却不需要,你知道 RocketMQ 是怎么做到的吗?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
218
专栏/中间件核心技术与实战/18案例:如何排查RocketMQ消息发送超时故障?.md
Normal file
218
专栏/中间件核心技术与实战/18案例:如何排查RocketMQ消息发送超时故障?.md
Normal file
@ -0,0 +1,218 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 案例:如何排查RocketMQ消息发送超时故障?
|
||||
你好,我是丁威。
|
||||
|
||||
不知道你在使用 RocketMQ 的时候有没有遇到过让人有些头疼的问题。我在用 RocketMQ 时遇到的最常见,也最让我头疼的问题就是消息发送超时。而且这种超时不是大面积的,而是偶尔会发生,占比在万分之一到万分之五之间。
|
||||
|
||||
现象与关键日志
|
||||
|
||||
消息发送超时的情况下,客户端的日志通常是下面这样:
|
||||
|
||||
|
||||
|
||||
我们这节课就从这些日志入手,看看怎样排查 RocketMQ 的消息发送超时故障。
|
||||
|
||||
首先,我们要查看 RocketMQ 相关的日志,在应用服务器上,RocketMQ 的日志默认路径为 ${USER_HOME}/logs/rocketmqlogs/ rocketmq_client.log。
|
||||
|
||||
在上面这张图中,有两条非常关键的日志。
|
||||
|
||||
|
||||
invokeSync:wait response timeout exception.
|
||||
|
||||
|
||||
它表示等待响应结果超时。
|
||||
|
||||
|
||||
recive response, but not matched any request.
|
||||
|
||||
|
||||
这条日志非常关键,它表示,尽管客户端在获取服务端返回结果时超时了,但客户端最终还是能收到服务端的响应结果,只是此时客户端已经在等待足够时间之后放弃处理了。
|
||||
|
||||
单一长连接如何实现多请求并发发送?
|
||||
|
||||
为什么第二条日志超时后还能收到服务端的响应结果,又为什么匹配不到对应的请求了呢?
|
||||
|
||||
我们可以详细探究一下这背后的原理。原来,这是使用单一长连接进行网络请求的编程范式。举个例子,一条长连接向服务端先后发送了两个请求,客户端在收到服务端响应结果时,需要判断这个响应结果对应的是哪个请求。
|
||||
|
||||
|
||||
|
||||
正如上图所示,客户端多个线程通过一条连接依次发送了 req1,req2 两个请求,服务端解码请求后,会将请求转发到线程池中异步执行。如果请求 2 处理得比较快,比请求 1 更早将结果返回给客户端,那客户端怎么识别服务端返回的数据对应的是哪个请求呢?
|
||||
|
||||
解决办法是,客户端在发送请求之前,会为这个请求生成一个本机器唯一的请求 ID(requestId),它还会采用 Future 模式,将 requestId 和 Future 对象放到一个 Map 中,然后将 reqestId 放入请求体。服务端在返回响应结果时,会将请求 ID 原封不动地放入响应结果中。客户端收到响应时,会先解码出 requestId,然后从缓存中找到对应的 Future 对象,唤醒业务线程,将返回结果通知给调用方,完成整个通信。
|
||||
|
||||
结合日志我发现,如果客户端在指定时间内没有收到服务端的请求,最终会抛出超时异常。但是,网络层面上客户端还是能收到服务端的响应结果。这就把矛头直接指向了 Broker 端,是不是 Broker 有瓶颈,处理慢导致的呢?
|
||||
|
||||
如何诊断 Broker 端内存写入性能?
|
||||
|
||||
我们知道消息发送时,一个非常重要的过程就是服务端写入。如果服务端出现写入瓶颈,通常会返回各种各样的 Broker Busy。我们可以简单来看一下消息发送的写入流程:
|
||||
|
||||
|
||||
|
||||
我们首先要判断的是,是不是消息写入 PageCache 或者磁盘写入慢导致的问题。我们这个集群采用的是异步刷盘机制,所以写磁盘这一环可以忽略。
|
||||
|
||||
然后,我们可以通过跟踪 Broker 端写入 PageCache 的数据指标来判断 Broker 有没有遇到瓶颈。具体做法是查看 RocketMQ 中的 store.log 文件,具体使用命令如下:
|
||||
|
||||
cd /home/codingw/logs/rocketmqlogs/store.log //其中codingw为当前rocketmq broker进程的归属用户
|
||||
grep "PAGECACHERT" store.log
|
||||
|
||||
|
||||
执行命令后,可以得到这样的结果:
|
||||
|
||||
|
||||
|
||||
这段日志记录了消息写入到 PageCache 的耗时分布。通过分析我们可以知道,写入 PageCache 的耗时都小于 100ms,所以 PageCache 的写入并没有产生瓶颈。不过,客户端可是真真切切地在 3 秒后才收到响应结果,难道是网络问题?
|
||||
|
||||
网络层排查通用方法
|
||||
|
||||
接下来我们就分析一下网络。
|
||||
|
||||
通常,我们可以用 netstat 命令来分析网络通信,需要重点关注网络通信中的 Recv-Q 与 Send-Q 这两个指标。
|
||||
|
||||
netstat 命令的执行效果如下图所示:
|
||||
|
||||
|
||||
|
||||
解释一下,这里的 Recv-Q 是 TCP 通道的接受缓存区;Send-Q 是 TCP 通道的发送缓存区。
|
||||
|
||||
在 TCP 中,Recv-Q 和 Send-Q 的工作机制如下图所示:
|
||||
|
||||
|
||||
|
||||
正如上图描述的那样,网络通信有下面几个关键步骤。
|
||||
|
||||
|
||||
客户端调用网络通道时(例如 NIO 的 Channel 写入数据),数据首先是写入到 TCP 的发送缓存区,如果发送缓存区已满,客户端无法继续向该通道发送请求,从 NIO 层面调用 Channel 底层的 write 方法的时候会返回 0。这个时候在应用层面需要注册写事件,待发送缓存区有空闲时,再通知上层应用程序继续写入上次未写入的数据。
|
||||
|
||||
数据进入到发送缓存区后,会随着网络到达目标端。数据首先进入的是目标端的接收缓存区,如果服务端采用事件选择机制的话,通道的读事件会就绪。应用从接收缓存区成功读取到字节后,会发送 ACK 给发送方。
|
||||
|
||||
发送方在收到 ACK 后,会删除发送缓冲区的数据。如果接收方一直不读取数据,那发送方也无法发送数据。
|
||||
|
||||
|
||||
运维同事分别在客户端和 MQ 服务器上,在服务器上写一个脚本,每 500ms 采集一次 netstat 。最终汇总到的采集结果如下:
|
||||
|
||||
|
||||
|
||||
从客户端来看,客户端的 Recv-Q 中出现大量积压,它对应的是 MQ 的 Send-Q 中的大量积压。
|
||||
|
||||
结合 Recv-Q、Send-Q 的工作机制,再次怀疑可能是客户端从网络中读取字节太慢导致的。为了验证这个观点,我修改了和 RocketMQ Client 相关的包,加入了 Netty 性能采集方面的代码:
|
||||
|
||||
|
||||
|
||||
我的核心思路是,针对每一次被触发的读事件,判断客户端会对一个通道进行多少次读取操作。如果一次读事件需要触发很多次的读取,说明这个通道确实积压了很多数据,网络读存在瓶颈。
|
||||
|
||||
部分采集数据如下:
|
||||
|
||||
|
||||
|
||||
我们可以通过 awk 命令对这个数据进行分析。从结果可以看出,一次读事件触发,大部分通道只要读两次就可以成功抽取读缓存区中的数据。读数据方面并不存在瓶颈。
|
||||
|
||||
统计分析结果如下图所示:
|
||||
|
||||
|
||||
|
||||
如此看来,瓶颈应该不在客户端,还是需要将目光转移到服务端。
|
||||
|
||||
从刚才的分析中我们已经看到,Broker 服务端写入 PageCache 很快。但是刚刚我们唯独没有监控“响应结果写入网络”这个环节。那是不是写入响应结果不及时,导致消息大量积压在 Netty 的写缓存区,不能及时写入到 TCP 的发送缓冲区,最终造成消息发送超时呢?
|
||||
|
||||
解决方案
|
||||
|
||||
为了验证这个设想,我最初的打算是改造代码,从 Netty 层面监控服务端的写性能。但这样做的风险比较大,所以我暂时搁置了这个计划,又认真读了一遍 RocketMQ 封装 Netty 的代码。在这之前,我一直以为 RocketMQ 的 网络层基本不需要参数优化,因为公司的服务器都是 64 核心的,而 Netty 的 IO 线程默认都是 CPU 的核数。
|
||||
|
||||
但这次阅读源码后我发现,RocketMQ 中和 IO 相关的线程参数有两个,分别是 serverSelectorThreads(默认值为 3)和 serverWorkerThreads(默认值为 8)。
|
||||
|
||||
在 Netty 中,serverSelectorThreads 就是 WorkGroup,即所谓的 IO 线程池。每一个线程池会持有一个 NIO 中的 Selector 对象用来进行事件选择,所有的通道会轮流注册在这 3 个线程中,绑定在一个线程中的所有 Channel 会串行进行网络读写操作。
|
||||
|
||||
我们的 MQ 服务器的配置,CPU 的核数都在 48C 及以上,用 3 个线程来做这件事显然太“小家子气”,这个参数可以调优。
|
||||
|
||||
RocketMQ 的网络通信层使用的是 Netty 框架,默认情况下事件的传播(编码、解码)都在 IO 线程中,也就是上面提到的 Selector 对象所在的线程。
|
||||
|
||||
在 RocketMQ 中 IO 线程就只负责网络读、写,然后将读取到的二进制数据转发到一个线程池处理。这个线程池会负责数据的编码、解码等操作,线程池线程数量由 serverWorkerThreads 指定。
|
||||
|
||||
看到这里,我开始心潮澎湃了,我感觉自己离真相越来越近了。参考 Netty 将 IO 线程设置为 CPU 核数的两倍,我的第一波优化是让 serverSelectorThreads=16,serverWorkerThreads=32,然后在生产环境中进行一波验证。
|
||||
|
||||
经过一个多月的验证,在集群数量逐步减少,业务量逐步上升的背景下,我们生产环境的消息发送超时比例达到了十万分之一,基本可以忽略不计。
|
||||
|
||||
网络超时问题的排查到这里就彻底完成了。但生产环境复杂无比,我们基本无法做到 100% 不出现超时。
|
||||
|
||||
比方说,虽然调整了 Broker 服务端网络的相关参数,超时问题得到了极大的缓解,但有时候还是会因为一些未知的问题导致网络超时。如果在一定时间内出现大量网络超时,会导致线程资源耗尽,继而影响其他业务的正常执行。
|
||||
|
||||
所以在这节课的最后我们再从代码层面介绍如何应对消息发送超时。
|
||||
|
||||
发送超时兜底策略
|
||||
|
||||
我们在应用中使用消息中间件就是看中了消息中间件的低延迟。但是如果消息发送超时,这就和我们的初衷相违背了。为了尽可能避免这样的问题出现,消息中间件领域解决超时的另一个思路是:增加快速失败的最大等待时长,并减少消息发送的超时时间,增加重试次数。
|
||||
|
||||
我们来看下具体做法。
|
||||
|
||||
|
||||
增加 Broker 端快速失败的等待时长。这里建议为 1000。在 Broker 的配置文件中增加如下配置:
|
||||
|
||||
|
||||
maxWaitTimeMillsInQueue=1000
|
||||
|
||||
|
||||
|
||||
减少超时时间,增加重试次数。
|
||||
|
||||
|
||||
你可能会问,现在已经发生超时了,你还要减少超时时间,那发生超时的概率岂不是更大了?
|
||||
|
||||
这样做背后的动机是希望客户端尽快超时并快速重试。因为局域网内的网络抖动是瞬时的,下次重试时就能恢复。并且 RocketMQ 有故障规避机制,重试的时候会尽量选择不同的 Broker。
|
||||
|
||||
执行这个操作的代码和版本有关,如果 RocketMQ 的客户端版本低于 4.3.0,代码如下:
|
||||
|
||||
DefaultMQProducer producer = new DefaultMQProducer("dw_test_producer_group");
|
||||
producer.setNamesrvAddr("127.0.0.1:9876");
|
||||
producer.setRetryTimesWhenSendFailed(5);// 同步发送模式:重试次数
|
||||
producer.setRetryTimesWhenSendAsyncFailed(5);// 异步发送模式:重试次数
|
||||
producer.start();
|
||||
producer.send(msg,500);//消息发送超时时间
|
||||
|
||||
|
||||
如果客户端版本是 4.3.0 及以上版本,因为设置的消息发送超时时间是所有重试的总的超时时间,所以不能直接设置 RocketMQ 的发送 API 的超时时间,而是需要对 RocketMQ API 进行包装,例如示例代码如下:
|
||||
|
||||
public static SendResult send(DefaultMQProducer producer, Message msg, int
|
||||
retryCount) {
|
||||
Throwable e = null;
|
||||
for(int i =0; i < retryCount; i ++ ) {
|
||||
try {
|
||||
return producer.send(msg,500); //设置超时时间,为 500ms,内部有重试机制
|
||||
} catch (Throwable e2) {
|
||||
e = e2;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("消息发送异常",e);
|
||||
}
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,我们这节课就介绍到这里了。
|
||||
|
||||
这节课,我首先抛出一个生产环境中,消息发送环节最容易遇到的问题:消息发送超时问题。我们对日志现象进行了解读,并引出了单一长连接支持多线程网络请求的原理。
|
||||
|
||||
整个排查过程,我首先判断了一下 Broker 写入 PageCache 是否有瓶颈,然后通过 netstat 命令,以 Recv-Q、Send-Q 两个指标为依据进行了网络方面的排查,最终定位到瓶颈可能在于服务端网络读写模型。通过研读 RocketMQ 的网络模型,我发现了两个至关重要的参数,serverSelectorThreads 和 serverWorkerThreads。其中:
|
||||
|
||||
|
||||
serverSelectorThreads 是 RocketMQ 服务端 IO 线程的个数,默认为 3,建议设置为 CPU 核数;
|
||||
|
||||
serverWorkerThreads 是 RocketMQ 事件处理线程数,主要承担编码、解码等责任,默认为 8,建议设置为 CPU 核数的两倍。
|
||||
|
||||
|
||||
通过调整这两个参数,我们极大地降低了网络超时发生的概率。
|
||||
|
||||
不过,发生网络超时的原因是多种多样的,所以我们还介绍了第二种方法,那就是降低超时时间,增加重试的次数,从而降低网络超时对运行时线程的影响,降低系统响应时间。
|
||||
|
||||
课后题
|
||||
|
||||
学完今天的内容,我也给你留一道课后题吧。
|
||||
|
||||
网络通讯在中间件领域非常重要,掌握网络排查相关的知识对线上故障分析有很大的帮助。建议你系统地学习一下 netstat 命令和网络抓包相关技能,分享一下你的经验和困惑。我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
127
专栏/中间件核心技术与实战/19案例:如何排查RocketMQ消息消费积压问题?.md
Normal file
127
专栏/中间件核心技术与实战/19案例:如何排查RocketMQ消息消费积压问题?.md
Normal file
@ -0,0 +1,127 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 案例:如何排查RocketMQ消息消费积压问题?
|
||||
你好,我是丁威。
|
||||
|
||||
我想,几乎每一位使用过消息中间件的小伙伴,都会在消息消费时遇到消费积压的问题。在处理这类问题时,大部分同学都会选择横向扩容。但不幸的是,这种解决办法治标不治本,到最后问题还是得不到解决。
|
||||
|
||||
说到底,消费端出现消息消费积压是一个结果,但引起这个结果的原因是什么呢?在没有弄清楚原因之前谈优化和解决方案都显得很苍白。
|
||||
|
||||
这节课,我们就进一步认识一下消费积压和 RocketMQ 的消息消费模型,看看怎么从根本上排查消费积压的问题。
|
||||
|
||||
RocketMQ 的消息消费模型
|
||||
|
||||
在 RocketMQ 消费领域中,判断消费端遇到的瓶颈通常会用到两个重要的指标:Delay 和 LastConsumeTime。
|
||||
|
||||
在开源版本的控制台 rocketmq-console 界面中,我们可以查阅消费端的这两个指标:
|
||||
|
||||
|
||||
|
||||
|
||||
Delay 指的是消息积压数量,它是由 BrokerOffset(服务端当前最大的逻辑偏移量)减去 ConsumerOffset(消费者消费的当前位点)计算出来的。如果 Delay 值很大,说明消费端遇到了瓶颈。
|
||||
|
||||
LastConsumeTime 表示上一次成功消费消息的存储时间。这个值如果很大,同样能说明消费端遇到了瓶颈。如果这个值线上为 1970 年,表示消费者当前消费位点对应的消息在服务端已经过期,被删除了。
|
||||
|
||||
|
||||
那为什么消费会积压呢?要理解这个问题,我们首先要了解 RocketMQ 消费者的消费处理模型。核心流程如下图所示:
|
||||
|
||||
|
||||
|
||||
说明一下具体的工作流程。
|
||||
|
||||
|
||||
PullMessageService 线程从拉取任务队列中获取一个待拉取任务 PullRquest。
|
||||
|
||||
PullMessageService 线程根据 PullRequest 中的主题名称、队列编号、拉取位点向 Broker 服务器拉取一批消息。拉取到消息后,服务端会更新 PullRequest 中下一次拉取任务的偏移量,将其放到队列的尾部。
|
||||
|
||||
PullMessageService 线程将拉取到的消息存入到处理队列(ProcessQueue),每一个 MessageQueue(Broker 名称 + 主题名称 + 队列编号)对应一个处理队列。
|
||||
|
||||
PullMessageService 线程将拉取到的消息提交到线程池。
|
||||
|
||||
PullMessageService 线程将消息提交到线程池后,不会等这批消息处理完成,而是立即返回。然后 PullMessageService 线程重复步骤一到步骤五。
|
||||
|
||||
当消息提交到消费线程池后,进行异步消费。消息消费成功后,会将消息从处理队列(ProcessQueue)中移除,然后获取处理队列中的最小偏移量,提交消费位点。
|
||||
|
||||
|
||||
从这个过程中可以看出,在 RocketMQ 的消费处理模型中,PullMessageService 线程“马不停歇”地从拉取队列中获取任务,拉完一批消息后继续再将 PullRequest(待拉取任务)放入到队列末尾,确保 PullMessageService 可以不间断地拉取消息,从而实现 Push 模式的效果。
|
||||
|
||||
从理论设计的角度,我们不难看出产生消费积压的原因可能有两个。
|
||||
|
||||
|
||||
第一,Pull 线程不拉取消息,那就无法消费消息,没有消费消息,消费位点自然不会提交。
|
||||
|
||||
第二,消费线程池中的线程因为某种原因阻塞,导致不消费消息,进而同样使得消费位点不提交。
|
||||
|
||||
|
||||
针对第一点,Pull 线程的 run 方法采用的是 while(true)+try catch 的模式,只要不主动关闭消费者,这个线程是不会停止的。具体的代码实现如下:
|
||||
|
||||
|
||||
|
||||
这么看来,消费积压基本都是消费线程池由于某种原因阻塞导致的。
|
||||
|
||||
在探究阻塞会发生在何处之前,你不妨思考一下,如果消费线程不干活,但拉取线程还一直在从服务端拉取消息,再将消息提交到消费线程池和 ProcessQueue,这时会出现什么问题?
|
||||
|
||||
没错,内存溢出。所以,为了保护消费者进程,这个时候我们必须引入限流机制限制拉取线程的行为。
|
||||
|
||||
在 RocketMQ 中,我们主要通过三点来判断是否需要进行限流:
|
||||
|
||||
|
||||
消息消费端队列中积压的消息超过 1000 条;
|
||||
|
||||
消息处理队列中积压的消息尽管没有超过 1000 条,但最大偏移量和最小偏移量的差值超过 2000;
|
||||
|
||||
消息处理队列中积压的消息总大小超过 100M。
|
||||
|
||||
|
||||
RocketMQ 一旦触发限流,往往会在 ${user_home}/logs/rocketmqlogs/rocketmq_client.log 文件中打印对应的日志,如果日志中包含了关键字“so do flow control”,表明消费端存在性能瓶颈,这就是我们的突破方向。
|
||||
|
||||
如何排查 RocketMQ 消息消费积压问题?
|
||||
|
||||
那如何定位消费端慢在哪,又是卡在了哪行代码呢?
|
||||
|
||||
我们常用的排查方法是跟踪线程栈,利用 jstack 命令查看线程运行情况,以此探究线程的运行情况。通常可以使用下面的命令:
|
||||
|
||||
ps -ef | grep java
|
||||
jstack pid > j1.log
|
||||
|
||||
|
||||
为了方便对比,我一般会连续打印五个文件,这样可以在五个文件中查看同一个消费者线程的状态,看它是否发生了变化。如果始终没有变化,说明该消费线程长时间阻塞,这就需要我们重点关注了。
|
||||
|
||||
在 RocketMQ 中,消费端线程以 ConsumeMessageThread_ 打头,通过对线程的判断,可以发现下面这段代码:
|
||||
|
||||
|
||||
|
||||
这些线程的状态为 RUNNABLE,并且在 jstack 日志中状态一直没有发生变化,说明这些线程是有问题的。通过线程栈,我们可以清楚地定位到具体的代码行。
|
||||
|
||||
在这个示例中,通过对线程栈的分析,我们发现是调用 HTTP 请求时没有设置超时时间,这就导致线程一直阻塞,对应的消息始终没有处理完成。消息一直在处理队列(ProcessQueue)中,而 RocketMQ 采取的又是最小位点提交机制,消费位点无法继续向前推进,这才出现了消费积压。
|
||||
|
||||
至此,消费积压问题的根本原因就定位出来了。
|
||||
|
||||
最后,我还想跟你分享几个小经验。
|
||||
|
||||
结合我的生产实践,通常情况下,RocketMQ 消息发送问题很可能与服务端有直接关系,而 RocketMQ 消费端遇到的一些性能问题通常与消费进程自身有关系。
|
||||
|
||||
另外,消费积压的时候,可以简单关注一下这个集群其他消费者的情况。如果其他消费者没有积压,只有你负责的消费组有积压,那就一定是消费端代码的问题了。
|
||||
|
||||
在这里最后再强调一遍,查看线程栈并不只是去查看线程状态为 BLOCKED、TIME_WRATING 的线程,RUNNABLE 的线程状态同样需要查看。因为在一些网络操作中(例如,HTTP 请求等待返回结果时、MySQL 写入 / 查询等待获取执行结果时),线程的状态也是 RUNNABLE。
|
||||
|
||||
总结
|
||||
|
||||
好了,今天就讲到这里。我们这节课主要是聚焦在 RocketMQ 消息消费积压这个核心问题上,这是消费端最常见的问题。
|
||||
|
||||
刚才,我简单地介绍了消费积压、和 LastConsumeTime 的计算规则,然后详细地介绍了 RocketMQ 消息消费的核心流程,探究了消费者的限流策略,最后介绍了精准定位消费积压的方法。
|
||||
|
||||
思考题
|
||||
|
||||
在课程的最后,我也给你留一道思考题。
|
||||
|
||||
我们这节课提到,RocketMQ 在消费端主要通过三种方式来判断是否需要限流。其中,限制积压的消息条数和消息总大小这个很容易理解,因为这样可以避免内存溢出。可是,为什么还需要限制消息处理队列中最大与最小偏移量之间的间隔呢?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
203
专栏/中间件核心技术与实战/20技术选型:分布式定时调度框架的功能和未来.md
Normal file
203
专栏/中间件核心技术与实战/20技术选型:分布式定时调度框架的功能和未来.md
Normal file
@ -0,0 +1,203 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 技术选型:分布式定时调度框架的功能和未来
|
||||
你好,我是丁威。
|
||||
|
||||
从这节课开始,我们将进入一个新的模块:定时调度中间件。
|
||||
|
||||
定时调度在业务开发领域的应用非常普遍,它通常会出现在数据清洗、批处理等应用场景中。我们这一模块总共分为三讲,首先,我们要来了解一下分布式定时调度框架的设计目标和未来,然后我会重点介绍一种基于 ZooKeeper 配置中心的编程模型,最后,我们会以一个实际场景串起分布式调度框架的要点。
|
||||
|
||||
定时调度框架要解决什么问题?市面上有哪些优秀的定时调度框架?定时调度未来的发展趋势又是什么?这节课我们就来聊聊这些问题。
|
||||
|
||||
定时调度功能需求
|
||||
|
||||
在大部分交易类场景下,比方说购物网站或者购票系统中,都会有一个特殊的业务规则:如果用户下单后超过指定时间未支付,平台将自动取消该订单。
|
||||
|
||||
定时延迟触发机制
|
||||
|
||||
要想实现这个功能,第一个必须具备的就是定时延迟触发机制。目前在定时调度领域,触发器都是基于 cron 表达式来定义的。cron 表达式支持按日历的概念来定义定时语义。例如,每周五上午 10 点,每个工作日上午 10 点等。我们这节课不会详细介绍怎么编写 cron 表达式,因为现在很多网站都支持快速生成 cron 表达式。如果有需要,你可以看看这个网站。
|
||||
|
||||
一旦解决了定时任务的触发问题,要在用户没有支付时及时取消订单、释放库存的需求就变得比较简单了。我们只需要编写对应的订单超时逻辑,然后触发器就可以根据定义的 cron 表达式在指定的时间点调用业务执行器,完成业务逻辑。
|
||||
|
||||
|
||||
|
||||
但一个项目中不可能只有一个任务,部门、公司更不可能只有一个任务,当需要管理的任务数量较多时,新的问题接踵而来:如何有效管理这些任务呢?
|
||||
|
||||
任务可视化管理机制
|
||||
|
||||
这样一来,定时调度又衍生了任务可视化管理需求,它通常包含:
|
||||
|
||||
|
||||
任务的新建、编辑、查看;
|
||||
|
||||
任务的启动、停止、重启;
|
||||
|
||||
任务的调度历史、执行情况。
|
||||
|
||||
|
||||
引入了任务可视化管理后,定时调度的架构基本是下面这个样子:
|
||||
|
||||
|
||||
|
||||
到这里,任务触发机制和任务可视化管理加起来,基本构成了定时调度框架的标配。它们可以帮助框架使用者方便地管理定时调度任务。但随着定时调度任务的逐渐增多,与之对应的是对可用性提出了更高的要求,也就是对触发器的分布式部署功能提出了更高的要求。支持分布式部署的架构如下图所示:
|
||||
|
||||
|
||||
|
||||
在分布式架构体系中,系统可以部署多个任务执行器,每一个任务执行器负责调度一部分任务。如果一个任务调度器宕机,任务可以转移到其他存活的调度器上去执行,从而实现高可用。
|
||||
|
||||
但随着业务的不断增长,单个定时调度任务需要处理的数据越来越多,单个任务执行的时长也逐步增加,这时,数据处理就容易出现较大延迟,当一个调度任务只在一个节点运行已经无法满足日益增长的数据要求时,提升性能就变得迫在眉睫了。
|
||||
|
||||
数据分片机制
|
||||
|
||||
为此,定时调度框架在分布式部署的基础上又引入了数据分片机制。调度触发器触发一次调度任务后,先计算本次调度需要执行多少数据,然后将这些数据按照分片算法切分成多份。这些独立的分片被包装为一个子任务,并被下发给不同的任务执行器。这样就实现了一个任务在不同进程之间的调度,从而提升了系统并发度。
|
||||
|
||||
错过执行任务重触发机制
|
||||
|
||||
通过分布式部署与数据切分后,定时框架就具备了高可用性、高性能和弹性扩缩容。不过在此基础上,定时调度框架还要提供错过执行任务重触发机制,这主要是为了避免任务调度次数丢失。
|
||||
|
||||
这个机制主要解决的是一个任务的执行时间大于任务调度频率的问题。例如,一个任务每隔 5s 调度一次,但如果一次调度期间业务的执行时间为 15s,它的调度触发如表格所示:
|
||||
|
||||
|
||||
|
||||
请你思考一下,错过执行的调度还需要继续执行吗?还是要等待下一次调度任务被触发呢?通常,定时调度应对这种情况应该提供一个参数供人选择。
|
||||
|
||||
当然,在实际业务中,还有一类定时调度任务更复杂,那就是有顺序要求的定时调度。只有执行完上一次调度任务之后,才能触发新的定时调度任务,通常的解决方案是,基于有向无环图(DAG)来定义作业之间的依赖。
|
||||
|
||||
定时调度框架发展与选型
|
||||
|
||||
了解了定时调度的基本功能需求后,我们再来看看市面上主流的分布式调度框架。
|
||||
|
||||
Quartz
|
||||
|
||||
互联网还没兴起时,Quartz 是定时调度框架的王者。这是一个非常经典的分布式调度框架,它是基于数据库来实现任务的分配的。
|
||||
|
||||
Quartz 集群部署如下图所示:
|
||||
|
||||
|
||||
|
||||
各个 Quartz 调度节点之间并不通信。
|
||||
|
||||
在 Quartz 中,节点默认每隔 20s 会查询数据库中的 QRTZ_TRIGGERS,不断地去获取并和其他节点抢占 Trigger。一旦该节点获取了 Trigger 的控制权,本次任务的调度就由调度器执行。
|
||||
|
||||
具体的抢占逻辑是,调度器尝试获取 TRIGGER_ACCESS 锁,成功获取锁的调度器执行本次调度,未获取锁的调度器进行锁等待,一旦获取锁的调度器释放锁,其他调度器就可以接管。具体的流程如下图所示:
|
||||
|
||||
|
||||
|
||||
Quartz 的使用方法非常简单,而且能够非常方便地支持 Spring 容器管理。但是如果需要管理的任务越来越多,特别是当触发周期很短的任务(例如每 10s 调度一次,每 1min 调度一次)越来越多时,基于数据库悲观锁的分布式调度机制就存在明显的性能瓶颈,无法支持快速发展的业务了。
|
||||
|
||||
伴随着互联网业务的不断扩大,互联网大厂都开源了自己的分布式调度框架,其中最典型代表就是 ElasticJob 和 XXL-JOB。这两款调度框架的调度机制底层使用的都是 Quartz。接下来我们就分别了解一下它们。
|
||||
|
||||
ElasticJob
|
||||
|
||||
ElasticJob 是一个分布式调度解决方案,最早由当当网开源,目前已经成为 Apache ShardingSphere 旗下的子项目。ElasticJob 由 2 个相互独立的子项目 ElasticJob-Lite 和 ElasticJob-Cloud 组成,但是因为目前市面上主要使用的是 ElasticJob-Lite,所以接下来我们讲到的 ElasticJob,主要指的就是这个 ElasticJob-Lite。
|
||||
|
||||
ElasticJob 的定位是轻量级的无中心化解决方案,其架构设计图如下:
|
||||
|
||||
|
||||
|
||||
使用 ElasticJob 进行开发比较简单,通过在应用程序中引入 ElasticJob 的客户端 Jar 包,就可以完成定时调度任务业务逻辑。ElasticJob 支持分布式部署、数据分片、弹性扩缩容、任务执行失败故障转移等高级特性。
|
||||
|
||||
启动 ElasticJob 的各个任务调度器后,当需要执行一个新的调度任务时,集群中所有的调度器会选举出一个 Leader,后续的调度由 Leader 来统一承担。其他的调度器作为这个任务的备份。一旦 Leader 失败,其他备份调度器就会重新进行选举。
|
||||
|
||||
ElasticJob 在功能维度也很丰富,它有下面几大亮点。
|
||||
|
||||
|
||||
弹性调度
|
||||
|
||||
|
||||
ElasticJob 支持在分布式场景下的数据分片与高可用,支持水平扩展任务从而提高吞吐量,任务的处理能力可以随资源的配备进行弹性伸缩。
|
||||
|
||||
|
||||
作业治理
|
||||
|
||||
|
||||
ElasticJob 支持分片失效转移、错过作业重新执行等特性。
|
||||
|
||||
|
||||
可视化管控
|
||||
|
||||
|
||||
ElasticJob 提供了相对完善的运维作业管控端,支持作业历史数据追踪、注册中心管理等功能。
|
||||
|
||||
|
||||
作业开放生态
|
||||
|
||||
|
||||
ElasticJob 提供了可扩展到作业类型的统一接口,能够与 Spring 依赖注入无缝整合。
|
||||
|
||||
稍显遗憾的是,ElasticJob 对 ZooKeeper 具有强依赖,所有核心功能的实现都依赖 ZooKeeper,并且调度与任务并未分离,一旦 ZooKeeper 出现问题,整个调度系统都可能瘫痪。
|
||||
|
||||
XXL-JOB
|
||||
|
||||
我们再来看看由大众点评开源的 XXL-JOB 分布式调度框架。
|
||||
|
||||
XXL-JOB 的一个核心设计亮点是,它将调度行为抽象形成了“调度中心”公共平台,平台自身并不承担业务逻辑,而是由“调度中心”发起调度请求,实现了“调度”和“任务”之间的解耦合,它的核心架构设计图如下:
|
||||
|
||||
|
||||
|
||||
XXL-JOB 的整体架构分为调度中心与执行器两个部分,我们简单说明一下它们的具体职责。
|
||||
|
||||
调度模块(调度中心)
|
||||
|
||||
负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码。调度系统与任务解耦,提高了系统可用性和稳定性,同时,调度系统的性能不再受限于任务模块。
|
||||
|
||||
调度中心支持可视化,能够简单且动态地管理调度信息,这些操作包括任务新建,更新,删除,Glue 开发和任务报警等,上面所有操作都会实时生效。同时,调度中心还支持监控调度结果和执行日志,支持执行器 Failover。
|
||||
|
||||
执行模块(执行器)
|
||||
|
||||
负责接收调度请求并执行任务逻辑。任务模块专注于任务的执行等操作,开发和维护更加简单和高效。
|
||||
|
||||
执行器的主要任务就是接收“调度中心”的执行请求、终止请求和日志请求等。
|
||||
|
||||
XXL-JOB 与 ElasticJob 是两款非常优秀的分布式调度框架,我们针对分布式调度中的核心技术对它们做一个简单的对比:
|
||||
|
||||
|
||||
|
||||
总的来看,XXL-JOB 的集群、分布式调度是基于数据库的锁机制开发的,在处理数据量较大的任务时,还是会存在明显瓶颈。但 XXL-JOB 的应用类功能更加完善,并且在架构上采取调度与任务执行相分离的架构方案,扩展性更强。
|
||||
|
||||
ElasticJob 更加关注数据,它的弹性扩容和数据分片机制更加灵活高效,能最大限度地利用分布式服务器的资源,性能强大。如果调度任务需要处理的数据量非常庞大,强烈推荐 ElasticJob。
|
||||
|
||||
定时调度框架的自研思路
|
||||
|
||||
在这节课的最后,我想给你分享一下我们公司关于定时调度的自研思路。
|
||||
|
||||
我们前面看到的 XXL-JOB 和 ElasticJob 各有所长,你可能会想,如果能将它们的优点融合在一起就完美了。
|
||||
|
||||
不错,我们公司就是在充分调研了 ElasticJob 和 XXL-JOB 之后,决定自研定时调度框架。
|
||||
|
||||
我们重点吸收了 XXL-JOB 的“调度”和“任务”执行解耦合的思路。调度平台只负责任务的管理、触发,然后通过 RPC 等手段远程调度任务的执行,使得框架高度平台化。具体的运行效果如下:
|
||||
|
||||
|
||||
|
||||
而我们调度器的数据分片、分布式调度、任务容错机制基本都参与 ElasticJob 进行,同时,我们还支持容器部署,使用容器部署能极大地提高资源利用率。
|
||||
|
||||
我们的定时调度框架通常有两类任务:批处理和定时调度。
|
||||
|
||||
批处理指的是在处理完一个批次后,如果有新的数据到达,就继续处理下一个批次。如果没有任务可执行,就休眠一段时间。
|
||||
|
||||
定时调度通常类似于每天凌晨几点定时执行这类任务。如果采用传统的虚拟机部署,这种任务一天只执行一次。但任务执行完成后,进程一直存活,虚拟机的资源一直被占用。但如果采用容器部署,执行完任务后,调度器就可以退出,等下一次触发时再创建新的调度器。
|
||||
|
||||
总结
|
||||
|
||||
好了,我们这节课就介绍到这里了。
|
||||
|
||||
在这节课的开始,我们从一个实际的使用场景出发,逐步引出了定时调度通常的功能需求,它们包括触发器、任务可视化管理、分布式部署、数据分片、故障转移、任务依赖等。
|
||||
|
||||
紧接着,我介绍了目前主流的分布式定时调度框架:Quartz、XXL-JOB 和 ElasticJob。我们重点对比了 XXL-JOB 和 ElasticJob 的差异。其中,XXL-JOB 的一个显著的设计亮点是调度与任务执行的解耦合,而 ElasticJob 在分布式部署、数据分片等机制上的优势则非常明显,适合处理数据量较大的调度任务。
|
||||
|
||||
最后,我还简单介绍了我所在公司自研分布式调度框架的一些思路。如果你的公司有类似的需求,应该会给你一些启发。
|
||||
|
||||
课后题
|
||||
|
||||
学完这节课,我也给你留一道课后题。
|
||||
|
||||
分布式定时调度中一个最具亮点的功能应该就是数据分片机制了。那它是如何做到动态扩缩容的呢?在这里强烈建议你去研读一下 ElasticJob 在这方面的源码,一定会对你理解分布式调度框架大有裨益。
|
||||
|
||||
如果你在阅读源码上有一定难度,也可以参考我写的ElasticJob 系列文章。欢迎你在留言区与我交流讨论,我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
250
专栏/中间件核心技术与实战/21设计理念:如何基于ZooKeeper设计准实时架构?.md
Normal file
250
专栏/中间件核心技术与实战/21设计理念:如何基于ZooKeeper设计准实时架构?.md
Normal file
@ -0,0 +1,250 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 设计理念:如何基于ZooKeeper设计准实时架构?
|
||||
你好,我是丁威。
|
||||
|
||||
先跟你分享一段我的经历吧。记得我在尝试学习分布式调度框架时,因为我们公司采用的分布式调度框架是 ElasticJob,所以我决定以 ElasticJob 为突破口,通过研读 ElasticJob 的源码,深入探究定时调度框架的实现原理。
|
||||
|
||||
在阅读 ElasticJob 源码的过程中,它灵活使用 ZooKeeper 来实现多进程协作的机制让我印象深刻,这里蕴藏着互联网一种通用的架构设计理念,那就是:基于 ZooKeeper 实现元信息配置管理与实时感知。
|
||||
|
||||
上节课中我们也重点提到过,ElasticJob 可以实现分布式部署、并且支持数据分片,它同时还支持故障转移机制,其实这一切都是依托 ZooKeeper 来实现的。
|
||||
|
||||
基于 ZooKeeper 的事件通知机制
|
||||
|
||||
ElasticJob 的架构采取的是去中心化设计,也就是说,ElasticJob 在集群部署时,各个节点之间没有主从之分,它们的地位都是平等的。并且,ElasticJob 的调度侧重对数据进行分布式处理(也就是数据分片机制),在调度每一个任务之前需要先计算分片信息,然后才能下发给集群内的其他节点来执行。实际部署效果图如下:
|
||||
|
||||
|
||||
|
||||
在这张图中,order-service-job 应用中创建了两个定时任务 job-1 和 job-2,而且 order-service-job 这个应用部署在两台机器上,也就是说,我们拥有两个调度执行器。那么问题来了,job-1 和 job-2 的分片信息由哪个节点来计算呢?
|
||||
|
||||
在 ElasticJob 的实现中,并不是将分片的计算任务固定分配给某一个节点,而是以任务为维度允许各个调度器参与竞选,竞选成功的调度器成为该任务的 Leader 节点,竞选失败的节点成为备选节点。备选节点只能在 Leader 节点宕机时重新竞争,选举出新的 Leader 并接管前任 Leader 的工作,从而实现高可用。
|
||||
|
||||
那具体如何实现呢?原来,ElasticJob 利用了 ZooKeeper 的强一致性与事件监听机制。
|
||||
|
||||
当一个任务需要被调度时,调度器会首先将任务注册到 ZooKeeper 中,具体操作为在 ZooKeeper 中创建对应的节点。ElasticJob 中的任务在 ZooKeeper 中的目录布局如下:
|
||||
|
||||
|
||||
|
||||
简单说明一下各个节点的用途。
|
||||
|
||||
|
||||
config:存放任务的配置信息。
|
||||
|
||||
servers:存放任务调度器服务器 IP 地址。
|
||||
|
||||
instances:存放任务调度器实例(IP+ 进程)。
|
||||
|
||||
sharding:存放任务的分片信息。
|
||||
|
||||
leader/election/instance:存放任务的 Leader 信息。
|
||||
|
||||
|
||||
创建好对应的节点之后,就要根据不同的业务处理注册事件监听了。在 ElasticJob 中,根据不同的任务会创建如下事件监听管理器,从而完成核心功能:
|
||||
|
||||
|
||||
|
||||
我们这节课重点关注的是 ElectionListenerManager 的实现细节,掌握基于 ZooKeeper 事件通知的编程技巧。
|
||||
|
||||
ElectionListenerManager 会在内部进行事件注册:
|
||||
|
||||
|
||||
|
||||
事件注册的底层使用的是 ZooKeeper 的 watch,每一个监听器在一个特定的节点处监听,一旦节点信息发生变化,ZooKeeper 就会通知执行注册的事件监听器,执行对应的业务处理。
|
||||
|
||||
一个节点信息的变化包括:节点创建、节点值内容变更、节点删除、子节点新增、子节点删除、子节点内容变更等。
|
||||
|
||||
调度器监听了 ZooKeeper 中的任务节点之后,一旦任务节点下任何一个子节点发生变化,调度器 Leader 选举监听器就会得到通知,进而执行 LeaderElectionJobListener 的 onChange 方法,触发选举。
|
||||
|
||||
接下来我们结合核心代码实现,来学习一下如何使用 Zookeeper 来实现主节点选举。
|
||||
|
||||
ElasticJob 直接使用了 Apache Curator 开源框架(ZooKeeper 客户端 API 类库)提供的实现类(org.apache.curator.framework.recipes.leader.LeaderLatch),具体代码如下:
|
||||
|
||||
public void executeInLeader(final String key, final LeaderExecutionCallback callback) {
|
||||
try (LeaderLatch latch = new LeaderLatch(client, key)) { // @1
|
||||
latch.start(); // @2
|
||||
latch.await();
|
||||
callback.execute();
|
||||
//CHECKSTYLE:OFF
|
||||
} catch (final Exception ex) {
|
||||
//CHECKSTYLE:ON
|
||||
handleException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们解读一下关键代码。LeaderLatch 需要传入两个参数:CuratorFramework client 和 latchPath。
|
||||
|
||||
CuratorFramework client 是 Curator 的框架客户端。latchPath 则是锁节点路径,ElasticJob 的锁节点路径为:/{namespace}/{Jobname}/leader/election/latch。
|
||||
|
||||
启动 LeaderLatch 的 start 方法之后,ZooKeeper 客户端会尝试去 latchPath 路径下创建一个临时顺序节点。如果创建的节点序号最小,LeaderLatch 的 await 方法会返回后执行 LeaderExecutionCallback 的 execute 方法,如果存放具体实例的节点 ({namespace}/{jobname}/leader/election/instance) 不存在,那就要创建这个临时节点,节点存储的内容为 IP 地址 @-@进程 ID,也就是说创建一个临时节点,记录当前任务的 Leader 信息,从而完成选举。
|
||||
|
||||
当 Leader 所在进程宕机后,在锁节点路径(/leader/election/latch)中创建的临时顺序节点会被删除,并且删除事件能够被其他节点感知,继而能够及时重新选举 Leader,实现 Leader 的高可用。
|
||||
|
||||
|
||||
|
||||
经过上面两个步骤,我们就基于 ZooKeeper 轻松实现了分布式环境下集群的选举功能。我们再来总结一下基于 ZooKeeper 事件监听机制的编程要点。
|
||||
|
||||
|
||||
在 Zookeeper 中创建对应的节点。
|
||||
|
||||
|
||||
节点的类型通常分为临时节点与持久节点。如果是存放静态信息(例如配置信息),我们通常使用持久节点;如果是存储运行时信息,则要创建临时节点。当会话失效后,临时节点会自动删除。
|
||||
|
||||
|
||||
在对应节点通过 watch 机制注册事件回调机制。
|
||||
|
||||
|
||||
如果你对这一机制感兴趣,建议你看看 ElasticJob 在这方面的源码,我的源码分析专栏 应该也可以给你提供一些帮助。
|
||||
|
||||
应用案例
|
||||
|
||||
深入学习一款中间件,不仅能让我们了解中间件的底层实现细节,还能学到一些设计理念,那 ElasticJob 这种基于 ZooKeeper 实现元数据动态感知的设计模式会有哪些应用实战呢?
|
||||
|
||||
我想分享两个我在工作中遇到的实际场景。
|
||||
|
||||
案例一
|
||||
|
||||
2019 年,我刚来到中通,我在负责的全链路压测项目需要在压测任务开启后自动启动影子消费组,然后等压测结束后,在不重启应用程序的情况下关闭影子消费组。我们在释放线程资源时,就用到了 ZooKeeper 的事件通知机制。
|
||||
|
||||
首先我们来图解一下当时的需求:
|
||||
|
||||
|
||||
|
||||
我们解读一下具体的实现思路。
|
||||
|
||||
第一步,在压测任务配置界面中,提供对应的配置项,将本次压测任务需要关联的消费组存储到数据库中,同时持久到 ZooKeeper 的一个指定目录中,如下图所示:
|
||||
|
||||
|
||||
|
||||
ZooKeeper 中的目录设计结构如下。
|
||||
|
||||
|
||||
/zpt:全链路压测的根目录。
|
||||
|
||||
/zpt/order_service_consumer:应用 Aphid。
|
||||
|
||||
/zpt/order_service_consumer/zpt_task_1:压测任务。
|
||||
|
||||
/zpt/order_service_consumer/zpt_task_1/order_bil_group:具体的消费组。
|
||||
|
||||
|
||||
在这里,每一个消费组节点存储的值为 JSON 格式,其中,从 enable 字段可以看出该消费组的影子消费组是否需要启动,默认为 0 表示不启动。
|
||||
|
||||
第二步,启动应用程序时,应用程序会根据应用自身的 AppID 去 ZooKeeper 中查找所有的消费组,提取出各个消费组的 enable 属性,如果 enable 属性如果为 1,则立即启动影子消费组。
|
||||
|
||||
同时,我们还要监听 /zpt/order_service_consumer 节点,一旦该节点下任意一个子节点发生变化,zpt-sdk 就能收到一个事件通知。
|
||||
|
||||
在需要进行全链路压测时,用户如果在全链路压测页面启动压测任务,就将该任务下消费组的 enable 属性设置为 1,同时更新 ZooKeeper 中的值。一旦节点的值发生变化,zpt-sdk 将收到一个节点变更事件,并启动对应消费组的影子消费组。
|
||||
|
||||
当停止全链路压测时,压测控制台将对应消费组在 ZooKeeper 中的值修改为 0,这样 zpt 同样会收到一个事件通知,从而动态停止消费组。
|
||||
|
||||
这样,我们在不重启应用程序的情况下就实现了影子消费组的启动与停止。
|
||||
|
||||
注册事件的关键代码如下:
|
||||
|
||||
private CuratorFramework client; // carator客户端
|
||||
|
||||
public static void addDataListener(String path, TreeCacheListener listener) { //注册事件监听
|
||||
TreeCache cache = instance.caches.get(path);
|
||||
if(cache == null ) {
|
||||
cache = addCacheData(path);
|
||||
}
|
||||
cache.getListenable().addListener(listener);
|
||||
}
|
||||
|
||||
|
||||
事件监听器中的关键代码如下:
|
||||
|
||||
|
||||
class MqConsumerGroupDataNodeListener extends TreeCacheListener {
|
||||
protected void dataChanged(String path, TreeCacheEvent.Type eventType, String data) {
|
||||
//首先触发事件的节点,判断路径是否为消费组的路径,如果不是,忽略本次处理
|
||||
if(StringUtils.isBlank(path) || !ZptNodePath.isMQConsumerGroupDataNode(path)) {
|
||||
logger.warn(String.format("path:%s is empty or is not a consumerGroup path.", path));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String consumerGroup = getLastKey(path);
|
||||
if(logger.isDebugEnabled()) {
|
||||
logger.debug(String.format("节点path:%s,节点值:%s", path, data));
|
||||
}
|
||||
if(!Zpt.isConsumerGroup(consumerGroup)) {
|
||||
logger.info(String.format("消费组:%s,不属于当前应用提供的,故无需订阅", consumerGroup));
|
||||
return;
|
||||
}
|
||||
// 如果节点的变更类型为删除,则直接停止消费组
|
||||
if(StringUtils.isBlank(data) || TreeCacheEvent.Type.NODE_REMOVED.equals(eventType)) {
|
||||
invokeListener(consumerGroup, false);
|
||||
}
|
||||
// 取得节点的值
|
||||
MqConsumerVo mqVo = JsonUtil.parse(data, MqConsumerVo.class);
|
||||
// 如果为空,或则为0,则停止消费组
|
||||
if(mqVo == null || StringUtils.isBlank(mqVo.getEnable()) || "0".equals(mqVo.getEnable())) {
|
||||
invokeListener(consumerGroup, false);
|
||||
return;
|
||||
} else { // 否则启动消费组。
|
||||
invokeListener(consumerGroup, true);
|
||||
return;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
logger.error("zk mq consumerGroup manager dataChange error", e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
案例二
|
||||
|
||||
在这节课的最后,我们再看一下另外一个案例:消息中间件 SDK 的核心设计理念。
|
||||
|
||||
我们公司对消息中间件的消息发送与消息消费做了统一的封装,对用户弱化了集群的概念,用户发送、消费消息时,不需要知道主题所在的集群地址,相关的 API 如下所示:
|
||||
|
||||
public static SendResult send(String topic, SimpleMessage simpleMessage)
|
||||
public static void subscribe(String consumerGroup, MessageListener listener)
|
||||
|
||||
|
||||
那问题来了,我们在调用消息发送 API 时,如何正确路由到真实的消息集群呢?
|
||||
|
||||
其实,我们公司对主题、消费组进行了强管控,项目组在使用主题、消费组之前,需要通过消息运维平台进行申请,审批通过后会将主题分配到对应的物理集群上,并会将 topic 的元数据分别存储到数据库和 ZooKeeper 中。因为这属于配置类信息,所以这一类节点会创建为持久化节点。
|
||||
|
||||
这样,消息发送 API 在初次发送主题时,会根据主题的名称在 ZooKeeper 中查找主题的元信息,包括主题的类型(RocketMQ/Kafka)、所在的集群地址(NameServer 地址或 Kafka Broker 地址)等,然后构建对应的消息发送客户端进行消息发送。
|
||||
|
||||
那我们为什么要将主题、消费组的信息存储到 ZooKeeper 中呢?
|
||||
|
||||
这是因为,为了便于高效运维,我们对主题、消费组的使用方屏蔽了集群相关的信息,你可以看看下面这个场景:
|
||||
|
||||
|
||||
|
||||
你能在不重启应用的情况下将 order_topic 从 A 集群迁移到 B 集群吗?
|
||||
|
||||
没错,在我们这种架构下,将主题从一个集群迁移到另外一个集群将变得非常简单。
|
||||
|
||||
我们只需要在 ZooKeeper 中修改一下 order_topic 的元信息,将维护的集群的信息由集群 A 变更为集群 B,然后 zms-sdk 在监听 order_topic 对应的主题节点时,就能收到主题元信息变更事件了。然后 zms-sdk 会基于新的元信息重新构建一个 MQ Producer 对象,再关闭老的生产者,这样就实现了主题流量的无缝迁移,快速进行故障恢复,极大程度保证了系统的高可用性。
|
||||
|
||||
我们公司已经把这个项目开源了,具体的实现代码你可以打开链接查看(ZMS 开源项目)。
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
好了,这节课我们就介绍到这里了。
|
||||
|
||||
这节课我们通过 ElasticJob 分布式环境中的集群部署,引出了 ZooKeeper 来实现多进程协作机制。并着重介绍了基于 ZooKeeper 实现 Leader 选举的方法。我们还总结出了一套互联网中常用的设计模式:基于 ZooKeeper 的事件通知机制。
|
||||
|
||||
我还结合我工作中两个真实的技术需求,将 ZooKeeper 作为配置中心,结合事件监听机制实现了不重启项目,在不重启应用程序的情况下,完成了影子消费组和消息发送者的启动与停止。
|
||||
|
||||
课后题
|
||||
|
||||
最后我也给你留一道题。
|
||||
|
||||
请你尝试编写一个功能,使用 curator 开源类库,去监听 ZooKeeper 中的一个节点,打印节点的名称,并能动态感知节点内容的变化、子节点列表的变化。程序编写后,可以通过 ZooKeeper 运维命令去操作节点,从而验证程序的输出值是否正确。
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课再见。
|
||||
|
||||
|
||||
|
||||
|
204
专栏/中间件核心技术与实战/22案例:使用分布式调度框架该考虑哪些问题?.md
Normal file
204
专栏/中间件核心技术与实战/22案例:使用分布式调度框架该考虑哪些问题?.md
Normal file
@ -0,0 +1,204 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 案例:使用分布式调度框架该考虑哪些问题?
|
||||
你好,我是丁威。
|
||||
|
||||
定时调度框架的应用非常广泛,例如电商平台的订单支付超时被取消时,数据清洗时等等。在中间件应用领域,定时调度框架通常和 MQ 等中间件组合使用,联合完成分布式环境下事务的最终一致性。
|
||||
|
||||
这节课,我们就一起来看看定时调度框架在消息发送领域的事务一致性设计方案和落地细节。
|
||||
|
||||
设计方案
|
||||
|
||||
不知道你还记不记得我在[第 13 讲]中提到的用户注册优惠活动的场景,为了实现用户注册主流程与活动的解耦合,我们引入了消息中间件,它的时序图如下所示:
|
||||
|
||||
|
||||
|
||||
这里的核心指导思想是让账户中心完成用户的注册逻辑,将用户写入到账户中心数据库,然后发送一条消息到 MQ 服务器,再给返回用户“注册成功”。之后,引入两个消费者,分别对消息进行对应的处理,异步赠送优惠券或者积分。
|
||||
|
||||
这个方法的架构思路非常不错,但是我们还不得不思考一个问题:如何保证写入数据库与消息发送这两个步骤的一致性呢?我们希望这两个步骤要么一起成功,要么一起失败,绝不能出现用户数据成功写入数据库,但消息发送失败的情况。因为这样用户无法收到优惠券,容易产生一系列的投诉和纠纷。
|
||||
|
||||
这其实是一个分布式事务的问题,也就是要保证数据库写入和消息发送这两个分布式操作的一致性。
|
||||
|
||||
一种比较常见的解决方案就是:“本地消息表 + 定时任务”。
|
||||
|
||||
具体而言,我们首先需要在数据库创建一张本地消息表,表的结构大致如下:
|
||||
|
||||
|
||||
|
||||
创建了消息发送本地记录表之后,用户注册的流程将变成:
|
||||
|
||||
|
||||
开启数据库本地事务;
|
||||
|
||||
insert into user 表(用户注册表);
|
||||
|
||||
insert into msg_send_record,并且存储账户的唯一编号、状态,其中状态的初始值为 0;
|
||||
|
||||
提交本地事务。
|
||||
|
||||
|
||||
这样做的目的是,保证 user 表和 msg_send_record 的事务一致性,如果用户信息成功存入 user 表,msg_send_record 表中必然存在一条对应的记录,后续我们只需要根据 msg_send_record 表中的记录发送一条对应的 MQ 消息即可。
|
||||
|
||||
当然,为了保证 msg_send_record 的写入不至于带来太大的性能损耗,通常我们会采取下面几个措施。
|
||||
|
||||
|
||||
如果在分库分表环境中,msg_send_record 采取的分库策略与 user 表一致,我们要保证这个过程是一个本地事务,不至于出现跨库 Join 的情况。
|
||||
|
||||
为 account_no、创建时间这两个字段添加索引。
|
||||
|
||||
定时清除 msg_send_record 表中的数据,这个表不需要保留太长时间,尽量控制单表数据量。
|
||||
|
||||
|
||||
数据成功写入消息待发送表后,接下来我们需要引入定时调度程序,定时扫描 msg_send_record 中的记录,将消息发送到 MQ 中。
|
||||
|
||||
定时调度程序的数据处理策略主要有三步。首先,按照分页机制从数据库中拉取一批数据;然后,根据用户账户查询用户表,构建消息体(用户账户、用户注册时间);最后,将消息发送到消息服务器(这里必须提供重试机制)。
|
||||
|
||||
引入定时调度程序后,用户注册送积分的时序图变成了下面这样:
|
||||
|
||||
|
||||
|
||||
在计划执行这个方案时,还有一个非常重要的事情,就是要明确定时调度任务的执行频率。
|
||||
|
||||
因为定时调度任务的调度频率直接决定了消息发送的实时性,随着需要调度的任务越来越多,大部分定时调度框架对秒级别的定时调度都不太友好。这时的调度通常都是分钟级的,但分钟级的调度会给任务带来较大的延迟,这是大部分业务无法容忍的,怎么办呢?
|
||||
|
||||
ElasticJob 可以通过支持流式任务解决这个问题。具体的思路是:将任务配置为按照分钟级进行调度,例如每分钟执行一次调度。每次调度按照分页去查找数据,处理完一批数据,再查询下一批,如果查到待处理数据,就继续处理数据,直到没有待处理数据时,才结束本次业务处理。如果本次处理时间超过了一个调度周期,那么利用 ElasticJob 的任务错过补偿执行机制会再触发一次调度。
|
||||
|
||||
在业务高峰期,这种方式基本上提供了准实时的处理效果。只有在业务量较少时,如果处理完一批数据后没有其他待处理的数据,这时新到的数据才会延迟 1 分钟执行。
|
||||
|
||||
综合来看,通过支持流式任务,我们可以极大地提高数据的处理时效。
|
||||
|
||||
消息领域定时调度框架的设计方案就介绍到这里了,我想你也许会问,RocketMQ 不是已经提供了事务消息机制吗?这里能不能直接使用 RocketMQ 的事务消息呢?
|
||||
|
||||
当然可以。但是很多公司的内部都采用了多种类型的消息中间件,有的中间件并不支持事务消息这个功能。考虑到架构设计方案的普适性,我们通常不会依赖单个中间件的特性。
|
||||
|
||||
方案落地
|
||||
|
||||
了解了设计方案,我们就可以实现消息发送和数据库操作的分布式事务一致性了。光说不练假把式,接下来,我们就尝试落地这个方案。我会给出一些关键代码,方便你在生产环境中落地实战。
|
||||
|
||||
我会基于 ElasticJob 框架简单梳理一下关键代码。通过 ElasticJob 实现一个定时调度任务通常包含两个重要步骤。
|
||||
|
||||
首先,我们要实现 ElasticJob 的流式任务接口 DataflowJob,这个接口主要完成定时调度任务的具体业务逻辑:
|
||||
|
||||
public class UserSendMqJob implements DataflowJob<UserMsgSendRecord> {
|
||||
private static int PAGE_SIZE = 100;
|
||||
private static String USER_SEND_MQ_TOPIC = "user_register_topic";
|
||||
private IUserMsgSendRecordDao userMsgSendRecordDao;
|
||||
private DefaultMQProducer defaultMQProducer;
|
||||
@Override
|
||||
public List<UserMsgSendRecord> fetchData(ShardingContext shardingContext) {
|
||||
// 分片总数
|
||||
int shardingTotalCount = shardingContext.getShardingTotalCount();
|
||||
//当前任务所处的分片序号
|
||||
int shardingItem = shardingContext.getShardingItem();
|
||||
int mod = shardingItem % shardingTotalCount;
|
||||
// 每次从数据库中取出一批数据
|
||||
return userMsgSendRecordDao.selectWaitSendRecordPage(mod, 0, PAGE_SIZE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void processData(ShardingContext shardingContext, List<UserMsgSendRecord> datas) {
|
||||
if(datas == null || datas.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for(UserMsgSendRecord record : datas) {
|
||||
String body = toJsonMsgBody(record);
|
||||
String key = record.getAccountNo();
|
||||
try {
|
||||
SendResult result = defaultMQProducer.send(new Message(USER_SEND_MQ_TOPIC, null, key, body.getBytes(StandardCharsets.UTF_8)));
|
||||
record.setMsgId( record.getMsgId());
|
||||
record.setSendStatus(1);
|
||||
userMsgSendRecordDao.update(record);
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
// 等待下一次调度
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String toJsonMsgBody(UserMsgSendRecord record) {
|
||||
// 其实就是构建JSON 消息内容,例如包含 注册用户编号
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("accountNo", record.getAccountNo());
|
||||
body.put("registerTime", record.getCreateTime());
|
||||
return JSON.toJSONString(body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DataflowJob 接口主要定义 fetchData 和 processData 接口,我们分别解读一下这两个接口的实现要点。
|
||||
|
||||
先说一下 fetchData。
|
||||
|
||||
它主要用于拉取待处理数据,ElasticJob 每触发一次任务调度,都会首先调用 fetchData 方法,尝试获取数据。如果该方法返回数据,ElasticJob 将调用 processData 来完成具体的业务逻辑。处理完一批数据后,还会循环调用 fetchData,看有没有待处理的数据。如果有,则继续调用 processData,直到查询不到待处理数据时,结束本次业务调度。
|
||||
|
||||
这里有一个非常关键的点:我们可以通过 ShardingContext 来获取任务的分片信息。其中,shardingTotalCount 是本次任务的总分片数量,shardingItem 是当前任务所处理数据的分片序号。通常我们可以用这两个数和 id 取模,实现数据分片。你可以看看下面这张示意图:
|
||||
|
||||
|
||||
|
||||
processData 方法,顾名思义,就是用来处理业务逻辑的方法。通过 fetchData 方法查询到的数据会传入 processData 方法中执行,我们这个实例主要是根据待发送记录组装 MQ 消息,然后将消息发送到 MQ 服务器,更新待发送记录,最后将状态从待发送变更为已发送的过程。
|
||||
|
||||
不过在实际执行过程中,通常还会遇到另外一个问题。假设我们的业务逻辑要根据不同的类型发送不到不同的 MQ 集群中,部分主题可能一直发送失败,最后影响到其他主题的正常发送。具体的示意图如下:
|
||||
|
||||
|
||||
|
||||
如果 fetchData 在获取数据时,每一次只拉取 3 条消息,那么它会一次取出 id 为 1,2,3 的三条消息,然后将这些消息发送到 cluster_a 集群的 topic_a 主题。如果某一时刻集群 cluster_a 发生故障,一段时间内无法发送消息,数据仍然被传入 proccessData 方法,就会发现 fetchData 每次拉取出的 id 都是 1,2,3。因为这些消息在 proccessData 中没有处理成功,state 的状态不会更新,需要发往集群 b 的消息也无法正常发送,这会导致严重的业务故障。
|
||||
|
||||
那我们如何解决这个问题呢?
|
||||
|
||||
第一步,我们要在消息待发送表(msg_send_record)中增加两个字段,一个是当前重试次数(retry_count),另外一个是下一次调度的最小时间(next_select_time)。增加了这两个字段的表数据是下面这样:
|
||||
|
||||
|
||||
|
||||
在处理数据时,如果第一次处理数据失败了,我们需要将重试次数加一,并设置下一次调度的最小时间。例如,用当前时间加一分钟,意味着一分钟内流式处理任务将不再拉取该数据,这就给了其他数据执行机会。
|
||||
|
||||
第二步,通过配置文件或者其他整合方式声明一个任务,我们这个实例是用 Spirng 方式整合了 ElasticJob,所以我们需要在 XML 文件中配置任务,具体的配置代码如下:
|
||||
|
||||
<job:dataflow id="UserSendMqJob" class="net.codingw.mq.task.UserSendMqJob" registry-center-ref="zkRegistryCenter"
|
||||
cron="0 0/2 * * * ?" sharding-total-count="4"
|
||||
sharding-item-parameters="0=0,1=1,2=2,3=3" failover="true" streaming-process="true">
|
||||
|
||||
|
||||
我们简要说明一下这些配置参数的含义。
|
||||
|
||||
|
||||
id:任务 id,它是全局唯一的。
|
||||
|
||||
class:调度任务逻辑具体实现类。
|
||||
|
||||
registry-center-ref:ElasticJob 调度器依赖的 ZooKeeper Bean。
|
||||
|
||||
cron:定时调度 cron 表达式。
|
||||
|
||||
sharding-total-count:总分片个数。
|
||||
|
||||
sharding-item-parameters:分片参数,用于定义各个分片的参数。在进入到 fetchData 方法时,可以原封不动地获取该值,方便地实现一些定制化数据切分策略。
|
||||
|
||||
failover:是否支持故障转移。设置为“true”表示支持,设置为“false”表示禁用故障转移机制。
|
||||
|
||||
streaming-process:是否启用流式任务,true 表示启用流式任务。
|
||||
|
||||
|
||||
关于使用 ElasticJob 的其他代码我在这里就不详细介绍了,如果你对使用 ElasticJob 的方法还不是太熟悉,可以看看官方提供的官方示例代码。
|
||||
|
||||
总结
|
||||
|
||||
好了,我们这课就讲到这里了。
|
||||
|
||||
这节课,我们围绕中间件领域如何实现消息发送与业务的分布式事务这个核心问题,详细展示了用 ElasticJob 开发定时调度任务的方法。
|
||||
|
||||
我们学习了目前业界解决分布式事务最经典的方案:定时调度 + 本地消息表。这个方案结合 ElasticJob 支持流式任务的特性,提升了任务的实时性。我们也总结了流式任务最常见的“坑”,给出了可行的解决方案。
|
||||
|
||||
课后题
|
||||
|
||||
学完这节课,给你留一道思考题。
|
||||
|
||||
你有没有在工作中遇到需要处理分布式事务的场景?你又是如何设计的呢?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
322
专栏/中间件核心技术与实战/23案例:如何在生产环境进行全链路压测?.md
Normal file
322
专栏/中间件核心技术与实战/23案例:如何在生产环境进行全链路压测?.md
Normal file
@ -0,0 +1,322 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 案例:如何在生产环境进行全链路压测?
|
||||
你好,我是丁威。
|
||||
|
||||
不知不觉,我们已经进入了专栏的最后一个板块。这节课,我想给你介绍一下我在全链路压测领域的一些实践经验,让你对中间件相关技术有一个全局的理解。
|
||||
|
||||
实际上,全链路压测也是我进入中通负责的第一个项目。当时,我们需要从 0 到 1 打造全链路压测项目,而我对主流中间件的深入了解,在项目的开发和启动过程中发挥了极大的作用,也让作为新人的我在新公司站稳了脚跟。
|
||||
|
||||
全链路压测概述
|
||||
|
||||
那什么是全链路压测呢?网上关于它的定义有很多,所以我在这里只给出一个可能不太全面,但是比较简单易懂的我的版本:全链路压测就是在生产环境对我们的系统进行压测,压测流量的行进方向和真实用户的请求流量是一致的,也就是说压测流量会完全覆盖真实的业务请求链路。
|
||||
|
||||
全链路压测的核心目的是高保真地检测系统的当前容量,方便在大促时科学合理地扩缩容,为合理规划资源提供可视化的数据支撑。
|
||||
|
||||
在介绍全链路压测之前,我们先来看一张简易的数据流向图,感受一下我们公司需要落地全链路压测的底层系统的布局情况:
|
||||
|
||||
|
||||
|
||||
首先,压测端通常采用 JMeter 构建压测请求(HTTP 请求),全链路压测系统需要提供一种机制对真实的业务流量与测试流量加以区分,然后压测流量与真实流量分别进入接入层的负载均衡组件,最终进入接口网关。接口网关再发起 HTTP 或者 Dubbo 等 RPC 请求,进入到内部的业务系统。
|
||||
|
||||
内部系统 A 可能会访问 MySQL 数据库或者 Redis,同时存在一些数据同步组件将 MySQL 的数据抽取到 Elasticsearch 中。接着,内部应用 B 可能会继续调用内部应用 C,让 C 发送消息到 MQ 集群。下游的消费者应用消费数据,并访问 Es、MySQL 等存储组件。
|
||||
|
||||
我们可以用一张图概括一套完整的全链路压测的基本功能需求:
|
||||
|
||||
|
||||
|
||||
简单解释一下不同阶段的内涵。
|
||||
|
||||
|
||||
压测前
|
||||
|
||||
|
||||
主要任务是构建压测数据。全链路压测的核心目标是高保真模拟真实请求。但目前市面上的性能压测实践基本都是通过 JMeter 来人工模拟接口的数据最终生成测试请求的,这样无法反馈用户的真实行为。全链路压测最期望的结果是对真实用户请求日志进行脱敏和清洗,然后将其存入数据仓库中。在真正进行压测时,根据数据仓库中的数据模拟用户的真实行为。
|
||||
|
||||
|
||||
压测运行时
|
||||
|
||||
|
||||
主要包括请求打标、透传以及各主流中间件的数据隔离,这是全链路压测的基座。
|
||||
|
||||
|
||||
压测后
|
||||
|
||||
|
||||
压测结束后,我们需要生成压测报告并实时监控压测过程,一旦压测过程中系统扛不住,要立马提供熔断压测,避免对生产环境造成影响。
|
||||
|
||||
我这节课不会覆盖全链路压测的方方面面,而是主要介绍与中间件关联非常强的流量染色与透传机制和数据隔离机制。
|
||||
|
||||
流量染色与透传机制
|
||||
|
||||
我们首先来介绍流量染色与透传机制。其中,流量染色的目的就是正确地标记压测流量,确保在系统内部之间进行 RPC 调用、发送 MQ 等操作之后,能够顺利传递压测流量及其标记,确保整个过程中流量标记不丢失。
|
||||
|
||||
流量染色与透传机制的设计概要如下:
|
||||
|
||||
|
||||
|
||||
这个机制主要包含三部分,本地线程上下文管理、二次路由支持,还有实现 RPC 透传过滤器。先看第一部分,本地线程上下文管理。
|
||||
|
||||
由于流量染色标记需要在整个调用链中传播,在进行数据存储或查询时,都需要根据该标记来路由。所以,为了不侵入代码,把流量染色标记存储在本地线程上下文中是最合适的。
|
||||
|
||||
说起本地线程变量,我相信你首先想到的就是 JDK 默认提供的 ThreadLocal,它可以存储整个调用链中都需要访问的数据,而且它的线程是安全的。
|
||||
|
||||
但 ThreadLocal 在多线程环境中并不友好,举个例子,在执行线程 A 的过程中,要创建另外一个线程 B 进行并发调用时,存储在线程 A 中的本地环境变量并不会传递到线程 B,这会导致染色标记丢失,带来严重的数据污染问题:
|
||||
|
||||
|
||||
|
||||
为了解决线程本地变量在线程之间传递的问题,阿里巴巴开源了 transmittable-thread-local 类库,它支持线程本地变量在线程之间、线程池之间进行传递,可以确保多线程环境下本地变量不丢失。
|
||||
|
||||
关于本地线程变量的详细调研情况、示例代码,你可以参考我的另一篇文章《线程本地上下文调研实践》。
|
||||
|
||||
流量染色与透传机制的第二部分是两次路由支持,它又细分为两级。
|
||||
|
||||
第一级路由主要是根据本地线程变量中是否存储了压测标记而进行的路由选择。但在全链路压测的一些场景中,某些服务(特别是像查询基础数据、地址解析这类基础服务)不需要走测试流量。而且测试流量也可以、并且也应该走生产流量,这样可以节省一大笔资源,这就说到了第二级路由。它指的是除了根据压测标记进行路由选择外,我们还需要提供另外一层的路由,即根据配置进行的路由选择,第二级路由的设计图如下:
|
||||
|
||||
|
||||
|
||||
流量染色标记存储到本地线程变量之后,第三步就是要对流量进行透传了。目前,RPC 调用通常指的是 HTTP 请求与 Dubbo 远程调用。
|
||||
|
||||
我们来看一个远程 RPC 的服务调用。
|
||||
|
||||
|
||||
|
||||
我们要分别针对 HTTP、Dubbo 实现请求标记的透传。
|
||||
|
||||
先来说 HTTP。HTTP 中提供了 Web 过滤器机制,它允许我们在真正执行 Controller 层代码之前先执行过滤器。所以我们可以单独定义一个过滤器,从请求中将压测标记存储到本地线程变量中。然后,请求在真正执行业务代码时,就可以非常方便地从本地线程上下文获取染色标记了。示例代码如下:
|
||||
|
||||
public class PtWebFilter implements Filter {
|
||||
private static Logger logger = LoggerFactory.getLogger(PtWebFilter.class);
|
||||
private static final String PT_HEADER = "_PT_HEADER";
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain
|
||||
filterChain) throws IOException, ServletException {
|
||||
try {
|
||||
if(servletRequest instanceof HttpServletRequest) {
|
||||
HttpServletRequest req = (HttpServletRequest)servletRequest;
|
||||
String requestType = req.getHeader(PT_HEADER);
|
||||
|
||||
if(logger.isDebugEnabled()) {
|
||||
logger.debug("set requestType before,current requestFlag:{}",
|
||||
PTLocalContext.shouldAsShadowOp());
|
||||
logger.debug(String.format("request header requestType:%s", requestType));
|
||||
}
|
||||
|
||||
// 如果请求头中存在压测标记,则将上下文设置为Test请求
|
||||
if(StringUtil.isNotBlank(requestType) && RequestType.TEST.name().equalsIgnoreCase(requestType))
|
||||
PTLocalContext.setRequestFlag(RequestType.TEST);
|
||||
} else { //正式请求,设置其标记为正式请求标记
|
||||
PTLocalContext.setRequestFlag(RequestType.PROD);
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(servletRequest, servletResponse);
|
||||
} finally {
|
||||
PTLocalContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里,代码通过解析 HTTP 的 Header,提取出流量染色标记,并将其存储到本地线程上下文中管理。
|
||||
|
||||
那怎样发起 HTTP 请求,将流量染色标记传递到下一个应用呢?
|
||||
|
||||
我们项目中是使用 HttpClient 类库实现的 HTTP 调用。HttpClient 提供了拦截器机制,允许我们在发起 HTTP 请求之前,为 HTTP 请求设置对应的 HTTP Header,具体的示范代码如下:
|
||||
|
||||
/**
|
||||
* Http 上下文拦截器,使用HttpClientUtils将当前本地线程上下文环境写入到Http Request Header中,方便被调用方感知它的存在
|
||||
*/
|
||||
public class PtHttpClientFilter implements HttpClientFilter {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PtHttpClientFilter.class);
|
||||
private static final String PT_HEADER = "_PT_HEADER";
|
||||
private static final String PT_SERVICE_NAME_KEY = "_PT_SERVICE_NAME_KEY";
|
||||
|
||||
@Override
|
||||
public void doFilter(HttpReqAndRsp httpReqAndRsp,/* HttpResponse httpResponse,*/ HttpClientFilterChain
|
||||
httpFilterChain) {
|
||||
String appId = EnvironmentManager.getAppName();
|
||||
String httpServiceName = (String) httpFilterChain.getAttachment(PT_SERVICE_NAME_KEY);
|
||||
if (PTLocalContext.shouldAsShadowOp(true)) { // 如果当前环境是测试环境,忽略二级路由
|
||||
if (!Pt.serviceAvailable(appId, httpServiceName, StubServiceType.HTTP)) {//服务不可用
|
||||
HttpEntity httpEntity = new StringEntity(stubData, "UTF-8");
|
||||
HttpResponse httpResponse = new BasicHttpResponse(new ProtocolVersion("http", 1, 1), 200,
|
||||
"ok");
|
||||
httpResponse.setEntity(httpEntity);
|
||||
httpReqAndRsp.setHttpResponse(httpResponse);
|
||||
} else {//服务可用
|
||||
//将测试标记、服务名、当前使用的taskName 写入到Header中,实现透传
|
||||
httpReqAndRsp.getHttpRequest().setHeader(PT_HEADER, RequestType.TEST.name());
|
||||
httpReqAndRsp.getHttpRequest().setHeader(PT_SERVICE_NAME_KEY,
|
||||
httpServiceName);
|
||||
httpFilterChain.doFilter(httpReqAndRsp);
|
||||
}
|
||||
} else {//正常请求
|
||||
//将测试标记写入到Header中,实现透传
|
||||
httpReqAndRsp.getHttpRequest().setHeader(PT_HEADER, RequestType.PROD.name());
|
||||
httpFilterChain.doFilter(httpReqAndRsp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在这里还有一个设计非常重要,那就是服务白名单机制。它的意思是,如果调用一个远程 RPC 服务,而当前流量是测试请求,那么只有白名单中配置的服务才可以发起调用。这主要是为了避免被调用方的服务如果没有接入全链路压测,会不具备流量识别功能,容易将测试请求当成正式请求处理,造成数据污染。
|
||||
|
||||
学完 HTTP 的流量透传机制,你可以结合我们讲过的 Dubbo 相关知识思考一下,在 Dubbo 中怎么实现流量透传功能呢?
|
||||
|
||||
没错,Dubbo 同样提供了 Filter 机制,在发起或接受请求之前,都可以定义 Filter 来实现同样的功能。
|
||||
|
||||
消费者在调用一个 RPC 服务之前,需要将本地线程变量中的流量染色标记通过网络传输到服务端,具体的做法就是将标记放到 RpcContext 中:
|
||||
|
||||
@Activate(group = {Constants.CONSUMER}, order = -2000)
|
||||
public class PtContextOutputFilter implements Filter {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(PtContextOutputFilter.class);
|
||||
private ConcurrentHashMap<String,Class<?>> returnTypeMap = new ConcurrentHashMap();
|
||||
private static final String PT_HEADER = "_PT_HEADER";
|
||||
private static final String PT_SERVICE_NAME_KEY = "_PT_SERVICE_NAME_KEY";
|
||||
@Override
|
||||
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
|
||||
String appId = EnvironmentManager.getAppName();
|
||||
String dubboServiceName = DubboPtUtils.getServiceName(invocation);
|
||||
|
||||
// 如果当前本地线程变量的请求标志为Test,并忽略二级路由的影响
|
||||
if(PTLocalContext.shouldAsShadowOp(true)) {
|
||||
RpcContext.getContext().setAttachment(PT_HEADER, RequestType.TEST.name());
|
||||
}
|
||||
return invoker.invoke(invocation);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
同样,在服务端真正开始处理业务逻辑之前,需要先从 RpcContext 中获取流量染色标记,将其放入本地线程变量中:
|
||||
|
||||
@Activate(group = {Constants.PROVIDER}, order = -2000)
|
||||
public class PtContextInputFilter implements Filter {
|
||||
private static Logger logger = LoggerFactory.getLogger(PtContextInputFilter.class);
|
||||
private static final String PT_HEADER = "_PT_HEADER";
|
||||
@Override
|
||||
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
|
||||
try {
|
||||
//从上下文环境中取出测试标记
|
||||
String requestType = RpcContext.getContext().getAttachment(PT_HEADER);
|
||||
|
||||
if(StringUtil.isNotBlank(requestType) && RequestType.TEST.name().equalsIgnoreCase(requestType)) { // 如果是测试流量,则设置请求标记
|
||||
// 设置请求标记
|
||||
PTLocalContext.setRequestFlag(RequestType.TEST);
|
||||
// 设置当前被调用的服务名
|
||||
String serviceName = DubboPtUtils.getServiceName(invocation);
|
||||
PTLocalContext.setDubboServiceKey(serviceName);
|
||||
|
||||
} else { //如果是正式流量,设置其请求标记为生产流量
|
||||
PTLocalContext.setRequestFlag(RequestType.PROD);
|
||||
if(logger.isInfoEnabled()) {
|
||||
logger.info(String.format("PtContextInputFilter set request flag: prod"));
|
||||
}
|
||||
}
|
||||
|
||||
return invoker.invoke(invocation);
|
||||
} finally {
|
||||
// 清理线程本地资源,避免被污染
|
||||
PTLocalContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
数据隔离机制
|
||||
|
||||
了解了流量染色和透传机制,接下来我们重点看看各主流中间件的数据存储隔离机制,它是全链路压测的核心基石。
|
||||
|
||||
目前常用的数据存储中间件包括:关系型数据库(MySQL/Oracle)、Redis、MQ、Elasticsearch、HBase。
|
||||
|
||||
那压测数据和正式请求数据要采用哪种存储方式来避免数据访问混淆呢?这也是数据隔离要重点解决的问题。针对各种存储类中间件,业界已经给出了存储隔离的最佳实践,我总结了一下,画了下面这张思维导图:
|
||||
|
||||
|
||||
|
||||
接下来,我们就挑选最具代表性,也是我们这个专栏重点介绍过的数据库、MQ 这两个中间件来介绍一下具体的实现细节。其他的中间件也可以基于这种思路来实现。你可以根据不同中间件提供的客户端 API,对数据请求进行拦截,并根据本地环境变量的值进行路由选择,确保正式请求访问正式资源,测试请求访问影子资源。
|
||||
|
||||
数据库层面,通常有两种数据隔离机制,一种是影子库,另外一种是影子表。
|
||||
|
||||
我们借着数据库这种场景简单来介绍一下什么是影子库和影子表。你可以先看看下面这张示意图:
|
||||
|
||||
|
||||
|
||||
影子表是在同一个 Schema 下为每一个表再创建一个相同结构的表,测试请求访问的是 shadow 开头的表。
|
||||
|
||||
影子表的实现非常复杂,一般团队难以驾驭,因为它涉及到复杂的 SQL 语句解析和改写。例如,创建订单,最终写入数据库的 SQL 语句是下面的样子:
|
||||
|
||||
insert into t_order(id,order_no,...) values ();
|
||||
|
||||
|
||||
如果判断出是测试请求,我们首先需要解析这条 SQL 语句中的所有表,并将表转化为影子表,再执行这条 SQL 语句。要是碰上连接、多层嵌套 SQL,要正确解析语句会非常困难。
|
||||
|
||||
受到团队规模的限制,加上我对 SQL 解析还没有十足的把握,所以我们在实践全链路压测时并没有选择影子表,而是使用了影子库。
|
||||
|
||||
无论是 MySQL,还是像 Oracle 这种关系型数据库,基本都是基于 JDBC 的数据源进行数据读写的。在 JDBC 中,每一个 Schema 对应一个数据源(Datasource),根据影子库的设计理念,我们通常需要创建两个数据源对象。如下图所示:
|
||||
|
||||
|
||||
|
||||
建好两个数据源之后,我们只需要根据当前线程本地环境中存储的流量标记,在需要执行 SQL 语句时选择相对应的数据源,创建对应的数据库连接,再执行 SQL,就可以实现数据的隔离机制了。
|
||||
|
||||
那如何优雅地进行路由选择呢?
|
||||
|
||||
我们可以借助 Spring-JDBC 提供的 AbstractRoutingDataSource 路由选择机制。它的核心机制如下图所示:
|
||||
|
||||
|
||||
|
||||
首先,我们会为项目中每一个正式数据源创建一个影子数据源。例如使用 HikariCPDatasource,然后为它创建一个对应的影子数据源,把两个数据源存入到 targetDataSources 集合中,然后再根据本地线程上下文中的流量标记,选择对应的数据源。实现代码如下:
|
||||
|
||||
private static final String ORIGIN_KEY = "originKey";
|
||||
private static final String SHARD_KEY = "shardowkey";
|
||||
public Object determineCurrentLookupKey() {
|
||||
if (PTLocalContext.shouldAsShadowOp()) {
|
||||
return ORIGIN_KEY;
|
||||
} else {
|
||||
return SHARD_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
通过这种方法,我们就轻松实现了数据源的选择,完成了根据流量标记选择对应数据源的任务。
|
||||
|
||||
不过,这种方式会带来一个问题。那就是,在压测过程中,每一个正式的数据源会再对应生成一个相同配置的影子消费组,这样数据库服务端的连接数会翻倍。
|
||||
|
||||
我们再来看看 MQ 方面如何进行数据隔离。
|
||||
|
||||
|
||||
|
||||
在消息发送端,我们通常通过对原生 SDK 进行封装,通过 AOP 等方法拦截消息发送 API,根据当前流量标记来判断是否需要改写主题名称。如果当前流量标记为压测流量,我们要把主题的名称修改为压测主题,把消息发送到同一消息集群的不同主题中。
|
||||
|
||||
当消费端启动时,要为每一个正式消费组再额外创建一个影子消费组,订阅的主题为影子主题。这样,我们就在消费端实现了线程级别的隔离。影子消费组的线程本地变量默认为测试流量,然后沿着消息的消费传递影子标记。
|
||||
|
||||
在全链路压测这个场景,为了不影响没有接入全链路压测的应用,使用影子主题与消费组基本是唯一的解法。因为没有接入全链路的消费组是无法感知到影子主题中的消息的,这就把边界控制在了合理的范围中:
|
||||
|
||||
|
||||
|
||||
当上游消息发送接入全链路压测时,如果流量是测试请求,消息会发送到影子主题。但是下游如果有应用暂时未接入全链路压测,应用就不会自动创建影子消费组,也不会去订阅影子主题,避免了多余的影响。
|
||||
|
||||
总结
|
||||
|
||||
好了,我们这节课就讲到这里了。
|
||||
|
||||
我们这节课开篇就解释了全链路压测的概念。我还结合一张生产环境数据流向图,引出了压测前、压测运行时、压测结束三个阶段的功能需求,重点介绍了流量染色与透传机制和主流中间件的数据隔离机制。
|
||||
|
||||
在流量染色部分,我们重点提到了使用线程本地变量存储染色标记的方法,并采用阿里巴巴开源的 transmittable-thread-local 类库,在多线程环境下安全地传递了染色标记。然后,我们又分别针对 HTTP 请求与 Dubbo RPC 请求,讲解了让请求标记在进程之间传递的方法。
|
||||
|
||||
最后,我们还学习了主流中间件的数据隔离机制,了解了数据库影子库、消息中间件的影子主题、影子消费组的具体落地思路。
|
||||
|
||||
课后题
|
||||
|
||||
学完这节课,给你留一道思考题。
|
||||
|
||||
全链路压测中,一个最容易出现的问题就是流量标记的丢失。你知道为什么 JDK 自带的 ThreadLocal 无法在多线程环境中传递吗?那阿里开源的 transmittable-thread-local 又是怎么支持多线程环境下本地线程变量的自动传递的呢?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
133
专栏/中间件核心技术与实战/大咖助阵高楼:我们应该如何学习中间件?.md
Normal file
133
专栏/中间件核心技术与实战/大咖助阵高楼:我们应该如何学习中间件?.md
Normal file
@ -0,0 +1,133 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
大咖助阵 高楼:我们应该如何学习中间件?
|
||||
今天我想跟你聊聊我对技术的看法。
|
||||
|
||||
最近我在做一些技术岗位的面试。面试过程一般是这样,首先,我会根据简历上的描述进行一些常规的提问。在这之后呢,我还会问一些技术的细节和原理。但是很遗憾,我面试十个人,也不见得能有一个半个能对技术细节有深刻的领悟。
|
||||
|
||||
举一个我今天在面试中问到的一个问题,“我们如何确定一个 ES 的线程数是够用的?”面试的人想了很久也没回答出个所以然来。然后我又继续往基础里问:“那在操作系统上,我们如何理解 load average 这个数据的含义呢?”对方的回答是,这是 cpu 的队列,这个值不高于 cpu 的个数即为正常。这句话明显是在网络上的各种文章里看到的。然后我又问:“只有 cpu 的队列吗?那如何通过这个值来判断处理问题的方向呢?”面试者又回答不出来了。
|
||||
|
||||
我们对底层的掌握还远远不够
|
||||
|
||||
这个面试者就是我说的对技术细节的掌握还不够精确。而且这还不是个例,在我的面试经历中,很多人面对类似的问题都没有给出明确的、有逻辑的回答。
|
||||
|
||||
也许你要说,没有一个人可以完全掌握所有技术,这取决于岗位的要求。确实,对于一份养家糊口的工作来说,我们卖出时间换来生存的资本,在一个岗位上能够尽职尽责已经非常难得了。
|
||||
|
||||
我们的职位是有技能范围的要求的,企业招人,会先给出岗位和 JD,这时候你只要满足这个技能要求就可以了。但是从个人技能在市场上的竞争力来看,我们还有技能范围的要求吗?当你的能力大于岗位的技能要求时,那必然是有更多工作机会的。
|
||||
|
||||
如今技术仍然在飞速发展,技术市场上充斥着花里胡哨的上层应用,但是我们不得不承认一个事实,那就是当今人们对技术的敬畏仍然非常有限,对底层的掌握和了解也远没有达到脱贫的水平。而中间件就是其中一个薄弱的环节。
|
||||
|
||||
我们应该如何学习中间件?
|
||||
|
||||
如果我们把技术粗略地划分为软件和硬件,它包含的内容大概如下图所示:
|
||||
|
||||
|
||||
|
||||
当今的互联网行业,技术复杂度越来越高,中间件是一个绕不开的话题。以前,由于技术架构单一,中间件无非就是那么几个。但是随着分布式、微服务的兴起,中间件的范畴越来越大。这些中间件你不需要全都非常精通,但你至少要精通那么一两个。而且,在有新的需要的时候,还得能够快速掌握它们。怎么做呢?我们从下面三个方面来拆解一下:
|
||||
|
||||
|
||||
技术操作能力;
|
||||
原理理解能力;
|
||||
综合判断能力。
|
||||
|
||||
|
||||
技术操作能力
|
||||
|
||||
不管你去学习什么技能,一开始要具备的一定是动手能力。
|
||||
|
||||
有些人只知道概念,抬手就忘记操作方法,这类人我是不相信有什么发展前途的。有人说他熟悉 Redis,但是聊到 Redis 的操作细节却连常规的操作命令都不清楚,这样的人你敢让他去处理 Redis 的问题吗?那显然是不行的。
|
||||
|
||||
操作能力如何锻炼呢?只有一个招,那就是:动手。把一个命令操作个一万遍,即便你不想记住,抬起手来也能行云流水地操作下来。这就像跑步的时候,你一开始可能连 500 米都跑不动,但是你坚持一年,再抬起腿来的时候,腿的肌肉记忆就能自然而然地带着你往前跑。所以我绝对不相信那些说什么都接触过,但是一谈到细节就记不清楚的人。
|
||||
|
||||
我记得我几年前参加过一个技术研讨会。会后吃饭的时候,我们又聊起来一个技术细节,是关于数据库的。聊到一半,有个人直接把笔记本电脑拿出来,把具体的技术细节给我们操作了一遍。我当时就非常欣赏他。当然,他是一个做了二十年的数据库专家,技术过硬,但我并不是欣赏他的技术,而是欣赏他对技术这种认真的态度。
|
||||
|
||||
不过说归说,面对现在市场上五花八门的中间件,要想完全地掌握所有的操作细节也是不现实的。但是你要在至少在一个类型的中间件上做到拔尖。俗话说文无第一、武无第二,在我看来,技术行业也属于武行,不会就是不会,只有去练习,不需要解释和狡辩。
|
||||
|
||||
在中间件的学习上,我建议的学习路径是下面这样的。
|
||||
|
||||
首先要学一个关系型数据库。虽然现在的内存数据库、文档数据库、时序数据库等等已经有很多种了,但是关系型数据库仍然是不能被忽视的。至少现在不太可能抛弃掉关系型数据库,而其他的数据库都是在关系型数据库适应不了某类场景时衍生出来的。比如你做一个文档全文搜索,这时候如果你一定要用 MySQL 那就是在跟自己赌气了,因为选择 Elasticsearch 是更合理的,它就是为此而生的。同样,如果你做一个金融交易系统(如数字货币)而不考虑时序数据库,那也会产生技术债。
|
||||
|
||||
那为什么我还要强调学习关系型数据库呢?因为关系型数据库的技术特点(比如 ACID)决定了它的应用场景。对于一些核心系统的数据持久化来说,关系型数据库仍然是首选。而且当你完全掌握了一个关系型数据库,你再去看其他的数据库都会觉得“怎么这么简单”。
|
||||
|
||||
具体来说,我推荐你学习 MySQL。因为它用户群大,资料多,免费又开源。
|
||||
|
||||
其次,学习缓存和队列。对于现在流行的技术栈来说,缓存和队列对分布式架构的作用那是相当的大。再加上现在的系统容量越来越大,为了应对海量请求,缓存可以大幅提高容量,而队列则可以降低系统产生的瞬间冲击。
|
||||
|
||||
这里我推荐学习 Redis 和 Kafka。原因也是差不多的,它们的用户群大,资料多,还免费开源。
|
||||
|
||||
接下来,你要学习负载均衡相关的知识。不懂负载均衡是不可能理解架构的(只理解负载均衡也不能理解架构)。负载均衡可以为系统提供更高的吞吐能力。不管是软负载还是硬负载,不管是四层还是七层,负载均衡的目标都是为了让系统的容量变得更大。这里我推荐你学习的是 Nginx。
|
||||
|
||||
第四点,你要学习一门语言。
|
||||
|
||||
有一点是确定的,那就是一个开发工程师因为对语言更理解,所以对一些技术细节的掌握比很多其他岗位要扎实一些。而我们现在所有的系统,除了一些常见的固定技术组件外,开发工程师编写的代码是一大变量。
|
||||
|
||||
但是很多人因此把 IT 行业的开发岗看得特别高,一有问题就是“找开发”,似乎开发可以给我们解决各种问题。我特别不能理解这一点,因为从我的技术和管理经历上来看,开发的能力差异是很大的。有很多开发只关注业务代码的实现,对其他的内容也是一知半解。前几天在项目上和一个干了多年的开发处理一个问题,我已经告诉他是哪段代码有问题了,但他还是一直在说”我们再试试”,试了两个半小时毫无进展,最后还是回到我一开始判断的方向上。
|
||||
|
||||
说这些不是为了贬低开发这个职位,而是说每个人都有自己的知识范围,我们不能过于依赖开发,要自己学着去理解开发语言的运行逻辑。因为所有的软件系统都是由开发语言编写出来的,当你学会一门语言之后,就可以对很多软件系统有更深入理解的机会。在分析技术问题时有更多的洞见和能动性。
|
||||
|
||||
我认为,对于应用层的开发,学习 Java 或者 Python 任意一门语言即可。
|
||||
|
||||
第五点,学习操作系统。之所以在最后才提及操作系统,主要是因为我们在讲中间件,自然要把中间件往前放。不过在我看来,所有的技术栈,不管你玩什么,也不管你在什么岗位,操作系统都是绕不过去的基础技能,不懂操作系统就不能说是干技术的。操作系统的文件操作、用户操作、网络操作、配置操作等等都是我们”必备”的技能。
|
||||
|
||||
我推荐学习的操作系统非 Linux 莫属,任意一个发行版都可以。毕竟 Windows 在服务端领域还是跟不上溜的。
|
||||
|
||||
学过了数据库、缓存、队列、负载均衡,掌握一到两门常用的语言,也清楚操作系统的基本玩法,就可以说有了技术操作能力了,那下一步就是培养原理理解能力了。
|
||||
|
||||
原理理解能力
|
||||
|
||||
这些年,我深感技术行业中大家原理理解能力的欠缺。我们不管是在大学,还是在职场,都常常忽视原理。而当前云计算的发展让大量的 IT 技术人员失去了对基础技术组件的操控权,我们对原理的理解能力就更弱了。在中间件上的表现就是,云环境给我们提供了应用级的操作入口,但是却关闭了我们进一步理解原理的权限。
|
||||
|
||||
像网络、存储、数据库、缓存、队列等这些组件,如果是一个应用层的 IT 技术人员,公司可能会考虑到安全问题而限制他们的权限。从管理上来说,这种限制无可厚非,但在能力的培养上,那绝对是越走越窄的路子。
|
||||
|
||||
既然在应用层的原理已经是很多人的薄弱环节了,那像这个专栏中提到的多线程模式、锁、数组与链表等等就更需要功力了。
|
||||
|
||||
我建议要想学习原理,就去看具体技术组件的实现逻辑。通俗点说就是翻代码。不是要你去开发这些底层逻辑,而是要完全掌握,只有完全掌握了才能用得流畅。比如在 Java 中,如果我们不能理解多线程的原理,就不可能设计出高性能、低延迟的系统;在网络中,不理解阻塞 / 非阻塞 IO、同步 / 异步 IO,就不可能设计出适合业务逻辑的架构。
|
||||
|
||||
这里为什么我没有拿某个具体的中间件来举例子呢?因为我们现在提到的大部分中间件,从操作系统的视角来看,都只是一个又一个的软件,这些软件都必然遵循同样的底层原理。中间件是为了特定场景而存在的,但底层逻辑不会有变化。
|
||||
|
||||
就像协议一样,我们用了几十年的 HTTP1.1 了,虽然现在 HTTP2.0、3.0 都已经出现了,但是 HTTP1.1 依然在互联网上大量存续。你理解了 HTTP1.1 之后,2.0、3.0 也很容易掌握。
|
||||
|
||||
所以我强烈建议你关注原理层面的理解能力。在上层应用日新月异的时候,我们在技术实现层面消耗了太多精力。如果年轻人都只是将青春倾注在面对用户的应用系统上,习惯性地忽略底层原理,那我们技术市场的生命力就会越来越弱。
|
||||
|
||||
综合判断能力
|
||||
|
||||
有了上面的两种能力之后,就必须进入到第三重技术功力了:综合判断能力。
|
||||
|
||||
也许你会有这种感觉,在技术的汪洋大海之中摸索了很多年,操作能力已经非常强了,原理也大都能理解,但是仍然无法快速处理问题。这就说明你缺乏综合判断能力。
|
||||
|
||||
在我们有了技术操作能力和原理理解能力之后,其实还需要把技术栈梳理一遍,将这些散乱的知识点连接起来,形成自己的综合判断能力。
|
||||
|
||||
我们经常会看到这样的场景,一个项目遇到了技术问题,卷入了很多技术大牛一起分析,这时候的沟通成本比解决技术问题本身花费的时间多得多。
|
||||
|
||||
举例来说,当你看到一个中间件跑在一个 K8s+Docker 的微服务分布式技术栈中的时候,如果中间件把 CPU 使用率消耗到 100%,我们能不能在五分钟之内确定这是哪一段代码或配置导致的呢?
|
||||
|
||||
如果你是有经验的,我觉得五分钟都长了。在这个具体的场景中,可能你只需要执行一段脚本,一分钟就可以告诉别人这是哪一段代码或配置惹的祸。但是这个分析过程是需要有证据链的,这个证据链的梳理过程就需要你有足够的技术操作能力和原理理解能力。
|
||||
|
||||
当我们分析这个问题时,首先要判断是什么 CPU 使用率达到了 100%,是 us、sy 还是 wa、si?我们在分析中间件的时候,每个不同的 CPU 计数器会给出明确的方向,如果是 us CPU 使用率高,你却一直在往如何减少中间件的读写上使劲,那必然是南辕北辙了。
|
||||
|
||||
再比如,当我们发现队列服务器中有大量积压的消息时,我们要判断的方向就非常多。是消费者能力不行了?还是网络阻塞了?还是计算能力到头了?分析和解决这样的问题,都需要我们的综合判断能力。
|
||||
|
||||
我们不可能完全掌握所有的技术细节,但是当你有了自己的综合判断方法论之后,遇到没有见过的技术难题也就不会发怵了。综合判断能力就像灯塔一样,可以给你指明方向,而不至于摸不着头脑地告诉别人“再试一遍”。
|
||||
|
||||
总结
|
||||
|
||||
总结一下。技术操作能力是每个 IT 从业人员的基础,原理理解能力是提升自己的必经过程,综合判断能力是让你成为技术人中龙凤的唯一可能。
|
||||
|
||||
在一层一层向上探索的过程中,人的惰性是必然要攻克的难关。我看到有很多技术人,一遇到简单的技术操作,就有点不屑一顾,不愿意花更多时间去深入学习。这样就容易慢慢丧失掉操作能力,是技术成长过程中的大忌。
|
||||
|
||||
也许你也遇到过这样的人,当他在计算一道题“一排 4 颗树,一种 4 排,共有几颗树”时,可以非常快速的告诉你是 16 颗树。但当你把树换成电线杆子的时候,他就不会算了。这就是缺乏技术操作能力的一种表现。
|
||||
|
||||
原理理解能力虽然没有技术操作能力那么显性,但是在能力提升速度上会有明确的差异。就像有的企业只招 985、211 出来的学神一样,虽然不是所有的 985、211 出来的人都比大专生强,但是从比例上来说,你不得不承认,好学校出来的学生,他们的知识积淀确实比大专生更深厚,学习新技能也会更快(这里只是类比,并不特指)。技术也是类似的,当你掌握了技术的原理之后,你会进入一个触类旁通的境界。
|
||||
|
||||
综合判断能力是每一个技术岗位都应该努力拥有的一种能力。只有拥有这种能力才能真的变成某个领域中的真正的专家。因为综合判断能力带来的直接优势就是,你可以处理得了别人处理不了的问题,看到别人看不到的风险。
|
||||
|
||||
从我对技术市场的理解来看,我们仍然需要更多的人一起去理智思考,反思我们缺少什么,然后去弥补缺少的东西,这样才有机会让技术市场更进一步。如果像一些互联网企业一样,只是追逐利益,不断推陈出新想着如何设计出赚钱的系统,每天想的都是如何割韭菜,而不为市场的良性发展负责,最终的结果就是百业凋敝。
|
||||
|
||||
技术的发展说到底还是靠市场上的这些技术人,希望我们每个技术人都可以理智地提升自己的综合技术素养,一起成长进步,让我们的技术市场越来越强悍。
|
||||
|
||||
|
||||
|
||||
|
61
专栏/中间件核心技术与实战/用户故事学而时习之,不亦乐乎.md
Normal file
61
专栏/中间件核心技术与实战/用户故事学而时习之,不亦乐乎.md
Normal file
@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
用户故事 学而时习之,不亦乐乎
|
||||
你好,我是大熊。
|
||||
|
||||
目前是一名 Java 后端开发工程师,坐标广州,主要从事人事系统的研发工作。
|
||||
|
||||
我算是《中间件核心技术与实战》这个专栏最早的一批读者了,专栏上线伊始我就被这个“高大上”的专栏名称给吸引住了。试看了前两节,感觉干货满满,就开始“路转粉”。专栏上线至今也有一段时间了,我也有了不少收获,今天就在这里跟你分享一下。希望也能给你提供一些新的思考和启发。
|
||||
|
||||
为什么要学习中间件?
|
||||
|
||||
作为一名Javaer,只要公司在使用的技术栈不是太老,工作中就难免与各式各样的中间件打交道。有了使用的需求,就有了了解的必要。不知道你是不是跟我一样,在使用各种中间件组件的时候,都是把它们当成一个“黑盒子”。从官方的示例教程中简单学了一下搭建方法、使用 API 以及大致原理之后,就在项目里用起来,也不去深挖中间件背后的实现原理。
|
||||
|
||||
这种使用方式不能说不好,但是有个很致命的问题。那就是,一旦项目中使用的中间件组件出现问题,我们往往就会手足无措。幸运的话,我们可以“面向谷歌编程”,在网上搜索报错信息,找到别人的解决方案。
|
||||
|
||||
但是,网上的信息稂莠不齐,而且别人的场景也不见得跟你的一样,把别人的方案生搬过来也不一定能起什么作用。此时,我们就不得不求助于中间件的源码了。正如 Linux 创始人 Linus Torvalds 所言:”Talk is cheap,show me the code.“阅读中间件的源码是了解中间件设计最直接也是收获最大的途径。
|
||||
|
||||
我也分享一下我最近的一段经历。当时,我们项目用到了 RabbitMQ。为了提高系统可用性,我要在我们公司的 Kubernetes 集群上部署一个三个节点的 RabbitMQ 集群。我按照官网的 YAML 文件示例,在 Kubernetes 集群新建了各种资源,但是在最后的启动阶段,我发现三个节点的容器都无法正常启动。检查 RabbitMQ 的启动日志之后,我发现了一段错误日志,这段日志的大致意思是,“有一个名叫 Host 的字段没有找到”。
|
||||
|
||||
我在网上搜了一圈,也没找到这方面的回答。这就让我很抓狂。后来没办法,我只能硬着头皮去 GitHub 翻出 RabbitMQ 集群发现组件的源码来看(这里不得不吐槽一句,Erlang 是真的难懂)。
|
||||
|
||||
我定位到日志输出的那段代码之后,仔细阅读了一遍,发现它的执行逻辑是这样的:容器在启动的时候,发现组件会向 Kubernetes 的 API Server 请求处于同一个 StatefulSet 下的所有副本信息,然后从副本信息中提取 Host 字段,让它们彼此进行注册。
|
||||
|
||||
那我们的系统为啥会找不到 Host 字段呢?
|
||||
|
||||
为了搞明白这个问题,我自己去尝试请求了一下那个 API,发现在这一步返回的信息中就没有 Host 字段。最后,确定是我们公司的 Kubernetes 集群部署存在问题。将问题反馈给维护方之后,问题就成功解决了。
|
||||
|
||||
这个专栏给我带来了什么帮助?
|
||||
|
||||
从这个例子就可以看得出,了解中间件的设计原理和底层实现确实十分重要,而阅读源码就是达到这个目标一个很好的方法。但是,任何一个大型的中间件项目,它的源代码行数都是上万级别的,我们漫无目的地去看项目源码显然效率不高,而且也容易抓不住重点,导致最后迷失在函数调用中。而这个专栏正好在代码的汪洋大海中给我们指明了一个方向。
|
||||
|
||||
在专栏的基础篇,丁威老师介绍了中间件编程必知必会的数据结构,多线程编程以及 IO 编程等几个领域的核心知识。这些知识都是实现高性能中间件的基石,为我们了解中间件的底层实现扫清了障碍。
|
||||
|
||||
我个人尤其喜欢第六讲《锁:如何理解锁的同步阻塞队列与条件队列?》,这一讲从源码的角度剖析了 JUC 里一些基于 AQS 常用锁的实现。带着我们一步一个脚印,先掌握执行流程,然后又到代码中去求证。做到了知其然,也知其所以然。
|
||||
|
||||
在实战篇里,丁老师分享了自己在实践中对 Dubbo 和消息中间件的一些巧妙应用。有些使用方式不禁让我感叹:“原来还能这么用!”这也极大地扩宽了我的视野。
|
||||
|
||||
值得一提的是,丁老师还分享了在自己的项目中用 Dubbo 和 RocketMQ 落地蓝绿发布的方案。恰好这段时间,我自己所在的项目组也计划搭建租户隔离环境和灰度发布环境,专栏内容给了我不少启发。
|
||||
|
||||
最开始我们用的是 RabbitMQ 的虚拟主机机制,想通过它对不同租户的业务消息进行隔离。但是这种做法太过绝对了,导致一个多租户公用的服务在想要监听所有租户的相关消息时,实现成本太高。看了丁老师对“基于消息主题的隔离机制”的分析之后,我们放弃了每个租户配置一个虚拟主机的方案,改为每个租户都有属于自己的主题队列,并以租户标识作为后缀。这样一来,多个租户公用的服务就可以使用通配符模式消费了。
|
||||
|
||||
我是如何学习本专栏的?
|
||||
|
||||
最后,我想再聊聊我学习这个专栏的方法。
|
||||
|
||||
丁老师的这个专栏干货满满,有时候看了一遍却感觉没有消化多少。遇到这样的内容,我就会隔一段时间再复习一遍。第一遍看不懂的东西,过几天来看或许会豁然开朗。这大抵是“书读百遍,其意自现”的道理吧。
|
||||
|
||||
极客时间的专栏大都配有音频,很方便我们在上班通勤这段时间学习。不过,因为专栏内容很多都附有代码样例,这都是音频呈现不出来的,而且手机上也无法编译运行样例,所以我还是倾向于专门规划一段时间来学习,这个专栏的知识密度更应该如此。每次的学习时间不需要太长,半个小时到一个小时左右即可。
|
||||
|
||||
另外,我觉得评论区也对学习有非常不错的辅助作用。学完一节课之后,可以扫一遍评论区的内容,看看同学们提的问题你有没有想到。如果你也想到了,那你的回答跟老师的有什么差别?这些都是检验自己学习成果很好的手段。
|
||||
|
||||
最后我想说,好记性不如烂笔头。我随时都会拿个笔记本把自己感兴趣的,或者没听过的新概念、新技术记录下来。如果时间允许,我还会在纸上画出知识点之间的关联,时常翻阅温习一下。
|
||||
|
||||
我们每个人的实践经历不同,学习同一个专栏也一定会有不同的理解与收获。也期待的你的留言,我们共同进步。
|
||||
|
||||
|
||||
|
||||
|
67
专栏/中间件核心技术与实战/用户故事愿做技术的追梦人.md
Normal file
67
专栏/中间件核心技术与实战/用户故事愿做技术的追梦人.md
Normal file
@ -0,0 +1,67 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
用户故事 愿做技术的追梦人
|
||||
你好,我是徐拥,目前是京东科技交易相关部门的软件开发工程师。
|
||||
|
||||
我从《中间件核心技术与实战》刚上架的时候,就一直在跟随丁老师的脚步学习中间件相关的知识。在这个过程中,我对各种中间件有了一些新的认知,也收获了很多新的思维方式。很高兴能在这里跟你分享我学习课程的心得体会和学习方法。
|
||||
|
||||
为什么要学习和中间件相关的专栏?
|
||||
|
||||
在互联网电商领域,高并发场景下,数据流量动辄就是 GB 级、TB 级甚至是 PB 级的,这些数据都需要服务器在短时间内处理完,这就涉及到如何充分利用单机的性能,如何保障服务间的交互等问题了。
|
||||
|
||||
于是, 削峰填谷、降级限流、异步解耦、分布式,各种概念扑面而来。而它们也成为了系统交互、任务处理的关键。在我所在的岗位上,分布式事务、数据一致性、突发流量的应对、大数据处理,这些都是工作必备的基础知识。
|
||||
|
||||
比如,某个审计系统通过 KafkaStream 对接实时流量数据,在经过过滤清洗后,存储在 Es 的数据量依旧非常大。
|
||||
|
||||
比如,每天的订单量虽然会流转很多系统,但是必须保证事务的最终一致性和补偿 / 对账机制,不能有一丝的差错。
|
||||
|
||||
再比如,在活动系统中,每天官网都会有优惠秒杀、限时折扣等类型的活动。
|
||||
|
||||
如果负责这些板块,但却不能尽快熟知各个组件的原理,尽快解决或者避免各种问题的发生,那既是一种失职,也会让自己的技术水平日益落后于同事。所以在工作之余,我会主动地收集从点到面再到点的相关资料,修炼专项技能,吸取很多业界大佬和同事们的经验。
|
||||
|
||||
兴趣是最好的老师
|
||||
|
||||
作为一名初级程序员,我和很多人都一样,学习的动力主要是基于对新技术的兴趣,看到各种新技术就想 demo 它。对于不同中间件推出的新特性,我也经常迫不及待地想要尝尝鲜,可以说是乐在其中。但是每次出现没见过的问题,自己解决不了,我就只能去 Google。这也是大家的常规操作了,不过时间久了,我也发现自己对它太依赖了。
|
||||
|
||||
这么做的问题是,如果我对组件的了解只停留在用的程度,也就是黑盒。一旦出现问题,就可能自乱阵脚。这时候我们需要知道的是,组件这块是怎么写的,然后扒拉一下它的代码,看看各种包、类、方法和思想。
|
||||
|
||||
理解组件的原理和思想,要从 case 入手
|
||||
|
||||
接下来,我想跟你分享一下我在学习 Kafka 时的经验。
|
||||
|
||||
首先,push 下代码后,要把对应的环境搭建好。然后从 case 入手,理解这个 case 的用途,它是测试什么的。然后一层层、一行行地 Debug。如果对哪个模块感兴趣,就去写个小 demo 运行一下,了解这个模块的具体作用(要想研究源码,首先得知道这个模块是干啥的,有什么作用)。
|
||||
|
||||
在 Debug 的过程中,我们第一遍的重点是根据方法名,简略地看下逻辑,每个方法都用了哪些类。
|
||||
|
||||
第二遍看源码,要深入特定模块,看看这些类承担了哪些功能,使用了哪些设计模式。如果看完还是有点迷糊,就需要重复这一步,同时根据主要类画出调用时序图。这样,根据图再去看代码,思路就会更加清晰一些。其实这个过程也是在印证你思考的过程。你会被带入某些场景中,有更深刻的理解。
|
||||
|
||||
学习的另一条“捷径”——线上问题
|
||||
|
||||
学习的另一个“捷径”,就是让 Bug 给自己带路,通过解决线上遇到的问题,来扩大自己的知识储备,提高应对问题的能力。这个学习思路在丁威老师的案例课里也有体现。
|
||||
|
||||
虽然我们踩过的坑前人基本都踩过,但不得不说,什么事都有例外。因为各种环境不一致导致的问题就是这样。
|
||||
|
||||
举个例子,在 KStream 消费时,如果中间件或者网络不稳定,超过了 Kafka 的默认配置,这个时候服务虽然健康,但是消息已经不消费了。因为线上消息是非常庞大的,在很短的时间内, Lag 就会特别大。
|
||||
|
||||
出现这个问题之后,我们也是第一时间重启恢复了消费,紧接着就是复盘。以前,我的学习都是这样由 Bug 驱动的。出现问题我会第一时间 Google 一下, 但是还是找不到原因,所以我发邮件请教了 Kafka 团队。同时,把 Kafka 代码下载下来看,最终发现修改某些心跳参数和拉取量,就能尽可能避免这种问题的出现。
|
||||
|
||||
在这期间,我对这模块代码的理解也有了质的飞跃。不过,只是 Bug 驱动的学习,会让知识体系变得比较零散。今年 6 月,我发现了极客时间的专栏,这个新大陆发现得太及时,我一眼就爱上了。
|
||||
|
||||
丁威老师的这个专栏,刚好弥补我在系统性上的欠缺。它让我慢慢开始从架构师的视角来看待业务,同时也让我认识到,技术是服务于业务的,所以不要过度设计,因为这样会提高团队的学习成本。
|
||||
|
||||
写在最后
|
||||
|
||||
很荣幸能和大家共同学习,我也认为,订阅这门课程的同学本身就对技术和视野有更高的追求。
|
||||
|
||||
借用黄清昊老师的一句话 :“优秀的同学往往对技术本身有着更强烈的好奇,比如出现一些事故的时候,想办法快速解决问题之后,他们往往会做更深刻的复盘,去了解相关中间件或者代码背后的运行机制,甚至还会做一些分享。”也祝我们在学完课程后,自身的能力都可以更上一层楼。
|
||||
|
||||
最后,我想借这个专栏,感谢一下在我刚进入团队时就带着我的成良和英草导师,是他们让我成为一个更坚定、更有温度的技术追梦人。世界急剧变化,未来山高路远,但相信一定可以江湖再见。
|
||||
|
||||
期待你的留言,我们一起交流和讨论,共同进步!
|
||||
|
||||
|
||||
|
||||
|
61
专栏/中间件核心技术与实战/用户故事浪费时间也是为了珍惜时间.md
Normal file
61
专栏/中间件核心技术与实战/用户故事浪费时间也是为了珍惜时间.md
Normal file
@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
用户故事 浪费时间也是为了珍惜时间
|
||||
你好,我是苜蓿,目前是一名 Java 后端开发,已经工作四年了。
|
||||
|
||||
首先非常感谢《中间件核心技术与实战》的课程编辑邀请我分享学习经历和心得,当然更要感谢丁威大佬的这门课程,让我收获了很多知识。
|
||||
|
||||
我的学习方法跟别人可能不太一样。经过了一年多的摸索,我逐渐掌握了“浪费时间学习法”的要义,今天就来跟你分享一下我的经历和思考。
|
||||
|
||||
改变:从绝望之谷到开悟之坡
|
||||
|
||||
记得两年前,我还在教育行业,当时我所在的公司开始收回在线业务模块,刚巧新冠疫情袭来,我就进入了疯狂加班模式。当时我的工作范围让人有点迷惑,说是在开发吧,好像也没在写代码。每天都在处理一线问题和一线业务沟通协调,紧急解决生产问题。几乎每周要进行压测,疯狂熬夜。不知道你有没有类似的工作状态。
|
||||
|
||||
后来公司业务调整,我就去了另外一个部门。新部门业务量逐渐大起来,又恰逢负责这个项目的架构师离职,还赶上暑假报名,我又进入了疯狂熬夜的状态。
|
||||
|
||||
那时候我是挺焦虑的,应该是开始进入邓宁 - 克鲁格心理效应里的绝望之谷了吧,这还是从小乔老师的课上学到的概念,附上一张小乔老师给出的图片供你参考一下。
|
||||
|
||||
|
||||
|
||||
这个阶段我迷茫过,也很焦虑,不过它也给了我必须做出改变的动力。孔子说:吾十有五而志于学,但其实我是到了这时候才有了志于学的想法。
|
||||
|
||||
学习:要敢于“浪费时间”
|
||||
|
||||
我一口气买了很多课程(Java 训练营、云原生训练营,算法训练营,还有文字版课程),算是给自己一些心理安慰。浑浑噩噩算是学完了 Java 训练营,这时候心里对后端算是有谱了。哦,原来我需要的是这些框架上的东西(计算机相关基础、Java 基础、中间件、微服务、云原生)。其实这些内容在公司里每天都能见到,只是自己之前没留心。
|
||||
|
||||
这就是我一开始说的“浪费时间学习法”。听起来不太靠谱,其实意思就是,要接受迷茫,花时间去探索不同的知识,找到不同知识的交叉点。
|
||||
|
||||
虽然学得多难免忘得也多,但时间久了我们脑海中的知识结构会越来越清晰。搭建起自己的学习网络之后,我们就能有针对性地再慢慢去深挖了。落实到具体的操作呢,就是“单点攻破 + 多点涉猎”。
|
||||
|
||||
今年二三月份,因为教育行业的现状,我换了公司。这时候我的心态已经趋于平和了,知道自己要干嘛,也知道自己缺少哪些知识,于是我开始有计划、有阶段地安排学习。
|
||||
|
||||
第一阶段我重新学习了一遍计算机基础相关的东西,包括基础、网络、组装三剑客,中间穿插着学习 MySQL、Redis。
|
||||
|
||||
但是这段时间学得还是很累,不懂的也好多,于是又难以避免地浪费了一些时间。我开始重新思考是不是应该专攻某一点,然后稍微穿插学一些其他领域的东西。比如产品、设计、项目等。
|
||||
|
||||
所以我第二阶段的计划是学习算法,一边学习一边认真总结,同时也穿插着学点 JVM,不过不花主要精力,只看不实操。就这么学习完了线性结构、树形结构,又开始学常用的高阶结构,这时候我慢慢有点上道的感觉了。原来树结构和 MySQL 底层有关系,红黑树是这样“玩”的,丁威老师这门课原来也和 Dubbo 有关系。感觉自己的知识体系慢慢建立起来,知识点正在交叉成网。
|
||||
|
||||
我其实是打算把中间件放在第三阶段,和微服务框架一起穿插学习的。这方面我还没有全方位展开,所以丁老师这门课我暂时没有全部学完。但我也看了基础篇还有和公司业务相关的一些章节。
|
||||
|
||||
比方说,实战篇里,老师谈到了服务网关设计。其实我们公司现在也有一个项目是要解决三方对接问题的。
|
||||
|
||||
我们的业务背景是要对接全国各省各市的监管部门,要进行业务数据上报和数据监管。和老师的项目一样,业务比较复杂,省有省的规范,市有市的标准,并没有统一规则。最难受的是有些业务只能使用内网。因为我们还不了解公司目前如何对接内网,所以还没找到解决方案,仍然是沿用之前比较笨重的方式。使用了签名接口、配置化数据抽取(包括 XXL 定时上报)。学了老师的课程,我感觉还可以加更多东西适配起来。
|
||||
|
||||
回顾我的学习历程,好像就是在浪费和走弯路里慢慢前进的。一开始学东西肯定不能一下就理解透彻,那只是电影里才有的情节。不要担心学不懂搞不定,有些事,就是要浪费时间才能慢慢找到通路的。
|
||||
|
||||
写在最后
|
||||
|
||||
现在的我还是有很多知识不会,但是我已经不像前几年那么迷茫了。我还自己创建了一个算法链接,记录自己的学习成果。也希望自己快点学完基础知识,进入到下一个学习环节,我会把在专栏中学到的知识都总结下来,放到评论区里。
|
||||
|
||||
“吾十有五而志于学”的下面几句是,“三十而立,四十而不惑”,丁威老师 2010 年在传统行业工作,2017 年去到和互联网高度相关的物流行业,七年的时间已经立了起来,我们也要多读书多学习,早点立起来。
|
||||
|
||||
最后许个愿吧,希望老师出个中间件常见疑难杂症和优化的课程,把中间件常见的问题总结一下,这样我们在后面遇到问题时,就可以在专栏中按图索骥了。丁威老师的公众号“中间件兴趣圈”也是个宝藏,推荐你关注起来。
|
||||
|
||||
我们求学路上见。
|
||||
|
||||
|
||||
|
||||
|
112
专栏/中间件核心技术与实战/结束语坚持不懈,越努力越幸运.md
Normal file
112
专栏/中间件核心技术与实战/结束语坚持不懈,越努力越幸运.md
Normal file
@ -0,0 +1,112 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 坚持不懈,越努力越幸运
|
||||
你好,我是丁威。
|
||||
|
||||
不知不觉,我们已经一起学完了专栏的所有内容。虽然学习的步伐远没有结束,但我们却是时候说再见了。今天这最后一节课,我想结合我的一些从业经验,分享我的一些职场感悟。
|
||||
|
||||
我是多数普通开发者的一个缩影
|
||||
|
||||
其实,那些业界的“大神”终究只是少数,我想,绝大多数的开发者都没有那么多光环。
|
||||
|
||||
十年前,我只是一名普通二类本科的毕业生,毕业后在一家小公司一呆就是四年。我当时主要从事的是电子政务方面的业务,虽然我很努力,解决工作中的问题也显得得心应手,受到了同事和领导们的认可。但受到所在平台和公司规模的限制,我的薪资待遇并不理想,技术水平似乎也在原地踏步。这样一来,我就有了离开的想法。
|
||||
|
||||
但我始料未及的是,我满怀信心地出去找工作,却备受打击地回来了。阿里系企业问的很多问题直接把我秒杀。比如:HashMap 的内存结构是什么?HashMap 为什么不是线程安全的?对大数据、高并发这些场景有多少了解?很多问题都是我连想都没有想到过的。
|
||||
|
||||
我才发现,在我职业生涯的前 6 年压根就没有机会接触高并发、大数据等技术场景,这导致我的求职屡屡碰壁。
|
||||
|
||||
2015 年,随着“互联网 +”理念的兴起,很多传统公司开始了 IT 信息化改造。部分拥有互联网经验的人才流入了传统行业,我也乘着这股浪潮入职了雅居乐地产公司的科技信息中心,参与了雅居乐地产智慧物业相关系统从 0 到 1 的系统的打造。
|
||||
|
||||
尽管“互联网 +”相关企业的并发量依然不高,但它们的技术架构、技术思想都借鉴了互联网企业,让我在一定程度上开了眼界,我的职业前景开始有些明朗了。
|
||||
|
||||
研究源码,打造技术影响力
|
||||
|
||||
在雅居乐地产公司任职期间,我们部门的首席架构师看中了我的技术热情和技术能力,询问我是否愿意参加 MyCat 开源社区,为 MyCat 开源社区贡献自己的一份力量。
|
||||
|
||||
说实话,当时我根本没意识到,这样一件事会给我的职业生涯带来前所未有的助力,当时我只是觉得有一个技术大牛认可你,那就无需想太多,直接干就对了。
|
||||
|
||||
我仔细研读了 MyCat 的官方文档,逐渐对分库分表中间件的工作原理有了一些较为深入的了解和思考,很快就开始在 MyCat 官方群中回答群友们的问题。
|
||||
|
||||
但我意识到,要代表 MyCat 官方社区为企业做一些 MyCat 咨询相关的工作,只看官方文档是不够的。这时候我们还需要阅读源码,理解中间件底层的实现细节,为开源社区编写更多更细的文档。也就是说,必须从一个文档使用者转为一个文档创造者。
|
||||
|
||||
于是我做出了一个非常重要的决定:阅读 MyCat 项目源码。不过,刚开始阅读 MyCat 源码的时候我还是举步维艰。这么大一个工程,我完全不知道如何下手。像无头苍蝇乱撞了两周之后,就有些坚持不下去了。
|
||||
|
||||
但我并没有彻底放弃,而是意识到自己确实是一个“技术菜鸟”,Java 基础薄弱,必须补齐。
|
||||
|
||||
我分析了一下各类主流的分布式架构,决定先研读 Java 基础数据结构、JUC(Java 并发框架)和 Netty(NIO 框架,网络通信基础框架)。
|
||||
|
||||
你可以看一下我的学习时间轴:
|
||||
|
||||
|
||||
|
||||
2016 年 10 月,我开始学习 Java 数据结构,学习的过程中,我会把知识点和自己的思考记录下来。到 2017 年 4 月,我完成了分析 Netty 源码的专栏。整个基础学习阶段大概持续了半年。这之后,我再次尝试阅读 MyCat 源码已经没有任何阻力了,于是接下来我又发表了《源码分析 MyCat》系列文章。
|
||||
|
||||
发布《源码分析 MyCat》专栏之后,我在 MyCat 开源社区的知名度越来越大。后来,在 MyCat 开源社区的引荐下,2017 年 11 月,我入职了上海优速物流公司,这也终于给了我在生产环境中处理高并发、大数据量的机会,我开始正式接触互联网分布式架构,薪酬也直接翻倍了。
|
||||
|
||||
为什么学完基础知识再读 MyCat 源码就这么轻松了呢?我想主要有下面两方面原因。
|
||||
|
||||
|
||||
基础技能逐渐提升,知识盲区渐渐补齐。
|
||||
|
||||
过往的源码学习经验让我总结提炼出了一套适合自己的源码学习方法论。
|
||||
|
||||
|
||||
我也把这套阅读源码的方法分享给你。
|
||||
|
||||
|
||||
了解这款软件的使用场景、以及它在架构设计中将承担的责任。
|
||||
寻找官方文档,从整体上把握这款软件的设计理念。
|
||||
搭建自己的开发调试环境,运行官方提供的 Demo 示例,为后续深入研究打下基础。
|
||||
先研究主干流程再专注分支流程,注意切割,逐个击破。
|
||||
|
||||
|
||||
坚持不懈,越努力越幸运
|
||||
|
||||
回顾这段经历,我觉得最难能可贵的是坚持。因为阅读源码是非常枯燥的,这半年中我遇到了无数难题,有很多次想要放弃。
|
||||
|
||||
我记得有一次我阅读 Netty 源码,当时刚刚写完 Netty 的内存泄露检测,准备开始研究内存分配机制。但这部分非常抽象,涉及到的数据结构特别复杂,需要掌握二叉树与数组之间如何映射,还牵扯到大量的位运算。这让我在探究 Netty 内存分配机制时寸步难行。
|
||||
|
||||
当连着一周、两周都无法取得突破时,我们很容易为自己找一个借口:这样持续投入时间,又没有进展,也没有回报,这不是在浪费时间吗?
|
||||
|
||||
当时我确实想过放弃。但转念一想,放弃后我会做什么呢?玩游戏?看电视?这不更是浪费时间吗?想清楚这一层后,继续攻关、突破就成了我唯一的选择。
|
||||
|
||||
每攻克一个难题,我都能得到极大的满足,技术攻关能力也越来越强。
|
||||
|
||||
我接连发布了 RocketMQ、Kafka、ElasticJob、Dubbo、Sentinel、MyBatis、Canal 等源码分析专栏,形成了较为完备的中间件知识体系(如果你想要获取这些源码分析专栏,可以关注“中间件兴趣圈”公众号,回复对应的关键字即可,例如回复 RocketMQ 即可获取 RocketMQ 专栏系列文章):
|
||||
|
||||
|
||||
|
||||
在这个过程中,又发生了一件我意料不到的事情,出版社邀请我出版一本书。
|
||||
|
||||
写书,这是我连想都不敢想的事情。因为我高中阶段的语文成绩一直在及格线徘徊。但在编辑老师的帮助下,《RocketMQ 技术内幕》一出版就受到了大家的认可。
|
||||
|
||||
这本书也逐渐打开了我的知名度,让我顺利进入物流行业的头部企业中通快递担任资深架构师。这份工作需要负责日均消息流转量超万亿级别的集群,让我能够将理论与实践相结合,极大提升了我对消息中间件的理解和把控能力。
|
||||
|
||||
回首这些年的工作经历,正是坚持不懈的努力让我获得了今天的成绩。反过来,也正是我收获的这些正向反馈让我有了持续学习的动力,让我坚定地拥抱开源,走技术分享的道路。不要怀疑,越努力真的会越幸运。
|
||||
|
||||
中间件研发的两条技术成长路线
|
||||
|
||||
不过也许你会问:你对 RocketMQ 这么熟悉,为什么没有成为 RocketMQ 的 Committer 呢,为什么没有参与 RocketMQ 的代码贡献呢?
|
||||
|
||||
要回答这个问题,我们要先理清中间件研发的两条技术成长路线。
|
||||
|
||||
第一条路线:成为开源项目的创造者,也就是走代码贡献路线,成为开源项目的 Committer。
|
||||
|
||||
中间件的细分领域非常多,例如微服务、消息中间件、缓存、搜索、数据库分库分表等。而选择成为 Commiter,通常意味着需要选择其中一个方向深耕。这样做的难度一般会比较高,但一旦取得突破,成为这方面的专家,在行业中的地位就会比较稳固,薪资待遇当然也不会差。
|
||||
|
||||
但这条路的缺点是职位选择范围会越来越窄,也就是宽度不够。一旦无法突围,失败的概率就会比较大,毕竟这类工作的岗位需求量还是比较少的。
|
||||
|
||||
第二条路线:专注于中间件的应用,成为中间件领域的应用专家、技术架构师。
|
||||
|
||||
我选择的是这第二条路线。要实现这个目标,通常的做法就是学习市面上主流的中间件,深入研究各个中间件的源码,深入理解设计者的架构思想,在生产环境中灵活运用各类中间件解决实际业务问题,并且能利用中间件及时规避故障,快速排查故障。
|
||||
|
||||
这有助于我们成为多个中间件的技术应用专家。与此同时,研究中间件的实现细节也能帮助我们理解分布式架构,为我们成为技术架构师储备知识。不过,选择了宽度自然容易丧失深度,让人缺乏亮眼的标签。所以我的建议是,达到一定广度后,还是要选择一两个中间件重点攻破,形成自己的金刚钻。
|
||||
|
||||
总之,不管是想要走代码贡献的道路,还是专注于中间件的应用,都需要我们的努力和持续的输出。相信我们只要坚持不懈,积极分享,一定可以突破职场瓶颈,拥有自己的社区影响力。我们一起加油!
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user