Redis设计与实现 学习笔记 第八章 对象

在前面的章节中,我们陆续介绍了Redis用到的所有主要数据结构。Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象、有序集合对象五种,每种对象都用到了至少一种前面章节介绍的数据结构。

通过这五种对象,Redis可以在执行命令前,根据对象类型来判断一个对象是否可以执行给定的命令。适用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。

此外,Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再需要某个对象时,这个对象所占用的内存就会被自动释放;另外,Redis还通过引用计数实现了对象共享机制,这一机制可以在适当条件下,通过让多个数据库键共享同一个对象来节约内存。

最后,Redis的对象带有访问时间信息,该信息可用于计算数据库键的空转时长,在服务器启用了maxmemory功能时,空转时长较大的那些键会优先被服务器删除。

8.1 对象的类型与编码

Redis使用对象来表示数据库中的键和值,每次当我们在Redis中创建一个键值对时,我们至少会创建两个对象,一个对象用作键(键对象),另一个对象用作值(值对象)。

举个例子,以下SET命令在数据库中创建了一个新键值对:

其中键值对的键是一个包含了字符串值"msg"的对象,而键值对的值是一个包含了字符串值"hello world"的对象。

Redis中每个对象都由一个redisObject结构表示:

c 复制代码
typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 指向底层实现数据结构的指针
    void *ptr;
    // 以上是和保存数据有关的属性
    // ...
} robj;

8.1.1 类型

对象的type属性记录了对象的类型,这个属性的值可以是下表之一:

对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值可以是上图中的任一种,因此:

1.我们称一个数据库键为"字符串键"时,指的是这个数据库键对应的值为字符串对象;

2.我们称一个数据库键为"列表键"时,指的是这个数据库键对应的值为列表对象;

TYPE命令会返回数据库键对应的值对象的类型,而不是键对象的类型:


下表列出了TYPE命令面对不同类型的值对象时,所产生的输出:

8.1.2 编码和底层实现

对象的ptr指针指向对象的底层实现数据结构,这些数据结构由对象的encoding属性决定。

也就是说,encoding属性记录了这个对象使用了什么数据结构作为底层实现,encoding属性的值可以是下表之一:

每种类型的对象都至少使用了两种不同编码,下表列出每种类型的对象可以使用的编码:

使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码:

下表列出了不同编码的对象对应的OBJECT ENCODING命令的输出:

通过encoding属性来设定对象使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率,因为Redis可以根据不同的场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。

举个例子,在列表对象包含的元素较少时,Redis使用压缩列表作为列表对象的底层实现:

1.因为压缩列表比双端链表更节约内存,且元素数量较少时,在内存中以连续块保存的压缩列表比起双端链表可以更快被载入到缓存中;

2.随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失,对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上;

8.2 字符串对象

字符串对象的编码可以是int、raw、embstr。

如果一个字符串对象保存的是整数值,且这个整数值可以用long来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里(将void*转换成long),并将字符串对象的编码设为int。

举个例子,如果我们执行以下SET命令:

那么服务器将创建一个如图8-1所示的int编码的字符串对象作为number键的值:

如果字符串对象保存的是一个字符串值,且这个字符串值的长度大于32字节(后来Redis又将长度限制更新为了39字节,但不影响下面的讨论,改动只有长度限制而已),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设为raw。

举个例子,如果我们执行以下命令:

那么服务器会创建一个如图8-2所示的raw编码的字符串对象作为story键的值:

如果字符串对象保存的是一个字符串值,且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。

embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构,如图8-3所示:

embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串对象执行命令时产生的效果是相同的,但使用embstr编码的字符串对象来保存短字符串值有以下好处:

1.embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。

2.释放embstr编码的字符串对象只需调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。

3.因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里,所以这种编码的字符串对象比起raw编码的字符串对象能更好地利用缓存带来的优势。

作为例子,以下命令创建了一个embstr编码的字符串对象作为msg键的值:

值对象的样子如图8-4所示:

最后要说的是,可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果我们要保存一个浮点数到字符串对象里,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值。

举个例子,以下命令将创建一个值为"3.14"的字符串对象:

在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的浮点数值转换回字符串值,并继续保存在字符串对象里:

8.2.1 编码的转换

int编码的字符串对象和embstr编码的字符串在条件满足的情况下,会被转换为raw编码的字符串对象。

对于int编码的字符串来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码就会从int变为raw。

下例中,我们通过APPEND命令,向一个保存整数值的字符串对象追加了一个字符串值:

