内部运作机制

数据库

数据库的结构:Redis中的每个数据库,都由一个redis.h/redisDb结构表示

typedef struct redisDb {
    //保存着数据库以整数表示的号码
    int id;
    
    //保存着数据库中的所有键值对数据
    //这个属性也被称为键空间
    dict *dict;

    //保存着键的过期信息
    dict *expires;

    //实现列表阻塞原语,如BLPOP
    dict *blocking_keys;
    dict *ready_keys;

    //用于实现WATCH命令
    dict *watched_keys;
} redisDb;

数据库的切换:

在Redis服务器初始化时,它会创建出redis.h/REDIS_DEFAULT_DBNUM个数据库,并将所有数据库保存到redis.h/redisServer.db数组中,每个数据库的id为从0到REDIS_DEFAULT_DBNUM - 1的值

当执行SELECT number命令时,程序直接使用redisServer.db[number]来切换数据库

一些内部程序,比如AOF程序、复制程序和RDB程序,需要知道当前数据库的号码,如果没有id域的话,程序就只能在当前使用的数据库的指针,和redisServer.db数组中所有数据库的指针进行对比,以此来弄清楚自己正在使用的是哪个数据库

有了id域,程序就可以通过读取id域来了解自己正在使用的是哪个数据库,这样就不用对比指针了

FLUSHDB命令:删除键空间中的所有键值对

RANDOMKEY命令:从键空间中随机返回一个键

DBSIZE命令:返回键空间中键值对的数量

EXISTS命令:检查给定键是否存在于键空间中

RENAME命令:在键空间中,对给定键进行改名

键的过期时间

设置键的过期时间EXPIRE、PEXPIRE、EXPIREAT、PEXPIREAT

命令TTL和PTTL用于返回给定键距离过期还有多长时间

键的过期时间是如何保存的:在数据库中,所有键的过期时间都被保存在redisDb结构的expires字典中

expires字典的键是一个指向dict字典(键空间)里某个键的指针,而字典的值则是键所指向的数据库键的到期时间,这个值以long long类型表示

设置生存时间

Redis有四个命令可以设置键的生存时间(可以存活多久)和过期时间(什么时候到期):

EXPIRE以秒为单位设置键的生存时间

PEXPIRE以毫秒为单位设置键的生存时间

EXPIREAT以秒为单位,设置键的过期UNIX时间戳

PEXPIREAT以毫秒为单位,设置键的过期UNIX时间戳

不管如何设置,在expires字典的值只保存“以毫秒为单位的过期UNIX时间戳”

过期键的判定

检查键是否存在于expires字典,如果存在,取出键的过期时间

检查当前UNIX时间戳是否大于键的过期时间,如果是,那么键已经过期,否则,键未过期

过期键的清除-->如果一个键是过期的,那么它什么时候被删除?

定时删除:在设置键的过期时间时,创建一个定时事件,当过期时间到达时,由事件处理器自动执行键的删除操作。定时删除策略对内存是最友好的,因为它保证过期键会在第一时间被删除,过期键所消耗的内存会立即被释放。缺点是它对CPU时间是最不友好的,因为删除操作可能会占用大量的CPU时间在内存不紧张、但是CPU时间非常紧张的时候(比如进行交集计算或排序的时候),将CPU时间花在删除那些和当前任务无关的过期键上,会是低效的。除此之外,Redis事件处理器对时间事件的实现方式是无需链表,查找时间复杂度为O(N),并不适合用来处理大量时间事件

惰性删除:放任键过期不管,但是在每次从dict字典中取出键值时,要检查键是否过期,如果过期删除并返回空,如果没过期,返回键值。惰性删除对CPU时间是最友好的,它只会在取出键时进行检查,这可以保证删除操作只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的键,不会在删除其他无关的过期键上花费任何CPU时间。缺点是对内存是最不友好的,如果一个键已经过期,而这个键又仍然保留在数据库中,那么dict字典和expires字典都需要继续保存这个键的信息,只要这个过期键不被删除,它占用的内存就不会释放

定期删除:每隔一段时间,对expires字典进行检查,删除里面的过期键。是上面两种策略的折中,每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率,借此来减少删除操作对CPU时间的影响;通过定期删除过期键,有效地减少了因惰性删除而带来的内存浪费

 

