V2EX rayeaster
 rayeaster 最近的时间轴更新
rayeaster

rayeaster

V2EX 第 74493 号会员,加入于 2014-09-19 22:00:26 +08:00
rayeaster 最近回复了
第 5 章第 3 节:最终一致性里的旧数据幻影

无论从哪个技术指标看,我们新的技术架构都表现非凡。系统运行流畅稳定且能够服务 10 万用户。以系统工程视角来说,这确实是赢得了一场架构升级之战。但如果站在用户角度,新架构却引入了一个十分奇怪,甚至有些魔幻的让人困扰的新问题。

设想一位名叫 Priya 的卖家。她经营着一家专卖定制珠宝的精品小店。她登录 dukaan 页面然后视线停留在一款店铺里最火的项链(售价 1000 卢比)。她决定搞一次闪促把价格降到 800 卢比( 20%折扣)。于是她修改价格点击“保存”按钮。系统给出的响应非常及时:“商品信息修改成功!”

为了再次确认价格已修改,Priya 尝试向顾客一样点击“查看店铺”按钮。很快就看到了那款项链,但是价格仍是 1000 卢比。

她心头一紧。是刚才没有保存成功嘛?她回到店铺商家后台页面,那里显示价格的确已经改成了 800 卢比。于是她回到店铺页面,但是价格却显示仍为 1000 卢比。她有点被搞晕了,甚至会有点恐慌。是她的店铺出问题了嘛?她的顾客会因为错误的价格而多付了钱?她只好一遍又一遍刷新页面。1000 卢比。仍是 1000 卢比。在经历了 5 秒疯狂的页面刷新之后,价格终于变成了正确的 800 卢比。

Priya 刚经历的这种情况可以被称作旧数据幻影。这让 Priya 成为同步延时的受害者。她的“保存”操作被立马送到了主库写入。但是她的“查看店铺”操作被送到了读库,而此时读库的数据与主库尚未完成同步。

正如前面所述,这并不是代码层面的 bug 。这是我们高性能的新架构内在的一个特性。我们牺牲了实时一致性以换取高可用性。欢迎来到最终一致性的世界。

**深入技术细节:最终一致性**

要明白这个概念,可以先跟大家普遍期待的情形做个对比。
- **强一致性**:这是大家生活中都习以为常的情形。当在银行转账时,余额应该马上在所有分行都得到更新。也就是说写入操作完成后,所有之后的读操作都保证能读到新的数据。默认情况,单数据库单服务器提供的就是强一致性。数据正确性的来源就是唯一的那台数据库。
- **最终一致性**:这是我们身处的分布式系统的情形。系统保证的是在所有写入操作停止之后,所有数据副本*最终*都会完成同步。但不保证完成同步的所需时间。它保证的是一致性一定会达成,但是不一定很快。

这的确是某种折中方案。我们牺牲了即刻的强一致性,换来的是可以同时服务上百万读请求的能力。对于我们 99.9%的用户来说(浏览店铺页面的顾客群体),价格修改延时 1 秒完全可以接受,甚至大部分人都不会注意到这些延时。但对于剩下的 0.1%的提供数据修改的用户(像 Priya 那样的卖家)来说,1 秒的延时却是不爽或者完全无法接受的体验。

我们无法根除同步延时,因为它来自物理层面的限制。但我们必须得想个法子让用户免受其苦。

**深入技术细节:数据过期策略**

如何解决上述难题呢?既然无法让系统更快完成同步,那就只能让我们的应用变得更聪明些。

策略 1:啥也不做(如果这能被接受)

对于大多数功能,短暂的延时完全不会有任何问题。举个例子,如果我们的管理员后台显示总店铺数,那这个数字比主库最新修改延时 30 秒也没啥关系。这种策略要求主动精确地识别出应用中哪些部分需要强一致性,哪些部分可以容忍最终一致性。

策略 2:写后重读方案( VIP 通道)

这正是我们针对 Priya 面临的困扰所采用的策略。逻辑很简单:对于某个具体的用户,在其完成某次写入操作之后,立刻把后续的读请求也一并发送到主库,虽然这违反了之前我们关于读请求送到从库的规则。

这就类似给了 Priya 一张 VIP 通行证:

1. Priya 点击保存项链的新价格:发送写入操作到主库。
2. 我们的应用此时成功完成了这次价格修改的写入,然后会在 Priya 的会话中设置一个临时的标志位(类似浏览器的 cookie )表示:“该用户在接下来的 60 秒窗口期内享受 VIP 待遇。”
3. Priya 改完价格之后马上刷新店铺页面:读请求发送至 dukaan 应用。
4. 我们定制的数据库路由服务收到了来自 Priya 的读请求,并发现会话中包括存在关于“VIP”的标志位。
5. 于是数据库路由会把这次请求直接发送到主库,而不是像对待其它用户那样发送到只读从库。
6. 因为主库总是拥有最新的数据,这次读请求会返回正确的 800 卢比。Priya 能够立马看到修改后的效果,这样她对 dukaan 的信心就不会因为最终一致性导致数据延时而削弱。
7. 一分钟之后,VIP 标志位在 Priya 的会话中失效。下一次读请求会如往常一样发送至从库,此刻数据同步应该早已完成。

这种方案让我们能够同时享受两种一致性模型的优势:提供给普通用户的始终可用,以及提供给关键用户的强一致性。

**第 5 章关键知识点总结**

- **用只读从库拓展数据库是性能巨大提升,但不是没有代价**:为了最终一致性(技术实现更复杂)牺牲了强一致性的简洁。
- **同步延时是物理层面的现实,并不是 bug**。但主库和从库之间总会有延时。这个延时没法根除,只能从应用层面尽量降低影响。
- **最终一致性可能会导致让人不爽且疑惑的体验**。如果在用户修改之后刷新页面仍然看到修改前的旧数据,那这可能会严重损伤对产品的信任。
- **实施一个兜底的“写后重读”策略**。对于刚完成数据修改的用户,可以临时把相关读请求直接发送到主库。这可以让关注实时一致性的用户得到强一致性的体验,也不会牺牲只读从库的可用性。
第 5 章第 2 节:保安和 VIP 通道

问题已足够清晰。我们的图书馆只有一个通道,海量的读者洪流在仅有通道的中排起了长队,导致作家们无法及时完成新书的注册登记工作。解决方案是新开设一个专用通道。这样我们的作家们就有了专属的 VIP 的通道,前来借阅的芸芸大众也会有更宽敞的读者大厅。
在数据库技术架构中,这种策略被称作数据同步。
深入技术细节:数据库同步机制
正如字面含义,同步就是为同一个数据库创建并维护数据副本的过程。相比之前我们只有总包总揽的唯一数据库,现在我们有了一组各司其职的数据库集群。我们采用的正是最常见的一种同步形式:主-从同步。
让我们暂时抛开图书馆的类比,设想一个网红酒吧的场景。
数据库主库:VIP 厅
主库就类似酒吧里只服务尊贵 VIP 的专属区域。所有数据的正确性以主库为准。

主库会处理所有写入操作( INSERT ,UPDATE 以及 DELETE )。酒吧状态的任何改变,比如新抵达了一位 VIP 顾客,在场的客人点了一杯酒,或者有客人离店了都需要通过主库来完成。就好像 VIP 厅口站着一位很严格很干练的保安,它会确保所有状态改变都是符合规则且被如实准确地记录在案。
对 Dukaan 来说,主库就是卖家的访问入口。当店家更新商品价格,增加某种新商品,或者删除某样下架的商品,这些请求都会直接发送到数据库主库。这些操作都十分重要,所以会优先通过没那么拥挤的专享主库来完成。

只读从库( Slave ):酒吧大厅
只读从库就如同酒吧里服务普通顾客的大厅。从库是 VIP 厅(主库)所有改变的完美即时复制,允许所有人随时访问。

从库只允许读操作( SELECT )。成千上万的顾客可以同时出现在宽敞的大厅里,享受音乐,欣赏周围的一切。他们可以看到 VIP 厅的活动,但是无法做出任何“写入”操作。
从库的任务就是承载巨大的读取流量。由于有了从库帮助承担那些只是“随便看看”的读请求,主库的资源就被解放出来得以去完成重要的写入操作。如果流量实在太大, 甚至我们可以同时拥有多个只读从库,就如同多个大厅。

