关注前沿技术,分享热点话题,QCon全球软件开发大会三站同启,重磅回归!立即查看 了解详情

怎样构建一个完整的技术平台?以一个简单的电商平台为例

2020 年 10 月 05 日

怎样构建一个完整的技术平台?以一个简单的电商平台为例

本文最初发布于 Robert Heaton 的个人博客,经原作者授权由 InfoQ 中文站翻译并分享。

假如你和好朋友 Steve Steveington又开了一家公司,这是一个在线市场 Steveslist,人们可以在那里买卖东西,没有人会问太多问题。

你将负责构建整个 Steveslist 的技术平台,包括网站、移动应用、数据库和其他基础设施。你认为自己应该能拼凑出一个小网站,因为以前做过几次。但是,对于大型在线平台需要的所有其他基础设施和工具,你甚至不知道如何开始。

因此,你迫切需要一个详细而简明的概述,了解真正的公司是如何做到这一点的。

他们如何存储他们的数据?不同的应用程序如何相互通信?他们如何将系统扩展到数百万用户?他们如何保证安全?他们如何确保一切正常?API、Webhooks 和客户端库是什么,该何时使用?

于是,你给另一个好朋友 Kate Kateberry 发了一条信息,看看她是否能帮上忙。过去,你们在一起工作时非常有效率,她在硅谷最大、最具争议性的公司工作,在创建这类系统上有几十年的经验。 很快,她接受了你的邀请。实际上,你打电话过去,只是为了得到一些粗略的指导并顺便闲聊。


Kate 走进你在旧金山公共图书馆 19 世纪文学区的办公室。

“好,我们开始吧!”她轻声喊,“到目前为止,我们都做了哪些工作?我们的系统都准备好了吗?有什么计划?”。

你向后靠在椅子上,合上笔记本电脑,电脑没有开机,因为你把充电器落在家里了。你竖起大拇指,赞扬她“想得周全”。

“这个问题我得请教你,Kate。你认为计划应该是什么?”

Kate 深吸一口气,描绘了 Steveslist 平台未来五年的详细蓝图及其基础设施。


在我们开始之前(Kate 说),我想澄清一点,我接下来要说的内容并不一定都是构建基础设施的“正确”方式。如果你有更信任的人说了一些不同的话,那你应该按照他们说的去做。现在有很多工具,每个工具都有不同的优缺点,也有很多方法可以创立一家技术公司。说实话,我们许多技术选择的真实原因是,“我们选择 X 是因为 Sara 非常了解它”和“我们选择了 Y,这似乎不是一个重大的决定,我们再也没有找时间重新评估。”

尽管如此,让我们快进到 5 年后的未来。现在,Steveslist 面向消费者的产品主要有两种:

  • Steveslist Web 应用
  • Steveslist App

这些是用户直接与 Steveslist 平台交互的主要方式。此外,我们还提供一个 API,让程序员可以在 Steveslist 平台之上构建能力工具,例如,通过编程为成百上千的物品创建列表。为支持这一点,我们要提供:

  • 一个 Steveslist API
  • Steveslist API 客户端库,让程序员可以轻松地编写代码,与 API 交互。

关于这一点,我将在白板上画一个示意图:

最终,我们在后台运行许多服务,为这些外部应用程序提供数据和能力:

  • Webhooks——当用户的帐户发生什么事情(如“已下单”)时,通知用户。
  • 用户密码验证——保证用户安全登录。
  • SQL 数据库——Steveslist 的主存储,需要具备高可扩展性和高可靠性。
  • 自由文本搜索系统——让人们可以在搜索框中使用宽泛的搜索词进行搜索,如“TVs”或“motorbikes”。
  • 内部工具——帮我们管理 Steveslist 平台以及采取行动,如向恶意用户发出严厉警告。
  • Cron 作业——运行定期任务,从生成发票到客户计费,再到尽可能多地向第三方广告网络发送用户数据。
  • “Pubsub”系统——让我们可以针对不同的触发事件采取异步操作(如当有新用户注册时,向他们发送一封欢迎邮件)。
  • 大数据分析系统——让我们可以对 Steveslist 的整个数据做大的查询。
  • 还有许多…

让我们逐个看一下其中的每个系统。如果有什么不清楚的地方,或者有任何问题,或者我搞错了什么, 请告诉我

服务器究竟是什么?

在开始前,让我们定义一些重要术语。我们将讨论很多关于“服务器”的内容。但是,当你认真地考虑服务器时,它到底是什么?

就我们的目的而言,服务器是运行在网络上并侦听来自其他计算机的通信的计算机。当它从另一台计算机接收到一些数据时,作为响应,它会执行某种动作,并且通常会返回它自己的一些数据。例如,Web 服务器在网络上侦听HTTP 请求,并返回Web 页面和信息作为响应。数据库服务器侦听数据库查询,并读取数据以及将数据写入正在运行的数据库中。

这个简短的描述跳过了很多细节,当然还有更精确的方法来定义“服务器”这个词。但现在,这已经够用。你刚才说什么?“网络”到底是什么?这个问题下次再讨论。

现在,我们开始讨论Steveslist 平台。

Steveslist web 应用

这是 Steveslist 的主要产品。它只是一个普通的 Web 应用程序,和你以前构建的任何网站都差不多,只是大很多。这是一个现代化的“单页应用”(SPA)。这里的“单页”指的是,当用户点击我们的站点时,用户的浏览器几乎不需要完全重新加载页面。

相反,当浏览器向我们的服务器发出第一个 HTTP 请求时,我们会返回一个基本的 HTML 框架页面和一大堆 JavaScript 代码。这些 JavaScript 代码在浏览器内执行,并更新页面视图以响应用户操作。当 JavaScript 希望向 Steveslist 发送数据或从 Steveslist 检索数据时,它会在后台向一个 URL 发送一个异步 JavaScript XML 请求(一般简称为 AJAX)。当服务器响应时,JavaScript 使用响应更新相应的浏览器视图。

