后端工程师都应该知道的最佳实践

0 前言

《On Designing and Deploying Internet-Scale Services》是一篇非常经典的论文,例举了设计和部署互联网规模的服务要注意的方方面面,其核心内容是自动化、轻依赖、可监控且信息准确、可应急

上一篇内容翻译了《On Designing and Deploying Internet-Scale Services》,本篇文章进行总结,对以下各个部分(忽略了硬件部分)的核心原则进行提炼,便于时常回顾自查。建议大家有时间都阅读一些原文。

  1. 整体设计

  2. 自动化管理和配置

  3. 依赖管理

  4. 发布周期和测试

  5. 运维和容量规划

  6. 审计、监控和报警

  7. 优雅降级和准入控制

 

1 整体设计

面向失败设计(design for failure)

  1. 大规模服务中机器故障是频繁发生的(大规模服务中,机器数足够多,那么每天都可能会发生磁盘、网络等故障)

  2. 常规的机器故障不能依赖于人工介入恢复

  3. 故障恢复过程应该足够简单,且经常被测试

  4. 可以考虑永远采用暴力关闭(比如kill -9)的方式停止服务,来检验故障恢复逻辑

冗余和故障恢复(redundancy and fault recovery)

  1. 任意服务器在任何时候出现故障不会影响服务的SLA

  2. 考虑每个组件,或者多个组件同时失败时系统如何运行(明确系统可以接受的情况,比如强依赖DB,DB故障后系统不可用是预期内可接受的)

  3. 当集群足够大的时候,多个组件同时故障的概率会大大提高,并且未来一定会发生

通用硬件(commodity hardware slice)

  1. 同等计算/存储能力下,由普通服务器组成的大集群要比由大型服务器组成的小集群更便宜

  2. 大集群中普通服务器的故障比小集群中大型服务器故障造成的影响要小

单一版本软件(single-version software)

  1. 保持单一版本的软件,可以使运维成本降低

  2. 为了保持单一版本,需要保证在版本演进时不造成用户体验的损失(尽量要保持兼容)

多租户(multi-tenancy)

  1. 让多个租户共享一个物理集群,而不是为每个租户部署一个物理集群,这样才能大大降低运维成本

快速的服务健康检查(quick service health check)

  1. 提供一套快速的测试机制,一旦这个测试通过就能保证基础的功能没有问题,代码可以执行Check In,这将大大提高开发效率(比如编写足够好的单元测试,保证每次check in代码时这些测试都能运行通过,且这些测试保证了代码基础功能的正确性)

在完整环境下进行开发(develope in the full environment)

  1. 尽量保证能提供一个完成的环境给开发人员进行测试,因为开发人员需要确认变更代码除了通过自己的测试外,还要不影响系统其它组件的功能(最好是能本机启动一个完成的环境,这样将大大提高测试效率)

零信任依赖组件(zero trust of underlying components)

  1. 考虑依赖的组件一定会挂掉,并考虑如何在它挂掉后提供服务,比如:

    1. 以只读模式提供服务(降级)

    2. 继续为未受影响的用户提供服务(隔离)

不重复实现(do not build the same functionality in multi components)

  1. 避免在多个组件中实现相同的功能(保证代码不重复、功能不重复)

一个节点或者集群不应该影响另一个节点或者集群(one pod or cluster should not affect another pod or cluster)

  1. 一个服务由若干个节点或者子集群组成,保证每个节点和集群的独立性(避免雪崩)

  2. 当依赖不可避免时,尽量将依赖放到集群内部

允许人工应急干预(allow rare emergency human intervention)

  1. 将系统设计的尽量自动化,避免人工介入

  2. 但是人工介入是不可避免的,将人工介入的步骤制作成脚本程序,且这些程序要经常进行测试

  3. 需要在生产环境进行演练,没有经过生产环境演练的程序在应急时是不可靠的

保持简单和健壮(keep things simple and robust)

  1. 复杂的算法和组件交互会将调试和部署变得困难

  2. 一个总体的原则是:超过一个数量级的优化才值得去做,只有几个百分点的提升和改进是不值得去做的

在所有层级进行准入控制(enforce admission control at all levels)

  1. 在服务入口设计准入控制,避免请求继续进入已经过载的系统

  2. 在所有重要组件的入口都提供准入的控制(比如核心的异步处理逻辑,控制队列的大小)

  3. 尽量提供优雅降级的能力,而不是直接无法提供服务

