This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
<audio id="audio" title="21 | 赫赫有名的双刃剑:缓存(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/40/f3e641921aeb81aef153ecf91ac78340.mp3"></audio>
你好,我是四火。
从今天开始,我们将继续在基于 Web 的全栈技术上深挖,本章我们介绍持久层。缓存是全栈开发中非常重要的一环,因此我把它放到了数据持久化系列的开篇。
缓存使用好了,会是一把无比锋利的宝剑,特别对于性能的提升往往是立竿见影的;但使用不好就会严重影响系统运行,甚至因为数据一致性问题造成严重的数据错误。这一讲,我将为你介绍缓存的本质以及缓存的应用模式。
## 缓存的本质
工作中,我们可能每周都会谈到缓存,我们见过各种各样的缓存实现,网上也有各种各样的解释和定义。可是,你觉得,到底什么是缓存呢?
我认为,缓存,简单说就是为了节约对原始资源重复获取的开销,而将结果数据副本存放起来以供获取的方式。
**首先,缓存往往针对的是“资源”。**我们前面已经多次提到过,当某一个操作是“幂等”的和“安全”的(如果不记得的话请重看 [[第 04 讲]](https://time.geekbang.org/column/article/136795)),那么这样的操作就可以被抽象为对“资源”的获取操作,那么它才可以考虑被缓存。有些操作不幂等、不安全,比如银行转账,改变了目标对象的状态,自然就难以被缓存。
**其次,缓存数据必须是“重复”获取的。**缓存能生效的本质是空间换时间。也就是说,将曾经出现过的数据以占据缓存空间的方式存放下来,在下一次的访问时直接返回,从而节约了通过原始流程访问数据的时间。有时候,某些资源的获取行为本身是幂等的和安全的,但实际应用上却不会“重复”获取,那么这样的资源是无法被设计成真正的缓存的。我们把一批数据获取中,通过缓存获得数据的次数,除以总的次数,得到的结果,叫做缓存的命中率。
**再次,缓存是为了解决“开销”的问题。**这个开销,可不只有时间的开销。虽然我们在很多情况下讲的开销,确实都是在时间维度上的,但它还可以是 CPU、网络、I/O 等一切资源。例如我们有时在 Web 服务中增加一层缓存,是为了避免了对原始资源获取的时候,对数据库资源调用的开销。
**最后,缓存的存取其实不一定是“更快”的。**有些程序员朋友对缓存访问总有一个比原始资源访问“更快”的概念,但这是不确切的。那不快,还要缓存干什么呢?别急,请往下看。
针对上面说的对“开销”的节约,你可以想象,每一种开销都能够成为缓存使用的动机。但其中,**有两个使用动机最为常见,一个是 latency延迟**,即追求更低的延迟,这也是“更快”这个印象的由来;**另一个使用动机,是 throughput吞吐量**,即追求更高的吞吐量。这个事实存在,也很常见,但是却较少人提及,且看下面的例子。
比如某个系统,数据在关系数据库中存放,获取速度很快,但是还在 S3 这个分布式文件系统上存放有数据副本,它的访问速度在该系统中要低于数据库的访问速度。某些请求量大的下游系统,会去 S3 获取数据,这样就缓和了前一条提到的数据库“开销”问题,但数据获取的速度却降下来了。这里 S3 存放的数据也可以成为很有意义的缓存即便它的存取其实是更慢的。这种情况下S3 并没有改善延迟,但提供了额外的吞吐量,符合上面提到的第二个使用动机。
另外,即便我们平时谈论的缓存“更快”访问的场景,**这个“快”也是相对而言的,在不同系统中同一对象会发生角色的变化。**例如CPU 的多级高速缓存,就是内存访问的“缓存”;而内存虽然较 CPU 存取较慢,但比磁盘快得多,因此它可以被用作磁盘的“缓存”介质。
## 缓存无处不在
曾经有一个很经典的问题,讲的大致是当浏览器地址栏中,输入 URL比如极客时间 [https://time.geekbang.org/](https://time.geekbang.org/))按下回车,之后的几秒钟时间里,到底发生了什么。我们今天还来谈论这件事情,但是从一个特别的角度——缓存的角度来审视它。
对于地址栏中输入的域名,浏览器需要搞清楚它代表的 IP 地址,才能进行访问。过程如下:
- 它会先查询浏览器内部的“域名-IP”缓存如果你曾经使用该浏览器访问过这个域名这里很可能留有曾经的映射缓存
- 如果没有,会查询操作系统是否存在这个缓存,例如在 Mac 中,我们可以通过修改 /etc/hosts 文件来自定义这个域名到 IP 的映射缓存;
- 如果还没有就会查询域名服务器DNSDomain Name System得到对应的 IP 和可缓存时间。
Linux 或 Mac 系统中,你可以使用 dig 命令来查询:
```
dig time.geekbang.org
```
得到的信息中包含:
```
time.geekbang.org. 600 IN A 39.106.233.176
```
这是说这个 IP 地址就是极客时间对应的地址,可以被缓存 600 秒。
当请求抵达服务端,在[反向代理](https://zh.wikipedia.org/wiki/%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86)中也是可以进行缓存配置的,比如我们曾经在 [[第 09 讲]](https://time.geekbang.org/column/article/141817) 中介绍过服务端包含 SSI 的方式来加载母页面上的一些静态内容。
接着,请求终于抵达服务端的代码逻辑了,对于一个采用 MVC 架构的应用来说MVC 的各层都是可以应用缓存模式的。
- 对于 Controller 层来说,我们在 [[第 12 讲]](https://time.geekbang.org/column/article/143909) 中曾经介绍过拦截过滤器,而拦截过滤器中,我们就是可以配置缓存来过滤服务的,即满足某些要求的可缓存请求,我们可以直接通过过滤器返回缓存结果,而不执行后面的逻辑,我们在下一讲会学到具体怎样配置。
- 对于 Model 层来说,几乎所有的数据库 ORM 框架都提供了缓存能力,对于贫血模型的系统,在 DAO 上方的 Service 层基于其暴露的 API 应用缓存,也是一种非常常见的形式。
- 对于 View 层,很多页面模板都支持缓存标签,页面中的部分内容,不需要每次都执行渲染操作(这个开销很可能不止渲染本身,还包括需要调用模型层的接口而造成显著的系统开销),而可以直接从缓存中获取渲染后的数据并返回。
当母页面 HTML 返回了浏览器,还需要加载页面上需要的大量资源,包括 CSS、JavaScript、图像等等都是可以通过读取浏览器内的缓存而避免一个新的 HTTP 请求的开销的。通过服务端设置返回 HTTP 响应的 Cache-Control 头,就可以很容易做到这一点。例如:
```
Cache-Control: public, max-age=84600
```
上面这个请求头就是说,这个响应中的数据是“公有”的,可以被任意级节点(包括代理节点等等)缓存最多 84600 秒。
即便某资源无法被缓存,必须发起单独的 HTTP 请求去获取这样的资源,也可以通过 CDN 的方式,去较近的资源服务器获取,而这样的资源服务器,对于分布式网络远端的中心节点来说,就是它的缓存。
你看,对于这样的一个过程,居然有那么多的缓存在默默地工作,为你的网上冲浪保驾护航。如果继续往细了说,这个过程中你会看到更多的缓存技术应用,但我们就此打住吧,这些例子已经足够说明缓存应用的广泛度和重要性了。
## 缓存应用模式
在 Web 应用中,缓存的应用是有一些模式的,而我们可以归纳出这些模式以比较的方式来学习,了解其优劣,从而在实际业务中可以合理地使用它们。
### 1. Cache-Aside
这是最常见的一种缓存应用模式,整个过程也很好理解。
数据获取策略:
- 应用先去查看缓存是否有所需数据;
- 如果有,应用直接将缓存数据返回给请求方;
- 如果没有,应用执行原始逻辑,例如查询数据库得到结果数据;
- 应用将结果数据写入缓存。
<img src="https://static001.geekbang.org/resource/image/6c/f2/6c2bb8131481c5b931275f734a393bf2.png" alt="">
我们见到的多数缓存,例如前面提到的拦截过滤器中的缓存,基本上都是按照这种方式来配置和使用的。
数据读取的异常情形:
- 如果数据库读取异常,直接返回失败,没有数据不一致的情况发生;
- 如果数据库读取成功,但是缓存写入失败,那么下一次同一数据的访问还将继续尝试写入,因此这时也没有不一致的情况发生。
可见,这两种异常情形都是“安全”的。
数据更新策略:
- 应用先更新数据库;
- 应用再令缓存失效。
这里,避免踩坑的关键点有两个:
数据更新的这个策略,通常来说,最重要的一点是**必须先更新数据库,而不是先令缓存失效**,即这个顺序不能倒过来。原因在于,如果先令缓存失效,那么在数据库更新成功前,如果有另外一个请求访问了缓存,发现缓存数据库已经失效,于是就会按照数据获取策略,从数据库中使用这个已经陈旧的数值去更新缓存中的数据,这就导致这个过期的数据会长期存在于缓存中,最终导致数据不一致的严重问题。
这里我画了一张图,可以帮你理解,如果先令缓存失效,再更新数据库,为什么会导致问题:
<img src="https://static001.geekbang.org/resource/image/83/b8/837a288bc5cb4ad7c478a37dcce6d4b8.png" alt="">
第二个关键点是,**数据库更新以后,需要令缓存失效,而不是更新缓存为数据库的最新值。**为什么呢?你想一下,如果两个几乎同时发出的请求分别要更新数据库中的值为 A 和 B如果结果是 B 的更新晚于 A那么数据库中的最终值是 B。但是如果在数据库更新后去更新缓存而不是令缓存失效那么缓存中的数据就有可能是 A而不是 B。因为数据库虽然是“更新为 A”在“更新为 B”之前发生但如果不做特殊的跨存储系统的事务控制缓存的更新顺序就未必会遵从“A 先于 B”这个规则这就会导致这个缓存中的数据会是一个长期错误的值 A。
这张图可以帮你理解,如果是更新缓存为数据库最新值,而不是令缓存失效,为什么会产生问题:
<img src="https://static001.geekbang.org/resource/image/59/fb/597af9e088dccf6d1d1c3718cdb708fb.jpeg" alt="">
如果是令缓存失效,这个问题就消失了。因为 B 是后写入数据库的,那么在 B 写入数据库以后,无论是写入 B 的请求让缓存失效,还是并发的竞争情形下写入 A 的请求让缓存失效,缓存反正都是失效了。那么下一次的访问就会从数据库中取得最新的值,并写入缓存,这个值就一定是 B。
这两个关键点非常重要,而且不当使用引起的错误还非常常见,希望你可以完全理解它们。在我参与过的项目中,在这两个关键点上出错的系统我都见过(在这两点做到的情况下,其实还有一个理论上极小概率的情况下依然会出现数据错误,但是这个概率如此之小,以至于一般的系统设计当中都会直接将它忽略,但是你依然可以考虑一下它是什么)。
数据更新的异常情形:
- 如果数据库操作失败,那么直接返回失败,没有数据不一致的情况发生;
- 如果数据库操作成功,但是缓存失效操作失败,这个问题很难发生,但一旦发生就会非常麻烦,缓存中的数据是过期数据,需要特殊处理来纠正。
### 2. Read-Through
这种情况下缓存系统彻底变成了它身后数据库的代理,二者成为了一个整体,应用的请求访问只能看到缓存的返回数据,而数据库系统对它是透明的。
<img src="https://static001.geekbang.org/resource/image/12/fa/124961b9e43e50f2a833b8563f47f0fa.png" alt="">
有的框架提供的内置缓存,例如一些 ORM 框架,就是按这种 Read-Through 和 Write-Through 来实现的。
数据获取策略:
- 应用向缓存要求数据;
- 如果缓存中有数据,返回给应用,应用再将数据返回;
- 如果没有,缓存查询数据库,并将结果写入自己;
- 缓存将数据返回给应用。
数据读取异常的情况分析和 Cache-Aside 类似,没有数据不一致的情况发生。
### 3. Write-Through
和 Read-Through 类似,图示同上,但 Write-Through 是用来处理数据更新的场景。
数据更新策略:
- 应用要求缓存更新数据;
- 如果缓存中有对应数据,先更新该数据;
- 缓存再更新数据库中的数据;
- 缓存告知应用更新完成。
这里的一个关键点是,**缓存系统需要自己内部保证并发场景下,缓存更新的顺序要和数据库更新的顺序一致。**比如说,两个请求分别要把数据更新为 A 和 B那么如果 B 后写入数据库,缓存中最后的结果也必须是 B。这个一致性可以用乐观锁等方式来保证。
数据更新的异常情形:
- 如果缓存更新失败,直接返回失败,没有数据不一致的情况发生;
- 如果缓存更新成功,数据库更新失败,这种情况下需要回滚缓存中的更新,或者干脆从缓存中删除该数据。
还有一种和 Write-Through 非常类似的数据更新模式,叫做 Write-Around。它们的区别在于 Write-Through 需要更新缓存和数据库,而 Write-Around 只更新数据库(缓存的更新完全留给读操作)。
### 4. Write-Back
对于 Write-Back 模式来说,更新操作发生的时候,数据写入缓存之后就立即返回了,而数据库的更新异步完成。这种模式在一些分布式系统中很常见。
这种方式带来的最大好处是拥有最大的请求吞吐量,并且操作非常迅速,数据库的更新甚至可以批量进行,因而拥有杰出的更新效率以及稳定的速率,这个缓存就像是一个写入的缓冲,可以平滑访问尖峰。另外,对于存在数据库短时间无法访问的问题,它也能够很好地处理。
但是它的弊端也很明显,异步更新一定会存在着不可避免的一致性问题,并且也存在着数据丢失的风险(数据写入缓存但还未入库时,如果宕机了,那么这些数据就丢失了)。
## 总结思考
今天我们学习了缓存的本质、应用仔细比较了几种常见的应用模式。在理解缓存本质的基础上Cache-Aside 模式是缓存应用模式中的重点,在我们实际系统的设计和实现中,它是最为常用的那一个。希望这些缓存的知识可以帮到你!
现在我来提两个问题,检验一下今天的学习成果吧。
- 在你参与的项目中,是否应用到了缓存,属于哪一个应用模式,能否举例说明呢?
- 这一讲提到了几种缓存应用模式,你能否说出 Cache-Aside 和 Write-Back这两种模式各有什么优劣它们都适应怎样的实际场景呢
看到最后,你可能会想,不是说双刃剑吗?杀敌的那一刃已经介绍了,可自伤的那一刃呢?别急,我们下一讲就会讲到缓存使用中的坑,以期有效避免缓存使用过程中的问题。今天的内容就到这里,欢迎你和我讨论。
## 扩展阅读
- 文中提到使用 dig 命令来查询 DNS 返回的 IP 地址,想了解更完整的原理,可以参阅 [DNS 原理入门](http://www.ruanyifeng.com/blog/2016/06/dns.html)。
- 文中提到了 HTTP 响应中的缓存设置头,请参阅 MDN 的 [HTTP 缓存](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Caching_FAQ)一节以获得更为细致的讲解。
- 文中提到了乐观锁,不清楚的话,你可以阅读这个[词条](https://zh.wikipedia.org/wiki/%E4%B9%90%E8%A7%82%E5%B9%B6%E5%8F%91%E6%8E%A7%E5%88%B6),以及这篇[文章](https://juejin.im/post/5b4977ae5188251b146b2fc8)以进一步理解。

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="22 | 赫赫有名的双刃剑:缓存(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/1a/bfa68c1b4f60be134c65f1fb999e151a.mp3"></audio>
你好,我是四火。
在上一讲中,我们介绍了缓存的本质和应用模式。今天我们继续讨论缓存,这一讲会结合一些实际项目,谈一谈缓存的使用会有哪些问题,以及缓存框架的一些通用性的东西。
## 缓存使用的问题
既然说缓存是“双刃剑”,那我们就必须要谈论它的另一刃——缓存使用可能带来的问题。
### 1. 缓存穿透
**缓存穿透,指的是在某些情况下,大量对于同一个数据的访问,经过了缓存屏障,但是缓存却未能起到应有的保护作用。**举例来说,对某一个 key 的查询,如果数据库里没有这个数据,那么缓存中也没有数据的存放,每次请求到来都会去查询数据库,缓存根本起不到应有的作用。
当然,这个问题也不难解决,比方说我们可以在缓存中对这个 key 存放一个空结果,毕竟“没有结果”也是结果,也是需要缓存起来的。还有一种缓解方法是使用[布隆过滤器](https://zh.wikipedia.org/wiki/%E5%B8%83%E9%9A%86%E8%BF%87%E6%BB%A4%E5%99%A8)等数据结构,在数据库查询之前,预先过滤掉某些不存在的结果。
还有一种特殊情况也会造成缓存穿透的严重后果。一般的缓存策略下,往往需要先发生一次缓存命中失败,接着从实际存储(比如数据库)中得到结果,再回填到内存缓存中。但是,如果这个数据库查询过程比较慢,大量同一数据的请求像雨点一样几乎同时到来,就会全部穿透缓存,一并落到了数据库上,而那个时候最早的那个请求引发的缓存回填甚至都还没有发生,在这种情况下数据库直接就挂掉了,虽然缓存的机制本身看起来并没有任何问题。
这种问题在某些时间窗口敏感的高并发系统中可能出现,解决方法有这样两种。
- 一种是以流量控制的方式,限制对于同一数据的访问,必须等到前一个完成以后,下一个才能进行,即如果缓存失效而引发的数据库查询正在进行,其它请求就得老老实实地等着。这种方法通用性好,但这个等待机制可能较为复杂,且有可能影响用户体验。
- 另一种方法是缓存预热,在大批量请求到来以前,先主动将该缓存填充好。这种方法操作简单高效,但局限性是需要提前知道哪些数据可能引发缓存穿透的问题。
### 2. 缓存雪崩
**原本起屏障作用的缓存,如果在一定的时间段内,对于大量的请求访问失效,即失去了屏障作用,造成它后方的系统压力过大,引起系统过载、宕机等问题,就叫做缓存雪崩。**
我以前在 Amazon 工作的时候,有个著名的内部分享,介绍了 Amazon 曾经发生的“六大灾难”,其中一大就是缓存雪崩。这个问题发生的时间已经是好多年前了,具体是这样的:有一次 Amazon 机房突然断电,在恢复的时候把网页服务器都通上了电,这时候缓存服务几乎还没有缓存数据,缓存命中率几乎为零,于是大量的请求冲向数据库,直接把数据库冲垮了。外在的表现就是,断电导致网站无法提供服务,短期内访问恢复,随后又丧失服务能力。
事实上,我们也总能看到很多技术报告里面写:平均的缓存命中率能够达到百分之九十多,可以飙到多少多少的 TPS为此可以节约多少多少硬件成本。初看这样的设计真不错但是很容易忽视的一点是这样的数据是建立在足够长的时间以及足够多的统计数据的基础之上的但是在单个时间段内由于缓存雪崩效应缓存命中率可以低到难以承受的地步导致底层的数据服务直接被冲垮。
对于**这种类型的雪崩,最常见的解决方法无非还是限流、预热两种:**前者保证了请求大量落到数据库的时候,系统只接纳能够承载的数量;而后者则在请求访问前,先主动地往内存中加载一定的热点数据,这样请求到来的时候,缓存不是空的,已经具有一定的保护能力了。
好,我们再回到 Amazon 那个问题,当时的解决方法就是我们刚刚讲的第一种——限流。当时整个系统对于单台机器的限流已经做得比较好了,后来工程师一台一台逐步启动,每启动一台机器,就等一会,等到缓存数据填充并稳定以后,再启动下一台,这样最多也就是单台机器的所有请求全部发生了穿透,这个数量就小得多了,数据库也是可以正常负载的。
另外一个常见的缓存雪崩场景是:缓存数据通常都有过期时间的,如果缓存加载的时间比较集中,那么很可能到了某一时间点,大量的缓存就会同时过期,于是对应这些数据的请求全部落到了后面的数据库上,从而造成系统崩溃。这个问题解决起来也不难,那就是避免缓存集中写入的时间,如果无法避免,就使用一个范围随机数来均匀地分散过期时间,从而打散缓存过期对系统造成的压力。
### 3. 缓存容量失控
刚工作不久的时候,我参与做过这样一个系统,用户的行为需要被记录到数据库里,但是每条记录发生的时候都写一次数据库的话开销就太大了,于是有同事设计了一个链表:
- 用户的行为首先会被即时记录到内存链表里面去;
- 每 10 分钟从链表往数据库里面集中写一次数据,然后清空链表内的数据。
看起来这就像是我们在 [[第 21 讲]](https://time.geekbang.org/column/article/156886) 中讲到的 Write-Back 模式,看起来也确实可以实现需求。可是,上线没多久系统就挂掉了。那么,这样的设计有什么问题呢?
- 清空链表数据是使用时间条件触发的任务来完成,**通过时间因素来限制空间大小,远不如通过队列长度来限制空间大小来得可靠。**换句话说,如果这 10 分钟内事件暴增,链表就很容易变得非常大。**这个变化范围取决于请求的上限,而不是在缓存系统自己的掌控中。**
- 清空链表的任务,如果在执行的过程中出现了异常,甚至仅仅是处理速度受到阻塞,那就会直接导致链表数据无法得到清空,甚至越积越多。实际上,**链表清空数据并写入数据库是一个耗时的异步行为,这是另一个受控性较差的点。**我们在使用异步系统批量写入数据的时候,一定要考虑这个潜在的危险。
这些问题当然在明确的情况下可以得到规避但是毫无疑问这样的设计充满了潜在的危险。事实上最终这样的问题也确实发生了二者相加导致的结果是链表巨大撑死了整个系统OOM系统失去响应。
因此,我们对于缓存容量的控制,最好是基于缓存容量本身来直接控制,但是考虑到某些编程语言的自身限制,比如 Java从内存消耗的角度来实现不方便那么就可以通过基于队列的长度来替代实现。
### 4. LRU 的致命缺陷
LRU 指的是 Least Recently Used最少最近使用算法。这是缓存队列维护的最常见算法原理是维护一个限定最大容量的队列队列头部总是放置最近访问的元素包括新加入的元素而在超过容量限制时总是从队尾淘汰元素。
我们可以用这样一张图,来解释 LRU 的工作原理:
<img src="https://static001.geekbang.org/resource/image/a4/1a/a4866b19d2718b8ed679615292bf501a.png" alt="">
假设用这个缓存的 LRU 队列来存储城市信息,且队列容量只有 2。
- 第一步,用户访问上海信息,上海节点被加入队列;
- 第二步,用户访问北京信息,北京节点从队列头部加入,上海相应地被往尾部推;
- 第三步,用户又访问上海信息,上海被挪到头部;
- 第四步,用户访问天津信息,从头部加入队列后,队列长度超出容量 2因此从尾部将北京挤出缓存队列。
这看起来是个很完美的缓存淘汰算法,在队列较长时,总是能保证最近访问的数据位于队列的头部,而在需要从缓存中淘汰数据时,总是能从尾部淘汰最不常用的那一个。但是,如果用户有意无意地访问一些错误信息,就会破坏掉这个 LRU 队列中最近访问数据的真实性。
我曾经在实际项目中遇到过这样一个问题,由于搜索引擎的多个并行爬虫在短时间内访问网站并抓取一些冷门页面,这时候这个 LRU 队列中就存储了相关的冷门数据信息。接着网站活动开启的时间到了,用户量很快就上来了,这时候大量的数据访问全部穿透缓存,导致数据库压力剧增,网站响应时间一下就飙升到了告警线之上。
既然这个问题已经很明确了,那么解决就不是难事了。有多种算法可以作为 LRU 的改进方案,比如 LRU-K。就是主缓存队列排的是“第 K 次访问的元素”,也就是说,如果访问次数小于 K则在另外的一个“低级”队列中维护这样就保证了只有到达一定的访问下限才会被送到主 LRU 队列中。
这种方法保证了偶然的页面访问不会影响网站在 LRU 队列中应有的数据分布。再进一步优化,可以将两级队列变成更多级,或者是将低级队列的策略变成 FIFO2Q 算法)等等,但原理是不变的。
## 缓存框架
鉴于缓存的普遍性,缓存框架也可以说是百花齐放。如果你在大型 Web 项目中工作过,你很可能已经用过某一个缓存框架了。下面我就针对缓存框架的两个方面进行讲解,一方面是集成方式,另一方面是核心要素。希望这部分内容,可以帮助你在考察新的缓存框架的时候,心里能有个大致可以参照的谱。
### 集成方式
在上一讲我介绍了 Web 应用 MVC 的三层都可以集成缓存能力,下面我们来进一步思考这部分内容。缓存功能具体怎样整合集成到 Web 应用中,每一种方式都意味着一个切入点。我认为归纳一下,通常包含了下面这样几种方式。
**方式 1编程方式**
这种是最常见的方式,使用编程的方式来获取缓存数据。这种方式比较灵活,对于代码往往以 Cache-Aside 模式应用。我们以 Java 世界应用最广泛的缓存框架 [Ehcache](http://www.ehcache.org/) 为例,示例代码片段如下:
```
Cache&lt;String, City&gt; cityCache = cacheManager.createCache(&quot;cityCache&quot;, CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, City.class, resourcePools));
cityCache.put(&quot;Beijing&quot;, beijingInfo);
City beijing = cityCache.get(&quot;Beijing&quot;);
```
这里建立了一个城市的缓存key 为城市名称value 为城市对象,存取操作和对普通 Map 的操作相比,没有区别。
**方式 2方法注解**
这种方式的好处在于,可以对方法的调用保持透明,不需要使用单独的缓存代码去分散对业务逻辑的专注。且看下面的例子:
```
@Cacheable(value=&quot;getCity&quot;, key=&quot;#name&quot;)
public City getCity(String name) { ... }
```
这种方式下,同名、同参数方法的再次调用,就可以命中缓存而直接返回。
**方式 3配置文件的注入**
这种也比较常见,比如 MyBatis 在 mapper 标签中可以指定 cache 标签,通过这种方式就可以把选定的缓存框架注入到这个持久层框架中。对于指定映射的数据,再次访问时会优先从缓存中查找,这种应用方式就是前一讲我们提到的缓存应用模式中的 Read/Write-Through 模式。
```
&lt;mapper namespace=&quot;...&quot; &gt;
&lt;cache type=&quot;org.mybatis.caches.ehcache.EhcacheCache&quot;/&gt;
...
&lt;/mapper&gt;
```
**方式 4Web 容器的 Filter**
在 Ehcache 2 中,可以配置 net.sf.ehcache.constructs.web.filter.SimplePageCachingFilter 这样一个 filter 到 Tomcat 的 web.xml 中,再配合 filter 的映射匹配参数和初始化参数,就可以实现整个请求的过滤功能。
在 Ehcache 3 中,这个类被取消了,因为它的业务性过于具体,不符合 Ehcache 的设计原则。但是,你依然可以在 filter 里面,以前面提到的编程方式很容易地实现对于完整请求的缓存。如果你对这里提到的 filter 感到陌生,可以回看 [[第 12 讲]](https://time.geekbang.org/column/article/143909) 中的“Tomcat 中配置过滤器”这部分内容。
**方式 5页面模板中的 Cache 标签**
这种方式相对比较少见,有一些页面模板支持 Cache 标签或表达式语法(例如 Django 中,它被称为 Template Fragment Caching在标签属性或语法参数中可以指定缓存的时间和条件标签内部的 HTML 将被缓存起来,以避免在每次模板渲染时都去执行其中的逻辑。
### 核心要素
一个缓存框架,拥有的特性和要素可以说五花八门,可是,有一些是真正的“核心”,在缺少了以后,就很难再称之为一个“缓存框架”了。那么,有哪些要素可以称之为缓存框架的核心呢?我认为,它至少包括这样几点。
**要素 1缓存数据的生命周期管理**
缓存框架不只提供了一个简单的容器,还提供了使容器中的数据进行变动的能力,比如数据可以创建、更新、移动以及淘汰。且看 [Ehcache 官网](https://www.ehcache.org/documentation/2.7/configuration/data-life.html)上的这张示意图:
<img src="https://static001.geekbang.org/resource/image/f5/b9/f521239d2fc5a17715e4d432400f5eb9.jpg" alt="">
整个容器是分层的,从上到下分别为 L1 Heap、L1 BigMemory、L2 Heap、L2 BigMemory 和 L2 Disk级别依次降低。这里面定义了几种不同的行为来反映数据的流动
- Flush右侧黄色的箭头数据从高层向低层移动
- Fault左侧绿色箭头数据从低层拷贝到高层但不删除
- Eviction下方红色箭头数据永久淘汰出缓存数据容器
- Expiration上方烟灰色图案数据过期了意味着可以被 flushed 或者 evicted但是考虑到性能不一定立即执行这个操作
- Pinning右上角蓝色图案数据被强制钉在某一层不受流动规则控制。
**要素 2数据变动规则**
上面这些基本数据变动的“行为”,是属于系统侧的定义,只有它们,缓存系统是无法工作的。我们必须有规则,执行规则,才会触发上面的不同行为,引起数据真正的变动。
比如说,当一个热点数据因为最近没有访问而从 L1 Heap 挤出去的时候Flush 行为发生了;在 L2 Disk 上的数据一直没有被访问,超过了期限,淘汰出容器。这样,这些缓存数据变动的具体行为就得到了解释,而这正是由我们预先定义好的“规则”所决定的(这里的算法不一定只是缓存队列的淘汰算法,正如你所见,淘汰可以只是多个数据变动行为中的一个而已)。
**要素 3核心 API**
这里本质上反映的是缓存框架实现的时候,核心代码结构的设计。当我们把这类的代码结构设计进一步上升到规范层面,它们就可以被定义成接口,即允许不同的缓存框架可以实现同样的设计,在 Java 中,这个东西有一个官方 JSR 的版本 [JSR-107](https://www.jcp.org/en/jsr/detail?id=107)。它定义了 CachingProvider、CacheManager、Cache、Cache.Entry 等几个接口。
**要素 4用户侧 API**
这是指暴露给用户访问缓存的接口,比如常见的向缓存内放置一条数据的接口,或者从缓存内取出一条数据的接口。值得一提的是,我们通常见到的用户 API 都是 Map-like 的结构,即众所周知的 key-value 形式,但其实缓存框架完全可以支持其它的形式,这取决于数据访问的方式,因此这并不是一个绝对的限制。
## 总结思考
今天我们结合实际案例学习了缓存使用中的一些常见的“坑”,并了解了千变万化的缓存框架中一些共性的东西。希望你能够重点体会和理解缓存使用中的问题,即这把双刃剑中向着程序员和系统自己的那一刃,绕开那些已经有人踩过的坑。毕竟,失败的故事总是比成功的故事更有总结的价值。
现在,我来提两个问题,请你思考:
- 在你的项目中,是否使用到了缓存,在使用的过程中,是否遇到过什么问题,能否跟我们大家分享一下呢?
- 缓存框架我介绍了几个核心要素,但是,一个缓存框架还存在着许多的“重要特性”。那么,根据你的经验和理解,你觉得它们还有哪些呢?
好,今天的内容就到这里,对于缓存,你还有什么感悟,欢迎在留言区和我聊一聊。
## 扩展阅读
- 文中提到了布隆过滤器Bloom Filter它基于概率用来判断存在性的数据结构它的时间和空间复杂度往往远远低于一般的存在性判别算法它对于“不存在”判断的正确率是 100%,但对于“存在”的判断存在错误的可能。想了解其具体设计原理,[Bloom Filters by Example](https://llimllib.github.io/bloomfilter-tutorial/zh_CN/) 这篇文章是一个很好的开始。
- 文中提到了 Ehcache 和 Spring 整合后,就可以使用注解的方式来建立方法缓存,如果你想进一步了解具体的配置方式,可以参见 [Spring Caching and Ehcache example](https://www.mkyong.com/spring/spring-caching-and-ehcache-example/) 这篇文章。
- 对于 JSR-107如果你对文中介绍的核心 API 感兴趣的话,请移步 GitHub 上的 [jsr107spec 项目](https://github.com/jsr107/jsr107spec)。

View File

@@ -0,0 +1,205 @@
<audio id="audio" title="23 | 知其然,知其所以然:数据的持久化和一致性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/78/6a/7830a85c67da622d2dfa284d2ccca46a.mp3"></audio>
你好,我是四火。
我想你很可能已经使用过许多存储层的技术了,例如缓存、文件、关系数据库,甚至一些云上 key-value 的存储服务,但就如同我之前提到的那样,某项具体技术总是相对好学,可对于全栈知识系统地学习,也包括持久层的学习,是一定要立足于技术的基础、原理和本质的。今天,我们要讲的就是其中之一 —— 一致性Consistency
数据的可用性和一致性是很多工程师几乎每天都会挂在嘴边的概念,在存储系统的技术选型上面,一致性将会是我们一个重要的衡量因素,而在持久层架构设计上面,它也将是最重要的思考维度。
数据的一致性不但是数据持久化的一个核心内容,也是学习的一个难点,希望我们一起努力,从原理上去彻底理解它,并学习一些常见的应用模式,做到“知其然,知其所以然”,我们一起把这个难啃的骨头给啃下来。
## 概念和背景
数据持久化,本质上就是把内存中的数据给转换并写入指定的存储系统中,这个过程是保证数据不丢失的基本方式,而这个存储系统可以具备很多种形式,可以是网络、硬盘文件,也可以是数据库,还可以是前面两讲提到的某种形式的缓存。
你也许听说过对于一致性的不同解释,而我们在谈论数据持久化的时候讲到的一致性,我认为简单来说,指的就是在存储系统中,客户端对数据的读写行为都是可以预期、符合一定规则的。这里有两个值得注意的方面:
- **可以预期和符合规则,而不是说读到的数据是“一致的”“准确的”或是“最新的”**,是因为存在不同的一致性模型,数据对一致性遵从的程度和规则都不同,下文我会讲到。
- 一致性判断的视角要从客户端来看,也就是说,**存储系统实际存储的数据可以在某些时候不遵从我们所要求的一致性,而只需要保证存储系统的客户端能读取到一致的数据就可以了。**举例来说,某一个数据更新的过程中,对于存储系统来说,新数据其实已经写入,但由于事务还未提交,这时客户端读到的还是老数据。
我想这个概念并没有什么特殊之处,但是,这里面隐含了一个事情,就是说,为什么要有数据备份呢?
为了可用性Availability
**服务为了高可用,就要部署多个节点;数据为了高可用,就要存放多个备份。**这里的数据,既包括数据本身,又包括数据的读写服务,这是因为:
- 要让数据不丢失,冗余几乎是唯一的办法,因为再好的存储介质也架不住设备老化和各种原因的破坏;
- 同理,为了数据访问服务能保持可用,包括保证足够的性能,必须要提供多个节点的读写操作服务,于是,我们不得不创建多个数据副本。
那么一环扣一环,如果只有一份数据,是不存在一致性问题的,因为数据自己也只有一份,没法存在不一致,但有了数据副本,一致性就成为了课题。
## 一致性模型
你很可能已经听说过这三种一致性模型,下面我们来分别了解一下。
**强一致性Strong Consistency**强一致性要求任意时间下,读操作总是能取得最近一次写操作写入的数据。
注意,这里依然是从存储系统客户端的角度来描述的,**即便如强一致性的限制,也只要求在读取的时候能读到“最新”的数据就可以了,至于这个在上次写操作之后、这次读操作之前,对存储系统内部的数据是否是“最新”的并无要求。**我们经常使用的传统关系型数据库,比如 Oracle、MySQL它们都是符合强一致性的。
**弱一致性Weak Consistency<strong>弱一致性和强一致性相反,读操作**并不能保证</strong>可以取得最新一次写操作写入的数据,也就是说,客户端可能读到最新的数据,也可能读不到最新的数据。
这个“并不能保证”就有点“搞笑”了——都不确定能不能读到最新值,那它有什么用?其实它的应用也挺广泛的,最常见的例子就是缓存,比如一个静态资源被浏览器缓存起来,那么这之后只要是从缓存内取得的数据,使用者其实根本不知道这个数据是不是最新的,因为即便它实际有了更新,服务端也不会通知你。
**最终一致性Eventual Consistency**最终一致性介于强一致性和弱一致性之间,写操作之后立即进行读操作,可能无法读到更新后的值,但是如果经过了一个指定的时间窗口,就能保证可以读到那个更新后的值。
最终一致性可能是我们日常生活中最常见的一致性模型了。比如搜索引擎,搜索引擎的爬虫会定期爬取数据,并更新搜索数据,因此如果你的网站只是刚刚更新,可能还无法搜到这个更新内容,但只要是过了一定的时间窗口,它就会出现在搜索结果中了。
## 数据高可用的架构技术
接下来,我们探讨互联网应用中最常见的几种架构技术,它们都是用以解决数据可用性的问题,就如同我们在上文中所提到的那样,既包括数据本身的可用性,又包括数据读写服务的可用性。
### 1. 简单备份
简单备份Backup指的就是定期或按需对存储系统中的数据全量或增量进行复制并保存为副本从而降低数据丢失风险的一种方式。
<img src="https://static001.geekbang.org/resource/image/53/4f/53c3e61aca5906962a78446cbdb5fa4f.png" alt="">
这是一种实现上最简单的技术,在个人电脑上极其常见,但即便在工业界,依然有大量的应用场景。比方说 Amazon RDS将关系数据库搬到云上的 Snapshot 技术,可以定期将所有数据导出为一份副本。在 CPU 和 I/O 等资源不成为瓶颈的情况下,因为是异步进行的,简单备份往往对存储系统读写操作的影响很小。
但是,这种方式存在存储系统的单点故障问题,一旦存储系统挂掉了,服务也就中断了,因此基本没法谈可用性。
你可能会说,可用性的话,可以给访问存储的 Web 服务器做双机备份啊。没错,但那解决的是 Web 服务器可用性的问题,并不是我们这里最关心的数据可用性的问题,数据存储依然是单点的。同时,如果什么时候存储系统挂掉了,那么只能恢复到最近一次的备份点,因此可能丢失大量的数据。
### 2. Multi-Master
Multi-Master 架构是指存在多个 Master节点各自都提供完整的读写服务数据备份之间的互相拷贝为了不影响读写请求的性能通常是异步进行的。
<img src="https://static001.geekbang.org/resource/image/b9/cc/b9153efbb171d6381a08cfa577bc02cc.jpg" alt="">
从图中,你可以看到,如果某一主节点对应的存储服务挂掉了,那么还有另一个主节点可以提供对应服务,因此,这种方式是可以提供高可用服务的。图中只放了两个主节点,但是其实是可以放置多个的。
关于一致性,通常情况下节点之间的数据互拷贝是异步进行的,因此是最终一致性。需要说明的是,这个数据互拷贝理论上也是可以做到同步进行的,即将数据拷贝到所有其它的主节点以后再将响应返回给用户,而且那种情况下就可以做到强一致性,不过实际却很少有这样做的,这是为什么呢?
第一个原因,显而易见,同步的数据拷贝会导致整体请求响应的时延增加。
第二个,也是更重要的原因,如果有节点异常,这个拷贝操作就可能会超时或失败,这种情况下,你觉得存储系统应该怎样对待这个错误?显然,存储系统会陷入两难的境地。
- 如果系统容许错误发生,不返回错误给用户,那么强一致性就无法保证,既然无法保证,那么这个拷贝过程就完全可以设计成异步的,因为既然无论如何也无法保证强一致性,这个同步除了增加时延以外,并未带来任何明显的好处。
- 如果系统不容许错误发生,即返回错误给用户,一致性就被严格保证了,但是这样的话,整个存储系统就不再是高可用了,因为任何一个主节点的不可用,就会导致其它任意主节点向其拷贝数据的失败,进而导致整个系统都变得不可用。我们使用多个主节点的目的就是要提高可用性,而现在这样的设计和高可用性的目的就自相矛盾了。
其实,对待这个节点间的数据拷贝错误,还有第三种方式,它结合了上述二者的优点。我先卖个关子,我们在下面 Master-Slave 的部分会谈到。
再来说说 Multi-Master 的缺陷。**它最大的缺陷是关于事务处理的,本地事务(即单个存储节点)可以提交成功,但是全局事务(所有存储节点)却可能失败。**它包含这样两种典型的产生问题的场景:
- 由于是最终一致性,那么数据丢失也是可能发生的,即在写操作成功而节点间数据拷贝还没完成的时刻,如果主节点挂掉了,那么数据丢失也就发生了,只不过丢失的数据可能相对较少,但是全局事务的完整性就无从谈起了。
- 如果没有节点异常,主节点 A 的事务提交成功,主节点 B 的事务也提交成功,它们是做到了对本地数据库中事务操作的原子性。可是当进行节点间数据互拷贝时,一旦这两个提交的事务发生冲突(例如修改同一条记录),它们就傻眼了,到底应该以 A 还是以 B 的事务为准?这种冲突的解决会比较复杂,而且由于发生在异步的拷贝环节,这时候用户的请求都已经返回响应了,就没法告知用户事务冲突了。
因此当我们要实现全局事务的时候Multi-Master 往往不是一个好的选择。
### 3. Master-Slave
Master-Slave 架构是指存在一个可读可写(或者只写)的 Master 节点,而存在多个只读的 Slave 节点,每当有通过 Master 的更新出现,数据会以异步的方式单向拷贝到所有的 Slave 节点上去。
<img src="https://static001.geekbang.org/resource/image/d2/46/d2ee6e41dd28830a49d736f6e5168a46.jpg" alt="">
这种方式和 Multi-Master 比起来将可写的节点数减少为了一个而允许有多个只读的节点图中只画了一个但实际可以有多个这种方式比较适用互联网较常见的业务即读远大于写的场景而且读的可扩展性Scalability较强即增加一个 Slave 节点的代价较小),而且不存在 Multi-Master 的事务冲突问题。
当然了,**Master-Slave 的缺点也很明显**。既然**只有一个可写的节点**,那么写的可扩展性就很差了;而且和 Multi-Master 一样,数据从 Master 到 Slave 的拷贝是异步进行的,因此数据存在丢失的可能。
和 Multi-Master 一样,我们当然也可以让数据拷贝变成同步进行的,但是这又存在着上文讨论过的同样的缺陷。但是,有一种介于全同步和全异步之间的缓解这个问题的方法,即“最小副本数量”,比如可能存在 5 个 Slave 节点,但是从 Master 到 Slave 的数据拷贝一旦在 2 个节点成功了,就不用等另外 3 个返回,直接返回用户操作成功。即便那 3 个中存在失败,系统也可以标记失败节点,并按照既定策略自动处理掉,而不影响用户感知。
因此,**我们可以说这种数据拷贝的方法是“部分同步”“部分异步”的,既降低了数据丢失的可能,又避免了因为某个 Slave 问题而导致Master“死等”的情况发生。**
最后,值得注意的是,写现在变成单点的了,为了避免单点故障引起的服务中断,一种方式是在 Master 挂掉的时候Slave 可以挺身而出,变为 Master 顶上去提供写的服务。但是这件事情说说容易,实际要让它自动发生却有大量的工作要做,比如,谁顶上去,以及顶上去了之后,原来以为挂掉的 Master 又活过来了怎么办,等等。
### 4. 其它
还有其它更为复杂的方法,一种是 2PC 或 3PC即两阶段提交或三阶段提交甚至采用高容错的分布式的共识算法 Paxos。这些方法能够保证强一致性但是在实现上都要复杂许多我在今天的扩展阅读中会介绍它们。
下面这张比较的表格来自 [Transactions Across Datacenters](https://snarfed.org/transactions_across_datacenters_io.html) 这个著名的演讲,这张图在互联网上流传很广。
<img src="https://static001.geekbang.org/resource/image/31/c2/31ba31142c4854ae042ad29e627ee7c2.jpg" alt="">
简单说明一下,从上到下每行的含义依次为:一致性、事务支持、延迟、吞吐量、数据丢失和故障转移(指的是节点出现故障以后,其它节点可以自动顶替上来的能力)。
从中我们可以看到没有一列能够做到全绿色这正如我们所知道的那样软件工程上的问题都“没有银弹”。特别是Backups、M/S 和 MM 得益于异步的副本拷贝,能够做到低延迟,这就无法做到强一致性;而 2PC 和 Paxos 通过同步操作可以做到强一致性,却带来了高延迟。
## 总结思考
今天我们学习和理解了数据持久化中一致性的相关概念和实现技术,希望通过今天的学习,你能做到“知其然,知其所以然”。
现在,我来提一个问题,检验一下你的学习成果。请将下列存储系统按照“强一致性”“弱一致性”和“最终一致性”进行归类:
- 关系数据库
- 本地文件
- 浏览器缓存
- 网盘数据
- CDN 节点上的静态资源
- 搜索引擎爬虫爬到的数据
好,今天的正文内容就到这里。如果你对一致性哈希原理了解不够透彻的话,我强烈推荐你继续学习今天的选修课堂。
## 选修课堂:一致性哈希
在今天的选修课堂中我们来学习一种特殊的哈希算法——一致性哈希Consistent Hashing
首先,你可以在脑海里回忆一下,什么是哈希算法。哈希算法,又被称为散列算法,就是通过某种确定的键值函数,将源数据映射成为一个简短的新数据串,这个串叫做哈希值。**如果两个源数据的 hash 值不同那么它们一定不相同如果两个源数据的hash值相同那么这两个源数据可能相同也可能不相同。**
我们在 [[第 02 讲]](https://time.geekbang.org/column/article/135864) 中谈到的数字签名,就是通过一个哈希算法,得到证书的哈希值,也就是它的“指纹”,是经过加密以后得到的。哈希是很常用的算法和技术,在后面的全栈内容中我们还会遇到。
我们有时候会使用一个特殊的哈希算法,来将每项数据都映射到某一个“位置”,从而将大量的数据分散存储到不同的位置中。哈希算法在数据量大,且单个节点(单台机器)无法处理的时候尤为有用。比如说,我们要将从 0 到 9999 这 10000 个连续自然数分散到 5 个数据存储的节点上,那我就可以设计一个基于取余数的哈希算法,做到均匀分布:
```
f(x) = x % 5
```
我们可以看到,这个函数的结果,也就是哈希值,只有 0、1、2、3、4 这 5 个,对应这 5 个节点,那么我可以根据其结果把这个 x 放到相应的节点上去。这样,每个节点就只需要存储 2000 个数。
好,这看起来是个挺好的解决办法,但是现在问题来了,由于业务的扩张,我们现在需要处理从 0 到 11999 这 12000 个数了,也就是说,多了 2000 个数。可是,节点承载的数据量已经基本到达了极限,没法再加入那么多数据了。
没问题,我们加机器吧,现在有 6 个节点了,我们就得修改这个算法:
```
f(x) = x % 6
```
嗯,这样数据还是能均匀分布。
原理上没错,可是这又带来了一个问题,就是这些已经在节点上的数据,必须要调整位置了,毕竟算法变了嘛,因此这些数所在的节点可能要改变,这个过程叫做 **Rehashing**。这一调整,就傻眼了,只有同为 5 和 6 的倍数的数(即只有为 30 倍数的数不用调整位置其它全部都要调整。也就是说就因为加了这一台机器29/30 = 96.7% 的数据全部都要调整位置!
这个代价显然是接受不了的,那有没有办法可以优化它呢?
当然!**一致性哈希,就是一种尽可能减少 Rehashing 过程中进行数据迁移的算法。**且看下面这张图:
<img src="https://static001.geekbang.org/resource/image/dd/39/dd1f0fd322a16176c72395b79422cf39.jpg" alt="">
请你**把上面的圆盘想象成一个时钟**,总共有 12 格0 点到 12 点),假如说我们通过上面类似的哈希算法,把数据映射到时钟的每个格子上。因为是时钟,我们这次取 12 的余数:
```
f(x) = x % 12
```
同时系统中总共有三台服务器那么每台服务器就可以负责管理其中的“4 个小时”的数据。比如哈希值是 1~4 的数据(范围 B存储在右下角的节点5~8 的数据(范围 C存储在左下角的节点而 9~12 的数据(范围 A存储到正上方的节点。
用这种方式来打散数据看起来似乎没有什么特别的对不对,别急,当我们添加新硬件,有一个新节点加入的时候,情况就不同了,请看下图:
<img src="https://static001.geekbang.org/resource/image/55/50/550fd7dc68feb27eb729d9b915d3da50.jpg" alt="">
在这种情况下,正下方有一台机器被加入,原本 5~8 点的数据被分成两部分7~8 点的数据C2依然存储在左下角的原节点而 5~6 点的数据C1则需要迁移到新的也就是正下方的节点上。
你看,这种情况下,添加一个节点,只需要移动其中的一部分数据,也就是 2/12 = 1/6 的数据就行,是不是对整个系统影响就小了很多?
等等!你可能会说,这样添加了一台服务器,如果原始数据哈希计算后的分布是均匀的,**经过 了添加机器的操作,节点上承载数据分布却是不均匀的**——正上方、右下角的服务器分别承载了总共 1/3 的数据,而左下角、正下方的服务器却各自只需要承载 1/6 的数据。
那么,这个问题,怎么解决?如果你能想到这个问题,那非常好。
解决方法就是引入“**虚拟节点**”,我们根据时钟的 12 个数字,把它均匀分成 12 个区域,分别由 12 个虚拟节点负责,并且顺时针按照 Ax-Bx-Cx 这样命名。这样在添加机器以前每台机器需要负责4块数据例如某台机器 A 需要承载 A1、A2、A3 和 A4 的数据),并且它们均匀地散布在圆环上:
<img src="https://static001.geekbang.org/resource/image/75/b0/75a9aa4ba97bae18fbff18698601ceb0.jpg" alt="">
好,现在添加新机器,我们只需要把 A1、B2、C3 这三个虚拟节点的数据,搬迁到新机器 D 上:
<img src="https://static001.geekbang.org/resource/image/a0/6c/a0d26ef488f14ed18e6056960ab7d46c.jpg" alt="">
你看,同样搬迁了最少量的数据,且元盘上的数据还是均匀分布的,只是从均匀分布在 3 台机器,变成了均匀分布到 4 台机器上。当然,作为示意,我这里是把圆环分成了 12 份,实际可以分成更多的 2^n 份。
## 扩展阅读
- 关于 2PC 和 3PC如果你感兴趣的话可以阅读维基百科的词条[2PC](https://en.wikipedia.org/wiki/Two-phase_commit_protocol) 和 [3PC](https://en.wikipedia.org/wiki/Three-phase_commit_protocol),或者是直接阅读 [The Two-Phase Commit Protocol](http://courses.cs.vt.edu/~cs5204/fall00/distributedDBMS/duckett/tpcp.html) 和 [Three-Phase Commit Protocol](http://courses.cs.vt.edu/~cs5204/fall00/distributedDBMS/sreenu/3pc.html)。
- 关于 Paxos算法本身比较难如果你很感兴趣我找了一些中文材料我觉得 [Paxos 算法详解](https://zhuanlan.zhihu.com/p/31780743)这篇是相对讲得比较清楚的。
- 文中那个表格最早是来自于 Google I/O 2009 的 [Transactions Across Datacenters](https://www.youtube.com/watch?v=srOgpXECblk) 这个分享,后来有人[上传到了 Bilibili 上](https://www.bilibili.com/video/av57190094/),我推荐你听一下这个分享。

View File

@@ -0,0 +1,159 @@
<audio id="audio" title="24 | 尺有所短寸有所长CAP和数据存储技术选择" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d3/28/d31090b4db489d4bcabfdefb067e4928.mp3"></audio>
你好,我是四火。
在上一讲中我们着重讲了持久层的一致性,其实,它是分布式系统的一个基础理论。你可能会问,学习基于 Web 的全栈技能,也需要学习一些分布式系统的技术吗?是的!特别是我们在学习其持久层的时候,我们还真得学习一些分布式系统的基础理论,从而正确理解和使用我们熟悉的这些持久层技术。
CAP 理论就是分布式系统技术中一个必须要掌握的内容,也是在项目早期和设计阶段实实在在地影响我们技术选型、技术决策的内容。
## 理解概念
我想,你已经很熟悉一致性了。今天,在一致性之后,我们也要涉及到 CAP 的另外的两个方面——可用性和分区容忍性。
### 1. CAP 的概念
CAP 理论又叫做布鲁尔理论Brewers Theorem指的是在一个共享数据的分布式存储系统中下面三者最多只能同时保证二者对这三者简单描述如下
- 一致性Consistency读操作得到最近一次写入的数据其实就是上一讲我们讲的强一致性
- 可用性Availability请求在限定时间内从非失败的节点得到非失败的响应
- 分区容忍性Partition Tolerance系统允许节点间网络消息的丢失或延迟出现分区
下面,请让我进一步说明,从而帮助你理解。
**一致性,这里体现了这个存储系统对统一数据提供的读写操作是线性化的。**如果客户端写入数据,并且写操作返回成功给客户端,那么在下一次读取的时候(下一次写入以前),如果系统返回了“非失败”的响应,就一定是读出了完整、正确(最新)的那份数据,而不会读取到过期数据,也不会读取到中间数据。
**可用性,体现的是存储系统持续提供服务的能力**,这里表现在两个方面:
- **返回“非失败”的响应**,就是说,不是光有响应就可以了,系统得是在实实在在地提供服务,而不是在报错;
- **在限定时间内返回**,就是说,这个响应是预期时间内返回的,而不出现请求超时。
请注意这里说的是“非失败”响应而并没有说“正确”的响应。也就是说返回了数据但可以是过期的可以是中间数据因为数据是否“正确”并非由可用性来保证而是由一致性来保证的。系统的单个节点可能会在任意时间内故障、出错但是系统总能够靠处于非失败non-failing状态的其它节点来继续提供服务保证可用性。
**分区容忍性,体现了系统是否能够接纳基于数据的网络分区。**只要出现了网络故障,无论什么原因导致某个节点和系统的其它节点失去了联系,节点间的数据同步操作无法被“及时”完成,那么,即便它依然可以对外(客户端)提供服务,网络分区也已经出现了。
当然,如果数据只有一份,不存在其它节点保存的副本,或不需要跨节点的数据共享,那么,这就不存在“分区”,这样的分布式存储系统也就不是 CAP 关心的对象。
### 2. 进一步理解
如果你觉得模糊,没关系,让我使用一个简单的图示来帮你理解。
<img src="https://static001.geekbang.org/resource/image/9a/4b/9a552d641a142f77650b5fd07988174b.jpg" alt="">
有这样一个存储系统,存在两个节点 A 和 B各自存放一份数据拷贝。那么在正常情况下客户端无论写数据到 A 还是 B都需要将数据同步到另一个节点再返回成功。比如图示中带序号的四个箭头
- 箭头 ①,客户端写数据到节点 A
- 箭头 ②,节点 A 同步数据变更到节点 B
- 箭头 ③,节点 B 返回成功响应到节点 A
- 箭头 ④,节点 A 返回成功响应给客户端。
不知道你有没有回想起 [[第 23 讲]](https://time.geekbang.org/column/article/159453) 中的 Multi-Master 架构,对,但唯一需要特别指出的不同是,节点间数据拷贝是同步进行的,需要完成拷贝以后再返回响应,因为我们需要保证一致性。
之后,客户端尝试读取刚写入的数据,无论是从节点 A 还是 B都可以得到准确的数据
<img src="https://static001.geekbang.org/resource/image/8f/8a/8fe1b2a21d18314edd93fc7d0aa9998a.jpg" alt="">
好,这种情况下数据在 A、B 上都是一致的,并且系统也是可用的。
但是现在网络突然出现故障A 和 B 之间的数据拷贝通道被打断了,也就是说,分区发生了。这时候客户端再写入 A 就会出现以下情况:
<img src="https://static001.geekbang.org/resource/image/9e/88/9e08dd2874a9bc6810d7813057dcef88.jpg" alt="">
你看,这时候节点 A 已经无法将数据“及时”同步到节点 B 了, 那么,节点 A 是否应该将数据写入自己,并返回“成功”给客户端呢?它陷入了两难:
- **如果写入并返回成功,满足系统的可用性,就意味着丢失了数据一致性。**因为节点 A 的数据是最新的,而节点 B 的数据是过期的。
- **如果不写入数据,而直接返回失败**,即节点 A 拒绝写操作,那么 A 和 B 节点上的**数据依然满足一致性(写入失败,但依然都是相互一致的老数据),但是整个系统失去了可用性。**
你看,我们怎么也无法同时保证一致性、可用性和分区容忍性这三者。
### 3. 三选二
紧接着我要谈一谈对于 CAP 理论一个很大的误解——三选二。从上面对于 CAP 的描述来看, CAP 的应用似乎就是一个三选二的选择题,但事实上,完全不是这样的。
开门见山地说,**在讨论 CAP 定理的时候P也就是分区容忍性是必选项。**具体来说,跨区域的系统,分区容忍性往往是不可以拿掉的,因为无论是硬件损坏、机房断电,还是地震海啸,都是无法预料、无法避免的,任何时间都可能出现网络故障而发生分区,因此工程师能做的,就是从 CP 和 AP 中选择合适的那一个。
你可以想想上面我拿图示举的那个例子在分区发生的时候最多只能保证一致性和可用性一个。也就是说CAP 理论不是三选二的,而是二选一,当然,具体选哪个,我们需要“权衡”。
需要特别说明的是,**这里说的是只能“保证”一致性和可用性二者之一,而不是说,在系统正常运行时,二者不可能“同时满足”。**在系统运行正常的时候,网络分区没有出现,那么技术上我们是可能同时满足一致性和可用性两者的。
这时你可能会问,难道没有 CA即同时“保证”一致性和可用性而牺牲掉分区容忍性的系统吗
有!**但请注意,那其实已经不是 CAP 理论关心的对象了,因为 CAP 要求的是节点间的数据交换和数据共享。**任何时候都不会有分区发生,这种系统基本上有这样两种形式:
- **单节点系统**,这很好理解,没有节点间数据的交换,那么无论网络出不出故障,系统始终只包含一个节点。比方说,传统的关系型数据库,像 MySQL 或者 OracleDB在单节点的配置下。
- **虽然是多节点,但是节点间没有数据共享和数据交换**——即节点上的数据不需要拷贝到其它节点上。比方说无数据副本Replica配置的集群 Elasticsearch 或 Memcached在经过 hash 以后,每个节点都存放着单份不同的数据。这种情况看起来也算分布式存储,但是节点之间是互相独立的。
## 存储技术的选择NoSQL 三角形
在谈到根据 CAP 来选择技术的时候,我想先来介绍一下 NoSQL你将会看到它们大量地分布在下面“NoSQL 三角形”的 CP 和 AP 两条边上。
那么,到底什么是 NoSQL 呢我们可以简单地认为NoSQL 是“非关系数据库”和它相对应的是传统的“关系数据库”。它被设计出来的目的并非要取代关系数据库而是成为关系数据库的补充即“Not Only SQL”。
也就是说,它放弃了对于“关系”的支持,**损失了强结构定义和关系查询等能力但是它往往可以具备比关系数据库高得多的性能和横向扩展性scalability等优势。**这在 Web 2.0 时代对于一些关系数据库不擅长的场景例如数据量巨大数据之间的关联关系较弱数据结构schema多变强可用性要求和低一致性要求等等NoSQL 可以发挥其最大的价值。
在实际业务中,我们可以利用 CAP 定理来权衡和帮助选择合适的存储技术,且看下面这张 NoSQL 系统的 CAP 三角形(来自 [Visual Guide to NoSQL Systems](http://blog.nahurst.com/visual-guide-to-nosql-systems))。尺有所短,寸有所长,我们可以从 CAP 的角度来理解这些技术的优劣。
<img src="https://static001.geekbang.org/resource/image/90/52/90a6c7ddb6556fa206f95a80a7a6c652.jpg" alt="">
从图中可以发现,关系数据几乎都落在了 CA 一侧,但是请注意,技术也在不断更新,许多关系数据库如今也可以通过配置而形成其它节点的数据冗余;有时,我们则是在其上方自己实现数据冗余,比如配置数据库的数据同步到备份数据库。
无论哪一种方法,一旦其它节点用于数据冗余的数据副本出现,这个存储系统就落到上述三角形的另外两边去了。
云上的 NoSQL 存储服务,多数落在了 AP 一侧,这也和 NoSQL 运动可用性优先保证而降级一致性的主题符合。比如 Amazon 的 DynamoDB但是这个也是可以通过不同的设置选项来改变的比如 DynamoDB 默认采用最终一致性,但也允许配置为强一致性,那时它就落到了 CP 上面。
### 实际场景
接着我们考虑几个实际应用场景,看看该采用哪一条边的技术呢?既然是基于 Web 的全栈工程师的技术学习,我就来举两个基于网站应用的例子。
还记得我们在 [[第 09 讲]](https://time.geekbang.org/column/article/141817) 中谈到的页面聚合吗?对于门户网站来说,无论是显示的数据,还是图片、样式等等静态资源,通过 CDN 的方式,都可以把副本存放在离用户较近的节点,这样它们的获取可以减少延迟,提高用户体验。因此,这些系统联合起来,就形成了一个可以使用 CAP 讨论的分布式系统。
那么,很容易理解的是,且不用说网络故障而发生分区的情况,即便在正常情况下,这些信息并不需要具备那么严格的“即时性”,新闻早显示、晚显示几秒钟,乃至几分钟,都不是什么问题,上海的读者比北京的读者晚看到一会儿,也不是什么问题。但是,大型网站页面打不开,就是一个问题了,这显然会影响用户的体验。因此,从这个角度说,我们可以牺牲一致性,但需要尽量保证可用性,因此这是一个选择 AP 的例子。
事实上,对于大型的系统而言,我们往往不需要严格的一致性,但是我们希望保证可用性,因此在大多数情况下我们都会选择 AP。但是有时情况却未必如此。
再举一个例子,航空公司卖机票,在不考虑超售的情况下,一座一票,航空公司的网站当然可以采用上面类似的做法;有时,甚至在正常的情况下,余票的显示都可以不是非常准确的(比如显示“有票”可以避免显示这个具体数字)。但是,当客户真正在选座售票的时候,即扣款和出票的时候就不是这样的了,一致性必须优先保证。因为如果可用性保证不了,即有时候订票失败,用户最多也就是牢骚几句,这还可以接受,但要是出现一致性问题,即两个人订了同一个座位的票,那就是很严重的问题了。
最后,我想说的是,这里的选择是一个带有灰度的过程,并非只有 0 和 1 这两个绝对的答案,我们还是需要具体问题具体分析,不要一刀切。
**从特性上说,甚至可以部分特性做到 CP部分做到 AP这都是有可能的。**比如说,涉及钱的问题一定是 CP 吗不一定ATM 机就是一个很经典的例子在网络故障发生时ATM 会处于 stand-alone 模式,在这种模式下,用户依然可以执行查询余额等操作(很可能数额不准确),甚至还可以取款,但是这时的取款会有所限制,例如限制一个额度(银行承担风险),或者是限制只能给某些银行的卡取款,毕竟可用性和一致性的丢失会带来不同的风险和后果,两害相权取其轻。
## 总结思考
今天我们学习和理解了 CAP 理论,并且了解了一些实际应用的例子。希望你能够通过今天的内容,彻底掌握其原理,并能够逐渐在设计中应用起来,特别是在技术选型做“权衡”的时候。
现在,我们来看一下今天的思考题吧:
- 你是否了解或是接触过分布式系统,特别是分布式存储系统,它是否能归类到 NoSQL 三角形中的某一条边上呢?
- 互联网上的绝大多数系统都是可以牺牲一致性,而优先保证可用性的,但也有一些例外。你能举出几个即便牺牲可用性,也要保证数据一致性的例子来吗?
今天的主要内容就到这里,欢迎你在留言区进行讨论,也欢迎你继续学习下面的选修课堂。
## 选修课堂:从 ACID 到 BASE
ACID 和 BASE正好是英文里“酸”和“碱”的意思。有意思的是关系数据库和非关系数据库它们各自的重要特性也恰恰可以用酸和碱来体现。下面我来简单做个比较你可以从中感受一下二者的差异和对立性为我们后两讲介绍技术选型打下基础。
先说说 ACID。
关系数据库的一大优势,就是可以通过事务的支持来实现强一致性,而事务,通常可以包含这样几个特性。
- Atomicity原子性指的是无论事务执行的过程有多么复杂要么提交成功改变状态要么提交失败回滚到提交前的状态这些过程是原子化的不存在第三种状态。
- Consistency一致性这里的一致性和我们前面介绍的一致性含义略有不同它指的是事务开始前、结束后数据库的完整性都没有被破坏所有键、数据类型、检查、触发器等等都依然有效。
- Isolation隔离性指的是多个并发事务同一时间对于数据进行读写的能力同时执行互不影响。事务隔离分为四大级别不同的数据库默认实现在不同的级别我在扩展阅读中放置了一些学习材料感兴趣的话可以进一步学习。
- Durability持久性一旦事务成功提交那么改变是永久性的。
接着说说 BASE。
前面已经谈到了 NoSQLCAP、最终一致性再加上 BASE被称作 NoSQL 的三大基石。而 BASE是基于 CAP 衍生出来,对于其牺牲一致性和保证可用性的这一分支,落实到具体实践中的经验总结,在大规模互联网分布式系统的设计中具有指导意义。
- BA基本可用即 Basically Available。这就是说为了保障核心特性的“基本可用”无论是次要特性的功能上还是性能上都可以牺牲。严格说来这都是在“可用性”方面做的妥协。例如电商网站在双十一等访问压力较大的期间可以关闭某一些次要特性将购物支付等核心特性保证起来。如果你还记得 [[第 17 讲]](https://time.geekbang.org/column/article/151464) 中的“优雅降级”,那么你应该知道,这里说的就是优雅降级中一个常见的应用场景。
- S软状态即 Soft State。说的是允许系统中的数据存在中间状态这也一样为了可用性而牺牲了一致性。
- E最终一致性即 Eventually Consistent。S 和 E 两点其实说的是一个事情,一致性的牺牲是可行且有限度的,某个数据变更后的时间窗口内出现了不一致的情况,但是之后数据会恢复到一致的状态。举例来说,上文提到过的 CDN 系统便是如此,再比如社交媒体发布后的互动,像点赞、评论等功能,这些数据可以延迟一会儿显示,但是超过了一定的时间窗口还不同步到就会是问题。
## 扩展阅读
- [Towards Robust Distributed Systems](https://sites.cs.ucsb.edu/~rich/class/cs293b-cloud/papers/Brewer_podc_keynote_2000.pdf),这是一个胶片,来自 Eric Brewer 最早谈及 CAP 理论的一个分享;而 [Brewers CAP Theorem](http://www.julianbrowne.com/article/brewers-cap-theorem),这一篇是对 CAP 理论证明的论文,想看中文的话可以看看这篇[中文译文](https://www.cnblogs.com/13yan/archive/2018/06/29/9243669.html)。
- 文中提到了 NoSQL 的概念CAP 的三角形一图中也有一些实现的例子,[NoSQL Databases](http://nosql-database.org/) 这个网站列出了比较全面的 NoSQL 数据库列表,可供查询。
- 文中提到了某些存储服务能够通过配置在 CAP 的三角形上切换。比如 DynamoDB它是一个 NoSQL 的键/值文档数据库,就可以配置为 CP也可以配置为 AP官方的[读取一致性](https://docs.aws.amazon.com/zh_cn/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html)这篇文章做了简要说明;再比如 S3它是一个云上的对象存储服务它的一致性根据对象创建和对象修改而有所不同你可以看一下[官方的这个说明](https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/dev/Introduction.html#ConsistencyModel)。
- 如果对 BASE 感兴趣,你可以看看这篇最原始的 [Base: An Acid Alternative](https://queue.acm.org/detail.cfm?id=1394128),想看中文译文的话可以看看[这篇](https://zhuanlan.zhihu.com/p/29083764)。
- 对于文中提到的事务隔离,感兴趣可以进一步参见[维基百科](https://zh.wikipedia.org/wiki/%E4%BA%8B%E5%8B%99%E9%9A%94%E9%9B%A2),还有一篇美团技术团队写的[Innodb中的事务隔离级别和锁的关系](https://tech.meituan.com/2014/08/20/innodb-lock.html),也是很好的针对事务隔离的学习材料。

View File

@@ -0,0 +1,162 @@
<audio id="audio" title="25 | 设计数据持久层(上):理论分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/73/82/73066b1ee8b6bc0f79153d2a64b52182.mp3"></audio>
你好,我是四火。
在基于 Web 的全栈技术下,每一层的设计都有共同点,当然,也有各自的特殊之处。你可以回想一下,我们曾经在第一章谈到的客户端和服务端交互以及 Web API 的设计,在第三章谈到的前端的设计,在第二章谈到的服务端 MVC 各层的设计,从前到后。那么,本章余下的内容,我们就来让整个设计层面上的体系变得完整,讲一讲最后面一层的数据持久层怎样设计。
持久层的设计包括持久化框架选择、持久层代码设计,以及存储技术选型等等,考虑到这其中有部分内容我们在第二章谈论 MVC 模型层的时候已经讲到过了,那么在这一讲和下一讲中,我就会先偏重于持久层的数据存储技术本身,再结合实际的设计案例来介绍怎样选择合适的技术来解决那些经典的实际问题。
## 关系数据库
关系数据库就是以“关系模型”为基础而建立的数据库,这里的关系模型说的是数据可以通过数学上的关系表示和关联起来,也就是说,关系模型最终可以通过二维表格的结构来表达。关系数据库除了带来了明确的 schema 和关系以外,还带来了对事务的支持,也就是对于强一致性的支持。
### 数据库范式
数据库的表设计可以说是全栈工程师经常需要面对的问题。而这部分其实是有“套路”可循的其中一些常见的规范要求就被总结为不同的“范式”Normal Form。它可以说是数据库表设计的基础对于数据库表设计很有实际的指导意义。我注意到有很多程序员朋友都不太清楚不同范式的实际含义那么今天就请让我通过一个尽可能简单的图书管理系统的例子来把它讲清楚。
**1. 第一范式1 NF**
**第一范式要求每个属性值都是不可再分的。**满足 1NF 的关系被称为规范化的关系1NF 也是关系模式应具备的最起码的条件。比如下面这样的 Books 表:
<img src="https://static001.geekbang.org/resource/image/c3/e0/c3e7821cd54c491b0aff7e12026573e0.jpg" alt="">
你看在上面这张表中有两本书重名了都叫“Life”但是国际标准书号 ISBN 是不同的。放在了同一个属性 ISBN 中,并非不可再分,这显然违反了第一范式。那解决这个问题的办法就是拆分:
<img src="https://static001.geekbang.org/resource/image/60/9a/60bb114c4dfe64f3341c34cc4b0b859a.jpg" alt="">
**2. 第二范式2 NF**
**第二范式要求去除局部依赖。**也就是说,表中的属性完全依赖于全部主键,而不是部分主键。
<img src="https://static001.geekbang.org/resource/image/80/fe/803754244fa279d784a6af203d74fdfe.jpg" alt="">
你看,在上面这张表中,原本的设计是想让 BOOK_ID 和 AUTHOR_ID 组成联合主键但是BOOK_NAME 仅仅依赖于部分主键 BOOK_ID而 AUTHOR_NAME 也仅仅依赖于部分主键 AUTHOR_ID违背了第二范式 。解决的办法依然是拆分,把这个可以独立被依赖的部分主键拿出去,上面的表可以拆成下面这样两张表:
<img src="https://static001.geekbang.org/resource/image/9d/cb/9d6f9f22b2b5feab01cd6ea1de9582cb.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/98/c3/9874e9af23ebd64c5278654a78cfacc3.jpg" alt="">
从这个拆分中我们也可以看到,原表被拆成了 N 对 1 关系的两个表而被不合范式依赖的那个“部分主键”变成了“1”这头的主键。
**3. 第三范式3 NF**
**第三范式要求去除非主属性的传递依赖。**即在第二范式的基础上,非主属性必须直接依赖于主键,而不能传递依赖于主键。
<img src="https://static001.geekbang.org/resource/image/88/29/889a89fd92fab3a818aae622f3655e29.jpg" alt="">
你看上面这张表,主键是 BOOK_ID而 CATEGORY_NAME 是非关键字段,并非直接依赖于主键,而是通过这样的传递依赖实现:
>
CATEGORY_NAME → CATEGORY_ID → BOOK_ID
因此,为了消除这个传递依赖,我们还是拆表,让这个传递链中间的 CATEGORY_ID 自立门户:
<img src="https://static001.geekbang.org/resource/image/ec/4f/ec8cb2f6b80835e76d0631dcd1fe764f.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/73/51/73c47a542f083d6b372056588dc10551.jpg" alt="">
一般我们在设计中分析到第三范式就打住了很少有情况会考虑更为严格的范式。例如BC 范式和第三范式就很像,但是,第三范式只是消除了非主属性对主属性的传递依赖,而 BC 范式更进一步,要求消除主属性对主属性的传递依赖,从而,消除所有属性对主属性的传递依赖。
当然,还有第四、第五范式等等,要求更加严格,解耦更加彻底,但却不太常用了,如果你想进一步了解,可以参阅维基百科的[词条](https://en.wikipedia.org/wiki/Database_normalization#Normal_forms)。
范式的程度更高,冗余度便更低。但正如同我们在第二章介绍的“拆分”大法一样,每一次范式的升级都意味着一个拆表的过程,一旦过度解耦,拆分出太多零散的表,对于程序员的理解,脑海中数据模型的建立,甚至包括联表操作的 I/O 性能,都是不利的。因此我们需要“权衡”,掌握好这个度,**这一原则,和我们介绍过的分层设计是一致的。**
## NoSQL
在上一讲中,我已经介绍了 NoSQL 的概念。在 NoSQL 出现以前,设计大型网站等 Web 应用的时候,全栈工程师的武器库里可供使用的选择,要局限得多。尤其是对一些量大且非结构化的数据,缺乏特别理想的解决方法,工程师有时不得不采用一些非常规的特殊方案,而更多时候我们只能在传统关系数据库的基础上,使用数据库的 Sharding 和 Partition 这样的操作。
虽然那时候在数据规模上面临的挑战远没有现在大,可那个时候 DBADatabase Administrator在市场上可以说是炙手可热一旦出了问题有时甚至还得请专门的数据库厂商的专家这其中的费用极其高昂是以分钟计算的。
如今,你可能听说过许多互联网企业去 IOE 的故事IOE 指的是 IBM的小型机、Oracle 数据库、EMC 存储设备这三者),其中阿里巴巴的版本听起来还颇为传奇。事实上,这可不只是只有国内的阿里巴巴曾经努力做的事情,还是全球许多大型互联网企业都曾经或正在做的事情,也包括 Amazon。我曾经在 Amazon 的销量预测团队中工作,当时我们团队可以说就在整个亚马逊最大的传统(非云上)关系数据库上工作,里面存放了全部商品库存、销量等有关的信息。
Web 2.0 时代的到来,为互联网应用带来了深远的影响,**用户代替传统媒体,成为了 Web 2.0 时代主要的数据制造者。海量、不定结构、弱关联关系、高可用性和低一致性要求的数据特点,让关系数据库力不从心;而 NoSQL 则具有更好的横向扩展性、海量数据支持、易维护和廉价等等优势,犹如一剂特效药,成为了市场上这个数据难题的大杀器。**
当然,现实中依然存在大量需要强一致性和关系查询的业务场景,因此关系数据库依然是我们倚赖的重要工具。可是,**依然使用关系数据库并不代表依然靠互联网企业自己亲力亲为地做繁重的数据库管理工作**,关系数据库云服务的崛起将传统的数据库管理工作自动化,于是普通的软件工程师也可以完成以往 DBA 才能完成的工作了。从这里也能看出DBA 也确实是一个技术变更影响技术人才市场需求的典型例子。
综上,这个数据的问题就有了两个层次的解决方案:
- 出现了更适合业务的非关系数据库服务,也就是 NoSQL
- 把关系数据库搬到云上,从而让互联网企业从繁重的数据库管理工作中解脱出来,例如 RDS。
### NoSQL 数据库的分类
别看 NoSQL 的数据库那么多,它们大致可以被分为这样几类,每一类也都有自己的优势和劣势。在介绍每一类的时候,我会以我比较熟悉的 AWS 上的实现来具体介绍,当然,很可能你见过的是其它的例子(比如非云上的本地版本,再比如不同的云服务厂商都会提供自己的实现),但在原理层面都是类似的。
**1. 键值Key-value数据库**
这一类 NoSQL 的数据库,采用的是 key-value 这样的访问模型,也就是说,可以根据一个唯一的 key 来获取所需要的值,这个 key 被称为主键,而这个根据 key 来获取 value 的访问的过程是通过 Hash 算法来实现的。本地的 Redis 或者云上的 DynamoDB 都属于这一类。
以 DynamoDB 为例,它的 key 由 Partition Key 和 Sort Key 两级组成即前者用来找到数据存在哪一个存储单元Storage Unit而后者用来找到 value 在存储单元上的具体位置。通常来说,这个二级 Hash 的过程时间开销并不会随着数据量的增大而增大,下图来自[官方的 Blog](https://aws.amazon.com/cn/blogs/database/choosing-the-right-dynamodb-partition-key/)
<img src="https://static001.geekbang.org/resource/image/6e/b3/6e087df74ef924619966e74fdcd0f5b3.jpg" alt="">
DynamoDB 表结构看起来和传统的关系数据库有些像,并且每一行的 schema 可以完全不同,即“列”可以是任意的。每张表的数据支持有限的范围查询,包括主键的范围查询,以及索引列的范围查询。其中,索引的数量是有着明确限制的,一种是全局的(相当于 Partition Key + Sort Key每张表上限 20 个;一种是本地的(相当于 Partition Key 已经确定,只通过 Sort Key 索引),每张表上限 5 个。
**2. 列式Columnar数据库**
经典的数据库是面向行的,即数据在存储的时候,每一行的数据是放在一起的,这样数据库在读取磁盘上连续数据的时候,实际每次可以一气读取若干行。如果需要完整地查询出特定某些行的数据,行数据库是高效的。且看下面的示例,来自 [Redshift 官方文档](https://docs.aws.amazon.com/zh_cn/redshift/latest/dg/c_columnar_storage_disk_mem_mgmnt.html)
<img src="https://static001.geekbang.org/resource/image/b1/1b/b1a304f40d3d8f006a3a1e79f663af1b.jpg" alt="">
但是列式数据库不一样,它是将每一列的数据放在一起。这样的话,如果我们的处理逻辑是要求取出所有数据中的特定列,那么列数据库就是更好的选择:
<img src="https://static001.geekbang.org/resource/image/68/0a/6827c62314d7a7dc40053a8d1ea7aa0a.jpg" alt="">
事实上,对于数据库来说,磁盘的读、写本身,往往还不是最慢的,最慢的是寻址操作。因此,无论是行数据库还是列数据库,如果根据实际需要,我们的实际访问能够从随机访问变成顺序访问,那么就可以极大地提高效率。在大数据处理中经常使用的 HBase 和云上的 Redshift 都属于这一类。
和行数据库相比,列数据库还有一些其它的好处。比如说,对于很多数据来说,某一个特定列都是满足某种特定的格式的,那么列数据库就可以根据这种格式来执行特定的压缩操作。
**3. 文档Document数据库**
文档数据库是前面提到的键值数据库的演化版,值是以某一种特定的文档格式来存储的,比如 JSON、XML 等等。也就是说,文档携带的数据,是已经指定了既定的编码、格式等等信息的。某一些文档数据库针对文档的特点,提供了对于文档内容查询的功能,这是比原始的键值数据库功能上强大的地方。本地的 MongoDB 和 AWS 上的 DocumentDB 都属于这个类型。
**4. 对象Object数据库**
和上面介绍的文档数据库类似,当 value 变成一个可序列化的对象特别是一个大对象的时候它就被归类为对象数据库了。AWS 上最常用的 NoSQL 存储,除了前面介绍过的 DynamoDB就是 S3 了它相对成本更为低廉耐久性durability数据不丢失的级别非常高达到了 11 个 9并且存储对象可以很大因此用户经常把它当做一个“文件系统”来存放各种类型的文件而把 key 设计成操作系统文件路径这样一级一级的形式S3 也就被称为“文件系统”。但是,实际上 S3 的“文件”还是和我们所熟悉的操作系统的文件系统有着很大的区别。
当然还有一些其它的类型包括图形Graph数据库、搜索Search数据库等等我就不一一列举了。
## 演进趋势
我们在这一章已经从一致性、可用性等方面了解了关系数据库和非关系数据库各自的优势,我想,对这两方面,你应该已经有了自己的认识。现在,我想再补充两个角度,让比较更为全面,分别是扩展性和数据的结构性,我们一起来看看持久层的存储技术的演进趋势。
### 1. 从 Scale Up 到 Scale Out
先回顾一下“扩展性”Scalability也有翻译为“伸缩性”的这个概念。
按理说扩展性是包括“纵向垂直扩展”和“横向水平扩展”的。当然如今使用这个词的时候我们往往特指的是“Scale Out”也就是横向扩展说白了就是通过在同一层上增加硬件资源的方式来增加扩展性从而提高系统的吞吐量或容量。比如说增加机器。NoSQL 技术往往具备很强的横向扩展能力,至于其中的一个典型原理,你可以回顾一下 [[第 23 讲]](https://time.geekbang.org/column/article/159453) 的选修课堂“一致性哈希”,你就会明白,为什么 NoSQL 技术可以很方便地在同一层增加硬件资源了。
可是你知道吗在很早以前人们谈“扩展性”的时候默认指的却是“Scale Up”。它指的是在硬件设施数量不变的基础上通过增加单个硬件设施的性能和容量来达到同样的“扩展”目的。比如说把同一台机器的 CPU 换成更好的,把内存升级,把磁盘换成容量更大的,等等。毕竟,在那时候,程序员对于很多问题的思考都还普遍停留在“单机”的层面上。
为什么横向扩展如此重要,以至于成为了“扩展性”的默认指代?
原因很简单,单个硬件设施的性能提升是非常有限的,而且极其昂贵。我记得我刚工作那会儿,去国内某电信运营商开局,众所周知他们“不差钱”,为了提升性能,我们做了很多 Scale Up 的工作,包括把 CPU 升级成了 96 核的。可是你看看现在的互联网公司哪个会这么玩因此在现实中多数情况下扩容这件事情上Scale Out 是唯一的选择。
### 2. 从结构化数据到非结构化数据
我们使用关系数据库的时候每一行数据都是严格符合表结构的定义有多少列每一列的类型是什么等等我们把这类数据叫做“结构化”structured数据而这个确定的“结构”就是 schema。结构化的数据具备最佳的查询、校验和关联能力。
但是当我们使用 DynamoDB 这样的 NoSQL 数据库的时候我们发现每一行数据依然可以分成一列一列的但是有多少列或者每一列的类型或者表示的具体含义却变得不再固定了。这时候我们说这样的结构依然存在但是共通的部分明显比结构化数据少多了于是我们把它们叫做“半结构化”semi-structured的数据。
再一般化就是“非结构化”non-structured数据了上面说的 S3 就是一个很好的例子。即便 S3 上存储的文件是符合某种结构的(比如 JSON我们也无法利用这个存储服务来完成依据结构而进行的查询等等操作了。
无论是同步还是异步的数据处理,我们总是希望数据的结构性越强越好,因为“结构”本质上意味着“规则”,越强的结构,就越容易使用简单直白的代码逻辑去处理。可是,恰恰相反的是,**我们在现实中遇到的绝大多数的数据,都是非结构化的**,或者说,很难用某一种特定的规则去套。
## 总结思考
今天我们从不同角度学习了关系数据库和非关系数据库,掌握了一些存储设计的原理和技巧,希望你可以将内容慢慢消化。
下面是今天的提问环节了,我想换个形式。
我们已经学习了几种常见的数据库范式,下面这张图书馆用户表的数据库表的设计是不合理的,你觉得它满足了第几范式呢?并且,你能不能通过学到的拆分方法,分析一下它的问题,把它进一步优化,消除冗余呢?
<img src="https://static001.geekbang.org/resource/image/7a/27/7a609fd55a0882a88a95a1dc43fc9c27.jpg" alt="">
好,今天的内容就到这里。如果有思考、有问题,欢迎在留言区发言,我们一起讨论。
## 扩展阅读
- 文中提到了 Web 2.0 的概念,我推荐你阅读 Web 2.0 的[维基百科词条](https://zh.wikipedia.org/wiki/Web_2.0),以及 [Web 2.0网站的九个特点](http://www.ruanyifeng.com/blog/2007/11/web_2_0.html)这篇文章,以对它有一个明确的认识。
- 对于 NoSQL 的一些特定的术语,以及数据库的分类和比较,推荐你阅读这篇[什么是 NoSQL](https://aws.amazon.com/cn/nosql/?nc1=h_ls)
- 文中介绍了去 IOE 的事儿,正好 Amazon 最近宣称他们正式完成了从 Oracle 关系数据库迁移离开的工作,感兴趣的话可以[看一看](https://aws.amazon.com/cn/blogs/aws/migration-complete-amazons-consumer-business-just-turned-off-its-final-oracle-database/)。
- 对于分布式存储感兴趣,并且阅读能力还可以的话,有一些经典论文可以是进一步学习的对象,但请注意它们不是我们这个阶段或当前学习周期内需要学习的内容。比如 [Dynamo: Amazons Highly Available Key-value Store](https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf) 这篇,中文译文可以[参考这篇](https://arthurchiao.github.io/blog/amazon-dynamo-zh/),它影响了后来很多分布式系统的设计和发展,我几年前也学习并写了一些[自己的理解](https://www.raychase.net/2396)。
- 再就是 Google 著名的“三驾马车”了,[GFS](https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/gfs-sosp2003.pdf)[中文译文](http://blog.bizcloudsoft.com/wp-content/uploads/Google-File-System%E4%B8%AD%E6%96%87%E7%89%88_1.0.pdf)[MapReduce](https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/mapreduce-osdi04.pdf)[中文译文](http://blog.bizcloudsoft.com/wp-content/uploads/Google-MapReduce%E4%B8%AD%E6%96%87%E7%89%88_1.0.pdf) 和 [BigTable](https://static.googleusercontent.com/media/research.google.com/zh-CN//archive/bigtable-osdi06.pdf)[中文译文](http://blog.bizcloudsoft.com/wp-content/uploads/Google-Bigtable%E4%B8%AD%E6%96%87%E7%89%88_1.0.pdf))。我仅仅把它们放在这里,只是供感兴趣且有一定论文阅读能力的程序员朋友参考,而对于本专栏全栈的学习来说,不接触它们是完全没问题的。

View File

@@ -0,0 +1,216 @@
<audio id="audio" title="26 | 设计数据持久层(下):案例介绍" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/29/dedbebcf8b5dbb90f670ec172d0e1b29.mp3"></audio>
你好,我是四火。
本章我们已经学习了不少持久化,特别是有关存储的技术。那在实际业务中,复杂的问题是一个接着一个的,面对这些琳琅满目的具体技术,我们该怎样运用自己所掌握的知识,做出合理的选择呢?今天我们就来接触一些典型的系统,看看对于它们来说,该做出怎样的持久化设计和技术选型。我相信我们实际接触的系统也有相当程度的类比性,可以带来应用的参考意义。
## 搜索引擎
小到 BBS 网站的帖子搜索,大到互联网数据搜索引擎,搜索引擎可以说是我们日常接触的几大系统之一。可是,搜索数据的存储该怎么设计呢?
有一些反应迅速的程序员朋友,也许会设想这样的存储结构,利用关系数据库,创建这样一个存储文本(文章)的关系数据库表 ARTICLES
<img src="https://static001.geekbang.org/resource/image/45/04/4568eea6b14c67c379e840b2918ee404.jpg" alt="">
那么,假如现在的搜索关键字是“存储”,我们就可以利用字符串匹配的方式来对 CONTENT 列进行匹配查询:
```
select * from ARTICLES where CONTENT like '%存储%';
```
这很容易就实现了搜索功能。但是,这样的方式有着明显的问题,即使用 % 来进行字符串匹配是非常低效的,因此这样的查询需要遍历整个表(全表扫描)。几篇、几十篇文章的时候,还不是什么问题,但是如果有几十万、几百万的文章,这种方式是完全不可行的。且不说单独的关系数据库表就不能容纳那么大的数据了,就是能够容纳,要扫描一遍,这里的时间代价是难以想象的,就算我们的系统愿意做,用户可都不愿意等啊。
于是,我们就要引入**“倒排索引”Inverted Index**的技术了。在前面所述的场景下,我们可以把这个概念拆分为两个部分来解释:
- “倒排”,指的是存储的结构不再是先定位到文章,再去文章的内容中找寻关键字了;而是反过来,先定位到关键字,再去看关键字属于哪些文章。
- “索引”,指的是关键字,是被索引起来的,因此查询的速度会比较快。
好,那上面的 ARTICLES 表依然存在,但现在需要添加一个关键字表 KEYWORDS并且KEYWORD 列需要添加索引,因此这条关键字的记录可以被迅速找到:
<img src="https://static001.geekbang.org/resource/image/c9/11/c9868489ed7415cdad3f760e0ea5d411.jpg" alt="">
当然,我们还需要一个关联关系表把 KEYWORDS 表和 ARTICLES 表结合起来KEYWORD_ID 和 ARTICLE_ID 作为联合主键:
<img src="https://static001.geekbang.org/resource/image/ba/9b/ba5a26f2d882314e7d0d1f60c23d979b.jpg" alt="">
你看,这其实是一个多对多的关系,即同一个关键字可以出现在多篇文章中,而一篇文章可以包含多个不同的关键字。这样,我们可以先根据被索引了的关键字,从 KEYWARDS 表中找到相应的 KEYWORD_ID进而根据它在上面的关联关系表找到 ARTICLE_ID再根据它去 ARTICLES 表中找到对应的文章。
这看起来是三次查找,但是因为每次都走索引,就免去了全表扫描,在数据量较小的时候速度并不慢,并且,在使用 SQL 实现的时候,这个过程完全可以放到一个 SQL 语句中。在数据量较小的时候,上面的方法已经足够好用了。
但是,这个方法只解决了全表扫描和字符串 % 匹配查询造成的性能问题,并且,在数据量较大时,并没有解决数据量本身在单机模式下造成的性能问题。
于是,我们可以考虑搭建和使用 [Elasticsearch](https://www.elastic.co/products/elasticsearch)或者干脆使用云上的版本。Elasticsearch 将关键字使用哈希算法分散到多个不同的被称为“Shard”的虚拟节点并且把它们部署到不同的机器节点上且每一个 shard 具备指定数量的冗余副本Replica这些副本要求被放置到不同的物理机器节点上。通过这样的方式我们就可以保证每台机器都只管理稳定且可控的数据量并且保证了搜索服务数据的可用性。
<img src="https://static001.geekbang.org/resource/image/8c/09/8c4e956ca12bccc88ea9fd5d8f461a09.jpg" alt="">
对于每一个关键字,都可以配置指向文章和文章中位置的映射。比如有这样两篇文章:
>
<p>文章 1 的正文是:今天介绍存储技术。<br>
文章 2 的正文是:存储技术有多种分类。</p>
那么,就有如下映射关系(下表仅用于表示在 Shard 中的数据映射,并非关系数据库表):
<img src="https://static001.geekbang.org/resource/image/77/68/779702fd2a29ff418711928789bd3b68.jpg" alt="">
你看DOCUMENT 这一部分每一行都可以存放若干个“文章id : 文中关键字的位置”的组合。
## 地理信息系统
有这样一款订餐软件,上面有这样一个功能,在地图上可以列出距离当前用户最近的签约饭馆,并且随着用户缩放地图,还可以控制这个距离的大小。每个饭馆的位置可以简单考虑为经度和纬度组合的坐标(下图来自 Google 地图,仅示意用)。
<img src="https://static001.geekbang.org/resource/image/50/9a/500b7bc8394a1b63c7fb89ee4c18cb9a.jpg" alt="">
简言之,这个功能就是“显示一定范围内的目标集合”,可它该怎样实现呢?
在考虑这个功能以前,我们可以类比地想一想,它其实是一个相当常见且通用的功能,常常应用于订餐、导航软件、旅游网站等等这类 LBSLocation-Based Service基于位置的服务应用中因此这个问题是具有一定典型意义的。
这个背后的数据结构以及存储又是怎样的呢?我们顺着这个“经纬度”的思路往下想,那么,如果就把这样的地理信息,放到一张 LOCATIONS 表上,就会是这样:
<img src="https://static001.geekbang.org/resource/image/bb/43/bb027beec790f8ee207b051b4a5ca943.jpg" alt="">
当然了,还有一张 RESTAURANTS 表:
<img src="https://static001.geekbang.org/resource/image/ba/02/bafddb5244287066ec676b6c30054c02.jpg" alt="">
于是,要查出范围内的饭馆,我们就可以写这样的 SQL
```
select * from LOCATIONS l, RESTAURANTS r where
l.RESTAURANT_ID = r.RESTAURANT_ID and
l.LONGITUDE &gt;= 经度下界 and
l.LONGITUDE &lt;= 经度上界 and
l.LATITUDE &gt;= 纬度下界 and
l.LATITUDE &lt;= 纬度上界;
```
其中,这个经度、纬度的上下界,是根据用户所在位置,以及地图缩放程度折算出来的。显然,这需要一个全表扫描,加一个笛卡尔积,复杂度偏高,能否优化一下它呢?在往下阅读前,你可以先想一想。
**思路1给单一维度加索引**
嗯,如果经纬度可以分开处理,是不是就可以搞定?比方说,只考虑经度的话,给经度一列建立索引,所有饭馆按照从小到大的顺序排好。这样的话,当给定范围的时候,我们就可以快速找到经度范围内所有满足经度条件的饭馆。从时间复杂度的角度来考虑,在不做额外优化的情况下,以在有序经度列上的二分查找为例,这个复杂度是 log(n)。
当再考虑纬度的时候,假如有 m 家满足经度条件的饭馆,接下去我们就只能挨个去检查这 m 家饭馆,找出它们中满足纬度条件的了,也就是说,总的时间复杂度是 m*log(n)。这种方法比较简单,在数据量不太大的情况下也没有太大问题,因此这已经是很好的方法了。但是,在某些场景下这个 m 还有可能比较大,那么,有进一步优化的办法吗?
**思路 2GeoHash**
其实,经度和纬度的大致思路可以,但是在框选饭馆的时候,不能把经度和纬度分别框选,而应该结合起来框选,并且把复杂度依然控制在 log(n) 的级别。
其中一个办法就是 [GeoHash](https://en.wikipedia.org/wiki/Geohash),它的大致思路就是降维。即把一个经度和纬度的二维坐标用一个一维的数来表示。具体实现上,有一种常见的办法就是把经度和纬度用一个长位数的数来表示,比如:
```
经度101010……
纬度100110……
```
接着把二者从左到右挨个位拼接,黑色字符来自经度,蓝色字符来自纬度:
<img src="https://static001.geekbang.org/resource/image/11/af/1142aa596e19561794080a5896667aaf.jpg" alt="">
在这种方式下,从结果的左边最高位开始,取任意长度截断所得到的前缀,可以用来匹配距离目标位置一定距离范围的所有饭馆。当用户选取的地图范围越大,前缀长度就越长,这个匹配精度也就越高,匹配到的饭馆数量也就越少。通过这种方式,区域不断用前缀的方式来细分,相当于给每个子区域一个标记号码。
那么,我们数据库表中的经度和纬度就可以合并为一列,再令这一列为主键,或者做索引,就能够进行单列的范围查询了。
<img src="https://static001.geekbang.org/resource/image/a7/fa/a703d1d17e273fcc4d1150e9105071fa.jpg" alt="">
最后,我们已经走到这一步了,接下来该怎么把这个表落实到数据库中呢?这就有多种方式可供选择了。比如我们可以使用关系数据库(例如 MySQL也可以使用 NoSQL 中的键值数据库(例如 Redis 等)。这方面可以根据其它业务需求,以及实际开发的限制来选择,具体选择的策略,请继续阅读下文。
## SQL or NoSQL
我们在实际的存储系统选择时,经常会涉及到 SQL 数据库和 NoSQL 数据库的选择,也就是关系数据库和非关系数据库的选择。举个例子,如果是电子商务网站(这可能是我们平时听到的最多的例子之一了),应该选择 SQL 还是 NoSQL
### 两个前提角度
设计和选型方面,有很多问题都不是黑白分明的,而是要拆分开来一块一块分析。我听到过很多“一刀切”的答案,比如有的人说用 MySQL有的说用 Redis我认为这样的结论都是不妥的。那么怎样来选择呢下面我就来介绍一些 SQL 和 NoSQL 选择的原则。但是在讲原则以前,我觉得需要从两个“前提角度”去厘清我们的问题。
以电商网站的设计为例,这两个角度就是这样的。
**1. 数据分类**
电子商务网站,这个概念所意味着的数据类型太多了。简单举几个例子:
- 商品元数据,即商品的描述、厂家等等信息;
- 媒体数据,比如图片和视频;
- 库存数据,包括在某个地点的库房某商品还有多少件库存;
- 交易信息,比如订单、支付、余额管理;
- 用户信息,涉及的功能包括登陆、注册和用户设置。
因此,在讨论什么存储适合数据和访问的时候,我们最好明确,到底具体是哪一种类型的数据。毕竟,看起来上面的业务场景将有着巨大差别。
**2. 数据规模**
电子商务网站有大有小,可别一想到电商网站,脑海里就是淘宝和京东,商品可以上千万,甚至上亿。但其实,我们大多数接触的系统,都不会有那么大的规模,电商网站完全可以小到一个提供在线购物业务的私人体育用品专营店,商品数量可以只有几十到几百。
只有把问题做如上的展开并明确以后,我们再去思考和讨论数据结构、一致性、可用性等等这些我们“熟悉”的方面,才准确。因此,从上面的例子来看,**我们在选择技术的时候,很可能要针对每一类数据选择“一组”技术,而不是笼统地选择“一项”技术了。**
### 选择的思路
那么下一步,我们该怎样来选择 SQL 或是 NoSQL 数据库呢?这部分可以说,不同的人有着颇为不同的看法。下面我想根据我的认识,谈谈一个大致的选择思路,请注意这只是一个粗略的基于经验的分类,具体的技术选择还要具体问题具体分析和细化。
**1. 对于中小型系统,在数据量不大且没有特殊的吞吐量、可用性等要求的情况下,或者在多种关系和非关系数据库都满足业务要求的情况下,优先考虑关系数据库。**
关系数据库提供了成熟且强大的功能,包括强 schema 定义、关系查询、事务支持等等。关系数据库能够带来较强的扩展能力,未来在业务发展的时候,通过增加索引、增加表、增加列、增加关系查询,就可以迅速解决问题。
在从内存模型到实际存储数据的 ORM 转换的时候,有非常成熟的且支持程度各异的框架,有的把 ORM 完全自动化,让程序员可以关注在核心业务模型上面;有的则是把 ORM 定义让出来,提供足够的灵活性(这部分可以参见 [[第 12 讲]](https://time.geekbang.org/column/article/143909) 的加餐部分)。
值得注意的是,**不要觉得 NoSQL 是大数据量的一个必然选择。**事实上,即便数据量增大,关系数据库有时也依然是一个选择。当然需要明确的是,通常单表在数据量增大时,会产生性能方面的问题,但是可以使用 Sharding 和 Partitioning 技术来缓和(扩展阅读中有这方面技术的介绍);而数据可用性的问题,也可以使用集群加冗余技术来解决,当然,有得必有失,这种情况下,通常会牺牲一定程度的一致性。
那么,这个数据量多大算大到关系数据库无法承担了呢?我可以给你一个事实,即微博和 Twitter 都是使用 MySQL 作为主要推文存储的(你可以参看扩展阅读中的文章),因此你可以看到在实际应用中,关系数据库对于特大数据量的支持也是有成功实践的。
**2. 是否具备明确的 schema 定义,是否需要支持关系查询和事务?如果有一项回答“是”,优先考虑关系数据库。**
关系数据库,首当其冲的特点就是“关系”。因此,可能会有朋友说,不对,电商网站的“商品”其实 schema 是不确定的啊——例如,服装一类商品,都有“尺寸”信息;而电器类呢,都有“功率”信息,这样特定类型的属性决定了商品很难被抽象成某一个统一的表啊。
没错,但是为什么要做到如此牵强的“统一”?通用的商品属性,例如厂家、商品唯一编号当然可以放到“统一”的商品表里面,但余下的信息还是可以根据商品类型放到各自类型的特定表里面,这就好像基类和派生类一样,抽象和统一只能做到某一个层次,层次太高反而不利于理解和维护。
对于一些需要事务的需求例如订购往往需要关系数据库的支持。当然这只是多数情况NoSQL 也有例外,即允许选择 CAP 中的 CP具备强一致性且支持事务机制的例如 DynamoDB。
而有一些系统和数据则变化很大。比如用户数据在多数情况下schema 往往是比较明确的而且数量上也没有订单等数据一般有特别大的伸缩性要求因此往往也放到关系数据库里面但是在另外一些系统中用户信息的组成不确定或者说schema不确定用户信息会放到 JSON 等松散结构的文本中,这种情况下文档数据库也是一个常见的选择;但是在搜索等某些相关功能的实现上,可能又会使用搜索引擎等不同于上面任一者的其它方式。
**3. 如果符合结构不定(包括半结构化和无结构化)、高伸缩性、最终一致性、高性能(高吞吐量、高可用性、低时延等)的特点和要求,可以考虑非关系数据库。**
简单来说,这时的具体技术选择,可以按照这样两个步骤来落地:
- 如果你还记得 [[第 24 讲]](https://time.geekbang.org/column/article/160999) 中介绍的那个 NoSQL 三角,根据一致性、可用性的要求,我们可以选择这个三角中的某一条边;
- 而在 [[第 25 讲]](https://time.geekbang.org/column/article/161829) 中则介绍了具体的 NoSQL 技术的分类和适用的问题类型,可以依其做进一步选择,比如根据数据结构化的程度,在那条边上,来进一步选择某一类 NoSQL 的存储技术。
比如说,这个电商问题中提到的媒体数据,即图片和视频,通常来说可用性更为重要,而这一类的大文件,没有内部的 schema可以考虑使用擅长存放无结构大对象的文档型数据库。当然也可以直接存放为文件利用 CDN 的特性同步到离用户更近的节点去,特别对于视频来说,要提供好的用户体验,砸钱到 CDN 服务几乎是必选。
再比如,商品页在很大程度上都是可以缓存的,而缓存基本上都是为了保证可用性而会牺牲一定的一致性。除了上面介绍的 CDN 实现了对媒体内容的缓存以外,商品页本身,或是大部分区域的信息,都是可以利用 Memcached 等缓存服务缓存起来的。
## 总结思考
到这一章末尾,我们对于数据持久层的介绍就完结了。不妨来回顾一下,设计持久层,都有哪些需要考虑的方面呢?
首先,是代码层面的设计:
- 提供数据服务的设计,即 MVC 中模型层的设计,你可以阅读 [[第 08 讲]](https://time.geekbang.org/column/article/141679) 来复习回顾;
- 对于模型到关系数据库的映射ORM和技术选择在 [[第 12 讲]](https://time.geekbang.org/column/article/143909https://time.geekbang.org/column/article/158139) 的加餐部分有所介绍;
其次,是系统层面的设计:
- 持久层内部或者持久层之上的缓存技术,请参阅 [[第 21 讲]](https://time.geekbang.org/column/article/156886) 和 [[第 22 讲]](https://time.geekbang.org/column/article/158139)
- 对于持久化的核心关注点之一 ——一致性,包括存储系统扩容的基础技术一致性哈希,请参阅 [[第 23 讲]](https://time.geekbang.org/column/article/159453)
- 关于分布式数据存储涉及到的 CAP 理论和应用,以及相关的 ACID、BASE 原则,请参阅 [[第 24 讲]](https://time.geekbang.org/column/article/160999)
- 持久层存储技术的选择,你可以阅读 [[第 25 讲]](https://time.geekbang.org/column/article/161829) 来回顾,更进一步地,今天这一讲提供了一些具体问题实例和技术选择的思路。
希望通过这些内容的学习,你的持久层部分的知识,可以形成体系,而非零零散散的一个个孤岛。
**最后,在这里我留一下今天的作业,这是一个开放性的思考题:**
假如说,你要实现一个简化了的微信聊天功能,用户可以一对一聊天,也可以加好友,那么,你该怎么选择技术,并怎样设计持久层呢?
好,今天的内容就到这里,希望你在阅读后能有所收获。关于本章的内容,如果有想法,欢迎和我讨论。
## 扩展阅读
- 文中介绍了倒排索引,感兴趣的话你可以进一步阅读[搜索引擎是如何设计倒排索引的?](https://zhuanlan.zhihu.com/p/33166920)
- 文中介绍了 GeoHash在 [geohash.org](http://geohash.org/) 的网站上,可以通过给出经纬度坐标,在地图上找到这个实际位置。感兴趣的话,还可以进一步了解:本质上,类似这种二维到一维的降维方式,都属于[空间填充曲线](https://en.wikipedia.org/wiki/Space-filling_curve) ,比如说最有名的[希尔伯特曲线](https://en.wikipedia.org/wiki/Hilbert_curve)。
- Sharding 和 Partitioning 是在数据库中常见的“拆分”方式,文中也提到了,但是这两个概念经常被混用,具体含义你可以参考维基百科 [Shard](https://en.wikipedia.org/wiki/Shard_(database_architecture)) 和 [Partition](https://en.wikipedia.org/wiki/Partition_(database))。
- 有一篇介绍 Twitter 怎样应用推、拉模式,处理和存储消息,应对高访问量的[文章](http://highscalability.com/blog/2013/7/8/the-architecture-twitter-uses-to-deal-with-150m-active-users.html),相应的,微博的技术专家也写了一篇文章来介绍微博的[处理方式](https://time.geekbang.org/column/article/79602),你可以比较阅读。

View File

@@ -0,0 +1,131 @@
<audio id="audio" title="27 | 特别放送:聊一聊代码审查" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/f4/fb4e4529b2bbe2a0d23808cb6c6f37f4.mp3"></audio>
你好,我是四火。
又到了特别放送时间今天我想聊一聊代码审查Code Review。在软件工程师日常的开发工作中**如果要挑出一项极其重要,却又很容易被忽视的工作,在我看来代码审查几乎是无可争议的第一。**
代码审查是一个低投入、高产出的开发活动,就我个人而言,我从其中学到的习惯、方法和知识,让我获益匪浅。但是,我也在微博、微信上看到程序员朋友们争论代码审查的必要性,甚至包括很多大厂的程序员,还有一些有着许多年经验的程序员。
一开始我觉得有些不可思议,和代价相比,代码审查的好处实在太多了,这有必要费那么大心思去讨论这个必要性吗?后来我意识到,**造成这个争论的原因,既包括缺乏对于代码审查好处的认识,也包括一些因果逻辑上的混淆。**我想,今天的特别放送,我就来把代码审查这个事儿聊清楚,希望它能成为你日常开发工作当中认真对待的必选项。
那值得一提的是,对于全栈工程师而言,代码审查又有一点特殊性。因为我们经常要写多个层面的代码,包括前端代码 HTML、CSS、JavaScript后端逻辑比如 Java 或者 Python还很可能写很多的脚本代码比如 Shell做各种各样的配置像是和基于 XML、JSON 的配置文件打交道,还很可能使用 SQL 写持久层的逻辑。这些代码中,既包括命令式的代码,也包括声明式的代码。由于涉及到的代码类型比较广泛,代码审查者就自然会和不同的团队或项目中的角色打交道,也需要在不同的思维模式之间来回切换。
## 代码审查的流程
先来简单介绍一下常见的代码审查的流程。为了开发某个新特性或者修复某个特定问题负责的程序员会从代码库的主分支master branch上面建立并 check out 一个新的分支,将工作分为一次到若干次的“代码变更”来提交。这每一次的代码变更,都可以组成一次代码审查的单元,有的公司把它叫做 CRCode Change有的叫做 PRPull Request还有的叫做 CLChange List但无论叫做什么它一般至少包含这么几项内容
- **帮助理解代码的描述**,如果有问题单(任务)来跟踪,需要包括相关的问题单号或者问题单链接;
- **实际的代码变更主体**,包括实际的代码和配置;
- **测试和结果**,根据项目的情况,它可以具备不同形式,比如单元测试代码,以及手工测试等其它测试执行的结果说明。
多数情况下,以上这三项都不可或缺,缺少任何一项都会让代码变更失去一定可审查的价值。
进行审查的,一般是一起工作的,对代码涉及变更熟悉的其他程序员。这个“熟悉”,既包括业务,也包括技术,二者当中,有一项不具备,就很难做好审查工作,给出有建设性的审查意见。
接下去的交互就在这个代码变更上面了,审查者会提出其问题和建议,变更的作者会选择性采纳并改进变更的描述、代码主体以及测试。**双方思考、争辩,以及妥协,目的都是寻求一个切合实际且可以改进代码质量的平衡。**
如果审查的程序员觉得代码没有太多问题就会盖上一个“Approved”或者“Shipped”戳表示认可和通过。这根据项目而定一般代码变更最少要得到 1~3 个这样的认可才可以将代码变更合并merge到主分支。而主分支的代码会随着 CI/CD 的流程进入自动化的测试程序,并部署上线(关于这部分你可以参阅 [第 30 讲])。
## 常见的争议
在介绍代码审查的好处之前,我想先来谈谈争议。因为我观察到**大多数的争议,都不是在否认代码审查的好处,而是聚焦在不进行代码审查的那些“原因”** 或者 “借口”上,而有些讽刺的是,我认为**这里面大部分的“原因”,所代表着的因果关系并不成立**。
**1. 加班要累死了,完成项目都来不及,还做什么代码审查?**
类似的问题还有,“代码审查拖慢了进度”,“代码审查不利于快速上线”。这是最常见的不做代码审查,或者草草进行代码审查的理由了,但是稍稍一细想,就会发现这里的因果逻辑完全不对。
这就像以前国内大兴“敏捷”的时候,有好多程序员,甚至项目经理,觉得因为项目时间紧才要实施敏捷,因为可以少写文档,少做测试,随意变更需求,可这里的因为所以根本是牛头不对马嘴。我记得知乎上有句流行的话叫做,“先问是不是,再问为什么”,这里也可以用,因为项目压力大就让“不做代码审查”来承担后果,这实在是过于牵强了。
项目压力大,时间紧,可以草草做分析,不做设计,直接编码,不做重构、不做测试、不做审查,直接上线,快及一时,可是造成的损失,最后总是要有谁来背锅的。这个锅很可能,就是上线后无尽的问题,就是恶性循环加班加点地改问题,就是代码一个版本比一个版本烂。当这些问题都焦头烂额,就更不要说团队和程序员的成长了。
**2. 代码审查太费时间,改来改去无非是一些格式、注释、命名之类不痛不痒的问题。**
这也是个逻辑不通的论述,虽然这个还比前面那个稍微好一点。只能提出这些“次要问题”,很可能是代码审查的能力不够,而并非代码审查没有价值;或者是代码审查的力度不够,只能提出一些浅表的问题,这个现象其实更为普遍。
前面已经介绍过了,**一是技术,二是业务,二者缺一都无法做出比较好的审查。**在某些特殊情况下,有时候确实不具备完备的代码审查条件,我们现在来分业务、技术欠缺的情况进行讨论。
如果团队中有业务达人,但是技术能力不足。比如说,新版本使用的是 Scala 来实现的,但是团队中没有精通 Scala 的程序员,这个时候可以寻找其它团队有 Scala 经验的程序员来重点进行技术层面的代码审查,而自己团队则主要关注于业务逻辑层面。当然,既然是自己团队的代码,所用到的技术要慢慢补起来。
如果团队的成员具备技术能力,但是业务不了解。这种情况也可以进行将业务和技术分开审查这样的类似处理,但是如果业务相对复杂,可以先开一个预审查会,就着代码或者设计文档,简单地将业务逻辑介绍和讨论清楚,再进行审查。
**3. 团队的习惯和流程就是不做代码审查,大家都是这么过来的。**
我觉得这也不是一个论述不应该做代码审查的正当理由,类似的还有“绩效考评又不提代码审查”,以及“我上班、码代码、下班、拿钱,审查代码干什么”。大家都不做,并不代表不做就是正确的,如果你赞同代码审查的好处和必要性,那么你的思考会告诉你,应该做这件事情,大家不做并不是一个理由。
如果你发现这件事很难推动,你可以尝试去和你的项目经理聊一聊,或者结合自己的项目以及下面会讲到的代码审查的好处论一论,看看是不是能说服那些没有意识到代码审查好处的程序员和项目经理。当然,这是另外和人沟通以及表达自己观点的事情,如果大家都是朴素的干活拿钱的观点,没有对于代码质量和个人发展更高的追求,或者价值观和你相距十万八千里,改变很困难,你就应该好好思考是不是应该选择更好的团队了。
**4. 代码审查不利于团建,因为经常有程序员因为观点不同在代码审查的时候吵起来。**
这依然不是一个正当理由,这就好像说“因为开车容易出交通事故,所以平时不允许开车”这样荒谬的逻辑一样。
首先,如果有偏执的不愿意合作的程序员,那么不只是代码审查,任何需要沟通和协作的活动都可以把争吵的干柴点燃。对于这样的程序员的管理,或者如何和这样的程序员合作,是另外的一个话题,但这并不能否认代码审查的必要性。当然,在下文讲到实践的部分我会介绍一些小的技巧,帮助你在代码审查中心平气和地说服对方。
其次,有控制的一定强度内的争执,未必是坏事。有句话叫做“理越辩越明”,除了能做出尽可能合理的决定以外,在争论的过程中,你还会得到分析、思考、权衡、归纳、表达,乃至心理这些综合能力的锻炼,本来它们就不是很容易得到的机会,我们为什么还要放过呢?
## 代码审查的好处
下面我们来谈谈代码审查的好处。你可能会想,这有什么可谈的,这好处难道不是发现软件 bug提高代码质量吗
别急,代码审查的好处可远远不止这一个,我觉得它还至少包括下面这些好处。
**1. 代码审查是个人和团队提升的最佳途径之一。**
这里的学习,既包括技术学习,也包括业务学习。和英语学习一样,如果只听 BBC 或者 VOA 的纯正口音,没有任何语法错误,英文反而不容易学好,学英文就要接触生活英语,各种口音,各种不合标准的习惯用法。阅读代码也一样,要学习不同的代码风格和实现。
在做代码审查的时候,如果不理解代码,是无法给出最佳审查的。因此自己会被迫去仔细阅读代码,弄懂每一行每一个变量,而不是给一个 LGTM“Looks Good To Me”了事。
**2. 代码审查是团队关系建设和扩大双方影响力的有效方式。**
争论是这个过程中必不可少的一环,争论除了能加深对于问题和解决方法的理解,在不断的反驳和妥协中,也能树立影响力,建立良好的关系。另外值得一提的是,**代码审查可不是说非得给别人挑刺儿,对于做得特别漂亮的地方,要赞扬,这也是建立良好关系的一种途径。**从团队合作和交流的角度来说,程序员往往缺乏沟通,每个人不能只专注于自己的那一份代码默默耕耘,而是需要建立自己的影响力的,代码审查过程中的交互,就是一个不可多得的方式。
**3. 识别出设计的缺陷,找到安全、性能、依赖和兼容性等测试不易发现的问题。**
代码审查在整个软件工程流程中还算早、中期,尽早发现问题就能够尽可能地减少修复问题的成本。而且,代码审查能够发现的问题,往往是其它途径不易发现的。因此,从这个角度来讲,代码审查要有方向性,比如主流程和某些重要用例,在审查的时候可以要求代码变更的程序员提供单元测试,或者是手工覆盖的测试结果,这样就可以认定这些分支覆盖到的逻辑是正确的,不需要在审查时额外关注。
**4. 设立团队质量标杆的最佳实践方式。**
在我经历的团队中,基本上代码审查做得好的,代码质量都高。这不见得是程序员的能力特别出色,而是通过代码审查把这个质量的 bar 顶起来了。你可以想象,一个对别人的代码颇为“挑剔”的人,他会对自己的代码截然相反地糊弄了事,睁一只眼闭一只眼吗?特别对于刚踏入职场的程序员来说,这点尤为重要,要知道一个人刚工作的两三年,对性格、习惯这些关乎职业生涯因素的影响是巨大的,一个好的标杆比任何口号都有效。
## 一些小技巧
最后我们来谈一些小技巧,来帮助这个代码审查的过程顺利进行。
**1. 每次变更所包含的代码量一定要小。**
这一点很重要,代码变更是要给人看的,因此确保变更足够小,能够让它容易理解,审查代码的人,也不会觉得疲劳和有压力。代码清楚了,审查也就可以有效地进行,也更容易得到通过和认可。如果预计代码量大怎么办?可以尝试将其分解成若干个小的变更,一个一个提交。
**2. 让团队中的“牛人”在代码审查中发挥作用。**
团队中的核心成员,可以相对来说少做一点实现,多在设计上做一点参与和决策,多把握代码审查这一环节。以前我在某一个团队中,总代码超过了六十万行,我们实施过这样一种管理方式,将代码划分为几个大的模块,每一模块都指定一个技术责任人,他会对该层代码全面负责,所有的代码变更都要经过他的审查。
**3. 变更代码的质量要超过当前代码库的平均水准。**
代码的审查意见有“建设性意见”和“次要意见”之分,那么那些“次要意见”,例如格式、注释、命名,到底做到什么层次,就会成为一个争论的话题,要求低了代码质量接受不了,而要求高了又会拖慢开发进度。我觉得,这种情况下,可以遵循这样的判断标准:看**新提交的代码会让当前的代码库代码质量更高了,还是更低了**,只有高于当前项目平均质量的代码才能合并入主分支。
**4. 新员工代码,骨架代码的代码审查要更为严格。**
对于新员工的代码审查可以稍微严格一些,这有助于培养良好的质量意识和习惯,前面已经提到了,这对于职业生涯都是有益的。“骨架代码”指的是那些与项目业务无关的架构代码,这部分代码从技术的层面来说更加重要,往往也很考验代码功底,代码审查可以更严格一些。
**5. 及时表达肯定,委婉表达意见。**
只针对代码,不针对人。这听起来很简单,都知道对事不对人的重要性,但是要非常小心不能违背。审查并不是只提反面意见的,在遇到好的实现,不错的想法的时候,可以表示肯定,当然这个数量不宜多,要不然适得其反。至于表达意见方面,我来举几个例子:
- 对于一些次要问题,我都会标注这个问题是一个 picky 或者 nit 的问题(“挑剔的问题”)。这样的好处在于,明确告知对方,**我虽然提出了这个问题,但是它没有什么大不了的,如果你坚持不改,我也不打算说服你。**或者说,我对这个问题持有不同的看法,但是我也并不坚信我的提议更好。
- 使用也许、或许、可能、似乎这样表示不确定的语气词(英文中有时可以使用虚拟语气)。这样的好处是,缓和自己表达观点的语气。比如说:“这个地方重构一下,去掉这个循环,也许会更好。”
- 间接地表达否定。比如说,你看到对方配置了周期为 60 秒,但是你觉得不对,但又不很确定,你可以这样说:“我有一个疑问,为什么这里要使用 60 秒而不是其他值呢?” 对方可能会反应过来这个值选取得不够恰当。你看,这个方式就是使用疑问,而非直接的否定,这就委婉得多。
- 放上例子、讨论的链接,以及其它一些辅助材料证明自己的观点,但是不要直接表述观点,让对方来确认这个观点。比如说:“下面的讨论是关于这个逻辑的另一种实现方式,不知你觉得如何?”
- 先肯定,再否定。这个我想很多人一直都在用,先摆事实诚恳地说一些同意和正面的话,然后用“不过”、“但是”和“然而”之类的将话锋一转,说出相反的情况,这样也就在言论中比较了优劣,意味着这是经过权衡得出的结论。
**6. 审查时,代码要过两遍,第一遍抓主要问题,第二遍看次要问题。**
代码过两遍的好处在于,可以把代码中的问题有层次地提出来。第一遍的时候,搞清楚代码大致的机制、原理、结构,这样有大的建设性问题可以提出来,等待修复或达成一致。根据第一遍的情况来决定需不需要过第二遍,如果没有大的分歧,可以过第二遍。这第二遍就可以非常仔细了,包括可以提出一些细节问题,也包括格式和命名之类的次要问题。总结一下就是,这种方式的最大好处就在于可以让大的问题被单独提出来,优先解决,让问题的讨论和解决有了层次。
## 总结思考
今天的特别放送就到这里,在今天的内容中,我结合自己的经历,向你介绍了代码审查的方方面面,主要涉及了“为什么要做代码审查”以及“怎么样做代码审查”这两个方面。
最后留一个小问题吧,欢迎在留言区一起讨论。
你所在的技术团队代码审查是怎么做的,你有没有什么代码审查上的小技巧愿意分享一下呢?