拉勾教育学习-笔记分享の"深入"分布式原理

756 阅读48分钟

【文章内容输出来源:拉勾教育Java高薪训练营】
--- 所有脑图均本人制作,未经允许请勿滥用 ---
将分布式架构深入剖析


与其临渊羡鱼,不如退而结网

一、分布式理论

Part 1 - 分布式架构系统回顾

「分布式系统 定义」

是一个硬件或软件组件分布在不同的网络计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统

  • 业务拆分成子业务
  • 子业务分部在不同的服务器节点
  • 服务器节点可以是:「不同机柜、不同机房、不同城市......」
集群:多个人在做同样的事;
分布式:多个人在做不同的事;

分布式系统 特点 —— 分布性、对等性、并发性、缺乏全局时钟、故障总是会发送

「分布式系统 发展史」

阿里巴巴发起的"去 IOE"运动 (IOE 指的是 IBM 小型机、Oracle 数据库、EMC 的高端存储)。 阿里巴巴2009 年“去 IOE”战略技术总监透露,截止到 2013 年 5 月 17 日阿里巴巴最后一台 IBM 小型机在支付宝下线

去IOE的意义:
1 升级单机处理能力的性价比越来越低 2 单机处理能力存在瓶颈 3 稳定性和可用性这两个指标很难达到

「分布式架构的演变」

脑图 in here

Part 2 - 分布式系统面临的问题

「通信异常」

网络本身的不可靠性,因此每次网络通信都会伴随着网络不可用的风险(光纤、路由、DNS等硬件设备或系统的不可用)
导致最终分布式系统无法顺利进行一次网络通信

分布式系统各节点之间的网络通信能够正常执行,其延时也会大于单机操作;
巨大的延时差别,也会影响消息的收发过程,因此消息丢失和消息延迟变的非常普遍

「网络分区」

网络之间出现了网络不连通,但各个子网络的内部网络是正常的
从而导致整个系统的网络环境被切分成了若干个孤立的区域-->分布式系统就会出现局部小集群
在极端情况下,这些小集群会独立完成原本需要整个分布式系统才能完成的功能,包括数据的事务处理,这就对分布式一致性提出非常大的挑战

「节点故障」

组成分布式系统的服务器节点出现的宕机"僵死"现象, 根据经验来说,每个节点都有可能出现故障,并且经常发生

「三态」

成功 失败 超时

由于网络是不可靠的,虽然绝大部分情况下,网络通信能够接收到成功或失败的响应
但当网络出现异常的情况下,就会出现超时现象

  • 发送时的信息丢失
  • 反馈时的信息丢失

Part 3 - 分布式理论:一致性

数据在多份副本中存储时,各副本中的数据是一致的

「副本一致性」

分布式系统当中,数据往往会有多个副本
如果是一台数据库处理所有的数据请求,那么通过ACID四原则,基本可以保证数据的一致性。

但涉及到多份副本拷贝问题时,我们几乎没有办法保证可以同时更新所有机器当中的包括备份所有数据
网络延迟,即使我在同一时间给所有机器发送了更新数据的请求,也不能保证这些请求被响应的时间保持一致存在时间差,就会存在某些机器之间的数据不一致的情况

总得来说,我们无法找到一种能够满足分布式系统所有系统属性的分布式一致性解决方案。
因此,如何既保证数据的一致性,同时又不影响系统运行的性能,是每一个分布式系统都需要重点考虑和权衡的。

...... 于是,一致性级别由此诞生

「一致性分类」

=> 强一致性

这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也是什么用户体验好
但实现起来往往对系统的性能影响大,很难实现

=> 弱一致性

这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致
但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到 一致状态


--> 读写一致性
用户读取自己写入结果的一致性,保证用户永远能够第一时间看到自己更新的内容。
比如我们发一条朋友圈,朋友圈的内容是不是第一时间被朋友看见不重要,但是一定要显示在自己的列表上.

【解决方案】:

  • 方案1:一种方案是对于一些特定的内容我们每次都去主库读取。 (问题主库压力大)
  • 方案2:我们设置一个更新时间窗口,在刚刚更新的一段时间内,我们默认都从主库读取,过了这个窗口之后,我们会挑选最近有 过更新的从库进行读取
  • 方案3:我们直接记录用户更新的时间戳,在请求的时候把这个时间戳带上,凡是最后更新时间小于这个时间戳的从库都不予以响应。

--> 单调一致性
本次读到的数据不能比上次读到的旧。
由于主从节点更新数据的时间不一致,导致用户在不停地刷新的时候,有时候能刷出来,再次刷新之后会发现数据不见了,再刷新 又可能再刷出来,就好像遇见灵异事件一样

【解决方案】:

  • 就是根据用户ID计算一个hash值,再通过hash值映射到机器。同一个用户不管怎么刷新,都只会被映射到同一台机器 上。这样就保证了不会读到其他从库的内容,带来用户体验不好的影响。

--> 因果一致性
如果节点 A 在更新完某个数据后通知了节点 B,那么节点 B 之后对该数据的访问和修改都是基于 A 更新后的值。
于此同时,和节点 A 无因果关系的节点 C 的数据访问则没有这样的限制。

--> 最终一致性 √

最终一致性是所有分布式一致性模型当中最弱的。可以认为是没有任何优化的“最”弱一致性

不考虑所有的中间 状态的影响,只保证当没有新的更新之后,经过一段时间之后,最终系统内所有副本的数据是正确的。
它最大程度上保证了系统的并发能力,也因此,在高并发的场景下,它也是使用最广的一致性模型

Part 4 - 分布式理论:CAP定理

一个分布式系统不可能同时满足一致性C:Consistency),可用性A: Availability)和分区容错性P:Partition tolerance)这三个基本需求,最多只能同时满足其中的2个

  • C 一致性
    分布式系统当中的一致性指的是所有节点的数据一致,或者说是所有副本的数据一致

  • A 可用性
    Reads and writes always succeed. 也就是说系统一直可用,而且服务一直保持正常

  • P 分区容错性
    系统在遇到一些节点或者网络分区故障的时候,仍然能够提供满足一致性和可用性的服务

「C - Consistency」

一致性是值写操作后读操作可以读到最新的数据状态,当数据分布在多个节点上时,从任意节点读取到的数据都是最新的
【实现目标】

1 写库成功 --> 查库亦成功
2 写库失败 --> 查库亦失败

【实现方式】