我们需要的架构升级就是如上所述的职责分离。这样可以让我们单独地拓展数据库读取和数据库写入操作。
深入技术细节:实施数据库同步
理论听上去非常不错,具体应该如何操作呢?酒吧大厅里的人们该如何即时得知 VIP 区域发生的事情呢?
PostgreSQL 的流式同步机制
PostgreSQL 内置了超级好用的流式同步功能。

WAL (预写日志):我们的主库( VIP 厅)有一位勤勉的保安,它在一本特殊的日志中事无巨细地记录下了所有发生的变化。来了一位新顾客?记下来。价格变化了?记下来。这样一本日志被称作预写日志( WAL )。本质是按时间排序且实时变化的数据库完整修改记录。
数据流:我们建立一个只读从库把它配置成连接到我们的主库。从库的第一条指令便是“订阅 WAL”。然后主库开始像打开的水龙头一样哗哗地把预写日志里的每一条记录通过安全的专属网络连接实时复制到从库。
从库更新:制度从库收到主库发来的数据修改之后,会严格按照原有顺序一条不差地复制到自己的数据副本上。

以上流程的结果便是从库近乎完美地镜像了主库。就好像 VIP 厅的活动通过视频流转播到酒吧大厅巨大的屏幕上,让所有人都能实时看到。
更新 Django 应用以便使用只读从库
设置好数据库同步只完成了任务的一半。我们 Django 应用代码还不了解发生了什么。它现在仍然只能够与一个数据库通信。我们需要让它变得更聪明,就像酒吧保安那样决定谁能进 VIP 厅以及谁只能去大厅。
我们的应用代码需要若干主要的改动:

配置多个数据库连接:我们首先需要在 Django 的配置里从一个数据库连接增加到两个数据库连接:默认的( default )连接至主库,只读连接( read_replica )至新创建的制度从库。
创建一个数据库路由:接下来我们需要定制一个“数据库路由”。这是 Django 中一段特殊的代码,它负责拦截每一个数据库请求然后决定把该请求送往哪一个数据库。逻辑很简单但至关重要:

# 我们数据库路由的简化版本
class PrimaryReplicaRouter:
def db_for_read(self, model, **hints):
# 所有只读操作都送到从库
return 'read_replica'

def db_for_write(self, model, **hints):
# 所有写入操作都送到主库
return 'default'

有了数据库路由,现在我们的应用代码变得更聪明。每次当用户加载店铺页面(触发一系列 SELECT 数据查询),路由代码会把这些流量都发送到强大的只读从库。当卖家点击按钮保存新商品时(触发 INSERT 或者 UPDATE 写入操作),路由代码会把该写入操作发送到受到严密访问控制的主库。
这些改变实施之后的效果立竿见影且显著。商铺页面瞬间能完成加载。店家们反馈保存信息修改也很快。我们主库的 CPU 和输入输出负载恢复了正常。我们成功地拓展了数据库。
不可能三角:CAP 理论
数据库同步实施之后,效果就如同魔法。主库负责写,从库负责读,整个酒吧的人流瞬间变得通畅无比。卖家再也不必因为突然一波顾客流量暴涨而拖慢修改商品名录而头疼。顾客们也可以随意浏览店铺页面,而无需因为页面无法加载反复刷新。看上去我们找到了完美的系统。
但分布式系统的完美从不是免费的。
计算机行业里有一个很经典的理论,之前曾被我忽视,但现在成了我每天都必须面对的:CAP 原理。
CAP 表示一致性( Consistency ),可用性( Availability ) 以及 分区容忍( Partition olerance )。CAP 原理意为,对于分布式系统,上述 3 项性质只能 3 选 2 ,不可能 3 项同时满足。

一致性意味着酒吧所有人都能同时看到同一个状态。比如 VIP 厅更改了背景音乐歌单,那大厅的人们也能立刻听到相同的新歌曲。
可用性意味着酒吧永不打烊。无论发生什么,任何顾客任何时候来到酒吧都能得到应有的服务,即所有请求都可以得到某种响应。
分区容忍意味着即使走廊被阻塞,酒吧也可以继续营业。又或者连接 VIP 厅与大厅之间的音响设备有一些小故障,但是不影响酒吧接着奏乐接着舞。

关键在于:现实世界中分区必然存在。有时候是网络中断,网络包丢失,光缆损坏等等。所以在分区存在的情况下,现实的分布式系统必须要在一致性和可用性中 2 选 1 。
当我们引入只读从库时,虽然我们可能尚未意识到,但实际我们已经做出了选择。酒吧大厅(从库)在即使与 VIP 厅(主库)不同步的情况下仍然会继续保持营业,为众多顾客提供服务。结果就是大厅显示的可能是过时的信息。
具体例子
比如一个卖家在 VIP 厅(主库)修改某款裙子的价格:从 1000 卢比降到 800 卢比。主库马上记录下这条修改。

如果下次请求直抵主库,顾客将看到修改后的数据:800 卢比。
如果请求在数据同步复制之前就到达从库,顾客看到的仍是旧的价格:1000 卢比。

这两个价格可能都算是“合理”的,取决于所处是 VIP 厅还是大厅。但是从卖家角度看,这显然有问题。价格刚才已经被修改了,为啥店铺仍然显示旧的价格?
CAP 为什么重要
CAP 不只是书本上遥远的理论,它是添加从库,分发数据或者跨区域同步时必然需要处理的隐型不可能三角。当我们开始拥抱数据同步,我们就一定会碰到某些读取会与之前的写入没有及时同步。这不是代码错误( bug )。CAP 理论提醒我们分布式系统里没有银弹,为了某些目的却总得选择一款毒酒喝下去。
一致性的阴影
如果你认可 CAP 理论,下一个问题来了:如果不能同时满足 CAP 的 3 项,那我们所需的一致性到底是什么?实际答案可能不止一种。分布式系统的一致性要求各有不同,每一款分布式软件也都依赖各自设计哲学侧重做出了不同的选择。
以下是常见的 3 种类型:


强一致性
这是人们直观上最容易理解的情况。如果卖家把价格改成了 800 卢比,那么此后任何读取,无论请求发送至哪个服务器(主库或者从库),都必须返回相同的 800 卢比。
在酒吧的类比中,VIP 厅的 DJ 一旦修改背景音乐曲目,大厅里的人们也会立刻毫无意外地听到相同的新歌曲。
强一致性让人感觉一切有条不紊,但代价是可用性。如果 VIP 厅和大厅之间的联络即使短暂被阻塞一小会,整个酒吧 也会宁愿暂停营业,也不想让人们听到“错误”的歌曲。


最终一致性
这种情况就是只读从库发挥的场景。VIP 厅的变化会尽快传播到大厅,但也许会稍有延迟。如果倒霉的话,可能会先仍听到一段旧歌曲的旋律,最终才能听到新歌的响起。
从用户角度看,这也许令人困扰:刚明明已经保存了新数据修改,但是店铺页面仍然显示的旧数据。虽然一段时间之后,最终所有数据同步都会完成,所有显示都会一致,但是这个“一段时间”可能是 1 秒,也可能是 5 秒,无法准确得知。


因果一致性
这是一种试图维护因果顺序的中间方案。比如某个名叫 Priya 的卖家给她家卖的项链降价了然后查看自家店铺页面,因果一致性可以保证她自己是能够马上看到降价之后的价格,但是其它用户却不一定能马上看到。
在酒吧的类比中,如果 DJ 修改了播放曲目,VIP 厅里见证这个改动的人们都能立马听到新曲目,但是大厅里的人们却不一定能即刻同步听到。
虽然因果一致性不保证完美地全局同步,但它保证“我修改了某项数据,我立马能看到修改后的结果。”