Redis使用的过期删除策略是惰性删除加上定期删除,这两个策略相互配合,可以很好地在合理利用CPU时间和节约内存空间之间取得平衡

实现:实现过期键惰性删除策略的核心是db.c/expireIfNeeded函数,所有命令在读取或写入数据库之前,程序都会调用expireIfNeeded对输入键进行检查,并将过期键删除,expireIfNeeded的作用是如果输入键已经过期的话,那么将键、键的值、键保存在expires字典中的过期时间都删除掉

对过期键的定期删除由redis.c/activeExpireCycle函数执行,每当Redis的例行处理程序serverCron执行时,activeExpireCycle都会被调用,这个函数在规定的时间限制内,尽可能地遍历各个数据库的expires字典,随机地检查一部分键的过期时间,并删除其中的过期键

过期键对AOF、RDB和复制的影响-->

更新后的RDB文件:在创建新的RDB文件时,程序会对键进行检查,过期的键不会被写入到更新后的RDB文件中。过期键对更新后的RDB文件没有影响

AOF文件:在键已经过期,但是还没有被惰性删除或者定期删除之前,这个键不会产生任何影响,AOF文件也不会因为这个键而被修改。当过期键被惰性删除、或者定期删除之后,程序会向AOF文件追加一条DEL命令,来显式地记录该键已被删除

AOF重写:和RDB文件类似,当进行AOF重写时,程序会对键进行检查,过期的键不会被保存到重写后的AOF文件。过期键对重写后的AOF文件没有影响

复制:当服务器带有附属节点时,过期键的删除由主节点统一控制

  • 如果服务器是主节点,那么它在删除一个过期键之后,会显式地向所有附属节点发送一个DEL命令
  • 如果服务器是附属节点,那么当它碰到一个过期键的时候,它会向程序返回键已过期的回复,但并不真正地删除过期键。当接到从主节点发来的DEL命令之后,附属节点才会真正删除过期的键

附属节点不自主对键进行删除是为了和主节点的数据保持绝对一致,因此当一个过期键还存在于主节点时这个键在所有附属节点的副本也不会被删除

小结:

  • 数据库主要由dict和expires两个字典构成,其中dict保存键值对,而expires则保存键的过期时间
  • 数据库的键总是一个字符串对象,而值可以是任意一种Redis数据类型
  • expires的某个键和dict的某个键共同指向同一个字符串对象,而expires键的值则是该键以毫秒计算的UNIX过期时间戳
  • Redis使用惰性删除和定期删除两种策略来删除过期的键
  • 更新后的RDB文件和重写后的AOF文件都不会保留已过期的键
  • 当一个过期键被删除之后,程序会追加一条新的DEL命令到现有AOF文件末尾
  • 当主节点删除一个过期键之后,会显式地发送一条DEL命令道所有附属节点
  • 附属节点即使发现过期键,也不会自主删除它,而是等待主节点发来DEL命令,这样可以保证数据一致性
  • 数据库的dict字典和expires字典的扩展策略和普通字典一样。收缩策略是当节点的填充百分比不足10%时,将可用节点数量减少至大于等于当前已用节点数量

RDB-->

在Redis运行时,RDB程序将当前内存中的数据库快照保存到磁盘文件中,在Redis重启时,RDB程序可以通过载入RDB文件来还原数据库的状态

rdbSave用于生成RDB文件到磁盘,rdbLoad用于将RDB文件中的数据重新载入到内存中

rdbSave函数负责将内存中的数据库数据以RDB格式保存到磁盘中,如果RDB文件已存在,那么新的RDB文件将替换已有的RDB文件

在保存RDB文件期间,主进程会被阻塞,直到保存完成为止

SAVE直接调用rdbSave,阻塞Redis主进程,直到保存完成为止,在主进程阻塞期间,服务器不能处理客户端的任何请求

BGSAVE则fork出一个子进程,子进程负责调用rdbSave,并在保存完成之后向主进程发送信号,通知保存已完成,因为rdbSave在子进程被调用,所以Redis服务器在BGSAVE执行期间仍然可以继续处理客户端的请求

SAVE、BGSAVE、AOF写入和BGREWRITEAOF