1 写入主数据库后要数据同步到从数据库
2 写入主数据库后,在向从数据库同步期间要将从数据库锁定, 等待同步完成后在释放锁,以免在写新数据后,向从数据库查询到旧的数据.

「A - Availability」

任何操作都可以得到响应的结果,且不会出现响应超时或响应错误
【实现目标】

1 立即响应查询结果
2 不允许响应超时/错误

【实现方式】

1 写入主数据库后要将数据同步到从数据
2 不可以数据库中资源锁定
3 即使数据还没有同步过来,从数据库也要返回查询数据, 哪怕是旧数据,但不能返回错误和超时

「P - Partition tolerance」√

分布式系统的各个节点部署在不同的子网中, 不可避免的会出现由于网络问题导致节点之间通信失败,此时仍可以对外提供服务
【实现目标】

1 向 从数据库 同步失败,不影响写操作
2 一个节点挂掉,不影响其他节点

【实现方式】

1 尽量使用异步取代同步操作,举例 使用异步方式将数据从主数据库同步到从数据库, 这样节点之间能有效的实现松耦合;
2 添加数据库节点,其中一个从节点挂掉,由其他从节点提供服务

「C/A/P 只能 3选2」 ☆☆☆

==> 证明

  • 有用户向N1发送了请求更改了数据,将数据库从V0更新成了V1。
    由于网络断开,所以N2数据库依然是V0,如果这个时候有一个请 求发给了N2,但是N2并没有办法可以直接给出最新的结果V1,这个时候该怎么办呢?

  • 这个时候无非两种方法:
    1 将错就错,将错误的V0数据返回给用户。
    2 阻塞等待,等待网络通信恢复,N2中的数据更新 之后再返回给用户。显然前者牺牲了一致性后者牺牲了可用性

这个例子虽然简单,但是说明的内容却很重要。在分布式系统当中,CAP三个特性我们是无法同时满足的,必然要舍弃一个。三者 舍弃一个,显然排列组合一共有三种可能。

1 舍弃A(可用性),保留CP(一致性和分区容错性)

允许出现系统无法访问的情况出现,这个时候往往会 牺牲用户体验,让用户保持等待,一直到系统数据一致了之后,再恢复服务

2 舍弃C(一致性),保留AP(可用性和分区容错性)常用√

这种是大部分的分布式系统的设计,保证高可用和分区容错,但是会牺牲一致性

3 舍弃P(分区容错性),保留CA(一致性和可用性)不存在×

如果要舍弃P,那么就是要舍弃分布式系统,CAP也就无从谈起了。
可以说P是分布式系统的前提,所以这种情况是不存在的。

Part 5 - 分布式理论:BASE 理论

BA (Basically Available 基本可用) S (Sox state 软状态) E (Eventually consistent 最终一致性)

「核心思想」

即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

「Basically Available (基本可用)」

分布式系统在出现不可预知故障的时候,允许损失部分可用性 (≠系统不可用)

e.g.

  • 响应时间上的损失
    正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了1~2秒。
  • 功能上的损失
    正常情况下,在一个电子商务网站(比如淘宝)上购物,消费者几乎能够顺利地完成每一笔订单。
    但在一些节日大促购物高峰的时候(比如双十一、双十二),由于消费者的购物行为激增,为了保护系统的稳定性(或者保证一致性),部分消费者可能会被引导到一个降级页面

「Soft state(软状态)」

相比一致性,要求多个节点的数据副本都是一致的,是一种 “硬状态”。

软状态指的是:
允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性
---> 即允许系统在多个不同节点的数据副本之间进行数据同步的过程中存在延迟

「Eventually consistent(最终一致性)」

系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态

一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性

Part 6 - 分布式理论:分布式事务

「数据库事务回顾」

Atomicity(原子性)
事务是一个不可分割的整体,所有操作要么全做,要么全不做;
只要事务中有一个操作出错,回滚到事务开始前的状态的话,那么之前已经执行的所有操作都是无效的,都应该回滚到开始前的状态。
Consistency(一致性)
事务执行前后,数据从一个状态到另一个状态必须是一致的
比如A向B转账(A、B的总金额就是一个一致性状态),不可能出现A扣了钱,B却没收到的情况发生。
Isolation(隔离性)
多个并发事务之间相互隔离,不能互相干扰。
能出现脏读、幻读的情况,即事务A不能读取事务B还没有提交的数据,或者在事务A读取数据进行更新操作时,不允许事务B率先更新掉这条数据
常用的手段就是`加锁`
Durablity(持久性)
事务完成后,对数据库的更改是永久保存的。

「分布式事务定义」

其实分布式事务从实质上与数据库事务的概念是一致的,既然是事务也就需要满足事务的基本特性(ACID)
只是分布式事务相对于本地事务而言其表现形式有很大的不同

Part 7 - 分布式理论:一致性协议 2PC

2P (Two-Phase 两阶段) C (Commit 提交)

「准备阶段(Prepare phase)」

事务管理器给每个参与者发送Prepare消息,每个数据库参与者在本地执行事务,并写本地的Undo/Redo日志,此时事务没有提交。

Undo日志是记录修改前的数据,用于数据库回滚,Redo日志是记录修改后的数据,用于提交事务后写入数 据文件

「提交阶段(commit phase)」

如果事务管理器收到了参与者:
执行失败或者超时消息时,直接给每个参与者发送回滚(Rollback)消息
否则,发送提交(Commit)消息

参与者根据事务管理器的指令执行提交或者回滚操作,并释放事务处理过程中使用的锁资源。注意:必须在最后阶段释放锁资源。

「执行流程」

「缺点」

【核心问题】
同步阻塞 ——> 在第二阶段提交的执行过程中,所有参与该事务操作的逻辑都处于阻塞状态

  • 单点问题
    协调者在整个二阶段提交过程中很重要,如果协调者在提交阶段出现问题,那么整个流程将无法运转,更重要的 是:其他参与者将会处于一直锁定事务资源的状态中,而无法继续完成事务操作

  • 数据不一致
    假设当协调者向所有的参与者发送 commit 请求之后,发生了局部网络异常或者是协调者在尚未发送完所有 commit请求之前自身发生了崩溃,导致最终只有部分参与者收到了 commit 请求。这将导致严重的数据不一致问题

  • 过于保守
    如果在二阶段提交的提交询问阶段中,参与者出现故障而导致协调者始终无法获取到所有参与者的响应信息的话,这时协调者只能依靠其自身的超时机制来判断是否需要中断事务,显然,这种策略过于保守。
    换句话说,二阶段提交协议没有设计较为完善的容错机制,任意一个节点失败都会导致整个事务的失败。

