原文链接:http://pages.cs.wisc.edu/~remzi/OSTEP/dist-nfs.pdf
第一次使用分布式客户端服务器模式的计算的其中一个领域是分布式文件系统。在这样一个环境中,有许多客户机和一个服务器(服务器或者更多),服务器将数据保存在它的本地磁盘上,客户机通过组织良好的协议消息来获取数据。
正如上面图片中看到的,服务器有磁盘,客户机发送消息来获取它们需要的目录和文件。为什么我们要使用这种很麻烦的布局呢?(比如,为什么不直接使用客户机的本地磁盘呢?)主要的原因是这种布局使得在客户机之间的数据共享更加容易。比如,如果你访问Client 0上的一个文件,然后你使用Client 2,你会看到相同的文件系统视图。数据可以很自然地在不同机器上共享。第二个原因是集中管理,比如,要备份文件,可以只备份少数几个服务器上的数据,而不用备份许多客户机上的数据。另一个好处是安全,将所有的服务器放在一个锁好的机房里可以防止特定类型的问题发生。
关键点:如何构建一个分布式文件系统?
我们如何构建一个分布式文件系统?需要思考哪些关键因素?什么容易导致错误?可以从现在的系统上学到什么?
8.1 一个基本的分布式文件系统
现在,让我们来研究一个基本的分布式文件系统的架构。一个简单的客户机服务器分布式文件系统比我们已经研究过的文件系统有更多的组件。在客户端,客户端程序通过客户端文件系统访问文件和目录。客户端程序为了获取服务器上的数据可以向客户机发送系统调用。这样,对于客户端程序而言,分布式文件系统跟本地文件系统没什么不同,除了性能,因此,从这个方面说,分布式文件系统的一个目标是可以透明地访问文件,毕竟,谁想用一个需要另外一套API来使用的文件系统呢?
客户端的文件系统需要对这些系统调用进行响应。比如,如果客户端程序发出一个read()请求,客户端文件系统可能会想服务器文件系统发送一个消息来读取一个特定的块,然后,文件服务器会从磁盘(或者缓存)中读取块,然后将请求的数据构成一条消息发送给客户端。客户端文件系统会将数据拷贝到read()系统调用提供的用户缓冲区中,一个请求过程就结束了。注意,客户端在接下来请求的同一个块可能会缓存在内存或者本地磁盘中,因此,最好的情况下,没有产生任何网络流量。
从这个简单的试图可以看出,你必须知道在一个分布式文件系统中有两种不同的软件:客户端文件系统和文件服务器。它们的行为共同决定分布式文件系统的行为。现在,是时候来研究一个特定的系统了:Sun的NFS。
旁白:服务器为什么会崩溃?
在深入了解NFSv2协议之前,你可能会奇怪:服务器为什么会崩溃?确实,你可能会猜,有许多原因导致服务器崩溃。服务器会因为断电而暂停服务,不过那是暂时的;当电力恢复时,机器就可以重启。服务器软件可能包括十几万行或者几百万行代码;因此,可能会有bugs,甚至好的软件每一百万行代码就会有一些bugs,服务器软件可能会触发一个导致它们崩溃的bug。服务器软件可能会有内存泄露;即使是一个很小的内存泄露也可能导致内存耗尽,然后就会崩溃。最后,在一个分布式系统中,客户端和服务器之间用网络连接,如果网络的行为异常(比如,如果网络被隔离了,客户端和服务器可以工作,但是不能通信)于是,它们的行为就好像远程机器崩溃了,但是事实是不能通过网络到达。
48.2 On to NFS
最早的且非常成功的一个分布式系统是Sun开发的NFS。在定义NFS时,Sun采用了一种不同寻常的方法:没有创建一个专有的封闭的系统,而是开发了一种开放的协议,该协议只是指定了客户端和服务器通信的精确的消息格式。不同的组织可以开发它们自己的NFS服务器,然后在保留互操作性的情况下争夺NFS市场。NFS确实很好:今天,许多公司在销售它们的NFS服务器(包括Oracle,NetApp,EMC,IBM等等),NFS的成功可能要归功于这个开放市场的方法。
48.3 重点:简单和服务器快速宕机恢复
在这节中,我们会讨论传统的NFS协议(NFSv2),它在过去许多年来都是标准。从NFSv2到NFSv3变化不大,从NFSv2到NFSv4有大规模的协议变化。然而,NFSv2是如此奇妙,而且它令人不快,因此,它是我们的研究重点。
NFSv2设计的主要目标是简单和服务器快速宕机恢复。在多客户端、单服务器环境下,这个目标很有意义,任何时候服务器宕机会使得所有的客户失望,造成所有的客户端不能正常工作。因此,服务器对于整个系统是个关键。
48.4 快速宕机恢复的关键:无状态性
为了实现这个简单的目标,NFSv2被设计为一个无状态性的协议。服务器并不跟踪每个客户端所发生的事。比如,服务器不知道哪个客户机缓存了哪些块,或者每个客户机当前打开了哪些文件,或者某个文件的当前文件指针位置等等。因此,服务器并不知道客户端当前正在做的事,而且,协议被设计用来传送完成请求所需要的所有信息。如果它不知道,正如我们接下来讨论的,这种无状态的方法更有意义。
来看一个有状态的协议的例子,考虑open()系统调用。给定一个路径,open()返回一个文件描述符。这个文件描述符在接下来的read()或者write()操作中用来访问文件块。
现在,假设,客户端文件系统打开一个文件,向服务器发送一个协议消息,说“打开文件foo,返回给我文件描述符”。然后,文件服务器打开文件,将文件描述符回送给客户端。在接下来的读操作中,客户端程序使用文件描述符调用read()系统调用;客户端文件系统将文件描述符封装成一个消息,然后将消息发送给文件服务器,说“从我发给你的文件描述符指向的文件读取一些字节”。
在这个例子中,文件描述符是客户机和服务器共享的状态。正如我们在上面提到的共享状态使得宕机恢复复杂。假如,服务器在第一个读操作完成后,但是在客户机发送第二个请求前崩溃了。在服务器重启后,客户机发送第二个读请求。不幸的是,服务器不知道文件描述符fd指向哪个文件,该信息是个瞬时信息,因此,当服务器崩溃后,该信息就丢失了。为了处理这种情况,客户机和服务器会启动某种恢复协议,客户端要确保它本身保存了内存中足够的信息,这样它就能够告诉服务器发生了什么。
如果考虑一个状态服务器要处理客户机的宕机,情况会变得更糟。比如说,客户端打开了一个文件,然后崩溃了。open()操作使用的是服务器上的一个文件描述符,服务器如何能够知道可以关闭这个文件?在正常情况下,客户端最后会调用close()告诉服务器应该关闭这个文件。然而,如果客户机崩溃了,服务器就接收不到close(),因此,为了关闭这个文件,服务器必须知道客户机已经崩溃了。
基于这些原因,NFS的设计者们提出了一种无状态的方法:每个客户端操作包含用于完成请求的所有信息。不需要代价较高的宕机恢复;服务器只需要重启,在更糟的情况下,客户端可能重新发送请求。
48.5 NFSv2
总算可以看看NFSv2协议了。我们的问题很简单:
关键点:如何定义无状态的文件协议?
我们如何才能定义网络协议来实现无状态操作呢?我们已经知道,有状态的调用(比如open())不是我们讨论的部分(因为它需要服务器跟踪已经打开的文件),然而,客户端程序仍然希望使用open()、read()、write()、close()等其它标准API来访问文件和目录。因此,这个问题可以改为:我们如何才能定义一个协议,它是无状态的,同时它支持POSIX文件系统API。
理解NFS的一个关键是理解文件句柄(file handle)。文件句柄用于唯一地描述一个特定的文件操作所操作的文件或者目录;因此,许多协议请求包含一个文件句柄。
可以认为文件句柄包含三个重要部分:卷标识符、索引节点号和生成号;这三个部分共同构成了客户端希望访问的文件或者目录的唯一标识符。卷标识符告诉服务器这个请求指向哪个文件系统;索引节点号告诉服务器这个请求访问的是该分区的哪个文件。最后,当要重用一个索引节点号时,生成号是必要的;当要重用一个索引节点号时,就对生成号递增;服务器保证了客户端不能使用一个旧的文件句柄访问新创建的文件。
下面是该协议的一些重要部分的总结;整个协议的内容在网上都能够找到。
我们对该协议的重要部分设置了高亮。首先,LOOKUP协议消息用于获取一个文件句柄,之后就可以使用这个文件句柄进行文件访问。客户端传递一个目录文件句柄和文件的名字进行查询,文件句柄和它的属性被返回给客户端。
比如,假设客户端已经有了一个文件系统的根目录的文件句柄(事实上,根目录的文件句柄是通过NFS挂载协议获得的,NFS挂载协议是客户端和服务器开始是如何连接的)。如果客户端的一个程序打开了一个文件/foo.txt,客户端文件发送一个lookup请求给服务器,将根目录的句柄和名字foo.txt包装到消息中;如果操作成功,服务器就会返回foo.txt的文件句柄和属性给客户端。
在上面提到了文件属性,其实,文件属性就是文件系统跟踪每个文件的元数据,包括创建时间、最后更改时间、文件大小、所有者、访问权限信息和其它信息,就像调用stat()获得的信息。
好了,现在可以使用文件句柄了,客户端可以发送READ和WRITE协议消息来读写这个文件。READ协议消息还要发送文件读取的偏移量和读取字节数。然后,服务器就会发送read操作(毕竟,描述符告诉了服务器要读取哪个卷中的哪个文件,偏移量和字节数告诉服务器该读取哪部分数据),然后将数据返回给客户端。WRITE操作类似,除了数据是从客户端发送到服务器,返回一个成功码。
最后一个有趣的协议消息是GETATTR请求;给定一个文件句柄,它可以获得该文件的属性,包括文件的最后更改时间。下面我们会看到为什么在讨论缓存时这个协议消息在NFSv2中如此重要?
48.6 从协议到分布式文件系统
现在,你很想知道如何将该协议转换成跨越客户端文件系统和文件服务器的文件系统。客户端文件系统跟踪打开的文件,将应用程序请求转换成对应的协议消息。服务器只需要响应每个协议消息,每个协议消息包含完成请求的所有信息。
比如,我们来看看一个读文件的简单程序。在图48.1中,显示了程序调用了哪些系统调用,客户端文件系统和文件服务器对这些调用做了什么操作。
图中包含一些注释。首先,需要注意的是,客户端如何跟踪文件访问操作的对应状态,包括文件描述符与NFS文件句柄的对应和当前文件指针。这能够使得客户端将每个读请求转换成合适的读协议消息,该协议消息告诉服务器从文件中读取哪些字节。读取操作成功了,客户端会更新当前文件位置;接下来的读取操作针对同一个文件句柄,但是偏移量不同。
其次,你可能注意到了服务器交互发生的位置。当文件第一次打开时,客户端文件系统发送LOOKUP请求消息。事实上,如果文件名包含一个很长的路径,比如/home/remzi/foo.txt,客户端会发送三次LOOKUP:第一次在根目录查询home,第二次在home目录下查询remzi,第三次在remzi目录下查询foo.txt。
最后,你可能注意到每个服务器请求包含了了需要完成请求的所有信息。这个设计在服务器宕机后的恢复过程中十分重要;它保证了服务器不需要保存状态来响应请求。
诀窍:幂等性是有用的
幂等性在构建可靠系统时是一个非常有用的属性。当一个操作可以执行多次,那么,处理操作的失败就十分容易;你可以进行重试。如果一个操作不是幂等的,生活就会变得如此艰难。
48.7 用幂等操作处理服务器故障
当客户端向服务器发送一条消息,有时收不到回应。造成响应失败的原因有很多。有时,这条消息可能被网络丢弃了;网络丢掉了消息,那么,无论是请求还是响应都丢失了,客户端永远也收不到响应。
还有可能是服务器崩溃了,服务器就不会响应消息。之后,服务器重启,但是,所有的请求已经丢失了。在所有的情况下,客户端留下了一个问题:当服务器没有及时地响应,客户端应该怎么做呢?
在NFSv2中,客户端使用一种一致的优雅的方式:重新发送请求。在客户端发送请求后,设置一个定时器。如果在定时器超时之前,收到了响应,就取消定时器,操作成功。然而,如果定时器超时了,没有收到任何响应,客户端就会认为请求没有被处理,然后重新发送请求。如果服务器响应了,一切是多么完美啊,客户端如此灵活地处理了这个问题。
客户端可以重新发送请求(无论造成故障的原因是什么),这要归功于大多数NFS请求的一个重要特性:它们是幂等的。当一个操作执行多次的效果跟执行一次的效果一样,就称这个操作是幂等的。比如,如果你在一个内存地址存储一个值三次,它的效果于在该地址存储这个值一次的效果一样;因此,在内存中存储值这个操作就是一个幂等操作。然而,如果你在一个计数器上递增三次,它带来的结果与执行一次的结果不同;因此,计数器递增不是幂等的。更一般地,任何只读取数据的操作是幂等的;但是,更新数据的操作必须小心地考虑,看它是否有这个性质。
NFS的宕机恢复的设计核心就是大多数操作都是幂等的。LOOKUP和READ是幂等的,因为它们只从服务器读取数据,而不更新。更加有趣的是,WRITE请求通常也是幂等的。比如,如果WRITE失败了,客户端就重新发送WRITE请求。WRITE消息包含数据,数据的个数和写数据的偏移量。这样,执行多次写操作的结果与执行一次的结果相同。
使用这种方法,客户端可以以一种统一的方式处理所有的超时。如果WRITE请求丢失了(上图的情况1),客户端重新发送WRITE请求,服务器执行写操作,一切都很美好。如果请求已经发送,服务器碰巧宕机了,当第二次请求发送时,所有的操作重新执行(情况2)。最后,服务器事实上收到了WRITE请求,然后,服务器向磁盘发送write操作,将结果发送给客户端。这个响应可能丢失(情况3),客户端重新发送请求。当服务器再次收到了这个请求,它会做同样的事:向磁盘写数据,向客户端发送响应。如果客户端收到了响应,一切工作顺利,客户端以一种统一的方式处理了消息丢失和服务器鼓掌。多么干净利落啊!
还剩下一部分:一些操作很难使它变成幂等的。比如,当你想创建一个已经存在的目录,你知道这个mkdir操作失败了。因此,在NFS中,如果文件服务器接收到了MKDIR消息,执行成功了,但是响应丢失,客户端可能会重新发送该消息,然后该操作失败,事实上,这个操作在第一次成功了,只是在重试时失败了。当然,任何事情都不是完美的!!!
诀窍:没有任何事情是完美的
即使当你设计一个漂亮的系统,有时候,所有的细节情况不会如你预期地执行。看看上面的mkdir的例子;可以将mkdir重新设计,使得它有不同的语义,将它变成幂等的;然而,为什么如此烦恼呢?NFS的设计哲学包含了大多数的重要情况,它使得系统设计在应对故障时十分简洁和简单。因此,请接受吧,没有任何事情是完美的,设计一个系统是一个好的工程的标志。显然,这来自于Voltaire,他说:一个聪明的意大利人说没有任何事情是完美的。因此,我们称它为Voltaire定律。
48.8 提高性能:客户端缓存
基于许多原因,分布式文件系统是很有用的,但是,将所有的读写请求都发送到网络上会带来一个严重的性能问题:通常,网络带宽并不大,特别是相对于本地内存或者磁盘而言。因此,另一个问题是:我们如何才能提高分布式文件系统的性能。
正如你从上面的标题中看到的,答案是:使用客户端缓存。NFS客户端文件系统将从服务器读取到的数据和元数据缓存到本地内存中。这样,第一次访问的代价较高(比如,它需要网络通信),接下来的访问就很快,因为使用的是客户端内存。
缓存通常作为一个用于读的临时缓冲区。当客户端程序第一次写一个文件时,客户端在将数据发送到服务器之前将数据缓存在客户端内存中(与从文件服务器读取的数据缓存的位置相同)。这样的写缓存是很有用的,因为它减少了write()延迟,比如,应用程序调用write()立即成功(只是将数据存放到客户端文件系统的缓存中);之后再将数据写到文件服务器。
这样,NFS客户端缓存数据,性能得到了提升,一切都很好,不是吗?其实,情况并没有那么好。在任何有多个客户端的系统中加入缓存会带来一个严峻而有趣的挑战,我们称之为缓存一致性问题。
48.9 缓存一致性问题
缓存一致性问题可以用两个客户端和一个服务器的系统来解释。假如客户端C1读一个文件F,然后在它的本地缓存中保存这个文件的一个副本。现在,另一个客户端C2重写了文件F;我们将这个F文件的新版本称为F(v2),之前的文件F称为F(v1)。最后,第三个客户端C3,它还没有访问过F。
你可能看到问题所在了。事实上,这里有两个子问题。第一个子问题是,C2可能在将数据写到服务器之前,将它缓存一段时间;在这种情况下,F(v2)存储在C2的内存中,从其它客户端访问F时读取的数据是F(v1)。这样,将写操作的数据缓存在客户端,其它的客户端可能会读取到老版本的数据,这并不是所期望的行为;事实上,假如你登陆了C2,更新F,然后登陆C3,尝试读取这个文件,所读取的文件是老版本的副本。当然,这种情况让人不爽。我们称缓存一致性问题的这个方面为更新可见性;何时在一台客户端上进行更新操作,然后在其它客户端上可见呢?
缓存一致性的第二个子问题是缓存失效;在这种情况下,最终C2会将数据写到文件服务器,文件服务器就有了F(v2)。然而,C1仍然保存了F(v1)而不是F(v2),这不是所期望的行为。
NFSv2以两种方式解决缓存一致性问题。首先,为了解决更新可见性问题,客户端的实现通常称为flush-on-close一致性语义(也就是close-to-open);特别地,当一个客户端程序写文件,然后关闭文件,客户端会将所有的更新刷写到服务器。有了flush-on-close一致性,NFS可以保证之后从另一个客户端打开文件能够得到该文件的最新版本。
其次,为了解决缓存失效问题,NFSv2客户端在使用缓存内容之前会检查文件是否已经改变了。特别地,当打开文件时,客户端文件系统会向服务器发送一个GETATTR请求来获得文件的属性信息。更重要的是,属性信息包含服务器上文件上次的修改时间;如果更改时间比客户端缓存的文件的时间要新,客户端就使缓存的文件失效,并将它从客户端缓存中删除,保证之后的读操作会发送到服务器,以便获得文件的最新版本。另一方面,如果客户端发现缓存的文件是最新版本,它就会使用缓存文件,这样能够提高性能。
当Sun的团队在实现缓存失效问题的解决方案时,他们遇到了一个新的问题;NFS服务器被GETEATTR请求淹没了。一个良好的工程设计原则是尽量针对一般情况,这样也能工作得很好。这里,虽然通常情况是一个文件只被一个客户端访问,客户端还是经常需要向服务器发送GETATTR消息来保证没有其它客户端改变了这个文件。客户端就会发送许多消息,都是询问“是否有其他人已经改变了该文件”,但是,大多数时候并没有人改变这个文件。
为了改善这个情况,在每个客户端中添加了属性缓存。客户端在访问文件之前仍然需要查看文件是否有效,但是,大多数情况下,只需要从属性缓存中获取属性。当文件第一次访问时,该文件的属性存放在缓存中,过一段时间,会超时(3秒)。这样,在3秒以内,所有的文件访问都被认为可以使用缓存文件,这样的话,就没有与服务器的网络交互。
48.10 评估NFS的缓存一致性
flush-on-close的加入确实有点作用,但是也带来一个性能问题。特别地,如果在客户端创建一个临时的或者短期的文件,之后马上删除,该行为仍然要发送到服务器。一种方法是将这种短期的文件保存在内存中,直到它们被删除,这样就能够完全消除与服务器的就交互,或许能够提高性能。
更重要的是,属性缓存的加入使得我们很难知道我们获得的文件是哪个版本。有时,你会获得最新版本;有时,你可能会获取一个老版本,因为你的属性缓存还没有超时,客户端就会很高兴地将客户端内存中的数据发给你。虽然,这在大多数情况下工作得很好,但是,有时会导致奇怪的行为。
我们我已经说明了NFS客户端缓存的一些奇怪问题。
48.11 服务器端写缓存的含义
目前为止,我们的重点都在客户端缓存,在客户端缓存有许多有趣的问题。然而,NFS服务器也有内存,因此,也需要考虑缓存的问题。当数据和元数据从磁盘中读取出来,NFS会将它们缓存在内存中,之后对这些数据和元数据的访问就可以直接访问缓存,这对性能有稍许提高。
更有意思的情况是写缓存。针对一个WRITE请求,NFS服务器只有等到数据已经写到固定存储器(磁盘或者其它持久存储设备)中才会返回成功。虽然能够在服务器内存中保存一个副本,对WRITE请求返回成功会导致不正确的行为;你能够找出这是为什么吗?
答案在于我们对于客户端如何处理服务器失效的假设。假设客户端发送了下列写请求:
write(fd, a_buffer, size); // fill first block with a’s
write(fd, b_buffer, size); // fill second block with b’s
write(fd, c_buffer, size); // fill third block with c’s
这几个写操作用三个缓冲区的数据对文件的三个块进行重写。如果,文件开始是这样的:
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
我们可能会期望在经过三个写操作之后结果是这样,三个块分别被a's,b's,c's重写。
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
现在,作为一个例子,我们假设这三个写操作作为三个独立的WRITE协议消息发送到服务器。假设服务器受到第一个WRITE消息,对磁盘进行操作,通知客户端操作成功。现在,假设第二个写操作仅仅缓存在内存中,在将数据写到磁盘之前,服务器通知客户端操作成功;不幸的是,服务器在将数据写到磁盘之前崩溃了。服务器立刻重启,受到第三个写请求,当然也成功了。
这样,对于客户端而言,所有的请求都成功了,但是,我们发现内容变成了这样:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy <--- oops
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
因为服务器在将数据写到磁盘之前告诉客户端第二个写操作成功了,于是,文件的这部分数据仍然是老数据,这可能导致灾难性的后果。
为了解决这个问题,NFS服务器在通知客户端操作是否成功之前必须将数据写到持久存储设备中;这样做能够使客户端在写操作过程中检测服务器是否崩溃,然后进行重试,直到成功。这样做能够保证我们永远不会使数据发生上述例子中的混乱情况。
这个要求给NFS服务器的实现带来的一个问题是写性能会成为主要的性能瓶颈。事实上,一些公司(比如NetApp)采用一种已经存在的方式,他们的目标是使得NFS服务器能够快速执行写操作;他们使用的一个技巧是先把写的数据放在一个带后背电池的存储器中,使得可以快速响应WRITE请求,而不用担心数据的丢失,也消除了将数据写到磁盘的开销;第二个技巧是使用一个特别设计的文件系统,这个文件系统能够在需要进行写操作时快速地将数据写到磁盘。
48.12 总结
我们已经了解了NFS分布式文件系统的一些介绍。NFS的重心在于设计简单,面对服务器崩溃时的快速恢复,通过良好的协议设计来达到这个目标。操作的幂等性是必要的;因为客户端可以安全地响应失败的操作,无论服务器是否执行了请求,这样做都是可行的。
我们也了解了在多客户端单服务器环境下缓存的加入如何使得情况变得复杂。特别地,为了使行为正确,系统必须解决缓存一致性问题;然而,NFS采用的方式会导致奇怪的行为。最后,我们看到服务器缓存有也有问题:服务器的写操作在向客户端回送成功消息之前必须将数据写到持久存储器中(否则,数据可能会丢失)。
我们没有讨论其它的值得注意的与安全相关的问题。在早期的NFS实现中,安全的实现是很宽松的;客户端上的任何用户可以很轻易地伪装成其他人,然后访问任何文件。与更加严密的认证服务的结合可以解决这些不足。