robertheaton.com 不是一个单页应用程序。每当你点击一个链接时,浏览器必须重新加载整个页面。 twitter.com 是一个单页面应用程序。每当你点击一个链接,浏览器会动态更新页面的一小部分,而不强制完全刷新。 构建和维护 SPA 需要做大量的工作,但它们看起来确实不错。

Steveslist App

我们为 iOS 和 Android 平台提供了 Steveslist 的 App。在概念上,它们与我们的单页 Web 应用程序非常类似。我们的智能手机和 Web 应用程序都向服务器发送 HTTP 请求。然后,我们的服务器接收这些请求,执行一些操作并返回一个 HTTP 响应。最后,我们的智能手机和 Web 应用程序都更新了它们的显示,从而与用户进行对话。

因为我们的智能手机应用执行与 Web 应用程序相同的操作(例如,创建清单、发送消息等等),通常,他们甚至可以和 Web 应用程序一样向相同的 URL 发送请求。我们唯一需要完成的额外工作是开发应用的前端。一些框架甚至允许使用 JavaScript 编写移动应用,从而实现代码和逻辑的跨平台重用。

Steveslist API

我们允许用户和第三方以编程方式与平台交互,就像人们可以使用 Twitter API 编写代码来读取、喜欢和创建推特,我们也允许他们使用 Steveslist API 进行搜索、购买和罗列物品。

程序员可以通过编写代码使用我们的 API,向 API 端点发出 HTTP 请求。例如,为了检索所有清单的列表,程序员向 api.steveslist.com/v1/listings 发送一个 HTTP GET 请求。我们以 JSON 格式的数据响应他们的请求。JSON 是 JavaScript Object Notation 的缩写,但是 JSON 不是 JavaScript 特有的,使用任何编程语言都可以很容易地解析。对于检索所有用户清单的请求,JSON 响应可能是下面这样:

复制代码
{
"listings": [
{
"id": 2178123867,
"name": "Stolen TV",
"country": "US",
"city": "San Francisco",
"price_amount": 1000,
"price_currency": "usd",
# etc...
},
{
"id": 182312679,
"name": "Stolen Bicycle",
"country": "US",
"city": "San Francisco",
"price_amount": 2000,
"price_currency": "usd",
# etc...
}
]
}

对程序来说,这种结构化的响应格式非常容易解析,这意味着发出请求的代码可以轻松地解析和使用来自 API 的数据。用户使用 API Key 向我们的 API 标识自己或进行身份验证。大概来说,这相当于 API 的密码。它是一个随机的长字符串,我们生成并显示在用户的“设置”页面上。用户将他们的 API Key 作为 HTTP 头包含在他们(或他们的代码)向 API 发出的每个 HTTP 请求中。当我们收到一个 API 请求时,我们检查附加的 API Key 是否对应于一个 Steveslist 用户。如果是,我们就代表该用户执行该请求。

如果程序员愿意,他们也可以用他们所使用的开发语言中提供的标准 HTTP 库手工构建发送给 API 的 HTTP 请求。例如,在 Python 中,他们可能会这样写:

复制代码
import requests
url = 'https://api.steveslist.com/v1/listings/'
listing_params = {
"name": "Stolen TV",
"country": "US",
"city": "San Francisco",
"price_amount": 1000,
"price_currency": "usd",
}
api_key = "YOUR_API_KEY_GOES_HERE"
response = requests.post(
url,
data=listing_params,
headers={"X-Steveslist-API-Key": api_key},
)

不过,为了简化程序员的工作,我们会提供客户端库 。

Steveslist 客户端库

客户端库是“封装”Steveslist API 功能的库。这意味着,任何使用客户端库的人都不需要知道关于 Steveslist API 的任何细节。相反,他们可以这样写:

复制代码
import steveslist
listing = steveslist.Listing.create(
name="Stolen TV",
country="US",
city="San Francisco",
price_amount=1000,
price_currency="usd",
api_key="YOUR_API_KEY_GOES_HERE"
)

我们的库将给定的参数转换为适当格式的 HTTP 请求,并像平常一样发送给 Steveslist API。我们已经为能想到的每一种主要编程语言编写了客户端库,迄今为止,这是人们与我们 API 交互的最常见方式。

Webhooks

我们已经看到 Steveslist 的用户如何使用我们提供的 API 通过编程的方式与他们的帐户交互。此外,许多用户还希望我们在他们的 Steveslist 资料发生任何变化时主动告诉他们。例如,假设有人希望完全自动化在 Steveslist 上销售商品的过程。每当顾客使用我们新推出的、安全可靠的 StevePay 系统付款时,他们就会向顾客发送一封感谢邮件,并自动指示仓库将电视送到订单上的地址。我们的卖家可以不断地查询 Steveslist API,反复询问“有任何新的成交吗?有任何新的成交吗”然而,这将非常低效,会给我们的服务器带来很多不必要的负载。

相反,我们提供了一个名为 Webhooks 的行业标准系统。Webhook 是一个 HTTP 请求,当用户的帐户发生他们感兴趣的事情时,我们就发送给他们。它包含所有可以用来描述刚刚发生事件的数据,例如,商品 ID、价格、买家 ID、买家地址等等。Webhook 允许用户自动执行响应操作,如前面提到的电子邮件和自动发货。

要使用 webhook,用户得告诉我们,他们希望我们将其 webhook 发送给哪个 URL(例如, steveslistwebhooks.robertheaton.com )。他们在那个 URL 上设置了一个 Web 服务器,用于接收和操作这些 webhook 通知。

用户将代码部署在自己的 Web 服务器上,每当收到我们发来的 webhook 时,这些代码会执行相应的响应动作。我们不仅在购买商品时发送 Webhook,还在用户收到信息时发送;当他们的一件商品被管理员删除;或者当买家投诉时。这使得 Steveslist 卖家不仅可以自动列出商品,还可以自动销售和发货。

