Zookeeper的架构设计及原理分析
Posted 、楽.
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Zookeeper的架构设计及原理分析相关的知识,希望对你有一定的参考价值。
1. Zookeeper 设计猜想
Zookeeper 作为一个分布式协调组件,很多应用系统都会依赖Zookeeper来实现相关业务的处理。
前面我们一直在提到,在分布式架构中任何节点都不能以单点状态存在,所以Zookeeper首先需要解决的是单点故障问题,而常见的解决方案就是做主从集群,这个在前面的很多组件讲解中都有提到。
那么这个集群需要满足那些功能呢?
-
集群中要有主节点和从节点(也就是集群要有角色)。
-
集群要能做到数据同步,当主节点出现故障时,从节点能够顶替主节点继续工作,但是继续工作的前提是数据必须要主节点保持一致。
-
主节点挂了以后,从节点如何接替成为主节点? 是人工干预?还是自动选举。
所以基于这几点,我们先来大致画出zookeeper的集群节点。
在Zookeeper中,集群角色分为三种角色,分别是Leader、Follower、Observer。
1.1 Leader角色
Leader服务器是整个zookeeper集群的核心,主要的工作任务有两项:
-
事物请求的唯一调度和处理者,保证集群事物处理的顺序性。
-
集群内部各服务器的调度者。
1.2 Follower角色
Follower角色的主要职责有如下几点:
-
处理客户端非事物请求、转发事物请求给leader服务器。
-
参与事物请求Proposal的投票(需要半数以上服务器通过才能通知leader commit数据; Leader发起的提案,要求Follower投票)。
-
参与Leader选举的投票。
1.3 Observer角色
Observer是Zookeeper3.3开始引入的一个全新的服务器角色,从字面来理解,该角色充当了观察者的角色。
观察Zookeeper集群中的最新状态变化并将这些状态变化同步到observer服务器上。Observer的工作原理与follower角色基本一致,而它和follower角色唯一的不同在于observer不参与任何形式的投票,包括事物请求Proposal的投票和leader选举的投票。简单来说,observer服务器只提供非事物请求服务,通常在于不影响集群事物处理能力的前提下提升集群非事物处理的能力。
2. 节点之间的数据同步
知道了zookeeper的角色分类,那么我们如何做到节点间的数据同步呢?
如果要满足这样的一个高性能集群,我们最直观的想法应该是,每个节点都能接收到请求,并且每个节点的数据都必须要保持一致。要实现各个节点的数据一致性,就势必要一个leader节点负责协调和数据同步操作。这个我想大家都知道,如果在这样一个集群中没有leader节点,每个节点都可以接收所有请求,那么这个集群的数据同步的复杂度是非常大。
如下图所示,当客户端请求过来时,需要满足,事务型数据和非事务型数据的分开处理方式,就是leader节点可以处理事务和非事务型数据。而follower节点只能处理非事务型数据。原因是,对于数据变更的操作,应该由一个节点来维护,使得集群数据处理的简化。同时数据需要能够通过leader进行分发使得数据在集群中各个节点的一致性。
leader节点如何和其他节点保证数据一致性,并且要求是强一致的。在分布式系统中,每一个机器节点虽然都能够明确知道自己进行的事务操作过程是成功和失败,但是却无法直接获取其他分布式节点的操作结果。所以当一个事务操作涉及到跨节点的时候,就需要用到分布式事务,分布式事务的数据一致性协议有2PC协议和3PC协议。
2.1 关于2PC提交
当一个事务操作需要跨越多个分布式节点的时候,为了保持事务处理的ACID特性,就需要引入一个“协调者”(TM)来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为AP。TM负责调度AP的行为,并最终决定这些AP是否要把事务真正进行提交;因为整个事务是分为两个阶段提交,所以叫2pc。
2.2 阶段一:提交事务请求(投票)
- 事务询问
协调者向所有的参与者发送事务内容,询问是否可以执行事务提交操作,并开始等待各参与者的响应。
- 执行事务
各个参与者节点执行事务操作,并将Undo和Redo信息记录到事务日志中,尽量把提交过程中所有消耗时间的操作和准备都提前完成确保后面100%成功提交事务。
- 各个参与者向协调者反馈事务询问的响应
如果各个参与者成功执行了事务操作,那么就反馈给参与者 yes的响应,表示事务可以执行;如果参与者没有成功执行事务,就反馈给协调者 no的响应,表示事务不可以执行,上面这个阶段有点类似协调者组织各个参与者对一次事务操作的投票表态过程,因此2pc协议的第一个阶段称为“投票阶段”,即各参与者投票表名是否需要继续执行接下去的事务提交操作。
2.3 阶段二:执行事务提交
在这个阶段,协调者会根据各参与者的反馈情况来决定最终是否可以进行事务提交操作,正常情况下包含两种可能:执行事务、中断事务。
在Zookeeper中,采用少数服从多数的方式来实现数据同步,也就是不需要所有节点都在第一阶段给出明确的事务提交成功的回复,只需要大于半数节点都提交成功,那么Zookeeper就会认为该数据已经同步完成。所以我们的zookeeper在一定程度上可以说是弱一致性的,我们常说的zookeeper强一致性注意是体现在顺序强一致性上。
3. Zookeeper 集群组成
如下图所示,通常zookeeper是由2n+1台server组成,每个server都知道彼此的存在。每个server都维护的内存状态镜像以及持久化存储的事务日志和快照。
对于2n+1台server,只要有n+1台(大多数)server可用,整个系统保持可用。我们已经了解到,一个zookeeper集群如果要对外提供可用的服务,那么集群中必须要有过半的机器正常工作并且彼此之间能够正常通信,基于这个特性,如果想搭建一个能够允许F台机器down掉的集群,那么就要部署2*F+1台服务器构成的zookeeper集群。因此3台机器构成的zookeeper集群,能够在挂掉一台机器后依然正常工作。
一个5台机器集群的服务,能够对2台机器挂掉的情况下进行容灾。如果一台由6台服务构成的集群,同样只能挂掉2台机器。因此,5台和6台在容灾能力上并没有明显优势,反而增加了网络通信负担。
系统启动时,集群中的server会选举出一台server为Leader,其它的就作为follower(这里先不考虑observer角色)。
之所以要满足这样一个等式,是因为一个节点要成为集群中的leader,需要有超过及群众过半数点支持,这个涉及到leader选举算法,同时也涉及到事务请求的提交投票。
3.1 Zookeeper集群搭建
集群模式我们采用模拟3台机器来搭建zookeeper集群(三台服务器或者单机开三个端口也行)。分别复制安装包到三台机器上并解压,同时copy一份zoo.cfg。
-
修改配置文件
- 修改端口
- server.1=IP1:2888:3888 【2888:访问zookeeper的端口;3888:重新选举leader的端口】
- server.2=IP2.2888:3888
- server.3=IP3.2888:2888
-
server.A=B:C:D:其 中
- A 是一个数字,表示这个是第几号服务器;
- B 是这个服务器的 ip地址;
- C 表示的是这个服务器与集群中的 Leader 服务器交换信息的端口;
- D 表示的是万一集群中的 Leader 服务器挂了,需要一个端口来重新进行选举,选出一个新的 Leader,而这个端口就是用来执行选举时服务器相互通信的端口。如果是伪集群的配置方式,由于 B 都是一样,所以不同的 Zookeeper 实例通信端口号不能一样,所以要给它们分配不同的端口号。
- 在集群模式下,集群中每台机器都需要感知到整个集群是由哪几台机器组成的,在配置文件中,按照格式server.id=host:port:port,每一行代表一个机器配置。id: 指的是server ID,用来标识该机器在集群中的机器序号。
-
新建datadir目录,设置myid
在每台zookeeper机器上,我们都需要在数据目录(dataDir)下创建一个myid文件,该文件只有一行内容,对应每台机器的Server ID数字;比如server.1的myid文件内容就是1。【必须确保每个服务器的myid文件中的数字不同,并且和自己所在机器的zoo.cfg中server.id的id值一致,id的范围是1~255】
-
启动zookeeper
3.2 增加Observer节点
该模式运行的zookeeper
-
不参与选举。
-
不参与数据事务提交的ack应答。
配置方式如下
-
增加一个节点192.168.221.1
-
在该节点的zoo.cfg中加入下面的配置
peerType=observer server.1=192.168.221.128:2888:3888 server.2=192.168.221.129:2888:3888 server.3=192.168.221.0:2888:3888 #所有机器都需要配置这个 server.4=192.168.221.1:2888:3888:observer
-
最后一个配置 server.4=192.168.221.1:2888:3888:observer ,需要在所有节点都增加。
4. 关于Zookeeper中的一致性
前面我们在讲Zookeeper的数据同步时,提到zookeeper并不是强一致性服务,它是一个最终一致性模型,具体情况如下图所示。
ClientA/B/C假设只串行执行, clientA更新zookeeper上的一个值x。ClientB和clientC分别读取集群的不同副本,返回的x的值是不一样的。clientC的读取操作是发生在clientB之后,但是却读到了过期的值。很明显,这是一种弱一致模型。如果用它来实现锁机制是有问题的。
4.1 顺序一致性模型
顺序一致性提供了更强的一致性保证,我们来观察下图所示,从时间轴来看,B0发生在A0之前,读取的值是0,B2发生在A0之后,读取到的x的值为1。而读操作B1/C0/C1和写操作A0在时间轴上有重叠,因此他们可能读到旧的值为0,也可能读到新的值1。但是在强顺序一致性模型中,如果B1得到的x的值为1,那么C1看到的值也一定是1。
需要注意的是:由于网络的延迟以及系统本身执行请求的不确定性,会导致请求发起的早的客户端不一定会在服务端执行得早。最终以服务端执行的结果为准。
简单来说:顺序一致性是针对单个操作,单个数据对象。属于CAP中C这个范畴。一个数据被更新后,能够立马被后续的读操作读到。
但是zookeeper的顺序一致性实现是缩水版的,在下面这个网页中,可以看到官网对于一致性这块做了解释:
https://zookeeper.apache.org/doc/r3.6.1/zookeeperProgrammers.html#ch_zkGuarantees
zookeeper不保证在每个实例中,两个不同的客户端具有相同的zookeeper数据视图,由于网络延迟等因素,一个客户端可能会在另外一个客户端收到更改通知之前执行更新,考虑到2个客户端A和B的场景,如果A把znode /a的值从0设置为1,然后告诉客户端B读取 /a, 则客户端B可能会读取到旧的值0,具体取决于他连接到那个服务器,如果客户端A和B要读取必须要读取到相同的值,那么client B在读取操作之前执行sync方法。zooKeeper.sync();
除此之外,zookeeper基于zxid以及阻塞队列的方式来实现请求的顺序一致性。如果一个client连接到一个最新的follower上,那么它read读取到了最新的数据,然后client由于网络原因重新连接到zookeeper节点,而这个时候连接到一个还没有完成数据同步的follower节点,那么这一次读到的数据不久是旧的数据吗?实际上zookeeper处理了这种情况,client会记录自己已经读取到的最大的zxid,如果client重连到server发现client的zxid比自己大,连接会失败。
4.2 Single System Image的理解
zookeeper官网还说它保证了“Single System Image”,其解释为“A client will see the same view of the service regardless of the server that it connects to.”。实际上看来这个解释还是有一点误导性的。其实由上面zxid的原理可以看出,它表达的意思是“client只要连接过一次zookeeper,就不会有历史的倒退”。
https://github.com/apache/zookeeper/pull/931
5. Zookeeper 数据同步流程
在zookeeper中,客户端会随机连接到zookeeper集群中的一个节点,如果是读请求,就直接从当前节点中读取数据,如果是写请求,那么请求会被转发给leader提交事务,然后leader会广播事务,只要有超过半数节点写入成功,那么写请求就会被提交。(类2PC事务)
所有事务请求必须由一个全局唯一的服务器来协调处理,这个服务器就是Leader服务器,其他的服务器就是follower。leader服务器把客户端的请求转化成一个事务Proposal(提议),并把这个Proposal分发给集群中的所有Follower服务器。之后Leader服务器需要等待所有Follower服务器的反馈,一旦超过半数的Follower服务器进行了正确的反馈,那么Leader就会再次向所有的Follower服务器发送Commit消息,要求各个follower节点对前面的一个Proposal进行提交。
那么问题来了
-
集群中的leader节点如何选举出来?
-
leader节点崩溃以后,整个集群无法处理写请求,如何快速从其他节点里面选举出新的leader呢?
-
leader节点和各个follower节点的数据一致性如何保证
5.1 ZAB协议
ZAB(Zookeeper Atomic Broadcast) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。
ZAB协议包含两种基本模式,分别是:
-
崩溃恢复
-
原子广播
当整个集群在启动时,或者当leader节点出现网络中断、崩溃等情况时,ZAB协议就会进入恢复模式并选举产生新的Leader,当leader服务器选举出来后,并且集群中有过半的机器和该leader节点完成数据同步后(同步指的是数据同步,用来保证集群中过半的机器能够和leader服务器的数据状态保持一致),ZAB协议就会退出恢复模式。
当集群中已经有过半的Follower节点完成了和Leader状态同步以后,那么整个集群就进入了消息广播模式。这个时候,在Leader节点正常工作时,启动一台新的服务器加入到集群,那这个服务器会直接进入数据恢复模式,和leader节点进行数据同步。
同步完成后即可正常对外提供非事务请求的处理。
**需要注意的是:**leader节点可以处理事务请求和非事务请求,follower节点只能处理非事务请求,如果follower节点接收到非事务请求,会把这个请求转发给Leader服务器。
5.2 消息广播的实现原理
如下图所示,前面我们说过,消息广播的过程实际上是一个简化版本的二阶段提交过程。
-
leader接收到消息请求后,将消息赋予一个全局唯一的64位自增id,叫:zxid,通过zxid的大小比较既可以实现因果有序这个特征
-
leader为每个follower准备了一个FIFO队列(通过TCP协议来实现,以实现了全局有序这一个特点)将带有zxid的消息作为一个提案(proposal)分发给所有的follower
-
当follower接收到proposal,先把proposal写到磁盘,写入成功以后再向leader回复一个ack
-
当leader接收到合法数量(超过半数节点)的ACK后,leader就会向这些follower发送commit命令,同时会在本地执行该消息
-
当follower收到消息的commit命令以后,会提交该消息
和完整的2pc事务不一样的地方在于,zab协议不能终止事务,follower节点要么ACK给leader,要 么抛弃leader,只需要保证过半数的节点响应这个消息并提交了即可,虽然在某一个时刻follower节点和leader节点的状态会不一致,但是也是这个特性提升了集群的整体性能。 当然这种数据不 一致的问题,zab协议提供了一种恢复模式来进行数据恢复。
这里需要注意的是:
leader的投票过程,不需要Observer的ack,也就是Observer不需要参与投票过程,但是Observer必须要同步Leader的数据从而在处理请求的时候保证数据的一致性。
5.3 崩溃回复的实现原理
前面我们已经清楚了ZAB协议中的消息广播过程,ZAB协议的这个基于原子广播协议的消息广播过程,在正常情况下是没有任何问题的,但是一旦Leader节点崩溃,或者由于网络问题导致Leader服务器失去了过半的Follower节点的联系(leader失去与过半follower节点联系,可能是leader节点和follower节点之间产生了网络分区,那么此时的leader不再是合法的leader了),那么就会进入到崩溃恢复模式。
崩溃恢复状态下zab协议需要做两件事:
-
选举出新的leader
-
数据同步
前面在说消息广播时,知道ZAB协议的消息广播机制是简化版本的2PC协议,这种协议只需要集群中过半的节点响应提交即可。但是它无法处理Leader服务器崩溃带来的数据不一致问题。因此在ZAB协议中添加了一个“崩溃恢复模式”来解决这个问题。
那么ZAB协议中的崩溃恢复需要保证,如果一个事务Proposal在一台机器上被处理成功,那么这个事务应该在所有机器上都被处理成功,哪怕是出现故障。为了达到这个目的,我们先来设想一下,在zookeeper中会有哪些场景导致数据不一致性,以及针对这个场景,zab协议中的崩溃恢复应该怎么处理。
5.3.1 已经被处理的消息不能丢弃
当 leader 收到合法数量 follower 的 ACKs 后,就向各个 follower 广播 COMMIT 命令,同时也会在本地执行 COMMIT 并向连接的客户端返回「成功」。但是如果在各个 follower 在收到 COMMIT 命令前leader 就挂了,导致剩下的服务器并没有执行都这条消息。
图中的C2就是一个典型的例子,在集群正常运行过程的某一个时刻,Server1是leader服务器,先后广播了消息P1、P2、C1、P3和C2.
其中当leader服务器把消息C2(Commit事务proposal2)发出后就立即崩溃退出了,那么针对这种情况,ZAB协议就需要确保事务Proposal2最终能够在所有的服务器上都能被提交成功,否则将会出现不一致。
5.3.2 被丢弃的消息不能再次出现
如下图所示,当 leader 接收到消息请求生成 proposal 后就挂了,其他 follower 并没有收到此proposal,因此经过恢复模式重新选了 leader 后,这条消息是被跳过的。 此时,之前挂了的 leader 重新启动并注册成了 follower,他保留了被跳过消息的 proposal 状态,与整个系统的状态是不一致的,需要将其删除。
ZAB协议需要满足上面两种情况,就必须要设计一个leader选举算法:能够确保已经被leader提交的事务Proposal能够提交、同时丢弃已经被跳过的事务Proposal。针对这个要求:
-
如果leader选举算法能够保证新选举出来的Leader服务器拥有集群中所有机器最高编号(ZXID最大)的事务Proposal,那么就可以保证这个新选举出来的Leader一定具有已经提交的提案。
-
因为所有提案被 COMMIT 之前必须有超过半数的 follower ACK,即必须有超过半数节点的服务器的事务日志上有该提案的 proposal,因此,只要有合法数量的节点正常工作,就必然有一个节点保存了所有被 COMMIT 消息的 proposal 状态
-
另外一个,zxid是64位,高32位是epoch编号,每经过一次Leader选举产生一个新的leader,新的leader会将epoch号+1,低32位是消息计数器,每接收到一条消息这个值+1。
-
新leader选举后这个值重置为0。这样设计的好处在于老的leader挂了以后重启,它不会被选举为leader,因此此时它的zxid肯定小于当前新的leader。
-
当老的leader作为follower接入新的leader后,新的leader会让它将所有的拥有旧的 epoch 号的未被 COMMIT 的 proposal 清除。
5.4 关于ZXID
我们前面提到过多次ZXID,它是Zookeeper中的数据对应的事务ID。
为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加上了zxid。通过stat命令查看节点的信息。
实现中zxid是一个64位的数字,它高32位是epoch(ZAB协议通过epoch编号来区分Leader周期变化的策略)用来标识leader关系是否改变,每次一个leader被选出来,它都会有一个新的epoch=(原来的epoch+1),
标识当前属于那个leader的统治时期。低32位用于递增计数。
比如0x5c67, 实际上是: 500000c67, 前面的5表示epoch,后面的c67是16进制的递增编号。
epoch:可以理解为当前集群所处的年代或者周期,每个 leader 就像皇帝,都有自己的年号,所以每次改朝换代,leader 变更之后,都会在前一个年代的基础上加 1。这样就算旧的 leader 崩溃恢复之后,也没有人听他的了,因为 follower 只听从当前年代的 leader 的命令
zxid达到最大值后会触发集群重新选举,然后zxid会变为0。 日志中会看到如下信息:
INFO [ProcessThread(sid:31814 cport:-1)::PrepRequestProcessor@137] - zxid lower 32 bits have rolled over, forcing re-election, and therefore new epoch start
5.5 Zookeeper的事务日志
Zookeeper的数据是持久化在磁盘上的,默认的目录是在/tmp/zookeeper下,这个目录中会存放事务日志和快照日志。
该路径可以通过zoo.cfg文件来修改:
# 内存数据库快照存放地址
dataDir=/data/zookeeper-3.6.3/data
# 事务日志存储
dataLogDir=/data/zookeeper-3.6.3/data/log
在该目录下可以看到有以下文件内容,在Zab协议中我们知道每当有接收到客户端的事务请求后Leader与Follower都会将把该事务日志存入磁盘日志文件中,该日志文件就是这里所说的事务日志。
其中文件的命名是 log.zxid, 其中zxid表示当前日志文件中开始记录的第一条数据的zxid。
6. Leader选举原理分析
接下来再我们基于源码来分析leader选举的整个实现过程。
leader选举存在与两个阶段中,一个是服务器启动时的leader选举。 另一个是运行过程中的leader节点宕机导致的leader选举 ;
在开始分析选举的原理之前,先了解几个重要的参数
-
服务器ID(myid)
比如有三台服务器,编号分别是1,2,3。
编号越大在选择算法中的权重越大。
-
zxid事务id
值越大说明数据越新,在选举算法中的权重也越大
-
逻辑时钟(epoch – logicalclock)
或者叫投票的次数,同一轮投票过程中的逻辑时钟值是相同的。每投完一次票这个数据就会增加,然后与接收到的其它服务器返回的投票信息中的数值相比,根据不同的值做出不同的判断。
-
选举状态
LOOKING,竞选状态。
FOLLOWING,随从状态,同步leader状态,参与投票。
OBSERVING,观察状态,同步leader状态,不参与投票。
LEADING,领导者状态。
6.1 服务器启动时的leader选举
每个节点启动的时候状态都是LOOKING,处于观望状态,接下来就开始进行选主流程。
若进行Leader选举,则至少需要两台机器,这里选取3台机器组成的服务器集群为例。在集群初始化阶段,当有一台服务器Server1启动时,其单独无法进行和完成Leader选举,当第二台服务器Server2启动时,此时两台机器可以相互通信,每台机器都试图找到Leader,于是进入Leader选举过程。选举过程如下:
-
每个Server发出一个投票。由于是初始情况,Server1和Server2都会将自己作为Leader服务器来进行投票,每次投票会包含所推举的服务器的myid和ZXID、epoch,使用(myid, ZXID,epoch)来表示,此时Server1的投票为(1, 0,0),Server2的投票为(2, 0,0),然后各自将这个投票发给集群中其他机器。
-
接受来自各个服务器的投票。集群的每个服务器收到投票后,首先判断该投票的有效性,如检查是否是本轮投票(epoch)、是否来自LOOKING状态的服务器。
-
处理投票。针对每一个投票,服务器都需要将别人的投票和自己的投票进行PK,PK规则如下:
- 优先比较epoch
- 其次检查ZXID。ZXID比较大的服务器优先作为Leader。
- 如果ZXID相同,那么就比较myid。myid较大的服务器作为Leader服务器。
对于Server1而言,它的投票是(1, 0, 0),接收Server2的投票为(2, 0, 0),首先会比较两者的ZXID,均为0,再比较myid,此时Server2的myid最大,于是更新自己的投票为(2, 0, 0),然后重新投票,对于Server2而言,其无须更新自己的投票,只是再次向集群中所有机器发出上一次投票信息即可。
-
统计投票。每次投票后,服务器都会统计投票信息,判断是否已经有过半机器接受到相同的投票信息,对于Server1、Server2而言,都统计出集群中已经有两台机器接受了(2, 0)的投票信息,此时便认为已经选出了Leader。
-
改变服务器状态。一旦确定了Leader,每个服务器就会更新自己的状态,如果是Follower,那么就变更为FOLLOWING,如果是Leader,就变更为LEADING。
6.2 运行过程中的leader选举
当集群中的leader服务器出现宕机或者不可用的情况时,那么整个集群将无法对外提供服务,而是进入新一轮的Leader选举,服务器运行期间的Leader选举和启动时期的Leader选举基本过程是一致的。
-
变更状态。Leader挂后,余下的非Observer服务器都会将自己的服务器状态变更为LOOKING,然后开始进入Leader选举过程。
-
每个Server会发出一个投票。在运行期间,每个服务器上的ZXID可能不同,此时假定Server1的ZXID为123,Server3的ZXID为122;在第一轮投票中,Server1和Server3都会投自己,产生投票(1, 123),(3, 122),然后各自将投票发送给集群中所有机器。接收来自各个服务器的投票。与启动时过程相同。
-
处理投票。与启动时过程相同,此时,Server1将会成为Leader。
-
统计投票。与启动时过程相同。
-
改变服务器的状态。与启动时过程相同
以上是关于Zookeeper的架构设计及原理分析的主要内容,如果未能解决你的问题,请参考以下文章