Part 8 - 分布式理论:一致性协议 3PC

3P (Three-Phase 三阶段) C (Commit 提交)

将 2PC 的 “提交事务请求” 过程一分为二,共形成了由canCommitpreCommitdoCommit 三个阶段组成的事务处理协议

「canCommit」

1 事务询问
协调者向所有的参与者发送一个包含事务内容的canCommit请求,询问是否可以执行事务提交操作,并开始等待各 参与者的响应

2 各参与者向协调者反馈事务询问的响应
参与者在接收到来自协调者的包含了事务内容的canCommit请求后
-->正常情况下,如果自身认为可以顺利执行事务,则反馈Yes响应,并进入预备状态
-->否则反馈 No 响应

「preCommit」

1 假如所有参与反馈的都是 Yes
(1) 发送预提交请求 ==> 协调者向所有参与者节点发出preCommit请求,并进入prepared阶段
(2) 事务预提交 ==> 参与者接收到preCommit请求后,会执行事务操作,并将UndoRedo信息记录到事务日志中
(3) 各参与者向协调者反馈事务执行的结果 ==> 参与者成功执行了事务操作,那么反馈Ack

2 任一参与者反馈了 No 响应,或者在等待超时后,协调者尚无法接收到所有参与者反馈,则中断事务
(1) 发送中断请求 ==> 协调者向所有参与者发出abort请求
(2) 中断事务 ==> 无论是收到来自协调者的abort请求或者等待协调者请求过程中超时,参与者都会中断事务

「doCommit」

1 执行事务提交
(1) 发送提交请求 ==> 进入这一阶段,假设协调者处于正常工作状态,并且它接收到了来自所有参与者的Ack响应,那么他将从预提交状 态转化为提交状态,并向所有的参与者发送doCommit请求
(2) 事务提交 ==> 参与者接收到doCommit请求后,会正式执行事务提交操作,并在完成提交之后释放整个事务执行过程中占用的事务资源
(3) 反馈事务提交结果 ==> 参与者在完成事务提交后,向协调者发送Ack响应
(4) 完成事务 ==> 协调者接收到所有参与者反馈的Ack消息后,完成事务

2 中断事务
(1) 发送中断请求 ==> 参与者收到abort请求后,会根据记录的Undo信息来执行事务回滚,并在完成回滚之后释放整个事务执行期间占用的资源 态转化为提交状态,并向所有的参与者发送doCommit请求
(2) 事务回滚 ==> 参与者接收到doCommit请求后,会正式执行事务提交操作,并在完成提交之后释放整个事务执行过程中占用的事 务资源
(3) 反馈事务回滚结果 ==> 参与者在完成事务回滚后,向协调者发送Ack消息
(4) 中断事务 ==> 协调者接收到所有参与者反馈的Ack消息后,中断事务

【对比2PC & 3PC】
1 协调者和参与者都设置了超时机制
2 通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的
3 PreCommit是一个缓冲,保证了在最后提交阶段之前各参与节点的状态是一致的
-----P.S. 3PC协议并没有完全解决数据不一致问题---------

Part 9 - 分布式理论:一致性算法 Paxos

一种基于消息传递的分布式一致性算法

自Paxos问世以来就持续垄断了分布式一致性算法,Paxos这个名词几乎等同于分布式一致性
Google的很多大型分布式系统都采用了Paxos算法来解决分布式一致性问题,如ChubbyMegastore 以及Spanner等。
开源的ZooKeeper,以及MySQL 5.7推出的用来取代传统的主从复制的MySQL Group Replication等纷纷采用Paxos算法解决分布式一致性 问题。

「解决了什么?」

解决了分布式系统一致性问题
在一个可能发生上述异常的分布式系统中,快速且正确地在集群内部对某个数据的值 达成一致

这里某个数据的值并不只是狭义上的某个数,它可以是一条日志,也可以是一条命令(command)。。。根据应用场景不同, 某个数据的值有不同的含义

分布式系统才用多副本进行存储数据
如果对多个副本执行序列不控制, 那多个副本执行更新操作,由于 网络延迟 超时 等故障到 值各个副本的数据不一致

我们希望每个副本的执行序列是 [ op1 op2 op3 .... opn ] 不变的
Paxos 依次来确定不可变变量 opi的取值 , 每次确定完Opi之后,各个副本执行opi操作,以此类推

「产生背景」

有以下情形:
在一个集群环境中,要求所有机器上的状态是一致的
其中有2台机器想修改某个状态,机器A 想把状态改为 A机器 B 想把状态改为 B

↓ 按之前 2PC/3PC 所说的,就得引入协调者,【谁先请求,就听谁的】

↓ 但如果 协调者 宕机了呢?-- 需要对协调者也做备份,也要做集群.......

到底听谁的呢?

---》 Paxos 算法就是为了解决这个问题而生的

「相关概念」

☆☆ 提案 (Proposal):Proposal信息包括提案编号 (Proposal ID) 和提议的值 (Value)

角色1 - Client 客户端
客户端向分布式系统发出请求 ,并等待响应 。
例如,对分布式文件服务器中文件的写请求
角色2 - Proposer 提案发起者
提案者提倡客户请求,试图说服Acceptor对此达成一致,并在发生冲突时充当协调者以推动协议向前发展
角色3 - Acceptor 决策者
Acceptor可以接受(accept)提案;如果某个提案被选定(chosen),那么该提案里的value就被选定了
角色4 - Learners 最终决策的学习者
学习者充当该协议的复制因素

一个一致性算法需要保证以下几点:
  • 在这些被提出的提案中,只有一个会被选定
  • 如果没有提案被提出,就不应该有被选定的提案。
  • 当一个提案被选定后,那么所有进程都应该能学习(learn)到这个被选定的value

「推导过程」

(待跟进——后期引入专题)

「算法描述」

  • 阶段一: (a) Proposer选择一个提案编号N,然后向半数以上的Acceptor发送编号为N的Prepare请求。
    (b) 如果一个Acceptor收到一个编号为N的Prepare请求,且N大于该Acceptor已经响应过的所有Prepare请求的编号,那么它就会将它已经接受过的编号最大的提案(如果有的话)作为响应反馈给Proposer,同时该Acceptor承诺不再接受任何编号小于N的提案。
  • 阶段二: (a) 如果Proposer收到半数以上Acceptor对其发出的编号为N的Prepare请求的响应,那么它就会发送一个针对[N,V]提案的Accept请求给半数以上的Acceptor。注意:V就是收到的响应中编号最大的提案的value,如果响应中不包含任何提案,那么V就由Proposer自己决定。
    (b) 如果Acceptor收到一个针对编号为N的提案的Accept请求,只要该Acceptor没有对编号大于N的Prepare请求做出过响应,它就接受该提案。