Webhook 并发症

Webhook 主要有两个地方比较复杂:安全性和可靠性。首先,让我们讨论下安全问题。卖家提供给我们、让我们发送其 Webhook 的端点,互联网上的任何人都可以访问。任何知道 URL 的人都能发送假的 Webhook,如果我们的卖家不够谨慎,就可能会受攻击者欺骗,例如,发送免费的东西给攻击者。卖家的 Webhook URL 应该很难找到,因为卖家不会公开它的存在,但隐瞒和安全不是一回事。

为了让我们的卖家验证 Webhook 真的是由 Steveslist 发送的,我们会对 Webhook 的内容进行密码签名。

密码签名

密码签名是一个深奥而微妙的话题。我们在这里用于确保 Webhook 安全的用法是一个精简版本。

当卖家启用他们账户的 Webhook 时,我们会生成一个随机的“共享密钥”。我们告诉卖家,把这个密钥复制到他们的 Webhook 接收服务器上,并保证它的安全,这样它的值就只有我们和卖家知道。每次发送 webhook 时,我们都会带着这个共享密钥,通过一个名为 HMAC 的加密哈希函数将它与 Webhook 的内容结合起来,得到一个很长的、看似随机但完全确定的 Webhook“签名”。

我们会将这个签名包含在 Webhook 体中, 例如:

复制代码
{
"action": "item_sold",
"price": 100,
// ...more params...
"signature": "234gj98d49j834978gf39t78ndn98g7dq3ng897308y7"
}

当卖家的 Webhook 接收服务器收到 Webhook 时,它获取共享密钥和 Webhook 内容,就像我们所做的那样,计算出期望的签名。然后,将结果与附加到 Webhook 的签名进行比较;如果相匹配,就接收并处理 Webhook。由于签名只能使用我们和卖家都知道的共享密钥生成,所以 Webhook 接收服务器可以确信 Webhook 是我们发送的。不过,如果签名不匹配,服务器就会拒绝该 Webhook。
请注意,所有的签名验证码都必须由卖方编写和维护。我们可以为他们提供支持和示例,但我们不能强迫他们验证签名的正确性。如果要了解真实的例子,那么可以看看 Stripe GitHub 是如何对 Webhook 进行签名的。

可靠性

我们还需要考虑,当我 webhook 出现错误时会发生什么,以及我们想要向用户保证什么。如果我们试图发送一个 Webhook 但不能连接到用户的服务器,我们应该做些什么呢?如果我们成功地连接到他们的服务器并发送了 Webhook,但是他们的服务器返回一个错误呢?如果我们发送了 Webhook,但服务器断开前挂起了二十秒钟,并且没有告诉我们发生了什么事呢?

这里就涉及到权衡取舍,需要清晰的沟通以及管理数量惊人的基础设施。在 Steveslist,我们选择保证发送 Webhook“至少一次”。也就是说,如果 Webhook 发送失败,我们会继续尝试(大量但不是无限次),直到它发送成功。如果我们不确定 Webhook 是成功或失败,我们将继续尝试,直到我们确定一次尝试成功了。偶尔,这可能会导致我们将相同的 Webhook 发送两次,但这就是卖方的责任,让他们的代码做个优雅的处理,而不是客户定了一台电视,他们发五台。


“到目前为止你觉得怎么样?”Kate 问。“你大概就是这么想的吧?”你摆出一副不置可否的表情,然后咬了一大口旁边的三明治,以避免进一步的讨论。Kate 继续讲了下去。


让我们来谈谈后端系统,它将支持一些最重要的特性。

通过密码进行用户身份验证

大多数用户使用用户名和密码登录 Steveslist 网站和 App。此外,还有其他的登录网站方式,比如 OAuth。

我们必须非常安全地存储用户密码。用户密码不仅用于在 Stevelist 上登录(或验证),而且对许多用户来说,它们在许多其他服务上登录时可能使用了相同密码,尽管有很多人警告说这不是一个好主意。

关于如何保护密码,Rupert Herpton 写了一份影响深远的教程,我相信你已经读过。我这里再重复一下,该问题的关键是我们绝不能在任何地方以原始的明文形式存储密码。我们不能将它们以明文存储在数据库、日志文件或系统的任何其他部分。相反,在存储密码之前,我们必须首先使用哈希函数(如 bcrypt )对其进行哈希。

哈希函数是一种“单向”函数,它接受输入并将其转换为一个新的、看似随机而实则确定的字符串。在计算上,使用输入计算哈希值非常简单,但是逆转转换并从哈希值中恢复原始输入需要大量时间和计算能力,实际上,这是不可能的。

由于我们只存储用户密码的哈希值,如果黑客以某种方式窃取了我们的密码数据库(但愿不会),那么他们也只能看到密码的哈希值。他们无法轻松地将这些哈希值转换回明文密码,也就是说,他们无法使用这些哈希值登录 Steveslist,也无法利用所有在不同服务中重用密码的人。
因为我们只存储密码哈希值,所以当我们想检查用户提供给我们的登录密码是否正确时,我们首先要计算它的哈希值,并将结果与密码数据库中的哈希值进行比较。

如前所述,我们还必须注意,不要在任何调试输出中记录密码。这可能是一个很容易犯的错误——如果你记录每个 HTTP 请求的所有参数(以防需要),那么你肯定会记录用户的密码。Facebook 以明文记录密码很多年了,虽然这似乎没有影响他们的股价,但我敢肯定,他们会有一两天觉得自己很傻。


Kate 停下来,喘了口气,而你假装用没电的笔记本做笔记。


让我们探讨下基础设施。

数据库服务器