服务拆分(partition the service)

  1. 分区应该是无限可调的,不应该绑在任何物理世界的实体上,否则容易出现热点问题

理解网络(understand the network design)

  1. 在早期就去了解系统最终部署的物理架构,网络架构,跨数据中心的情况等

分析吞吐和延迟(analyze throughput and latency)

  1. 分析关键操作的吞吐和延迟,了解这些操作对系统的影响

  2. 针对每个服务需要跟踪这些指标来为扩容等操作提供依据

将工具作为系统的一部分(treat operations utilites as part of the service)

  1. 开发、测试、运维的工具都需要进行Code Review,跟代码一起维护

  2. 通常这些工具非常重要但是又没有被测试和维护

理解访问模式(Understand access patterns)

  1. 当规划新特性时,一定要考虑它给后端存储带来的负载。

  2. 通常服务模型和服务开发者所在的抽象层次太高了,以至于他们无法注意到负载给底层存储数据库带来怎样的影响。

  3. 一个最佳实践是SPEC(Standard Performance Evaluation Corporation,系统性能评估测试)加上一节:“这个feature对系统其它部分有什么影响?”然后feature上线时验证负载的情况是否符合。

对一切进行版本化(version everything)

  1. 目标是只运行维护一个版本,但是发布、灰度过程会运行多个版本

  2. 保证N和N-1版本是兼容的

保留上一次发布的所有测试用例(keep the unit/functional tests from the last release)

  1. 这些测试用来保证上一个版本的特性没有被破坏掉

  2. 持续性的在生产环境跑测试

避免单点失败(avoid single points of failure)

  1. 优先采用无状态的架构,保证水平扩展的能力

  2. 不要把请求和用户绑定到特定的服务器上,采用负载均衡分配到一组服务器上

  3. 数据库很难解决单点问题,要合理的拆分Partition

 

2 自动化管理和配置

在设计和部署之后进行服务的自动化是非常困难的。成功的自动化应当是简单和清晰的,易于做出运维判断的。这反过来又取决于服务的设计,必要的时候可以牺牲一些吞吐和延迟来达到自动化的目的。

  1. 所有的操作都需要支持重启,所有的持久化数据都需要有备份

  2. 支持跨分区部署(geo-distribution)

  3. 自动化的安装和配置

  4. 配置和代码作为整体提交,将配置和代码作为一个整体进行管理

  5. 意识到多系统故障是常态

  6. 始终坚持对非临时的状态数据进行备份

  7. 让部署过程保持简单

  8. 停掉数据中心,关闭机架,让服务器掉电。定期引入人为故障,不断暴露系统、网络、服务中的弱点

 

3 依赖管理

有如下情况的依赖,那么依赖管理是有意义的:

  1. 依赖的组件很大或者很复杂

  2. 依赖的服务的价值在于它是单一中心实例的

第一种情况的实例是存储和一致性算法的实现。第二种情况的实例是身份管理系统。这些系统的价值在于,他们是单一的共享实例,无法采用多实例避免这种依赖。

假设满足以上条件,管理他们的最佳实践如下:

  1. 考虑调用延迟:考虑外部调用的延迟,不要让一个服务或者组件的延迟导致其他地方的延迟

  2. 考虑失败隔离:架构需要能保持隔离,避免级联故障

  3. 使用可靠的组件:使用稳定的版本,稳定版本总是比尝鲜版本要好,哪怕新feature有多么的诱人

  4. 服务间的监控和告警:通过监控和告警,保证一个服务导致另一个服务过载的情况可以被发现

  5. 依赖双方有一致的设计目标:被依赖组件的SLA需要和依赖者保持一致

  6. 模块解耦:保证一个服务可以在依赖的服务故障时依旧提供服务,哪怕是降级了服务的能力(对于非强依赖的组件发生故障,要保证继续提供服务,比如以只读模式提供服务等)

 

4 发布周期和测试