SAVE:当SAVE执行时,Redis服务器是阻塞的,所以当SAVE正在执行时,新的SAVE、BGSAVE或BGREWRITEAOF调用都不会产生任何作用;只有在上一个SAVE执行完毕、Redis重新开始接受请求之后,新的SAVE、BGSAVE或BGREWRITEAOF命令才会被处理;因为AOF写入由后台线程完成,而BGREWRITEAOF则由子进程完成,所以在SAVE执行的过程中,AOF写入和BGREWRITEAOF可以同时进行

BGSAVE:在执行SAVE命令之前,服务器会检查BGSAVE是否正在执行中,如果是服务器就不调用rdbSave,而是向客户端返回一个出错信息告知在BGSAVE执行期间,不能执行SAVE;当BGSAVE在执行时,调用新BGSAVE命令的客户端会收到一个出错信息,告知BGSAVE已经在执行当中

BGREWRITEAOF和BGSAVE不能同时执行:

  • 如果BGSAVE正在执行,那么BGREWRITEAOF的重写请求会被延迟到BGSAVE执行完毕之后进行,执行BGREWRITEAOF命令的客户端会收到请求被延迟的回复
  • 如果BGREWRITEAOF正在执行,那么调用BGSAVE的客户端将收到出错信息,表示这两个命令不能同时执行

BGREWRITEAOF和BGSAVE两个命令在操作方面没有冲突,不能同时执行它们只是一个性能方面的考虑:并发出两个子进程,并且两个子进程都同时进行大量的磁盘写入操作,不是一个好的解决方案

载入

当Redis服务器启动时,rdbLoad函数就会被执行,它读取RDB文件,并将文件中的数据库数据载入到内存中

在载入期间,服务器每载入1000个键就处理一次所有已到达的请求,不过只有PUBLISH、SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBPE、PUNSUBSCRIBPE五个命令的请求会被正确处理,其他命令都返回错误。等到载入完成后,服务器才会开始正常处理所有命令

因为AOF文件的保存频率通常要高于RDB文件保存的频率,一般来说AOF文件中的数据会比RDB文件中的数据要新。如果服务器在启动时,打开了AOF功能,程序优先使用AOF文件来还原数据。只有在AOF功能未打开的情况下,Redis才会使用RDB文件来还原数据

RDB文件结构

内部运作机制

 内部运作机制

内部运作机制

 内部运作机制

 内部运作机制

 内部运作机制

小结:

  • rdbSave会将数据库数据保存到RDB文件,并在保存完成之前阻塞调用者
  • SAVE命令直接调用rdbSave,阻塞Redis主进程;BGSAVE用子进程调用rdbSave,主进程仍可继续处理命令请求
  • SAVE执行期间,AOF写入可以在后台线程进行,BGREWRITEAOF可以在子进程进行,所以这三种操作可以同时进行
  • 为了避免产生竞争条件,BGSAVE和BGREWRITEAOF不能同时执行
  • 为了避免性能问题,BGSAVE和BGREWRITEAOF不能同时执行
  • 调用rdbLoad函数载入RDB文件时,不能进行任何和数据库相关的操作,不过订阅与发布方面的命令可以正常执行,因为它们和数据库不相关联

AOF

Redis分别提供了RDB和AOF两种持久化机制:

RDB将数据库的快照以二进制的方式保存到磁盘中

AOF则以协议文本的方式,将所有对数据库进行过写入的命令(及其参数)记录到AOF文件,以此达到记录数据库状态的目的

AOF命令同步

Redis将所有对数据库进行过写入的命令(及其参数)记录到AOF文件,以此达到记录数据库状态的目的,称这种记录过程为同步

AOF文件使用网络通讯协议的格式来保存这些命令

同步命令到AOF文件的整个过程可以分为三个阶段:

命令传播-->Redis将执行完的命令、命令的参数、命令的参数个数等信息发送到AOF程序中。当一个Redis客户端需要执行命令时,它通过网络连接,将协议文本发送给Redis服务器。服务器在接到客户端的请求之后,会根据协议文本的内容,选择适当的命令函数,并将各个参数从字符串文本转换为Redis字符串对象(StringObject)。

每当命令函数成功执行之后,命令参数都会被传播到AOF程序,以及REPLICATION程序

