mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 07:33:47 +08:00
mod
This commit is contained in:
133
极客时间专栏/全栈工程师修炼指南/第六章 专题/34 | 网站性能优化(上).md
Normal file
133
极客时间专栏/全栈工程师修炼指南/第六章 专题/34 | 网站性能优化(上).md
Normal file
@@ -0,0 +1,133 @@
|
||||
<audio id="audio" title="34 | 网站性能优化(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/b0/3f8403c2e2891bf55f7d996ab01c23b0.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
从今天开始我们进入“专题”这一章,本章的内容会涉及一些不适合单独归纳到前面任何一章的话题,比如这一讲和下一讲,我们来聊一聊网站的性能优化。
|
||||
|
||||
具体说,在这两讲性能专题中,第一讲我将介绍性能优化的基本知识,包括性能优化和软件设计的关系,性能指标和关注点,以及怎样去寻找性能瓶颈,这些是我们需要优先明确、分析和思考的内容,而不是没头没脑地直接跳进问题的坑去“优化”;而第二讲则要讲真正的“优化”了,我将从产品和架构调整,后端和持久层优化,以及前端和网络层优化这样三个方面结合例子来介绍具体的性能优化技术。
|
||||
|
||||
## 性能优化与软件设计
|
||||
|
||||
在最开始的部分,我想明确最重要的一件事——也许你已经听说过这句话——“**好的性能是设计出来的,而不是优化出来的**”。这就像是“高质量的软件是设计出来的,而不是测试出来的”一样,强调的都是软件设计的地位。因此,在设计阶段不考虑性能,而寄希望于在上线前后通过集中式的性能优化突击来将网站性能提高到另一个层次,往往是主次颠倒的。
|
||||
|
||||
我来举一个实际的例子说明下这一点吧。
|
||||
|
||||
在我参加工作不久后,曾经参与过的一个项目,用户的所有读写操作都要入库,当时的设计是把这样的数据放到关系数据库一个用户行为的表中,以供后续的查阅。上线以后,由于用户量大,操作频繁,很快这张表就变得巨大,读写的性能瓶颈都在这张表上,而且所有用户的操作都受到了影响,因为所有的操作都要写入这张表,在这时我被卷入这个项目,希望尽快解决这个性能问题。说实话,最初设计的时候没有考虑好,而这时候要做小修小改来解决这个功能的性能问题,是比较困难的。
|
||||
|
||||
具体来说,如果在设计阶段,这里其实是有很多可以考虑的因素的,它们都可以帮助提前介入这个性能隐患的分析和解决:
|
||||
|
||||
**产品设计角度**,用户的行为分为读操作和写操作,我们知道写操作通常更为重要,那用户的读操作是否有那么重要,可不可以只记录写操作?这样的数据有多重要,丢失的代价有多大,数据保密性是怎样的?这决定了一致性要求和数据存储在客户端是否可能。历史数据是怎样消费的,即数据访问模型是怎样的,那个时候 NoSQL 还没有兴起,但是如果不需要复杂的关系查询,这里还是可以有很大的优化空间的。
|
||||
|
||||
**技术实现角度**,能否以某种方式保留最近的若干条记录即可?这将允许某一种方式进行定期的数据淘汰、清理和归档。归档文件的组织形式又和数据访问模型有关,因为归档文件不支持任意的关系查询,那该怎样去支持特定的业务查询模型呢?比如将较老的记录归档到以用户 ID 和时间打散的历史文件中去,目的都是保证表的大小可控。哪怕是面对最糟糕的看似没有办法的“窘境”,只要预先考虑到了,我们还有对于数据库表进行 sharding 或者 partition 这样的看似“不优雅”,但是也能解决问题的通用方法。
|
||||
|
||||
上面我举了几个对于该性能问题的“优化”可以考虑的方面,但相信你也看到了,与其说这些手段是“优化”,倒不如说是“设计”。当时那个项目,我们被迫加班加点,周末时间顶着客户不满赶工出一个线上的临时处理方案来缓解问题,并最终花了一周时间重构这部分设计,才算彻底地解决了这个问题。
|
||||
|
||||
这个教训是深刻的,**从工程上看,整个设计、开发、测试到运维一系列的步骤里面,我们总是不得不至少在某一个步骤做到足够细致和周全,这里的细致指的是深度,而周全则意味着广度。**
|
||||
|
||||
最理想的情况当然是在产品设计阶段就意识到这个问题,但是这也是最难的;如果产品设计阶段做不到,那么开发实现阶段,开发人员通过深入的思考就能够意识到这个问题;如果开发阶段做不到,在测试阶段如果可以进行细致和周全的思考,也可以发现这个问题;如果测试阶段还没有做到,那就只好等着上线以后,问题发生来打脸了。当然,这里面的代价是随着工程流程向后进行而递增的。
|
||||
|
||||
到这里,我想你应该理解了为什么说“好的性能是设计出来的”,但是说说容易做却难。如果由于种种原因,我们还是遇到了需要在项目的中后期,来做性能优化的情况,那么这时候,我们可以遵循这样三条原则来进行:
|
||||
|
||||
- 问题定位,出现性能瓶颈,定位清楚问题的原因,是进行后面优化工作的前提;
|
||||
- 问题解决,有时候问题比较棘手,我们可能会将解决方案划分为临时方案和长期方案,分别进行;
|
||||
- 问题泛化,这指的是将解决一个问题泛化到更大的维度上,比如项目中还有没有类似的隐患?能不能总结经验以供未来的其他开发人员学习?这些是为了提高对未发生问题的预见性。
|
||||
|
||||
## 性能指标与关注点
|
||||
|
||||
在我们从产品经理手里拿到产品设计文档的时候,性能指标就应当有一定程度的明确了。性能指标基本上包含两个大类,一个是从业务角度定义的,另一个是从资源角度定义的。
|
||||
|
||||
通常产品经理给的需求或设计文档中,就应当包含一些关键的业务角度定义的性能指标了,但是资源角度的性能指标,产品经理通常不怎么关心。一般说来,比如我们**最关心的性能指标,也往往就是从业务角度定义的,它可以说是“越高越好”;而资源角度,程序员有时考虑的却是“越低越好”,有时则是“达标就好”。**
|
||||
|
||||
### 1. 业务角度
|
||||
|
||||
你可以回想一下 [[第 21 讲]](https://time.geekbang.org/column/article/156886),我们在那时就介绍了缓存的使用动机,就是节约开销。这和性能指标的关注点如出一辙,既包括延迟(latency)和吞吐量(throughput),这两个同时往往也是业务角度最重要的性能指标,又包括资源消耗,这也完全符合前面说的资源角度。
|
||||
|
||||
延迟和吞吐量,这两个指标往往是应用性能评估中最重要的两个。先来明确二者的定义:延迟,其实就是响应时间,指的是客户端发送请求以后,到收到该请求的响应,中间需要的时间消耗;而吞吐量,指的是系统处理请求的速率,反映的是单位时间内处理请求的能力。
|
||||
|
||||
一般说来,**延迟和吞吐量是既互斥、又相辅相成的关系。**一图胜千言,且看这个典型例子(下图来自[这篇文章](https://docs.voltdb.com/v8docs/PlanningGuide/ChapBenchmark.php),这个图因很有典型意义而被到处转载):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/f8/6925e8b275b09943c3f6918bc8d1f4f8.png" alt="">
|
||||
|
||||
总体来说,随着压力的增大,同等时间内应用被调用的次数增加,我们可以发现这样几个规律:
|
||||
|
||||
- **单独观察 Latency 曲线,延迟总是非递减的。一开始延迟可以说几乎不变,到了某一个特定的值(转折点)以后,延迟突然迅速增大。**
|
||||
- **再来单独观察 Throughput 曲线,吞吐量总是一个先线性增大,后来增长减缓,到达峰值后逐渐减小的过程。**
|
||||
- 再把两者结合起来观察,延迟曲线前面提到的那个转折点,和吞吐量的最高点,通常在一个比较接近的位置上,并且**延迟的转折点往往要略先于吞吐量的最高点出现**。
|
||||
|
||||
一般说来,延迟会先上升,随着延迟的上升,吞吐量增大的斜率会越来越小,直到达到最高点,之后开始回落。请注意这个系统能达到的最佳吞吐量的点,这个点往往意味着延迟已经开始增大一小段时间了,因此对于一些延迟要求比较高的系统,我们是不允许它达到这个吞吐量的最高点的。
|
||||
|
||||
从这个图我们也可以看出,**如果我们不做流量控制,在系统压力大到一定程度的时候,系统并非只能做它“力所能及”的工作,而往往是“什么也做不成”了。**这也是一些不够健壮的系统,在压力较大的特殊业务场景,直接崩溃,对所有用户都拒绝服务的原因。
|
||||
|
||||
其它较常见的业务角度定义的性能指标还包括:
|
||||
|
||||
- TPS:Transactions Per Second,每秒处理的事务数。相似的还有 QPS,Queries Per Second,每秒处理的查询数量等等。
|
||||
- 并发用户数:同一时间有多少用户在使用系统。对于网站来说,由于 HTTP 连接的无状态性,往往会使用服务端的会话数量来定义有多少用户同时访问系统。
|
||||
- TP99:指的是请求中 99% 的请求能达到的性能,这里的 TP 是 Top Percentile 的缩写。比如,我们说某系统响应时间的 TP99 是 1 秒,指的就是 99% 的请求,都可以在 1 秒之内响应,至于剩下的 1% 就不一定了。
|
||||
|
||||
关于 TP99,为什么我们使用这种分布比例的方式,而不是使用简单的平均数来定义呢?
|
||||
|
||||
这是因为**性能平均数在真实的系统中往往不科学**。举例来说,在一个系统的 100 个请求中,99 个都在 1 秒左右返回了,还有一个 100 秒还不返回,那平均时间会大于 (99 x 1 + 1 x 100) / 100,大约等于 2 秒,这显然不能反映系统的真实情况。因为那一个耗时特别长的请求其实是一个异常,而非正常的请求,正常的请求平均时间就是 1 秒,而 TP99 就比较能反映真实情况了,因为 TP99 就可以达到 1 秒。
|
||||
|
||||
有了 TP99 这样的概念,就可以定义系统的 SLA了,SLA 是 Service-Level Agreement,它是系统对于它的客户所承诺的可以提供的服务质量,既包括功能,也包括性能。在性能方面,SLA 的核心定义往往就是基于 Top Percentile 来进行的。比方说,一个网站的 SLA 要求它对于浏览器响应时间的 TP95 为 3 秒。
|
||||
|
||||
### 2. 资源角度
|
||||
|
||||
相应的,资源指标包括 CPU 占用、内存占用、磁盘 I/O、网络 I/O 等等。常规来讲,平均使用率和峰值都是我们比较关心的两个数据。一般我们可以把系统上限的一个比例,比如 60% 或者 80% 定义为一个安全阈值,一旦超过(比如 x 分钟内有 y 次检测到指标超过该阈值,这种复杂指标设置的目的在于考虑性能波动,减少假警报)就要告警。
|
||||
|
||||
## 寻找性能瓶颈
|
||||
|
||||
很多时候,如果我们能找到系统的性能瓶颈,其实就已经解决了一半问题。值得注意的是,性能的瓶颈并不是系统暴露在表面的问题,比如,TP99 的延迟不达标是表面问题,而不是系统瓶颈。
|
||||
|
||||
### 大致思路
|
||||
|
||||
在性能优化这个繁琐的过程当中,一要寻找问题,二才是解决问题,而寻找性能瓶颈就和寻找根本原因有关,也是一个很有意思的事儿。不同的人显然会有不同的看法,我在网站项目中进行性能瓶颈的定位时,常常会遵循下面几个步骤,换句话说,这个步骤来自于我的经验:
|
||||
|
||||
**首先,我们应该对系统有一个大致的预期。**举例来说,根据现有的架构和硬件,在当前的请求模型下,我们希望单台机器能够负载 100 TPS 的请求,这些是设计阶段就考虑清楚的。我看到有些项目中,这些没有在开始定义清楚,等系统大致做好了,再来定义性能目标,这时候把性能优化的工程师推上去优化,就缺乏一定的目的性,谁也不知道优化到什么程度是个头。比方说,当前性能是 80 TPS,那么要优化到 100 TPS,可能小修小补,很小的代价就可以做到;但是要优化到 500 TPS,那么很可能要在产品和架构上做调整,代价往往是很不一样的,没有预期而直接生米煮熟饭显然是不合工程性的。
|
||||
|
||||
**其次,过一遍待优化业务的环境、配置、代码,对于整个请求的处理流程做到心中有数。**说白了,这就是走读代码,这个代码既包括应用的源码,也要了解环境和配置,知道整个流程的处理一步一步是怎样进行的。比如说,浏览器上用户触发事件以后,服务端的应用从线程池里面取得了线程来处理请求,接着查询了几次数据库,每次都从连接池中取出或建立连接,进行若干的查询或事务读写操作,再对响应做序列化返回浏览器,而浏览器收到请求以后又进行解析并将执行渲染。值得一提的是,这个过程中,全栈工程师的价值就体现出来了,因为他能够有端到端的视野来看待这个问题,分析整个流程。
|
||||
|
||||
**再次,开始压测,缓慢、逐步增大请求速率,直到预期的业务角度的性能指标无法再提升,待系统稳定。**有些指标可能一开始就不满足要求,比如某系统的延迟,在一开始就不达标;也有时候是随着压力增大才发现不达标的,比如多数系统的吞吐量,随着压力增大,吞吐量上不去了。通常,我们可以给到系统一个足够的压力,或者等于、甚至高于系统最大的吞吐量,且待系统稳定后,再来分析,这种情况下我们可以把问题暴露得更彻底。
|
||||
|
||||
**最后,才是根据业务角度的指标来进行特定的分析**,我就以最常见的延迟和吞吐量这两个为例来说明。
|
||||
|
||||
**1. 对于延迟一类的问题**
|
||||
|
||||
从大的流程到小的环节,从操作系统到应用本身,逐步定位,寻找端到端的时间消耗在哪里。比方说,可以**优先考虑系统快照类的工具**,像是 jstack、kill -3 命令可以打印系统当前的线程执行堆栈,因为这一类工具往往执行比较简单,对系统的影响较小;如果还需要进一步明确,**其次再考虑侵入性较强的,运行时剖面分析(profile)类型的工具**,比如 JProfiler。如果发现运行的 50 个线程里面,有 48 个卡在等某一个锁的释放上面,这就值得怀疑该锁的占用是造成延迟问题的罪魁祸首。
|
||||
|
||||
**2. 对于吞吐量一类的问题**
|
||||
|
||||
这类问题 CPU 是核心。在请求速率逐步增大时,我们来看这样四种情况:
|
||||
|
||||
- **吞吐量逐步上升,而 CPU 变化很小。**这种情况说明系统设计得比较好,能继续“扛”更高的负载,该阶段可以继续增大请求速率。
|
||||
- **吞吐量逐步上升,而 CPU 也平稳上升。**其实这还算是一种较为理想的系统,CPU 使用随着吞吐量的上升而平稳上升,直到 CPU 成为瓶颈——这里的原因很简单,用于业务处理的 CPU 是真正“干活的”,这部分的消耗显然是少不了的。如果这种情况下 CPU 已经很高了却还要优化,那就是说在不能动硬件的情况下,我们需要改进算法了,因为 CPU 已经在拼命干活了。就像程序员一周已经加班五十个小时了,还要再压迫他们,就要出事(系统崩溃)了。
|
||||
- **吞吐量变化很小甚至回落,而 CPU 大幅上升。**这种情况说明 CPU 的确在干活,却不是用于预期内的“业务处理”。最典型的例子就是,内存泄漏,系统在一个劲地 GC,CPU 看起来忙得要命,但是却不是忙于业务。
|
||||
- **吞吐量上不去,CPU 也上不去。**这种情况其实非常常见,而且随着压力的增大,它往往伴随着延迟的上升。这种情况是说,CPU 无法成为瓶颈,因为其它瓶颈先出现了,因此这种情况下就要检查磁盘 I/O、网络带宽、工作线程数等等。这就像是一个运动员的潜力(CPU)还很大,但是由于种种原因,比如技巧问题、心理原因,潜力发挥不出来,因此我们优化的目的,就是努力找到并移除当前的瓶颈,将 CPU 的这个潜力发挥出来。
|
||||
|
||||
### 工具的分类
|
||||
|
||||
在定位性能瓶颈的过程中,我们会和各种各样的工具打交道,在我看来,这些工具中最典型的有这样三种类型:
|
||||
|
||||
- 截取型:截取系统某个层面的一个快照加以分析。比如一些堆栈切面和分析的工具,jstack、jmap、kill -3、MAT、Heap Analyser 等,这类工具使用简单,对系统影响往往比较小。
|
||||
- 监控型:监视、剖析系统变化,甚至数据流向。比如 JProfiler、JConsole、JStat、BTrace 等等,如前文所述,这类工具的侵入性往往更高,甚至本身就会大幅影响性能。
|
||||
- 验尸型:系统已经宕机或完成运行任务了,但是留下了一些“罪证”,我们在事后来分析它们。最有名的就是 JVM 挂掉之后可能会留下的 hs_err_pid.log,或者是生成的 crash dump 文件。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们学习了性能优化的基本知识,包括性能优化和软件设计的关系,性能指标和关注点,以及怎样去寻找性能瓶颈,希望你能够理解,在跳进性能优化的“坑”之前先有了足够的认识、分析和思考。
|
||||
|
||||
现在,我来提一个问题吧:对于下面这些资源角度定义的性能指标,你能说说在 Linux 下,该用怎样的工具或命令来查看吗?
|
||||
|
||||
- CPU 使用率、负载;
|
||||
- 可用内存、换页;
|
||||
- 磁盘 I/O;
|
||||
- 网络 I/O;
|
||||
- 应用进程、线程。
|
||||
|
||||
最后,欢迎你来分享你在性能优化中做测试和寻找性能瓶颈的故事,我相信这些经历,都会很有意思。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 关于性能指标,我提供两篇文章你可以做扩展,一篇是[美团性能优化之路——性能指标体系](https://tech.meituan.com/2014/03/03/performance-metric.html),另一篇是[性能测试中服务器关键性能指标浅析](https://www.jianshu.com/p/62cf2690e6eb)。
|
||||
- 第二篇也相关,来自阿里云性能测试 PTS 的官方文档,[测试指标](https://help.aliyun.com/document_detail/29338.html?spm=a2c4g.11186623.6.616.5ac314766SuFIs),当然,对于文档中感兴趣的章节你也可以一并通读。
|
||||
|
||||
|
||||
181
极客时间专栏/全栈工程师修炼指南/第六章 专题/35 | 网站性能优化(下).md
Normal file
181
极客时间专栏/全栈工程师修炼指南/第六章 专题/35 | 网站性能优化(下).md
Normal file
@@ -0,0 +1,181 @@
|
||||
<audio id="audio" title="35 | 网站性能优化(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/03/2e25071356ee58399a97ad8d19ffbf03.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
上一讲我们介绍了网站性能优化的基础知识,包括性能指标、关注点,以及寻找性能瓶颈的思路。那么这一讲,我们就来介绍网站性能优化的具体方法,我们将从产品和架构、后端和持久层,以及前端和网络层这样三个部分分别展开。优化的角度和方法可以说花样繁多,我在这里举一些典型的例子,希望既能给你一些内容上的介绍,进而拓宽视野,也能给你一些思考角度上的启发。
|
||||
|
||||
## 产品和架构调整
|
||||
|
||||
对于一个应用来说,产品和架构恰巧是两个互相对立而又相辅相成的角度。作为全栈工程师,我们当然鼓励追求细节,但是在考虑性能优化的时候,我认为还是要**优先考虑从大处着眼,而不是把大量时间花费在小处的细节提升上**,以期望获得较为明显的效果。这里的“大处”,就主要包含了产品和技术架构两个维度。
|
||||
|
||||
### 1. 同步变异步
|
||||
|
||||
如果页面聚合在服务端进行,那么渲染前等待的时间,在整个任务依赖树上面,取决于最慢的一个路径什么时候完成;而如果页面聚合是在客户端进行的,那么页面每一个子区域的渲染往往都可以以 Ajax 的方式独立进行,且同时进行,而母页面则可以首先展示给用户,减少用户的等待时间。
|
||||
|
||||
这里我还想补充一点,我们可以把同步和异步结合起来使用以获得最好的效果。比方说,用户对于网页加载的延迟是很敏锐的,但是用户对于一个页面上不同的信息,关注程度是不同的。
|
||||
|
||||
举例来说,一篇文章,标题可能是最先关注的,其次才是作者或是正文,至于评论、广告等等这些内容,优先级则更低。因此,我们可以让标题和正文内容的第一屏等高优先级的内容在服务端进行聚合后一并同步返回,这就省去了 Ajax 二次调用的时间开销,而次要内容或是某些生成特别耗时的内容,则可以使用异步方式在客户端单独加载。
|
||||
|
||||
### 2. 远程变本地
|
||||
|
||||
有些时候,如果能够容许牺牲一定的一致性,而将数据从远程的数据中心冗余到“本地”这样离数据使用者更近的节点,可以减少数据获取的延迟。DNS 的均衡路由就是一个例子,不同地区的用户访问同一个域名的时候,可以被定向到不同的离自己更近的节点上去;CDN 也是一个很典型的例子,静态资源可以从较近的本地节点获取。
|
||||
|
||||
### 3. 页面静态化
|
||||
|
||||
对于为什么要从大处着眼,我来举一个我在项目中经历过的例子。在我们的一个网站项目,模板中使用了大量的 OGNL 表达式,在做了 profiling 之后,发现 OGNL 表达式占用了相当比例的 CPU,而将其改成 EL 表达式等其它方式,这样做确实可以降低这些 CPU 的使用。这件事情其实没有错,但是这个处理的优先级应该往下放,因为这样的优化,可能只会带来 10% 左右的最终性能提升。
|
||||
|
||||
但是另一方面,我们逐渐引入了页面静态化技术,对于几个关键页面,它直接带来了 300% 到 800% 的性能提升,这就让前面的页面模板的调整显得无足轻重了。性能优化就是这样,并非简单的“一分耕耘一分收获”,有时候我们能找到一些优化的办法,看起来效果很明显,但是调整的代价却不大。
|
||||
|
||||
关于页面静态化,我来举一个 [StackOverflow](https://stackoverflow.com/#) 的例子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/fc/b0980cecf4fdc829e6f931acd8822efc.png" alt="">
|
||||
|
||||
你看这个 StackOverflow 的页面,我们就可以按照页面静态化的原则来分析:把它划分为几个不同的区域,每个区域都可以具备自己特有的一致性要求,无论是页面还是数据,都可以单独做不同条件下的缓存。
|
||||
|
||||
比方说,正上方和左侧区域是不会变的,这些内容可以写在静态的 HTML 页面模板里,可以说是完全“静态”的;中间的主问题区域,则可能是定期或按一定规则刷新的,相当于在过期时间内也是静态的;而右上角和用户相关的数据,则需要每次页面访问实时生成,以便让不同的用户看到特有的属于他自己的内容,也就是说,这部分内容是完全“动态”的。
|
||||
|
||||
页面静态化的实践中,我们可以将页面解耦成不同的部分:
|
||||
|
||||
- 从产品的角度来定义每一个部分允许的一致性窗口,比如有的数据是一小时更新一次,数据可以不用非常准确,而有的则是要实时,数据要求非常准确;
|
||||
- 而技术角度,对于每一部分不同的一致性要求,依赖于缓存的特性,也就是空间换时间,我们可以让每个部分进行分别管理,最终聚合起来(页面聚合的方式请参见 [[第 09 讲]](https://time.geekbang.org/column/article/141817))。
|
||||
|
||||
在极端的情况下,整个页面是可以直接完全被缓存起来,甚至直接预先生成,而做到全页面静态化的,比如一些几乎不存在个性内容的静态博客站点就是如此。
|
||||
|
||||
当然,还有许多常见的其它架构上的设计,起着提高网站性能的作用,没有展开介绍,是因为它们已经被介绍过,或者是大家普遍比较熟悉了。比如说,对反向代理、集群和负载分担的使用,这些技术我们分别在 [[第 28 讲]](https://time.geekbang.org/column/article/165225) 和 [[第 29 讲]](https://time.geekbang.org/column/article/166084) 介绍过,如今几乎所有的大型网站都使用它们来组网。
|
||||
|
||||
## 后端和持久层优化
|
||||
|
||||
### 1. 串行变并行
|
||||
|
||||
道理上很简单,串行的逻辑,在没有依赖限制的情况下,可以并行执行。后端的逻辑如果需要执行多项操作,那么如果没有依赖,或者依赖项满足的情况下,可以立即执行,而不必一个一个挨个等待依次完成。Spring 的 [@Async](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/scheduling/annotation/Async.html) 注解,可以比较方便地将普通的 Java 方法调用变成异步进行的,用这种方法可以同时执行数个互不依赖的方法。
|
||||
|
||||
有些朋友可能知道,Amazon 是 SOA 架构(Service-Oriented Architecture,面向服务架构)最早的践行者。贝索斯在 2002 年的时候,就开始要求 Amazon 内部的所有服务,都只能以 Web 接口的形式暴露出来,供其他团队调用,而每个服务,都由专门的团队维护。如今,访问亚马逊的一个商品页,借助 SOA 架构,后台都要调用成百上千个开放的服务,你可以想象,如果这些调用是串行进行的,页面的加载时间将难以想象。
|
||||
|
||||
### 2. 数据库索引创建
|
||||
|
||||
凡是提到关系数据库的优化,索引的创建往往是很容易想到的优化方法之一。而对于某些支持半结构化数据存储的非关系数据库,往往也对索引存在有限的支持。
|
||||
|
||||
索引的创建,最常见的一个原因,是为了在查询的时候,显著减少消耗的时间。由于索引是单独以 B 树或者是 B+ 树等变种来存储的,而我们知道这样的数据结构查询的速度可以达到 log(n),和原表相比,索引在检索时的数据的读取量又很小,因此查询速度的提升往往是立竿见影的。
|
||||
|
||||
但是索引创建并非是没有代价的,在关系数据库中,索引作为原表中索引列数据的一份冗余,维护它自然是有开销的,每当索引数据增、删、改发生的时候,索引也需要相应地发生变化。
|
||||
|
||||
### 3. 数据库表拆分
|
||||
|
||||
数据库表拆分也是一个很常见的优化方式,这里的拆分分为横向(水平)拆分和纵向(垂直)拆分两种。
|
||||
|
||||
**横向拆分,指的是把业务意义上的同一个表,拆分到不同的数据库节点或不同子表中,也就是说,这些节点中的表结构都是一样的,当然,存储的数据是不一样的。**
|
||||
|
||||
我们前面介绍过的 Sharding 和 Partitioning 就是属于这一类型。那么,在查询或修改的时候,我们怎么知道数据在哪台机器上呢?
|
||||
|
||||
这就可以根据主键做 hash 映射或者范围映射来找到相应的节点,再继续进行操作了。Hash 映射本专栏已经介绍过了,而范围映射也很常见,比如用户的交易数据,3 月份的数据一个表,4 月份的数据一个表,这就是使用时间来做范围条件的一个例子。
|
||||
|
||||
**再来说说垂直拆分,说的是把一个表拆成多个表,甚至拆到多个库中,这时的拆分是按照不同列来进行的,拆分出的表结构是完全不一样的,表和表之间通常使用外键关联。**比如有这样一个文件表:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/2d/c91431880c44619198a143488ef15a2d.jpg" alt="">
|
||||
|
||||
各列分别为:唯一 ID、标题、创建日期、描述,以及以 BLOB 格式存储的文件具体内容(CONTENT)。
|
||||
|
||||
在这种情况下,如果执行全表扫描的查询,在文件非常大且记录数非常多的情况下,执行的过程会非常慢。这是因为一般的关系数据库是行数据库,数据是一行一行读取的(关于行数据库和列数据库的区别和原理,你可以回看 [[第 25 讲]](https://time.geekbang.org/column/article/161829)),磁盘一次读取一块,这一块内包含若干行。那么由于 CONTENT 列往往非常大,每次读取只能读到非常少的行,因此需要读取很多次才能完成全表扫描。这种情况下,我们就可以做这样的拆表优化,把这个表拆成如下两个:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/8d/5b2e4b69ea8981ead2938ed27de43f8d.jpg" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/9d/06d16222996df1acba163cace9b9889d.jpg" alt="">
|
||||
|
||||
你看,第一个表一下子就瘦身下来了,这个表没有了那个最大的 BLOB 对象组成的列,在全表扫描进行查询的时候,就可以比较快地进行,而当找到了相应的 ID 并需要取出 CONTENT 的时候,再根据 ID 到第二个表里面去查询出具体需要的文件来。
|
||||
|
||||
你可能注意到了,这种优化的动机和前提有这样两个:
|
||||
|
||||
- **查询无法单纯地走索引完成,而需要进行全表扫描或部分表扫描;**
|
||||
- **某列或某几列占用空间巨大,而它们却并不需要参与关系查询。**
|
||||
|
||||
当这两个条件都符合的时候,我们就可以考虑垂直拆分(纵向拆表)了。
|
||||
|
||||
### 4. 悲观锁变乐观锁
|
||||
|
||||
在关系数据库中,如果我们提到了“锁”,就意味着我们想让数据库某条数据的写操作变得“安全”,换言之,当我们需要根据某些条件而对数据进行更改的时候,不会受到并发的其它写操作的影响,而丧失正确性或完整性。在使用“锁”来实现的时候,有悲观锁和乐观锁两种实现。
|
||||
|
||||
所谓悲观锁(Pessimistic Locking),指的是数据库从“最坏”的角度考虑,所以它会先使用排它锁锁定相应的行,进行相应的读判断和写操作,一旦成功了,再提交变更并释放锁资源。**在使用悲观锁锁定相应行的过程中,如果有其它的写操作,是无法同时进行的,而只能等待。**且看这样一组基于 SQL 的例子,它用于将用户的积分更新:
|
||||
|
||||
```
|
||||
select POINTS from USERS where ID=1001 for update;
|
||||
... (省略,计算得出积分需要变更为123)
|
||||
update USERS set POINTS=123 where ID=1001;
|
||||
|
||||
```
|
||||
|
||||
我来简单做个说明:
|
||||
|
||||
- 第一行,使用 “for update” 这个技巧来锁定 ID 为 1001 的记录,查询出当前的积分;
|
||||
- 第二行,业务逻辑得到积分需要如何变更,假如说得出的结果是积分需要变更为 123;
|
||||
- 第三行,执行积分变更(这里假设事务在 update 之后是配置为自动提交的);
|
||||
|
||||
这样一来,如果有两条请求同时想执行以上逻辑,那么第一条请求可以执行成功,而第二条会一直等在那里,直到第一条执行完成,它再去执行。这种方式就保证了锁机制的有效性。
|
||||
|
||||
下面再来说说乐观锁(Optimistic Locking)。它和悲观锁正好相反,这种情况假定“大多数”的操作发生锁冲突的概率较小,使用一个当前版本号,来表示当前记录的版本。**乐观锁方式下,不需要使用显示的加锁、提交这样的操作,但缺点是一旦发生冲突,整个过程要重来。**我们可以把前面的步骤变成下面这样:
|
||||
|
||||
```
|
||||
select POINTS, VERSION from USERS where ID=1001;
|
||||
... (省略,计算得出积分需要变更为123)
|
||||
update USERS set POINTS=123 and VERSION=VERSION+1 where ID=1001 and VERSION=1;
|
||||
|
||||
```
|
||||
|
||||
- 第一行,在读取积分的时候,也一并读取到了当前的版本号,假设版本号是 1;
|
||||
- 第二行,业务逻辑得到积分需要如何变更,假如说得出的结果是积分需要变更为 123;
|
||||
- 第三行,更新积分为 123 并自增版本号,但是条件是版本号为 1。之后需要检测这条 update 语句影响的代码行数:如果影响的行数为 1,说明更新成功,程序结束;如果影响的行数为 0,说明在第一行读取数据以后,记录发生了变更,需要重新执行整个过程。
|
||||
|
||||
还有许许多多其它的后端和持久层的优化通用技术,这里就不展开了。比如缓存的应用,互联网应用有句话叫做“缓存为王”,缓存的本质就是空间换时间,在 [[第 21 讲]](https://time.geekbang.org/column/article/156886) 中我们曾经仔细聊过这部分的内容。再比如,应用之外,我们还经常需要从应用宿主和操作系统等角度来考虑,对于这部分,我在扩展阅读中我给出了一些材料供你阅读。
|
||||
|
||||
## 前端和网络层优化
|
||||
|
||||
当我们思考前端优化的时候,和后端一样,我们可以考虑连接、下载、解析、加载、渲染等等整个过程,先从大局上对整个时间消耗的分布有一个把握,下图来自[这篇](https://tech.meituan.com/2014/03/03/performance-metric.html)文章。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/91/7321dc1c3cc44bc7a15b1efaf2e92391.jpeg" alt="">
|
||||
|
||||
### 1. 减少请求次数
|
||||
|
||||
这是进行许多前端优化的共同目标之一,有不少全栈工程师日常在进行的实践,都遵循了这一条原则。
|
||||
|
||||
- 文本资源:CSS 压缩、JavaScript 压缩。有两个静态资源的后期处理方式我们经常结合起来使用,一个是压缩,一个是混淆。前者将多个文件压缩成一个,目的是减少大小,而更重要的是减少请求数;后者则是将变量使用无意义的名称替代,目的就是让产品代码“难懂”,减少代码实际意义的泄露。
|
||||
- 图像资源:[CSS Sprites](https://en.wikipedia.org/wiki/Sprite_(computer_graphics)#Sprites_by_CSS) 和 [Inline Image](https://en.wikipedia.org/wiki/Data_URI_scheme)。前者又叫做雪碧图、精灵图,是将网站所用的多张图片拼成一张,这样多张图片只需要下载一次,而使用 CSS 中的 background-image 和 background-position 在目标位置选择显示哪一张图片。后者则是干脆将二进制的图片使用 Base64 算法序列化成文本,直接嵌入在原始页面上。
|
||||
- 缓存控制头部。即 Cache-Control 头,这部分我们在 [[第 21 讲]](https://time.geekbang.org/column/article/156886) 已经介绍过;还有 Etag 头,浏览器会把它发送给服务端用于鉴别目标资源是否发生了更改,如果没有更改,一个 304 响应会返回;以及 Expires 头,是服务端来指定过期时间的。
|
||||
|
||||
### 2. 减少渲染次数
|
||||
|
||||
CSS 或者 DOM 的变化都会引发渲染,而渲染是由单独的线程来进行的,这个过程会阻塞用户的操作。对于较大的页面、DOM 较多的页面,浏览器渲染会占用大量的 CPU 并带来明显的停顿时间。
|
||||
|
||||
渲染其实包括两种,一种是 reflow,就是页面元素的位置、间隔等等发生更改,这个工作是由 CPU 完成的;另一种叫做 repaint,基本上就是当颜色等发生变更的时候就需要重新绘制,这个工作是由 GPU 完成的。
|
||||
|
||||
因此,如果我们能够减少反复、多次,或是无意义的渲染,就可以在一定程度上为 Web 应用提速,特别是 reflow。那么对于这方面优化的其中一个思路,就是合并操作,即可以合并多个 DOM 操作为一次进行,或是合并单个 DOM 的多次操作为一次进行(React 或者 Vue.js 的 Virtual DOM 技术就借鉴了这种思路)。
|
||||
|
||||
### 3. 减少 JavaScript 阻塞
|
||||
|
||||
JavaScript 阻塞,本质上是由于 JavaScript 解释执行是单线程所造成的,阻塞期间浏览器拒绝响应用户的操作。同步的 Ajax 调用会引发阻塞(一直阻塞到响应返回),耗时的 JavaScript 代码执行也会引起阻塞。
|
||||
|
||||
我们通过将大的工作分裂成多次执行(可以通过每次具备一定间隔时间的回调来实现),每次执行后主动让出执行线程,这样每次就可以只阻塞一小会儿,以显著减少 JavaScript 阻塞对用户造成的影响;而对于一些独立的耗时操作,可以引入 [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) 分配单独运行的线程来完成。
|
||||
|
||||
### 4. 文本消息压缩
|
||||
|
||||
对于页面这样的文本内容,通过配置 Web 容器的 gzip 压缩,可以获得很好的压缩比,从而减小消息体的大小,减少消息传输的时间,代价是压缩和解压缩需要消耗 CPU 时间。
|
||||
|
||||
**这种优化的本质是时间换空间,而前面介绍过的缓存本质则是空间换时间**,二者比起来,刚好是相反的。可有趣的是,它们的目的却是一致的,都是为了缩短用户访问的时间,都是为了减少延迟。
|
||||
|
||||
对于前端的优化,技巧比较零散,可能有不少朋友会想起那最著名的 [35 条军规](https://developer.yahoo.com/performance/rules.html)(中文译文有不少,比如[这里](https://github.com/creeperyang/blog/issues/1)),这篇文章是如今很多前端技能优化学习首先要阅读的“老文章”了。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我从产品和架构、后端和持久化,以及前端和网络层三个角度,结合一些具体的技巧,向你介绍了一些常见的网站性能优化方法。网站的性能优化是一个大课题,希望你在学完上一讲和这一讲之后,你能从前到后比较全面地去分析和思考问题。
|
||||
|
||||
下面我来提两个问题吧:
|
||||
|
||||
- 你在项目中是否做过性能优化的工作,能否介绍一下你都进行了哪些有效的优化实践呢?
|
||||
- 文中介绍了数据库表拆分的两种方式,水平拆分和垂直拆分,它们都带来了显而易见的好处。可是,我们总是需要辩证地去看待一项技术,你能说出它们会带来哪些坏处吗?
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 文中提到了 Amazon 对于 SOA 的实践,你可以阅读[这篇文章](http://www.ruanyifeng.com/blog/2016/09/how_amazon_take_soa.html)简单了解一下这个故事。
|
||||
- 本文主要讲的还是应用层面的调优,没有介绍虚拟机、容器等性能优化和操作系统的性能优化。如果你对它们感兴趣的话,我在这里推荐两个材料。关于 JVM 调优,可以参看 [Java 应用性能调优实践](https://www.ibm.com/developerworks/cn/java/j-lo-performance-tuning-practice/index.html)这篇,而操作系统层面的性能优化,你可以从 [Linux 性能调优指南](https://lihz1990.gitbooks.io/transoflptg/content/)这个材料中找感兴趣的阅读。
|
||||
- [从Webkit内部渲染机制出发,谈网站渲染性能优化](https://imweb.io/topic/5b4d417eee0c3b0779df96d9),这篇文章是从浏览器的机制这个角度来讲性能优化的,推荐一读。
|
||||
- 文中介绍了 reflow 和 repaint,对于这方面的优化可以阅读 [reflow和repaint引发的性能问题](https://juejin.im/post/5a9372895188257a6b06132e)这篇文章。
|
||||
|
||||
|
||||
230
极客时间专栏/全栈工程师修炼指南/第六章 专题/36 | 全栈开发中的算法(上).md
Normal file
230
极客时间专栏/全栈工程师修炼指南/第六章 专题/36 | 全栈开发中的算法(上).md
Normal file
@@ -0,0 +1,230 @@
|
||||
<audio id="audio" title="36 | 全栈开发中的算法(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/9d/c4abf6ace090c5a266479ba7decb3c9d.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
在本专栏中,我们已经接触到了全栈开发中的一些算法了。在这一讲和下一讲中,我又精心挑选了几个比较重要的。**和单纯地从数学角度去介绍算法不同,我想结合几个全栈开发中实际、典型的问题场景,向你介绍这几个相关的重要算法。**毕竟,我们关心的算法,其实就是可以解决实际问题的方法的一种数学抽象。
|
||||
|
||||
希望通过这两讲的学习,你能理解这些算法。除了理解算法原理本身,我们更要抓住它们的用途和算法自身的巧妙之处。今天我们来讲其中的第一个典型的问题场景——流量控制。
|
||||
|
||||
## 流量控制系统中的算法
|
||||
|
||||
对于全栈工程师来说,无论是网站,还是其它 Web 应用,一旦对外商用,就要考虑流量控制的问题。因此我们往往需要设计使用单独的流量控制模块,我们来看下面这样的一个问题。
|
||||
|
||||
假如说,我们现在需要给一组 Web API 设计一个流量控制系统,以避免请求对系统的过度冲击,对于任意一个用户账户 ID,每一个 API 都要满足下面所有的要求:
|
||||
|
||||
- 每分钟调用不能超过一万次;
|
||||
- 每小时调用不能超过十万次;
|
||||
- 每天调用不能超过一百万次;
|
||||
- 每周调用不能超过一千万次;
|
||||
- ……
|
||||
|
||||
在继续往下阅读之前,请你先从算法和数据结构的角度思考,你觉得该怎么设计这个流量控制系统呢?
|
||||
|
||||
### 简化问题
|
||||
|
||||
在解决实际问题的时候,我们面临的问题往往是复杂的、多样的,因此,我们可以**考虑能不能先简化问题,再来尝试映射到某一个数学模型上。那些先不考虑的复杂条件,有的可能就是可以忽略掉的,而有的则是为了思路的清晰。一开始我们可以先忽略,有了解题的方法原型以后,再逐步加回来考虑。**
|
||||
|
||||
那就这个问题而言,我可以做如下的简化:
|
||||
|
||||
- 有大量的用户账户 ID,但是我们现在只考虑某一个特定的账户 ID,反正其它账户 ID 的做法也是一样的;
|
||||
- 这里面有多个 Web API,但是我们可以只考虑其中特定的一个 API,反正其它 API 也是类似的;
|
||||
- 这里面有多条规则,但是我们可以只考虑其中的一个规则,即“每分钟调用不能超过一万次”,至于其它的规则,原理上也是一样的。
|
||||
- 为了简化问题,在这里我们也暂不考虑并发、分布式、线程安全等问题。
|
||||
|
||||
好,现在问题就简单多了,当我们把这个简化了的问题解决了之后,我们再引入多个用户 ID、多个 API和多条规则这样的维度:
|
||||
|
||||
```
|
||||
public class RateLimiter {
|
||||
public boolean isAllowed() {
|
||||
... // 当每分钟调用不超过 10000 次,就返回 true,否则返回 false
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 简单计数
|
||||
|
||||
好,最先进入脑海的是采用简单计数的办法,我们给 RateLimiter 一个起始时间的时间戳。如果当前时间在距离起始时间一分钟以内,我们就看当前已经放进来了多少个请求,如果是 10000 个以内,就允许访问,否则就拒绝访问;如果当前时间已经超过了起始时间一分钟,就更新时间戳,并清零计数器。参考代码如下:
|
||||
|
||||
```
|
||||
public class RateLimiter {
|
||||
private long start = System.currentTimeMillis();
|
||||
private int count = 0;
|
||||
|
||||
public boolean isAllowed() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now-start > 60*1000) {
|
||||
start = now - (now-start)%(60*1000); // 所在时间窗口的起始位置
|
||||
count = 0;
|
||||
}
|
||||
|
||||
if (count < 10000) {
|
||||
count++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样这个问题似乎就得到了解决。**可是,刚才我们在解决问题的时候,似乎“擅自”强化了一个条件……**
|
||||
|
||||
### 从固定窗口到滑动窗口
|
||||
|
||||
这个条件就是“固定时间窗口”。
|
||||
|
||||
举个例子,从 3:00 到 3:01 这一分钟时间内,假如系统收到了 9000 个请求,而在 3:01 到 3:02 这接着的一分钟内,系统也收到了 9000 个请求,二者都满足要求。但是,这是我们给了一个假定的增强条件——固定时间窗口,而得到的结论。
|
||||
|
||||
假如说前面这 9000 个请求都分布在 3:00:30 到 3:01:00 之间,后面这 9000 个请求都分布在 3:01:00 到 3:01:30 之间,即从 3 点 00 分 30 秒 到 3 点 01 分 30 秒这一分钟内,系统居然接纳了 9000 + 9000 = 18000 个请求。因此,如果我们考虑的是“滑动时间窗口”,这显然违背了我们的每分钟一万次最大请求量的规则。请看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/1b/2d376db8e707852fc10d21530837fa1b.png" alt="">
|
||||
|
||||
因此,相较来说,更实际的情况下,我们是要支持滑动时间窗口,也就是任意一分钟的时间窗口内,都要满足小于 10000 请求量的规则。看来,简单计数法需要改进。
|
||||
|
||||
### 时间队列
|
||||
|
||||
**对于滑动窗口的问题,我们经常要引入队列来解决问题。**因为队列的特点是先进先出,一头进,另一头出,而很多滑动窗口的问题,恰恰需要这个特性,这个问题也不例外。
|
||||
|
||||
假如我们维护一个最近时间戳的队列,这个队列长度不能超过 10000,那么,当新的请求到来的时候,我们只需要找到从“当前时间减 1 分钟”到“当前时间”这样一个滑动窗口区间。如果队列的尾部有任何存储的时间戳在这个区间之外(一分钟以前),那我们就把它从队列中拿掉。如果队列长度小于 10000,那么这个新的请求的时间戳就可以入队列,允许请求访问;反之,则不允许请求访问。请看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/e0/8002fa4ef1491a6ec8fd32d504e9bae0.png" alt="">
|
||||
|
||||
这个过程,参考代码如下:
|
||||
|
||||
```
|
||||
public class RateLimiter {
|
||||
private Queue<Long> queue = new LinkedList<>();
|
||||
|
||||
public boolean isAllowed() {
|
||||
long now = System.currentTimeMillis();
|
||||
while (!queue.isEmpty() && queue.peek() < now-60*1000) {
|
||||
// 如果请求已经是在一分钟以前了,忽略
|
||||
queue.remove();
|
||||
}
|
||||
if (queue.size() < 10000) {
|
||||
queue.add(now);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以看到,这个算法从时间消耗上看,颇为高效,但是在支持滑动窗口的同时,我们也能看到,付出的代价是一个数量级上相当于窗口宽度的空间复杂度,其实它就是这个队列的空间消耗,在这里队列最大长度就是 10000。
|
||||
|
||||
如果我们允许队列的长度较大,队列造成的空间消耗和单个处理请求的最大时间消耗就可能会成为问题,我们能优化一下吗?
|
||||
|
||||
能。那么这种情况下,**一种“妥协”的办法就是,队列中的每个元素,不再是实际请求精确到毫秒的时间戳,而是特定某一秒中包含的请求数目**,比如队列的其中一个元素表示 3:00:01 到 3:00:02 之间对应有 150 个请求。用这种方法,对于上述这个一分钟内流量限制的问题,我们可以把队列长度严格控制在 60(因为是 60 秒),每个元素都表示特定某一秒中的请求数目。当然,这个方法损失的是时间窗口毫秒级的精度。而这,就是我们控制时间窗口队列的长度所采用的一种较为常见的优化方式,它虽**损失了精度,但却降低了空间复杂度**。
|
||||
|
||||
好,规则已经做到严格匹配了,可是在实际应用中,在很多情况下,这还是有问题。为什么呢?
|
||||
|
||||
### 细化控制粒度
|
||||
|
||||
这要从流量控制的动机说起,**我们建立流量控制这个系统的目的,是为了避免对于系统的冲击,而无论使用固定窗口,还是滑动窗口,根据当前的规则,我们都只能限定这个一分钟窗口内的流量符合要求,却不能做到更细粒度的控制。**
|
||||
|
||||
举个极端的例子,一分钟内这一万个请求,如果均匀地分布在这一分钟的窗口中,系统很可能就不会出问题;但如果这一万个请求,全部集中在最开始的一秒钟内,系统就压垮了,这样的流量控制就没有起到有效的防御作用了。
|
||||
|
||||
那好,如果我们要做到系统可以接受的更细的粒度。举例来说,如果我们可以做到按秒控制,那么继续按照 10000 个/分钟来计算的话,这个限制就可以换算成不要超过 10000/60 ≈ 167 个/秒。
|
||||
|
||||
### 漏桶算法
|
||||
|
||||
漏桶(Leaky Bucket)算法就是可以带来更细粒度控制的限流算法,它的粒度取决于系统所支持的准确最小时间间隔,比如毫秒。
|
||||
|
||||
你可以想象一个有缺漏的桶,无论我们怎样往里面放水(发送请求),水都有可能以两种方式从桶中排出来:
|
||||
|
||||
- 从漏口往外流,如果桶中有水,这个流速是一定的(这就是**系统满载时,限流的流速**);
|
||||
- 注水太快,水从桶中溢出(这就是**请求被拒绝了,限流效果产生**)。
|
||||
|
||||
另外,由于请求的最小单位是一个,因此桶的大小不得小于 1。我们要求请求发送的速度不得小于漏水的速度,但我们更多时候会设置一定的桶容量,这就意味着系统允许一定程度的富余以应对突发量。这个桶大小,也就是突发量,被称为 burst。
|
||||
|
||||
于是,我们每次都可以根据流速以及上一次的流量检测时间,获知在考虑漏水的情况下,如果接纳当前请求,那么桶中将达到怎样的水位,是否会超过 burst。如果不超过,就允许此次访问,反之拒绝。参考代码如下:
|
||||
|
||||
```
|
||||
public class RateLimiter {
|
||||
private float leakingRate = 10000f/60/1000; // 每一毫秒能够漏掉的水
|
||||
private float remaining = 0; // 桶中余下的水
|
||||
private float burst = 10000; // 桶容量
|
||||
private long lastTime = System.currentTimeMillis(); // 最近一次流量检测时间
|
||||
|
||||
public boolean isAllowed() {
|
||||
long now = System.currentTimeMillis();
|
||||
remaining = Math.max(0, remaining - (now-lastTime)*leakingRate); // 如果漏完了,余下的就是0,不能出现负数
|
||||
lastTime = now;
|
||||
|
||||
if (remaining+1 <= burst) {
|
||||
remaining++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从复杂度上你也可以看到,我们通过变量 remaining 记录每一个请求到达的时刻,桶中水的余量,整个空间复杂度是常量级的。当然了,我们的控制已经不是针对“一分钟规则”了,控制粒度上更加细化,更符合我们对系统保护的实际要求,因此这个方法的应用更广。
|
||||
|
||||
### 令牌桶算法
|
||||
|
||||
还有一种和漏桶算法本质上一致,但是实现上有所不同的方法,叫做令牌桶(Token Bucket)算法。说它们实现上不同是因为,漏桶是不断往外漏水,看能不能把陆续到来的请求给消耗掉;而令牌桶呢,则是在令牌桶内会定期放入令牌,每一个请求到来,都要去令牌桶内取令牌,取得了才可以继续访问系统,否则就会被流量控制系统拒绝掉。
|
||||
|
||||
就像我们的问题,每 60*1000/10000 = 6 毫秒就要向令牌桶内放置一个令牌。和前面的漏桶算法一样,我们并不一定要真的建立一个放入令牌的线程来做这个放入令牌的工作,而是使用和上面类似的算法,在请求到来的时候,根据上次剩余的令牌数和上次之后流逝的时间,计算当前桶内是否还有完整的一张令牌,如果没有令牌,就拒绝请求,否则允许请求。因此,从这个角度说,漏桶和令牌桶这二者在思想本质上是一致的。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我通过一个常见的流量控制系统,向你介绍了全栈开发中几个典型的算法,包括基于固定时间窗口的简单计数法,滑动时间窗口的队列法,还有实际应用中更为常见的漏桶算法和令牌桶算法。希望通过今天的学习,你已经理解了它们的工作原理。
|
||||
|
||||
现在我来提两个问题吧:
|
||||
|
||||
- 漏桶算法我给出了示例代码,而具有一定相似性的令牌桶算法我没有给出示例代码,如果你理解了这两者,能否写出令牌桶算法的代码呢?
|
||||
- 为了简化问题,我在一开始的时候讲了,我们不考虑并发的问题。现在,如果我们把上面无论哪一种算法的代码,改成支持多个线程并发访问的情形,即要求保证线程安全,你觉得需要对代码做怎样的修改呢?
|
||||
|
||||
## 选修课堂:Diffie–Hellman 密钥交换
|
||||
|
||||
我们在 [[第 02 讲]](https://time.geekbang.org/column/article/135864) 中介绍 HTTPS 加密的时候,提到了 Pre-master Secret 生成的方式,其中一种就是 Diffie–Hellman 密钥交换这一算法的变种(如有遗忘,请回看),但是,我们并没有讲其中加密具体的算法原理。那么,下面我就来看一下 Diffie–Hellman 密钥交换,这个常见的 HTTPS 加密算法,是怎样做到**正向计算简单、逆向求解困难**,来保证安全性的。
|
||||
|
||||
### 密钥计算过程
|
||||
|
||||
Diffie–Hellman 密钥交换是一种在非保护信道中安全地创建共享密钥方法,它的出现在如今众所周知的 RSA 算法发明之前。现在让我们来玩一个角色扮演游戏,假设你要和我进行通信,我们就来使用这种办法安全地创建共享密钥:
|
||||
|
||||
- 通信的你和我都协议商定了质数 p 和另一个底数 g;
|
||||
- 你呢,先生成一个只有你自己知道的随机整数 a,并将结果 A = gᵃ mod p 发给我;
|
||||
- 我呢,也生成一个只有我自己知道的随机整数 b,并将结果 B = gᵇ mod p 发给你;
|
||||
- 你根据我发过来的 B,计算得到 s = Bᵃ mod p;
|
||||
- 我根据你发过来的 A,计算得到 s’ = Aᵇ mod p。
|
||||
|
||||
这个过程用简单的图示来表示就是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/3c/6d40d7bcf10858022fabf9c492626b3c.png" alt="">
|
||||
|
||||
你看,整个过程中,**只有 a、b 这两个数分别是你和我各自知道并保密的,而其它交换的数据全部都是公开的。**对于你来说,已经有了 a,又得到我传过来的 B,于是你算出了 s;对于我来说,已经有了 b,又得到了你传过来的 A,于是我算出了 s’。
|
||||
|
||||
有趣的是,经过计算,你得到的 s 和我得到的 s’,这两个数总是相等的,既然相等,那这个值也就可以用作你我之间通信的对称密钥了。也就是说,**通信双方分别算得了相等的密钥,这也就避免了密钥传递的风险。**可是,为什么 s 和 s’ 它们是相等的呢?
|
||||
|
||||
### 质数和模幂运算
|
||||
|
||||
因为,g 的 a 次方再 b 次方,等于 g 的 b 次方再 a 次方,即便每次幂运算后加上 p 来取模,也不影响最后结果的相等性,换言之:
|
||||
|
||||
**gᵃᵇ mod p = (gᵃ mod p)ᵇ mod p = (gᵇ mod p)ᵃ mod p**
|
||||
|
||||
上面这样的,先求幂,再取模的运算,我们把它简单称为“模幂运算”。在实际应用中,g 可以取一个比较小的数,而 a、b 和 p,都要取非常大的数,而且 p 往往会取一个“极大”的质数——因为质数在此会具备这样一个重要性质,模幂运算结果会在小于 p 的非负整数中均匀分布;而另外一个原因是,由于 g 的 a 次方或 b 次方会非常大,需要一个“上限”,一个使得生成的数无论是传输还是存储都能够可行的方法。**因此大质数 p 的取模运算被用来设定上限并将大数化小,且保持原有的逆向求解困难性。**
|
||||
|
||||
说到逆向求解的困难性,这是根据数学上[离散对数](https://zh.wikipedia.org/wiki/%E7%A6%BB%E6%95%A3%E5%AF%B9%E6%95%B0)求解的特性所决定的,具体说来,就是这样一个模幂等式:
|
||||
|
||||
**gᵃ mod p = A**
|
||||
|
||||
从难度上看,该式具有如下三个特性:
|
||||
|
||||
- 特性 ①:已知 g、a 和 p,求 A 容易;
|
||||
- 特性 ②:已知 g、p 和 A,求 a 困难;
|
||||
- 特性 ③:已知 a、p 和 A,求 g 也困难。
|
||||
|
||||
正好,Diffie–Hellman 密钥交换利用了其中的特性 ① 和特性 ②。比如 a 是超过 100 位的正整数,而 p 则达到了 300 位,那么在这种情况下,如果有恶意的攻击者,得到了 g、p,截获了 A,但是他根据这些信息,考虑我们前面介绍的公式 A = gᵃ mod p,在现有科技能达到的算力下,几乎是无法求解出其中的 a 来的。无法知道 a,无法进而求得对称密钥 s(因为 s 需要通过 Bᵃ mod p 求得),这就起到了加密的作用,这也是 Diffie–Hellman 密钥交换能够实现的原理。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 【基础】文中我提到了算法的时间复杂度和空间复杂度,这是属于算法的基础知识。如果不太熟悉的话可以阅读一下这个[词条](https://zh.wikipedia.org/wiki/%E7%AE%97%E6%B3%95#%E5%A4%8D%E6%9D%82%E5%BA%A6),以及[这篇](https://zhuanlan.zhihu.com/p/50479555)文章,而在[这里](https://zh.wikipedia.org/wiki/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6#%E5%B8%B8%E8%A7%81%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6%E5%88%97%E8%A1%A8)则有常见算法的时间复杂度列表。
|
||||
- 选修课堂中介绍的 Diffie–Hellman 密钥交换利用了模幂公式的“正向计算简单,逆向求解困难”这一特点,这个特点非常重要,还有一个相关的技术 RSA 也利用了这一特点。本来我是把 RSA 加密技术的原理介绍和 Diffie–Hellman 密钥交换放在一起讲述的,但是经过仔细斟酌,我觉得 RSA 涉及到的数学知识稍多,整体理解起来明显偏难,因此为了专栏内容和难度的一致性,我忍痛把它拿出去了,并放在了我自己的博客上,感兴趣的话可以[移步阅读](https://www.raychase.net/5698)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/8b/1181246bbd51ce348d5729696d1dc28b.jpg" alt="unpreview">
|
||||
203
极客时间专栏/全栈工程师修炼指南/第六章 专题/37 | 全栈开发中的算法(下).md
Normal file
203
极客时间专栏/全栈工程师修炼指南/第六章 专题/37 | 全栈开发中的算法(下).md
Normal file
@@ -0,0 +1,203 @@
|
||||
<audio id="audio" title="37 | 全栈开发中的算法(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/64/0f/64a423792e2588cb60775a000d968a0f.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
今天,我们来继续学习一些全栈开发中影响深远的算法,我们这次的归类是无损压缩算法。无损压缩,顾名思义就是经过压缩以后,数据的大小降下来了,但是只要经过还原,原始数据是一点都不丢失的。和无损压缩对应的,显然就叫做“有损压缩”了,它们能够做到在牺牲一定程度原数据质量的基础上,比有损压缩获得额外的压缩比。
|
||||
|
||||
无损压缩的算法其实有很多,但无论是哪一种,里面都没有多么神奇的把戏,**它们的最基本原理都是一致的,就是利用数据的重复性(冗余性)。**在经过某种无损压缩以后,由于数据的重复性已经降下来了,因此再压缩就无法获益了。
|
||||
|
||||
## 哈夫曼编码
|
||||
|
||||
不管是在哪一本系统介绍压缩算法的书中,那么多的无损压缩算法,哈夫曼编码(Huffman Coding)基本都是需要我们最先接触学习的那一个。哈夫曼编码从原理上看,它会根据字符的出现频率,来决定使用怎样的编码方式,并且是**变长编码**的一种。相应地,程序员熟悉的 ASCII 码,就是**定长编码**,因为总共就 128 个字符,不管是哪一个字符,占用的长度都是一样的。
|
||||
|
||||
下面我们来看一下哈夫曼编码的大致过程:
|
||||
|
||||
首先,统计被加密数据中每个字符出现的频率,把它们从高到低排好。比如下面这个表,就是某一段文字的字母出现的次数统计表格,你可以看到这些字母出现的次数是从左到右依次增加的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/2a/b9cd13863209a33f45974a72f306102a.jpg" alt="">
|
||||
|
||||
接下去,我们就可以从表格的左侧开始取数据,并自下而上地构造哈夫曼树了。
|
||||
|
||||
我们**每次只考虑表格中“出现次数”最少的两列**。第一次,我们发现出现次数最少的是字母 h,其次是 f,因此分别构造最底层的两个叶子节点 h 和 f,并将它们的和 5 + 6 = 11 也求出来,构造成为它们的父节点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/25/07ced87b093b15a37a53eec5c11f9525.jpg" alt="">
|
||||
|
||||
同时,我们更新这个统计表格,使用这个父节点去代替它原本的两个节点 h 和 f,依然保持各列按照出现次数递增排列:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/c4/bb6ba23b7599906e0947ce3c11c93ac4.jpg" alt="">
|
||||
|
||||
继续按照上面的操作,现在值最小的两个节点,分别为 7 和 8,因此现在这张图变成了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/23/9e960cccbc377d2aa78de0d1b0261523.jpg" alt="">
|
||||
|
||||
而表格变成了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/78/5030c305d3ec507e98fc9ef2837c7a78.jpg" alt="">
|
||||
|
||||
继续,现在值最小的两个节点,分别对应 11 和15:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/19/e9bddf90bba68e53fd05f7ba360be419.jpg" alt="">
|
||||
|
||||
表格变成了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/fa/e81b887b2257301b0550a8287108effa.jpg" alt="">
|
||||
|
||||
继续:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/6d/a570c424cfeb2c166b9b946ff43cba6d.jpg" alt="">
|
||||
|
||||
表格只有两列数据了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/65/2065dc12222e465cd65e46b31ec6f865.jpg" alt="">
|
||||
|
||||
好,这棵树构造完成,这时候表格里只剩下一列了,即 e+a+h+f+t+d=76,我就不列出来了。在树构造完成后,我们给图中所有的左分支赋值 0,右分支赋值 1:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/62/235d7ec15ea7a20981b0283d329c5362.jpg" alt="">
|
||||
|
||||
这就是哈夫曼树。
|
||||
|
||||
你可以看到,每一个叶子节点,都代表了我们希望加密的字符。**只要从根部开始,往叶子节点的方向按照最短路径的方式遍历(图中箭头的逆向),每一条路径都对应了实际的哈夫曼编码。**按照这个规则,让我们给最开始的那个记录字符出现次数的表格加上一行:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/77/75437d2a1157a36eba5f81c57c88c077.jpg" alt="">
|
||||
|
||||
你可以看到,大致的规律是,出现频率越高的字符,编码串的长度就越短。如果有一个单词 date,它的编码就是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/08/b3e14ecf2d8c67845f8bad9763be8c08.jpg" alt="">
|
||||
|
||||
这就是哈夫曼编码的原理,但是由于哈夫曼编码是变长的,**为了解码端能够准确地解码,就需要编码端同时附上字母到编码的映射关系表**(就是上面那个表格,出现次数一行可以删掉,剩下两行保留),也就是所谓的“编码表”。
|
||||
|
||||
## RLE 编码
|
||||
|
||||
RLE 编码,即 Run-Length Encoding,是一种原理非常简单的压缩编码方式,它利用的就是字符的“简单重复”。并且有意思的是,这种编码方式和很多其它压缩编码方式不冲突,也就是说,**数据可以以特定的方式经过 RLE 编码后,再使用哈夫曼等其它方式进一步压缩编码**。
|
||||
|
||||
举个例子,如果有这样一段单个字符重复率比较高的原串:
|
||||
|
||||
```
|
||||
AAAAABBCDDDDDDD
|
||||
|
||||
```
|
||||
|
||||
它就可以使用 RLE 的方式编码成:
|
||||
|
||||
```
|
||||
5A2B1C7D
|
||||
|
||||
```
|
||||
|
||||
这表示这段原数据中有 5 个连续的 A,2 个连续的 B,1 个 C 和 7 个连续的 D。
|
||||
|
||||
这种方式下,不知你是否想到,也可能存在问题。比如说,如果连续字符重复的比率很低,像是这样一段字符:
|
||||
|
||||
```
|
||||
ABCDABCD
|
||||
|
||||
```
|
||||
|
||||
按照刚才的规则,它就会被编码成:
|
||||
|
||||
```
|
||||
1A1B1C1D1A1B1C1D
|
||||
|
||||
```
|
||||
|
||||
什么嘛,编码后居然比原字符串还长!
|
||||
|
||||
**因此,RLE 这种压缩编码方式,更适合于连续字符发生率较高的数据。**比方说黑白的栅格图像(关于栅格图像的概念,你可以参考 [[第 18 讲]](https://time.geekbang.org/column/article/152557)),里面往往存在着大片大片的重复字符。
|
||||
|
||||
## 算术编码
|
||||
|
||||
算术编码,即 Arithmetic Coding,我们可以拿它和哈夫曼编码比较起来看:二者都会根据字符的出现频率来设定编码规则,但哈夫曼编码针对的单位是单个字符,每个字符对应一个数;而算术编码,则是整个消息串(编码单元)编码成一个数。
|
||||
|
||||
举个例子,某数据可能由四个字母组成,每个字母出现的概率如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/a2/2c0eb01a4b13fc3fd7e3f49da3ecd6a2.jpg" alt="">
|
||||
|
||||
根据上面的统计,假如说现在我们要给单词 eat 来进行算术编码。
|
||||
|
||||
第一步:我们先把 0 到 1 的范围按照该比例划分成这样 4 个区域,第一个字母 e 就在从 60% 到 100% 的位置(每一个区域,都是左闭右开的区间,比如 t 的区间是 [0, 10%))。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/48/e417f178a506359ae9db6ebfbdb9d548.jpg" alt="">
|
||||
|
||||
第二步:接下来是字母 a,我们把上面 e 这个区域,即 [60%, 100%),按照同样的比例划分成 4 个区域,根据同样的计算,a 的位置在 72% 到 84% 这一段上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/e6/b8638c5efae09d9fe98d11bc79e169e6.jpg" alt="">
|
||||
|
||||
第三步:最后是字母 t,做法也依然如此,把上面 a 这个区域按照同一比例划分成 4 个 区域,t 的位置在 72% 到 73.2% 这一段区域内。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/bc/30e8587b0176b4f0f92677585ddaf6bc.jpg" alt="">
|
||||
|
||||
那么,我们就可以给它取 73%,因为它就在最后的区域 t 内,当然,在编码中我们可以去掉百分号。因此,基于上面的统计,eat 这个单词,就可以采用算术编码成数值 73。也就是说,通过这种方法,**一段编码前的数据,最后编码成了一个数。**
|
||||
|
||||
这就是算术编码的大致原理,根据已知的统计,将给定数据段进行划分,原则就是让出现概率较大的字符根据比例来分配到相对更大的数据段,解码方根据编码方提供的这一依赖统计数据,就可以做出准确的解码。
|
||||
|
||||
当然,上面作为一个示例,是做了相当的简化了的,在实际应用中,还有很多因素需要我们考虑,比如:
|
||||
|
||||
**1. 采用二进制来代替十进制**
|
||||
|
||||
我们使用十进制,仅仅是示例之用,为了便于让你理解。实际上,使用二进制可以减小“浪费”的字符,从而缩短编码后的长度,提高压缩比。我们还是拿上面的那个例子来说,72% 和 73% 在数值位数长度相同的情况下都可以表示 eat 这个词,这就是一定程度上的浪费。**最理想的状况,应当是用于压缩后数据表达的数值,可以按照比例“恰好”地去覆盖所有被编码的数据组合——而不存在某一种表达是冗余的,或者说对应的被编码的实际数据组合不存在。**
|
||||
|
||||
**2. 引入结束字符**
|
||||
|
||||
通常我们不会把整个输入完全使用一个编码数值来表示,而是将输入根据某种规则划分成若干个部分,分别采用算术编码处理。比如一段英文文字,就可以使用空格来标识每一个单词的结束,因此像空格这样的“结束符”需要考虑到编码字符内。
|
||||
|
||||
**3. 适当考虑条件概率**
|
||||
|
||||
你看,上面例子中的第一步、第二步和第三步,t、d、a、e 的分布都是一样的,但是实际上,这个分布在“前一个”字母确定的情况下,是可以有变化的。举个例子,前面提到的字母概率分布,在“前一个字母为 t”的前提下,当前字母的概率遵循如下表格:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/55/0aa89490dad355f80853db5f3c884f55.jpg" alt="">
|
||||
|
||||
你可以看到,这个条件下的统计和前文比,发生了明显的变化,我们说它是“1 级”条件概率。如果我们讨论,在前两个字母分别是 t 和 e 的前提下,接着看第三个字母的概率分布,这时的条件概率其实就到“2 级”了。
|
||||
|
||||
你也可以想象,由于排列组合的关系,我们不可能无限地考虑条件概率,考虑 n 级条件概率,这个编码表的大小就是最初编码表大小的 n 次方,因此条件概率的选择,**我们要么选择某几个特定字母(规则)的条件概率**,比如我们只考虑字母 t 和字母 d 的条件概率;**要么严格限制条件概率的级数**,比如就实现 1 或 2 级的条件概率。
|
||||
|
||||
**4. 将静态模型改进为自适应模型**
|
||||
|
||||
算术编码需要在统计数据的指导下进行,我们当然希望统计数据可以精确。我们的例子中,统计信息是预先确定的,即什么字母的出现概率是百分之几都是已经知道的。也就是说,我们应用的是“静态模型”,对于静态模型来说,我们可以相对容易地追求最佳的压缩比。
|
||||
|
||||
但是在实际应用中,很多时候我们是不知道这个的,或者说,即便知道,这个值也是在不断变化的。使用静态模型的问题也就在这里,**如果用一个静态的统计去指导一个动态变化的问题,就像刻舟求剑一样,哪怕一开始编码是高效的,很快这个压缩率就降下来了。**
|
||||
|
||||
因此我们经常需要把静态模型变成自适应模型。如果还是前面那个例子显示的四个字母,假如说在一开始的时候 ,这些字母将在待编码的数据中出现的概率是未知的,那么我们可以简单地认为“每个字母都已经出现了 1 次”:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/0f/4aa648466c77541dc3dbe492713a1c0f.jpg" alt="">
|
||||
|
||||
那么,随着我们的编码流程向下进行,每次编码,或者每几次编码前这个概率统计,就会根据当前情况得到更新,再来指导下面的编码,不过算法和前面保持不变。
|
||||
|
||||
比如读入第一个字符 e 以后,这个统计表格就变成了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/eb/94/eb06f5f5601d6ceb251663c7b9530e94.jpg" alt="">
|
||||
|
||||
再读入一个字符 a,这个统计表格就变成了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/80/a335ecf918c0e0dca74c479df9b37080.jpg" alt="">
|
||||
|
||||
你看,随着编码的不断进行,这个统计表格会不断自我修正。如果源数据的字母分布在数据量增大的时候是收敛的,即不断趋近于某一个相应的比例,那么这个统计也会不断接近它,因此**随着时间的流逝,压缩工作的进行,压缩的效果会越来越好。**
|
||||
|
||||
**但是,在很多实际的场景中,这个分布统计并不是收敛的,而是随着时间的流逝会不断变化的。**举例来说,视频直播,随着播放的进行,统计数据需要时不时地发起更新,因为视频在不断变化,某一时间段内重复率高的数据串,可能到了下一个时间段内就变了。这时候,对于统计数据的生成就值得做文章了,比如我们可以选取一个适当的时间权重,**越是新的数据 ,权重越大,这种情况下我们得到的统计就具有一定的即时性了**,而一些较老的数据,显然对于统计分布的影响就很小了,而老到一定程度的数据,甚至就直接忽略掉好了。
|
||||
|
||||
于是,通过这种方式,统计数据不断地随着实际数据流的变化而变化,那么编码的规则(编码表)就可以实现不断地自我调整,去适应数据的变化,这也是一些视频流编码都采用自适应模型的原因。
|
||||
|
||||
好,现在我们回过头来看看哈夫曼编码和算术编码,它们尽管在技术实现上有着诸多不同,但是它们的压缩过程,都包含了这样两个步骤,这也是大部分时候我们使用的压缩算法实际上所遵循的两个步骤:
|
||||
|
||||
- 第一步,分析并计算得到原始数据的统计模型;
|
||||
- 第二步,根据统计模型,将相对较多出现的数据用较短的编码串表示,而较少出现的数据用较长的编码串表示。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们学习了无损压缩算法中较为基础的几种,事实上,对于我们日常接触的那些实际的、商用的压缩算法,往往都是这些基础技术的综合使用。好,希望你已经理解了这些算法的本。
|
||||
|
||||
现在又到了提问时间:
|
||||
|
||||
- 对比哈夫曼编码和算术编码,你能否比较并分别说出二者的优劣?
|
||||
- 关于哈夫曼编码,如果编码后的串,其中的某个数值(无论是 0 还是 1)丢失了,你觉得这样的数据损坏,会导致多大比例的数据无法被正确解码?
|
||||
|
||||
你可以回想一下上一讲的选修课堂,和本讲一样,都是将原数据进行某种特定的编码以后,得到目标数据,但是二者的目的是截然不同的,前者是为了加密,保护原始信息不被泄露、不被篡改,而后者是为了压缩,减少存储和传输数据的大小。
|
||||
|
||||
好,今天就到这里,不知道你是否在全栈开发的算法学习后有所收获,欢迎你和我分享你的思考。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 今天我介绍了几个较为基础和经典的无损压缩算法,你可以在这里查看无损压缩的[其它算法](https://en.wikipedia.org/wiki/Lossless_compression#Lossless_compression_methods)。就如同软件开发没有银弹一样,不同的无损压缩算法擅长于不同的数据类型和特定的数据重复模式,没有一种通用的方法可以对所有的数据压缩都做到最好。
|
||||
- 对于哈夫曼树的生成,这里有一个[网站](http://huffman.ooz.ie/),可以根据你输入的字符串,图形化地展示哈夫曼树。
|
||||
- 这两讲我介绍了一些全栈开发中重要的算法,但是还有许多其它很有意思、也很有地位的算法,推荐你阅读 InfoQ 上的这篇译文——[计算机科学中最重要的 32 个算法](https://www.infoq.cn/article/2012/08/32-most-important-algorithms)。
|
||||
|
||||
|
||||
264
极客时间专栏/全栈工程师修炼指南/第六章 专题/38 | 分页的那些事儿.md
Normal file
264
极客时间专栏/全栈工程师修炼指南/第六章 专题/38 | 分页的那些事儿.md
Normal file
@@ -0,0 +1,264 @@
|
||||
<audio id="audio" title="38 | 分页的那些事儿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/db/e5/db8586d9f8b81b1cd7181ba3266b1fe5.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
分页是全栈开发中非常常见的一个功能,绝大多数网站都需要用到。这个功能可谓麻雀虽小五脏俱全,是从呈现层、控制器层、到模型层,并和数据存储密切相关的真正的“全栈”功能。有时候你能见到一些清晰、明确的分页,也就是说,你能看到某一个内容的呈现被分成若干页,而当前又是在第几页;而有时候这个功能则是部分模糊了的,比方说,能翻页,但是并不显示总共有多少页。那今天,就让我们来了解一些典型的分页方法,理解分页的一些常见问题和它们的解决方式。
|
||||
|
||||
## 物理分页和逻辑分页
|
||||
|
||||
物理分页一般指的就是数据库分页,而逻辑分页则是程序员口中的“内存分页”。前者是通过条件查询的方式,从数据库中取出特定的数据段来,而后者,则是在完整数据都加载在内存中的情况下,从中取出特定段的数据来。
|
||||
|
||||
显然我们一般情况下更关注物理分页,因为内存的大小总是更为有限,多数情况下我们希望通过条件查询的方式去数据库中取出特定页来。但是也有反例,比方说某些数据量不大,但是访问频率较高的数据,以“缓存”的形式预加载到内存中,再根据客户端的分页条件返回数据,这种方式一样可以遵循分页接口,但是由于被分页的数据是全部在内存中的,这样的数据一般需要遵循如下几个要求:
|
||||
|
||||
- 数据量不大,可以放在内存中;
|
||||
- 一致性满足要求,或者使用专门的设计维护内存中数据的一致性;
|
||||
- 持久性方面,数据允许丢失,因为在内存中,断电即丢失。
|
||||
|
||||
## 分页代码设计
|
||||
|
||||
分页这个事儿从功能抽象的角度看基本不复杂,但是我们也经常能看到不同的实现。比较常见的有两种,指定页码的分页,以及使用 token 的分页,当然,严格来说,页码其实是 token 的一种特殊形式。就以指定页码的分页为例,在实际代码层面上,我见到的分页代码设计包括这样两种。
|
||||
|
||||
### 单独的分页工具类
|
||||
|
||||
**第一种是彻底把 DAO 的查询逻辑拿出去,创建一个单独的分页工具类 Paginator。**这个工具的实例对象只负责分页状态的存储和变更,不知道也不管理数据,在实例化的时候需要 totalCount 和 pageSize 两个参数,前者表示总共有多少条数据,后者表示每一页最多显示多少条。
|
||||
|
||||
DAO 的查询接口自己去创建或者接纳一个 Paginatior 对象,其实现会调用对象的 getStart() 和 getEnd() 方法,从而知道查询时数据的范围。请看这个 Paginatior 的例子,我把方法内的具体实现省略了:
|
||||
|
||||
```
|
||||
class Paginatior {
|
||||
public Paginatior(int totalCount, int pageSize) {}
|
||||
|
||||
// 翻页
|
||||
public void turnToNextPage() {}
|
||||
public void turnToPrevPage() {}
|
||||
public void setPageNo(int pageNo) {}
|
||||
|
||||
// 数据访问层需要的 start 和 end
|
||||
public int getStart() {}
|
||||
public int getEnd() {}
|
||||
|
||||
// 是否还有上一页、下一页
|
||||
public boolean hasNextPage() {}
|
||||
public boolean hasPrevPage() {}
|
||||
|
||||
// 当前在哪一页
|
||||
public int getPageNo() {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 绑定查询结果的分页对象
|
||||
|
||||
**第二种则将分页对象和查询数据的结果集绑定在一起,有的是将结果集对象继承自一个分页类,有的则是将结果集对象以组合的方式放到分页对象里面,数据访问层直接返回这个携带实际数据的分页对象。**从优先使用组合而不是继承的角度来说,组合的方式通常更好一些。
|
||||
|
||||
具体说,我们可以定义这样的分页对象:
|
||||
|
||||
```
|
||||
public class Page<T> {
|
||||
private int pageNo;
|
||||
private int totalCount;
|
||||
private int pageSize;
|
||||
private Collection<T> data; // 当前页的实际业务对象
|
||||
|
||||
public Page(Collection<T> data, int pageSize, int pageNo, int totalCount) {
|
||||
this.data = data;
|
||||
this.pageSize = pageSize;
|
||||
this.pageNo = pageNo;
|
||||
this.totalCount = totalCount;
|
||||
}
|
||||
|
||||
public int getPageSize() {}
|
||||
public int getPageNo() {}
|
||||
public int getTotalPages() {}
|
||||
public int getLastPageNo() {}
|
||||
public Collection<T> getData() {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你看,这个分页对象和前面的那个例子不同,它将 DAO(数据访问对象,在 [[第 12 讲]](https://time.geekbang.org/column/article/143909) 有介绍)的某次查询的结果,也就是某一页上的数据集合,给包装起来了。因此这个对象是由 DAO 返回的,并且里面的数据仅仅是特定某一页的,这也是它没有提供用于翻页的方法的原因——具体要访问哪一页的数据,需要将页码和页面大小这样的信息传给 DAO 去处理。
|
||||
|
||||
### 其它方法
|
||||
|
||||
比如说,如果按照充血模型的方式来设计(关于充血模型是什么,以及代码实现的例子,你可以参见 [[第 08 讲]](https://time.geekbang.org/column/article/141679)),则可以**把这个 Page 对象和 DAO 的代码整合起来,放到同一个有状态的业务对象上面去,这样这个对象既携带了业务数据,又携带了分页信息。**当然,这个方法的区别其实只是贫血模型和充血模型的区别,从分页设计的角度上来看,和上面的第二种其实没有本质区别。
|
||||
|
||||
此外,一些“管得宽”的持久层 ORM 框架,往往也把分页的事情给包装了,给开发人员开放易于使用的分页接口。比方说 Hibernate,你看这样一个例子:
|
||||
|
||||
```
|
||||
session
|
||||
.createCriteria(Example.class)
|
||||
.setFirstResult(10)
|
||||
.setMaxResults(5)
|
||||
.list();
|
||||
|
||||
```
|
||||
|
||||
这就是通过 Hibernate 的 Criteria Query API 来实现的,代码也很易于理解。当然,如果想要取得总行数,那可以用 Projection:
|
||||
|
||||
```
|
||||
session
|
||||
.createCriteria(Example.class)
|
||||
.setProjection(Projections.rowCount())
|
||||
.list()
|
||||
.get(0);
|
||||
|
||||
```
|
||||
|
||||
## SQL 实现
|
||||
|
||||
如果使用关系数据库,那么落实到 SQL 上,以 MySQL 为例,大致是这样的:
|
||||
|
||||
```
|
||||
select * from TABLE_NAME limit 3, 4;
|
||||
|
||||
```
|
||||
|
||||
limit 后面有两个参数,第一个表示从下标为 3 的行开始返回,第二个表示一共最多返回 4 行数据。
|
||||
|
||||
但是,如果使用的是 Oracle 数据库,很多朋友都知道可以使用行号 rownum,可是这里面有一个小坑——Oracle 给 rownum 赋值发生在当条子句的主干执行完毕后,比如说:
|
||||
|
||||
```
|
||||
select * from TABLE_NAME where rownum between 2 and 4;
|
||||
|
||||
```
|
||||
|
||||
如果表里面有 100 行数据,那么你觉得上面那条 SQL 会返回什么?
|
||||
|
||||
返回 2 行到第 4 行的数据吗?不对,它什么都不会返回。
|
||||
|
||||
这是为什么呢?因为对每一行数据,执行完毕以后才判断它的 rownum,于是:
|
||||
|
||||
- 第一行执行后的 rownum 就是 1,而因为不符合“2 到 4”的条件,因此该行遍历后,就从结果集中抛弃了;
|
||||
- 于是执行第二行,由于前面那行被抛弃了,那这一行是新的一行,因此它的 rownum 还是 1,显然还是不符合条件,抛弃;
|
||||
- 每一行都这样操作,结果就是,所有行都被抛弃,因此什么数据都没有返回。
|
||||
|
||||
解决方法也不难,用一个嵌套就好,让里面那层返回从第 1 条到第 4 条数据,并给 rownum 赋一个新的变量名 r,外面那层再过滤,取得 r 大于等于 3 的数据:
|
||||
|
||||
```
|
||||
select * from (select e.*, rownum r from TABLE_NAME e where rownum<=4) where r>=3;
|
||||
|
||||
```
|
||||
|
||||
## 重复数据的问题
|
||||
|
||||
下面我们再看一个分页的常见问题——不同页之间的重复数据。
|
||||
|
||||
这个问题指的是,某一条数据,在查询某一个接口第一页的时候,就返回了;可是查询第二页的时候,又返回了。
|
||||
|
||||
我来举个例子:
|
||||
|
||||
```
|
||||
select NAME, DESC from PRODUCTS order by COUNT desc limit 0, 50;
|
||||
|
||||
```
|
||||
|
||||
这个查询是要根据产品的数量倒序排序,然后列出第一页(前 50 条)来。
|
||||
|
||||
接着查询第二页:
|
||||
|
||||
```
|
||||
select NAME, DESC from PRODUCTS order by COUNT desc limit 50, 50;
|
||||
|
||||
```
|
||||
|
||||
结果发现有某一个产品数据,同时出现在第一页和第二页上。按理说,这是不应当发生的,这条数据要么在第一页,要么在第二页,怎么能同时在第一页和第二页上呢?
|
||||
|
||||
其实,这只是问题现象,而其中的原因是不确定的,比如说有这样两种可能。
|
||||
|
||||
### 排序不稳定
|
||||
|
||||
第一种是因为查询的结果排序不稳定。
|
||||
|
||||
说到这个“不稳定”,其实本质上就是排序算法的“不稳定”。如果对算法中的排序算法比较熟悉的话,你应该知道,**我们说排序算法稳不稳定,说的是当两个元素对排序来说“相等”的时候,是不是能够维持次序和排序前一样。**比如有这样一组数:
|
||||
|
||||
```
|
||||
1, 2a, 5, 2b, 3
|
||||
|
||||
```
|
||||
|
||||
这里面有两个 2,但是为了区分,其中一个叫作 2a,另一个叫作 2b。现在 2a 在 2b 前面,如果排序以后,2a 还是能够保证排在 2b 前面,那么这个排序就是稳定的,反之则是不稳定的。有很多排序是稳定的,比如冒泡排序、插入排序,还有归并排序;但也有很多是不稳定的,比如希尔排序、快排和堆排序。
|
||||
|
||||
再回到我们的问题上,分页查询是根据 COUNT 来排序的,如果多条数据,它们的 COUNT 一样,而在某些数据库(包括某些特定版本)下,查询的排序是不稳定的。这样就可能出现,这个相同 COUNT 的产品记录,在结果集中出现的顺序不一致的问题,那也就可能出现某条记录在第一页的查询中出现过了,而在第二页的查询中又出现了一遍。
|
||||
|
||||
其实,从数据库的角度来说,在我们根据上述代码要求数据库返回数据的时候,COUNT 相同的情况下,可并没有要求数据库一定要严格遵循某一个顺序啊,因此严格说起来,数据库这么做本身也是没有什么不对的。
|
||||
|
||||
无论如何,问题如果明确是这一个,那么解决方法就比较清楚了。既然问题只会在 COUNT 相同的时候出现,那么上例中,我们给 order by 增加一个次要条件——ID,而因为 ID 是表的主键,不可能出现重复,因此在 COUNT 相同的时候排序一定是严格按照 ID 来递减的,这样也就可以保证排序不会因为“不稳定”而造成问题:
|
||||
|
||||
```
|
||||
select NAME, DESC from PRODUCTS order by COUNT, ID desc limit 0, 50;
|
||||
|
||||
```
|
||||
|
||||
### 数据本身变化
|
||||
|
||||
第二个造成重复数据问题的原因是数据本身的变化。
|
||||
|
||||
这个也不难理解,比如还是上面这行 SQL:
|
||||
|
||||
```
|
||||
select NAME, DESC from PRODUCTS order by COUNT, ID desc limit 0, 50;
|
||||
|
||||
```
|
||||
|
||||
本来有一行数据是在第二页的开头,用户在上述查询执行后,这行数据突然发生了变化,COUNT 增加了不少,于是挤到第一页去了,那么相应地,第一页的最后一条数据就被挤到第二页了,于是这时候用户再来查询第二页的数据:
|
||||
|
||||
```
|
||||
select NAME, DESC from PRODUCTS order by COUNT, ID desc limit 50, 50;
|
||||
|
||||
```
|
||||
|
||||
这就发现原来第一页尾部的一条数据,又出现在了第二页的开头。
|
||||
|
||||
对于这个问题,我们也来看看有哪些解决方案。但是在看解决方案之前,我们先要明确,这是不是一个非得解决的问题。换言之,如果产品经理和程序员都觉得重复数据并不是什么大不了的事情,这个问题就可以放一放,我们不需要去解决那些“可以不是问题”(或者说优先极低)的问题。我知道很多人觉得这个太过浅显,甚至不值得讨论,但毕竟这是一个很基本的原则,做技术的我们在面对问题的时候始终需要明确,而不是一头扎进解决问题的泥塘里出不来了。
|
||||
|
||||
至于解决方案,比较常见的包括这样几个。
|
||||
|
||||
**1. 结果过滤**
|
||||
|
||||
如果我们认定两次查询的结果中,可能出现重复,但是考虑到数据变更的速度是比较慢的,顺序的变化也是缓慢的,因此这个重复数据即便有,也会很少。那么,第二页展示的时候,我们把结果中第一页曾经出现过的这些个别的数据给干掉,这样就规避了数据重复的问题。
|
||||
|
||||
这大概是最简单的一种处理方式,但是其不足也是很明显的:
|
||||
|
||||
- 说是“个别数据”,可到底有多少重复,这终究存在不可预测性,极端情况下第二页的数据可能会出现大量和第一页重复的情况,删除这些重复数据会导致第二页数据量特别少,从而引发糟糕的用户体验;
|
||||
- 数据丢失问题:既然第二页上出现第一页的重复,那就意味着存在某数据在用户查询第一页的时候它待在第二页,而用户查询第二页的时候又跑到第一页上去了,这样的数据最终没有返回给用户;
|
||||
- 有第一页、第二页,那就还有第三页、第四页的问题,比如第一页的数据可能跟第四页重复啊,如果我们把这些因素都考虑进去,这个方案就没有那么“简单”了。
|
||||
|
||||
**2. 独立的排序版本**
|
||||
|
||||
这个方法原理上也很简单,对于任何排序,都维持一个版本号。这样,在数据产生变化的时候,这个新的排序,需要采用一个不同的版本号。本质上,这就是把每次排序都单独拿出来维护,每一个排序都有一份独立的拷贝。
|
||||
|
||||
这种方法适合这个排序变更不太频繁的情况,因为它的缺点很明显,就是要给每一份排序单独存放一份完整的拷贝。但是,它的优点也很明显,就是在多次查询的过程中,这个列表是静态的,不会改变的,不需要担心数据重复和丢失的问题。特别是对于开放的 Web API 来说,我们经常需要用一个循环多次查询才能获取全量的数据,这个机制就很有用了。
|
||||
|
||||
**3. 数据队列**
|
||||
|
||||
在某些场景下,我们如果能保证数据的顺序不变,而添加的数据总在显示列表的一头,也就是说,把原本的数据集合变成一个队列,这样要解决重复数据问题的时候,就会有比较好的解决办法了。
|
||||
|
||||
**每次查询的时候,都记住了当前页最后一条记录的位置(比如我们可以使用自增长的 ID,或是使用数据添加时的 timestamp 来作为它“位置”的标记),而下一页,就从该记录开始继续往后查找就好了。**这样无论是否有新添加的数据,后面页数的切换,使用的都是相对位置,也就不会出现数据重复的问题了。看下面的例子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/0a/765eaf5e95d1edfb6480bec2419e4f0a.png" alt="">
|
||||
|
||||
你看,用户刚访问的时候,返回了 data 150 到 data 199 这 50 条记录,并且记住了当前页尾的位置。用户再访问第二页的时候,其实已经有新的元素 data 200 加进来了,但是不管它,我们根据前一页的页尾的位置找到了第二页开头的位置,返回 data 100 到 data 149 这 50 条记录。
|
||||
|
||||
当然,只有一种例外,即只有用户访问第一页的时候(或者说,是用户的查询传入的“位置”参数为空的时候),才始终查询并返回最新的第一页数据。比如现在 data 200 已经添加进来了,查询第一页的时候就返回 data 151 到 data 200 这最新的 50 条数据:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/15/3187828c0233827684c8735e6f74c615.png" alt="">
|
||||
|
||||
上述这种方式其实也挺常见的,特别像是新闻类网站(还有一些 SNS 网站),基本上一个栏目下新闻的发布都可以遵循这个队列的规则,即新发布的新闻总是可以放在最开始的位置,发布以后的新闻相对位置不发生改变。还有就是许多 NoSQL 数据库用于查询特定页的 token,都是利用了这个机制。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们一起学习了分页的前前后后,既包括设计、实现,也包括对分页常见的重复数据的问题的分析,希望今天的内容能让你有所收获。
|
||||
|
||||
老规矩,我来提两个问题吧:
|
||||
|
||||
- 对于文中介绍的工具类 Paginatior,我把方法签名写出来了,你能把这个类实现完整吗?
|
||||
- 对于“分页代码设计”一节中介绍的两种分页对象的设计方法,它们各有优劣,你能比较一下吗?
|
||||
|
||||
好,今天就到这里,欢迎你在留言区和我讨论。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 对于 Hibernate 分页查询用到的条件查询 API,你可以阅读[这篇文章](https://howtodoinjava.com/hibernate/hibernate-criteria-queries-tutorial/#paging),看到更多的例子;而对于通过 Projection 来取得总行数的代码,[这里](https://examples.javacodegeeks.com/enterprise-java/hibernate/count-total-records-in-hibernate-with-projections/)有完整例子。
|
||||
- 本讲一开始的时候我提到,分页的设计上,有的是使用指定页码的,也是本讲的重点;还有一种是使用 token 的,这种方式也是很多 Web API 常提供的方式,你可以阅读 DynamoDB 的[官方文档](https://docs.aws.amazon.com/zh_cn/amazondynamodb/latest/developerguide/Query.html#Query.Pagination)了解一下它的分页 API。
|
||||
|
||||
|
||||
280
极客时间专栏/全栈工程师修炼指南/第六章 专题/39 | XML、JSON、YAML比较.md
Normal file
280
极客时间专栏/全栈工程师修炼指南/第六章 专题/39 | XML、JSON、YAML比较.md
Normal file
@@ -0,0 +1,280 @@
|
||||
<audio id="audio" title="39 | XML、JSON、YAML比较" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/63/d7972b5946488d169867e73133a20663.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
XML 和 JSON,是程序员几乎每天都会打交道的数据、特别是配置数据的承载格式。我想你心里应该有一个大致的印象,它们二者各有什么优劣,但是也许没有系统地比较过。那今天我们就把它们放到一起,丁是丁卯是卯地分析分析,对比一下它们各自的特点。另外,这些年来,对于配置,特别是复杂 DSL 的配置,YAML 也逐渐流行开来,因此我们也把它拿过来剖析一番。
|
||||
|
||||
## XML 和 JSON 的比较
|
||||
|
||||
XML 全称叫做 Extensible Markup Language,就像 HTML、CSS 一样,是一种标记语言(标记语言不属于传统意义上的编程语言),且是一种具备结构化特征的数据交换语言;类似地,JSON,也就是 JavaScript Object Notation,被称作 JavaScript 对象表示法,非结构化,更轻量,但归根结底也是一种数据交换语言。因此,二者具备相当程度的相似性,在实际应用中,往往也可以比较和替代。
|
||||
|
||||
### 1. 简洁还是严谨
|
||||
|
||||
在 [[第 04 讲]](https://time.geekbang.org/column/article/136795) 的时候,我介绍了 REST 和 SOAP 这样一个简洁、一个严谨的俩兄弟。而在本讲中,JSON 和 XML 也在一定程度上同样满足这样的比较关系,JSON 往往是更为简洁、快速的那一个,而 XML 则更为严谨、周全。
|
||||
|
||||
我们来看一个简单的例子,id 为 1 的城市北京:
|
||||
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<city>
|
||||
<name>Beijing</name>
|
||||
<id>1<id>
|
||||
</city>
|
||||
|
||||
```
|
||||
|
||||
如果用 JSON 表示:
|
||||
|
||||
```
|
||||
{
|
||||
"city": {
|
||||
"name": "Beijing",
|
||||
"id": 1
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可能会说,除了 XML tag 的名字,在 JSON 中只需要写一遍以外,看起来复杂复杂、严谨的程度似乎也差不太多啊。
|
||||
|
||||
别急,往下看。XML 的结构,强制要求每一个内容数据,都必须具备能够说明它的结构,而 JSON 则没有这样的要求。比方说,如果我们把城市组成数组,用 XML 来表示,请你把这个文件存成 cities.xml,因为我们会多次用到这个文件:
|
||||
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<cities>
|
||||
<city>
|
||||
<name>Beijing</name>
|
||||
<id>1</id>
|
||||
</city>
|
||||
<city>
|
||||
<name>Shanghai</name>
|
||||
<id>2</id>
|
||||
</city>
|
||||
</cities>
|
||||
|
||||
```
|
||||
|
||||
如果使用 JSON 的话,由于对于数组可以使用中括号直接支持,而不需要显式写出上例中的 city 这个 tag 的名称,请你同样建立 cities.json:
|
||||
|
||||
```
|
||||
{
|
||||
"cities": [
|
||||
{"name": "Beijing", "id": 1},
|
||||
{"name": "Shanghai", "id": 2}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这就可以看出,在这种情况下,JSON 似乎确实要更为简洁一些。上例中 JSON 能够使用中括号直接表示数组,能够直接支持数值、字符串和布尔型的表示。
|
||||
|
||||
等等,这样说的话,JSON 因为能够直接支持数值的表示,这个 id 没有双引号修饰,就是数值类型,可是从 XML 中并不能看出这一点啊。因此,从这个角度说,应该是 JSON 更严谨啊!那为什么说 XML 更严谨,严谨在哪呢?
|
||||
|
||||
有些程序员朋友可能会马上想到,XML 是可以定义 tag 属性的,预定义一个 type 不就好了?
|
||||
|
||||
```
|
||||
<city>
|
||||
<name type="String">Beijing</name>
|
||||
<id type="number">1<id>
|
||||
</city>
|
||||
|
||||
```
|
||||
|
||||
看起来也能达到“严谨”的目的,可这很可能就是一个不好的实践了,因为XML 对于这些常见的数据类型,内置了直接的支持。我们可以通过定义 XML Schema Definition(XSD)来对 XML 的结构做出明确的要求,也就是说,我们不必自己去造轮子,来定义并实现这个 type 属性。针对上面的 cities.xml,我们可以定义这样的 XSD:
|
||||
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="cities">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="city" maxOccurs="unbounded" minOccurs="0">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element type="xs:string" name="name"/>
|
||||
<xs:element type="xs:byte" name="id"/>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
|
||||
```
|
||||
|
||||
这样一来,我们就对 cities 和 city 这两个 tag 做了严格的内容限定,包括包含的子节点有哪些,顺序如何,取值类型是什么等等。在实际的 XML 定义中,我们可以引用这个 XSD,这样 XML 的处理程序就会加载这个 XSD 并根据 schema 的规则对 XML 进行校验,从而发现 XML 不合要求的问题。
|
||||
|
||||
进一步地,你可以自己动动手,看一下[这个工具](https://www.freeformatter.com/xsd-generator.html),它可以帮助你通过 XML 快速生成样例 XSD;而[这个工具](https://www.freeformatter.com/xml-validator-xsd.html)则可以帮你快速验证 XML 是不是满足某 XSD 的要求,它们都很有用。
|
||||
|
||||
补充一下,你可能也听说过,或使用过类似的叫做 DTD,也就是 Document Type Definition 的方式,能想到这个很好,但是 XSD 相对来说有着更大的优势,并成为了 W3C 的标准。因此我在这里不提 DTD,但是我在扩展阅读中放了关于 XSD 和 DTD 的比较材料,供感兴趣的朋友拓展。
|
||||
|
||||
我想,从 XSD 你应该可以体会到 XML 的严谨性了。那喜爱使用 JSON 的程序员,就不能创造一个类似的东西,来填补这个坑——即定义和保证 JSON 的严谨性吗?
|
||||
|
||||
有,它就是 [JSON Schema](https://json-schema.org/),也许你已经在项目中使用了,但是还没有统一成标准,也没有被足够广泛地接纳,因此我就不展开来说了。你可以自己实践一下,把上面提到的 JSON 填写到这个 [JSON Schema 推断工具](https://jsonschema.net/)上面,去看看生成的 JSON Schema 样例。
|
||||
|
||||
### 2. JavaScript 一家亲
|
||||
|
||||
对于全栈工程师来说,和 XML 比较起来,JSON 对于前端开发来说,可以说有着不可比拟的亲和力。本来,JSON 就是 JavaScript 对象表示法,就是从 JavaScript 这个源头发展而来的,当然,JSON 如今是不依赖于任何编程语言的。这个“一家亲”,首先表现在,JSON 和 JavaScript 对象之间的互相转化,可以说是轻而易举的。
|
||||
|
||||
我们来动动手实践一下,打开 Chrome 的开发者工具,切换到 Console 页,打开前面建立的 cities.json,拷贝其中的内容到一对反引号(backtick,就是键盘上 esc 下面的那个按键)中,并赋给变量 text:
|
||||
|
||||
```
|
||||
var text = `JSON 字符串`;
|
||||
|
||||
```
|
||||
|
||||
我们很轻松地就可以把它转化为 JavaScript 对象(反序列化),不需要任何第三方的 parser:
|
||||
|
||||
```
|
||||
var obj = JSON.parse(text);
|
||||
|
||||
```
|
||||
|
||||
在早些时候的 ES 版本中,这个方法不支持,那么还可以使用 eval 大法,效果是一样的:
|
||||
|
||||
```
|
||||
var obj = eval('(' + text + ')');
|
||||
|
||||
```
|
||||
|
||||
不过,在现代浏览器中,如果 text 不是绝对安全的,就不要使用这样的方法,因为 eval 可以执行任何恶意代码。
|
||||
|
||||
当然,我们也可以把 JavaScript 对象转换回(序列化)JSON 文本:
|
||||
|
||||
```
|
||||
var serializedText = JSON.stringify(obj);
|
||||
|
||||
```
|
||||
|
||||
完成以后,先不要关闭控制台,下面还会用到。
|
||||
|
||||
### 3. 路径表达式
|
||||
|
||||
对于一段巨大的 XML 或 JSON 文本,我们经常需要找出其中特定的一个或多个节点(tag)、内容(content)或者属性,二者都有相似的方法。
|
||||
|
||||
对于 XML 来说,它就是 XPath,也是 [W3C 的标准](https://www.w3.org/TR/xpath/all/)。现在我们来动手操作一下吧:
|
||||
|
||||
我们可以利用 Xpath Generator 来直观地生成相应的 XPath。让我们打开[这个工具](https://xmltoolbox.appspot.com/xpath_generator.html),找到我们刚才保存下来的 cities.xml 文件,拷贝里面的内容,粘贴到页面上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/98/3612154fb3c1fb8781c1e94388868798.png" alt="">
|
||||
|
||||
接着,点击第一个 id 标签,你将看到这样的 XPath:
|
||||
|
||||
```
|
||||
/cities/city[1]/id
|
||||
|
||||
```
|
||||
|
||||
这就唯一确定了 XML 中,cities 这个节点下,city 节点中的第一个,它下面的 id 节点。
|
||||
|
||||
我们再来试一试,点击 Shanghai 这个 content,你将看到:
|
||||
|
||||
```
|
||||
/cities/city[2]/name/text()
|
||||
|
||||
```
|
||||
|
||||
对于 JSON 来说,也有 JSONPath 这样的东西,但是,我们却很少提及它,因为正如上文我提到的,它和 JavaScript 极强的亲和力。我们在前端代码中已经不自觉地通过对象的点操作符,或是数组的下标操作符使用了,于是,JSONPath 在多数场景中就显得不那么重要了。
|
||||
|
||||
举例来说,上面我给出的两个 XPath 例子,我们在将 cities.json 反序列化成 JavaScript 对象以后,我们可以直接访问(你可以在前面实践的控制台上,继续键入):
|
||||
|
||||
```
|
||||
obj.cities[0].id
|
||||
|
||||
```
|
||||
|
||||
以及:
|
||||
|
||||
```
|
||||
obj['cities'][1].name
|
||||
|
||||
```
|
||||
|
||||
但是,还有很多场景,特别是对于 JSON 支持不像 JavaScript 那么足够的场景,JSONPath 就有其用武之地了。和前面介绍的 XPath 查找的例子一样,你可以打开 [JSON Path Finder](http://jsonpathfinder.com/) 页面,把之前 cities.json 的文本粘贴到左侧的输入框中,在右侧选择对应的节点或值,上方就会显示出 JSONPath 了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/11/33060ae88d70328e2d8c0ea24bd8af11.png" alt="">
|
||||
|
||||
所以,Beijing 的 id 和 Shanghai 的 name 分别显示为:
|
||||
|
||||
```
|
||||
x.cities[0].id
|
||||
x.cities[1].name
|
||||
|
||||
```
|
||||
|
||||
这和 JavaScript 对象访问的表达式是一致的。
|
||||
|
||||
### 4. 特殊字符
|
||||
|
||||
任何格式都要使用特定的字符来表示结构和关系,那么 XML 和 JSON 也不例外,这些特定字符,如果被用来表示实际内容,就会出现“冲突”,于是我们就需要转义。
|
||||
|
||||
对于 XML 来说,且看下面的表格,第三列是“预定义实体”,也就是字符转义后相应的形式:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/13/275ae26a648e6b40a577b73aa6bcee13.jpg" alt="">
|
||||
|
||||
值得一提的是,在 XML 中,我们还能够以 CDATA 来表示内容,这种方式可以尽可能地避免对于转义的使用,并可以直接使用换行等控制字符,增加 XML 的可读性。比方说下面这个例子,实际的 function 使用 CDATA 的方式给嵌在了 function 这个 tag 内:
|
||||
|
||||
```
|
||||
<function><![CDATA[
|
||||
function compare(a, b) {
|
||||
...
|
||||
}
|
||||
]]></function>
|
||||
|
||||
```
|
||||
|
||||
对于 JSON 来说,没有这样的预定义实体,但是我们也经常需要转义,比如说,如果双引号出现在了一对双引号内的字符串中,这时候我们可以用常规的反斜杠[转义序列](https://zh.wikipedia.org/wiki/%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97)来表示,比如引号“转义为 \”等。
|
||||
|
||||
## 审视一番 YAML
|
||||
|
||||
最后来讲一讲 YAML,这是一种可读性非常优秀的数据交换语言。如果你使用过 Python,那么对于其“有意义”的缩进应该印象深刻,而 YAML,也具备这样的特点。你可以打开 [XML to YAML Converter](https://codebeautify.org/xml-to-yaml) 这个网站,然后将前面我们创建的 cities.xml 的内容拷贝出来,贴到左侧的输入框中:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/8d/3a4473188cfb646fe2f66e1311630e8d.png" alt="">
|
||||
|
||||
然后点击 XML TO YAML 按钮,你就能看到前面的例子,如果使用 YAML 来表示,它会是这么个样子:
|
||||
|
||||
```
|
||||
cities:
|
||||
city:
|
||||
-
|
||||
name: Beijing
|
||||
id: 1
|
||||
-
|
||||
name: Shanghai
|
||||
id: 2
|
||||
|
||||
```
|
||||
|
||||
你看,这种方式对于层次结构的表达,可以说比 XML 或 JSON 更为清晰。对于 XML 和 JSON 的表达,对于每一层节点,你都要去找结尾标记,并和节点开头标记对应起来看;但是 YAML 则完全不用,它利用了人阅读的时候,一行一行往下顺序进行的特性,利用直观的缩进块来表达一个特定深度的节点。对于某些强调层次结构的特定信息表达的场景,比如说电子邮件消息,或者是商品信息、候选人的简历等等,使用 YAML 比其它的数据交换格式都要直接和清晰。
|
||||
|
||||
值得注意的是,对于缩进,YAML 要求不可以使用 TAB,只能使用空格,这和 Python 是不同的;而对于每层缩进多少个空格,这不重要,只要保证不同层次的缩进空格数量不同即可,这一点和 Python 是相同的。
|
||||
|
||||
**YAML 由于极强的可读性,它在数据类型的明确性上做了一定程度的牺牲。**从上面的例子你可以发现,本来我们希望 name 是字符串,id 是数值,可是 YAML 它根本不关心这一点,如你所见,字符串也没有双引号修饰,它只是诚实地把具体的文本数值罗列展示出来罢了。这一点,在我们权衡不同的数据交换格式的时候(比如设计哪一种格式作为我们的模块配置项文件),需要注意。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们一边动手比较、一边学习了 XML 和 JSON 的前前后后,包括它们的风格、schema 和路径表达式等等,并在之后了解了可读性至上的另一种数据交换语言 YAML。希望这些内容能够帮助你对于这些数据交换语言有更为全面的认识,并能够在工作中选择合适的技术来解决实际问题。
|
||||
|
||||
今天的提问环节,我想换个形式。这一讲我们已经比较了许多 XML 和 JSON 的特性了,其中一些涉及到了它们之间的优劣。那么,你能不能归纳出 XML 和 JSON 各自的一些优劣来呢?比如有这样几个方面供你参考,当然,你完全可以谈其它方面:
|
||||
|
||||
- 报文大小;
|
||||
- 数据类型的表达能力;
|
||||
- Schema 的支持;
|
||||
- 可读性;
|
||||
- 数据校验的支持性;
|
||||
- 序列化和反序列化(编程访问)的难易程度;
|
||||
- 程序处理的性能;
|
||||
- Web 中的普适性;
|
||||
- 可扩展性(自定义 DSL 的能力);
|
||||
- ……
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 关于 DTD,你可以在[这里](https://www.w3schools.com/xml/xml_dtd_examples.asp)找到许多例子;而 XSD,它的例子则在[这里](https://www.w3schools.com/xml/schema_example.asp)。而且,在 [XML Schema 与 XML DTD 的技术比较与分析](https://www.ibm.com/developerworks/cn/xml/x-sd/index.html)这篇文章里,你可以得知为什么 W3C 最后选择了 XSD,而不是 DTD。另外,对于 XSD 的批评,你可以参看[这里](https://zh.wikipedia.org/wiki/XML_Schema#%E6%89%B9%E8%AF%84)。
|
||||
- 对于 XPath,如果想全面地了解它的语法,请参阅 [XPath 词条](https://zh.wikipedia.org/wiki/XPath);如果你想检验所学,校验自己书写的 XPath 的正确性,那么你可以使用[这个工具](https://codebeautify.org/Xpath-Tester),这个工具和文中介绍的 Xpath Generator 配合起来使用,可以非常有效地帮助你理解 XPath。
|
||||
- 相应的,对于 JSONPath,你可以阅读[这篇文章](https://goessner.net/articles/JsonPath/)来进一步了解它的语法,你也可以使用[这个工具](https://jsonpath.com/)来校验其正确性。
|
||||
- 对于 YAML,你可以阅读[这个词条](https://zh.wikipedia.org/wiki/YAML)来获得较为全面的了解;另外,你可能听说过 YAML 和 JSON 之间的超集与子集这样的关系,那我推荐你阅读 YAML 官方文档的[这一小段](https://yaml.org/spec/1.2/spec.html#id2759572)关于它与 JSON 的区别和联系。
|
||||
- 文中介绍了数据交换语言这个大家族中的三个,其实还有其它成员,你可以阅读一[下这个列表](https://zh.wikipedia.org/wiki/%E6%95%B0%E6%8D%AE%E4%BA%A4%E6%8D%A2#%E7%94%A8%E6%96%BC%E6%95%B8%E6%93%9A%E4%BA%A4%E6%8F%9B%E7%9A%84%E5%B8%B8%E7%94%A8%E8%AA%9E%E8%A8%80)。
|
||||
|
||||
|
||||
94
极客时间专栏/全栈工程师修炼指南/第六章 专题/40 | 全栈衍化:让全栈意味着更多.md
Normal file
94
极客时间专栏/全栈工程师修炼指南/第六章 专题/40 | 全栈衍化:让全栈意味着更多.md
Normal file
@@ -0,0 +1,94 @@
|
||||
<audio id="audio" title="40 | 全栈衍化:让全栈意味着更多" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/a4/1c3edf9b2151d17d6ae6181b2fe025a4.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
专栏更到这里,我们已经把基于 Web 全栈的这棵大树,主要的枝枝丫丫大致都覆盖到了,可是,技术上我们总是希望“更进一步”。这棵大树所在的森林中,还有着广阔的领域等待着探索。当然,我想明确的是,我知道很多程序员还是会继续坚持这条路,因为全栈工程师本身是一个非常理想的职业发展方向,毕竟这个角色,大厂招,创业小团队更是需要;但同时我也知道,也有很多走在 Web 全栈路上的工程师,有着更多的想法,想尝试不一样的“可能性”,而这,就是我想说的个人的“全栈衍化”了。
|
||||
|
||||
## 个人
|
||||
|
||||
不知道你是否能记得起,我在这个专栏的 [[开篇词]](https://time.geekbang.org/column/article/134212) 中说到了关于全栈工程师的职业延伸,特别是在有了相当的积累的时候,比如工作接近五年、十年的时候,很多程序员都会对自己有更深刻的认识,更明确自己需要什么,喜欢什么。Web 全栈技术中,他们更愿意深挖某一个具体的领域;或是跳出了这个圈子,看上了另外的一棵技能树。也就是说,他们将全栈工程师作为自己的基础铺垫和视野拓展,从而成就职业进一步发展的跳板,毕竟,“有了全栈工程师的底子,未来面对软件行业进一步细化,选择其它细分职业时,会因为有了全面而扎实的基础而更有利”。
|
||||
|
||||
### 纵向:深挖 Web 的一个领域
|
||||
|
||||
如你所见,Web 全栈工程的覆盖面已经很广了,然后你再有了足够的积累,既包括项目的积累,又包括个人技术、非技术能力上的积累。但同时,如果你也发现,自己更喜爱某一个特定的子领域,那么这时候,是可以花一点时间,静下心来,想一想是不是可以细化自己的职业发展通道了。下面我来举两个纵向技术衍化的例子,希望给你一点启发:
|
||||
|
||||
**1. 前端工程师**
|
||||
|
||||
前端工程师是全栈衍化一个最常见的去向之一,当然,反过来的案例也许更多(从前端工程师到全栈)。我们在第三章一开始已经提到了,由于前端技术的特殊性,比方说,前端领域的思维模式有着显著的特殊性(参见 [[第 14 讲]](https://time.geekbang.org/column/article/145875)),Web 领域的工作细分很早,就从独立出前端作为开始,发生了。
|
||||
|
||||
由于工作和项目的关系,我接触过不少不同背景的前端工程师,或是类似的角色(这个名称在不同的企业中有不同的称呼,比如 Amazon 它叫做 WDE,Web Development Engineer),但是总体来说,有着扎实的全栈工程底子的前端工程师,还是明显地显露出,很不一样的认识问题的视野和思考角度。比方说,定位一个用户访问响应时间的问题,这样的工程师很擅长从整个请求响应的完整链路分层去剖析;再比如说,代码设计和组织,他们在分层和模块化方面,相对而言,有着普遍扎实的基础。
|
||||
|
||||
**2. SRE**
|
||||
|
||||
SRE(Site Reliability Engineer,网站可靠性工程师),这个角色最早很可能是 Google 创造出来的,从名称上也可以看出,这个职位的工程师所致力于解决的问题,就是网站可靠性的问题,这里的“可靠性”,包括可用性、延迟、容量等多个方面。他们就像是医院里的主刀大夫和急诊科医生,这是一个综合能力要求颇高的职位,也是一个绝对“实战”的职位,因为他们要面对的,都是大流量的网站等 Web 服务,都是一点点小问题都可能带来巨大经济损失的场景。
|
||||
|
||||
因此 SRE 需要尽力确保服务每分每秒的正常运行,他们的扮演的角色可远不只是“救火队长”,在“时间就是金钱”的压力环境下,严谨而大胆,快速定位和解决问题,但更重要的是,帮助不同的团队“防患于未然”,比如主导和把关新建服务的可靠性设计。SRE 有时要解决基础设施的问题,有时要分析服务端的压力来源,有时则要搞定网页上造成大量用户访问困难的“小 bug”。很显然,一个狭窄领域知识的工程师,是不可能胜任这样的岗位的,对于从端到端俯瞰整个流程的能力,Web 全栈工程师有着天然的优势。
|
||||
|
||||
### 横向:点亮另一棵技能树
|
||||
|
||||
下面再来看另一种分类,如果你发现自己的兴趣或是专长,并不在 Web 全栈领域方面,而是跃跃欲试地盯上了软件技术领域的另外一类角色,那其实也是一件可喜可贺的事情。毕竟,越早明确自己的兴趣和专长,在职业中做出变更的决定,就越能帮助自己接近目标,这其实有点像 RPG 游戏中的转职了。下面我就来举几个横向衍化的例子:
|
||||
|
||||
**1. 数据方向**
|
||||
|
||||
这里的数据未必指大数据。如果我们退回到 10 年前,数据所扮演的角色,远没有当今的软件行业这样重要。如果你在 Web 全栈的学习过程中发现,自己对于数据有着超乎平常人的敏感度,或者对于数据本身所蕴含的事实原理充满兴趣,那么有一些和数据密切相关的职业角色,可能会成为你未来职业发展的一个好的选择。
|
||||
|
||||
比如说数据科学家(Data Scientist)、数据分析师(Data Analyst)和商业智能工程师(Business Intelligence Engineer),其中的前两者,我在 [[第 20 讲]](https://time.geekbang.org/column/article/154696) 中有过介绍。这些角色,都需要具备相当的数据分析能力,掌握一定的统计知识。全栈工程师的背景,能让你在胜任这样的角色的时候,具备更综合的工程能力,从而脱颖而出。比如你可以更容易地设计和改进数据分析工具,比如你已经掌握了一定的数据可视化技术(参见 [[第 18 讲]](https://time.geekbang.org/column/article/152557)),就可以迅速地将实现方案落地。
|
||||
|
||||
**2. 系统方向**
|
||||
|
||||
这部分往往来自于对软件系统有着更高追求的工程师,这个方向在横向衍化中具备着相当的比例,比如,有一些软件工程师职位,从职责上看,其实和 Web 全栈工程师没有本质上的区别,但是因为其所涉及的项目、产品和技术领域独立在传统的 Web 之外,我就把它们单独拿出来介绍了。
|
||||
|
||||
我觉得这一点可以以我自己为例,我这几年所呆的团队,开发和维护的产品中,都包括典型的分布式系统。两年前是一个分布式计算平台,Amazon 所有产品的成本和利润就是在它上面完成计算的;如今在 Oracle 则是一个分布式工作流引擎。老实说,它们都和传统的 Web 全栈没有什么“直接关系”,但是正如我在专栏开始的时候所说,技术都是相通的,从全栈领域学到的那些套路和方法,可以帮助我在新的软件领域上手那些新技术。
|
||||
|
||||
我知道很多程序员朋友,从远期看都想成为架构师,比如平台架构师,解决方案架构师等等。但凡谈及“架构”基本上都意味着一个模糊的、足够大的领域。在我所了解的那些互联网巨头中,这些高级研发职位都要求跨团队、跨项目的技术决策和合作,很难想象一个狭窄领域诞生一个架构师级别的角色,而这一点,又得益于我们如今学得广、学得杂而夯实的基础。
|
||||
|
||||
**3. 产品经理**
|
||||
|
||||
产品经理可以说也是一个非常常见的方向,产品经理和程序员之间相爱相杀的故事,已经“烂大街”了。“土生土长”的产品经理的比例其实不算太高,很多公司的产品经理都是从不同的岗位转过来的。比如运维岗,这样的产品经理,往往会额外地关注产品的可靠性以及运维的难度,毕竟他们是从这条路走过来的,深知其重要性。
|
||||
|
||||
同样的,有着全栈工程师背景的产品经理,显然更能够从工程的角度出发,去理解需求的实现,理解用户交互过程背后的原理,撰写更优秀的非功能需求的产品设计文档,也可以轻而易举地做出 HTML 快速原型。
|
||||
|
||||
## 项目和团队
|
||||
|
||||
上面我只是从个人角度介绍了全栈的衍化,这也是最常规的思路。但是,我们也完全可以从更多的角度来审视这个概念,比如说项目和团队。
|
||||
|
||||
在这里我想说的全栈项目,是指能够从不同的软件领域和软件技术的角度,覆盖端到端需求并解决问题的项目或项目集合,这里未必指单个的项目,而可以是多个关联项目所组成的集合;相应的,全栈团队,其实我在 [[第 20 讲]](https://time.geekbang.org/column/article/154696) 中已经介绍过了,是指一个团队具备较多方面、较多层次的技能,联合协作,去解决某一个领域的问题。
|
||||
|
||||
为什么要讲这个?因为我们整个专栏都在专注于 Web 全栈工程技能这一点上,但是我不希望在专栏之后,因为它而给你造成了思考的禁锢。我想学到了今天,你已经对于程序员掌握全栈技能的优势有了自己的理解,可是这一部分,完全可以衍化到项目和团队这样的维度上。
|
||||
|
||||
### 我自己的故事
|
||||
|
||||
在我工作的最初几年,虽然已经是一个全栈工程师了,个人技术上虽然收获很大,但是并没有产生这一个层次的认识。直到我加入了 Amazon,它的工程师文化对我之后的成长产生了很大的影响,也就是从那时候开始,我有了对于全栈项目和全栈团队的思考。
|
||||
|
||||
拿我自己来举一个例子,我曾经在销量预测团队中工作,我们整个团队五十余人,用一句话来 粗略地概括我们每天的工作,就是给几千万的商品预测销量。显然这就是一个全栈的大项目,里面有十几、二十个不同领域的小项目,包括销量预测的计算平台、高可用数据分发服务、数据同步服务、预测数据的序列化和存储服务,数据分析的可视化工具、预测统计和健康监控系统等等。
|
||||
|
||||
因此,从项目的多样性,你就能够想象团队角色的多样性(具体内容请参见 [[第 20 讲]](https://time.geekbang.org/column/article/154696))。团队中有着许许多多擅长不同领域的角色,包括软件工程师、数据科学家、数据分析师、产品经理和支持工程师等等。而单说我们最熟悉的软件工程师,就具备着不同背景、不同专长,比如有擅长 MR 相关框架和技术的负责计算平台的工程师,有维护高可用数据分发服务的工程师,也有熟悉 Web 前后端开发的负责数据可视化的工程师等等。
|
||||
|
||||
后来我又加入了成本核算团队,项目也好,团队也好,技术也罢,虽然存在很大不同,可全栈的特性却是一致的。比方说,MR 的框架和工具从 Hadoop 系变成了 Spark 系,主要编程语言从 Java 变成了 Scala,工作流引擎从一个老旧的自研引擎服务变成了一个基于 SWF(Simple Work Flow)开发的在公司“内部开源”的新产品(即便放到今天来说,我依然觉得,它的共享资源管理等某几个核心功能,还是要比如今市面上我见到的都要优秀一些)……可是那又怎样,项目依然包含多个层次、不同的类别,而团队则依然包含类似分类的、多样的角色。
|
||||
|
||||
### 优势
|
||||
|
||||
在我看来,一个全栈的项目和团队,至少可以具备这样几个优势:
|
||||
|
||||
1.从多样的角度出发,提供完整的解决方案。
|
||||
|
||||
正如同销量预测团队中,预测一个产品的销量是一个极端困难的事儿,需要多种机器学习的模型配合工作,对于不同类型的商品,应用不同的数学模型;而数据的获取、计算、分发……这些又都需要软件来完成;数据的清洗、转换、分析又需要多种数据和统计知识,配合合适的工具来做到。对于一个角色单一的团队或项目,显然是无法做到这样一个复杂的过程的。
|
||||
|
||||
2.具备包容的团队,为不同特长和兴趣的人才提供创造价值的平台。
|
||||
|
||||
3.保持整体上健康和多样的思考角度,保证团队和产品的均衡发展。
|
||||
|
||||
每年我们都会举办 Hackathon,大致就是,团队成员可以提出创意、“招兵买马”,完整的两、三天时间,自发组织小团队,团队里有产品经理、工程师和数据分析师等等不同角色,一起把这个创意做出快速原型。其中优秀的一部分会成为未来一年真正的项目和产品。各种创意火花碰撞,这是我最喜欢的一个地方。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我聊了聊 Web 全栈工程师在完成核心技术的修炼之后,可以考虑的下一步和进一步的方向,也就是个人的全栈衍化;也从项目和团队的角度,讲了全栈的优势和重要性。
|
||||
|
||||
不知道正在阅读的你,关于这方面,从职业规划的角度看,你的思路是怎样的,能简单分享一下吗?
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 关于 SRE 这个角色,你可以参看 Google [自己的描述](https://landing.google.com/sre/),以及 SRE 这个[词条](https://en.wikipedia.org/wiki/Site_Reliability_Engineering)。
|
||||
- 关于文中提到的 Hackathon,想了解更多的话,你可以阅读[这个](https://en.wikipedia.org/wiki/Hackathon)内容。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user