选择适合的方案
不同的系统做出的选择各有差别。银行系统需要强一致性,比如顾客肯定不愿意看到某个分行显示余额有 10000 卢比,但是另一家分行显示余额只有一半。社交网络应用可能更倾向最终一致性,比如给你点赞的计数延时了几秒钟,大家都能接受。因果一致性在面向终端用户的应用中越来越流行,因为它考虑到了用户个人即时的期待从而做出了一定平衡。
对于 Dukaan ,我们通过只读从库建立了一个实现最终一致性的系统。这就是卖家 Priya 有时候看到旧数据的原因。这并不是 bug ,而是教科书上关于最终一致性的经典场景。
但如同每一个改进,新架构引入了一个新的不易察觉的潜在危险副作用。
新问题:同步延时
主库到从库的同步数据流虽然很快耗时很短,但的确不是“瞬间”。一般总会有毫秒级别的延时。在高负载情况下,延时甚至可能飙升到 1 秒或者 2 秒。这个延时被称作同步延时。
这意味着大厅的人们接受到的 VIP 厅状态变化比实际发生总是晚了一点点( 1 秒之内)。
这会导致一系列潜在的令人困惑的问题。比如当店家把某款商品价格从 100 卢比降到 90 卢比(送往主库的写操作)之后马上刷新店铺页面(发送到读库),结果看到仍是修改前的 100 卢比,因为此时数据同步尚未完成。这种情况怎么办?
这就是最终一致性导致的令人困惑的危险局面。
第 5 章:数据库俱乐部的保镖:只读副本

作为创业公司,当用户数从几千达到 10 万级别的时候,业务重心会发生显著变化。早期主要关注的是冷启动获客以及确保刚上线的服务能正常工作。出现的问题也多半是显而易见的,比如服务器宕机,网站应用崩溃了。解决方案也比较简单粗暴:重启服务器,或者加钱买更好配置的服务器。

但是当用户数达到 10 万的里程碑之后,新类型的问题就会出现。肉眼可见的火情会被缓慢悄无声息的发热取代。系统可能不会突然崩溃,但是可能会逐渐力不从心,变得愈发迟缓。问题重心不再是有没有顾客用,而是如何提高性能满足更多用户的需求。所以相应的解决办法不再那么简单粗暴,需要更多如外科手术般的精准下药。就像盖房子,此时不能仅满足于屋里的灯还能亮,而是要开始思考建筑物本身的结构是否足够稳固等更深层的问题。

我们通过水平拓展的服务器集群以及负载均衡技术成功解决了厨房产能的问题。现在我们面临的问题是储藏室/图书馆开始变得非常拥挤以至于甚至让人都迈不开步。

**第 1 节:图书馆里的交通阻塞**

用上负载均衡之后的确很爽。应用层的流量处理充满技术美感。当我们实时监控到流量和应用服务器 CPU 占用率暴涨的时候,我们只需要点击几下鼠标就能增加一个新服务器到集群然后就能看到工作负荷神奇地被自动分配到新加的服务器,然后其它服务器的负载就会降下来。应用服务层面完全可拓展,一切尽在掌握。

我们的用户数很快就彪过了 5 万,接着是 8 万,迅速接近起初难以想象的 10 万大关。每个店铺卖家都拥有各自的顾客群体,意味着浏览 Dukaan 网站的用户人数可能是百万级别的。我们正在服务的流量已经远超预想。

熟悉的恐惧感又悄悄回来了。我们又开始收到来自用户的抱怨,但这次不是关于网站宕机了,而是响应太慢了。

- “我的顾客需要等待 5 到 6 秒才能看到我家店铺”
- “有时候当我保存一个新商品的时候,网页要加载很久才能保存成功。”

这种网页响应迟缓的情况在印度日常工作高峰时间段(上午 11 点至下午 5 点)达到顶峰。我和苏米特目不转睛盯着监控图表。应用服务器看上去表现还行,CPU 的工作负荷分配均衡且极少超过 50%。负载均衡服务工作地很出色。

但我们那台数据库服务器的性能图表却是完全不同的情景。CPU 占用率基本维持在 80%到 90%的高位。硬盘输入输出数据已经爆表,表明硬盘已经达到基线负荷了。曾经扮演我们救星的独立强大的数据库服务器现在正被负载压得喘不过气。我们的图书馆已经满满当当挤满了人,我们的英雄图书馆管理员变得不堪重负。

**确定瓶颈:只是看一看的人数实在太多了**

简单地说“数据库响应太慢了”就如同一位医生说“这个病人确实生病了。”一样毫无意义。这不是精确的诊断,只是表面的观察。如果要实现有效治疗,必须搞清楚具体明确的病因。我们需要深入数据库内部,确认它正为之头疼忙碌的具体任务。

**深入技术细节:区分数据库操作(只读与写入)**

说到底,数据库主要有两种不同的任务类型,理解它们之间的差异非常重要。

1. **写入**:**改变**的数据的操作。主要指令包括 INSERT (增加新数据),UPDATE (修改已有数据)以及 DELETE (删除数据)。
- 类比:可以把这些想象成图书馆管理员调整藏书的工作。INSERT 就是新书到库。UPDATE 就像图书馆管理员修改索引目录卡片上的一个错别字。DELETE 类似从书架上移除一本年代久远且已损坏的旧书。
- 这些操作很关键。必须谨慎处理避免伤及馆藏图书的完整性。通常来说,这些写入需要“某种锁机制”确保不会有两个图书馆管理员同时修改同一个信息。所以一般这些操作相对处理速度不会太快,并且更耗费资源。在 Dukaan 场景里,这些操作对应的就是店家增加商品,修改价格或者顾客下了一个订单。

2. **只读查询**:这是只涉及**读取数据**的操作。主要指令是 SELECT 。
- **类比**:就像一位普通市民走进图书馆想要查阅某本馆藏书籍。它并不会修改任何信息。只是查找书籍,阅读,然后把书籍放回书架。只是信息的消费。
- 这样的操作一般都比写入处理速度更快,也消耗更少资源。对于 Dukaan 来说,这就对应着顾客浏览店铺和商品名录产生的流量洪流。

我们为了确定解决问题的关键方向特意安装了一款分析数据库请求的工具。我们发现我们面对的问题在互联网应用行业十分常见,甚至大家都给这个现象专门起了一个名字。

**95/5 法则(读写差异)**

我们的分析揭示了令人的不平衡状态。在数据库每 100 次请求中:
- 95 次是 SELECT 只读查询;
- 只有 5 次是 INSERT ,UPDATE 或者 DELETE 写入操作。

这其实很符合我们的观察。一位店家每天可能只会更新他们的商品(数据写入)寥寥数次,但是它的店铺会被数以千计的顾客反复查看(数千次只读操作)。我们的系统承载的绝大部分流量都来自只读请求。

**只读操作如何拖累写入操作**

这就是我们面临的问题核心:我们的数据库按照同等优先级处理只读和写入。它只有一个任务队列。

设想我们的图书馆管理员如何面对只有唯一入口以及唯一等待排队队伍的场景。在排队的人群中,95%只是想问“我在哪能这本书?”(这其实是一个很快的只读查询)。但同时剩余的 5%可能是作家群体想要完成新书的馆藏登记,这样的处理请求通常需要填写很多表格并更新庞大的书籍索引(类似更慢的写入操作)。

作家们不得不与常规入馆的庞大读者等在同一个长长的队伍中。简单的数据只读查询请求量实在太大以至于产生了堵塞反而拖累了关键的数据写入请求。这就是问为什么店家保存新商品的时候会感觉很慢很慢,因为他们重要的写入操作已被淹没在成百上千来自匿名顾客对店铺的浏览请求之后,完全得不到被数据库处理的机会。

解决方案也比较明确了。不能让所有人都挤在同一个队伍里。需要为作家们单独创建一个专享专用的通道,同时为进馆借阅的众多读者提供一个更宽敞的空间。意味着我们需要把只读操作与写入操作分离开来。
第 4 章第 3 节:我们的第一位交警


前面讨论的理论已经很完备。我们制定了一个构建服务器集群的计划,还需要一个负载均衡服务来分配流量。现在该撸起袖子把这些理论变成现实了。
首要问题是负载均衡服务该用哪款软件呢?可供的选择有很多:既可以花大钱买专用于负载均衡的硬件,也可以购买由云服务商提供的产品,比如亚马逊的弹性负载均衡( AWS Elastic Load Balancer )。但我们还只是一个预算非常有限的创业小作坊。我们需要的负载均衡服务不仅要强大可靠,最好还能是免费的。
最佳选择已在眼前,就在我们的服务器上。
深入技术细节:Nginx 作为负载均衡
目前我们主要使用 Nginx 作为网络服务器,它是一个称职的服务员,可以效率很高地返回静态文件给用户并把网络请求转发到 Django 应用。其实,Nginx 同时也可提供世界级的负载均衡服务。只需要在配置文件里面加上几行,我们就可以让我们的服务员同时变身为聪明的超市经理。
这对于我们绝对是巨大的好消息。因为我们无需学习然后安装新的复杂软件来实现负载均衡。我们能够继续信任手头熟悉的工具:Nginx 。
实现起来确实非常简单。我在 DigitalOcean 再次购买了一款同配置(每月 5 美元)的服务器。现在我们就拥有了两位可以同时炒菜的厨师。然后我 SSH 登录到我们的域名( dukaan.app )指向的第一台服务器。这台服务器现在多了个角色:作为负载均衡服务器。
我打开 Nginx 配置文件(/etc/nginx/nginx.conf )并添加了两小段配置。
我们的 Nginx 负载均衡配置