Steveslist 的主数据存储是一种常见的 SQL 数据库,称为 MySQL 。这里,我不会过多地讨论 SQL——详细的工作原理并不太重要,如果你感兴趣,可以通过谷歌查找相关信息。由于我们非常成功,所以我们有大量的、不断增长的数据需要管理。我们开始强烈地意识到,数据库并不是魔法。它们在计算机上运行,就像其他程序一样。

随着数据库中数据量的增长,计算机的硬盘驱动器和内存都被填满了。在公司成立之初,处理这个问题最简单的方法是,在一台有巨大硬盘驱动器的计算机(或机器)上运行我们的数据库。但这只能拖延时间,并不能从根本上解决问题。最终,我们数据库中的数据超过了任何价格可接受的的硬盘驱动器的存储容量,并且数据库开始变得越来越慢,因为我们迫使它搜索越来越多的记录。

我们需要采用一种全新的方法,重新配置数据库,将数据存储在多台计算机上。我们借助分片来完成这项工作。

分片

数据库分片意味着将数据分成多个块(或“片”),并将每个块存储在单独的机器上。构成数据库的所有机器统称为数据库集群。如何在集群中的机器之间拆分数据取决于你的应用程序和将要执行的操作类型。对于 Steveslist,我们选择按用户拆分数据。这意味着给定用户的所有数据都存储在同一台机器上。这包括他们的个人资料信息、他们的清单、他们的消息等等。没有对应用户的数据(如“每日交易”)可以使用用户以外的一个分片键进行分片,而如果数据集很小,则根本不用进行分片。

这意味着在数据库前面,我们需要有一个额外的“路由”层,它知道哪台数据库机器能够为哪些查询提供服务。我们可以让应用程序服务器(执行我们的代码服务器)负责维护用户 ID 与数据库分片的映射,或者有一个集中式的“路由器”,所有服务器都将请求发送给它,它负责将请求转发到恰当的机器。这两种方法各有优点。让应用服务器负责维护映射,可以减少请求的跳数,从而加快速度。但是,拥有一个集中式的路由器让更新分片映射变得更加容易,因为你只需要在一个地方更新它。

如果应用程序服务器负责维护分片布局,那么我们的系统看起来会是这个样子:

如果你有一个集中式的数据库路由器,那么我们的系统看起来会是这个样子:

如何决定将每个用户分配到哪个数据库?我们想怎么做都可以。开始时,可以将 ID 为奇数的用户分配给分片机器 1,将 ID 为偶数的用户分配给分片机器 2。我们希望这种随机分配能够使我们在分片之间相对均匀地平衡我们的数据。
然而,我们可能有少数超级用户,他们创建的数据比其他人多得多。也许它们创建了太多数据,以至于我们想为它们分配一个属于它们自己的分片。这完全没问题——我们可以完全控制如何将用户分配到分片。随机分配通常是最简单的,但绝不是唯一的选择。我们可以选择将用户随机分配到分片,除了用户 367823,他会被分配到分片 15,其他用户不会被分配到分片 15。

如果一个分片变得太大,硬盘驱动器都快被填满了,那么我们可以将其分割成多个更小的分片。如何在不关闭平台的情况下将数据迁移到这些新的分片上?也许要按以下步骤进行:

  1. 通过“双写”将数据同时写入旧分片和新分片。继续从旧分片读取数据。
  2. 从旧分片将所有数据复制到新分片,继续向新旧分片双写。
  3. 说服自己,新旧分片包含相同的数据。要做到这一点,我们可以比较数据库的快照,和 / 或在两个数据库中执行每个查询,比较结果,并在数据不同时发出警报。
  4. 一旦我们确信新旧分片的数据相同了,我们就可以进行切换,从新分片读取数据了。我们还会继续进行双写,以防需要切换回来。
  5. 一旦确信这个步骤已经成功,我们就停止双写,并删除旧分片。

Rupert Herpton 写了一篇很好的文章,介绍了一种大体相似的迁移方式,但更详细。简而言之,在数据库分片之间迁移数据的过程非常繁琐,但也是完全可行且合乎逻辑的。某些类型的数据库甚至可以自动为你处理分片。

复制

我们得非常小心,确保我们的数据不会被意外删除,我们的数据库服务器不会意外地停止工作。但意外会发生,有时计算机也会意外地停止工作。为了减轻数据库相关灾难的影响,我们在多台机器上(接近)实时地复制数据。

实时复制主要有两个优点。首先,这意味着如果我们的数据库服务器发生爆炸、起火或停止工作,我们有多个近乎完美的副本可以无缝地补充进来。简单来说,如果是普通的故障,则我们服务的用户可能永远不会知道出了什么问题。复制的第二个好处是,我们可以跨多个数据库服务器分发针对相同数据的查询。如果很多用户要求从同一个数据库读取数据,我们可以将他们的查询分散到所有副本上,这意味着我们可以并行处理更多查询。

注意,数据库复制与数据库备份不是同一个概念。复制发生在(接近)实时的情况下,目的是在生产系统中动态地处理个别问题。数据库备份是按照计划执行的(例如,每天执行一次),数据的副本是单独存储的,和生产系统不在一起。备份被设计为防止大范围的、灾难性的数据丢失的最后保护措施,如果你所有的数据中心都被烧毁了,就可能会发生这种情况。
假设你在一家大型银行的呼叫中心工作,人们会不停地给你打电话,要求转账,询问他们的当前余额。复制就相当于在你接收到转帐细节时,匆忙地写进多本书中,以防你丢失其中的一本。做备份就相当于每天复印一次这些书,然后把它们存放在你的文件柜里。

在概念上,复制的过程非常简单。每当将新数据写入数据库时,我们都会将其复制到多台机器上。这意味着如果 / 当一台机器出现故障时,我们可以继续查询它的兄弟机器,而不会对用户造成影响。这还意味着我们可以将查询分散到相同数据的多个副本上,从而加快查询响应速度。

然而,具体怎么做,还有许多重要而微妙的细节,而且要视情况而定。复制没有正确的方法——通常情况下,具体采用什么方法取决于系统特定的需求和约束。

