第一部分 数据系统的基石
第一章:可靠性,可扩展性,可维护性
现今很多应用程序都是 数据密集型(data-intensive) 的,而非计算密集型(compute-intensive)
的。因此CPU很少成为这类应用的瓶颈,更大的问题通常来自数据量、数据复杂性、以及数据的变更速度。
本书着重讨论三个在⼤大多数软件系统中都很重要的问题:
可靠性(Reliability)
系统在困境(adversity)(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)。
可扩展性(Scalability)
有合理的办法应对系统的增长(数据量、流量、复杂性)(参阅“可扩展性”)
可维护性(Maintainability)
许多不同的人(工程师、运维)在不同的生命周期,都能在高效地在系统上工作(使系统保持现有行为,并适应新的应用场景)。(参阅”可维护性“)
可靠性
人们对于一个东西是否可靠,都有一个直观的想法。人们对可靠软件的典型期望包括:
- 应用程序表现出用户所期望的功能。
- 允许用户犯错,允许用户以出乎意料的方式使用软件。
- 在预期的负载和数据量下,性能满足要求。
- 系统能防止未经授权的访问和滥用。
硬件故障
软件错误
人为错误
可扩展性
可扩展性(Scalability)是用来描述系统应对负载增长能力的术语。
描述负载
在讨论增长问题(如果负载加倍会发生什么?)前,首先要能简要描述系统的当前负载。负载可以用一些称为负载参数(load parameters)的数字来描述。参数的最佳选择取决于系统架构,它可能是每秒向Web服务器发出的请求、数据库中的读写比率、聊天室中同时活跃的用户数量、缓存命中率或其他东西。除此之外,也许平均情况对你很重要,也许你的瓶颈是少数极端场景。
为了使这个概念更加具体,我们以推特在2012年11月发布的数据【16】为例。推特的两个主要业务是:
发布推文
用户可以向其粉丝发布新消息(平均 4.6k请求/秒,峰值超过 12k请求/秒)
主页时间线
用户可以查阅他们关注的人发布的推文(300k请求/秒)。
处理每秒12,000次写入(发推文的速率峰值)还是很简单的。然而推特的扩展性挑战并不是主要来自推特量,而是来自扇出(fan-out)–每个用户关注了很多人,也被很多人关注。
大体上讲,这一对操作有两种实现方式。
1.发布推文时,只需将新推文插入全局推文集合即可。当一个用户请求自己的主页时间线时,首先查找他关注的所有人,查询这些被关注用户发布的推文并按时间顺序合并。在如图1-2所示的关系型数据库中,可以编写这样的查询:
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

2.为每个用户的主页时间线维护一个缓存,就像每个用户的推文收件箱(图1-3)。
当一个用户发布推文时,查找所有关注该用户的人,并将新的推文插入到每个主页时间线缓存中。因此读取主页时间线的请求开销很小,因为结果已经提前计算好了。

然而方法2的缺点是,发推现在需要大量的额外工作。平均来说,一条推文会发往约75个关注者,所以每秒4.6k的发推写入,变成了对主页时间线缓存每秒345k的写入。但这个平均值隐藏了用户粉丝数差异巨大这一现实,一些用户有超过3000万的粉丝,这意味着一条推文就可能会导致主页时间线缓存的3000万次写入!及时完成这种操作是一个巨大的挑战 – 推特尝试在5秒内向粉丝发送推文。
描述性能
一旦系统的负载被描述好,就可以研究当负载增加会发生什么。我们可以从两种角度来看
-
增加负载参数并保持系统资源(CPU、内存、网络带宽等)不变时,系统性能将受到什么影响?
-
增加负载参数并希望保持性能不变时,需要增加多少系统资源?
这两个问题都需要性能数据,所以让我们简单地看一下如何描述系统性能。
对于Hadoop这样的批处理系统,通常关心的是吞吐量(throughput),即每秒可以处理的记录数
量,或者在特定规模数据集上运行作业的总时间2。对于在线系统,通常更重要的是服务的响应时间(response time),即客户端发送请求到接收响应之间的时间。
延迟和响应时间
延迟(latency)和响应时间(response time)经常用作同义词,但实际上它们并不一样。响应
时间是客户所看到的,除了实际处理请求的时间(服务时间(servicetime))之外,还包括网
络延迟和排队延迟。延迟是某个请求等待处理的持续时长,在此期间它处于休眠(latent)状
态,并等待服务【17】。
即使不断重复发送同样的请求,每次得到的响应时间也都会略有不同。现实世界的系统会处理各式各样的请求,响应时间可能会有很大差异。因此我们需要将响应时间视为一个可以测量的数值分布(distribution),而不是单个数值。
在图1-4中,每个灰条表代表一次对服务的请求,其高度表示请求花费了多长时间。大多数请求是相当快的,但偶尔会出现需要更长的时间的异常值。这也许是因为缓慢的请求实质上开销更大,例如它们可能会处理更多的数据。但即使(你认为)所有请求都花费相同时间的情况下,随机的附加延迟也会导结果变化,例如:上下文切换到后台进程,网络数据包丢失与TCP重传,垃圾收集暂停,强制从磁盘读取的页面错误,服务器机架中的震动【18】,还有很多其他原因。

