服务端开发对于任何互联网公司来讲,都并非易事,它所涉及的技术知识面非常广泛,如果开发人员的经验不足,将直接影响产品用户的体验。作为七牛云存储创始人,许式伟有着超过15年的编程经验,对于服务端开发那些事甚是了解。因此,在本文中,他将对服务端开发所涉及的各方面原理知识进行详细阐述,内容涵盖网络协议、操作系统原理、存储系统原理、模块设计、服务器设计等多方面。
以下为演讲整理
大家好,我今天的演讲题目是《服务端开发那些事儿》,主要涵盖的内容为网络协议、操作系统原理、存储系统原理、模块设计和服务器设计。这些是我觉得服务端开发人员比较直接相关的东西。第一个是网络协议,因为毕竟服务端是基于c/s模型,一上来涉及的就是协议。第二个是操作系统原理,因为服务端开发和客户端不太一样,服务端涉及到大量的锁、通讯相关的东西,所以操作系统原理对服务端程序员比对客户端程序员来说是要重要很多的。我最早的时候在做office软件,那时候基本上不涉及到太多多线程的东西,只是应用的逻辑很复杂,这是桌面端开发的特点。第三个东西,我觉得是存储系统原理。存储系统会有哪一些基本的道理,我觉得也是做服务端开发非常重视的。第四个是模块设计本身,这个是和服务端开发没有关系,是所有的开发人员应该掌握的一些基础的东西。再后一点是服务器开发本身设计的相关的要点。
网络协议
首先从网络协议开始。在七牛有一个特点,就是我们所有的服务端都是直接基于http协议的,很少有定义私有网络协议的行为。我们认为http协议的周边支撑是非常完善的,而且因为它是文本协议,所以大家去调试的时候非常容易理解。如果是私有二进制协议的话,还需要要专门为它写一个包的解析和查看的工具。而http有很多天然的好处在里面,所以我们会基于http协议。http协议最直接的就是get、post等,我就不详细讲了,更复杂一点就是这是带授权的。以下4张图将涵盖最基本的http协议。
操作系统原理
第二个层面我们谈谈操作系统原理。这块最核心的就是线程和进程之间的通讯,这个通讯包括互斥、同步、消息。大家经常会接触到互斥。只要有共享变量就一定会有锁。在go语言的服务器开发,很难避开锁。为什么呢?因为服务器本身是其实是有很多请求同时在响应的,服务器本身就是共享资源,既然是共享资源,那么必然是有锁的。
这里我话外要提一提的是erlang。erlang里面很多人会说它没有锁。我一直有个看法,不是因为erlang是函数式程序设计语言,它没有变量,所以没有锁。只要是服务器,有很多并发的请求,那么服务器就一定是共享资源,这个是物理事实,是不可改变的。为什么erlang可以没有锁,原因是因为erlang强制让所有的请求排队了。排队其实就是单线程化,那当然没有锁的,在c里面,在go里面都可以这么做,所以这并不奇怪。因此,本质上来讲,并不是因为它是函数式程序设计语言,而是因为它把请求串行化,也就是说不并发。那怎么并发呢?erlang里面想要并发,其实是用异步消息,也就是将消息发出去,让别人做,自己继续往下执行。这样就涉及到的异步编程,这些我今天不展开讲。但是我认为,本质上来讲,服务器编程其实互斥是难以避免的,因此,golang服务器runtim.gomaxprocs(1)将程序设为单线程后,仍然需要锁,单线程!=所有请求串行化处理。而锁主要存在以下几个问题。
1.锁最大的问题:不易控制
很多人会因为慢而避开锁,其实这样做是错误的。大部分框架想避开锁并不是因为锁慢,而是不易控制,主要表现为如果锁忘记了unlock,结果将是灾难性的,因为不止是该请求挂掉,而是整个服务器挂掉了,所有的请求都会被这个锁挡在外面。如果lock和unlock不匹配,将因为一个请求而导致所有人均受影响。
2.锁的次要问题:性能杀手
锁虽然会导致代码串行化执行,但锁并不是特别慢。因为线程之间的通讯,它有其他原语,如同步、收发消息,这些都是比锁慢很多的原语。网络上有部分人用golang的channel实现锁,这很不正确。因为channel就是线程之间发消息,成本比锁高很多。比锁快的东西,一是没有锁,二是原子操作。其中,原子操作并未比锁快很多,因为如果在冲突不多的情况下,一个锁基本上就是一个原子操作,发现没有冲突,直接继续执行。所以锁的成本并没有像大家想象的那么高,尤其是在服务端,因为服务端绝大部分应用的程序其实是io比较多,更多的时间是花在io上面的。
在锁的最佳实践里面,核心是控制锁的粒度。如果锁的粒度太大,例如把某一个io操作给包进去了,那这个锁就比较灾难了。比如这个io操作是操作数据库,那么这个锁把数据库的操作,请求和返回结果这样一个操作包进去了,那这个锁的粒度就很大,就会导致很多人都会被挡在外面。这个是锁粒度的问题。这也是锁里面比较难控制的一个点。
在锁的最佳实践里面,第一点是要懂得善用defer。在go里面有一点是比较好的,go语言里面有defer,容易让你避免锁的lock和unlock不匹配的问题,可以大大降低用锁的心智负担。但滥用defer可能会导致锁的粒度变得很大,因为你可能在函数的开始就lock,然后deferunlock,这样整个函数的执行全都被锁,函数里面只要有时间较长的io操作,服务器的性能就会下降。这是锁需要注意的地方。
另外,锁的最佳实践中,第二点是要善用读写锁。绝大部分服务器里面,尤其是一些请求量比较大的请求,大部分请求的读操作居多而写操作较少,这种情况下用读写锁是非常好的方法,可以大大降低锁的成本。另外一个降低锁粒度的方法是锁数组。锁数组是用于什么场景呢?如果服务器共享资源本身有很强的分区特征,那么用锁数组比较好。例如你要做一个网盘服务,不同用户之间的数据没有关系,网盘就是一个文件系统,它是树型结构,这个树型结构的操作往往需要较高的一致性的要求,不能出现操作到一半被另外一个操作给中断,导致文件系统的树结构被破坏。所以在网盘里面更有可能出现包含了io操作的大锁,这种情况下,如果某个用户的一次网盘同步操作会影响其他用户就会很难受。因此,在网盘服务的一个系统里,用锁数组会比较自然,你可以直接用用户的id除以锁数组的数组大小然后取模,数组的大小决定于服务的并发量有多大,选一个合适的值就好。这样可以让不同的用户相互不干扰,同一个用户只影响他自己。
我认为,掌握好与锁相关的技术,基本上是将服务器里面很可能最大的一个坑给解决了。线程间其他的通讯,比如说同步、消息相关的坑相对少。例如,go语言的channel实际上非常好用,既可以作为同步原语,也可以作为收发消息的原语。channel唯一一个需要注意的,channel是有缓冲区大小的,所以如果不设缓冲区的话,有一个goroutine发消息,另一个goroutine如果没有及时接收的话,发消息的那个goroutine就阻塞了。但是这个其实也很容易就能找到问题,所以这个问题不是很大。但是要注意,channel不是唯一的同步原语。go语言里面其实同步原语还是蛮多的。比如说group,这是一个很好用的同步原语,它是用来干吗的呢?它是让很多人一起干做某件事情,然后最后在某一个总控的地方等所有的人干完,然后继续往下走的一个原语。另外一个就是cond原语,cond其实用得不多,原因是channel把大部分cond适用的场景给满足了。但是作为操作系统原理中经常提的生产者消费者模型里面最重要的一个原语,了解它是很重要的。因为channel这样一个通讯设施,它背后其实是可以认为就是用cond实现的。而cond它要比channel原始很多,应用范畴也要广得多。我今天不展开讲cond了,大家要感兴趣,可以翻一翻操作系统原理相关的书。
存储系统原理
七牛就是做存储的。我觉得存储这个东西对服务端开发来说很重要。为什么呢?因为实际上服务器端开发的难度原理上比大家想象得要大,之所以今天大家不会觉得特别特别累,就是因为有存储中间件。存储是什么东西呢?存储其实是状态的维持者,存储它本身不是问题,但是有了服务器之后,它就是问题。因为大家在桌面端,大家知道存储的要求不高的,文件系统就是一个存储,那它放图片或者放什么,丢了就丢了,也没有多少操作系统关心它丢了会怎么样。但是在服务器端大家都知道,服务必须逻辑上是不宕机的。也就意味着状态维持的人是不能挂掉的。物理的服务器肯定是会挂掉的,但是哪怕物理服务器挂掉了,你的逻辑的服务或者说服务器本身不应该被挂掉的。因此,它的状态继续要维持,那谁维持呢?就是存储。如果这个世界上没有存储中间件的话,大家可以想象,写服务器是非常非常累的,你每做一件事情,做这件事情的每一步,都要想一想,中间需要把状态存下来,以便万一挂掉之后我该怎么办这样一个问题。
因此,存储中间件是大家最重要的生存基础。对于服务器程序员来讲,它是真正革命性的,它是让你能够今天这么轻松的写代码的基础。这也是我们需要理解存储系统为什么重要,它是大家赖以生存的最重要的一个外部条件。存储我蛮早的时候提过一个观点,存储就是数据结构。这个世界上存储中间件是写不完的,很多很多,消息队列这些是存储,文件系统、数据库、搜索引擎的倒排档等等,这些其实都是存储。为什么说存储就是数据结构呢?因为在桌面端开发的时候,大家都知道数据结构通常都是自己写的,或者说某个语言的标准库写的。但是在服务端里面,因为状态通常是持久化的,所以数据结构很难写。而存储其实就是一个中间件服务,是让你把状态维持这样一件事情,从业务里面剥离出来。可以想象,存储是非常多样化的,并且会和大家熟知的各种各样的数据结构对应起来(参考文档)。
靠谱的服务器是怎么构建的呢?很核心的一个原理,叫failfast,也就是速错。我认为,速错思想对于服务端开发来说非常非常重要。但是速错理念的基础是靠谱的存储。因为速错的意思是说,系统万一有问题,就挂掉了,挂要之后要重启重新做。但是重新做,你得知道它刚才在干什么,它的基础就是要有人维持状态,也就是存储。速错的思想最早是在硬件领域,后来erlang语言中首先提出将速错这样一个思想运用在软件开发里面,以构建高可靠的软件系统。这是一篇erlang作者的博士论文。这篇文章对于我的影响是非常大的,是我个人在服务端开发里面的启蒙的一个著作。大家知道软件是偏实践的科学,比较少有体系化的理念出现,这个是我见过的很棒的一个服务端开发或者分布式系统相关的理论,个人受益匪浅。
然而存储为什么难呢?是因为别人都可以failfast,但是存储系统不行。存储系统必须遵守顶层设计理念,其实是和failfast相反的,它需要达到的结果是,无论怎么错都应该有正确的结果。当然如果说存储系统完全和failfast相反倒也不至于,因为存储系统的内部实现细节本身,还是会用到很多速错相关的原理。但是存储系统对外表现出来的、所呈现的使用界面,和速错原理会有反过来的感觉。因为无论发生什么样的错误,包括软件、网络、磁盘、服务器断电、内存,甚至是idc故障等等,对于一个存储系统来讲,它都认为,这必须是能承受的,必须有合理的结果。当然这个能承受的范围,不同的存储系统是不一样的,代价也不一样。比如说memcache这样的存储系统,它就不考虑断电这样的问题。对于mysql这样的东西,如果说在最早的时候,它是不考虑宕机这样的故障的,后来引入了主从之后,你就可以想象,它就能够解决服务器挂掉、硬盘挂掉等问题。不同的存储系统,因为对可靠性要求不一样,它的实现难度也有非常大的差别(参考文档)。
那么现实中的存储,好吧,第一个我提了七牛云存储,我这是打广告了。第二像mongodb、mysql等这些都是存储。大家经常接触的也主要是这一些。
模块的设计
我一般讲模块设计的时候,都会先讲架构相关的一些东西。当然架构这个话题,要完整的讲,可以讲很长很长时间。因为架构的话题真的很复杂。如果只是用一两页描述架构的话,我会谈这么一些点。首先架构师必须重视的第一件事情是需求,因为架构的目的是为了满足需求,这一点千万不能搞错。谈到架构,很多人都会喜欢说,我设计了一个牛逼的框架。但是我长期以来在强调的一个观点是说,框架这种事情其实在架构哲学里面一点都不重要,框架其实是实践层面的事情,架构真正需要关心的其实是需求的正交分解,怎么样把需求分解得足够的正交。所谓的正交就是两个模块之间没有什么太复杂的关系。当然正交是数学里面的词,我不知道其他人有没有会把它用到这个领域。但是我觉得正交这个词很符合需求分解的这个概念。
随着大需求(比如说一个应用程序,或者一个服务器)逐渐被切成很多个小需求,�...