# 定义处理应用逻辑的应用服务器集群。这个集群被称为"app_servers"。
upstream app_servers{
# 这就是见证奇迹之处:Nginx 将依据这条配置采用前述的最少连接算法(流量会被送给拥有最少连接的服务器)
least_conn;

# 列出应用服务器集群的内网 IP (出于安全和效率考虑)
server 10.132.2.31; # 我们第 1 台应用服务器
server 10.132.4.55; # 我们第 2 太应用服务器
# 如果需要拓展,这里可以添加更多服务器
}

server{
listen 80;
server_name dukaan.app;

location / {
# 以下这行配置就足以让 Nginx 把所有流量转发到上面我们定义的应用服务器集群"app_servers"
proxy_pass http://app_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

好了。上面配置中的 upstrem 部分定义了我们的应用服务器集群。least_conn;那行指定了我们智能的流量分配策略。proxy_pass 指令让 Nginx 开始做流量分配。保存并重启 Nginx 之后,我们的负载均衡服务就已经上线开始运行了。
架构更新
我们的系统架构再次演进。现在用户流量走向更复杂一些但也更具韧性。

用户访问 dukaan.app 网站。请求到达作为负载均衡的 Nginx 。
负载均衡服务检查两台应用服务器看看哪台当前活跃连接数量更少。
接着请求会被转发给有较少连接的那台服务器(比如 2 号服务器)。
2 号服务器运行着 Django 代码,需要数据来处理分配给它的用户请求。
2 号服务器连接到我们唯一那台数据库服务器读取所需数据。
处理完毕后,响应被原路返回给用户。

如果 1 号服务器宕机了,Nginx 作为负载均衡服务将通过健康检查及时发现这种情况并停止转发流量给 1 号服务器。接下来所有流量将全部转发给 2 号服务器。网站此时仍然在线。系统实现了一定程度的容错性。流量爆发和服务器宕机都能应对自如。此刻我们又感觉膨胀了。
新问题:图书馆开始拥挤了
启用负载均衡之后,网站确实完美地工作了一段时间。当流量增长时,我们也不会很慌。我们只需要购买第 3 台服务器,把它的内网 IP 加到 Nginx 的 upstream 配置里面然后重新启动 Nginx 就好了。分分钟,我们就可以雇佣新的厨师开始干活。
如果有 10 个厨师同时在炒菜,但他们都很焦躁,怎么办?
因为他们都需要食材。他们只能同时跑到同一个储藏室,对着同一个管理员咆哮希望尽快拿到属于自己的食材。
瓶颈再次转变:不再是单台服务器导致的 CPU 处理能力(通过水平拓展暂时解决了),而是上一章拯救我们的英雄,那只单台数据库巨兽。
当一整个服务器集群都在疯狂请求数据时,数据库服务器也开始力不从心。数据库服务器的 CPU 占用率开始攀升,数据库查询速度开始变慢。
我们的厨房已经完成了扩容,但我们的储藏室/图书馆仍然只有一个房间,一个管理员,而且即将过载。
第 4 章关键知识点总结

水平拓展是可以同时实现高可用和大规模的唯一长期技术路径。虽然比垂直拓展更复杂,但是性价比更高,更灵活,并且能消除单点隐患。
负载均衡是实现水平拓展的必需网络交警。它能够为一个服务器集群分配流量并且具备容错能力。
尽量从简单选择入手。强大的 Nginx 可以同时作为网络服务器和负载均衡服务,帮你降低起步的复杂度。
“最少连接”可作为默认的负载均衡算法。相比简单的轮询算法,它能够更公平地分配工作负荷。
技术瓶颈总会变化。刚解决某个性能问题之后,负载就会自然往下一个最薄弱环节聚集(麻绳专挑细处断)。现在我们的应用服务器暂时不再是问题,我们的数据库将燃起新的火苗。
第 4 章第 2 节:网络交警

水平拓展的决策是我们创业路上一个重要的转折点。我们将从单服务器转向集群模式。但是如果没有能有效调动军队的指挥官和指令系统,那再大的集群也没用。我们现在已经拥有了随时可以开工的一群厨师,还需要一个懂得如何为厨师分配顾客点单的首席服务员。
在技术场景中,这位首席服务员,亦或可作为交警的角色,是由负载均衡扮演的,它是整个体系至关重要的一环。
深入技术细节:负载均衡是什么?
其实就按字面意思理解就行了。负载均衡就是前置于应用服务器集群的一组服务,它的唯一工作就是在应用服务器之间均衡地分配请求流量,不会让某个应用服务器承担太多。
对于外部用户来说,负载均衡服务就是要访问的网站本身。所有用户都访问同一个指向负载均衡服务的域名(比如我们的 dukaan.app )。用户完全不知道在那个域名背后可能是一个拥有着 2 台或是 10 台甚至是 100 台服务器的集群正在准备处理请求提供响应。负载均衡就像一扇大门,把厨房里的复杂忙碌都遮挡了起来。
更好的类别是,负载均衡服务就像繁忙超市里的当班经理。
设想顾客已经排起了长龙(网络流量)等待付款。如果结账柜台只有一个工作人员,那排队的人很快就会越来越多。顾客会因为等待变得越来越焦躁,结账的工作人员也会焦头烂额。
现在超市经理决定再新开四个结账柜台(类似我们的应用服务器集群)。但顾客不是随机挑一个柜台去结账,我们聪明的经理会站在柜台前,积极地引导队伍里下一个顾客去到当前空闲的结账柜台:

“您好先生,请到 3 号柜台结账。”
“您好女士,柜台 1 现在空出来了,您可以到那边结账。”

可以看出来,这位经理就扮演了类似负载均衡的角色。他们的工作都是确保每一个结账柜台的工作人员不会超负荷,也不会有人因为没有顾客前去结账而无所事事。他们为客流削峰填谷,保证系统高效运行。负载均衡同时也负责健康检查。如果某个工作人员突然晕倒(类似服务器宕机),经理会立刻停止给他再安排结账顾客,而是会把顾客引导去其他正常工作的柜台。这样系统就能保持稳定的运行。
深入技术细节:负载均衡算法
超市经理需要一套规则(或者说某种策略)来决定下一位顾客前往的结账柜台。在负载均衡的世界,这些规则被称为算法。有的算法可能非常复杂,我们这里只需要搞明白两种最常见的类型:

轮询( Round Robin )分配:简单而蠢萌的方法

这是最基本的负载均衡算法。也很容易按字面理解:简单的循环分配请求给所有的服务器。

第 1 个请求分配给服务器 A 。
第 2 个请求分配给服务器 B 。
第 3 个请求分配给服务器 C 。
第 4 个请求再次分配给服务器 A 。
。。。以此类推。

就像给一群玩家发扑克牌。每位玩家都会轮流得到一张牌。

优点:超级简单,基本无需负载均衡服务额外思考。
缺点(蠢萌的部分):轮询假设每一个请求都大差不差,每一个服务器也都有类似的配置。但如果发送到服务器 B 的第 2 个请求是耗时 10 秒的复杂任务,而其它请求都是耗时 1 秒的简单任务呢?轮询可不管这些。即使此时服务器 B 仍然正在为之前那个复杂的任务忙碌,它仍会继续把第 4 个和第 5 个请求发给服务器 B ,而全然不顾服务器 A 此时可能完全无活可干。这会导致服务器之间工作负荷的分配不均。


最少连接( Least Connections )分配:更聪明的方法

这是一种更聪明的动态算法。负载均衡服务会实时监控集群中每一个应用服务器当前有效连接的数量。当新的网络请求到达时,负载均衡服务会把它送到当前连接数量最少的服务器。
这就像超市经理学聪明了,不是简单地按照编号告诉顾客该去哪个柜台结账,而是会根据排队人数多少给顾客挑一个人数最少的柜台。

优点:这种方法天然考虑到了有些请求会处理得比其它请求慢一些的情况。如果某台服务器正忙着处理复杂的请求,它会同时维持更多的用户连接,所以负载均衡会暂时不考虑继续给它新增任务,以便它尽快完成手头的活。这让工作负荷的分配更加公平也更有效率。
缺点:负载均衡的工作会稍微复杂一些,因为它现在不只是傻傻地按照服务器列表做轮询,而是需要时刻关注每个服务器的连接数量。

对于 Dukaan 网站来说,我们的选择已然明了。“最少连接”算法是更聪明更健壮的办法,能够更好地应对我们不可预知的用户流量。
现在理论部分已经清楚了。我们的网络交警有了一整套分配规则。是时候付诸实际了。我们需要一个合适的负载均衡工具并用它来管理我们全新的服务器集群。
第 4 章:网络世界的交警:走进负载均衡

第 1 节: 崩溃的厨房
在创业旅程中解决第一次重大危机之后的一段时间通常比较暗流涌动,往往预示潜在的危险。
数据库分离之后,我们的系统运行良好,忙碌而有序。应用的响应体验很快,服务器也很稳定,以至于我们竟然产生了某种错觉:我们的架构足以应对未来可能出现的业务量爆发。就好像我们成功为网站的基础架构实施了开胸手术,让它起死回生。甚至是妙手回春。
我们的日常工作也从慌乱的紧急救火转向充满乐观的系统巡视。我们可以实时看到用户数量在攀升,关注服务器的负载图表(系统压力令人喜悦的正常且运行稳定),这些都让我们感到颇为满足。我们成功构建了一套真实有效能解决用户痛点且能实现业务增长的电商系统。我们似乎在某一瞬间觉得自己所向披靡。
不出意外的是,出乎我们意料之外的下一场危机悄然开始。
这次不是之前那样缓慢的性能下滑,而是一次突然的爆发。苏拉特市( Surat )的一位售卖精美手作纺织品的网红店家,在一个人数众多的脸书( Facebook )聊天群里分享了她家的 Dukaan 店铺链接。与此同时,还有一位技术博主也撰文提到了我们的公司。这两次曝光合力形成了一次完美的增长风暴,让我们体验了从未见过的海啸般的流量。
我的手机开始发出熟悉的警报嗡嗡声。这次不是苏米特打来的电话,而是来自我们的服务器监控系统。“应用服务器 CPU 占用率超高。”其中一条警报显示。一分钟之后,另外一条警报显示了相同的内容:“危险:CPU 占用率已持续 5 分钟达到 100%。”
几乎同时,我还没得及打开笔记本电脑,苏米特的短信就发过来了。短信内容简短,熟悉的沮丧溢于言表。
“发生什么了?”
我马上 SSH 登录进服务器。手指飞快敲下我最信任的诊断工具:htop 。我首先检查的是数据库服务器。看上去一切正常。CPU 占用很低,内存使用也没正常。我们新建的图书馆表现安静从容,井井有条地处理着接收到的所有书籍(数据)查询请求。
接下来我继续检查应用服务器。然而这里就完全是个血流成河的场景了。CPU 占用率一直处于 100%。进程列表放眼望去,已被 Gunicorn 的工作进程几乎占满,它们面对如洪水般涌入的用户请求,正陷入不断重试然后失败再重试的恶性循环。应用服务器已经完全过载了。虽然还没宕机,当时已经几乎毫无响应。对于外部用户来说,Dukaan 网站再一次崩溃了。
确认瓶颈:独自面对 1000 位顾客的主厨
原因很明显:我们的厨房扛不住了。
延续前面几章的类比,我们之前的优化是在厨房边新建了一个现代化的储藏室/图书馆。这样我们的主厨不再需要担心储藏室的管理难题。现在的问题是,有 1000 名顾客同时挤进了餐厅,每一个都高声叫嚷着希望主厨做自己点的菜。
我们唯一的主厨(应用服务器)尽管此时已经因为数据库的分离变得更加高效,但面对 1000 名顾客的同时点餐,仍然力不从心。现实世界中,一位厨师同时能准备的菜肴显然是很有限的。对于我们的应用服务器来说,它已经触碰到了自身的能力天花板。点餐的排队甚至都要排到了餐厅门外,整个餐馆不得不暂停取号。
很明显我们需要更多的厨师。怎么办?这就引出了每一个业务快速增长的公司都需要面临的关键抉择。这是方向迥异的两条技术路径:垂直拓展或者水平拓展。
深入技术细节:垂直( Vertical )与水平( Horizontal )拓展
当服务器不堪重负时,通常有两个选择。

垂直拓展(性能升级 Scaling Up )

这可能是一种最容易想到的办法。如果厨房动作太慢,那就把厨师换成一个能以两倍效率工作的世界顶级大厨。
对于服务器而言,就是性能升级:在 DigitalOcean 上面点击关闭当前服务器的按钮。然后选择一个更强大的付费套餐:用 8 个 CPU 加 16GB 内存取代 2 个 CPU 和 4GB 内存。完工,现在应用就运行在一台性能猛兽上了。就好像把家用小轿车换成了一辆超大型的卡车巨兽。

优点:操作很简单。无需更改代码或者架构。只需要多花点钱就可以让问题大事化小。
缺点:这个方案有 3 个不足:

很容易导致开销飙升。两倍性能的服务器的价格不一定是两倍。有可能是 4 倍或者 8 倍。价格往往指数增长。
这种方案上限不高。服务器的性能总有极限,不可能无限升级。最终云服务商所能提供的最强大最贵的服务器也无法满足需求。那到时该怎么办?没有更大的巨型卡车可供选择了。
再强大的服务器也是一个单点( Point of Failure )。这是最关键的不足。虽然拥有一台看上去足够强劲也足够贵的服务器,但如果它发生硬件故障,或者因为系统安全补丁需要重启,整个业务都跟着下线了。这就像整个餐厅都完全依赖唯一的一位超级大厨。如果大厨生病了,餐馆就得关门歇业。




水平拓展(服务器集群 Scaling Out )

这个方法虽然不如前者那样容易想到,但却是更加强大的一种选择。与其重金重新聘请一位超级大厨,不如保留当前的厨师然后再请 3 位类似水平的厨师。这样让他们同时一起工作也能加大厨房的产能。
对于服务器而言,这就是集群策略:用一个小服务器的规模集群替代单一的大服务器。也就是用 4 辆常规的家用轿车替代巨兽卡车。

优点:

性价比很高。小型服务器的硬件通常不贵且易于替换。增加同配置的小型服务器成本可控,不会很离谱。
理论潜能不设限。如果需要更多处理能力,再增加一辆小轿车就行了。可以轻松地从 4 台服务器拓展到 40 台,甚至 400 台。横向拓展的系统就天生适合如此。
容错率高。这是水平拓展的超能力。即使有 1 位厨师因病回家(一台服务器宕机),剩下 3 位也能继续工作。这样虽然出餐会慢一点,但至少餐馆不至于关门歇业。单点隐患不复存在。


缺点:系统会变得更加复杂。比如当有服务员拿着顾客新点的单来到厨房时,那这个菜该交给厨房里四位水平相近的哪位厨师来处理呢?这个决定该如何做出?

我们的选择显而易见了。垂直拓展更像一个创口贴式的临时方案。它不适合作为长期的技术选择。我们希望建立的是可以服务数百万用户的公司,所以我们需要一个能够随着业务增长而演进的技术架构。水平拓展是我们的必修课。
既然决定了,那就得开始打造我们自己的服务器集群。也就意味着我们需要解决新架构带来的新问题。我们需要一个能够在厨师团队中高效合理分配顾客订单的新系统。
我们需要一个流量交警:负载均衡。
第 3 章第 4 节:分叉口-我们为什么坚持使用 SQL

如前所述,我们成功实施了数据库与应用服务器的分离。PostgreSQL 数据库现在拥有专属的强大服务器,不再与应用代码逻辑挤在一起。这对于关系型数据库而言是一个经典的拓展升级。
但在现代技术环境中,一个因此自然而生的问题是:为什么一定要用传统的关系型 SQL 数据库呢?换言之,为啥不考虑更快,水平拓展更容易的 NoSQL 数据库呢?比如我们常听到的一些流行选择:MongoDB 或者 Cassandra 。
这其实是我们刻意的技术路线选择。要明白其中原因,需要搞清楚数据库世界里的两个基本哲学。
深入技术细节:两个数据库的平行宇宙
我们有两个平行的数据库世界:SQL 以及 NoSQL 。这两个类别都包罗万象并且十分强大,但他们内在的运行逻辑迥异。

SQL 的宇宙(关系型数据库):
这个宇宙中包括广为人知的 PostgreSQL ,MySQL 以及微软的 SQL Server 等。


类比:SQL 数据库就像一个清晰明了的 Excel 文件,其中包含了多个互相关联的表格。
核心概念:结构和一致性。数据存储在有着严格类型和列定义的表( Table )中,比如一个 price 数据列必须保存的是数字类型,一个 created_at 数据列必须保存的是时间类型。各表之间的逻辑关系被严格约束。所以不可能出现一个商品属于一个不存在的店铺的情况。
超能力:ACID 原则。这是一整套确保业务逻辑绝对正确的可靠保证,包括原子性( Atomicity ),一致性( Consistency ),隔离性( Isolation )以及持久性( Durability )。在电商语境里,意味着如果顾客下单买了 5 样东西,数据库要么(成功情形)会在顾客订单记录中完整保存 5 样已购商品并且更新相应商品的库存,要么(失败情形)就干脆什么也不记录。绝不会出现数据前后不一只保存了某个部分的情况。
最佳场景:任何对数据完整性和一致性有着苛刻要求的应用。这显然包括电商,银行,金融系统或者任何中介预定平台。


NoSQL 的宇宙(非关系型数据库):
这个宇宙会更加多元化,主流的选择包括 MongoDB (文档型),Cassandra (超宽数据列),Redis (键值对)以及 DynamoDB 。


类比:NoSQL 数据库就像一个文件目录,其中塞满了各式各样的 Word 文件或者 JSON 文件。每个文件都可能拥有完全不一样的内容结构。
核心概念:灵活性和可拓展性。NoSQL 对于要保存的数据不做任何假设。比如某个商品的文档里面可能包含“颜色”这个字段,但并不是每个商品文档都有这个字段。这样的特性有利于在不做数据约束关系调整的情况下修改应用的功能。通常这些 NoSQL 从设计之初就适合做水平扩展(可以运行在多个廉价的服务器上)。
超能力:BASE 以及水平拓展能力。大部分 NoSQL 不强制严格的 ACID ,而是提供一种成为 BASE 的替代:常可用( Basically Availabel ),弱状态( Soft State )以及最终一致性( Eventual Consistency )。这意味着这类系统优先考虑的是系统可用,而不是时时刻刻的一致性(这个概念将在后面提及只读副本时继续讨论)。这有助于 NoSQL 以超高的数据写入速度进行海量数据的处理。
最佳场景:海量数据处理,社交媒体的信息流,物联网的传感器数据,实时的数据分析或者那些数据约束关系经常变化的应用。

快速总结一下 SQL 和 NoSQL 的不同点:

特性 SQL ( PostgreSQL ) NoSQL (比如 MongoDB )
数据模型 结构化的(数据表/一行行的数据) 灵活的(文档类型,键值对类型)
模式( Schema )需要提前定义好,严格遵循 动态可变的
拓展性 垂直拓展为主(更强大的服务器),可以采用只读副本 水平拓展为主(更多数量的服务器)
一致性 强一致( ACID 约束) 可调的最终一致性( BASE )
最佳场景 电商,金融等对数据一致要求严格的系统 社交媒体,大数据,物联网,分析类

我们为什么选择 SQL 路径
看看上面这张表,Dukaan 应用适合的技术选择一目了然。

我们的数据是高度结构化的:比如每个订单都有一个顾客,已购商品清单,以及一个总价。一个商品总会有名字,价格,以及库存量。我们业务逻辑依赖于这样严格的约束关系。我们不需要 NoSQL 的灵活性,而是需要 SQL 的严格一致。
数据一致性至关重要:对于电商平台来说,用户信任的基础就在于每一个订单都能被正确处理,每次库存更新能够被准确无误,每笔付款信息都能正确的显示。PostgreSQL 的 ACID 严格约束对于我们来说是不可获取的必需品。
我们的瓶颈在于读取数据,而不是写入数据:正如即将在第 5 章被探讨的那样,我们最大的挑战并不是需要每秒写入上百万的新增商品(很多数据写入的场景恰好是 NosQL 的主用武之地),而是如何满足数百万对现有商品信息读取的用户请求(这是一个读取数据很多的场景)。PostgreSQL 对于这个问题有一个很棒而且成熟可靠的解决方案:制度副本。

我们并没有海量数据的烦恼。我们面对的是电商领域经典的业务难题。如果我们选用一个流行的 NoSQL 数据库,倒像是不合时宜地杀鸡用牛刀。PostgreSQL 就是那个精准契合我们业务场景的可靠且强大的工具。我们相信采用 PostgreSQL 作为基础数据库足以满足业务增长的需求,甚至应对独角兽级别( 10 亿美金)的业务量也没问题。
第 3 章关键知识点总结

架构扩展的关键第一步是分离应用服务器以及数据库服务器。这样可以让各组件充分发挥潜能,不至于消耗在资源竞争上。
每一个问题解决都可能带来新的问题。转向分布式架构导致网络延时成为了必须考虑的性能瓶颈。
昂贵的网络请求能少则少。减少延时最有效的办法就是提高代码效率以减少并优化数据库的查询。可以尝试使用类似 select_related 以及 prefetch_related 这样的工具。
优化数据库连接。考虑使用类似 PgBouncer 这样的连接池以减少创建数据库连接的重复工作,这样可以帮助应用更具韧性,在高压下表现更稳健。
第 3 章第 3 节:新瓶颈

完成上述数据库迁移后,我们赢得了过去数周内第一次喘息之机。
数据库分离获得了成功。网站现在运行地很稳定,响应也很快,不再需要依赖每隔几小时的服务器重启就能搞定当前常规的新用户请求。厨房和储藏室都拥有了各自专属的空间,整个业务流程运行地很顺畅。我和苏米特很高兴。我们经历并成功挺过了第一次架构拓展升级的危机,得以让我们的网站变得更强大。这为我们的发展赢得了宝贵的时间。
但在创业公司的世界里,时间就是最宝贵的资源。每一次打补丁或者每一个被清除的瓶颈其实都预示着接下来还有其它问题正等待被发现和解决。互联网创业公司的规模拓展就像打地鼠游戏,刚解决了一个问题,另一个马上就冒出了头。
我们的新问题有点棘手。它并不是那种灾难性质的崩溃或者火烧眉毛的服务器问题。它是一种不太令人察觉的慢。即使现在已有两台专属的强大服务器,有些页面访问起来感觉还是有点迟缓。我们解决了资源竞争的问题,但同时也引入了一个新的更复杂的问题:网络延时。
深入技术细节:网络请求的开销
当我们的 Django 应用和 PostgreSQL 数据库同处一台服务器时( localhost ),它们之间的通信瞬间就可以完成。有点像主厨从身后的架子上取某个食材,一个转身就能完成。“往返时延”( Trip Time )无限接近于零。
现在情况有所不同了。我们的厨房(应用服务器)和我们的储藏室/图书馆(数据库服务器)处于两栋不同的建筑中。就像相邻的两栋房子,我们的两台服务器同属班加罗尔的某个数据中心,它们之间通过极速的光纤相连。但无论连接有多快,主厨仍然需要完成以下工作:

暂停手头的活儿。
走出厨房。
穿过即使很短的“过道”走到储藏室/图书馆。
找到图书馆管理员并向它请求某本书(所需数据)。
等待图书馆管理员找到并取回书。
原路穿越“过道”返回厨房。

以上整个过程就是一次完整的网络请求。所花时间被称为网络延时。
对于一次网络请求而言,这个延时可能微乎其微,往往只需 1 到 2 毫秒(千分之一秒)。用户几乎不会察觉。但关键在于:对于一个用户看到的网站页面而言,并不是只需要从数据库往返请求一次。比如,为了给卖家展示店铺页面,我们的代码需要完成以下工作:

获取店铺诸如名字等的完整信息。(一次往返)
获取店铺的全部商品类别。(又一次往返)
获取某个类别的全部商品信息。(再一次往返)
获取下一个类别的全部商品信息。(再来一次往返)
。。以此类推

一个简单的页面加载可以轻松产生 10 次,20 次甚至 50 次往返于数据库的网络请求。在数据库分离之前,这些请求的耗时可以忽略不计。但现在,它们在物理层面有实实在在的开销:50 次网络请求 乘以 2 毫秒/次网络延时 等于 100 毫秒的整体延时。
于是乎,即便不考虑应用服务器和数据库服务器本身处理任务的耗时,光是网络延时就已经达到了 0.1 秒。这成为了我们新的瓶颈。我们得优化一下我们的 Python 代码,让它不要过于频繁地向数据库发出请求。
挑战网络延时:更聪明更少的请求
面对昂贵的网络请求,理想的解决办法就是减少请求次数。相比于每次往返只拿回一样物件,主厨应该带上一张购物清单,这样只用跑一次就可以把全部东西都拿回来。对于 Django 代码来说,这意味着我们需要大幅优化数据库的查询:

N+1 查询难题:我们同样受困于互联网应用开发中最常见的性能杀手:N+1 查询问题。设想一下,你需要获得( 1 ) 10 家店铺的信息以及( 2 )每家店铺排名第一的商品信息。最无脑的代码可能是这样:

先查询一次数据库,获得 10 家店铺的信息数据。
然后每家店铺查询一次获得排名第一的商品信息,即需要查询 N 次(这里是 10 次)。
所以一共需要查询 11 次数据库。这非常低效。


解决方案( select_related 以及 prefetch_related ):幸运的是 Django 有内置的解决方案。它有一个称作 prefetch_related 的功能,我们可以让 Django 这样做:“当读取那 10 家店铺的数据信息时,因为我肯定也需要读取各家店铺的商品数据信息,所以干脆请把店铺的相关商品数据一并取回。”Django 很聪明,它明白这个任务只需要 2 次数据库读取,而不是 11 次。第一次读取拿到 10 家店铺的数据信息,第二次读取就把全部 10 家店铺的所有商品数据信息都拿回来并与前面拿到的店铺数据整合到一起,供我们的应用代码使用。这就如同是我们的“购物清单”。在所有代码中完成这些优化的效果立竿见影,显著降低了网络请求的次数,让整个应用的响应更加迅速。
数据库连接池( PgBouncer ):与此同时每一次数据库请求重新创建一个连接也很耗时。就如同主厨得找到图书馆大门的钥匙,然后拿着钥匙走到图书馆,用钥匙打开大门,取回要找的书,再用钥匙把图书馆大门锁上,最后返回厨房。这样一个过程实在太繁琐了。为了解决这个问题,我们需要一个新工具 PgBouncer 。它提供了一个数据库连接池。可以把它想象成位于厨房和图书馆之间的保安,它来负责进出大门(已提前解锁)的开合。当我们的应用需要找数据库要数据时,只需从 PgBouncer 拿到一个提前准备好的数据库连接就行了。这就避免了哪怕一次很简单的数据请求也得重新建立连接的麻烦事,从而进一步降低了我们整体的延时。
第 3 章第 2 节:迁移计划书

迁移数据库的决定已经做出。接下来就要付诸行动了。此刻感觉就像是站在悬崖边,但不得不往下跳。现在的问题是能不能在跳下去之前弄个降落伞。
我们花了数个小时详细以文字形式记录下迁移的每一个步骤,就如同对飞机起飞前做的航前检查。对于类似这种情况的高风险操作,最好不要临场发挥。提前做好计划,然后严格按计划行事最合适。否则一个小失误就可能是致命的。
架构设计:迁移前和迁移后的对比
目标是从单台不堪重负的服务器扩展成两台各司其职的组合。

迁移前:单台服务器(例如公网 IP:104.248.62.77 )搞定全部:Nginx ,Gunicorn ,Django 以及 PostegreSQL 。
迁移后:

应用服务器 (公网 IP 同迁移前:104.248.62.77 ): 负责运行 Nginx ,Gunicorn 以及 Django 。
数据库服务器(新公网 IP:142.93.218.155 ):只运行 PostgreSQL 数据库。



因此应用服务器不再同处于 localhost (意为在此同一台机器上)的数据库进行通信。现在它需要通过网络连接与新的专用数据库服务器对话。
以下便是我们制定的迁移计划书。如果以后你也需要完成类似的在线数据库迁移,那以下步骤即使看上去很吓人,那也得硬着头皮上。
步骤 1:提前准备好新的数据库服务器
如同搬家之前得先等新房造好,我们迁移数据库的第一步就是弄一台专属的服务器。
所以我们回到 DigitalOcean 的页面准备新建一个水滴( Droplet )服务器。不过这次我们选择的付费套餐跟之前的通用类型有所区别,这次我们特意挑了一个为存储优化过的类型。这种服务器类型拥有更快的 SSD 存储(称之为 NVMe )以及更多的内存空间,显然这是为了数据库的数据输入输出任务量身定制的。这将是我们全新进阶版本的图书馆。
服务器创建好之后,我通过 SSH 连接上去安装唯一需要的软件:PostgreSQL 数据库。其它比如 Nginx ,Python 或者任何其它应用层代码都不需要。这台服务器的职责很明确:好好保管我们的数据就行了。我还加上了防火墙配置,只允许从我们的应用服务器连接到这台数据库。互联网上的其它人就别妄想读取我们的数据了。这如同给予了我们的应用服务器一把特殊私密的进入图书馆的钥匙。
步骤 2:备份数据( pg_dump )
这是最关键的一步。如何复制一个时刻可能产生数据变化的数据库呢?简单地复制数据库文件没用,因为数据库文件在被复制时可能正被修改,移动文件可能导致数据永久损坏。
有一种办法是给数据库状态来一张完美的快照。对于 PostgreSQL 而言,pg_dump 就是这个魔法工具。
pg_dump 是一个可以完整读取整个数据库(包括所有的数据库表,所有数据,所有表之间的关系等等)并生成一个 sql 后缀超大单文件的命令行工具。这个文件包含了从零开始重建整个数据库所需的全部 SQL 指令。
可以设想一下:pg_dump 就是一个神奇的抄写员,他走进图书馆然后读完了每一本书,最后写下一本全新的大部头书籍:“重建图书馆馆藏的指南”。
所以,在我们之前那台不堪重负的服务器上,我运行了以下命令:
pg_dump -U postgres dukaan_prod > dukaan_backup.sql
我紧张地盯着服务器的 CPU 开始飙升。它正在很努力地创建数据库快照。几分钟之后,任务完成了。我们成功地生成了包含整个公司灵魂的备份文件:dukaan_backup.sql 。
步骤 3:传输并恢复备份
现在我们的备份数据已就绪,但是所处位置不对。我们需要把备份文件安全地从旧服务器转移到新的数据库专属服务器。为了完成这个任务,我们需要另外一个命令行工具 scp ( Secure Copy )。
scp dukaan_backup.sql [email protected]:/root/
这条指令将会安全地把我们的备份文件通过网络传输到新服务器。传输完毕后,新图书馆就拥有了完整的重建手册。
现在可以开始重建了。我 SSH 登录到新的数据库服务器,创建了一个新的(空空如也)名为 dukaan_prod 数据库,然后开始执行重建指令:
psql -U postgres -d dukaan_prod < dukaan_backup.sql
这条指令执行的内容与 pg_dump 正好相反。它从大部头的备份文件中一条一条读取指令然后执行。它会创建数据库表,插入数据并重建表之间的约束关系。我盯着屏幕,祈祷不要出错。几分钟之后,重建完成了。
步骤 4:切换
这一步是验证迁移是否正确完成的决定时刻,也是最危险的一步。我们将把 Dukaan 应用的连接从旧数据库切换到新数据库。这不可避免地导致网站会有几分钟不可访问。

启动维护模式:第一步是禁止任何新数据的写入。我们在网站上发布了一个“正在维护”的页面,任何此时访问 mydukaan.io 的用户都将被告知:“Dukaan 网站正在升级,请 5 分钟后再回来。”
增量数据的同步:在我们创建备份文件到此时做切换之间,网站产生了一些新的数据。比如一些新店铺,一些新产品的更新。所以我们需要再次重复上述步骤 2 (备份数据)和步骤 3 (传输并恢复备份)。因为这次网站已经处于维护模式,备份和恢复的速度会更快一些。这样可以确保我们的新数据库是跟原库完全一致的。
更新应用的数据库连接配置:这一步正是实施心脏外科手术的那一刀。在我们 Django 应用的配置文件中有一条关于数据库连接的配置项。类似 HOST:'localhost'这样。现在我们把它指向新的数据库专属服务器的 IP 地址:HOST:'142.93.218.155'。
重启并祷告:数据库新连接的配置保存完毕后,我敲下了重启应用服务器的指令:sudo systemctl restart gunicorn 。那之后的几秒里,我的心紧张地提到了嗓子眼。Dukaan 应用正在重启,然后将试图通过网络第一次向新数据库发起通话。
疯狂测试:Gunicorn 重启之后,我和苏米特开始尝试网页上的一切按钮。能不能登录?没问题。店铺信息能不能正确显示?可以。能不能添加一个新产品?也可以!成功了。数据库连接正确无误。
关闭维护模式:测试完成之后,我们长舒一口气,解除了网站的维护模式。

整个迁移过程中,我们的网站只有 3 分钟不能访问。
分离应用和数据库的升级成功完成。我们的应用现在拥有了自己独立的厨房,我们的数据也安全的保存在专属的图书馆中。用户们几乎立刻就高兴地告诉我们“网站感觉变快了。”我们顺利熬过了第一次重大架构升级。厨房变得更整洁,图书馆也变得井井有条,大厨和管理员现在可以尽情施展,完全不用担心会互相影响。
第 3 章 解耦应用和数据库

第 1 节 上线次日
Dukaan 就这样稀里糊涂的上线了。周末过后,我们在几个小企业主的 WhatsApp 聊天群里分享了指向我们刚发布的 MVP 的网址链接。我们其实并不知道会发生什么。也许会有零星的注册,一些礼貌性的反馈,然后就慢慢被大家遗忘。
但实际发生的事情出乎意料,我们的产品瞬间爆火。
事实证明我们假设完全正确。前文所述的在 WhatsApp 使用 PDF 做生意的问题切实捕捉到了商家们的痛点,他们亟需一个更好的解决方案。而我们构建的简单无脑小工具正好符合他们的需求。网址链接被分享到一个又一个 WhatsApp 聊天群。几天内,我们的用户就从几十个到了几百个,然后是几千个。每个店铺商家加好商品名录之后,会把他们的 mydukaan.io 链接分享给各自的顾客,而那些顾客自己往往也是小企业主。这简直是完美的用户增长。
世上没有比这更美妙的感觉了。每次刷新 Django 的管理员后台都可以看到来自印度全国各地的新店铺被创建出来。我们正实时注视着我们的简陋滑板一步步变成现实。但激动之余,一股不安悄然滋生。
因为 Dukaan 应用正变得越来越慢。
刚开始可以瞬间打开的网页现在需要花好几秒的时间。管理员后台有时候甚至会卡住不动。我们也开始接收到来自用户的抱怨:“网站打不开了,” 或是 “服务器崩了吗?”。我们就如同抓狂的消防员,每隔几小时就得重启一次服务器(这种临时解决办法正在失效)。MVP 的意外火爆正在压垮我们的小小后厨。
终于那一刻到来了。就是本书开头提到的半夜 3 点的电话。服务器彻底崩溃。
那晚是自我们 MVP 发布以来,第一波用户增长的巅峰。那台 5 美元每月承载了我们全部所托的 DigitlOcean 水滴服务器最终不堪重负倒下了。这其实是我们初版架构不可避免的必死终局。
第二天清晨,经历了辗转难眠的一夜之后,我和苏米特再次通话。虽然眼前大火已灭(我们再次重启了服务器),但我们知道这只是权宜之计。几小时之内服务器可能会再次崩溃。
苏米特紧张的说道:“苏巴什,反复重启也不是个事儿啊。“我们得找个更好的方案。到底是啥问题?”
过去几个小时我都在查看服务器的日志,即使眼睛感觉都要看废了也不敢放松从 htop 的输出数据中找出答案的努力。问题根源正浮出水面。
“应该是数据库的问题。” 我回答道,“数据库是导致崩溃的瓶颈所在。”
找到瓶颈:厨房里的鏖战
让我们再来回顾一下第 1 章提到的“只有一位厨师的后厨”比喻。我们的服务器空间狭小,主厨( CPU ),操作台面空间(内存)以及储藏室(硬盘)全都挤在一处。
正如第 1 章所述,当时服务器崩溃之后,我们的分析曾关注到一个关键的细节。主厨( CPU )耗费时间最多的地方并不是在烧菜(执行应用的 Python 代码)。主厨( CPU )正忙着往返于储藏室之间,慌乱地到处翻找或者整理食材(写入数据到数据库或者从数据库读取数据)。
数据库操作如此频繁以至于它们几乎耗尽了原本可用于 Dukaan 应用其它部分的资源。服务员( Nginx )只能拿着顾客的新订单等在门口,但主厨正因为混乱的食材进出储藏室之事而忙得不可开交,甚至连看服务员的一眼的机会都没有。这就是为什么网站变得很慢,最终完全停止了响应。
为了解决这个问题,我们首先需要搞懂一个系统设计中的关键概念:互联网应用的不同组件分工确有所不同。
深入技术细节:互联网应用和数据库的负载
很明显并不是所有分工都有同等的重要性。一个互联网应用主要有两个核心任务:

应用逻辑层面的工作(厨师的操作):这大部分是需要“思考”的工作,主要由 Django 代码实现并由 CPU 负责执行。所以这项逻辑执行工作的瓶颈在于 CPU:比如找到正确的产品用于展示,计算订单的总价,判定用户是否完成了登录。就类似主厨会不停忙着查看菜谱,切配食材,炒制以及试味等等。这个类型的工作需要动作麻利的主厨(一个比较高端的 CPU )以及一个相对较大的操作台面空间(内存)才能比较高效地完成。
数据库层面的工作(储藏室/图书馆):这是典型的仓储和跑腿苦力活。数据库的主要职责就是从磁盘读取数据以及向磁盘写入数据。这项工作的瓶颈在于输入输出的速度。不需要太多“思考”,更倾向于物理世界的信息获取任务。可以设想一位图书馆管理员跑到书架去找某本顾客需要的书籍,或者一个储藏室经理正在往货架上摆放物品。这种类型的工作需要一个高效的储藏室(高端的 SSD 硬盘)以及配套的合理存取体系。

而我们面临的问题就在于我们正在强迫我们优秀的大厨( CPU )还得全职兼任图书馆管理员。好比是要一个大厨在一个繁忙喧杂的图书馆烹饪一份精致的大餐。从书架来回奔命(磁盘读写)让大厨根本无暇顾及他的本职工作:烹饪(执行代码)。这就导致两个工作都没法完成。
解决方案理论上很简单,但是实际操作上却很麻烦。
“我们需要把数据库和网站应用彻底分开。” 我告诉苏米特。“我们需要给数据库一个独立的空间。就像一个规范的图书馆总得有个专职的图书馆管理员。而且我们也得给我们的主厨弄一间独立的厨房。”
这就意味着我们从一台服务器需要扩展到两台服务器。这是我们架构上的第一个重要改进。


服务器 1:应用服务器。这台服务器将专为依赖 CPU 的任务进行优化。它将运行 Nginx ,Gunicorn 和 Django 代码。它只负责进行“思考”。


服务器 2:数据库服务器。这台服务器将专为依赖输入输出的任务进行优化。它将运行我们的 PostgreSQL 数据库。它只负责“记录”。


这是当时我做出的优化方案。一次彻底的解耦。听上去符合逻辑,应该是个正确的选择。但这也意味着我们需要在保持 Dukaan 网站可被访问的条件下进行一次复杂的开胸外科手术。我们需要把存储着我们所有用户,所有商品以及我们创业公司所有数据的完整数据库从一台服务器迁移到另外一台服务器。
如果这个过程稍有纰漏,我们的数据将万劫不复。比如订单数据可能会丢失。我们将辜负数以千计刚刚开始尝试信任我们的卖家。这是个无比巨大的风险。
关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     2852 人在线   最高记录 6679       Select Language
创意工作者们的社区
World is powered by solitude
VERSION: 3.9.8.5 19ms UTC 05:25 PVG 13:25 LAX 21:25 JFK 00:25
Do have faith in what you're doing.
ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86