在现实世界中,有两个不利的情况使复制变得更加复杂。首先,数据库操作有时会随机失败。当我们试图将数据从一台服务器复制到另一台服务器时,偶尔会出现错误,导致机器之间不同步,至少在一段时间内是这样。其次,即使在平稳运行时期,复制也不是瞬间完成的。当新的数据写入我们的数据库集群时,集群中的一些服务器总是比其他服务器更快地获得更新。在选择复制策略时,我们必须了解这些限制以及它们如何影响我们的特定应用程序。例如,也许你可以冒险让一个帖子的“赞”数稍微有点不同步或过时,但不能冒险让用户银行账户里的钱多起来。

在选择复制策略时,我们必须做出的最大决策之一是希望复制是 同步的 还是 异步的

异步 vs 同步复制

复制可以同步处理,也可以异步处理。在系统设计时,这些词会在很多不同的地方出现,但它们的根本含义是相同的。如果一个操作是“同步”执行的,那么就意味着它执行时有东西在等待。如果你想同步发送信件,那么你就会到邮局,把信交给邮递员,坐在邮局的角落里等待几天,当邮递员确认你的信已安全抵达后才回家。

相反,如果一个操作是“异步”执行的,那么就意味着操作的发起者不会等待它完成。相反,当操作在后台进行时,它们会继续完成其余的工作。真正的邮政服务是异步的;你把信交给邮递员,然后离开邮局继续你的生活。异步操作可以发送操作结果的通知,如果他们愿意的话 (“你的信已经到了并且已经签收了”或者“Heaton 先生,你干洗的衣服已经好了。”),但他们不必非得这样做。

这些原则如何运用到数据库复制呢?在同步复制中,客户端将其新数据写入数据库服务器,然后等待。服务器在跨所有兄弟服务器完成客户端数据复制之前,不会告诉客户端是否成功完成了写操作。然后,只有在收到服务器的通知时,客户端才会继续执行它的其余代码。

在异步复制中,客户端像前面一样将其数据写入第一个数据库服务器。但是这一次,第一个服务器在将数据写入它自己的数据存储后就告诉客户端写操作成功了。它在后台启动复制过程,不等待复制过程完成,甚至不等待复制过程开始,就告诉客户端写操作成功。这意味着异步操作看起来比同步操作快得多,因为客户端不必等待所有复制完成就可以继续其余的工作。但是,如果后台复制失败,客户端也会认为它已经成功地将数据写入数据库,而实际上并没有。这可能会引起一些问题,对于这些问题的设想就留给读者作为练习了。

同步方法较慢,但比异步方法更“安全”。在完成接收和存储数据的每一步之前,数据库永远不会声称成功接收和存储了任何数据。因为客户端要等到完全确认后才能继续,所以保证不会出现客户认为自己已经向数据库写入了一些数据,而数据库却偷偷地将部分数据漏掉的情况。

哪种方法更好?要看情况而定。你更关心速度还是正确性,是系统运行速度明显加快,偶尔出现数据不一致的问题,还是一个错误就会导致灾难性的故障?

数据库复制系统还面临其他有趣的问题。其中,许多问题是“逻辑上的”而不是“技术上的”,而且不需要详细了解系统底层的复杂软件和网络协议。为便于描述,想象你回到了上文的呼叫中心。你和许多其他同事一起工作。你们一起扮演数据库服务器的角色。人们打电话要求转账,询问他们的当前余额。你和你的同事把新的转账信息写在纸上,然后互相打电话完成复制。

我们的数据库集群所面临的问题与我们呼叫中心所面临的问题相同。如果两个人打电话进来,与两个不同的员工交谈,试图同时更新相同的数据,会发生什么呢?或许我们可以通过一个“领导”员工来解决这个问题,他是唯一一个被授权为希望转账的人提供服务的人。所有其他员工只被允许回答关于当前余额的问询。那好,如果领导在收到了一些新数据后,中风去世了,但之前没有把新数据告诉其他人,这会怎么样呢?如果“追随者”员工临时发作了几秒钟,错过了领导的一些数据更新?如果我们从来无法完全确定我们的雇员都是死是活呢?

如果你看得太仔细或开始问一些让人尴尬的问题,那么这个类比可能就显得不是很贴切了。理解了这个奇怪的呼叫中心,就理解了数据库复制。要真正地理解复制,阅读这篇 博文

备份

除了实时复制之外,我们还定期存储整个数据库的备份,以防止发生灾难性的故障。我们复制数据库中的所有数据,将其存储在安全的地方,并为其做类似这样的标记“database backup, 2020-05-11:01:00”。

如果我们的整个数据库或其中一部分被以某种方式清除,那么我们就获取可用的最新备份并将其加载到新的数据库机器中。

这样的灾难可能是由网络攻击、数据中心事故、数据库迁移出现严重错误或者其他一些不太可能但可能毁掉公司的原因造成的。与实时复制不同,从备份中恢复数据库是一个缓慢的过程,用户肯定会注意到这一点。我们的整个系统可能会离线,直到恢复完成。我们将丢失上次备份之后、灾难发生之前发生的所有数据库更新。毫无疑问,当用户和系统试图访问那些曾经存在但现在永远丢失的数据时,将会产生大量的连锁反应。尽管如此,这比彻底的、不可挽回的毁掉公司及损失公司的所有数据要强多了。
这是关于我们的主 SQL 数据库的全部细节。让我们讨论存储和查询数据的其他一些方法。

自由文本搜索

标准 SQL 数据库非常擅长回答明确具体的查询,例如:

  • 查找用户#145122 在过去 90 天里创建的所有商品清单;
  • 查找商品#237326921 的详细信息;
  • 查找旧金山一天内产生的带有标签“Electronics”的新增清单的数量。

你可以使用类似下面这样的 SQL 查询获取此类数据:

复制代码
SELECT *
FROM items
WHERE
user_id = 145122 AND
created > currentDate() - 90

复制代码
SELECT
DATE_TRUNC('day', i.created) AS day,
COUNT(*)
FROM items AS i
INNER JOIN labels as l
ON i.id = l.item_id
WHERE
i.city = 'San Francisco' AND
l.text = 'electronics'
GROUP BY 1
ORDER BY 1 DESC

可以看到,这些查询包含许多非常“精确”的操作符,比如等号和大于号。但是,如何编写 SQL 查询才能使得返回的商品清单与谷歌“全文”搜索的结果类似呢,比如查询“二手电视”?

这会非常困难。你可以尝试 SQL 的 LIKE 操作符,它让你可以查找包含某个模式(比如子字符串)的记录。例如,下面的查询查找所有描述中包含字符串 second-hand TV 的商品:

复制代码
SELECT *
FROM items
WHERE description LIKE '%second-hand TV%'

但是,这个查询将只能匹配给定的非常特定的子字符串。它不会匹配“TV that is second-hand”、“sceond-hnad TV”。理论上,如果你有足够的毅力,就可以构建一个非常大的 SQL 查询,耐心地、尽可能多地列出各种排列:

复制代码
SELECT *
FROM items
WHERE
description LIKE '%second%hand%TV' OR
description LIKE '%second%TV%hand' OR
-- …and so on and so on for another bajillion ORs…

但是,最终的查询会很慢,也很麻烦,并且仍然会遗漏许多没有想到的重要的边缘情况。即使你得到了一个有用的搜索结果列表,也仍然要做很多工作才能决定如何对它们进行排序。你想要的顺序不是严格的“最近优先”或“按标题字母排序”。相反,排序使用的是一些更模糊的概念,“最好的”或“最相关的”优先。
所有这些都意味着 SQL 数据库通常不太擅长全文搜索。幸运的是,其他类型的数据库擅长,包括一种名为 Elasticsearch 的数据库。通常,Elasticsearch 被描述为 面向文档的数据库 。我们不需要详细了解它的工作原理,对我们来说最重要的是,与 SQL 数据库不同,Elasticsearch 很擅长接收类似“second-hand TV”这样的查询并返回一个列表,然后按与用户查询匹配的“相关性”进行排序。

你可能会说,“听起来 Elasticsearch 好像比 SQL 好,那么我们应该扔掉 SQL 数据库,把所有东西都放到 Elasticsearch 中吗?”别这么急。Elasticsearch 和其他类似的数据库有它们的长处,但也有它们的弱点。它们通常不如 SQL 数据库可靠,更可能意外地大规模丢失数据。而且,向它们写入新数据通常要慢很多。

正如我们已经指出的,对于任何类型的工作,很少有一个单一的、普遍适用的“最佳”工具。当然也就没有所谓的“最佳”数据库。相反,不同的数据库有不同的优缺点,不同的解决方案适用于不同的任务。在 Steveslist,我们非常注意针对任务选择正确的工具,我们将数据存储在 SQL 数据库和面向文档的数据库(如 Elasticsearch)中。

我们使用 SQL 数据库作为主数据存储。它是我们权威的“真相来源”,我们在把所有的新数据写在其他地方之前都先写到 SQL 数据库里。我们从它那里进行简单、精确的读取,特别是当准确和最新是我们的主要要求时。如果我们想向用户显示他们所有打开清单的列表,我们会从 SQL 数据库中读取。如果我们想验证用户密码,也可以从 SQL 数据库中读取。这发挥了 SQL 数据库的优势。

然而,在后台,我们还将数据从 SQL 数据库复制到一个 Elasticsearch 数据库中,并将通过搜索框进行的任何全文搜索查询发送到 Elasticsearch。这意味着我们受益于 Elasticsearch 的优点,而不会受到它的缺点的太多影响。根据不同数据集的需求,我们每隔几分钟、几小时或几天复制一批记录。或者,我们可以查看 SQL 数据库的日志以获取新增的或更新的记录,并近实时地创建或更新相应的 Elasticsearch 记录。

在探查 Steveslist 的竞争对手时,我注意到,当你在 Craigslist 上新建一个列表时,它会告诉你“你的列表会在 15 分钟内出现在搜索中”。我敢打赌,这是因为他们的主数据存储是一个 SQL 数据库,但他们的搜索框是基于一个类似 Elasticsearch 的引擎。他们的管道将数据从 SQL 复制到搜索可能需要大约 15 分钟,对于他们的特定用例,他们认为这(非常合理)是可以接受的延迟。
我们已经说过,Elasticsearch 不如大多数 SQL 数据库可靠。如果 Elasticsearch 不小心丢失了一些记录,那么它们就不会出现在搜索结果中。这不应该会使我们的产品成为糟糕的产品,我们应尽力避免。但是,它不会像丢失主数据存储中的数据那样成为灾难。只有当数据被写入并被我们作为“真相来源”的 SQL 数据库安全捕获后,我们才会将数据复制到 Elasticsearch,这才是最关键的。


现在是下午 6 点,图书馆要关门了。图书管理员试图让你离开。Kate 用一连串的纸团把他赶走了。

内部工具

管理 Steveslist 平台需要定制内部工具。我们需要执行各种各样的管理任务,比如删除不合规的列表(即使对我们来说也是如此)、发起退款、查看用户的个人信息以帮助处理支持请求等等。在这些工具中,有许多将被 Steveslist 其他团队的人使用,比如财务、合规、法律、销售支持等团队。由于它们需要执行的任务与 Steveslist 的工作方式紧密相关,因此我们不能使用第三方工具来完成,而必须构建自己的工具。

