服务端开发对于任何互联网公司来讲,都并非易事,它所涉及的技术知识面非常广泛,如果开发人员的经验不足,将直接影响产品用户的体验。作为七牛云存储创始人,许式伟有着超过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,然后defer Unlock,这样整个函数的执行全都被锁,函数里面只要有时间较长的IO操作,服务器的性能就会下降。这是锁需要注意的地方。
另外,锁的最佳实践中,第二点是要善用读写锁。绝大部分服务器里面,尤其是一些请求量比较大的请求,大部分请求的读操作居多而写操作较少,这种情况下用读写锁是非常好的方法,可以大大降低锁的成本。另外一个降低锁粒度的方法是锁数组。锁数组是用于什么场景呢?如果服务器共享资源本身有很强的分区特征,那么用锁数组比较好。例如你要做一个网盘服务,不同用户之间的数据没有关系,网盘就是一个文件系统,它是树型结构,这个树型结构的操作往往需要较高的一致性的要求,不能出现操作到一半被另外一个操作给中断,导致文件系统的树结构被破坏。所以在网盘里面更有可能出现包含了IO操作的大锁,这种情况下,如果某个用户的一次网盘同步操作会影响其他用户就会很难受。因此,在网盘服务的一个系统里,用锁数组会比较自然,你可以直接用用户的ID除以锁数组的数组大小然后取模,数组的大小决定于服务的并发量有多大,选一个合适的值就好。这样可以让不同的用户相互不干扰,同一个用户只影响他自己。
我认为,掌握好与锁相关的技术,基本上是将服务器里面很可能最大的一个坑给解决了。线程间其他的通讯,比如说同步、消息相关的坑相对少。例如,Go语言的channel实际上非常好用,既可以作为同步原语,也可以作为收发消息的原语。channel唯一一个需要注意的,channel是有缓冲区大小的,所以如果不设缓冲区的话,有一个goroutine发消息,另一个goroutine如果没有及时接收的话,发消息的那个goroutine就阻塞了。但是这个其实也很容易就能找到问题,所以这个问题不是很大。但是要注意,channel不是唯一的同步原语。Go语言里面其实同步原语还是蛮多的。比如说Group,这是一个很好用的同步原语,它是用来干吗的呢?它是让很多人一起干做某件事情,然后最后在某一个总控的地方等所有的人干完,然后继续往下走的一个原语。另外一个就是Cond原语,Cond其实用得不多,原因是channel把大部分Cond适用的场景给满足了。但是作为操作系统原理中经常提的生产者消费者模型里面最重要的一个原语,了解它是很重要的。因为channel这样一个通讯设施,它背后其实是可以认为就是用Cond实现的。而Cond它要比channel原始很多,应用范畴也要广得多。我今天不展开讲Cond了,大家要感兴趣,可以翻一翻操作系统原理相关的书。
存储系统原理
七牛就是做存储的。我觉得存储这个东西对服务端开发来说很重要。为什么呢?因为实际上服务器端开发的难度原理上比大家想象得要大,之所以今天大家不会觉得特别特别累,就是因为有存储中间件。存储是什么东西呢?存储其实是状态的维持者,存储它本身不是问题,但是有了服务器之后,它就是问题。因为大家在桌面端,大家知道存储的要求不高的,文件系统就是一个存储,那它放图片或者放什么,丢了就丢了,也没有多少操作系统关心它丢了会怎么样。但是在服务器端大家都知道,服务必须逻辑上是不宕机的。也就意味着状态维持的人是不能挂掉的。物理的服务器肯定是会挂掉的,但是哪怕物理服务器挂掉了,你的逻辑的服务或者说服务器本身不应该被挂掉的。因此,它的状态继续要维持,那谁维持呢?就是存储。如果这个世界上没有存储中间件的话,大家可以想象,写服务器是非常非常累的,你每做一件事情,做这件事情的每一步,都要想一想,中间需要把状态存下来,以便万一挂掉之后我该怎么办这样一个问题。
因此,存储中间件是大家最重要的生存基础。对于服务器程序员来讲,它是真正革命性的,它是让你能够今天这么轻松的写代码的基础。这也是我们需要理解存储系统为什么重要,它是大家赖以生存的最重要的一个外部条件。存储我蛮早的时候提过一个观点,存储就是数据结构。这个世界上存储中间件是写不完的,很多很多,消息队列这些是存储,文件系统、数据库、搜索引擎的倒排档等等,这些其实都是存储。为什么说存储就是数据结构呢?因为在桌面端开发的时候,大家都知道数据结构通常都是自己写的,或者说某个语言的标准库写的。但是在服务端里面,因为状态通常是持久化的,所以数据结构很难写。而存储其实就是一个中间件服务,是让你把状态维持这样一件事情,从业务里面剥离出来。可以想象,存储是非常多样化的,并且会和大家熟知的各种各样的数据结构对应起来(参考文档)。
靠谱的服务器是怎么构建的呢?很核心的一个原理,叫Fail Fast,也就是速错。我认为,速错思想对于服务端开发来说非常非常重要。但是速错理念的基础是靠谱的存储。因为速错的意思是说,系统万一有问题,就挂掉了,挂要之后要重启重新做。但是重新做,你得知道它刚才在干什么,它的基础就是要有人维持状态,也就是存储。速错的思想最早是在硬件领域,后来Erlang语言中首先提出将速错这样一个思想运用在软件开发里面,以构建高可靠的软件系统。这是一篇Erlang作者的博士论文。这篇文章对于我的影响是非常大的,是我个人在服务端开发里面的启蒙的一个著作。大家知道软件是偏实践的科学,比较少有体系化的理念出现,这个是我见过的很棒的一个服务端开发或者分布式系统相关的理论,个人受益匪浅。
然而存储为什么难呢?是因为别人都可以Fail Fast,但是存储系统不行。存储系统必须遵守顶层设计理念,其实是和Fail Fast相反的,它需要达到的结果是,无论怎么错都应该有正确的结果。当然如果说存储系统完全和Fail Fast相反倒也不至于,因为存储系统的内部实现细节本身,还是会用到很多速错相关的原理。但是存储系统对外表现出来的、所呈现的使用界面,和速错原理会有反过来的感觉。因为无论发生什么样的错误,包括软件、网络、磁盘、服务器断电、内存,甚至是IDC故障等等,对于一个存储系统来讲,它都认为,这必须是能承受的,必须有合理的结果。当然这个能承受的范围,不同的存储系统是不一样的,代价也不一样。比如说MemCache这样的存储系统,它就不考虑断电这样的问题。对于MySQL这样的东西,如果说在最早的时候,它是不考虑宕机这样的故障的,后来引入了主从之后,你就可以想象,它就能够解决服务器挂掉、硬盘挂掉等问题。不同的存储系统,因为对可靠性要求不一样,它的实现难度也有非常大的差别(参考文档)。
那么现实中的存储,好吧,第一个我提了七牛云存储,我这是打广告了。第二像MongoDB、MySQL等这些都是存储。大家经常接触的也主要是这一些。
模块的设计
我一般讲模块设计的时候,都会先讲架构相关的一些东西。当然架构这个话题,要完整的讲,可以讲很长很长时间。因为架构的话题真的很复杂。如果只是用一两页描述架构的话,我会谈这么一些点。首先架构师必须重视的第一件事情是需求,因为架构的目的是为了满足需求,这一点千万不能搞错。谈到架构,很多人都会喜欢说,我设计了一个牛逼的框架。但是我长期以来在强调的一个观点是说,框架这种事情其实在架构哲学里面一点都不重要,框架其实是实践层面的事情,架构真正需要关心的其实是需求的正交分解,怎么样把需求分解得足够的正交。所谓的正交就是两个模块之间没有什么太复杂的关系。当然正交是数学里面的词,我不知道其他人有没有会把它用到这个领域。但是我觉得正交这个词很符合需求分解的这个概念。
随着大需求(比如说一个应用程序,或者一个服务器)逐渐被切成很多个小需求,小的需求继续分解变成一个个类和函数。这一层层的需求分解的单元,本质上来讲,都是同样的东西,都是模块,只是粒度问题。所有这些app、service、package、class、func等,统一都可以称之为模块。那所有的模块,第一重要的是什么呢?是它的规格。模块里面最核心的,任何一个模块的规格是要体现需求。为什么我会说我是反框架的,因为框架其实就是模块的连接方式,不同的模块如何连接这个框架。那这种连接方式通常是易变的、不稳定的,因为框架是需要演进的。随着需求的增加、修改,它会不断演进,肯定后面会发现,之前搭的框架不太好了,需要重构。框架需要变的时候,通常很痛苦,所以也是很多人为什么重视框架的原因。但是不应该因为这一点儿把框架看得太重。因为不稳定的东西,通常是最不重要的东西。你要抓住的是稳定的东西。因此,框架只是实践程度可依赖的东西,但是从架构来讲不要太强调。
模块,刚才我讲了,模块其实最重要的是规格,也就是使用界面,或者叫interface(接口)。对于一个应用程序来说,interface就是用户交互。对于一个service来说,interface就是api。对于一个package来说,就是包的导出的函数或者类。对于一个class来说,就是公开的方法。对于函数来说就是函数的原型。这些都是interface。模块的interface必须体现需求,否则这个interface就不是一个好interface。
总结一下,如果要提炼模块的最佳实践的话,我会提炼这样三点。
第一,模块的需求一定要是单一职责。就是这个模块不能做太多的事情,如果有太多的事情,它就要进一步的分解。
第二,模块的使用界面要体现需求。大家一看这个模块的界面,就知道这个模块是干什么的。比如一个软件,你下载下来玩的时候,一看就应该知道这个软件目的是什么,而不是看了好几眼都分不清楚这个软件到底是财务软件还是什么软件,那这个interface就太糟糕了。所以其实所有的interface都是一样的,都要体现需求。
第三是模块的可测试性。任何一个模块,如果提炼得好的话,它应该很容易测试。为什么这一点很重要呢?因为测试在软件系统里面其实非常重要,尤其是在服务端开发,尤其是像七牛这样一个做基础服务的,一个bug或者一个故障会导致成千上万甚至上百万的公司受影响,那么这个测试非常非常重要。可测试性包括什么呢?它包括把模块跑起来的最小的环境。如果一个模块耦合小,意味着外部环境依赖少,这个模块就很容易测试。反过来,很容易测试意味着这个模块的耦合很低。因此,模块的可测试性,其实能够反向来推导这个模块设计得好与不好。
展开来讲,第一,模块的使用界面,它应该符合需求,而不应该符合某种框架的需要。这一点,我为什么强调呢?而且是反复强调呢?是因为我认为很多刚刚踏入这个行业的人会违背这一点,包括我自己。最早做office软件的时候,我很清楚自己犯了无数次这样的错误,所以我后来把这一条作为非常重要的告诫自己的点。因为不体现需求的话,意味着这个模块的使用界面是不稳定的。最自然体现需求的使用界面是最稳定的。第二,我认为模块应该是可完成的。也就是说它的需求是稳定的可预期的,或者是说模块的目标是单一的,只做一件事情。只有这样才能做到模块可完成。但是反例很多很多。比如C++里面有Boost、MFC、QT这些库。其实你知道,它们都是大而统,包含很多的东西,你不知道这个库是干嘛的。这种我个人是非常反对。我早期也是这样的,早期自己写了一些通用库,都是很含糊,想到一个很好的东西,就把它扔到通用库里面,最后这个通用库就变成垃圾筒,什么东西都有。任何一个模块,都有一个你对它的边界的界定,边界界定好之后,这个模块总归有一天,它逐步趋于稳定,最终几乎不必去修改(就算修改也只是实现上的优化)。
刚才我也讲了模块应该是可测试的。可测试性可以表征一个模块的耦合度。耦合越低越容易测试。所谓的耦合就是环境依赖,我依赖外部的东西越少越容易测试。一个模块要测试的话,必须要模拟整个环境,让它跑起来。
服务器的设计
服务器的设计首先要遵循模块的设计,其次是服务器有服务器特有的一些东西。第一是服务器的测试。七牛对于测试非常看重,参加过上次Gopher China大会的都知道我讲的内容,就是HTTP服务器如何测试。七牛为此自己发明了一个DSL语言,就是领域专用语言,专门用于测试。现在这个DSL在我们团队用得非常广泛,基本上所有新增的模块都会用这个方法进行单元测试。第二个是服务器的可维护性。我没有讲服务器本身应该怎么设计,因为这个其实跟领域是有关系的,也就是你做什么事情,本身是很具化的,我没办法告诉你应该怎么样设计。服务器的设计,无非遵循我刚刚讲的模块设计的一些准则,但是服务器有它自己的特征,因为它作为一个互联网,或者作为一个C/S结构的东西,它有一些通用的需求。刚才我们讲模块需要做需求的正交分解,那作为一个Web服务器的话,除了业务相关的东西,会不会有一些通用的需求?其实通用的需求是非常多的,我这里列了很多,但是肯定不完整,当然列这一些,已经有更多细节的话题可以展开来讲。
第一个比如路由,这个就不用说了。大家看到大部分的Web框架都会解决路由相关的问题。第二个是协议,通常大家看到比较多的协议,如果用HTTP的话,会比较多的见到form、json或者xml。第三个是授权,授权我就不展开了。会话,其实跟授权类似。第四个是问题的跟踪和定位。这个我等一下再讲。第五个是审计。审计有两个用处,一个是计费,像七牛这样的服务需要审计,因为每一次API请求,会有计费相关的东西;另一个做对账,你说有我说没有,那最终是有还是没有,看服务器的日志来说了算。第六个是性能的调优,然后是测试和监控。为什么会有这么多的需求呢?原因是因为服务器开发,我觉得大家可能关注了开发两个字,但是可能忘了服务器开发完了是干什么的。服务器开发完了,它后面还要在线上跑很长时间,而且绝大部分的时间是在线上。所以服务器的开发其实和在线上运维的过程是不能脱节的。正因为不能脱节,所以我们才会关注像监控、性能调优、问题跟踪定位、审计相关的一些需求。
这一些其实都是和具体的业务无关的,所有的服务器都需要。那么其实这些需求,可以被分解出来,由一些基础组件去实现。当然因为这些东西,很多都是和七牛内部的组件相关,所以我没有展开。
1. 服务器的测试
我大概的讲一下服务器的测试方法,在七牛这边怎么用的,还有服务器可维护性相关的东西。第一个是七牛用的两个东西,一个是叫mockhttp,当然这个不一定要用,因为我知道Go其实有标准的httptest模块,它能够监听随机端口的服务。七牛也用这种方式起测试服务器,但是我自己个人更喜欢用mockhttp。因为它不监听物理的端口,所以它没有端口冲突问题,心智相对负担比较低,而且相比那种监听真正物理端口的程序跑得会更快一些。这个mockhttp已经开源了,在github.com/qiniu上可以找到。
第二个是基于七牛的httptest,暂时还没有开源。今天我没有办法完整地讲,因为我之前有个完整的讲座,在网上可以搜索得到。它最核心的思想是什么呢?你不用写client端sdk,直接就可以基于http协议写测试案例。如果没有这样的工具,写一个服务器的测试,显然第一件事情就写一个sdk,把你的服务器的interface,也就是网络api包装一下,包装成一个类,里面有很多函数。然后通过这个类去测你的服务。这种模式有什么不好的地方呢?最大的问题是这个sdk,其实很多时候是不稳定的。不稳定会带来一个问题,就是这个sdk你改改改,有可能改sdk的人忘了服务器的测试案例在用它,改了后会导致编不过,从而导致测试案例失败。当然也有另外一种做法,就是我为服务器的测试专门写一个sdk,但是我觉得这个成本是比较高昂的,因为你相当于只为某一个具体的场景专门做一个事情,而这个事情,可能工作量不一定非常巨大,但是很繁杂。因此,七牛的httptest最本质的点是,可以直接写一个看起来像直接发网络包的方式去做测试。然后尽可能把网络协议的文本描述让它看起来更人性化一些,让人一看就知道发过去的是什么。这样也可以认为是写了一个sdk,但是这个sdk非常通用,所有的HTTP服务器都能去用它。这样所有的HTTP服务测试,包括单元测试和集成测试,都可以用这种方式测。
2. 服务器的可维护性
我刚才在讲服务器的需求时提过这一点,也是我觉得非常非常需要去强调的一点,就是服务器的可维护性。这一点是极其极其重要的,因为服务器的开发和运维并不能分割的,服务器本身的设计需要为运维做好准备。这个系统跑到线上会发生很多问题,发生这些问题之后,如何快速地解决,需要在开发阶段就去思考。正因为如此,所以才会有很多出于可维护性上的一些基础的需求,包括日志。日志其实是最基础的。没有日志怎么排除这种故障呢?但是对于经常要发生的情况,服务器设计本身就需要避免,最最基本的不能有单点,因为有单点,一个服务器挂掉了,线上就完蛋了,运维就要立刻跟上。但是这种事情必然会发生。对于必然会发生的事情,必然是需要在开发阶段就去避免。所以某种意义上来说,高可用是为了可维护性,如果不是为了可维护性,就不用考虑高可用的问题。
服务器的可维护性,我觉得大概分为这样几个类别的需求。第一个是性能瓶颈。性能瓶颈,比如说你发现业务支撑的并发量不够,经常需要加机器,这时候要发现慢到底慢在哪里。也许你认为网站刚刚上线的时候,不用考虑这个问题,但是如果这个网站能做大的话,总有一天会碰到瓶颈问题。因此,最早的时候,就要为瓶颈问题考虑好,如果万一发生瓶颈,如何能尽快发现瓶颈在哪里。此外,我认为非常非常关键的是异常情况的预警。很多时候如果存在瓶颈,那么等它发生的时候就已经是灾难了。最好的情况下,在达到灾难的临界点之前,最好有个预警线,在那个预警线上开放排除问题就比较好一点。
第二个是故障发现和处理。当线上真的发现故障了,虽然我们极力去避免,但是肯定避免不了了,一定会发生故障,没有一个公司不会发生故障。发生故障的时候,如何去快速地响应,这个就是快速地定位故障源。这个其实也是服务器开发里面,我觉得需要深度去考虑的一个问题。对于经常发生的故障,必须要实现自我恢复。也就是我刚刚第一个讲的。一旦发生这个事情,不是偶然,是经常的。那么你必须要在开发阶段解决,而不是到线上运维阶段解决这个问题。
第三个是用户问题排查,一个用户,提供了一个非常个例化的问题,不是服务总体的一个问题,可能就是一个客服的个例问题,那么就需要有所谓的reqid。每一个用户请求都有一个唯一的reqid,一旦是个例的问题需要跟进,客户要告诉我你的reqid是多少,输入这个ID,就能把所有和该请求相关的东西都能找到。整个服务端的请求链都能找到之后,这个问题的排查就更容易。