因为追加操作只能对字符串值执行,所以程序会先将之前保存的整数值10086转换为字符串值"10086",然后再执行追加操作,操作的执行结果就是一个raw编码的、保存了字符串值的字符串对象。

embstr编码的字符串对象实际上是只读的,当我们对embstr编码的字符串对象执行修改命令时,程序会将其编码转换为raw(重新分配内存?),然后再执行修改命令,因此embstr编码的字符串对象在执行修改命令后,总会变成一个raw编码的字符串对象。

以下编码展示了一个embstr编码的字符串对象在执行APPEND命令后,对象的编码从embstr变为raw的例子:

8.2.2 字符串命令的实现

表8-7列举了一部分字符串命令,以及这些命令在不同编码的字符串对象下的实现方法:

8.3 列表对象

列表对象的编码可以是ziplist或linkedlist。

ziplist编码的列表对象是用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。举个例子,如果我们执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值:

如果numbers键的值对象使用的是ziplist编码,这个值对象会如下图所示:

另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。

举个例子,如果前面所说的number键创建的列表对象使用的是linkedlist编码,那么numbers键的值对象将是图8-6所示的样子:

注意,linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象、有序集合对象中都会出现,字符串对象是Redis五种类型的对象中唯一会被其他四种类型对象嵌套的对象。

为了简化字符串对象的表示,图8-6中使用了一个带有StringObject字样的格子来表示一个字符串对象,而StringObject字样下面是字符串对象保存的值。比如说,图8-7代表的就是一个包含了字符串值"three"的字符串对象:

它是图8-8的简化表示:

本书接下来的内容会继续沿用这一简化表示。

8.3.1 编码转换

当列表对象同时满足以下两个条件时,列表对象使用ziplist编码:

1.列表对象保存的所有字符串的长度都小于64字节;

2.列表对象保存的元素数量小于512个;

不满足这两个条件的列表对象需要使用linkedlist编码。

以上两个条件的上限值是可以修改的,具体参考配置文件中关于list-max-ziplist-value和list-max-ziplist-entries选项的说明。

对于使用ziplist编码的列表对象来说,当以上两个条件中任意一个不能被满足时,对象的编码就会从ziplist变为linkedlist,原本保存在压缩列表里的所有元素会被转移到双端链表里。

下例展示了列表对象因为保存了长度太大的元素而进行编码转换的情况:

下例展示了列表对象因为保存的元素数量过多而进行编码转换的情况:

上图中,使用EVAL命令执行了lua脚本,其中,lua脚本作为第一个参数传入,EVAL的第二个参数1表示有1个键传给EVAL,键名是第三个参数"integers"。lua脚本中包含一个循环,i从1迭代到512,对于每次迭代的数字i,使用redis.call函数调用RPUSH命令,其中KEYS是通过EVAL命令传递的键,KEYS[1]是传给EVAL命令的第一个键,即"integers",而i是当前迭代的数字,所以整个lua脚本会依次将数字1到512推入列表integers。

8.3.2 列表命令的实现

8.4 哈希对象

哈希对象的编码可以是ziplist或hashtable。

ziplist编码的哈希对象是用压缩列表作为底层实现,每当有新键值对要加入哈希对象时,程序会先将键节点推入压缩列表表尾,再将值节点推入压缩列表表尾,因此:

1.保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;

2.先添加到哈希对象中的键值对会被放在压缩列表的表头方向,后添加的会被放在表尾方向。

举个例子,如果我们执行以下HSET命令:

如果profile键的值对象使用的是ziplist编码,profile键的值对象会如图8-9所示:

其中,底层的压缩列表如图8-10:

另一方面,hashtable编码的哈希对象中,每个键值对都使用一个字典键值对来保存:

1.字典的每个键都是一个字符串对象,对象中保存了键值对的键;

2.字典的每个值都是一个字符串对象,对象中保存了键值对的值。

举个例子,如果前面profile键创建的是hashtable编码的哈希对象,则这个哈希对象会如图8-11所示:

8.4.1 编码转换

当哈希对象同时满足以下条件时,哈希对象使用ziplist编码:

1.哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;

2.哈希对象保存的键值对数量小于512个;

不满足这两个条件的哈希对象需要使用hashtable编码。

这两个条件的上限值是可以修改的,具体参考配置文件中hash-max-ziplist-value和hash-max-ziplist-entries选项的说明。

