This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
<audio id="audio" title="52 | 算法实战剖析Redis常用数据类型对应的数据结构" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/8c/35e732ee9ec2e36cyy4ca31ce4efc88c.mp3"></audio>
到此为止,专栏前三部分我们全部讲完了。从今天开始,我们就正式进入实战篇的部分。这部分我主要通过一些开源项目、经典系统,真枪实弹地教你,如何将数据结构和算法应用到项目中。所以这部分的内容,更多的是知识点的回顾,相对于基础篇、高级篇的内容,其实这部分会更加容易看懂。
不过,我希望你不要只是看懂就完了。你要多举一反三地思考,自己接触过的开源项目、基础框架、中间件中,都用过哪些数据结构和算法。你也可以想一想,在自己做的项目中,有哪些可以用学过的数据结构和算法进一步优化。这样的学习效果才会更好。
好了,今天我就带你一块儿看下,**经典数据库Redis中的常用数据类型底层都是用哪种数据结构实现的**
## Redis数据库介绍
Redis是一种键值Key-Value数据库。相对于关系型数据库比如MySQLRedis也被叫作**非关系型数据库**。
像MySQL这样的关系型数据库表的结构比较复杂会包含很多字段可以通过SQL语句来实现非常复杂的查询需求。而Redis中只包含“键”和“值”两部分只能通过“键”来查询“值”。正是因为这样简单的存储结构也让Redis的读写效率非常高。
除此之外Redis主要是作为内存数据库来使用也就是说数据是存储在内存中的。尽管它经常被用作内存数据库但是它也支持将数据存储在硬盘中。这一点我们后面会介绍。
Redis中键的数据类型是字符串但是为了丰富数据存储的方式方便开发者使用值的数据类型有很多常用的数据类型有这样几种它们分别是字符串、列表、字典、集合、有序集合。
“字符串string”这种数据类型非常简单对应到数据结构里就是**字符串**。你应该非常熟悉,这里我就不多介绍了。我们着重看下,其他四种比较复杂点的数据类型,看看它们底层都依赖了哪些数据结构。
## 列表list
我们先来看列表。列表这种数据类型支持存储一组数据。这种数据类型对应两种实现方法,一种是**压缩列表**ziplist另一种是双向循环链表。
当列表中存储的数据量比较小的时候,列表就可以采用压缩列表的方式实现。具体需要同时满足下面两个条件:
<li>
列表中保存的单个数据有可能是字符串类型的小于64字节
</li>
<li>
列表中数据个数少于512个。
</li>
关于压缩列表我这里稍微解释一下。它并不是基础数据结构而是Redis自己设计的一种数据存储结构。它有点儿类似数组通过一片连续的内存空间来存储数据。不过它跟数组不同的一点是它允许存储的数据大小不同。具体的存储结构也非常简单你可以看我下面画的这幅图。
<img src="https://static001.geekbang.org/resource/image/49/b5/49fd8d46eb94f463ace98717f11c2cb5.jpg" alt="">
现在,我们来看看,压缩列表中的“压缩”两个字该如何理解?
听到“压缩”两个字直观的反应就是节省内存。之所以说这种存储结构节省内存是相较于数组的存储思路而言的。我们知道数组要求每个元素的大小相同如果我们要存储不同长度的字符串那我们就需要用最大长度的字符串大小作为元素的大小假设是20个字节。那当我们存储小于20个字节长度的字符串的时候便会浪费部分存储空间。听起来有点儿拗口我画个图解释一下。
<img src="https://static001.geekbang.org/resource/image/2e/69/2e2f2e5a2fe25d26dc2fc04cfe88f869.jpg" alt="">
压缩列表这种存储结构,一方面比较节省内存,另一方面可以支持不同类型数据的存储。而且,因为数据存储在一片连续的内存空间,通过键来获取值为列表类型的数据,读取的效率也非常高。
当列表中存储的数据量比较大的时候,也就是不能同时满足刚刚讲的两个条件的时候,列表就要通过双向循环链表来实现了。
在[链表](https://time.geekbang.org/column/article/41013)里我们已经讲过双向循环链表这种数据结构了如果不记得了你可以先回去复习一下。这里我们着重看一下Redis中双向链表的编码实现方式。
Redis的这种双向链表的实现方式非常值得借鉴。它额外定义一个list结构体来组织链表的首、尾指针还有长度等信息。这样在使用的时候就会非常方便。
```
// 以下是C语言代码因为Redis是用C语言实现的。
typedef struct listnode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
typedef struct list {
listNode *head;
listNode *tail;
unsigned long len;
// ....省略其他定义
} list;
```
## 字典hash
字典类型用来存储一组数据对。每个数据对又包含键值两部分。字典类型也有两种实现方式。一种是我们刚刚讲到的**压缩列表**,另一种是**散列表**。
同样只有当存储的数据量比较小的情况下Redis才使用压缩列表来实现字典类型。具体需要满足两个条件
<li>
字典中保存的键和值的大小都要小于64字节
</li>
<li>
字典中键值对的个数要小于512个。
</li>
当不能同时满足上面两个条件的时候Redis就使用散列表来实现字典类型。Redis使用[MurmurHash2](https://zh.wikipedia.org/wiki/Murmur%E5%93%88%E5%B8%8C)这种运行速度快、随机性好的哈希算法作为哈希函数。对于哈希冲突问题Redis使用链表法来解决。除此之外Redis还支持散列表的动态扩容、缩容。
当数据动态增加之后散列表的装载因子会不停地变大。为了避免散列表性能的下降当装载因子大于1的时候Redis会触发扩容将散列表扩大为原来大小的2倍左右具体值需要计算才能得到如果感兴趣你可以去阅读[源码](https://github.com/antirez/redis/blob/unstable/src/dict.c))。
当数据动态减少之后为了节省内存当装载因子小于0.1的时候Redis就会触发缩容缩小为字典中数据个数的大约2倍大小这个值也是计算得到的如果感兴趣你也可以去阅读[源码](https://github.com/antirez/redis/blob/unstable/src/dict.c))。
我们前面讲过扩容缩容要做大量的数据搬移和哈希值的重新计算所以比较耗时。针对这个问题Redis使用我们在[散列表(中)](https://time.geekbang.org/column/article/64586)讲的渐进式扩容缩容策略,将数据的搬移分批进行,避免了大量数据一次性搬移导致的服务停顿。
## 集合set
集合这种数据类型用来存储一组不重复的数据。这种数据类型也有两种实现方法,一种是基于有序数组,另一种是基于散列表。
当要存储的数据同时满足下面这样两个条件的时候Redis就采用有序数组来实现集合这种数据类型。
<li>
存储的数据都是整数;
</li>
<li>
存储的数据元素个数不超过512个。
</li>
当不能同时满足这两个条件的时候Redis就使用散列表来存储集合中的数据。
## 有序集合sortedset
有序集合这种数据类型,我们在[跳表](https://time.geekbang.org/column/article/42896)里已经详细讲过了。它用来存储一组数据,并且每个数据会附带一个得分。通过得分的大小,我们将数据组织成跳表这样的数据结构,以支持快速地按照得分值、得分区间获取数据。
实际上跟Redis的其他数据类型一样有序集合也并不仅仅只有跳表这一种实现方式。当数据量比较小的时候Redis会用压缩列表来实现有序集合。具体点说就是使用压缩列表来实现有序集合的前提有这样两个
<li>
所有数据的大小都要小于64字节
</li>
<li>
元素个数要小于128个。
</li>
## 数据结构持久化
尽管Redis经常会被用作内存数据库但是它也支持数据落盘也就是将内存中的数据存储到硬盘中。这样当机器断电的时候存储在Redis中的数据也不会丢失。在机器重新启动之后Redis只需要再将存储在硬盘中的数据重新读取到内存就可以继续工作了。
刚刚我们讲到Redis的数据格式由“键”和“值”两部分组成。而“值”又支持很多数据类型比如字符串、列表、字典、集合、有序集合。像字典、集合等类型底层用到了散列表散列表中有指针的概念而指针指向的是内存中的存储地址。 那Redis是如何将这样一个跟具体内存地址有关的数据结构存储到磁盘中的呢
实际上Redis遇到的这个问题并不特殊很多场景中都会遇到。我们把它叫作**数据结构的持久化问题**,或者**对象的持久化问题**。这里的“持久化”,你可以笼统地理解为“存储到磁盘”。
如何将数据结构持久化到硬盘?我们主要有两种解决思路。
第一种是清除原有的存储结构只将数据存储到磁盘中。当我们需要从磁盘还原数据到内存的时候再重新将数据组织成原来的数据结构。实际上Redis采用的就是这种持久化思路。
不过这种方式也有一定的弊端。那就是数据从硬盘还原到内存的过程会耗用比较多的时间。比如我们现在要将散列表中的数据存储到磁盘。当我们从磁盘中取出数据重新构建散列表的时候需要重新计算每个数据的哈希值。如果磁盘中存储的是几GB的数据那重构数据结构的耗时就不可忽视了。
第二种方式是保留原来的存储格式,将数据按照原有的格式存储在磁盘中。我们拿散列表这样的数据结构来举例。我们可以将散列表的大小、每个数据被散列到的槽的编号等信息,都保存在磁盘中。有了这些信息,我们从磁盘中将数据还原到内存中的时候,就可以避免重新计算哈希值。
## 总结引申
今天我们学习了Redis中常用数据类型底层依赖的数据结构总结一下大概有这五种**压缩列表**(可以看作一种特殊的数组)、**有序数组**、**链表**、**散列表**、**跳表**。实际上Redis就是这些常用数据结构的封装。
你有没有发现有了数据结构和算法的基础之后再去阅读Redis的源码理解起来就容易多了很多原来觉得很深奥的设计思想是不是就都会觉得顺理成章了呢
还是那句话,夯实基础很重要。同样是看源码,有些人只能看个热闹,了解一些皮毛,无法形成自己的知识结构,不能化为己用,过不几天就忘了。而有些人基础很好,不但能知其然,还能知其所以然,从而真正理解作者设计的动机。这样不但能有助于我们理解所用的开源软件,还能为我们自己创新添砖加瓦。
## 课后思考
<li>
你有没有发现在数据量比较小的情况下Redis中的很多数据类型比如字典、有序集合等都是通过多种数据结构来实现的为什么会这样设计呢用一种固定的数据结构来实现不是更加简单吗
</li>
<li>
我们讲到数据结构持久化有两种方法。对于二叉查找树这种数据结构,我们如何将它持久化到磁盘中呢?
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="53 | 算法实战(二):剖析搜索引擎背后的经典数据结构和算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/8d/a84d7173f440a0421af1368ccae31e8d.mp3"></audio>
像百度、Google这样的搜索引擎在我们平时的工作、生活中几乎天天都会用到。如果我们把搜索引擎也当作一个互联网产品的话那它跟社交、电商这些类型的产品相比有一个非常大的区别那就是它是一个技术驱动的产品。所谓技术驱动是指搜索引擎实现起来技术难度非常大技术的好坏直接决定了这个产品的核心竞争力。
在搜索引擎的设计与实现中会用到大量的算法。有很多针对特定问题的算法也有很多我们专栏中讲到的基础算法。所以百度、Google这样的搜索引擎公司在面试的时候会格外重视考察候选人的算法能力。
**今天我就借助搜索引擎,这样一个非常有技术含量的产品,来给你展示一下,数据结构和算法是如何应用在其中的。**
## 整体系统介绍
像Google这样的大型商用搜索引擎有成千上万的工程师十年如一日地对它进行优化改进所以它所包含的技术细节非常多。我很难、也没有这个能力通过一篇文章把所有细节都讲清楚当然这也不是我们专栏所专注的内容。
所以接下来的讲解我主要给你展示如何在一台机器上假设这台机器的内存是8GB 硬盘是100多GB通过少量的代码实现一个小型搜索引擎。不过麻雀虽小五脏俱全。跟大型搜索引擎相比实现这样一个小型搜索引擎所用到的理论基础是相通的。
搜索引擎大致可以分为四个部分:**搜集**、**分析**、**索引**、**查询**。其中搜集就是我们常说的利用爬虫爬取网页。分析主要负责网页内容抽取、分词构建临时索引计算PageRank值这几部分工作。索引主要负责通过分析阶段得到的临时索引构建倒排索引。查询主要负责响应用户的请求根据倒排索引获取相关网页计算网页排名返回查询结果给用户。
接下来,我就按照网页处理的生命周期,从这四个阶段,依次来给你讲解,一个网页从被爬取到最终展示给用户,这样一个完整的过程。与此同时,我会穿插讲解,这个过程中需要用到哪些数据结构和算法。
## 搜集
现在,互联网越来越发达,网站越来越多,对应的网页也就越来越多。对于搜索引擎来说,它事先并不知道网页都在哪里。打个比方来说就是,我们只知道海里面有很多鱼,但却并不知道鱼在哪里。那搜索引擎是如何爬取网页的呢?
搜索引擎把整个互联网看作数据结构中的有向图,把每个页面看作一个顶点。如果某个页面中包含另外一个页面的链接,那我们就在两个顶点之间连一条有向边。我们可以利用图的遍历搜索算法,来遍历整个互联网中的网页。
我们前面介绍过两种图的遍历方法,深度优先和广度优先。搜索引擎采用的是广度优先搜索策略。具体点讲的话,那就是,我们先找一些比较知名的网页(专业的叫法是权重比较高)的链接(比如新浪主页网址、腾讯主页网址等),作为种子网页链接,放入到队列中。爬虫按照广度优先的策略,不停地从队列中取出链接,然后去爬取对应的网页,解析出网页里包含的其他网页链接,再将解析出来的链接添加到队列中。
基本的原理就是这么简单。但落实到实现层面,还有很多技术细节。我下面借助搜集阶段涉及的几个重要文件,来给你解释一下搜集工程都有哪些关键技术细节。
### 1.待爬取网页链接文件links.bin
在广度优先搜索爬取页面的过程中爬虫会不停地解析页面链接将其放到队列中。于是队列中的链接就会越来越多可能会多到内存放不下。所以我们用一个存储在磁盘中的文件links.bin来作为广度优先搜索中的队列。爬虫从links.bin文件中取出链接去爬取对应的页面。等爬取到网页之后将解析出来的链接直接存储到links.bin文件中。
这样用文件来存储网页链接的方式,还有其他好处。比如,支持断点续爬。也就是说,当机器断电之后,网页链接不会丢失;当机器重启之后,还可以从之前爬取到的位置继续爬取。
关于如何解析页面获取链接,我额外多说几句。我们可以把整个页面看作一个大的字符串,然后利用字符串匹配算法,在这个大字符串中,搜索`&lt;link&gt;`这样一个网页标签,然后顺序读取`&lt;link&gt;&lt;/link&gt;`之间的字符串。这其实就是网页链接。
### 2.网页判重文件bloom_filter.bin
如何避免重复爬取相同的网页呢?这个问题我们在[位图](https://time.geekbang.org/column/article/76827)那一节已经讲过了。使用布隆过滤器,我们就可以快速并且非常节省内存地实现网页的判重。
不过,还是刚刚那个问题,如果我们把布隆过滤器存储在内存中,那机器宕机重启之后,布隆过滤器就被清空了。这样就可能导致大量已经爬取的网页会被重复爬取。
这个问题该怎么解决呢我们可以定期地比如每隔半小时将布隆过滤器持久化到磁盘中存储在bloom_filter.bin文件中。这样即便出现机器宕机也只会丢失布隆过滤器中的部分数据。当机器重启之后我们就可以重新读取磁盘中的bloom_filter.bin文件将其恢复到内存中。
### 3.原始网页存储文件doc_raw.bin
爬取到网页之后,我们需要将其存储下来,以备后面离线分析、索引之用。那如何存储海量的原始网页数据呢?
如果我们把每个网页都存储为一个独立的文件那磁盘中的文件就会非常多数量可能会有几千万甚至上亿。常用的文件系统显然不适合存储如此多的文件。所以我们可以把多个网页存储在一个文件中。每个网页之间通过一定的标识进行分隔方便后续读取。具体的存储格式如下图所示。其中doc_id这个字段是网页的编号我们待会儿再解释。
<img src="https://static001.geekbang.org/resource/image/19/4d/195c9a1dceaaa9f4d2483fa91455404d.jpg" alt="">
当然这样的一个文件也不能太大因为文件系统对文件的大小也有一定的限制。所以我们可以设置每个文件的大小不能超过一定的值比如1GB。随着越来越多的网页被添加到文件中文件的大小就会越来越大当超过1GB的时候我们就创建一个新的文件用来存储新爬取的网页。
假设一台机器的硬盘大小是100GB左右一个网页的平均大小是64KB。那在一台机器上我们可以存储100万到200万左右的网页。假设我们的机器的带宽是10MB那下载100GB的网页大约需要10000秒。也就是说爬取100多万的网页也就是只需要花费几小时的时间。
### 4.网页链接及其编号的对应文件doc_id.bin
刚刚我们提到了网页编号这个概念我现在解释一下。网页编号实际上就是给每个网页分配一个唯一的ID方便我们后续对网页进行分析、索引。那如何给网页编号呢
我们可以按照网页被爬取的先后顺序从小到大依次编号。具体是这样做的我们维护一个中心的计数器每爬取到一个网页之后就从计数器中拿一个号码分配给这个网页然后计数器加一。在存储网页的同时我们将网页链接跟编号之间的对应关系存储在另一个doc_id.bin文件中。
**爬虫在爬取网页的过程中涉及的四个重要的文件我就介绍完了。其中links.bin和bloom_filter.bin这两个文件是爬虫自身所用的。另外的两个doc_raw.bin、doc_id.bin是作为搜集阶段的成果供后面的分析、索引、查询用的。**
## 分析
网页爬取下来之后,我们需要对网页进行离线分析。分析阶段主要包括两个步骤,第一个是抽取网页文本信息,第二个是分词并创建临时索引。我们逐一来讲解。
### 1.抽取网页文本信息
网页是半结构化数据里面夹杂着各种标签、JavaScript代码、CSS样式。对于搜索引擎来说它只关心网页中的文本信息也就是网页显示在浏览器中时能被用户肉眼看到的那部分信息。我们如何从半结构化的网页中抽取出搜索引擎关系的文本信息呢
我们之所以把网页叫作半结构化数据,是因为它本身是按照一定的规则来书写的。这个规则就是**HTML语法规范**。我们依靠HTML标签来抽取网页中的文本信息。这个抽取的过程大体可以分为两步。
第一步是去掉JavaScript代码、CSS格式以及下拉框中的内容因为下拉框在用户不操作的情况下也是看不到的。也就是`&lt;style&gt;&lt;/style&gt;``&lt;script&gt;&lt;/script&gt;``&lt;option&gt;&lt;/option&gt;`这三组标签之间的内容。我们可以利用AC自动机这种多模式串匹配算法在网页这个大字符串中一次性查找`&lt;style&gt;`, `&lt;script&gt;`, `&lt;option&gt;`这三个关键词。当找到某个关键词出现的位置之后,我们只需要依次往后遍历,直到对应结束标签(`&lt;/style&gt;`, `&lt;/script&gt;`, `&lt;/option`)为止。而这期间遍历到的字符串连带着标签就应该从网页中删除。
第二步是去掉所有HTML标签。这一步也是通过字符串匹配算法来实现的。过程跟第一步类似我就不重复讲了。
### 2.分词并创建临时索引
经过上面的处理之后,我们就从网页中抽取出了我们关心的文本信息。接下来,我们要对文本信息进行分词,并且创建临时索引。
对于英文网页来说,分词非常简单。我们只需要通过空格、标点符号等分隔符,将每个单词分割开来就可以了。但是,对于中文来说,分词就复杂太多了。我这里介绍一种比较简单的思路,基于字典和规则的分词方法。
其中,字典也叫词库,里面包含大量常用的词语(我们可以直接从网上下载别人整理好的)。我们借助词库并采用最长匹配规则,来对文本进行分词。所谓最长匹配,也就是匹配尽可能长的词语。我举个例子解释一下。
比如要分词的文本是“中国人民解放了”我们词库中有“中国”“中国人”“中国人民”“中国人民解放军”这几个词那我们就取最长匹配也就是“中国人民”划为一个词而不是把“中国”、“中国人”划为一个词。具体到实现层面我们可以将词库中的单词构建成Trie树结构然后拿网页文本在Trie树中匹配。
每个网页的文本信息在分词完成之后我们都得到一组单词列表。我们把单词与网页之间的对应关系写入到一个临时索引文件中tmp_Index.bin这个临时索引文件用来构建倒排索引文件。临时索引文件的格式如下
<img src="https://static001.geekbang.org/resource/image/15/1e/156ee98c0ad5763a082c1f3002d6051e.jpg" alt="">
在临时索引文件中我们存储的是单词编号也就是图中的term_id而非单词本身。这样做的目的主要是为了节省存储的空间。那这些单词的编号是怎么来的呢
给单词编号的方式,跟给网页编号类似。我们维护一个计数器,每当从网页文本信息中分割出一个新的单词的时候,我们就从计数器中取一个编号,分配给它,然后计数器加一。
在这个过程中,我们还需要使用散列表,记录已经编过号的单词。在对网页文本信息分词的过程中,我们拿分割出来的单词,先到散列表中查找,如果找到,那就直接使用已有的编号;如果没有找到,我们再去计数器中拿号码,并且将这个新单词以及编号添加到散列表中。
当所有的网页处理分词及写入临时索引完成之后我们再将这个单词跟编号之间的对应关系写入到磁盘文件中并命名为term_id.bin。
**经过分析阶段我们得到了两个重要的文件。它们分别是临时索引文件tmp_index.bin和单词编号文件term_id.bin。**
## 索引
索引阶段主要负责将分析阶段产生的临时索引,构建成倒排索引。倒排索引( Inverted index中记录了每个单词以及包含它的网页列表。文字描述比较难理解我画了一张倒排索引的结构图你一看就明白。
<img src="https://static001.geekbang.org/resource/image/de/34/de1f212bc669312a499bbbf2ee3a3734.jpg" alt="">
我们刚刚讲到,在临时索引文件中,记录的是单词跟每个包含它的文档之间的对应关系。那如何通过临时索引文件,构建出倒排索引文件呢?这是一个非常典型的算法问题,你可以先自己思考一下,再看我下面的讲解。
解决这个问题的方法有很多。考虑到临时索引文件很大,无法一次性加载到内存中,搜索引擎一般会选择使用**多路归并排序**的方法来实现。
我们先对临时索引文件按照单词编号的大小进行排序。因为临时索引很大所以一般基于内存的排序算法就没法处理这个问题了。我们可以用之前讲到的归并排序的处理思想将其分割成多个小文件先对每个小文件独立排序最后再合并在一起。当然实际的软件开发中我们其实可以直接利用MapReduce来处理。
临时索引文件排序完成之后,相同的单词就被排列到了一起。我们只需要顺序地遍历排好序的临时索引文件,就能将每个单词对应的网页编号列表找出来,然后把它们存储在倒排索引文件中。具体的处理过程,我画成了一张图。通过图,你应该更容易理解。
<img src="https://static001.geekbang.org/resource/image/c9/e6/c91c960472d88233f60d5d4ce6538ee6.jpg" alt="">
除了倒排文件之外我们还需要一个文件来记录每个单词编号在倒排索引文件中的偏移位置。我们把这个文件命名为term_offset.bin。这个文件的作用是帮助我们快速地查找某个单词编号在倒排索引中存储的位置进而快速地从倒排索引中读取单词编号对应的网页编号列表。
<img src="https://static001.geekbang.org/resource/image/de/54/deb2fd01ea6f7e1df9da1ad3a8da5854.jpg" alt="">
**经过索引阶段的处理我们得到了两个有价值的文件它们分别是倒排索引文件index.bin和记录单词编号在索引文件中的偏移位置的文件term_offset.bin。**
## 查询
前面三个阶段的处理,只是为了最后的查询做铺垫。因此,现在我们就要利用之前产生的几个文件,来实现最终的用户搜索功能。
<li>
doc_id.bin记录网页链接和编号之间的对应关系。
</li>
<li>
term_id.bin记录单词和编号之间的对应关系。
</li>
<li>
index.bin倒排索引文件记录每个单词编号以及对应包含它的网页编号列表。
</li>
<li>
term_offsert.bin记录每个单词编号在倒排索引文件中的偏移位置。
</li>
这四个文件中除了倒排索引文件index.bin比较大之外其他的都比较小。为了方便快速查找数据我们将其他三个文件都加载到内存中并且组织成散列表这种数据结构。
当用户在搜索框中输入某个查询文本的时候我们先对用户输入的文本进行分词处理。假设分词之后我们得到k个单词。
我们拿这k个单词去term_id.bin对应的散列表中查找对应的单词编号。经过这个查询之后我们得到了这k个单词对应的单词编号。
我们拿这k个单词编号去term_offset.bin对应的散列表中查找每个单词编号在倒排索引文件中的偏移位置。经过这个查询之后我们得到了k个偏移位置。
我们拿这k个偏移位置去倒排索引index.bin查找k个单词对应的包含它的网页编号列表。经过这一步查询之后我们得到了k个网页编号列表。
我们针对这k个网页编号列表统计每个网页编号出现的次数。具体到实现层面我们可以借助散列表来进行统计。统计得到的结果我们按照出现次数的多少从小到大排序。出现次数越多说明包含越多的用户查询单词用户输入的搜索文本经过分词之后的单词
经过这一系列查询我们就得到了一组排好序的网页编号。我们拿着网页编号去doc_id.bin文件中查找对应的网页链接分页显示给用户就可以了。
## 总结引申
今天,我给你展示了一个小型搜索引擎的设计思路。这只是一个搜索引擎设计的基本原理,有很多优化、细节我们并未涉及,比如计算网页权重的[PageRank](https://zh.wikipedia.org/wiki/PageRank)算法、计算查询结果排名的[tf](https://zh.wikipedia.org/wiki/Tf-idf)[-](https://zh.wikipedia.org/wiki/Tf-idf)[idf](https://zh.wikipedia.org/wiki/Tf-idf)模型等等。
在讲解的过程中我们涉及的数据结构和算法有图、散列表、Trie树、布隆过滤器、单模式字符串匹配算法、AC自动机、广度优先遍历、归并排序等。如果对其中哪些内容不清楚你可以回到对应的章节进行复习。
最后如果有时间的话我强烈建议你按照我的思路自己写代码实现一个简单的搜索引擎。这样写出来的即便只是一个demo但对于你深入理解数据结构和算法也是很有帮助的。
## 课后思考
<li>
图的遍历方法有两种,深度优先和广度优先。我们讲到,搜索引擎中的爬虫是通过广度优先策略来爬取网页的。搜索引擎为什么选择广度优先策略,而不是深度优先策略呢?
</li>
<li>
大部分搜索引擎在结果显示的时候,都支持摘要信息和网页快照。实际上,你只需要对我今天讲的设计思路,稍加改造,就可以支持这两项功能。你知道如何改造吗?
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,167 @@
<audio id="audio" title="54 | 算法实战剖析高性能队列Disruptor背后的数据结构和算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/e3/3f782c89e037c5b448dddf77b91319e3.mp3"></audio>
Disruptor你是否听说过呢它是一种内存消息队列。从功能上讲它其实有点儿类似Kafka。不过和Kafka不同的是Disruptor是线程之间用于消息传递的队列。它在Apache Storm、Camel、Log4j 2等很多知名项目中都有广泛应用。
之所以如此受青睐主要还是因为它的性能表现非常优秀。它比Java中另外一个非常常用的内存消息队列ArrayBlockingQueueABS的性能要高一个数量级可以算得上是最快的内存消息队列了。它还因此获得过Oracle官方的Duke大奖。
如此高性能的内存消息队列,在设计和实现上,必然有它独到的地方。今天,我们就来一块儿看下,**Disruptor是如何做到如此高性能的其底层依赖了哪些数据结构和算法**
## 基于循环队列的“生产者-消费者模型”
什么是内存消息队列?对很多业务工程师或者前端工程师来说,可能会比较陌生。不过,如果我说“生产者-消费者模型”,估计大部分人都知道。在这个模型中,“生产者”生产数据,并且将数据放到一个中心存储容器中。之后,“消费者”从中心存储容器中,取出数据消费。
这个模型非常简单、好理解,那你有没有思考过,这里面存储数据的中心存储容器,是用什么样的数据结构来实现的呢?
实际上,实现中心存储容器最常用的一种数据结构,就是我们在[第9节](https://time.geekbang.org/column/article/41330)讲的队列。队列支持数据的先进先出。正是这个特性,使得数据被消费的顺序性可以得到保证,也就是说,早被生产的数据就会早被消费。
我们在第9节讲过队列有两种实现思路。一种是基于链表实现的链式队列另一种是基于数组实现的顺序队列。不同的需求背景下我们会选择不同的实现方式。
如果我们要实现一个无界队列,也就是说,队列的大小事先不确定,理论上可以支持无限大。这种情况下,我们适合选用链表来实现队列。因为链表支持快速地动态扩容。如果我们要实现一个有界队列,也就是说,队列的大小事先确定,当队列中数据满了之后,生产者就需要等待。直到消费者消费了数据,队列有空闲位置的时候,生产者才能将数据放入。
实际上相较于无界队列有界队列的应用场景更加广泛。毕竟我们的机器内存是有限的。而无界队列占用的内存数量是不可控的。对于实际的软件开发来说这种不可控的因素就会有潜在的风险。在某些极端情况下无界队列就有可能因为内存持续增长而导致OOMOut of Memory错误。
在第9节中我们还讲过一种特殊的顺序队列循环队列。我们讲过非循环的顺序队列在添加、删除数据的工程中会涉及数据的搬移操作导致性能变差。而循环队列正好可以解决这个数据搬移的问题所以性能更加好。所以大部分用到顺序队列的场景中我们都选择用顺序队列中的循环队列。
实际上,**循环队列这种数据结构,就是我们今天要讲的内存消息队列的雏形。**我借助循环队列,实现了一个最简单的“生产者-消费者模型”。对应的代码我贴到这里,你可以看看。
为了方便你理解,对于生产者和消费者之间操作的同步,我并没有用到线程相关的操作。而是采用了“当队列满了之后,生产者就轮训等待;当队列空了之后,消费者就轮训等待”这样的措施。
```
public class Queue {
private Long[] data;
private int size = 0, head = 0, tail = 0;
public Queue(int size) {
this.data = new Long[size];
this.size = size;
}
public boolean add(Long element) {
if ((tail + 1) % size == head) return false;
data[tail] = element;
tail = (tail + 1) % size;
return true;
}
public Long poll() {
if (head == tail) return null;
long ret = data[head];
head = (head + 1) % size;
return ret;
}
}
public class Producer {
private Queue queue;
public Producer(Queue queue) {
this.queue = queue;
}
public void produce(Long data) throws InterruptedException {
while (!queue.add(data)) {
Thread.sleep(100);
}
}
}
public class Consumer {
private Queue queue;
public Consumer(Queue queue) {
this.queue = queue;
}
public void comsume() throws InterruptedException {
while (true) {
Long data = queue.poll();
if (data == null) {
Thread.sleep(100);
} else {
// TODO:...消费数据的业务逻辑...
}
}
}
}
```
## 基于加锁的并发“生产者-消费者模型”
实际上,刚刚的“生产者-消费者模型”实现代码,是不完善的。为什么这么说呢?
如果我们只有一个生产者往队列中写数据,一个消费者从队列中读取数据,那上面的代码是没有问题的。但是,如果有多个生产者在并发地往队列中写入数据,或者多个消费者并发地从队列中消费数据,那上面的代码就不能正确工作了。我来给你讲讲为什么。
在多个生产者或者多个消费者并发操作队列的情况下,刚刚的代码主要会有下面两个问题:
<li>
多个生产者写入的数据可能会互相覆盖;
</li>
<li>
多个消费者可能会读取重复的数据。
</li>
因为第一个问题和第二个问题产生的原理是类似的。所以,我着重讲解第一个问题是如何产生的以及该如何解决。对于第二个问题,你可以类比我对第一个问题的解决思路自己来想一想。
两个线程同时往队列中添加数据也就相当于两个线程同时执行类Queue中的add()函数。我们假设队列的大小size是10当前的tail指向下标7head指向下标3也就是说队列中还有空闲空间。这个时候线程1调用add()函数往队列中添加一个值为12的数据线程2调用add()函数往队列中添加一个值为15的数据。在极端情况下本来是往队列中添加了两个数据12和15最终可能只有一个数据添加成功另一个数据会被覆盖。这是为什么呢
<img src="https://static001.geekbang.org/resource/image/4f/3d/4f88bec40128dbc8c1b700b4cf38b63d.jpg" alt="">
为了方便你查看队列Queue中的add()函数,我把它从上面的代码中摘录出来,贴在这里。
```
public boolean add(Long element) {
if ((tail + 1) % size == head) return false;
data[tail] = element;
tail = (tail + 1) % size;
return true;
}
```
从这段代码中我们可以看到第3行给data[tail]赋值然后第4行才给tail的值加一。赋值和tail加一两个操作并非原子操作。这就会导致这样的情况发生当线程1和线程2同时执行add()函数的时候线程1先执行完了第3行语句将data[7]tail等于7的值设置为12。在线程1还未执行到第4行语句之前也就是还未将tail加一之前线程2执行了第3行语句又将data[7]的值设置为15也就是说那线程2插入的数据覆盖了线程1插入的数据。原本应该插入两个数据12和15现在只插入了一个数据15
<img src="https://static001.geekbang.org/resource/image/27/3a/27ee7d9c12590cfdf02a2f95996b713a.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/05/f7/05764e49514974aafaa97b70214a7af7.jpg" alt="">
那如何解决这种线程并发往队列中添加数据时,导致的数据覆盖、运行不正确问题呢?
最简单的处理方法就是给这段代码加锁同一时间只允许一个线程执行add()函数。这就相当于将这段代码的执行,由并行改成了串行,也就不存在我们刚刚说的问题了。
不过,天下没有免费的午餐,加锁将并行改成串行,必然导致多个生产者同时生产数据的时候,执行效率的下降。当然,我们可以继续优化代码,用[CAS](https://en.wikipedia.org/wiki/Compare-and-swap)compare and swap比较并交换操作等减少加锁的粒度但是这不是我们这节的重点。我们直接看Disruptor的处理方法。
## 基于无锁的并发“生产者-消费者模型”
尽管Disruptor的源码读起来很复杂但是基本思想其实非常简单。实际上它是换了一种队列和“生产者-消费者模型”的实现思路。
之前的实现思路中队列只支持两个操作添加数据和读取并移除数据分别对应代码中的add()函数和poll()函数而Disruptor采用了另一种实现思路。
对于生产者来说它往队列中添加数据之前先申请可用空闲存储单元并且是批量地申请连续的n个n≥1存储单元。当申请到这组连续的存储单元之后后续往队列中添加元素就可以不用加锁了因为这组存储单元是这个线程独享的。不过从刚刚的描述中我们可以看出申请存储单元的过程是需要加锁的。
对于消费者来说,处理的过程跟生产者是类似的。它先去申请一批连续可读的存储单元(这个申请的过程也是需要加锁的),当申请到这批存储单元之后,后续的读取操作就可以不用加锁了。
不过还有一个需要特别注意的地方那就是如果生产者A申请到了一组连续的存储单元假设是下标为3到6的存储单元生产者B紧跟着申请到了下标是7到9的存储单元那在3到6没有完全写入数据之前7到9的数据是无法读取的。这个也是Disruptor实现思路的一个弊端。
文字描述不好理解,我画了一个图,给你展示一下这个操作过程。
<img src="https://static001.geekbang.org/resource/image/a2/ba/a2c0d268070ed7cc11a5d22eb223f3ba.jpg" alt="">
实际上Disruptor采用的是RingBuffer和AvailableBuffer这两个结构来实现我刚刚讲的功能。不过因为我们主要聚焦在数据结构和算法上所以我对这两种结构做了简化但是基本思想是一致的。如果你对Disruptor感兴趣可以去阅读一下它的[源码](https://github.com/LMAX-Exchange/disruptor)。
## 总结引申
今天,我讲了如何实现一个高性能的并发队列。这里的“并发”两个字,实际上就是多线程安全的意思。
常见的内存队列往往采用循环队列来实现。这种实现方法,对于只有一个生产者和一个消费者的场景,已经足够了。但是,当存在多个生产者或者多个消费者的时候,单纯的循环队列的实现方式,就无法正确工作了。
这主要是因为,多个生产者在同时往队列中写入数据的时候,在某些情况下,会存在数据覆盖的问题。而多个消费者同时消费数据,在某些情况下,会存在消费重复数据的问题。
针对这个问题,最简单、暴力的解决方法就是,对写入和读取过程加锁。这种处理方法,相当于将原来可以并行执行的操作,强制串行执行,相应地就会导致操作性能的下降。
为了在保证逻辑正确的前提下尽可能地提高队列在并发情况下的性能Disruptor采用了“两阶段写入”的方法。在写入数据之前先加锁申请批量的空闲存储单元之后往队列中写入数据的操作就不需要加锁了写入的性能因此就提高了。Disruptor对消费过程的改造跟对生产过程的改造是类似的。它先加锁申请批量的可读取的存储单元之后从队列中读取数据的操作也就不需要加锁了读取的性能因此也就提高了。
你可能会觉得这个优化思路非常简单。实际上,不管架构设计还是产品设计,往往越简单的设计思路,越能更好地解决问题。正所谓“大道至简”,就是这个意思。
## 课后思考
为了提高存储性能我们往往通过分库分表的方式设计数据库表。假设我们有8张表用来存储用户信息。这个时候每张用户表中的ID字段就不能通过自增的方式来产生了。因为这样的话就会导致不同表之间的用户ID值重复。
为了解决这个问题我们需要实现一个ID生成器可以为所有的用户表生成唯一的ID号。那现在问题是如何设计一个高性能、支持并发的、能够生成全局唯一ID的ID生成器呢
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,136 @@
<audio id="audio" title="55 | 算法实战(四):剖析微服务接口鉴权限流背后的数据结构和算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/7d/b62bf5128fc94efa48ca5bcfde84587d.mp3"></audio>
微服务是最近几年才兴起的概念。简单点讲,就是把复杂的大应用,解耦拆分成几个小的应用。这样做的好处有很多。比如,这样有利于团队组织架构的拆分,毕竟团队越大协作的难度越大;再比如,每个应用都可以独立运维,独立扩容,独立上线,各个应用之间互不影响。不用像原来那样,一个小功能上线,整个大应用都要重新发布。
不过有利就有弊。大应用拆分成微服务之后服务之间的调用关系变得更复杂平台的整体复杂熵升高出错的概率、debug问题的难度都高了好几个数量级。所以为了解决这些问题服务治理便成了微服务的一个技术重点。
所谓服务治理,简单点讲,就是管理微服务,保证平台整体正常、平稳地运行。服务治理涉及的内容比较多,比如鉴权、限流、降级、熔断、监控告警等等。这些服务治理功能的实现,底层依赖大量的数据结构和算法。今天,我就拿其中的鉴权和限流这两个功能,来带你看看,它们的实现过程中都要用到哪些数据结构和算法。
## 鉴权背景介绍
以防你之前可能对微服务没有太多了解,所以我对鉴权的背景做了简化。
假设我们有一个微服务叫用户服务User Service。它提供很多用户相关的接口比如获取用户信息、注册、登录等给公司内部的其他应用使用。但是并不是公司内部所有应用都可以访问这个用户服务也并不是每个有访问权限的应用都可以访问用户服务的所有接口。
我举了一个例子给你讲解一下你可以看我画的这幅图。这里面只有A、B、C、D四个应用可以访问用户服务并且每个应用只能访问用户服务的部分接口。
<img src="https://static001.geekbang.org/resource/image/1a/3d/1a574c209ab80e2dcdc9a52479d4f73d.jpg" alt="">
要实现接口鉴权功能我们需要事先将应用对接口的访问权限规则设置好。当某个应用访问其中一个接口的时候我们就可以拿应用的请求URL在规则中进行匹配。如果匹配成功就说明允许访问如果没有可以匹配的规则那就说明这个应用没有这个接口的访问权限我们就拒绝服务。
## 如何实现快速鉴权?
接口的格式有很多有类似Dubbo这样的RPC接口也有类似Spring Cloud这样的HTTP接口。不同接口的鉴权实现方式是类似的我这里主要拿HTTP接口给你讲解。
鉴权的原理比较简单、好理解。那具体到实现层面我们该用什么数据结构来存储规则呢用户请求URL在规则中快速匹配又该用什么样的算法呢
实际上,不同的规则和匹配模式,对应的数据结构和匹配算法也是不一样的。所以,关于这个问题,我继续细化为三个更加详细的需求给你讲解。
### 1.如何实现精确匹配规则?
我们先来看最简单的一种匹配模式。只有当请求URL跟规则中配置的某个接口精确匹配时这个请求才会被接受、处理。为了方便你理解我举了一个例子你可以看一下。
<img src="https://static001.geekbang.org/resource/image/19/d1/19355363fa47c116edfd7d2ea57af4d1.jpg" alt="">
不同的应用对应不同的规则集合。我们可以采用散列表来存储这种对应关系。我这里着重讲下,每个应用对应的规则集合,该如何存储和匹配。
针对这种匹配模式我们可以将每个应用对应的权限规则存储在一个字符串数组中。当用户请求到来时我们拿用户的请求URL在这个字符串数组中逐一匹配匹配的算法就是我们之前学过的字符串匹配算法比如KMP、BM、BF等
规则不会经常变动所以为了加快匹配速度我们可以按照字符串的大小给规则排序把它组织成有序数组这种数据结构。当要查找某个URL能否匹配其中某条规则的时候我们可以采用二分查找算法在有序数组中进行匹配。
而二分查找算法的时间复杂度是O(logn)n表示规则的个数这比起时间复杂度是O(n)的顺序遍历快了很多。对于规则中接口长度比较长,并且鉴权功能调用量非常大的情况,这种优化方法带来的性能提升还是非常可观的 。
### 2.如何实现前缀匹配规则?
我们再来看一种稍微复杂的匹配模式。只要某条规则可以匹配请求URL的前缀我们就说这条规则能够跟这个请求URL匹配。同样为了方便你理解这种匹配模式我还是举一个例子说明一下。
<img src="https://static001.geekbang.org/resource/image/66/fe/662c4ffb278fedf842f0dffa465673fe.jpg" alt="">
不同的应用对应不同的规则集合。我们采用散列表来存储这种对应关系。我着重讲一下,每个应用的规则集合,最适合用什么样的数据结构来存储。
在[Trie树](https://time.geekbang.org/column/article/72414)那节我们讲到Trie树非常适合用来做前缀匹配。所以针对这个需求我们可以将每个用户的规则集合组织成Trie树这种数据结构。
不过Trie树中的每个节点不是存储单个字符而是存储接口被“/”分割之后的子目录(比如“/user/name”被分割为“user”“name”两个子目录。因为规则并不会经常变动所以在Trie树中我们可以把每个节点的子节点们组织成有序数组这种数据结构。在匹配的过程中我们可以利用二分查找算法决定从一个节点应该跳到哪一个子节点。
<img src="https://static001.geekbang.org/resource/image/69/b9/691d7f056fe48b8598f6f86568212db9.jpg" alt="">
### 3.如何实现模糊匹配规则?
如果我们的规则更加复杂,规则中包含通配符,比如“**”表示匹配任意多个子目录,“*”表示匹配任意一个子目录。只要用户请求URL可以跟某条规则模糊匹配我们就说这条规则适用于这个请求。为了方便你理解我举一个例子来解释一下。
<img src="https://static001.geekbang.org/resource/image/f7/32/f756e2fef50776442be41e48d7aa5532.jpg" alt="">
不同的应用对应不同的规则集合。我们还是采用散列表来存储这种对应关系。这点我们刚才讲过了,这里不再重复说了。我们着重看下,每个用户对应的规则集合,该用什么数据结构来存储?针对这种包含通配符的模糊匹配,我们又该使用什么算法来实现呢?
还记得我们在[回溯算法](https://time.geekbang.org/column/article/74287)那节讲的正则表达式的例子吗我们可以借助正则表达式那个例子的解决思路来解决这个问题。我们采用回溯算法拿请求URL跟每条规则逐一进行模糊匹配。如何用回溯算法进行模糊匹配这部分我就不重复讲了。你如果忘记了可以回到相应章节复习一下。
不过这个解决思路的时间复杂度是非常高的。我们需要拿每一个规则跟请求URL匹配一遍。那有没有办法可以继续优化一下呢
实际上,我们可以结合实际情况,挖掘出这样一个隐形的条件,那就是,并不是每条规则都包含通配符,包含通配符的只是少数。于是,我们可以把不包含通配符的规则和包含通配符的规则分开处理。
我们把不包含通配符的规则组织成有序数组或者Trie树具体组织成什么结构视具体的需求而定是精确匹配就组织成有序数组是前缀匹配就组织成Trie树而这一部分匹配就会非常高效。剩下的是少数包含通配符的规则我们只要把它们简单存储在一个数组中就可以了。尽管匹配起来会比较慢但是毕竟这种规则比较少所以这种方法也是可以接受的。
当接收到一个请求URL之后我们可以先在不包含通配符的有序数组或者Trie树中查找。如果能够匹配就不需要继续在通配符规则中匹配了如果不能匹配就继续在通配符规则中查找匹配。
## 限流背景介绍
讲完了鉴权的实现思路,我们再来看一下限流。
所谓限流顾名思义就是对接口调用的频率进行限制。比如每秒钟不能超过100次调用超过之后我们就拒绝服务。限流的原理听起来非常简单但它在很多场景中发挥着重要的作用。比如在秒杀、大促、双11、618等场景中限流已经成为了保证系统平稳运行的一种标配的技术解决方案。
按照不同的限流粒度,限流可以分为很多种类型。比如给每个接口限制不同的访问频率,或者给所有接口限制总的访问频率,又或者更细粒度地限制某个应用对某个接口的访问频率等等。
不同粒度的限流功能的实现思路都差不多,所以,我今天主要针对限制所有接口总的访问频率这样一个限流需求来讲解。其他粒度限流需求的实现思路,你可以自己思考。
## 如何实现精准限流?
最简单的限流算法叫**固定时间窗口限流算法**。这种算法是如何工作的呢首先我们需要选定一个时间起点之后每当有接口请求到来我们就将计数器加一。如果在当前时间窗口内根据限流规则比如每秒钟最大允许100次访问请求出现累加访问次数超过限流值的情况时我们就拒绝后续的访问请求。当进入下一个时间窗口之后计数器就清零重新计数。
<img src="https://static001.geekbang.org/resource/image/cd/3a/cd1343d3f0f09c9eba7fb6387f01b63a.jpg" alt="">
这种基于固定时间窗口的限流算法的缺点是,限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。这是怎么回事呢?我举一个例子给你解释一下。
假设我们的限流规则是每秒钟不能超过100次接口请求。第一个1s时间窗口内100次接口请求都集中在最后10ms内。在第二个1s的时间窗口内100次接口请求都集中在最开始的10ms内。虽然两个时间窗口内流量都符合限流要求≤100个请求但在两个时间窗口临界的20ms内会集中有200次接口请求。固定时间窗口限流算法并不能对这种情况做限制所以集中在这20ms内的200次请求就有可能压垮系统。
<img src="https://static001.geekbang.org/resource/image/e7/30/e712a0d49aaf0218d3760c7a5f9fdc30.jpg" alt="">
为了解决这个问题我们可以对固定时间窗口限流算法稍加改造。我们可以限制任意时间窗口比如1s接口请求数都不能超过某个阈值 比如100次。因此相对于固定时间窗口限流算法这个算法叫**滑动时间窗口限流算法**。
流量经过滑动时间窗口限流算法整形之后可以保证任意一个1s的时间窗口内都不会超过最大允许的限流值从流量曲线上来看会更加平滑。那具体到实现层面我们该如何来做呢
我们假设限流的规则是在任意1s内接口的请求次数都不能大于K次。我们就维护一个大小为K+1的循环队列用来记录1s内到来的请求。注意这里循环队列的大小等于限流次数加一因为循环队列存储数据时会浪费一个存储单元。
当有新的请求到来时我们将与这个新请求的时间间隔超过1s的请求从队列中删除。然后我们再来看循环队列中是否有空闲位置。如果有则把新请求存储在队列尾部tail指针所指的位置如果没有则说明这1秒内的请求次数已经超过了限流值K所以这个请求被拒绝服务。
为了方便你理解我举一个例子给你解释一下。在这个例子中我们假设限流的规则是任意1s内接口的请求次数都不能大于6次。
<img src="https://static001.geekbang.org/resource/image/74/79/748a2b39a068563d48837677016b8c79.jpg" alt="">
即便滑动时间窗口限流算法可以保证任意时间窗口内,接口请求次数都不会超过最大限流值,但是仍然不能防止,在细时间粒度上访问过于集中的问题。
比如我刚刚举的那个例子第一个1s的时间窗口内100次请求都集中在最后10ms中也就是说基于时间窗口的限流算法不管是固定时间窗口还是滑动时间窗口只能在选定的时间粒度上限流对选定时间粒度内的更加细粒度的访问频率不做限制。
实际上,针对这个问题,还有很多更加平滑的限流算法,比如令牌桶算法、漏桶算法等。如果感兴趣,你可以自己去研究一下。
## 总结引申
今天,我们讲解了跟微服务相关的接口鉴权和限流功能的实现思路。现在,我稍微总结一下。
关于鉴权,我们讲了三种不同的规则匹配模式。不管是哪种匹配模式,我们都可以用散列表来存储不同应用对应的不同规则集合。对于每个应用的规则集合的存储,三种匹配模式使用不同的数据结构。
对于第一种精确匹配模式我们利用有序数组来存储每个应用的规则集合并且通过二分查找和字符串匹配算法来匹配请求URL与规则。对于第二种前缀匹配模式我们利用Trie树来存储每个应用的规则集合。对于第三种模糊匹配模式我们采用普通的数组来存储包含通配符的规则通过回溯算法来进行请求URL与规则的匹配。
关于限流,我们讲了两种限流算法,第一种是固定时间窗口限流算法,第二种是滑动时间窗口限流算法。对于滑动时间窗口限流算法,我们用了之前学习过的循环队列来实现。比起固定时间窗口限流算法,它对流量的整形效果更好,流量更加平滑。
从今天的学习中,我们也可以看出,对于基础架构工程师来说,如果不精通数据结构和算法,我们就很难开发出性能卓越的基础架构、中间件。这其实就体现了数据结构和算法的重要性。
## 课后思考
<li>
除了用循环队列来实现滑动时间窗口限流算法之外,我们是否还可以用其他数据结构来实现呢?请对比一下这些数据结构跟循环队列在解决这个问题时的优劣之处。
</li>
<li>
分析一下鉴权那部分内容中,前缀匹配算法的时间复杂度和空间复杂度。
</li>
最后,有个消息提前通知你一下。本节是专栏的倒数第二节课了,不知道学到现在,你掌握得怎么样呢?为了帮你复习巩固,做到真正掌握这些知识,我针对专栏涉及的数据结构和算法,精心编制了一套练习题。从正月初一到初七,每天发布一篇。你要做好准备哦!

View File

@@ -0,0 +1,124 @@
<audio id="audio" title="56 | 算法实战(五):如何用学过的数据结构和算法实现一个短网址系统?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/47/fefd864fde1ff92fa456349ff7597c47.mp3"></audio>
短网址服务你用过吗如果我们在微博里发布一条带网址的信息微博会把里面的网址转化成一个更短的网址。我们只要访问这个短网址就相当于访问原始的网址。比如下面这两个网址尽管长度不同但是都可以跳转到我的一个GitHub开源项目里。其中第二个网址就是通过新浪提供的短网址服务生成的。
```
原始网址https://github.com/wangzheng0822/ratelimiter4j
短网址http://t.cn/EtR9QEG
```
从功能上讲,短网址服务其实非常简单,就是把一个长的网址转化成一个短的网址。作为一名软件工程师,你是否思考过,这样一个简单的功能,是如何实现的呢?底层都依赖了哪些数据结构和算法呢?
## 短网址服务整体介绍
刚刚我们讲了,短网址服务的一个核心功能,就是把原始的长网址转化成短网址。除了这个功能之外,短网址服务还有另外一个必不可少的功能。那就是,当用户点击短网址的时候,短网址服务会将浏览器重定向为原始网址。这个过程是如何实现的呢?
为了方便你理解,我画了一张对比图,你可以看下。
<img src="https://static001.geekbang.org/resource/image/1c/43/1cedb2511ec220d90d9caf71ef6c7643.jpg" alt="">
从图中我们可以看出,浏览器会先访问短网址服务,通过短网址获取到原始网址,再通过原始网址访问到页面。不过这部分功能并不是我们今天要讲的重点。我们重点来看,如何将长网址转化成短网址?
## 如何通过哈希算法生成短网址?
我们前面学过哈希算法。哈希算法可以将一个不管多长的字符串,转化成一个长度固定的哈希值。我们可以利用哈希算法,来生成短网址。
前面我们已经提过一些哈希算法了比如MD5、SHA等。但是实际上我们并不需要这些复杂的哈希算法。在生成短网址这个问题上毕竟我们不需要考虑反向解密的难度所以我们只需要关心哈希算法的计算速度和冲突概率。
能够满足这样要求的哈希算法有很多,其中比较著名并且应用广泛的一个哈希算法,那就是[MurmurHash算法](https://zh.wikipedia.org/wiki/Murmur%E5%93%88%E5%B8%8C)。尽管这个哈希算法在2008年才被发明出来但现在它已经广泛应用到Redis、MemCache、Cassandra、HBase、Lucene等众多著名的软件中。
MurmurHash算法提供了两种长度的哈希值一种是32bits一种是128bits。为了让最终生成的短网址尽可能短我们可以选择32bits的哈希值。对于开头那个GitHub网址经过MurmurHash计算后得到的哈希值就是181338494。我们再拼上短网址服务的域名就变成了最终的短网址http://t.cn/181338494其中[http://t.cn](http://t.cn) 是短网址服务的域名)。
### 1.如何让短网址更短?
不过你可能已经看出来了通过MurmurHash算法得到的短网址还是很长啊而且跟我们开头那个网址的格式好像也不一样。别着急我们只需要稍微改变一个哈希值的表示方法就可以轻松把短网址变得更短些。
我们可以将10进制的哈希值转化成更高进制的哈希值这样哈希值就变短了。我们知道16进制中我们用AF来表示1015。在网址URL中常用的合法字符有09、az、AZ这样62个字符。为了让哈希值表示起来尽可能短我们可以将10进制的哈希值转化成62进制。具体的计算过程我写在这里了。最终用62进制表示的短网址就是[http://t.cn/cgSqq](http://t.cn/cgSqq%E3%80%82)。
<img src="https://static001.geekbang.org/resource/image/15/f8/15e486a7db8d56a7b1c5ecf873b477f8.jpg" alt="">
### 2.如何解决哈希冲突问题?
不过我们前面讲过哈希算法无法避免的一个问题就是哈希冲突。尽管MurmurHash算法冲突的概率非常低。但是一旦冲突就会导致两个原始网址被转化成同一个短网址。当用户访问短网址的时候我们就无从判断用户想要访问的是哪一个原始网址了。这个问题该如何解决呢
一般情况下我们会保存短网址跟原始网址之间的对应关系以便后续用户在访问短网址的时候可以根据对应关系查找到原始网址。存储这种对应关系的方式有很多比如我们自己设计存储系统或者利用现成的数据库。前面我们讲到的数据库有MySQL、Redis。我们就拿MySQL来举例。假设短网址与原始网址之间的对应关系就存储在MySQL数据库中。
当有一个新的原始网址需要生成短网址的时候我们先利用MurmurHash算法生成短网址。然后我们拿这个新生成的短网址在MySQL数据库中查找。
如果没有找到相同的短网址这也就表明这个新生成的短网址没有冲突。于是我们就将这个短网址返回给用户请求生成短网址的用户然后将这个短网址与原始网址之间的对应关系存储到MySQL数据库中。
如果我们在数据库中,找到了相同的短网址,那也并不一定说明就冲突了。我们从数据库中,将这个短网址对应的原始网址也取出来。如果数据库中的原始网址,跟我们现在正在处理的原始网址是一样的,这就说明已经有人请求过这个原始网址的短网址了。我们就可以拿这个短网址直接用。如果数据库中记录的原始网址,跟我们正在处理的原始网址不一样,那就说明哈希算法发生了冲突。不同的原始网址,经过计算,得到的短网址重复了。这个时候,我们该怎么办呢?
我们可以给原始网址拼接一串特殊字符,比如“[DUPLICATED]”,然后再重新计算哈希值,两次哈希计算都冲突的概率,显然是非常低的。假设出现非常极端的情况,又发生冲突了,我们可以再换一个拼接字符串,比如“[OHMYGOD]”再计算哈希值。然后把计算得到的哈希值跟原始网址拼接了特殊字符串之后的文本一并存储在MySQL数据库中。
当用户访问短网址的时候,短网址服务先通过短网址,在数据库中查找到对应的原始网址。如果原始网址有拼接特殊字符(这个很容易通过字符串匹配算法找到),我们就先将特殊字符去掉,然后再将不包含特殊字符的原始网址返回给浏览器。
### 3.如何优化哈希算法生成短网址的性能?
为了判断生成的短网址是否冲突,我们需要拿生成的短网址,在数据库中查找。如果数据库中存储的数据非常多,那查找起来就会非常慢,势必影响短网址服务的性能。那有没有什么优化的手段呢?
还记得我们之前讲的MySQL数据库索引吗我们可以给短网址字段添加B+树索引。这样通过短网址查询原始网址的速度就提高了很多。实际上,在真实的软件开发中,我们还可以通过一个小技巧,来进一步提高速度。
在短网址生成的过程中我们会跟数据库打两次交道也就是会执行两条SQL语句。第一个SQL语句是通过短网址查询短网址与原始网址的对应关系第二个SQL语句是将新生成的短网址和原始网址之间的对应关系存储到数据库。
我们知道一般情况下数据库和应用服务只做计算不存储数据的业务逻辑部分会部署在两个独立的服务器或者虚拟服务器上。那两条SQL语句的执行就需要两次网络通信。这种IO通信耗时以及SQL语句的执行才是整个短网址服务的性能瓶颈所在。所以为了提高性能我们需要尽量减少SQL语句。那又该如何减少SQL语句呢
我们可以给数据库中的短网址字段,添加一个唯一索引(不只是索引,还要求表中不能有重复的数据)。当有新的原始网址需要生成短网址的时候,我们并不会先拿生成的短网址,在数据库中查找判重,而是直接将生成的短网址与对应的原始网址,尝试存储到数据库中。如果数据库能够将数据正常写入,那说明并没有违反唯一索引,也就是说,这个新生成的短网址并没有冲突。
当然如果数据库反馈违反唯一性索引异常那我们还得重新执行刚刚讲过的“查询、写入”过程SQL语句执行的次数不减反增。但是在大部分情况下我们把新生成的短网址和对应的原始网址插入到数据库的时候并不会出现冲突。所以大部分情况下我们只需要执行一条写入的SQL语句就可以了。所以从整体上看总的SQL语句执行次数会大大减少。
实际上我们还有另外一个优化SQL语句次数的方法那就是借助布隆过滤器。
我们把已经生成的短网址构建成布隆过滤器。我们知道布隆过滤器是比较节省内存的一种存储结构长度是10亿的布隆过滤器也只需要125MB左右的内存空间。
当有新的短网址生成的时候我们先拿这个新生成的短网址在布隆过滤器中查找。如果查找的结果是不存在那就说明这个新生成的短网址并没有冲突。这个时候我们只需要再执行写入短网址和对应原始网页的SQL语句就可以了。通过先查询布隆过滤器总的SQL语句的执行次数减少了。
到此利用哈希算法来生成短网址的思路我就讲完了。实际上这种解决思路已经完全满足需求了我们已经可以直接用到真实的软件开发中。不过我们还有另外一种短网址的生成算法那就是利用自增的ID生成器来生成短网址。我们接下来就看一下这种算法是如何工作的对于哈希算法生成短网址来说它又有什么优势和劣势
## 如何通过ID生成器生成短网址
我们可以维护一个ID自增生成器。它可以生成1、2、3…这样自增的整数ID。当短网址服务接收到一个原始网址转化成短网址的请求之后它先从ID生成器中取一个号码然后将其转化成62进制表示法拼接到短网址服务的域名比如[http://t.cn/](http://t.cn/))后面,就形成了最终的短网址。最后,我们还是会把生成的短网址和对应的原始网址存储到数据库中。
理论非常简单好理解。不过,这里有几个细节问题需要处理。
### 1.相同的原始网址可能会对应不同的短网址
每次新来一个原始网址,我们就生成一个新的短网址,这种做法就会导致两个相同的原始网址生成了不同的短网址。这个该如何处理呢?实际上,我们有两种处理思路。
第一种处理思路是**不做处理**。听起来有点无厘头,我稍微解释下你就明白了。实际上,相同的原始网址对应不同的短网址,这个用户是可以接受的。在大部分短网址的应用场景里,用户只关心短网址能否正确地跳转到原始网址。至于短网址长什么样子,他其实根本就不关心。所以,即便是同一个原始网址,两次生成的短网址不一样,也并不会影响到用户的使用。
第二种处理思路是**借助哈希算法生成短网址的处理思想,**当要给一个原始网址生成短网址的时候,我们要先拿原始网址在数据库中查找,看数据库中是否已经存在相同的原始网址了。如果数据库中存在,那我们就取出对应的短网址,直接返回给用户。
不过,这种处理思路有个问题,我们需要给数据库中的短网址和原始网址这两个字段,都添加索引。短网址上加索引是为了提高用户查询短网址对应的原始网页的速度,原始网址上加索引是为了加快刚刚讲的通过原始网址查询短网址的速度。这种解决思路虽然能满足“相同原始网址对应相同短网址”这样一个需求,但是是有代价的:一方面两个索引会占用更多的存储空间,另一方面索引还会导致插入、删除等操作性能的下降。
### 2.如何实现高性能的ID生成器
实现ID生成器的方法有很多比如利用数据库自增字段。当然我们也可以自己维护一个计数器不停地加一加一。但是一个计数器来应对频繁的短网址生成请求显然是有点吃力的因为计数器必须保证生成的ID不重复笼统概念上讲就是需要加锁。如何提高ID生成器的性能呢关于这个问题实际上有很多解决思路。我这里给出两种思路。
第一种思路是借助第54节中讲的方法。我们可以给ID生成器装多个前置发号器。我们批量地给每个前置发号器发送ID号码。当我们接受到短网址生成请求的时候就选择一个前置发号器来取号码。这样通过多个前置发号器明显提高了并发发号的能力。
<img src="https://static001.geekbang.org/resource/image/8f/35/8fde8862e17b1bdf7779f2b60b166335.jpg" alt="">
第二种思路跟第一种差不多。不过我们不再使用一个ID生成器和多个前置发号器这样的架构而是直接实现多个ID生成器同时服务。为了保证每个ID生成器生成的ID不重复。我们要求每个ID生成器按照一定的规则来生成ID号码。比如第一个ID生成器只能生成尾号为0的第二个只能生成尾号为1的以此类推。这样通过多个ID生成器同时工作也提高了ID生成的效率。
<img src="https://static001.geekbang.org/resource/image/bf/1a/bfeb7fc556b1fe5f9b768ce5ec90321a.jpg" alt="">
## 总结引申
今天,我们讲了短网址服务的两种实现方法。我现在来稍微总结一下。
第一种实现思路是通过哈希算法生成短网址。我们采用计算速度快、冲突概率小的MurmurHash算法并将计算得到的10进制数转化成62进制表示法进一步缩短短网址的长度。对于哈希算法的哈希冲突问题我们通过给原始网址添加特殊前缀字符重新计算哈希值的方法来解决。
第二种实现思路是通过ID生成器来生成短网址。我们维护一个ID自增的ID生成器给每个原始网址分配一个ID号码并且同样转成62进制表示法拼接到短网址服务的域名之后形成最终的短网址。
## 课后思考
<li>
如果我们还要额外支持用户自定义短网址功能http//t.cn/{用户自定部分}),我们又该如何改造刚刚的算法呢?
</li>
<li>
我们在讲通过ID生成器生成短网址这种实现思路的时候讲到相同的原始网址可能会对应不同的短网址。针对这个问题其中一个解决思路就是不做处理。但是如果每个请求都生成一个短网址并且存储在数据库中那这样会不会撑爆数据库呢我们又该如何解决呢
</li>
今天是农历的大年三十,我们专栏的正文到这里也就全部结束了。从明天开始,我会每天发布一篇练习题,内容针对专栏涉及的数据结构和算法。从初一到初七,帮你复习巩固所学知识,拿下数据结构和算法,打响新年进步的第一枪!明天见!