推荐在新版本的服务经过标准的单元测试、功能测试和类生产环境的测试后,就进入到一个受限制的生产环境进行最后的测试。严格遵循以下规则:

  1. 生产环境要保证冗余,当新服务发生故障时能快速的恢复状态

  2. 绝对不能破坏数据或状态的一致性

  3. 错误必须能被检测到,同时工程师团队必须持续监控受测试代码系统状态

  4. 保证所有变更能被回滚,且回滚操作是经过验证的

一些发布和测试相关的最佳实践:

  1. 频繁发布:有点反直觉,但是频繁的发布可以避免大爆炸式的变更,建议发布周期最长不超过3个月,甚至做到按周发布

  2. 使用生产环境的数据发现问题:收集最原始的数据来反映系统的状态,减少误报,分析数据的趋势;让系统的健康状况清晰可见——最好在自己的系统上就包含系统的健康状况

  3. 在开发中投入更多精力:有些问题看似是运维问题,实际是开发设计的问题,提前做好开发和设计能减少运维问题;将更多的精力投入到设计和开发阶段,避免问题在运维阶段才被发现

  4. 支持回滚到特定版本:必须支持回滚到特定的版本来做应急

  5. 保持向前和向后兼容:保持兼容才能做回滚,否则将因为回滚后无法解析磁盘上的数据等问题导致故障

  6. 压力测试:以两倍的负载来对系统进行压测(根据实际情况,以高于预期的负载进行压测)

 

5 运维和容量规划

要高效运维服务,关键在于构建系统时消除各种运维交互过程。目标在于让一个高可靠的、7*24小时可用的服务只需要5*8小时工作的运维团队维护即可。

关于运维和容量相关的最佳实践:

  1. 任何运维脚本都需要经过测试,没有经过频繁测试的工具是无法使用的;不要开发任何团队成员没有勇气去使用的工具;

  2. you build it, you manage it:如果开发人员经常在半夜被叫醒,那么他们会去优化系统;如果运维人员经常半夜被叫醒,可能会扩容运维团队

  3. 只进行软删除:只对删除数据进行标记而不进行删除,避免因误操作导致的数据丢失

  4. 追踪资源分配情况:每个服务需要将在线用户数,用户每秒的请求数这些数据和机器负载及资源情况进行绑定跟踪

  5. 让一切可配置化:任何可能在系统中发生变更的东西都应该是在生产环境下可配置和调整的,而不需要改变代码;即使某个值看起来没有很好的在生产环境中发生变更的理由,如果很容易的话,也应该将它们做成可配置的

 

6 审计、监控和报警

审计、监控、告警是避免故障,以及保证故障及时被处理的重要手段,关于审计、监控、报警的最佳实践:

  1. 审计一切:有任何配置发生变更都需要记录下来:谁,在什么时候,改了什么;生产环境出了问题第一个要考虑的问题就是最近是否做了什么变更

  2. 报警评价标准:只在需要的时候报警,如果报警之后不需要做任何错误,那么这个报警就不是必要的;报警和故障比应该是1,没有产生报警的故障数应该是0

  3. 从用户的角度看问题:进行端到端的测试,保证重要和复杂的逻辑都被测试到

  4. 延迟是最难发现的问题:像是IO缓慢但是还没有不可用,这种情况是很难发现的,要做好监控

  5. 可配置的日志:可以动态调整日志级别以帮助快速定位问题

  6. 快速定位问题:系统执行重要的操作时要打印必要的日志记录上下文信息

 

7 优雅降级和准入控制

有些时候比如收到DOS攻击或者模式发生某些改变时,会导致负载突然爆发。服务需要能够进行优雅的降级及准入控制。两个最佳实践:“Big Red Switch”和准入控制,需要针对每个服务进行量身定制。但是这两个都是非常强大和必要的。

  1. Big Red Switch:支持应急开关。大体来说“Big Red Switch”是一种当服务不再满足SLA或者迫在眉睫时,可以采取的经过设计和测试的动作。将优雅的降级比喻为“Big Red Switch”,稍微有些不太恰当,但核心的意思是指那种可以在紧急情况下摒弃那些非关键负载的能力。

  2. 准入控制:当系统已经过载时,需要适当地在最前端拒绝掉部分请求,以免系统进入一种完全无法恢复的状态

  3. 渐进式准入控制:当系统慢慢恢复时,需要可以逐渐让请求进入,而不是一次全部放开,避免恢复后一时间大量的请求涌入导致系统再次故障