0%

概述

定义

  • 按照一定规则,自动抓取万维网信息的程序或脚本

分类

  • 通用网络爬虫(General Purpose Web Crawler):根据一个种子 url 链接,扩展至整个 web 页面进行爬取,主要有深度优先爬行策略和广度优先爬行策略(见:数据结构与算法),应用于大型搜索引擎中

  • 聚焦网络爬虫(Focused Crawler):有目的性地进行爬取,将爬取目标定位在与主题相关的页面中,主要有基于内容评价、基于链接评价、基于增强学习、语境图和关于聚焦网络爬虫具体的爬行策略,主要应用在对特定信息的爬取中

  • 增量式网络爬虫(Incremental Web Crawler):是指对已下载网页采取增量式更新和只爬行新产生的或者已经发生变化网页的爬虫,在更新的时候只更新改变的地方,而未改变的地方则不更新。它能够在一定程度上保证所爬行的页面是尽可能新的页面深度网络爬虫

  • 深层网络爬虫(Deep Web Crawler):需要提交表单信息的,或者需要传递一些关键词才可以访问这个数据。最重要的部分即为表单填写部分,主要有基于领域知识的表单填写与基于网页结构分析的表单填写两种类型

原理

数据

数据的分类

  • 用广产生的数据,微信数据、抖音

  • 政府的数据

  • 公司管理的数据:聚合

  • 自己爬取的数据

数据的用途

  • 人工智能、机器学习、数据分析 etc.

robots 协议

  • 网站和爬虫协商的协议,网站中某些站点不允许爬虫的访问

反爬取措施

  • 验证码

    • 手机验证码
    • 静态(图片验证、文字)/ 动态(苹果动态验证)
  • ip 检测与封禁

  • 文字混淆、js 加密

  • 点击验证、滑块验证、滑动轨迹

  • cookie:身份校验

  • 防盗链 referer host

URL 的组成

  • 统一资源定位符:https://baike.baidu.com

  • 协议:https

  • 域名:baike.baidu. com

  • 端口:443

  • 查询路径:/item/url

  • 查询参数:wd=url

  • 锚点

    • 网页中,当前页面进行锚点定位
    • 作用在网址的导航

静态数据和动态数据

  • 静态数据:爬取的数据,在 html 源码中,并且这个页面是个静态页面

  • 动态数据:通过一定条件触发的、二次加载的数据

Chrome 调试

  • User-Agent:身份标识

跳跃表(skiplist)

定义

  • 在每个节点中维持多个指向其他节点的指针以快速访问节点的一种有序数据结构

  • 有序集合键的底层实现之一

适用场景

  • 一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串

  • 在集群节点中用作内部数据结构

跳跃表结点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 跳跃表结点
typedef struct zskiplistNode
{
// 层
struct zskiplistLevel
{
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
} zskiplistNode;

层(Level 数组)

  • 原理:包含多个元素,每个元素包含一个指向其他节点的指针,以加快访问其他节点的速度。一般来说,其访问其他节点的速度与层数正相关。

  • 高度:每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现概率越小)随机生成一个介于 132 之间的值作为 level 数组的大小

前进指针(level [i].forward 属性)

  • def 从表头向表尾方向访问节点的指针

    • 一次可以跳过多个成员
  • 用于执行有序集合的遍历操作

跨度(level [i].span 属性)

  • def 两个节点之间的距离

    • 跨度越大,间距越远
  • 注:指向 Null 的所有前进指针的跨度都为 0,因未连向任何节点

  • 用于计算有序集合中成员的排名(rank)

后退指针(level [i].backward 属性)

  • def 从表尾向表头方向访问节点的指针

    • 每个节点只有一个后退指针,这也意味着每次只能通过顺序回退的方式到达有序集合的前一节点,以向前遍历

分值(level [i].score 属性)

  • def double 类型的浮点数

    • 所有节点按照分值从小到大来排序

成员(level [i].obj 属性)

  • def 指向字符串对象的指针,指向的对象中保存着一个 SDS

  • 在同一个跳跃表中,各个节点保存的成员对象必须是唯一的;多个节点保存的分值可以相同

  • 分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)

跳跃表