缓存追加-->AOF程序根据接收到的命令数据,将命令转换为网络通讯协议的格式,然后将协议内容追加到服务器的AOF缓存中。当命令被传播到AOF程序之后,程序会根据命令以及命令的参数,将命令从字符串对象转换回原来的协议文本。协议文本生成之后,会被追加到redis.h/redisServer结构的aof_buf末尾。redisServer结构维持着Redis服务器的状态,aof_buf域则保存着所有等待写入到AOF文件的协议文本。

缓存追加过程:接受命令、命令的参数、以及参数的个数、所使用的数据库等信息;将命令还原成Redis网络通讯协议;将协议文本追加到aof_buf末尾

文件写入和保存-->AOF缓存中的内容被写入到AOF文件末尾,如果设定的AOF保存条件被满足的话,fsync函数或者fdatasync函数会被调用,将写入的内容真正地保存到磁盘中。每当服务器常规任务函数被执行、或者事件处理器被执行时,aof.c/flushAppendOnlyFile函数都会被调用,这个函数执行以下两个工作==》WRITE:根据条件,将aof_buf中的缓存写入到AOF文件;SAVE:根据条件,调用fsync或fdatasync函数,将AOF文件保存到磁盘中。两个步骤都需要根据一定的条件来执行,而这些条件由AOF所使用的保存模式来决定。

Redis目前支持三种AOF保存模式======》

AOF_FSYNC_NO:不保存==》在这种模式下,每次调用flushAppendOnlyFile函数,WRITE都会被执行,但SAVE会被略过。在这种模式下,SAVE只会在以下任意一种情况中被执行:Redis被关闭、AOF功能被关闭、系统的写缓存被刷新(可能是缓存已经被写满,或者定期保存操作被执行)

AOF_FSYNC_EVERYSEC:每一秒钟保存一次==》在这种模式中,SAVE原则上每隔一秒钟就会执行一次,因为SAVE操作是由后台子线程调用的,所以它不会引起服务器主进程阻塞。注意,在上一句的说明里面使用了词语“原则上”,在实际运行中,程序在这种模式下对fsync或fdatasync的调用并不是每秒一次,它和调用flushAppendOnlyFile函数时Redis所处的状态有关。每当flushAppendOnlyFile函数被调用时,可能会出现以下四种情况:

  • 子线程正在执行SAVE,并且:

      1,这个SAVE的执行时间未超过2秒,那么程序直接返回,并不执行WRITE或新的SAVE

      2,这个SAVE已经执行超过2秒,那么程序执行WRITE,但不执行新的SAVE。因为这时WRITE的写入必须等待子线程先完成(旧的)SAVE,因此这里WRITE会比平时阻塞更长时间

  • 子线程没有在执行SAVE,并且:

      3,上次成功执行SAVE距今不超过1秒,那么程序执行WRITE,但不执行SAVE

      4,上次成功执行SAVE距今已经超过1秒,那么程序执行WRITE和SAVE

内部运作机制

AOF_FSYNC_ALWAYS:每执行一个命令保存一次

在这种模式下,每次执行完一个命令之后,WRITE和SAVE都会被执行。另外,因为SAVE是由Redis主进程执行的,所以在SAVE执行期间,主进程会被阻塞,不能接受命令请求

AOF保存模式对性能和安全性的影响

不保存:写入和保存都由主进程执行,两个操作都会阻塞主进程

每一秒钟保存一次:写入操作由主进程执行,阻塞主进程。保存操作由子线程执行,不直接阻塞主进程,但保存操作完成的快慢会影响写入操作的阻塞时长

每执行一个命令保存一次:同1

模式1的保存操作只会在AOF关闭或Redis关闭时执行,或由操作系统触发,一般情况下,这种模式只需要为写入阻塞,因此写入性能较后面两种模式要高,这种模式下,如果运行的中途发生停机,那么丢失数据的数量由操作系统的缓存冲洗策略决定

模式2在性能方面要优于模式3,在通常情况下,这种模式最多丢失不多于2秒的数据,安全性要高于模式1,是一种兼顾性能和安全性的保存方案

模式3的安全性是最高的,性能是最差的,因为服务器必须阻塞直到命令信息被写入并保存到磁盘之后,才能继续处理请求

