当前位置: 首页 > 科技观察

七牛CEO许式伟:服务端开发那些事儿

时间:2023-03-17 00:48:18 科技观察

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