通常使用百分位点(percentiles)会更好。如果将响应时间列表按最快到最慢排序,那么中位数
(median)就在正中间:举个例子,如果你的响应时间中位数是200毫秒,这意味着一半请求的返回时间少于200毫秒,另一半比这个要长。

应对负载的方法
人们经常讨论纵向扩展(scalingup)(垂直扩展(verticalscaling),转向更强大的机器)和横向
扩展(scalingout)(水平扩展(horizontalscaling),将负载分布到多台小机器上)之间的对立。
跨多台机器分配负载也称为“无共享(shared-nothing)"架构。可以在单台机器上运行的系统通常更简单,但高端机器可能非常贵,所以非常密集的负载通常无法避免地需要横向扩展。现实世界中的优秀架构需要将这两种方法务实地结合,因为使用几台足够强大的机器可能比使用大量的小型虚拟机更简单也更便宜。
大规模的系统架构通常是应用特定的-- 没有一招鲜吃遍天的通用可扩展架构(不正式的叫法:万金油(magic scalingsauce))。应用的问题可能是读取量、写入量、要存储的数据量、数据的复杂度响应时间要求、访问模式或者所有问题的大杂烩。
举个例子,用于处理每秒十万个请求(每个大小为1kB)的系统与用于处理每分钟3个请求(每个大小为2GB)的系统看上去会非常不一样,尽管两个系统有同样的数据吞量。
可维护性
众所周知,软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、调查失效、适配新的平台、为新的场景进行修改、偿还技术债、添加新的功能等等。
-
可操作性(Operability)
便于运维团队保持系统平稳运行。 -
简单性(Simplicity)
从系统中消除尽可能多的复杂度(complexity),使新工程师也能轻松理解系统。(注意这和用户接口的简单性不一样。) -
可演化性(evolability)
使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配。也称为可扩展性(extensibility),可修改性(modifiability)或可塑性(plasticity)。
2. 数据模型与查询语言
关系模型与文档模型
现在最著名的数据模型可能是SQL。它基于Edgar Codd在1970年提出的关系模型【1】:数据被组织成关系(SOL中称作表),其中每个关系是元组(SOL中称作行)的无集合。
关系数据库起源于商业数据处理,在20世纪60年代和70年代用大型计算机来执行。从今天的角度来看,那些用例显得很平常:典型的事务处理(将销售或银行交易,航空公司预订,库存管理信息记录在库)和批处理(客户发票,工资单,报告)。
NOSOL的诞生
2009年一个关于分布式,非关系数据库上的开源聚会上。无论如何,这个术语触动了某些神经,并迅速在网络创业社区内外传播开来。好些有趣的数据库系统现在都与#NOSOL#标签相关联,并且NOSOL被追溯性地重新解释为不仅是SQL(Not OnlySQL)【4】
采用NOSQL数据库的背后有几个驱动因素,其中包括:
- 需要比关系数据库更好的可扩展性,包括非常大的数据集或非常高的写入吞吐量。
- 相比商业数据库产品,免费和开源软件更受偏爱。
- 关系模型不能很好地支持一些特殊的查询操作。
- 受挫于关系模型的限制性,渴望一种更具多动态性与表现力的数据模型【5】。
对象关系不匹配
目前大多数应用程序开发都使用面向对象的编程语言来开发,这导致了对SQL数据模型的普遍批评:如果数据存储在关系表中,那么需要一个笨拙的转换层,处于应用程序代码中的对象和表,行,列的数据库模型之间。模型之间的不连贯有时被称为阻抗不匹配(impedance mismatch)1
像ActiveRecord和Hibernate这样的对象关系映射(object-relational mapping,ORM)框架可以减
少这个转换层所需的样板代码的数量,但是它们不能完全隐藏这两个模型之间的差异。