当然,实际运行过程中,每一个Proposer都有可能产生多个提案
但只要每个Proposer都遵循如上所述的算法运行,就一定能够保证算法执行的正确性

「Learner学习被选定的value」

(待跟进——后期引入专题)

「保证Paxos算法的活性」

(待跟进——后期引入专题)

Part 10 - 分布式理论:一致性算法 Raft

一种为了管理复制日志的一致性算法

Rax提供了和Paxos算法相同的功能和性能,但是它的算法结构和Paxos不同。
Rax算法更加容易理解并且更容易构建实际的系统


Rax将一致性算法分解成了3模块+2阶段:

1 领导人选举
2 日志复制
3 安全性

a 选举过程
b 正常操作

「领导人Leader选举」

Rax 通过选举一个领导人,然后给予他全部的管理复制日志的责任来实现一致性

在Rax中,任何时候一个服务器都可以扮演下面的角色之一:

  • 领导者(leader):处理客户端交互,日志复制等动作,一般一次只有一个领导者
  • 候选者(candidate):候选者就是在选举过程中提名自己的实体,一旦选举成功,则成为领导者
  • 跟随者(follower):类似选民,完全被动的角色,这样的服务器等待被通知投票

影响他们身份变化的则是 --- 「选举」

Rax使用心跳机制来触发选举。
当server启动时,初始状态都是follower
每一个server都有一个定时器,超时时间为election timeout(一般为150-300ms),如果某server没有超时的情况下收到来自领导者或者候选者的任何消息,定时器重启,如果超时,它就开始一次选举。

动画详细描述

「节点异常」

-> leader 不可用

-> follower 不可用

某节点不可用 ---> (一段时间后) ----> 该节点恢复,并同步届时 leader 的日志

-> 多个 candidate 或多个 leader

集群中出现多个 candidate 或多个 leader 通常是由于数据传输不畅造成的

多个 leader 的情况相对少见
多个 candidate 比较容易出现在集群节点启动初期尚未选出 leader 的“混沌”时期

「日志复制(保证数据一致性)」

Leader选出后,就开始接收客户端的请求。
Leader把请求作为日志条目Log entries)加入到它的日志中,然后并行的向其他服务器发起 AppendEntries RPC复制日志条目。
当这条日志被复制到大多数服务器上,Leader将这条日志应用到它的状态机并向客户端返回执行结果。

  • 客户端的每一个请求都包含被复制状态机执行的指令。
  • leader把这个指令作为一条新的日志条目添加到日志中,然后并行发起 RPC 给其他的服务器,让他们复制这条信息。
  • 跟随者响应ACK,如果 follower 宕机或者运行缓慢或者丢包,leader会不断的重试,直到所有的 follower 最终都复制了所有的日志条目。
  • 通知所有的Follower提交日志,同时领导人提交这条日志到自己的状态机中,并返回给客户端。

二、分布式系统设计策略

Part 1 - 心跳检测

分布式环境中,存在很多“节点”的概念。
如何检测一个节点出现了故障乃至无法工作了?

通常解决这一问题是采用心跳检测的手段

心跳顾名思义,就是以固定的频率向其他节点汇报当前节点状态的方式。
收到心跳,一般可以认为一个节点和现在的网络拓扑是良好的。
当然,心跳汇报时,一般也会携带一些附加的状态、元数据信息,以便管理

收到心跳可以确认节点正常,但是收不到心跳也不能认为该节点就已经宣告“死亡”。
此时,可以通过一些方法帮助Server做决定: 周期检测心跳机制、累计失效检测机制

「周期检测心跳」

Server端每间隔 t 秒向Node集群发起监测请求,设定超时时间,如果超过超时时间,则判定“死亡”

「累计失效检测机」

在周期检测心跳机制的基础上,统计一定周期内节点的返回情况(包括超时及正确返回),以此计算节点的“死 亡”概率
另外,对于宣告“濒临死亡”的节点可以发起有限次数的重试,以作进一步判断。

Part 2 - 高可用设计

经过设计来减少系统不能提供服务的时间

「主备(Master-SLave)」

当主机宕机时,备机接管主机的一切工作,待主机恢复正常后,按使用者的设定以自动(热备)或手动(冷备)方式将服务切换到主机上运行。

在数据库部分,习惯称之为MS模式
MS模式即Master/Slave模式,这在数据库高可用性方案中比较常用,如MySQL、Redis等就采用MS模式实现主从复制, 保证高可用

MySQL之间数据复制的基础是二进制日志文件(binary log file)。

一台MySQL数据库一旦启用二进制日志后,作为master,它的数据库中所有操作都会以“事件”的方式记录在二进制日志中
其他数据库作为slave通过一个I/O线程与主服务器保持通信,并监控master的二进制日志文件的变化
如果发现master二进制日志文件发生变化,则会把变化复制到自己的中继日志中
然后slave的一个SQL线程会把相关的“事件”执行到自己的数据库中,以此实现从数据库和主数据库的一致性,也就实现了主从复制

「互备(Active-Active)」

两台主机同时运行各自的服务工作且相互监测情况

在数据库部分,习惯称之为MM模式
MS模式即Multi-Master模式,指一个系统存在多个master,每个master都具有read-write能力,会根据时间戳或业务逻辑合并版本

「集群(Cluster)」

有多个节点在运行,同时可以通过主控节点分担服务请求. 如Zookeeper

集群模式需要解决主控节点本身的高可用问题,一般采用主备模式。

Part 3 - 容错性

经典例子——「缓存穿透」

项目中使用缓存通常都是先检查缓存中是否存在
如果我们查询的某一个数据在缓存中一直不存在,就会造成每一次请求都查询DB,这样缓存就失去了意义,在流量大时,或者有人恶意攻击: 如频繁发起为id为“-1”的条件进行查询,可能DB就挂掉了

一个比较巧妙的方法是,可以将这个不存在的key预先设定一个值。
比如,key=“null”。在返回这个null值的时候,我们的应用就可以认为这是不存在的key,那我们的应用就可以决定是否继续等待访问,还是放弃掉这次操作:
1 如果继续等待访问,过一个时间轮询点后,再次请求这个key
2 如果取到的值不再是null,则可以认为这时候key有值
====> 从而避免了透传到数据库,把大量的类似请求挡在了缓存之中

