这篇文章给大家分享一下 我在设计、代码、优化方面的感悟。
标题借用了一下 “programming phoenix” 的封面 。
设计
很少有人马上写代码,都要先考虑清楚需求,逻辑,画个草图再动工。设计要搞到什么程度,要视情况而定。对于特别简单的小程序,没有设计的必要,而对于比较大型的项目,设计的好坏影响项目的扩展[1]和伸缩性[2]。
API First
首先商定和设计一个好的接口,给项目一个好的开端。
API 设计至关重要。一个设计合理、规范,使用方便的 API, 将提升整个项目的健壮性和可扩展能力,因为精心设计的 API 考虑到了各种可能的需求场景,考虑到了使用API的程序员和终端用户的便利程度,以及应用程序的安全,和将来扩展 API 的方法。
- API 构建工具
有很多设计工具可以可视化的方式构建、测试、分享你的API。
RESTful API工具方面,我用过的有 RAML 和 Swagger.
从使用体验上讲 raml 语法更加灵活,用起来更加舒服。swagger用 open api的语法,看起来差不多,但个人感觉不如raml, 使用swagger的过程中,频繁的google 它的语法。但swagger社区支持庞大,swagger ui用起来也很方便。我刚开始的时候用的是raml,后来也是因为社区支持问题,入坑 swagger。 - 安全
API 认证方面比较通用的是JWT, 方便又安全。 - RESTful API 规范
RESTful API 有一些设计约定,也有可以使用的规范, 如 JSON API. 是不是严格使用这些规范,比如HATEOAS, 要视情况而定。
Monitoring
一个要在设计阶段应该考虑的,但经常被遗忘东西是监控, 项目上线之后,需要各种各样的指标,用来监控项目的运转情况。这可能包括 logs, metrics, system monitor 等。面对一个没有监控的程序,我们就像睁眼瞎一样完全不知道其中发生了什么。我们需要各种代码过程的日志,各种事件发生的统计,各种系统状态的监控。
应该打什么日志,什么样的日志应该在什么级别,应该有那些metrics,都是需要在设计时考虑的。
系统分析工具:
日志分析方面有ELK, 特殊的, nginx日志分析方面推荐 goaccess.
系统监控方面我用过 glances, 不知道大家有没有更好的推荐?
系统工具方面 htop 一直是我的最爱。
Scalability
伸缩是设计之初就要考虑的。设计可伸缩的应用的一个基本的原则就是,不要做一个大而全的程序,最好分而治之.
这里有一个 节点(node) 的概念。每个 node 是一个可以独立开发和部署的单元,如一个认证服务,或者一个用户数据中心。node 之间使用某种协议交互,比如rpc。多个node一起,组成了一个集群 (cluster).
这样一个松散的架构带来较好的水平伸缩能力[3]. 我们可以根据不同服务的node的类型,来决定如何伸缩:
比如用户数据是I/O 密集型的服务,需要大量磁盘读写,图片处理是CPU密集型的服务,需要较多的计算资源,而高并发的即时通信可能需要大量的内存,用以维护长时间的TCP连接。面对业务增长带来的压力时,可以将用户数据服务部署到磁盘读写速度更快(比用SSD),容量更大的服务器上;增加图片服务的CPU数量和处理能力;增加通信服务器的数量以使用更多的内存。
每个单独的服务可以根据需要,随时启用和停用。比如发现邮件处理部分成为了瓶颈,可以简单的通过增加一个邮件服务来解决。
几个常见的可伸缩的系统构建方式:
-
Micro-services
“微服务” 的核心观点是,每个独立的功能用一个独立的小程序(服务)单独实现。微服务的架构方式的优点,我能想到的有:
-
每个服务代码量比较小,方便维护。
-
每个服务相对独立。方便针对某个单独的服务进行测试。
-
适应大型项目的人员组织架构,每个服务由某个人或者开发组维护。
-
分布式部署。因为相对独立,所以部署很灵活,各个服务可以部署在不同的服务器上,适应分布式部署环境。
-
可部分更新和部署。可以只更新某个服务,只要接口不变,不影响正在运行的其他服务。
-
容错能力强。单个服务的崩溃不会影响整个应用程序。
-
语言中立。每个服务可以用合适的编程语言来实现。这个视每个服务的需求和开发人员的技术偏好而定。
罗列这么多,其实说的是同一个事情,就是上面说的分清 "node" 概念的优点。个人感觉,相对其他架构来说,上面提到的最后一个语言中立,是微服务比较重要的优点,这一点对于大型的人员组织架构比较重要:
每种语言的应用场景不一样,每个程序员的技术方向也都不一样。我们可以使用高生产力的 Golang 做业务逻辑,使用简单快捷的 Python 做系统维护与监控,使用 Rust 或 C 来做性能比较敏感的文字处理,图片处理,使用高并发的 Erlang/Elixir 来做即时通信等。
微服务之间多数用REST API 或者 各种rpc框架, 如 thrift 协议 进行交互。交互消息格式多数用 JSON 或者二进制格式,比如 protobuf, msgpack
最后注意,微服务不是任何公司都合适。它比较适合大型项目和大的组织架构,小公司使用微服务反而会增加部署和维护的困难,但了解其理念还是有帮助的。
-
Peer to Peer
点对点应该是伸缩性最好的方式了,因为每个节点之间完全独立,没有状态/数据共享。没有状态共享意味着不需要考虑各个节点的状态一致性。尽量做到这种独立,你的应用程序就可以是 可无限伸缩的。比如文件下载服务,邮件服务等。
但实际场景来说,总会有场景需要共享状态,比如用户在多台服务器上的登录状态 (如sessionId),一个终端在不同通信服务器的路由信息(route table) 等。 -
Message Bus
使用消息中间件,给各个节点构建一个公共的消息通道。就像一个公路连接了各家各户、CBD、公司,所有车辆都走在这条公路上。公路就是那个Message Bus,车辆就是 Message Bus使用的内部协议,行人都被包在车辆里。用的比较多的消息中间件有 RabbitMQ, Kafka, MQTT 等。这几个看起来相似,但其设计理念和应用场景完全不同:
RabbitMQ适用于可靠的消息传递,比如消息之间的类似RPC的调用过程。而Kafka是专为高吞吐的事件、日志流处理而设计。具体可以看Erlang Solution上的解释。
MQTT是专门为IoT设备提供高并发通信、可靠QoS保证的协议,本身很少做消息通道使用,某些简单场景下也可行。EMQ 是一个很优秀的MQTT Broker
Message Bus 是非常方便的实现高伸缩能力的方式,它语言中立,接入方便。但也可能会额外增加编解码消息的开销。 -
RPC
如果是Elrang/Elixir 程序员,分布式,RPC都非常简单。但即使你不是,像java等本身不支持分布式的语言,也有很多第三方RPC框架可以用,虽然不够理想,因为语言本身没有分布式语义,会带来一些问题。
Choose right tools
技术选择是个耗费精力和体力的活儿。选择什么样的工具,直接影响了我们解决方案。总之就是那个锤子定理,手里拿的是锤子的话,眼里看到的所有东西都是钉子,都要用锤子敲的方式解决。吃面用筷子,喝汤用勺子,不是说用勺子吃不了面,只不过不大好用罢了。道理就是这样。所以你必须学习使用各种奇奇怪怪的工具,起码知道他们是做什么的。一个东西拿不准之前,上谷歌搜搜,比如 'pros cons of elm language','kafka vs. rabbitmq'.
技术选择很重要,却只能通过不停的学习来长经验。'学习' 的发音是 'google'
Prevent “over-engineering”
过度设计是应该极力避免的,一个设计应该根据其需求来定。设计只应该为业务需求服务,而不是一定要套用某些设计模式。从这个角度上来讲,设计应该越简单越好。想起前公司某些架构师设计的系统,使用各种设计模式,巨复杂,从头到尾都需要自己造轮子,维护相当困难。为系统添加一个普通tcp client端,需要一个月的工作量。当然这里面有某几个人垄断架构设计造成的原因。
记得看过某前辈书里的案例,说他们在项目的初期就引入了memcache 缓存机制,导致项目变得很复杂,后来测试过程中发现缓存命中率很低,最后又把缓存去掉了。
最初设计的时候,只需要保证程序的可扩展和可伸缩的能力,避免业务增长之后,应用不堪重负,需要改进却只能推倒重来。其他的过度设计都要避免。
代码
设计好了之后,编码过程就变得很轻松. 下面有几点写代码相关的建议。
TDD
测试驱动(TDD) 是说,写代码之前先根据文档,写好可以运行的测试用例,然后写代码,最后运行测试用例来测试代码,测试没能通过就修改代码,直至全部测试用例都通过了,我们就完成了本模块的开发。
测试驱动有几个好处:
- 编码过程中容易迷失方向,如果没有前期详细的 API 设计,很容易变成边写代码边考虑逻辑需求。如果采用TDD的方式,我们写代码的目的就是让所有用例都通过,从而一定程度上解决了这个问题。
- 代码重构困难。如果没有之前的测试用例,代码重构之后,我们不知道应用程序的基本功能是否正常,无法快速的解决引入的bug. 这些bug极有可能从测试人员手里漏过,在线上给我们造成不愉快的事情。而如果我们事先准备好了测试用例的话,改完了大批代码之后,我们只需要重新运行一遍测试用例即可。
然后TDD有个显而易见的缺点:在项目初期耗费大量时间编写测试用例。所以我觉着编写测试用例最好的时机是,你第一次重构代码之前。这个时候一般时间相对充裕。
Don't reinvent the wheel
不要造轮子,重要的是怎么开车 :-0。自己造的轮子,一般不会比别人的好用。使用开源的工具,或者在开源工具上面定制和修改,会极大提高开发效率,并从中学习到最新的技术。
Prevent "premature optimization"
编码阶段切忌过早优化。有些很容易考虑到的点,比如用异步的线程去做耗时的I/O操作等,可以顺手改掉之外,代码层面的优化都不应该在这个阶段完成。
一个编码信条:
Make it work, then make it beautiful. If it's really really necessary, then make it fast.
要相信现代编译器的优化能力,只要代码写漂亮了,那么多数情况下它就是高效的。在没有测试数据支撑的情况下,凭感觉改动某些代码试图使其高效,往往徒劳,更有可能的是可读性降低,性能比之前还糟。
性能优化需要测试支撑,但这一定不是编码阶段需要做的事情。
优化
当业务爆发带来新的性能需求的时候,首先要考虑的应该是伸缩,用更多的资源去处理更多的请求。如果做得好,我们就有了一个线性伸缩的系统。
当我们真的意识到某系统出了问题,需要在单node上解决性能瓶颈时,才需要考虑优化。有句话说,当工具没坏的时候,不要去修它。意思是你可能会把它修坏的
系统优化没有确定的方法论,但有些通用的经验。
Get the baseline
确定我们的基线: 当前我们的系统,在确定系统配置的情况下,能够支撑多大的并发访问?可支撑的最高吞吐量是多少?我们需要通过测试来获取确定的可量化数据,作为我们优化的起点。
Set goal and the deadline
设定我们要达到的目标: 在确定系统配置的情况下,应该至少支撑怎样的并发量或者吞吐量?当然不要太高,优化的目的是去除瓶颈,不是用单节点完成所有事情。
设定截止日期: 防止无休止优化,浪费人力和时间资源。在截止日期前,我们如果不能达到目的,放弃优化。
Find the bottleneck
找瓶颈: 目前是CPU使用过高,MEM不够,还是吞吐量太低?是哪个模块导致的?具体是那个模块的那个部分导致的,有什么证据?
找瓶颈的过程中同样需要大量的测试,并使用 profile工具来找到系统瓶颈。
每种编程语言都有自己的代码层面的 profile 工具,使用情况都不一样。Linux C 可以用 valgrind,gprof等。Erlang/Elixir 可以使用 etop, fprof等。fprof 太复杂,可以用 eep 代替。Golang 可以用 pprof.
Optimize and verify
根据profile的结果定位瓶颈所在,然后尝试修改代码,再次运行测试验收结果。不行的话退到上一步...
Give up
承认吧,调优失败了. 没错,多数情况都是这个结果 🤣
网友评论