例如,图2-1展示了如何在关系模式中表示简历(一个Linkedln简介)。整个简介可以通过一个唯一的标识符user id来标识。像 first name 和 last name 这样的字段每个用户只出现一次,所以可以在User表上将其建模为列。但是,大多数人在职业生涯中拥有多于一份的工作,人们可能有不同样的教育阶段和任意数量的联系信息。从用户到这些项目之间存在一对多的关系,可以用多种方式来表示:
- 传统SOL模型(SOL:1999之前)中,最常见的规范化表示形式是将职位,教育和联系信息放在单独的表中,对User表提供外键引用,如图2-1所示。
- 后续的SQL标准增加了对结构化数据类型和XML数据的支持;这允许将多值数据存储在单行内,并支持在这些文档内查询和索引。这些功能在Oracle,IBM DB2,MSSQLServer和PostgreSQL中都有不同程度的支持【6,7】。JSON数据类型也得到多个数据库的支持,包括IBM DB2,MVSQL和PostgreSOL 【8】。
- 第三种选择是将职业,教育和联系信息编码为JSON或XML文档,将其存储在数据库的文本列中,并让应用程序解析其结构和内容。这种配置下,通常不能使用数据库来查询该编码列中的值。
对于一个像简历这样自包含文档的数据结构而言,JSON表示是非常合适的:参见例2-1。ISON比XML更简单。面向文档的数据库(如MongoDB【9】,RethinkDB【10】,CouchDB【11】和
Espresso【12】)支持这种数据模型。例2-1.用JSON文档表示一个LinkedIn简介
{
"user_id": 251,
"first_name": "Bill",
"last_name": "Gates",
"summary": "Co-chair of the Bill & Melinda Gates... Active blogger.",
"region_id": "us:91",
"industry_id": 131,
"photo_url": "/p/7/000/253/05b/308dd6e.jpg",
"positions": [
{
"job_title": "Co-chair",
"organization": "Bill & Melinda Gates Foundation"
},
{
"job_title": "Co-founder, Chairman",
"organization": "Microsoft"
}
],
"education": [
{
"school_name": "Harvard University",
"start": 1973,
"end": 1975
},
{
"school_name": "Lakeside School, Seattle",
"start": null,
"end": null
}
],
"contact_info": {
"blog": "http://thegatesnotes.com",
"twitter": "http://twitter.com/BillGates"
}
}
从用户简介文件到用户职位,教育历史和联系信息,这种一对多关系隐含了数据中的一个树状结构,而JSON表示使得这个树状结构变得明确。

多对一和多对多的关系
在上一节的例2-1中,region id和 industry id是以ID,而不是纯字符串“Greater Seattle
Area"和“Philanthropy"的形式给出的。为什么?
存储ID还是文本字符串,这是个副本(duplication)问题。当使用ID时,对人类有意义的信息(比如单词:Philanthropy)只存储在一处,所有引用它的地方使用ID(ID只在数据库中有意义)。当直接存储文本时,对人类有意义的信息会复制在每处使用记录中。
组织和学校作为实体
在前面的描述中,organization(用户工作的公司)和school_name(他们学习的地方)只是字符
串。也许他们应该是对实体的引用呢?然后,每个组织,学校或大学都可以拥有自己的网页(标识,新闻提要等)。每个简历可以链接到它所提到的组织和学校,并且包括他们的图标和其他信息(参见图2.3,来自Linkedln的一个例子)。