Part 4 - 负载均衡

负载均衡器有硬件解决方案,也有软件解决方案。
硬件解决方案有著名的F5 // 软件有LVSHAProxyNginx

Nginx专题

以 Nginx 为例,负载均衡有以下几种策略:

  • 轮询Round Robin
    根据Nginx配置文件中的顺序,依次把客户端的Web请求分发到不同的后端服务器
  • 最少连接
    当前谁连接最少,分发给谁
  • IP地址哈希
    确定相同IP请求可以转发给同一个后端节点处理,以方便session保持
  • 基于权重的负载均衡
    配置Nginx把请求更多地分发到高配置的后端服务器上,把相对较少的请求分发到低配服务器

三、分布式架构网络通信

在分布式服务框架中,一个最基础的问题就是远程服务是怎么通讯的,在Java领域中有很多可实现远程通讯的技术:
例如:RMIHessianSOAPESBJMS等,它们背后到底是基于什么原理实现的呢?

Part 1 - 基本原理

计算机系统网络通信的基本原理:
底层层面去看,网络通信需要做的就是将流从一台计算机传输到另外一台计算机,基于传输协议和网络IO来实现,其中传输协议比较出名的有tcpudp等等

tcpudp都是在基于Socket概念上为某类应用场景而扩展出的传输协议,网络IO,主要有bio、nio、aio三种 方式,所有的分布式应用通讯都基于这个原理而实现,只是为了应用的易用,各种语言通常都会提供一些更为贴近 应用易用的应用层协议

Part 2 - RPC

Remote Procedure Call 远程过程调用

借助RPC可以做到像本地调用一样调用远程服务,是一种进程间的通信方式

比如两台服务器A和B,A服务器上部署一个应用,B服务器上部署一个应用
A服务器上的应用想调用B服务器上的应用提供的方法,由于两个应用不在一个内存空间,不能直接调用
==> 所以需要通过网络来表达调用的语义和传达调用的数据。

是RPC并不是一个具体的技术,而是指整个网络远程调用过程

「RPC架构」

四个核心的组件:

  • 客户端(Client)
    服务的调用方
  • 客户端存根(Client Stub)
    存放服务端的地址消息,再将客户端的请求参数打包成网络消息,然后通过网络远程发送给服务方
  • 服务端(Server)
    真正的服务提供者
  • 服务端存根(Server Stub)
    接收客户端发送过来的消息,将消息解包,并调用本地的方法

(1) 客户端(client)以本地调用方式(即以接口的方式)调用服务;
(2) 客户端存根(client stub)接收到调用后,负责将方法、参数等组装成能够进行网络传输的消息体(将消息体对象序列化为二进制);
(3) 客户端通过sockets将消息发送到服务端;
(4) 服务端存根( server stub)收到消息后进行解码(将消息对象反序列化);
(5) 服务端存根( server stub)根据解码结果调用本地的服务;
(6) 本地服务执行并将结果返回给服务端存根( server stub);
(7) 服务端存根( server stub)将返回结果打包成消息(将结果消息对象序列化);
(8) 服务端(server)通过sockets将消息发送到客户端;
(9) 客户端存根(client stub)接收到结果消息,并进行解码(将结果消息发序列化);
(10) 客户端(client)得到最终结果。
RPC的目标是要把2、3、4、7、8、9这些步骤都封装起来。

无论是何种类型的数据,最终都需要转换成二进制流在网络上进行传输,数据的发送方需要将对象转换为二进制流,而数据的接收方则需要把二进制流再恢复为对象

在java中RPC框架比较多,常见的有Hessian、gRPC、Thrix、HSF (High Speed Service Framework)、Dubbo
对 于RPC框架而言,核心模块 就是通讯序列化

Part 3 - Java RMI

Remote Method Invocation 远程方法调用

采用JRMPJava Remote Messageing protocol)作为通信协议,可以认为是纯java版本的分布式远程调用解决方案
RMI主要用于不同虚拟机之间的通信,这些虚拟机可以在不同的主机上、也可以在同一个主机上,这里的通信可以理解为一个虚拟机上的对象调用另一个虚拟机上对象的方法

「PMI架构」

三个核心的组件:

客户端

1)存根/桩(Stub):远程对象在客户端上的代理;
2)远程引用层(Remote Reference Layer):解析并执行远程引用协议;
3)传输层(Transport):发送调用、传递远程方法参数、接收远程方法执行结果。

服务端

1)骨架(Skeleton):读取客户端传递的方法参数,调用服务器方的实际对象方法,并接收方法执行后的返回值;
2)远程引用层(Remote Reference Layer):处理远程引用后向骨架发送远程方法调用;
3)传输层(Transport):监听客户端的入站连接,接收并转发调用到远程引用层。

注册表

URL形式注册远程对象,并向客户端回复对远程对象的引用

「远程调用过程」

1)客户端从远程服务器的注册表中查询并获取远程对象引用;
2)桩对象与远程对象具有相同的接口和方法列表,当客户端调用远程对象时,实际上是由相应的桩对象代理完成的。
3 )远程引用层在将桩的本地引用转换为服务器上对象的远程引用后,再将调用传递给传输层(Transport),由传输层通过TCP协议发送调用
4)在服务器端,传输层监听入站连接,它一旦接收到客户端远程调用后,就这个引用转发给其上层的远程引用层;
5)服务器端的远程引用层将客户端发送的远程应用转换本地虚拟机的引用后,再将请求传递给骨架(Skeleton)
6)骨架读取参数,又将请求传递给服务器,最后由服务器进行调用实际方法

「结果返回过程」

1)如果远程方法调用后有返回值,则服务器将这些结果又沿着“骨架->远程引用层->传输层”向下传递
2)客户端的传输层接收到返回值后,又沿着“传输层->远程引用层->桩”向上传递,然后由桩来反序列化这些返回值,并将最终的 结果传递给客户端程序。

「代码实现」

(待跟进)

Part 4 - BIO、NIO、AIO

「同步和异步」

同步(synchronize)、异步(asychronize)是指应用程序和内核的交互而言的

同步
用户进程触发IO操作等待或者轮训的方式查看IO操作是否就绪
e.g.
银行取钱,我自己去取钱,取钱的过程中等待

异步
当一个异步进程调用发出之后,调用者不会立刻得到结果。
而是在调用发出之后,被调用者通过状态、通知来通知调用者,或者通过回调函数来处理这个调用
e.g.
我请朋友帮我取钱,他取到钱后返回给我.
(委托给操作系统OS, OS需要支持IO异步API)