对于使用ziplist编码的列表对象来说,当以上两个条件中的任意一个不能被满足时,对象的编码就会转换为hashtable,原本保存在压缩列表里的所有键值对都会被转移并保存到字典里。

以下代码展示了哈希对象因为键值对的键太长而引起编码转换的情况:

以下代码展示了哈希对象因为键值对的值太长而引起编码转换的情况:

以下代码展示了哈希对象因为键值对的数量过多而引起编码转换的情况:

8.4.2 哈希命令的实现

8.5 集合对象

集合对象的编码可以是intset或hashtable。

intset编码的集合对象使用整数集合作为底层实现。

举个例子,以下代码将创建一个intset编码的集合对象:

创建的集合对象如下图所示:

hashtable编码的集合对象使用字典作为底层实现,字典的键是一个表示集合元素的字符串对象,字典的值全为NULL。例如,以下代码将创建一个hashtable编码的集合对象:

上图中有一个错误,SAD命令应该是SADD,该命令创建的整数集合如下图所示:

8.5.1 编码的转换

当集合对象同时满足以下两个条件时,集合对象使用intset编码:

1.集合对象保存的所有元素都是整数值;

2.集合对象保存的元素数量不超过512个。

不能满足这两个条件的集合对象使用hashtable编码。

第二个条件的上限值是可以修改的,具体参考配置文件中set-max-intset-entries选项的说明。

对于使用intset编码的集合对象来说,当以上两个条件中的任意一个不能被满足时,就会将集合对象的编码从intset转换为hashtable,原本保存在整数集合中的所有元素都会被转移并保存到字典里。

举个例子,以下代码创建了一个只包含整数元素的集合对象,编码为intset:

只要我们向numbers集合对象中添加一个字符串元素,就会触发编码转换:

此外,如果我们创建一个包含512个整数元素的集合对象,那么对象的编码会是intset:

此时,我们再向集合中添加一个新整数元素,使得集合的元素数变为513,那么对象的编码转换操作就会执行:

8.5.2 集合命令的实现

8.6 有序集合对象

有序集合的编码可以是ziplist或skiplist。

有序集合中的一个元素由两个部分组成,元素成员(member)和元素分值(score)。对于使用ziplist作为底层实现的有序集合对象,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素成员,第二个节点保存元素分值。

压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向。

举个例子,如果我们执行以下ZADD命令,那么服务器将创建一个有序集合对象作为price键的值:

如果price键的值对象使用的是ziplist编码,那么这个值对象会如下图所示:

上图中的压缩列表如下图所示:

skiplist编码的有序集合对象使用zset结构作为底层实现:

c 复制代码
typedef struct zset {
    zskiplist *zsl;
    dict *dict;
} zset;

zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素成员,跳跃表节点的score属性保存了元素分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,如ZRANK、ZRANGE等命令。

此外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,字典的值保存了元素的分值。通过这个字典,程序可以用O(1)的时间复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。

有序集合的每个元素成员都是一个字符串对象,而每个元素分值都是一个double类型浮点数。虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构会通过指针共享相同的元素成员和分值,不会浪费额外的内存。

为什么有序集合需要同时使用跳跃表和字典来实现?

在理论上,有序集合可以单独使用字典或跳跃表其中之一来实现,但无论单独使用哪个,在性能上比起同时使用字典和跳跃表都会有所降低。例如,如果只使用字典来实现有序集合,那么虽然以O(1)时间复杂度查找成员分值这一特性会被保留,但因为字典以无序方式保存集合元素,所以每次在执行范围型操作------比如ZRANK、ZRANGE等命令时,程序都需要对字典保存的所有元素进行排序,完成排序至少需要O(NlogN)时间复杂度,以及额外的O(N)内存(因为需要创建一个数组来保存排序后的元素)。

另一方面,如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的优点将会保留,但根据成员查找分值这一操作的复杂度将从O(1)上升为O(logN)。因此,为了让有序集合的查找和范围型操作都尽可能快地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合。

如果前面price键创建的不是ziplist编码的有序集合对象,而是skiplist编码的,那么这个有序集合将如下图所示:

上图中的zset结构如下图所示:

为了展示方便,图8-17在字典和跳跃表中重复展示了各元素的成员和分值,但在实际中,字典和跳跃表会共享元素的成员和分值,不会造成数据重复而浪费内存。

8.6.1 编码的转换

当有序集合对象同时满足以下两个条件时,对象是用ziplist编码:

1.有序集合保存的元素数量小于128个;

2.有序集合保存的所有元素成员的长度都小于64字节;