图2-4阐明了这些新功能需要如何使用多对多关系。每个虚线矩形内的数据可以分组成一个文档,但是对单位,学校和其他用户的引用需要表示成引用,并且在查询时需要连接。

文档数据库是否在重蹈覆辙?
在多对多的关系和连接已常规用在关系数据库时,文档数据库和NOSQL重启了辩论:如何最好地在数据库中表示多对多关系。那场辩论可比NOSQL古老得多,事实上,最早可以追溯到计算机化数据库系统。
IMS的设计中使用了一个相当简单的数据模型,称为层次模型(hierarchicalmodel),它与文档数据库使用的ISON模型有一些惊人的相似之处【2】。它将所有数据表示为嵌套在记录中的记录树,这很像图2-2的ISON结构。
同文档数据库一样,IMS能良好处理一对多的关系,但是很难应对多对多的关系,并且不支持连接。
那时人们提出了各种不同的解决方案来解决层次模型的局限性。其中最突出的两个是关系模型
(relational model)(它变成了SQL,统治了世界)和网络模型(networkmodel)(最初很受关
注,但最终变得冷门)。这两个阵营之间的“大辩论"在70年代持续了很久时间【2】。
网络模型
网络模型由一个称为数据系统语言会议(CODASYL)的委员会进行了标准化,并被数个不同的数据库商实现:它也被称为CODASYL模型【16】
CODASYL模型是层次模型的推广。在层次模型的树结构中,每条记录只有一个节点;在网络模式中,每条记录可能有多个父节点。例如,“Greater Seattle Area”地区可能是一条记录,每个居住在该地区的用户都可以与之相关联。这允许对多对一和多对多的关系进行建。
关系模型
相比之下,关系模型做的就是将所有的数据放在光天化日之下:一个关系(表)只是一个元组(行)的
集合,仅此而已。如果你想读取数据,它没有迷宫似的嵌套结构,也没有复杂的访问路径。你可以选中符合任意条件的行,读取表中的任何或所有行。你可以通过指定某些列作为匹配关键字来读取特定行。你可以在任何表中插入一个新的行,而不必担心与其他表的外键关系4
与文档数据库相比
在一个方面,文档数据库还原为层次模型:在其父记录中存储嵌套记录(图2-1中的一对多关系,如positions,education和contact info),而不是在单独的表中。
关系型数据库与文档数据库在今日的对比
支持文档数据模型的主要论据是架构灵活性,因局部性而拥有更好的性能,以及对于某些应用程序而言更接近于应用程序使用的数据结构。关系模型通过为连接提供更好的支持以及支持多对一和多对多的关系来反击。
哪个数据模型更方便写代码?
如果应用程序中的数据具有类似文档的结构(即,一对多关系树,通常一次性加载整个树),那么使用文档模型可能是一个好主意。将类似文档的结构分解成多个表(如图2-1中的 positions,
education和 contact info)的关系技术可能导致繁琐的模式和不必要的复杂的应用程序代码。
文档模型有一定的局限性:例如,不能直接引用文档中的嵌套的项目,而是需要说“用户251的位置列表中的第二项”(很像分层模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。
文档数据库对连接的糟糕支持也许或也许不是一个问题,这取决于应用程序。例如,分析应用程可能永远不需要多对多的关系,如果它使用文档数据库来记录何事发生于何时【19】
文档模型中的架构灵活性
大多数文档数据库以及关系数据库中的JSON支持都不会强制文档中的数据采用何种模式。关系数据库的XML支持通常带有可选的模式验证。没有模式意味着可以将任意的键和值添加到文档中,并且当读取时,客户端对无法保证文档可能包含的字段。
查询的数据局部性
文档通常以单个连续字符串形式进行存储,编码为JSON,XML或其二进制变体(如MongoDB的
BSON)。如果应用程序经常需要访问整个文档(例如,将其染至网页),那么存储局部性会带来性能优势。如果将数据分割到多个表中(如图2-1所示),则需要进行多次索引查找才能将其全部检索出来,这可能需要更多的磁盘查找并花费更多的时间。
文档和关系数据库的融合
随着时间的推移,关系数据库和文档数据库似乎变得越来越相似,这是一件好事:数据模型相互补充5,如果一个数据库能够处理类似文档的数据,并能够对其执行关系查询,那么应用程序就可以使用最符合其需求的功能组合。
关系模型和文档模型的混合是未来数据库一条很好的路线。
数据查询语言
当引入关系模型时,关系模型包含了一种查询数据的新方法:SQL是一种声明式查询语言,而IMS和CODASYL使用命令式代码来查询数据库。那是什么意思?
许多常用的编程语言是命令式的。例如,给定一个动物物种的列表,返回列表中的鲨鱼可以这样写
function getSharks() {
var sharks = [];
for (var i = 0; i < animals.length; i++) {
if (animals[i].family === "Sharks") {
sharks.push(animals[i]);
}
}
return sharks;
}
在关系代数中:
sharks=ofamil="sharks”(animals)
0(希腊字母西格玛)是选择操作符,只返回符合条件的动物,family=“shark”。
定义SQL时,它紧密地遵循关系代数的结构:
SELECT *FRoM animals wHERE family='Sharks';
Web上的声明式查询
声明式查询语言的优势不仅限于数据库。为了说明这一点,让我们在一个完全不同的环境中比较声明式和命令式方法:一个Web浏览器。
假设你有一个关于海洋动物的网站。用户当前正在查看鲨鱼页面,因此你将当前所选的导航项目“鲨鱼“标记为当前选中项目。
<ul>
<li class="selected">
<p>Sharks</p>
<ul>
<li>Great white Shark</li>
<li>Tiger Shark</li>
<li>Hammerhead Shark</li>
</ul>
</li>
<li>
<p>whales</p>
<ul>
<li>Blue whale</li>
<li>Humpback whale</li>
<li>Fin whale</li>
</ul>
</li>
</u1>
现在想让当前所选页面的标题具有一个蓝色的背景,以便在视觉上突出显示。使用CSS实现起来非常简单:
li.selected > p {
background-color: blue;
}
如果使用XSL而不是CSS,你可以做类似的事情:
<xsl:template match="li[@class='selected']/p">
<fo:block background-color="blue">
<xsl:apply-templates/>
</fo:block>
</xsl:template>
MapReduce查询
MapReduce既不是一个声明式的查询语言,也不是一个完全命令式的查询AP!,而是处于两者之间:查询的逻辑用代码片断来表示,这些代码片段会被处理框架重复性调用。它基于map(也称
为collect)和reduce(也称为 fold或 iniect)函数,两个函数存在于许多函数式编程语言中。
最好举例来解释MapReduce模型。假设你是一名海洋生物学家,每当你看到海洋中的动物时,你都会在数据库中添加一条观察记录。现在你想生成一个报告,说明你每月看到多少鱼。
在PostgreSQL中,你可以像这样表述这个查询:
SELECT
date_trunc('month', observation_timestamp) AS observation_month,
sum(num_animals) AS total_animals
FROM observations
WHERE family = 'Sharks'
GROUP BY observation_month;
同样的查询用MongoDB的MapReduce功能可以按如下来表述
db.observations.mapReduce(function map() {
var year = this.observationTimestamp.getFullYear();
var month = this.observationTimestamp.getMonth() + 1;
emit(year + "-" + month, this.numAnimals);
},
function reduce(key, values) {
return Array.sum(values);
}, {
query: {
family: "Sharks"
},
out: "monthlySharkReport"
});
本文深入探讨了数据密集型应用的关键特性,包括可靠性、可扩展性和可维护性。面对海量数据和复杂性挑战,文章阐述了如何确保系统在面临硬件故障、软件错误和人为错误时仍能稳定运行,以及如何通过合理的方法应对系统增长带来的负载问题,同时保持系统的高效维护。

被折叠的 条评论
为什么被折叠?