内部运作机制

AOF文件的读取和数据还原

Redis读取AOF文件并还原数据库的详细步骤:

1,创建一个不带网络连接的伪客户端

2,读取AOF所保存的文件,并根据内容还原出命令、命令的参数以及命令的个数

3,根据命令、命令的参数和命令的个数,使用伪客户端执行该命令

4,执行2和3,直到AOF文件中的所有命令执行完毕

AOF重写

AOF文件通过同步Redis服务器所执行的命令,从而实现了数据库状态的记录,但是,这种同步方式会造成一个问题:随着运行时间的流逝,AOF文件会变得越来越大

实际上,AOF重写并不需要对原有的AOF文件进行任何写入和读取,它针对的是数据库中键的当前值

AOF重写的实现原理就是根据键的类型,使用适当的写入命令来重现键的当前值

//重写过程伪码
def AOF_REWRITE(tmp_tile_name):
    f = create(tmp_tile_name)

    #遍历所有数据库
    for db in redisServer.db:
    
        #如果数据库为空,那么跳过这个数据库
        if db.is_empty():continue

        #写入SELECT命令,用于切换数据库
        f.write_command("SELECT " + db.number)

        #遍历所有键
        for key in db:
         
            #如果键带有过期时间,并且已经过期,那么跳过这个键
            if key.have_expire_time() and key.is_expired():continue

            if key.type == String:
            
                #用SET key value命令来保存字符串键
                value = get_value_from_string(key)
                f.write_command("SET " + key + value)

            elif key.type == list:
            
                #用RPUSH key item1 item2 ......itemN 命令来保存列表键
                item1, item2, ......, itemN = get_item_from_list(key)

                f.write_command("RPUSH" + key + item1 + item2 + ... + itemN)

            elif key.type == Set:
                #用SADD key member1 member2......memberN命令来保存集合键
                member1, member2, ..., memberN = get_member_from_set(key)
                f.write_command("SADD " + key + member1 + member2 + ...... +memberN)

            elif key.type == Hash:
                #用HMSET key field1 value1 命令来保存哈希键
                field1, value1, sield2, value2, ......, fieldN, valueN = 
get_field_and_value_from_hash(key)
                f.write_command("HMSET " + key + field1 + value1 + field2 + value2 + ...... + fieldN + valueN)

            elif key.type == SortedSet:
                #用ZADD key score1 member1 score2 member2 ...... scoreN memberN命令来保存有序集键
                score1, member1, score2, member2, ......, scoreN, memberN = get_score_and_member_from_sorted_set(key)
                f.write_command("ZADD " + key +score1 + member1 + score2 + member2 + ...... +scoreN + memberN)

            else:
                raise_type_error()

            #如果键带有过期时间,那么用EXPIREAT key time命令来保存键的过期时间
            if key.have_expire_time():
                f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())

            #关闭文件
            f.close()

AOF后台重写

好处:子进程进行AOF重写期间,主进程可以继续处理命令请求;子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性

为了解决AOF重写期间,主进程还需要继续处理命令,而新的命令可能对现有的数据进行修改,导致当前数据库的数据和重写后的AOF文件的数据不一致的问题,引入AOF重写缓存,这个缓存在fork出子进程之后开始启用,Redis主进程在接到新的写命令之后,除了会将这个写命令的协议内容追加到现有的AOF文件之外,还会追加到这个缓存中

当子进程在执行AOF重写时,主进程执行以下工作:

1,处理命令请求

2,将写命令追加到现有的AOF文件中

3,将写命令追加到AOF重写缓存中

这样就可以保证:

现有的AOF功能会继续执行,即使在AOF重写期间发生停机,也不会有任何数据丢失

所有对数据库进行修改的命令都会被记录到AOF重写缓存中

当子进程完成AOF重写之后,会向父进程发送一个完成信号,父进程在接到完成信号之后,会调用一个信号处理函数,并完成:

1,将AOF重写缓存中的内容全部写入到新AOF文件中

2,对新的AOF文件进行改名,覆盖原有的AOF文件

在整个AOF后台重写过程中,只有最后的写入缓存和改名操作会造成主进程阻塞,在其他时候,AOF后台重写都不会对主进程造成阻塞

AOF后台重写的触发条件