不满足以上两个条件的有序集合对象将使用skiplist编码。

以上两个条件的上限值是可以修改的,具体参考配置文件中zset-max-ziplist-entries和zset-max-ziplist-value选项的说明。

对于使用ziplist编码的有序集合对象来说,当以上两个条件中的任意一个不能被满足时,就会将对象的编码从ziplist转换为skiplist,原本保存在压缩列表中的有序集合元素也会被转移到zset结构里。

以下代码展示了有序集合对象因为包含了过多元素而引发编码转换的情况:

以下代码展示了有序集合对象因为元素的成员过长而引发编码转换的情况:

8.6.2 有序集合命令的实现

8.7 类型检查与命令多态

Redis中用于操作键的命令可分为两种类型。

一种命令可以对任何类型的键执行,比如DEL、EXPIRE、RENAME、TYPE、OBJECT命令等。

例如,以下代码使用DEL命令删除三种不同类型的键:

而另一种命令只能对特定类型的键执行,如:

1.SET、GET、APPEND、STRLEN等命令只能对字符串键执行;

2.HDEL、HSET、HGET、HLEN等命令只能对哈希键执行;

3.RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行;

4.SADD、SPOP、SINTER、SCARD等命令只能对集合键执行;

5.ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行;

例如,我们可以用SET命令创建一个字符串键,然后用GET和APPEND命令操作这个键,但如果我们对这个字符串键使用列表键专属的LLEN命令,那么Redis将向我们返回一个类型错误:

8.7.1 类型检查的实现

在执行一个类型特定的命令前,Redis会先检查输入键的类型是否正确。

类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的:

1.在执行一个类型特定命令前,服务器会先检查输入键的值对象是否为执行命令所需的类型,如果是,服务器才对键执行该命令;

2.否则,服务器拒绝执行命令,并向客户端返回一个类型错误。

图8-18展示了类型检查过程:

8.7.2 多态命令的实现

Redis除了会根据值对象的类型来判断是否能执行指定命令外,还会根据值对象的编码方式,选择正确的命令实现。

例如,列表对象的编码可以是ziplist或linkedlist,前者会使用压缩列表API来实现列表命令,后者会使用双端链表API来实现列表命令。

考虑这样一种情况,如果我们对一个键执行LLEN命令,服务器除了要确保执行命令的是列表键外,还要根据值对象的编码选择正确的LLEN命令:

1.如果列表对象的编码是ziplist,则程序会使用ziplistLen函数返回压缩列表的长度;

2.如果列表对象的编码是linkedlist,则程序会使用listLength函数返回双端链表的长度;

借用面向对象的术语来说,我们可以认为LLEN命令是多态(polymorphism)的,只要执行LLEN命令的是列表键,不管其底层实现是什么,命令都能正常执行。

图8-19展示了LLEN命令从类型检查到根据编码选择实现的过程:

实际上,我们可以将DEL、EXPIRE、TYPE等命令也称为多态命令,因为无论输入的键是什么类型,这些命令都能正确执行。

DEL、EXPIRE命令和LLEN命令的区别在于,前者是基于类型的多态------一个命令可以处理不同类型的键,后者是基于编码的多态------一个命令可以处理多种不同编码。

8.8 内存回收

因为C语言不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,通过这一机制,程序可以在适当的时候自动释放对象并进行内存回收。

每个对象的引用计数由redisObject结构的refcount属性记录:

c 复制代码
typedef struct redisObject {
    // ...
    // 引用计数
    int refcount;
    // ...
} robj;

对象的引用计数会随着对象的使用状态不断变化:

1.在创建一个新对象时,其引用计数的值被初始化为1;

2.当对象被一个新变量引用时,其引用计数加1;

3.当对象不再被变量引用时,其引用计数减1;

4.当对象的引用计数变为0时,对象占用的内存会被释放。

对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段,例如以下字符串对象从创建到释放的整个过程:

8.9 对象共享

引用计数除了用于实现内存回收机制外,还带有对象共享的作用。例如,假设创建了一个键A,它的值对象是包含整数值100的字符串对象,如图8-20所示:

如果这时要创建一个值对象和键A相同的键B,那么服务器有两种做法:

1.为键B新创建一个包含整数值100的字符串对象;

2.让键A和键B共享同一个字符串对象;

显然第二种做法更节约内存。

在Redis中,让多个键共享同一个值对象需要以下两个步骤:

1.将数据库键的值指针指向一个现有的值对象;

2.将被共享的值对象的引用计数加1。