早期,我们将这个功能添加到主要的 Stevelist 产品中,只向设置了 is_steveslist_employee = True 数据库标识的用户公开。对于小公司来说,这是一个不错的方法,但很脆弱,也很容易出问题。一想到会把超级用户能力暴露在运行主要产品的同一台服务器上,我们就感到害怕。它会使应用程序安全缺陷的潜在后果变得更严重,并导致了新的错误类型,例如忘记为一个被解雇的员工禁用 is_steveslist_employee 标识。

一旦 Steveslist 达到一定的成熟水平,我们就为自己构建了一个完全独立的管理平台,使用单独的、经过增强的身份验证和授权。该服务运行在完全独立的服务器上,需要 VPNTLS 客户端证书 才能访问(这个问题我们将在以后讨论)。这样就将我们的内部产品和外部产品分开了,大大减少了因一个愚蠢的错误而暴露我们管理工具的机会。我们经常构建新的内部工具——一个用于用户管理,一个用于服务器管理,一个用于欺诈检测等等。这些服务都与我们面向用户的产品使用相同的数据库,因此,它们都可以访问相同的数据。它们只是运行在不同的服务器上,外部世界完全无法访问这些服务器。

Cron 作业

有许多任务,我们希望可以按一定的间隔在固定的时间点执行,例如:

  • 每周通过邮件给自己发送使用情况报告;
  • 向用户发送营销邮件;
  • 向使用我们订阅服务的信用卡用户收费;
  • 从 SQL 数据库向 Elasticsearch 复制数据。

cron 是 运行调度作业最常用的工具,这是一种内置在 Unix 操作系统中的工具。它如此常见,以至于人们经常将任何类型的调度作业都称为“cron 作业”,即使它实际上不是由 cron 运行的。

你可以运行以下命令,在自己的计算机上设置 cron 作业:

复制代码
crontab -e

然后在提示符下输入:

复制代码
*/5 * * * * $YOUR_COMMAND

这将使你的计算机每 5 分钟执行一次 $YOUR_COMMAND

目前,Steveslist 有一个非常简单但有点脆弱的 cron 设置。我们有一个“调度作业服务器”。我们在这个服务器上使用 crontab (如上所述)来告诉它我们想让它运行的命令,以及想让它什么时间运行。这种设置的扩展性不是很好——如果调度作业服务器发生故障,那么我们有时候会不知道它运行了哪些作业,哪些作业没有运行,并且不得不匆忙启动一个替代服务器。尽管服务器非常健壮和强大,但最终我们想要运行的作业还是会超出它的一次性处理能力。我们正在考虑将 cron 作业拆分成多个服务器,或者使用 Kubernetes 等现代化工具构建一个新系统。

Pubsub

行为都有结果。这是我们给那些在互联网上说我们坏话的人发送邮件的原因,也是我们建立 Pubsub系统的原因。

当新用户注册时,我们希望向他们发送一封电子邮件,欢迎他们加入 Steveslist,并提醒他们注意我刚才描述的行为 - 后果关系。当旧金山有人新增了电视清单,我们想要通知那些针对搜索“旧金山湾区电视”设置了提醒的用户。当信用卡被拒绝订阅时,我们想要给有责任的用户发送电子邮件,礼貌地警示他们。当有新的清单被添加时,我们希望执行一些非常粗略的反欺诈检查。当有人购买了商品时,我们想发送 webhook 到它的卖家。诸如此类。

从技术上讲,所有这些动作都可以由执行初始化操作的服务器同步(还记得这个词吗?)执行。不过,这通常不是一个好主意,原因有两个。首先,响应动作(比如通过电子邮件通知所有订阅了新增电视通知的用户)可能非常缓慢。同步执行响应动作意味着,如果初始动作是由用户执行的,那么他们将不得不等待所有响应动作完成。如果响应行动小而迅速,比如发送一封电子邮件,这可能不是什么大问题。但如果动作比较大——比如搜索并通知所有可能对新商品感兴趣的用户——那么,这虽然还不算大问题,但会是一个糟糕的用户体验。

为了缓解这个问题,我们构建了一个“Publish-Subscribe”或“Pubsub”系统。当执行触发器动作时,执行该动作的代码“发布一个描述该动作的事件”,例如 NewListingCreatedSubscriptionCardDeclined 。这个语境里的“事件”和“发布”定义得相当粗,没有严格的技术定义。事件只是对已发生的事情所做的某种记录,而发布一个事件只是意味着你以某种方式记录下某件事情发生了。至于实现细节,完全取决于正在讨论的 pubsub 系统。在简单的系统中,事件可能存储在 SQL 数据库的表中,代码将通过向表写入新记录来发布事件。

在 Steveslist ,如果程序员希望在发布特定类型的事件时执行一个响应动作,他们可以编写一个 Consumer 。这是一段“订阅”了某类事件的代码,每当该类型的事件发布时,都会执行这段代码。它使用事件的详细信息(例如,订阅支付失败的用户)来异步执行程序员想要执行的任何动作(例如,向用户发送警示邮件)。

通常,Pubsub 系统由一个中央消息代理 管理。系统向代理发布事件,代理负责将事件发送给任何订阅的消费者。这可以使用 机制来实现。消费者可以通过轮询并反复询问“是否有新事件?”来从代理拉取消息,或者它们可以等待并侦听,代理可以向它们推送新事件通知,例如向它们发送一个 HTTP 请求。

Pubsub 系统有许多好处:

  • 非关键动作异步执行,为用户提供流畅的体验;
  • 保持代码干净和良好的隔离。发布事件的代码不必关心事件的订阅者在响应中做了什么。
  • 如果订阅者的动作由于某种原因而失败(例如,电子邮件发送系统出现故障),Pubsub 系统可以获知这个失败并稍后重试。

接下来,让我们聊聊大数据。

大数据和分析

为理解和优化我们的业务,我们需要能够计算整个 Steveslist 平台的复杂的统计数据。按国家和城市划分,每天创建多少列表?每个月有多少注册用户在注册后 90 天内创建了一个列表?