「阻塞和非阻塞」

阻塞和非阻塞是针对于进程访问数据的时候,根据IO操作的就绪状态采取不同的方式

阻塞方式 ==> 读取和写入将一直等待
e.g.
ATM机排队取款,你只能等待排队取款
(使用阻塞IO的时候,Java调用会一直阻塞到读写完成才返回。)

非阻塞方式 ==> 读取和写入方法会返回一个状态值
e.g.
柜台取款,取个号,然后坐在椅子上做其他事,等广播通知,没到你的号你就不能去,但你可以不断的问大堂经理排到了没有。
(使用非阻塞IO时,如果不能读写Java调用会马上返回,当IO事件分发器会通知可读写时再继续进行读写,不断循环直到读写完成)

「举例辨析」
老张煮开水。 老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

1 老张把水壶放到火上,站立着等水开。(同步阻塞)

2 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞)

3 老张把响水壶放到火上,站着等水开。(异步阻塞)

4 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)

「BIO」☆☆

B (Blocking) IO -- 同步阻塞IO

一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理
如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善

BIO 使用

(待跟进)

「NIO」☆☆☆

N (non-blocking / new) IO -- 同步非阻塞IO
(jdk1.4 以上)
NIO 介绍

一个请求一个通道,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有IO请求时才启动一个线程进行处理。

通道
NIO 新引入的最重要的抽象是通道的概念。Channel 数据连接的通道。
数据可以从Channel读到Buffer中,也可以从 Buffer 写到 Channel中

缓冲区
通道channel可以向缓冲区Buwer中写数据,也可以像buwer中存数据

选择器
使用选择器,借助单一线程,就可对数量庞大的活动 I/O 通道实时监控和维护

特点

当一个连接创建后,需要对应一个线程,这个连接会被注册到多路复用器,所以一个连接只需要一个线程即可,所有的连接需要一个线程就可以操作,该线程的多路复用器会轮训,发现连接有请求时,才开启一个线程处理。

举例辨析

在一家幼儿园里,小朋友有上厕所的需求,小朋友都太小以至于你要问他要不要上厕所,他才会告诉你。
幼儿园一共有100个小朋友,有两种方案可以解决小朋友上厕所的问题:
  1. 每个小朋友配一个老师。每个老师隔段时间询问小朋友是否要上厕所,如果要上,就领他去厕所,100个小朋友就需要100个老师 来询问,并且每个小朋友上厕所的时候都需要一个老师领着他去上,这就是IO模型,一个连接对应一个线程。

  2. 所有的小朋友都配同一个老师。这个老师隔段时间询问所有的小朋友是否有人要上厕所,然后每一时刻把所有要上厕所的小朋友批 量领到厕所,这就是NIO模型,所有小朋友都注册到同一个老师,对应的就是所有的连接都注册到一个线程,然后批量轮询。

NIO 使用

(待跟进)

「AIO」☆☆☆

A (asynchronize) IO -- 异步非阻塞IO

当有流可以读时,操作系统会将可以读的流传入read方法的缓冲区,并通知应用程序
对于写操作,OS将write方法的流写入完毕时操作系统会主动通知应用程序。因此read和write都是异步的,完成后会调用回调函数。

使用场景:连接数目多且连接比较长(重操作)的架构,比如相册服务器。重点调用了OS参与并发操作,编程比较复杂。Java7开始支持

Part 5 - Netty

由 JBOSS 提供一个异步的、 基于事件驱动的网络编程框架

Netty 可以帮助你快速、 简单的开发出一个网络应用, 相当于简化和流程化了 NIO 的开发过程
作为当前最流行的 NIO 框架, Netty 在互联网领域、 大数据分布式计算领域、 游戏行业、 通信行业等获得了广泛的应用, 知名的 ElasticsearchDubbo 框架内部都采用了 Netty

「优点」

  • 对各种传输协议提供统一的 API
  • 高度可定制的线程模型——单线程、一个或多个线程池
  • 吞吐量,更的等待延迟
  • 资源消耗
  • 最小化不必要的内存拷贝

「缺点」

  • NIO 的类库和 API 繁杂,使用麻烦。你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、 ByteBuffer 等.
  • 可靠性不强,开发工作量和难度都非常大
  • NIO 的 Bug。 例如 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。

「线程模型」

① 单线程模型

② 线程池模型

③ Netty模型

Netty 抽象出两组线程池, BossGroup 专门负责接收客户端连接WorkerGroup 专门负责网络读写操作
NioEventLoop 表示一个不断循环执行处理任务的线程, 每个 NioEventLoop 都有一个 selector, 用于监听绑定在其 上的 socket 网络通道。
NioEventLoop 内部采用串行化设计, 从消息的 读取->解码->处理->编码->发送, 始终由 IO 线 程 NioEventLoop 负责。

「Netty核心组件」

① ChannelHandler 及其实现类

定义了许多事件处理的方法, 我们可以通过重写这些方法去实现具 体的业务逻辑

我们经常需要自定义一个 Handler类去继承 ChannelInboundHandlerAdapter, 然后通过 重写相应方法实现业务逻辑

- public void channelActive(ChannelHandlerContext ctx), 通道就绪事件
- public void channelRead(ChannelHandlerContext ctx, Object msg), 通道读取数据事件
- public void channelReadComplete(ChannelHandlerContext ctx) , 数据读取完毕事件
- public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause), 通道发生异常事件
② ChannelPipeline

一个 Handler 的集合, 它负责处理和拦截 inbound 或者 outbound事件和操作, 相当于一个贯穿 Netty 的链

- ChannelPipeline addFirst(ChannelHandler... handlers), 把一个业务处理类(handler) 添加到链中的第一个位 置
- ChannelPipeline addLast(ChannelHandler... handlers), 把一个业务处理类(handler) 添加到链中的最后一个 位置
③ ChannelHandlerContext

事件处理器上下文对象,Pipeline 链中的实际处理节点 。

每个处理节点 ChannelHandlerContext 中包含一个具体的事件处理器 ChannelHandler , 同 时ChannelHandlerContext 中也绑定了对应的 pipeline 和 Channel 的信息,方便对 ChannelHandler 进行调用

- ChannelFuture close(), 关闭通道
- ChannelOutboundInvoker flush(), 刷新
- ChannelFuture writeAndFlush(Object msg) , 将 数 据 写 到 ChannelPipeline 中 当 前
- ChannelHandler 的下一个 ChannelHandler 开始处理(出站)
④ ChannelFuture