图8-21展示了图8-20中的字符串对象同时被键A和键B共享的样子,可以看到,除了对象的引用计数从之前的1变为2外,其他属性都没有变化:

目前来说,Redis在初始化服务器时,会创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,就会直接使用这些共享对象,而不用创建新对象。

Redis服务器初始化时创建的共享数值字符串对象的数量可通过redis.h/REDIS_SHARED_INTEGERS常量来修改。

例如,如果我们创建一个值为100的键A,使用OBJECT REFCOUNT命令查看值对象的引用计数时会发现值为2:

引用这个值对象的分别是服务器本身和键A,如图8-22所示:

如果这时我们再创建一个值为100的键B,会使得共享对象的引用计数变为3:

图8-23展示了上图执行后的情况:

共享对象不单单只有字符串键可以使用,在其他数据结构中(如linkedlist编码的列表对象)嵌套了字符串对象时,也可以使用这些共享对象。

Redis只会共享这10000个整数值字符串对象和一些常用的非整数值短字符串对象(如Redis经常返回给我们的OK、-ERR、QUEUED等),因为:当服务器创建一个新键值对时,程序需要检查新值对象是否与当前存在的某个对象完全相同,只有完全相同时,才能共享已有的对象,而一个值对象越复杂,验证其与某个现有对象是否完全相同的复杂度就越高:

1.如果值对象是保存整数值的字符串对象,那么验证操作的时间复杂度为O(1);

2.如果值对象是保存字符串值的字符串对象,那么验证操作的时间复杂度为O(N),其中N为字符串长度;

3.如果值对象中包含了多个对象,比如列表或哈希对象,那么验证操作的复杂度会是O(NM),其中N为值对象中每个元素的平均字符串长度,M为对象中的元素个数。

8.10 对象的空转时长

redisObject结构包含的最后一个属性是lru,该属性记录了对象最后一次被访问的时间:

c 复制代码
typedef struct redisObject {
    // ...
    unsigned lru:22;
    // ...
} robj;

OBJECT IDLETIME命令可打印出给定键的空转时长,该时长是通过将当前时间减去值对象的lru时间得出的:

OBJECT IDLETIME命令是特殊的,它在执行时,不会修改对象的lru属性。

键的空转时长除了可以被OBJECT IDLETIME命令打印出来外,还有另一作用:如果服务器打开了maxmemory选项,且服务器用于回收内存的算法为volatile-lru或allkeys-lru,那么当服务器所占内存超过了maxmemory选项设置的上限值时,空转时长较高的键会优先被服务器释放,从而回收内存。

配置文件的maxmemory和maxmemory-policy选项的说明介绍了更多信息。

8.11 重点回顾

1.Redis中的每个键值对的键和值都是一个对象。

2.Redis有字符串、列表、哈希、集合、有序集合五种类型对象,每种类型对象都至少有两种编码方式,不同编码可以在不同使用场景上优化对象的使用效率。

3.服务器在执行某些命令前,会先检查给定键的类型能否执行指定命令(通过值对象的类型属性进行检查)。

4.Redis的对象系统带有引用计数实现的内存回收机制,当一个对象不再被使用时,该对象所占内存会被自动释放。

5.Redis会共享值为0到9999的字符串对象。

6.对象会记录自己的最后一次被访问时间,这个时间可用于计算对象的空转时间。

相关推荐
西瓜本瓜@4 分钟前
在Android开发中实现静默拍视频
android·java·开发语言·学习·音视频
kaka_hikun25 分钟前
【CPN TOOLS建模学习】设置库所的属性
学习·cpn·cpn tools·库所
Mephisto.java33 分钟前
【大数据学习 | kafka】kafka的整体框架与数据结构
大数据·学习
琼火hu44 分钟前
R语言笔记(五):Apply函数
开发语言·笔记·r语言·apply
茶馆大橘1 小时前
Docker部署学习
linux·运维·学习·docker·容器
qq22951165021 小时前
python基于django线上视频学习系统设计与实现_j0189d4x
python·学习·django
achaoyang2 小时前
【Python学习计算机知识储备】
开发语言·python·学习
网安kk2 小时前
2024年三个月自学手册 网络安全(黑客技术)
网络·学习·安全·web安全·网络安全
光明中黑暗2 小时前
Python 学习笔记
笔记·python·学习
聪明的墨菲特i2 小时前
VUE组件学习 | 六、v-if, v-else-if, v-else组件
前端·vue.js·学习·前端框架·vue