1
2
3
4
5
6
7
8
9
10
// 跳跃表
typedef struct zskiplist
{
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;

执行下述操作的时间复杂度均为 O (1):

  • 程序定位表头节点和表尾节点:header 和 tail 指针分别指向跳跃表的表头和表尾节点

  • 返回跳跃表的长度:使用 length 属性来记录节点的数量

  • 获取跳跃表中层高最大的那个节点的层数量:使用 level 属性,注:表头节点的层高并不计算在内

有序集合(zset)操作

常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import redis

# 创建连接
conn = redis.Redis(host='127.0.0.1', port=6379, password=123456)

# ZADD:向有序集合中添加指定成员及对应分值
# 注:此处Python做法与Redis相反,Redis中应先输入分值再输入成员名称
conn.zadd('zset_key', 'a', 1, 'b', 2, 'c', 3)
# ZCARD:返回有序集合包含的成员数量
conn.zcard('zset_key')
# ZINCRBY:将有序集合中的指定成员的对应分值加上incr量
conn.zincrby('zset_key', 'a', 3)
# ZSCORE:返回指定成员的分值
conn.zscore('zset_key', 'b')
# ZRANK:返回指定成员在有序集合中的排名
# 可以先获取指定成员的排名,再根据排名决定ZRANGE的范围
conn.zrank('zset_key', 'c')
# ZCOUNT:返回有序集合中介于给定最低和最高分值之间的成员数目
conn.zcount('zset_key', 0, 3)
# ZREM:从有序集合中移除指定成员,并返回被移除成员的数目
conn.zrem('zset_key', 'b')
# ZRANGE:返回有序集合中排名介于指定开始与结束位置的成员
# 指定withscores参数为真,则会一并返回成员的对应分值
conn.zrange('zset_key', 0, -1, withscores=True)

范围性数据的获取与删除命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ZREVRANK:返回有序集合中成员的排名
# 按照分值从大到小排列
# 注:按照逆序处理有序集合,其工作方式与非逆序大体相同
conn.zrevrank('zset_key', 'c')
# ZREVRANGE:返回有序集合给定排名范围内的成员
# 按照分值从大到小排列
conn.zrevrange('zset_key', 0, -1, withscores=True)
# ZRANGEBYSCORE:返回有序集合中分值介于给定最小与较大范围之间的所有成员
conn.zrangebyscore('zset_key', 0, -1)
# ZREVRANGEBYSCORE:获取有序集合中分值介于给定最小与较大范围之间的所有成员
# 按照分值从大到小排列
conn.zrevrangebyscore('zset_key', 0, -1)
# ZREMRANGEBYRANK:移除有序集合中排名介于开始与结束位置之间的所有成员
conn.zremrangebyrank('zset_key', 0, 0)
# ZREMRANGEBYSCORE:移除有序集合中分值介于开始与结束位置之间的所有成员
conn.zremrangebyscore('zset_key', 0, 1)

并集与交集命令

1
2
3
4
5
6
conn.zadd('zset_key2', 'a', 1, 'b', 2, 'c', 3)
conn.zadd('zset_key3', 'a', 3, 'b', 2, 'c', 1)
# ZINTERSCORE:对给定有序集合进行交集运算
# 默认使用sum作为聚合函数,则相当于对各个有序集合中的成员分值做累加操作
conn.zinterstore('zset_key_i', ['zset_key2', 'zset_key3'])

1
2
3
# ZUNIONSTORE:对给定有序集合进行并集运算
# 还可以指定聚合函数为min/max,以适应不同的运算场景
conn.zunionstore('zset_key_u', ['zset_key2', 'zset_key3'], aggregate='min')

【参考】
[1] 《Redis 的设计与实现》

[2] 《Redis 实战》

整数集合(intset)

定义

  • 用于保存整数值的集合抽象数据结构

    • 可保存类型为 int16_t、int32_t 或 int64_t 的整数值
    • 保证集合中不会出现重复元素
  • 集合键的底层实现之一

1
2
3
4
5
6
7
8
9
typedef struct intset
{
//编码方式,决定contents数组的真正类型
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;

注:contents 数组是整数集合的底层实现,每个数组元素作为一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。

适用场景

  • 集合只包含整数值元素,并且该集合的元素数量不多

升级(upgrade)

产生原因

  • 添加新元素到数组中时,由于新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级

目的

  • 提升整数集合的灵活性:因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将 int16_t、int32_t 或者 int64_t 类型的整数添加到集合中,而不必担心出现类型错误

  • 尽可能地节约内存

步骤

  • 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间

  • 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变

  • 将新元素添加到底层数组中,再更新原整数集合的 encoding 和 length

时间复杂度

  • O (N):每次升级都需要对底层数组中已有的所有元素进行类型转换

降级(degrade)

  • 整数集合不支持该操作。这也意味着一旦整数集合经过升级并占用更大的内存后,即便经过后续操作后不再需要过大的内存空间时,也无法进行回退

集合操作

常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import redis

# 获取连接
conn = redis.Redis(host='127.0.0.1', password='123456', port=6379)

# SADD:向指定集合中添加尚不存在的元素(可一次添加多个)
# 返回的是被添加元素的数量
conn.sadd('set_key', 'a', 'b', 'c', 'd', 'e')
# SREM:从指定集合中移除元素(可一次移除多个)
# 注:Python中返回布尔值;实际redis中返回的是被移除元素的数量
conn.srem('set_key', 'c', 'd')
# SISMEMBER:检查数据项item是否存在于指定集合中
conn.sismember('set_key', 'val')
# SCARD:返回集合中元素的数量
conn.scard('set_key')
# SMEMBERS:返回集合中包含的所有元素
conn.smembers('set_key')
# SRANDMEMBER:从指定集合中随机返回一个或多个元素值
# 这里可以指定count参数,但该值不是随机返回元素的个数
# 而是根据其正负情况决定是否返回重复元素(有无放回)
# 若为正数,则不会重复;否则可以取出重复的值
conn.srandmember('set_key', -1)
# SPOP:随机(!)的移除指定集合中的一个元素
# 返回被移除的元素
conn.spop('set_key')
# SMOVE:从集合1中移除元素并移动到集合2中
# 若成功,返回1;否则返回0,相当于命令无效
conn.smove('set_key', 'set_key2', 'a')

差集、交集与并集运算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# SDIFF:对两个集合作差集运算
# 返回存在于第一个集合但不存在于第二个集合的所有元素
conn.sdiff('set_key', 'set_key2')
# SDIFFSTORE:对两个集合作差集运算
# 储存存在于第一个集合但不存在于第二个集合的所有元素
conn.sdiffstore('store_key', 'set_key', 'set_key2')
# SINTER:对两个集合作交集运算
# 返回那些同时存在于所有集合的元素
conn.sinter('set_key', 'set_key2')
# SINTERSTORE:对两个集合作交集运算
# 储存那些同时存在于所有集合的元素
conn.sinterstore('store_key', 'set_key', 'set_key2')
# SUNION:对两个集合作并集运算
# 返回那些至少存在于一个集合的元素
conn.sunion('set_key', 'set_key2')
# SUNIONSTORE:对两个集合作并集运算
# 储存那些至少存在于一个集合的元素
conn.sunionstore('store_key', 'set_key', 'set_key2')

注:就集合这一结构而言,其基本功能在 Python 与 Redis 中的实现并无过大差别。Redis 集合的好处在于,可以同时被多个客户端进行远程访问。

【参考】
[1] 《Redis 的设计与实现》

[2] 《Redis 实战》

字典

定义

  • 一种用于保存键值对(key-value pair)的抽象数据结构

    • 一个键(key)可以和一个值(value)进行关联(或者说将键映射为值), 这些关联的键和值就称为键值对
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 键值对
typedef struct dictEntry
{
void *key; //键
union //值
{
void *val; //可为一个指针
unsigned int tu64; //可为uint64型整数
int ts64; //可为int64型整数
} v;
//指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;

// 底层实现:哈希表
typedef struct dictht
{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于size - 1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
} dictht;

注:sizemask 和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面

适用场景

  • 一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串

redis 字典

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
typedef struct dictType
{
//计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
//复制键的函数
void *(*keyDup)(void *privdata, const void *key);
//复制值的函数
void *(*valDup)(void *privdata, const void *obj);
//对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
//销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
//销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;

typedef struct dict
{
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
// rehash 索引
//当rehash 不在进行时,值为-1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

注:type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置

  • type 属性是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特定 类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数

  • privdata 属性则保存了需要传给那些类型特定函数的可选参数

哈希算法:MurmurHash2 算法

步骤
  • 根据键值对的键计算出哈希值和索引值

  • 根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上

1
2
3
4
5
# 使用字典设置的哈希函数,计算键key的哈希值
hash = dict -> type -> hashFunction(key)
# 使用哈希表的sizemask 属性和哈希值,计算出索引值
# 根据情况不同,ht[x] 可以是ht[0]或者ht[1]
index = hash & dict -> ht[x].sizemask

e.g. 一个完整的添加键值对 <k0, v0> 过程:

Step 1:使用语句 hash = dict->type->hashFunction(k0); 计算的处 k0 的哈希值

Step 2:假设计算出的哈希值为 8,则程序继续 index = hash & dict->ht[0].sizemask = 8 & 3 = 0; 计算得到 k0 的索引值为 0,这表示包含这个键值对的节点应该放置到哈希表数组的索引 0 位置上

键冲突的解决

  • 链地址法(separate chaining)

    • def 每个哈希表节点都有一个 next 指针,多个哈希表节点用 next 指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来
    • 由于 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是 将新节点添加到链表的表头位置,其复杂度为 O (1),排在其他已有节点的前面

大小调整:rehash

定义

​ 重新计算键的哈希值和索引值,然后将键值对从 ht [0] 放置到 ht [1] 的指定位置上

原因

​ 当哈希表保存的键值对太多或太少时,程序要对哈希表的大小进行相应的扩展或收缩,让哈希表负载因子维持在一个合理范围之内

  • 负载因子

    • def 散列表装满程度的标志因子,α = 填入表中的元素个数 / 散列表的长度
    • 由于表长是定值,α 与填入表中的元素个数成正比,所以,α 越大,填入表中的元素就越多,产生冲突的可能性就越大;反之,α 越小,标明填入表中的元素就越少,产生冲突的可能性就越小。一般应该严格控制在 0.7~0.8 之间。超过 0.8,查表时的不命中率按照指数曲线上升

注:详见《数据结构与算法》哈希冲突与二次探测

步骤
  • 为字典的 ht [1] 哈希表分配空间

    • 如果执行的是扩展操作,则 ht [1] 的大小为第一个大于等于 ht [0].used*2 的 2^n
    • 如果执行的收缩操作,则 ht [1] 的大小为第一个大于等于 ht [0].used 的 2^n
  • 进行 rehash 操作

  • 当 ht [0] 包含的所有键值对都迁移到 ht [1] 之后,释放 ht [0],将 ht [1] 设置为 ht [0],并在 ht [1] 新创建一个空白哈希表,为下一次 rehash 做准备

e.g. 要对图示字典 ht [0] 进行扩展操作:

Step 1:ht [0].used 当前的值为 4,4*2=8,而 8(2^3)恰好是第一个大于等于 4 的 2 的 n 次方,所以程序会将 ht [1] 哈希表的大小设置为 8

Step 2:将 ht [0] 包含的四个键值对都 rehash 到 ht [1]

Step 3:释放 ht [0],并将 ht [1] 设置为 ht [0],然后为 ht [1] 分配一个空白哈希表。执行完毕,程序成功将哈希表的大小从原来的 4 改为了现在的 8

执行条件

​ 当以下条件中任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  • 服务器目前没有执行 BGSAVE 或 BGREWRITEAOF 命令,并且哈希表负载因子大于等于 1

  • 服务器正在执行 BGSAVE 或 BGREWRITEAOF 命令,并且哈希表负载因子大于等于 5

​ 当哈希表负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作

注:大多数系统采用写时复制优化子进程使用效率,所以在子进程存在期间服务器会提高执行扩展操作所需的负载因子,以尽可能避免在子进程存在期间进行哈希表扩展操作,以避免不必要的内存写入、最大限度地节约空间

优化的大小调整:渐进式 rehash

定义
  • 分多次、渐进式地将 ht [0] 里面的键值对慢慢地 rehash 到 ht [1]

目的
  • 避免 rehash 对服务器性能造成影响

步骤
  • 为 ht [1] 分配空间,让字典同时持有 ht [0] 和 ht [1] 两个哈希表

  • 在字典中维持一个索引计数器变量 rehashidx置 0,表示 rehash 开始工作

  • 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定操作以外,还会顺带将 ht [0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht [1] 中,当 rehash 工作完成之后,程序将 rehashidx 属性的值 + 1

  • 随着字典操作的不断进行,最终在某个时间点上,ht [0] 的所有键值对都被 rehash 到 ht [1] 上,这时将 rehashidx 属性设为 - 1,表示 rehash 完成

e.g. 一次完整的渐进式 rehash 过程:

注:在渐进式 rehash 进行期间,字典 CRUD 操作会在两个哈希表上都进行;新添加到字典的键值对一律会被保存到 ht [1] 里面,而 ht [0] 则不再进行任何添加操作,以保证 ht [0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表

散列表操作

常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import redis

# 获取连接
conn = redis.Redis(host='127.0.0.1', port=6379, password='123456')

# HMSET:设置散列表键值对,可添加多个键值对
conn.hmset('hash_key', {'k1': 'v1', 'k2': 'v2', 'k3': 'v3'})
# HMGET:根据键(key)从散列中获取值(value),可同时获取多个值
conn.hmget('hash_key', ['k1', 'k2'])
# HDEL:从散列表中删除指定键值对,可同时删除多个键值对
conn.hdel('hash_key', 'k2', 'k3')
# HLEN:获取散列表当前长度
conn.hlen('hash_key')
# HSET:将散列表中设置指定键的值为给定值
conn.hset('hash_key', 'k1', ['value1'])
# HGET:获取散列表中指定键的值
conn.hget('hash_key', 'k1')
# HSETNX:为散列表中不存在的字段赋值
conn.setnx('hash_key', 'new_field', 'hello_world')

高级特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# HEXISTS:检查给定键(key)是否存在于散列中
conn.exists('hash_key', 'k1')
# HKEYS:获取所有键(key)
conn.keys('hash_key')
# HVALS:获取所有值(value)
conn.hvals('hash_key')
# HGETALL:获取所有键值对
conn.hgetall('hash_key')

conn.hmset('hash_key', {'num_int': '0', 'num_float': '0.0'})
# HINCRBY:将散列中指定键值加上一个给定整数
conn.hincrby('hash_key', 'num_int')
# HINCRBYFLOAT:将散列中指定键值加上一个给定浮点数数
conn.hincrbyfloat('hash_key', 'num_float')

注:当散列中键值对的数目过多时,可以考虑先用 HKEYS 获取所有键(key),再通过 HVALS 只获取必要的值以减少需要传输的数据量

【参考】
[1] 《Redis 的设计与实现》

[2] 《Redis 实战》

链表

定义

  • 由一系列结点组成的非连续、非顺序的存储结构

  • 作为列表键的底层实现之一

1
2
3
4
5
6
7
8
9
10
// listNode
typedef struct listNode
{
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点值
void *value;
} listNode;

redis 链表:双向无环链表

参考可见:Redis 数据结构 —— 链表 - Mr 于 - 博客园 (cnblogs.com)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// list
typedef struct list
{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void *(*free)(void *ptr);
//节点值对比函数
int (*match)(void *ptr, void *key);
} list;
  • 双向:链表节点有前驱和后继指针,获取的时间复杂度为 O (1)

  • 无环:链表为非循环链表表头节点的前驱和表尾节点的后继指针都指向 Null,对链表的访问以 Null 为终点

  • 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点 和表尾节点的复杂度为 O (1)

  • 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序 获取链表中节点数量的复杂度为 O (1)

  • 多态:链表节点使用 void * 指针来保存节点值,并且可以通过 list 结构的 dup、free、 match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值

    • dup 函数用于复制链表节点所保存的值
    • free 函数用于释放链表节点所保存的值
    • match 函数则用于对比链表节点所保存的值和另一个输入值是否相等

使用场景

  • 一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串

压缩列表 (Ziplist)

定义

  • 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构

  • 作为列表键和哈希键的底层实现之一

目的

  • 节约内存

使用场景

  • 当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串

压缩列表的组成

压缩列表的各个组成部分
  • zlbytes:4bytes,记录整个压缩列表占用的内存字节数

  • zltail:4bytes,记录压缩列表表尾节点距离压缩列表的起始地址有多少字节

    • 偏移量,确定表尾节点的地址
  • zllen:2bytes,记录了压缩列表包含的节点数量

  • entryX:列表节点,节点的长度由节点保存的内容决定

  • zlend:1byte,值 0xFF 表示 2^8-1=255,用于标记压缩列表的末端

压缩列表结点的构成

压缩列表结点的各个组成部分
  • previous_entry_length:1byte/5bytes,记录压缩列表中前一个节点的长度

    • 前一节点的长度小于 254 字节,previous_entry_length 属性的长度为 1 字节,保存值为前一个结点长度
    • 前一节点的长度大于等于 254 字节,previous_entry_length 属性的长度为 5 字节。第一字节会被设置为 0xFE(254),之后的四个字节则用于保存前一节点的长度。
运算前一结点的指针位置
  • encoding:记录节点的 content 属性所保存数据的类型以及长度 D:

  • content:保存节点的值,值的类型和长度由节点的 encoding 属性决定

    • 整数编码:1byte,最高位以 11 开头;content 属性保存整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录
    • 字节数组编码:1/2/5byte (s),最高位为 00、01 或者 10;content 属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记 录

连锁更新

定义

​ 由于 previous_entry_length 因更新或删除而在 1byte/5bytes 切换引起的连锁内存重分配现象

复杂度

​ 最坏情况下,需要对压缩列表执行 N 次空间重分配操作,而每次空间重分配的最坏复杂度为 O(N),所以连锁更新的最坏复杂度为 O(N^2)

列表操作

元素的推入和弹出操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import redis
# 建立连接
conn = redis.Redis(host='127.0.0.1', port=6379, password='123456')

# RPUSH/LPUSH:推入元素,就语义上而言左侧为头,右侧为尾
# 返回的是当前列表的长度
conn.rpush('list_key', 'last')
conn.lpush('list_key', 'first')
# 可同时推入多个元素
conn.rpush('list_key', 'a', 'b', 'c')

# LRANGE:返回列表中从start到end偏移量范围内所有元素
# 注:会同时包含左侧start和右侧end的元素
conn.lrange('list_key', 0, -1)

# LTRIM:从列表左端或右端或两端同时删减任意数量的元素
# 注意只有LTRIM,删除时左开右闭
conn.ltrim('list_key', 2, -1)

# LPOP/RPOP:弹出列表左侧或右侧的元素
conn.lpop('list_key')
conn.rpop('list_key')

# LINDEX/RINDEX:通过下标获取列表值
conn.lindex(1)
conn.rindex(0)

# LREM:精确匹配以删除list集合中(count:指定个数)的值
conn.lrem('list_key', 2, 'last')

# LSET:将list中指定索引的值设置为给定值
# 前提:当前该索引位置应当存在该值,否则会报错 ERR No such key
conn.lset('list_key', 0, 'first_updated')

# LINSERT:往一个list指定索引的前一个/后一个位置插入值
# 若key不存在,则会创建新的list;存在则直接新增内容
# 在两侧进行插入/改动的效率最高,而中间效率相对较慢
conn.linsert('list_key', 'AFTER', 'first_updated', 'second')

阻塞弹出与元素移动命令

  • 常用于消息传递(messaging)任务队列(task queue)

1
2
3
4
5
6
7
8
9
10
conn.rpush('list_key2', 'item')
# BRPOPLPUSH:将最右端(尾)元素从一个列表中弹出,并压入至另一个列表最左端(头)
# 注意参数顺序:需弹出元素list 需压入元素list timeout时间(单位:s)
# 这里若需弹出元素list没有可供弹出元素,则在timeout时间内等待,block直到有新元素可用;否则,返回None
conn.brpoplpush('list_key2', 'list_key', 1)

# BLPOP/BRPOP:自左向右检查传入的所有列表,对第一个遇到的非空列表进行LPOP/RPOP操作
# timeout:同上处理
conn.blpop(['list_key1', 'list_key2'], 1)
conn.brpop(['list_key1', 'list_key2'], 1)

【参考】
[1] 《Redis 的设计与实现》

[2] 《Redis 实战》