Channel异步 I/O 操作的结果, 在 Netty 中所有的 I/O 操作都是异步的, I/O 的调 用会直接返回, 调用者并不能立刻获得结果, 但是可以通过 ChannelFuture 来获取 I/O 操作 的处理状态

- Channel channel(), 返回当前正在进行 IO 操作的通道 
- ChannelFuture sync(), 等待异步操作执行完毕
⑤ EventLoopGroup 和其实现类 NioEventLoopGroup

是一组 EventLoop 的抽象

为了更好的利用多核 CPU 资源, 一般 会有多个 EventLoop 同时工作每个 EventLoop 维护着一个 Selector 实例
EventLoopGroup 提供 next 接口, 可以从组里面按照一定规则获取其中一个 EventLoop 来处理任务。
在 Netty 服务器端编程中, 我们一般都需要提供两个 EventLoopGroup
例如: BossEventLoopGroupWorkerEventLoopGroup

- public NioEventLoopGroup(), 构造方法
- public Future<?> shutdownGracefully(), 断开连接, 关闭线程
⑥ ServerBootstrap 和 Bootstrap

ServerBootstrap 是 Netty 中的服务器端启动助手,通过它可以完成服务器端的各种配置
Bootstrap 是 Netty 中的客户端启动助手, 通过它可以完成客户端的各种配置

- public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup),该方法用于 服务器端, 用来设置两个 EventLoop
- public B group(EventLoopGroup group) , 该方法用于客户端, 用来设置一个 EventLoop
- public B channel(Class<? extends C> channelClass), 该方法用来设置一个服务器端的通道实现
- public <T> B option(ChannelOption<T> option, T value), 用来给 ServerChannel 添加配置
- public <T> ServerBootstrap childOption(ChannelOption<T> childOption, T value), 用来给接收到的 通 道添加配置
- public ServerBootstrap childHandler(ChannelHandler childHandler), 该方法用来设置业务处理类(自定 义的 handler)
- public ChannelFuture bind(int inetPort) , 该方法用于服务器端, 用来设置占用的端口号
- public ChannelFuture connect(String inetHost, int inetPort) 该方法用于客户端, 用来连接服务器端

「Netty版案例实现」

目标
使用netty客户端给服务端发送数据,服务端接收消息打印

① 先引入Maven依赖
<dependency> 
  <groupId>io.netty</groupId> 
  <artifactId>netty-all</artifactId> 
  <version>4.1.6.Final</version> 
</dependency>
② 服务端实现代码

NettyServer.java

public class NettyServer {
    
    public static void main(String[] args) throws InterruptedException {
    
        // 创建两个 NioEventLoopGroup(当前这两个实例代表两个线程池,默认线程数为CPU核心数乘2)
        // bossGroup 用于 接收客户端
        // workerGroup 用于 处理请求
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
    
        // 创建 服务启动辅助类 ==> 组装必要组件
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        // 设置组。 bossGroup负责连接, workerGroup负责连接之后的io处理
        serverBootstrap.group(bossGroup, workerGroup)
                // channel方法 -> 指定服务器监听的通道类型
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
    
                    @Override
                    protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception {
                        // 传输通道
                        ChannelPipeline pipeline = nioSocketChannel.pipeline();
                        // 在通道上添加对通道的处理器, 还可以是一个监听器
                        pipeline.addLast(new StringEncoder());
                        pipeline.addLast(new StringDecoder());
                        // 监听器队列上添加我们自己的处理方式
                        pipeline.addLast(new SimpleChannelInboundHandler<String>() {
                            @Override
                            protected void channelRead0(ChannelHandlerContext channelHandlerContext, String s) throws Exception {
                                System.out.println(s);
                            }
                        });
                    }
                });
    
        // bind 监听端口
        ChannelFuture channelFuture = serverBootstrap.bind(8000).sync();
        System.out.println(" Tcp server start success ! ");
        // 阻塞等待,直到服务器的channel关闭
        channelFuture.channel().closeFuture().sync();
    }   
}
③ 客户端NIO的实现部分
public class NettyClient {
    
    public static void main(String[] args) throws InterruptedException {
        // 客户端的 启动辅助类
        Bootstrap bootstrap = new Bootstrap();
        // 线程池的实例
        NioEventLoopGroup group = new NioEventLoopGroup();
        // 添加到 组 中
        bootstrap.group(group)
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<Channel>() {
                    @Override
                    protected void initChannel(Channel channel) throws Exception {
                        channel.pipeline().addLast(new StringEncoder());
                    }
                });
        Channel channel = bootstrap.connect("127.0.0.1", 8000).channel();
        while (true) {
            channel.writeAndFlush(new Date() + "hello world");
            Thread.sleep(2000);
        }
    }

}

Part 6 - 基于Netty自定义RPC

如之前讲的,RPC称为 远程过程调用

我们熟知的 远程调用 有以下两个:

  1. 基于HTTP的restful形式的广义远程调用
    spring couldfeignrestTemplate为代表,采用的协议是HTTP的7层调用协议,并且协议的参数和响应序列化基本以JSON格式和XML格式为主

  2. 基于TCP的狭义的RPC远程调用
    以阿里的Dubbo为代表,主要通过netty来实现4层网络协议NIO来异步传输,序列化也可以是JSON或者hessian2以及java自带的序列化等,可以配置

「需求描述」

模仿 dubbo,消费者和提供者约定接口和协议,消费者远程调用提供者,提供者返回一个字符串,消费者打印提供者返回的数据。底层网络通信使用 Netty

「思路步骤」

  1. 创建一个公共的接口项目以及创建接口及方法,用于消费者和提供者之间的约定。
  2. 创建一个提供者,该类需要监听消费者的请求,并按照约定返回数据。
  3. 创建一个消费者,该类需要透明的调用自己不存在的方法,内部需要使用 Netty 请求提供者返回数据

「实现」

① 添加依赖
<dependency> 
  <groupId>io.netty</groupId> 
  <artifactId>netty-all</artifactId> 
  <version>4.1.6.Final</version> 
</dependency>
② 服务端的实现

源码链接

模块结构

  • common 引入 Netty 依赖
  • provider 和 comsumer 依赖于 common (在两个pom.xml中指向)

public interface IUserService {
    public String sayHello(String msg);
}

public class ServerBoot {
    public static void main(String[] args) throws InterruptedException {
        UserServiceImpl.startServer("127.0.0.1", 8999);
    }
}
public class UserServiceHandler extends ChannelInboundHandlerAdapter {
    