AOF重写可以由用户通过调用BGREWRITEAOF手动触发

服务器在AOF功能开启的情况下,会维持以下三个变量:

  • 记录当前AOF文件大小的变量aof_current_size
  • 记录最后一次AOF重写之后,AOF文件大小的变量aof_rewrite_base_size
  • 增长百分比变量aof_rewrite_perc

每次当serverCron函数执行时,都会检查以下条件是否全部满足,如果满足就触发自动的AOF重写:

1,没有BGSAVE命令在进行

2,没有BGREWRITEAOF在进行

3,当前AOF文件大小大于server.aof_rewrite_min_size(默认值为1MB)

4,当前AOF文件大小和最后一次AOF重写后的大小之间的比率大于等于指定的增长百分比

默认情况下,增长百分比为100%

小结:

AOF文件通过保存所有修改数据库的命令来记录数据库的状态

AOF文件中的所有命令都以Redis通讯协议的格式保存

不同的AOF保存模式对数据的安全性、以及Redis的性能有很大的影响

AOF重写的目的是用更小的体积来保存数据库状态,整个重写过程基本上不影响Redis主进程处理命令请求

AOF重写实际的重写工作是针对数据库的当前值来进行的,程序既不读写、也不适用原有的AOF文件

AOF可以由用户手动触发,也可以由服务器自动触发

事件

事件是Redis服务器的核心,处理两项重要的任务:

处理文件事件:在多个客户端中实现多路复用,接受它们发来的命令请求,并将命令的执行结果返回给客户端

时间事件:实现服务器常规操作

文件事件:Redis服务器通过在多个客户端之间进行多路复用,从而实现高效的命令请求处理:多个客户端通过套接字连接到Redis服务器中,但只有在套接字可以无阻塞地进行读或写时,服务器才会和这些客户端进行交互

读事件:读事件标志着客户端命令请求的发送状态。当一个新的客户端连接到服务器时,服务器会给为该客户端绑定读事件,直到客户端断开连接之后,这个读事件才会被移除

读事件在整个网络连接的生命期内,都会在等待和就绪两种状态之间切换:

  • 当客户端只是连接到服务器,但并没有向服务器发送命令时,该客户端的读事件就处于等待状态
  • 当客户端给服务器发送命令请求,并且请求已到达时(相应的套接字可以无阻塞地执行读操作),该客户端的读事件处于就绪状态

当事件处理器被执行时,就绪的文件事件会被识别到,相应的命令请求会被发送到命令执行器,并对命令进行求值

写事件:写事件标志着客户端对命令结果的接收状态,服务器只会在有命令结果要传回给客户端时,才会为客户端关联写事件,并且在命令结果传送完毕之后,客户端和写事件的关联就会被移除

一个写事件会在两种状态之间切换:

当服务器有命令结果需要返回给客户端,但客户端还未能执行无阻塞写,那么写事件处于等待状态

当服务器有命令结果需要返回给客户端,并且客户端可以进行无阻塞写,那么写事件处于就绪状态

当客户端向服务器发送命令请求,并且请求被接受并执行之后,服务器就需要将保存在缓存内的命令执行结果返回给客户端,这时服务器就会为客户端关联写事件

当同时有读写事件时,优先处理读事件

时间事件

时间事件记录着那些要在指定时间点运行的事件,多个时间事件以无序链表的形式保存在服务器状态中

每个时间事件主要由三个属性组成:

when:以毫秒格式的UNIX时间戳为单位,记录了应该在什么时间点执行事件处理函数

timeProc:事件处理函数

next指向下一个时间事件,形成链表

根据timeProc的返回值,将时间事件分为:

  • 如果事件处理函数返回ae.h/AE_NOMORE,那么这个事件为单次执行事件:该事件会在指定的时间被处理一次,之后该事件就会被删除,不再执行
  • 如果事件处理函数返回一个非AE_NOMORE的整数值,那么这个事件为循环执行事件:该事件会在指定的时间被处理,之后它会按照事件处理函数的返回值,更新事件的when属性,让这个事件在之后的某个时间点再次运行,并以这种方式一直更新并运行下去

当时间事件处理器被执行时,遍历所有链表中的时间事件,检查它们的到达事件,并执行其中的已到达事件

相关推荐