可靠性、可扩展性、可维护性
本书为什么以数据系统为主题
数据系统是一种模糊的统称,在信息社会中,一切皆可信息化,或者,某种程度上来说–数字化。这些数据的采集、存储和使用,是构成信息社会的基础。
因此作为IT从业人员,有必要系统性地了解一下现代的、分布式地数据系统。学习本书,能够学习到数据系统地背后的原理、了解其常见的实践,进而将其应用到我们工作的系统设计中。
常见的数据系统有哪些?
-
数据库:存储数据,以便之后再次使用
-
缓存:记录一些非常重地操作结果,方便之后加快读取速度
-
搜索引擎:允许用户以各种关键字搜索、以各种条件过滤数据
-
流式处理:源源不断地产生数据,并发送给其他进程进行处理
-
批处理:定时处理累积地大量数据
-
消息队列:进行消息地传送与分发
数据系统的日益复杂化
这些年来,出现了许多新的数据存储工具与数据处理工具,它们针对不同的应用场景进行优化,因此不再适合生硬地归入传统类别。
-
Kafka:可以持久化存储一段时间日志数据,可以作为消息队列对数据进行分发,可以作为流式处理组件对数据反复蒸馏
-
Spark:可以对数据进行批处理,也可以化小批为流,对数据进行流式处理
-
Redis:可以作为缓存加速对数据库的访问,也可以作为事件中心对消息的发布订阅
我们面临一个新的场景,以某种组合使用这些组件的时候,在某种程度上,便是创建了一个新的数据系统。
下图是一个常见的对用户数据进行采集、存储、查询、旁路等操作的数据系统实例,从其示意图中可以看到各种web services的影子。
这样的系统在设计的时候可以有很多取舍:
-
采用哪种缓存策略,是旁路还是写穿透
-
部分组件机器出现问题时,是保证可用性还是保证一致性
-
当机器一时难以恢复,如何保证数据的正确性和完整性
-
当负载增加时,是增加机器还是提升单机性能
-
设计对外API时,是力求简洁还是追求强大。
-
当部分系统降级时,如何为用户提供始终如一的良好性能
因此,有必要从根本上思考下如何评价一个好数据系统,如何构建一个好的数据系统,有哪些可以遵循的设计模式,有哪些通常需要考虑的方面。
用三个词来回答就是:可靠性、可扩展性、可维护性
可靠性
如何衡量可靠性呢?
-
功能上:
-
正常情况下,应用行为满足API给出的行为
-
在用户误输入/误操作时,能够正常处理
-
-
性能上:能够在给定硬件和数据量下满足承诺的性能指标
-
安全上:能够阻止未授权、恶意破坏
如果所有这些在一起意味着“正确工作”,那么可以把可靠性粗略理解为“即使出现问题,也能继续正确工作”。
造成这种错误的原因叫故障,能够预料并应对故障的系统特性可称为“容错”或“韧性”。
注意:故障(fault) 不同于 失效(failure)
故障定义为系统的一部分状态偏离标准
失效是系统作为一个整体停止向用户提供服务。
故障的概率不可能降低到0,因此需要设计容错机制来防止因故障而导致失效。
在这类容错系统里,通过故意触发来提高故障率是有意义的,因为这种方法可以确保容错机制不断运行并接受考验,比如Netflix公司的(Home - Chaos Monkey)
硬件故障
在一个大型数据中心中,以下现象是常态:
-
网络抖动、不通
-
硬盘老化坏道
-
内存故障
-
机器过热导致CPU出问题
-
机房断电
这些都是硬件故障,磁盘的平均无故障时间约为10-15年,因此从数学期望上来讲,在拥有1w个磁盘的存储集群上,平均每天都会有一个磁盘出故障。
那么如何解决系统的故障率呢? 第一反应通常是增加单个硬件的冗余度,例如磁盘可以组件RAID,服务器可能有双路电源和热插拔CPU,数据中心可能有电池和柴油发电机作为后备电源,某个组件挂掉时冗余组件可以立刻接管。
软件错误
我们通常认为硬件故障是随机的、相互独立的,大量硬件组件之间同时发生故障是极其罕见的。另一类错误是内部的系统性错误,这类错误难以预料,而且是跨节点相关的可能会比硬件故障造成更多的系统失效。
以下列举了几个例子:
-
不能处理特定输入,导致系统崩溃。
-
失控进程(如循环未释放资源)耗尽CPU、内存、磁盘空间和网络资源
-
系统依赖的服务变慢,无响应或是开始返回错误的响应
-
级联故障
在设计软件时,我们通常有一些环境假设,和一些隐性约束。随着时间的推移、系统的持续运行,如果这些假设不能够继续被满足;如果这些约束被后面维护者增加功能时所破坏;都有可能让一开始正常运行的系统,突然崩溃。
人为错误
系统中最不稳定的是人,因此要在设计层面尽可能消除人对系统影响。根据软件的生命周期,分几个阶段来考虑:
-
设计编码
-
尽量消除所有不必要的假设,提供合理的抽象,仔细设计API
-
进程间进行隔离,对尤其容易出错的模块使用沙箱机制
-
对服务依赖进行熔断设计
-
-
测试阶段
-
尽可能引入第三方成员测试,将测试平台自动化
-
单元测试、集成测试、e2e测试、混沌测试
-
-
运行阶段
-
详细的仪表盘、看板
-
持续自检
-
报警机制
-
问题预案
-
-
针对组织
- 科学的培训和管理
可靠性有多重要
事关用户数据安全、企业声誉,企业存活和做大的基石
可扩展性
可扩展性表示描述系统应对负载增长能力。它很重要,但是在实践中又很难做好,因为存在一个基本矛盾 :只有能存活下来的产品才有资格谈扩展,而过早为扩展设计往往活不下去 。
但是可以了解一些基本的概念,来应对可能会暴增的负载。
衡量负载
应对负载前,先找到合适的方法来衡量负载,如负载参数
-
应用的日活、月活
-
每秒向web服务器发出的请求
-
数据库的读写比率
-
聊天室里同时活跃的用户数
-
缓存命中率
在上述情况里,也许平均情况更重要,也是少数的极端场景才是你的瓶颈
下面以推特在2012年11月发布的数据为例,推特的两个主要业务是:
-
发布推文用户可以向其粉丝发布新消息,平均4.6k请求/秒,峰值超过12k请求/秒
-
主页时间线用户可以查询他们关注的人发布的推文,即推荐feed流,300K请求/秒
处理每秒12000次写入(发推文的速率峰值)还是很简单的,但是推特的可扩展性挑战并不是来自推特量,而是来自扇出(fan-out)-- 每个用户关注了许多人,也被很多人关注
大体来说,这一对操作有两种实现方式:
-
发布推文时,只需要将新推文插入全局推文集合即可。当一个用户请求自己的推荐feed流主页时间线时,首先查找所有他关注的人,查询这些被关注用户发布的推荐并按时间顺序合并。如下图的关系型数据库里,可以写出来这样的查询:
SELECT tweets.*, users.*
FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
- 为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱。当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因为结果已经提前计算好了,所以读取主页时间线的请求开销很小。
简单来说就是推和拉两种方式:
-
拉:每个人查看其首页推荐feed流时,从数据库现拉取所有关注用户的推文,合并后呈现
-
推:为每个用户保存一个feed流视图,当用户发推文时,将其插入所有关注者的feed流视图中。
前者是Lazy的,用户只有查看时才会去拉取,不会有无效计算和请求,但每次都需要现算,呈现速度较慢,而且流量一大也扛不住。
后者事先计算出视图,而不管用户看不看,呈现速度较快,但是引入很低无效请求。
后者的效果更好,因为发推频率比查询主页时间线频率几乎低了两个数量级,所以在这种情况下,最好是在写入时做更多的工作,而在读取时做更少的工作。
后者的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约75个关注者,所以每秒4.6k的发推写入,变成了对主页时间线缓存每秒345K的写入。但是这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过3000万的粉丝,这意味着一条推文就可能导致主页时间线缓存的3000万次写入。
在这个例子里,每个用户粉丝数的分布是探讨可扩展性的一个关键负载参数,因为它决定了扇出负载。
最后推特使用了两种方法的混合,大多数用户发的推文会被扇出写入其粉丝主页时间线缓存中,但是少数拥有海量粉丝的用户(名流)会被排除在外。当用户读取主页时间线时,分别获取该用户关注的每位名流的推文,再与用户的主页时间线缓存合并。
描述性能
系统负载是从用户的视角来审视系统,是一种客观指标;系统性能是描述系统的一种实际能力。比如:
-
吞吐量:每秒可以处理的单位数据量,通常记为QPS
-
响应时间:从用户侧观察到的发出请求到收到回复的时间
-
延迟:日常中,延迟经常和响应时间混用指代响应时间,但严格来说,延迟只是把请求过程中排队等休眠时间,虽然其在响应时间中一般占大头;但是只有我们把请求真正处理耗时认为是瞬时,延迟才能等同于响应时间。
响应时间通常用百分位点来衡量,比如p95,p99,p999,它们意味着95%,99%,99.9%的请求都能在该阈值内完成。在实际中,通常使用滑动窗口滚动计算最近一段时间的响应时间分布,并通常以折线图或柱状图进行呈现。
响应时间的高百分位点(也称为 尾部延迟,即 tail latencies)非常重要,因为它们直接影响用户的服务体验。
应对负载的方法
在讨论了用于描述负载的参数和用于衡量性能的指标后,可以讨论可扩展性了:当负载参数增加时,如何保持良好的性能?
一般有下面两种方案:
-
纵向扩展,也称为垂直扩展:转向更强大的机器
-
横向扩展,也成为水平扩展:将负载分布到多台小机器
负载的两种扩展方式:
-
自动:负载不好预测而且变化较多,使用该方式较好。缺点是不容易跟踪负载,容易抖动,造成资源浪费。
-
手动:如果负载容易预测且不经常变化,最好手动。设计简单而且不容易出错。
针对不同应用场景来说的话:
-
如果规模很小,尽量先用性能好的机器,可以省去很多麻烦。
-
可以上云,利用云的可扩展性
-
自行设计可扩展的分布式架构
两种服务类型:
-
无状态服务:比较简单,多台机器,外层加一个gateway就行
-
有状态服务:根据需求场景,如读写负载、存储量级、数据复杂度、响应时间、访问模式来进行取舍,设计合乎需求的架构
没有万金油架构!
可维护性
软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债和添加新的功能。
但是我们可以,也应该以这样一种方式来设计软件:在设计之初就尽量考虑尽可能减少维护期间的痛苦,从而避免自己的软件系统变成遗留系统。为此,我们将特别关注软件系统的三个设计原则:
-
可操作性:便于运维团队保持系统平稳运行
-
简单性:从系统中消除尽可能多的复杂度,使新工程师也能轻松理解系统
-
可演化性:工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配
可操作性:人生苦短,关爱运维
一个优秀运维团队的典型职责如下:
- 监控系统的运行状况,并在服务状态不佳时快速恢复服务。
- 跟踪问题的原因,例如系统故障或性能下降。
- 及时更新软件和平台,比如安全补丁。
- 了解系统间的相互作用,以便在异常变更造成损失前进行规避。
- 预测未来的问题,并在问题出现之前加以解决(例如,容量规划)。
- 建立部署、配置、管理方面的良好实践,编写相应工具。
- 执行复杂的维护任务,例如将应用程序从一个平台迁移到另一个平台。
- 当配置变更时,维持系统的安全性。
- 定义工作流程,使运维操作可预测,并保持生产环境稳定。
- 铁打的营盘流水的兵,维持组织对系统的了解。
数据系统可以通过各种方式使日常任务更轻松:
- 通过良好的监控,提供对系统内部状态和运行时行为的 可见性(visibility)。
- 为自动化提供良好支持,将系统与标准化工具相集成。
- 避免依赖单台机器(在整个系统继续不间断运行的情况下允许机器停机维护)。
- 提供良好的文档和易于理解的操作模型(“如果做 X,会发生 Y”)。
- 提供良好的默认行为,但需要时也允许管理员自由覆盖默认值。
- 有条件时进行自我修复,但需要时也允许管理员手动控制系统状态。
- 行为可预测,最大限度减少意外。
简单性:管理复杂度
复杂度的表现:
-
状态空间的膨胀
-
组件间的强耦合
-
纠结的依赖关系
-
不一致的术语和命名
-
解决性能问题的hack
-
需要绕开的特例
过多地引入额外复杂度的原因一般是问题理解的不够本质,写出了流水账式的代码。如果你为一个问题找到了合适的抽象,那么问题就解决了一半,如:高级编程语言是一种抽象,隐藏了机器码、CPU 寄存器和系统调用。 SQL 也是一种抽象,隐藏了复杂的磁盘 / 内存数据结构、来自其他客户端的并发请求、崩溃后的不一致性。当然在用高级语言编程时,我们仍然用到了机器码;只不过没有 直接(directly) 使用罢了,正是因为编程语言的抽象,我们才不必去考虑这些实现细节。
如何找到合适的抽象?
- 从计算机领域常见的抽象中找。
- 从日常生活中常接触的概念找。
总之,一个合适的抽象,要么是符合直觉的;要么是和你的读者共享上下文的。
可演化性:降低改变门槛
系统的需求永远不变,基本是不可能的。更可能的情况是,它们处于常态的变化中,例如:
-
你了解了新的事实
-
出现意想不到的应用场景
-
业务优先级发生变化
-
用户要求新功能
-
新平台取代旧平台
-
法律或监管要求发生变化
-
系统增长迫使架构变化
如何应对?
-
组织流程方面:敏捷开发
-
系统设计:简单性与抽象性,合理抽象和封装,对修改关闭,对扩展开放