为此,我们需要编写对整个数据进行聚合的数据库查询。我们不想在生产环境的 SQL 数据库中运行这些查询,因为它们会产生巨大的负载。我们不希望内部分析师发出一个巨大的查询,使我们的生产数据库陷入瘫痪,但我们确实希望为该分析师提供一个非常适合他们需求的工具。让事情变得更加复杂的是,数据库引擎执行小查询(如返回所有属于一个用户的列表)很快,执行巨大的查询(如计算过去的 90 天里每天每个类别的清单总共消费了多少美元)通常慢得不可接受(或者无法完成)。

尽管如此,在 Steveslist 成立后大概一年左右的时间里,我们还是冒险在主生产数据库里执行了我们的分析查询。这是一场赌博,但它几乎获得了成功。不管怎样,我们的数据不是太多,我们有更重要的事情需要关注,比如吸引那些创建大数据的客户,总有一天,我们必须找到一个更可扩展的解决方案。

最终,我们用一个过于雄心勃勃的查询搞挂了生产数据库,并因此决定是时候投资数据仓库了。数据仓库是一种非常适合系统范围的大型查询的数据存储。我们的仓库基于一个名为 Hive 的数据库引擎,但是我们也可以选择 Presto、Impala、Redshift,或者其他一些竞品。Hive 接受 SQL 编写的查询,但是在比 MySQL 数据库大得多的大型数据集上执行。

每晚一次,我们将数据从生产 SQL 数据库复制到 Hive。Steveslist 分析师和程序员可以使用最满足他们需求的数据库引擎查询相同的数据集。他们可以使用生产 SQL 数据库执行来自生产系统小而精确的真相查询;使用 Elasticsearch 进行全文搜索查询;或者使用数据仓库对海量数据进行大型的聚合查询。


外面天黑了,你又饿了。你问,差不多了吧?

“哦,天哪,不,”Kate 答道,“我们可以一直继续下去。但这是一个很好的开始。对广泛的主题有广泛的了解是有好处的,但没人需要知道所有的细节。我发现,一旦了解了一些基础知识,你就可以继续学习更多的基础知识,甚至在学习过程中还可以学到一些细节知识。”

你问 Kate,是否可以继续详细地阐述下,她对 Steveslist 未来五年的设想。

“当然,”Kate 说。我们将着重讨论下一个真实的大型在线平台的内部情况,包括:

安全

  • HTTPS 及其他形式的加密
  • 双因素认证
  • SAML
  • 密钥管理

服务器管理

  • 部署新代码
  • AWS 及其竞争对手
  • Terraform
  • 负载均衡器
  • 容器
  • 故障告警

大数据

  • MapReduce 及其他大数据作业
  • 机器学习
  • 用户跟踪

“明早 7 点见?”

原文链接:

https://robertheaton.com/2020/04/06/systems-design-for-advanced-beginners/

2020 年 10 月 05 日 09:00 3376
用户头像

发布了 297 篇内容, 共 132.1 次阅读, 收获喜欢 582 次。

关注

评论 1 条评论

发布
用户头像
由浅入深,很不错
2020 年 10 月 11 日 06:22
回复
没有更多评论了
发现更多内容

Git 多用户多仓库配置 windows10

halapano

git

这个开源神器可快速帮你安装 MacOS 虚拟机!

JackTian

macos GitHub Linux 操作系统 虚拟机

钱从哪里来 - 中国家庭的财富方案

石云升

读书笔记 工作 财富 买房 资产配置

运维那点事 - jenkins流水线

yann [扬] :曹同学

spring-data-redis -- 一次执行链路的分析

PCMD

Java spring springdataredis

数据库技术概览(持续更新中)

BlueblueWings

RASP研发踩坑之attach失败

国服第一

JVMTI Java Agent JVM 信息安全 RASP

Kafka系列8:一网打尽常用脚本及配置,宜收藏落灰!

z小赵

大数据 kafka 实时计算

眼前搁座金山也看不见

池建强

搜索引擎 学习方法

这么多年了,QQ没发现这个问题吗?

BabyKing

奈学教育:分布式架构,刚性事务-2PC必须注意的问题及3PC详细解说

奈学教育

分布式架构 2PC 3PC

变则通,通则久 —— 读《谁动了我的奶酪?》

YoungZY

读书 读书感悟

如何用五步建设数据中台?

博文视点Broadview

大数据 数据中台 架构 中台

2020年全球经济萎缩,飞链热交易所逆袭而来闪耀数字经济

极客编

如何成为高手: 到知识的源头去

lmymirror

学习方法 方法论 高手

将footer固定在底部: Flexbox vs Grid

寇云

CSS css3

游戏夜读 | 游戏代码之道

game1night

分支管理模式

wiflish

git

JavaScript 基础拾遗 —— this 的前世今生

吴昊泉

JavaScript 前端 学习笔记

大规模分布式系统概览(持续更新中)

BlueblueWings

Streaming System笔记

BlueblueWings

Linux 终端下记不住命令的使用方法?这个开源项目帮你解决。

JackTian

Linux 运维 操作系统 命令 开源项目

原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (九)测试驱动开发(TDD)

编程道与术

Java 编程 软件测试 TDD 单元测试

DevOps知识点——3C知多少

陈琦

DevOps 测试 持续集成

到底谁是你老板

Neco.W

工作 创业心态

python实现·十大排序算法之堆排序(Heap Sort)

南风以南

Python 排序算法 堆排序

zabbix 实战指南(2)

橙子冰

zabbix

七年老程序员面试经历

代码诗人

OAM v1alpha2 新版:平衡标准与可扩展性

孙健波

AutoConfigurationImportSelector到底怎么初始化

编号94530

Java spring Spring Boot import

算法:时间复杂度和空间复杂度

shirley

算法 时间复杂度

微软秋季技术课堂

微软秋季技术课堂

怎样构建一个完整的技术平台?以一个简单的电商平台为例-InfoQ