    /**
     * 当客户端 [读取数据],该方法被调用
     *
     * @param ctx
     * @param msg 客户端发送请求时传递的参数 (UserService#sayHello#"R U OK?")
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 1. 判断当前的请求是否符合规则
        if (msg.toString().startsWith("UserService")) {
            // 1-1 若符合 --> 调用实现类获取result
            UserServiceImpl userService = new UserServiceImpl();
            String result = userService.sayHello(msg.toString().substring(msg.toString().lastIndexOf("#") + 1));
            // 1-2 将结果写到客户端
            ctx.writeAndFlush(result);
        }
    }
}
public class UserServiceImpl implements IUserService {
    
    /**
     * 将来客户端要远程调用的方法
     * @param msg
     * @return
     */
    @Override
    public String sayHello(String msg) {
        String str = "服务器返回数据:\t" + msg + "\t===>\t" + new Date();
        System.out.println(str);
        return str;
    }
    
    /**
     * 方法启动服务器
     * @param ip 地址
     * @param port 端口号
     */
    public static void startServer(String ip, int port) throws InterruptedException {
        // 1. 创建两个线程池对象
        NioEventLoopGroup bossGroup = new NioEventLoopGroup();
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
        // 2. 创建服务端的启动引导对象
        ServerBootstrap bootstrap = new ServerBootstrap();
        // 3. 配置启动引导对象
        bootstrap.group(bossGroup, workerGroup)
                // 设置通道为 NIO
                .channel(NioServerSocketChannel.class)
                // 创建监听 channel
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception {
                    
                        /* 获取管道对象 */
                        ChannelPipeline pipeline = nioSocketChannel.pipeline();
                        /* 给管道对象 pipeLine 设置编码 */
                        pipeline.addLast(new StringEncoder());
                        pipeline.addLast(new StringDecoder());
                        /* 把我们自定义的 ChannelHandler 添加到管道中 */
                        pipeline.addLast(new UserServiceHandler());
                    }
                });
    
        // 4. 绑定端口
        ChannelFuture future = bootstrap.bind(8999).sync();
    }
}
③ 客户端(消费者)的实现

消费者有一个需要注意的地方,就是调用需要透明,也就是说,框架使用者不用关心底层的网络实现。这里我们可以使用 JDK 的动态代理来实现这个目的

public class ConsumerBoot {
    
    // 参数定义
    private static final String PROVIDER_NAME = "UserService#sayHello#";
    
    public static void main(String[] args) throws InterruptedException {
        // 1. 创建代理对象
        IUserService service = (IUserService) RPCConsumer.createProxy(IUserService.class, PROVIDER_NAME);
        // 2. 循环给服务器写数据
        while (true) {
            String result = service.sayHello("R U OK ?");
            System.out.println(result);
            Thread.sleep(2000);
        }
    }
    
}
public class RPCConsumer {
    
    // 1. 创建一个线程池对象 ==> 它处理我们得自定义事件 (线程数以 当前电脑的 [CPU核数] 为准)
    private static ExecutorService executorService =
            Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
    
    // 2. 声明一个自定义事件处理器 UserClientHandler
    private static UserClientHandler userClientHandler;
    
    /**
     * 3. 初始化我们的客户端 (创建连接池->bootStrap->设置bootStrap->连接服务器)
     */
    public static void initClient() throws InterruptedException {
        // 1) 初始化UserClientHandler
        userClientHandler = new UserClientHandler();
        // 2) 创建连接池对象
        NioEventLoopGroup group = new NioEventLoopGroup();
        // 3) 创建客户端引导对象
        Bootstrap bootstrap = new Bootstrap();
        // 4) 配置启动引导对象
        bootstrap.group(group)
                /* 设置通道为 NIO */
                .channel(NioSocketChannel.class)
                /* 设置请求协议为 TCP */
                .option(ChannelOption.TCP_NODELAY, true)
                /* 监听 channel 并初始化 */
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        /* 获取管道 */
                        ChannelPipeline pipeline = socketChannel.pipeline();
                        /* 设置编码 */
                        pipeline.addLast(new StringEncoder());
                        pipeline.addLast(new StringDecoder());
                        /* 添加自定义事件处理器 */
                        pipeline.addLast(userClientHandler);
                    }
                });
        // 5) 连接服务器
        bootstrap.connect("127.0.0.1", 8999).sync();
    }
    
    /**
     * 4. 编写一个方法,使用 JDK 动态代理创建对象
     * @param serviceClass 接口类型 ==> 根据哪个接口生成子类代理对象
     * @param providerParam "UserService#sayHello#..."
     * @return
     */
    public static Object createProxy(Class<?> serviceClass, String providerParam) {
        
        return Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
                new Class[]{serviceClass}, new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        // 1) 初始化客户端
                        if (userClientHandler == null) {
                            initClient();
                        }
                        // 2) 给 UserClientHandler 设置参数
                        userClientHandler.setParam(providerParam + args[0]);
                        // 3) 使用线程池, 开启一个线程处理 call() 并返回结果
                        Object result = executorService.submit(userClientHandler).get();
                        // 4) 返回结果
                        return result;
                    }
                });
    }
}
public class UserClientHandler extends ChannelInboundHandlerAdapter implements Callable {

    // 1. 定义成员变量
    private ChannelHandlerContext context; // 事件处理器上下文对象(存储handler信息,写操作)
    private String result; // 记录服务器返回的数据
    private String param; // 记录将要发送给服务器的数据
    
    /**
     * 2. 实现 ChannelActive ==> 客户端和服务器连接时,该方法自动执行
     * @param ctx 上下文
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
    
        // 初始化 ChannelHandlerContext
        this.context = ctx;
    }
    
    /**
     * 3. 实现 ChannelRead ==> 当我们读取到服务器的数据,该方法自动执行
     * @param ctx 上下文
     * @param msg 读取到的服务器的数据
     * @throws Exception
     */
    @Override
    public synchronized void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 将读取到的数据保存
        this.result = msg.toString();
        notify();
    }
    
    // 4. 写客户端的数据到服务器
    public synchronized Object call() throws Exception {
        // context 给服务器写数据
        context.writeAndFlush(param);
        wait();
        return result;
    }
    
    // 5. 设置参数的方法
    public void setParam(String param) {
        this.param = param;
    }
    
}
④ 运行测试

先运行 Server 端的 main(), 然后运行 Cliend 端的 main()

每隔两秒进行一次 读写操作。