2022届秋招Java后端高频知识点汇总⑦--Redis

1. IO多路复用

I/O 多路复用模型是利用select、poll、epoll可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll是只轮询那些真正发出了事件的流),依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。


采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。

2. Redis中IO多路复用

redis的服务器在处理客户端的请求时,是通过socket处理的,客户端是通过socket和服务端相连的

当客户端和服务器在连接、关闭、写入、返回4个时刻是需要进行处理的。

用一个线程(IO多路复用程序)监听多个socket的状态,需要处理再进行处理

IO多路复用程序只负责监听,如果同时有多个socket需要处理,所以需要一个队列,让事件有序的进行处理

当IO多路复用程序监听到socket处于这样的状态时,就将这个socket对象放到队列中,让当前需要处理的socket对象变得有序。

文件事件分派器通过读取队列中的socket逐个进行处理,因为socket的状态是不同的,所以不同的socket需要进行不同的处理,根据每个socket的情况分派给不同的事件处理器进行处理。

事件处理器有:命令请求处理器、命令回复处理器、连接应答处理器等

底层实现:select、epoll、evport、kequeue。Redis中IO对路复用程序会根据当前的操作系统选择一个性能最高的实现方式。


3. Redis是单线程的,为什么还能这么快



4. Redis缓存淘汰策略

①LRU(最近最少使用):是按照最近最少使用的原则筛选数据,即最不常使用的数据会被筛选出来。

标准LRU:把所有的数据组成一个链表,表头和表尾分别表示最常使用端和最少使用端。刚被访问的数据和新增的数据会被移动到最常使用端,当链表的空间被占满时,它会删除最少使用端的数据。

近似LRU:Redis会记录每个数据的最近一次访问的时间戳。近似LRU会随机采样N个key,然后淘汰掉最旧的key,若淘汰后的内存仍然超出限制,则继续采样淘汰。

LRU算法的不足:若一个key很少被访问,只是偶尔被访问了一次,则它旧被认为是热点数据,短时间内不会被淘汰。

②LFU(最近使用频率):它根据key的最近访问频率进行淘汰。每个数据都有一个计数器,老统计这个数据的访问次数。当使用LFU策略淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出内存。如果两个数据的访问次数相同,LFU再比较这两个数据的访问时间,把访问时间更早的数据淘汰出内存。


默认的缓存淘汰策略:

noeviction:默认策略,不淘汰任何数据,如果内存达到了设定的值,再添加数据时会报错。


5. Redis的过期策略

①惰性删除:当访问一个key的时候,Redis会先检查它的过期时间,如果发现过期旧立即删除这个key。

②定期删除:Redis会将设置了过期时间的key放入一个独立的字典中,以后会定时遍历这个字典来删除过期的key。

Redis默认每秒进行10次过期扫描。

过期扫描不会遍历过期字典中所有的key,而是采用一种简单的贪心策略。步骤如下:

1.先从过期字典中随机选出20个key

2.删除这20个key中已经过期的key

3.如果过期的key的比例操作25%,就重复步骤1

同时,为了保证过期扫描不会出现循环过度,导致线程卡死的现象,算法还增加了扫描时间的上限,默认不会超过25ms。


惰性删除的优缺点:

优点:删除操作只发生在从redis中取出key的时候发生,而且只删除当前key,所以对CPU的时间占用是比较少的。

缺点:若大量的key在超出超时时间后,很多一段时间内都没有被获取过,那么可能发生内存泄漏。


定期删除的优缺点:

优点:定期删除,会在一段时间后主动删除过期的key,不会造成内存泄漏问题。

缺点:因为是采用贪心的策略,因此存在部分过期的key仍然没有被删除。如果过期的key较多,也会占用很多CPU时间。


6. Redis持久化机制

Redis支持RDB持久化、AOF持久化和RDB-AOF混合持久化三种持久化方式。

RDB持久化(默认方式)

RDB持久化是Redis默认采用的持久化方式,它以快照的形式将数据持久化到硬盘中。RDB会创建一个经过压缩的二进制文件,文件以“.rdb”结尾,内部存储了各个数据库的键值对数据等信息。


RDB持久化的触发方式有两种:

手动触发:通过SAVE或BGSAVE命令触发RDB持久化操作,创建“.rdb”文件。

自动触发:通过配置选项,让服务器在满足指定条件时自动执行BGSAVE命令。


其中,SAVE命令执行期间,Redis服务器将阻塞,直到“.rdb”文件创建完毕为止。而BGSAVE命令是异步版本的SAVE命令,它会使用Redis服务器进程的子进程,创建“.rdb”文件。BGSAVE命令在创建子进程时会存在短暂的阻塞,之后服务器便可以继续处理其他客户端的请求。总之,BGSAVE命令是针对SAVE阻塞问题做的优化,Redis内部所有涉及RDB的操作都采用BGSAVE的方式,而SAVE命令已经废弃!


RDB持久化的优缺点:

优点:RDB恢复数据的速度非常快。RDB生成压缩的二进制文件,体积小。

缺点:RDB持久化没法做到实时的持久化。(在执行BGSAVE命令时会创建子进程,会存在短暂的阻塞)


AOF持久化

AOF持久化是以独立日志的方式,记录了每次写入命令。通过执行AOF文件中的命令来恢复数据。

AOF持久化解决了数据持久化的实时性,是Redis目前持久化的主流方式。


AOF持久化的优缺点:

优点:与RDB持久化可能丢失大量的数据相比,AOF可以将数据丢失的时间窗口限制在1s之内。

缺点:AOF需要执行AOF文件中的命令来恢复数据库,恢复速度比RDB慢很多。

AOF存储的是协议文本,体积要比二进制格式的“.rdb”文件大很多。

AOF在进行重写时也需要创建子进行,在数据库体积较大时将占用大量资源,会导致服务器的短暂阻塞。


RDB-AOF混合持久化

Redis从4.0开始引入RDB-AOF持久化模式,这种模式是基于AOF持久化构建而来的。

RDB-AOF混合持久化将会根据数据库当前的状态生成RDB数据,并将其写入AOF文件。对于持久化开始到持久化结束这段时间执行的Redis命令,将以协议文本的方式追加到AOF文件的末尾,即RDB数据之后。


RDB-AOF混合持久化的优缺点:

优点:通过使用RDB-AOF混合持久化,用户可以同时获得RDB持久化和AOF持久化的优点,服务器既可以通过AOF文件包含的RDB数据来实现快速的数据恢复操作,又可以通过AOF文件包含的AOF数据来将丢失数据的时间窗口限制在1s之类。

缺点:兼容性差,一旦开启了混合持久化,在4.0之前版本都不识别该 AOF 文件,同时由于前部分是 RDB 格式,阅读性较差。


7. Redis事务

Redis 通过 MULTI、EXEC、WATCH 等命令来实现事务(transaction)功能。事务提供了⼀种将多个命令请求打包,然后⼀次性、按顺序地执⾏多个命令的机制,并且在事务执⾏期间,服务器不会中断事务⽽改去执⾏其他客户端的命令请求,它会将事务中的所有命令都执⾏完毕,然后才去处理其他客户端的命令请求。


在传统的关系式数据库中,常常⽤ ACID 性质来检验事务功能的可靠性和安全性。在 Redis 中,事务总是具有原⼦性(Atomicity)、⼀致性(Consistency)和隔离性(Isolation),并且当 Redis 运⾏在某种特定的持久化模式下时,事务也具有持久性(Durability)


【注意】

事物具有原子性指的是,数据库将事务中的多个操作当做一个整体来执行,服务器要么执行事务中的所有操作,要么就一个操作也不执行。

对于Redis的事务功能来说,事务队列中的命令要么就全部都执行,要么就一个都不执行,因此Redis的事务是具有原子性的。

但是,Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕。

解释:作者在事务功能的文档解释说,不支持事务回滚是因为这种复杂的功能和Redis追求简单高效的设计主旨不相符,并且他认为,Redis事务的执行时错误通常都是编程错误产生的,这种错误通常只会出现开发环境中,而很少会在实际生产环境中出现。



8. 如何保证缓存与数据库数据的一致性

1.先更新缓存,再更新数据库

2.先更新数据库,再更新缓存

3.先删除缓存,再更新数据库

4.先更新数据库,再删除缓存

5.延迟双删


更新缓存还是删除缓存?

更新缓存:

优点:每次数据变化都及时更新缓存,所以查询时不容易出现未命中的情况。

缺点:更新缓存消耗比较大。如果数据需要经过复杂的计算再写入缓存,那么频繁的更新缓存,就会影响服务器的性能,如果是写入数据频繁的业务场景,那么可能频繁的更新缓存时,却没有业务读取该数据。

删除缓存:

优点:操作简单,无论是更新操作是否复杂,都是将缓存中的数据直接删除。

缺点:删除缓存后,下一次查询缓存会出现未命中,这时需要重新读取一次数据库。


所以,一般情况下,删除缓存是更优的方案。



先删除缓存再更新数据库或者先更新数据库再删除缓存,第二步失败的情况

①先删除缓存,再更新数据库

可能出现的问题:删除缓存成功,更新数据库失败,最终缓存和数据库的数据是一致的,但是是旧数据。


1.进程A删除缓存成功

2.进程A更新数据库失败

3.进程B从缓存中读取数据

4.由于缓存被删,进程B无法从缓存中得到数据,进而从数据库读取数据。

5.进程B从数据库成功获取数据,然后将数据更新到了缓存。


最终:缓存和数据库的数据是一致的,但是是旧数据。而我们的期望是二者数据一致,并且是新的数据。


②先更新数据库再删除缓存

可能出现的问题:更新数据库成功,但是删除缓存失败,最终缓存和数据库中的数据是不一致的。


1.进程A更新数据库成功

2.进程A删除缓存失败

3.进程B读取缓存成功,由于缓存删除失败,所以进程B读取到的数据是旧数据。


可以看出,无论是先更新数据库还是先删除缓存,都会出现问题。在第二步出现问题的时候,都建议采用重试机制解决。为了避免重试机制影响主要业务的执行,一般建议重试机制采用异步的方式执行。


例如:在先更新数据库,再删除缓存的方式。来说明重试机制的主要步骤。

1.更新数据库成功

2.删除缓存失败

3.将此数据加入消息队列

4.业务代码消费这条消息的内容,发起重试机制,从缓存中删除这条记录。

先删除缓存再更新数据库或者先更新数据库再删除缓存,不失败的情况

①先删除缓存再更新数据库

在没有失败时可能出现的问题:在删除缓存和更新数据库之间,其它进程访问了缓存,将数据库中旧数据存入了缓存。最终,缓存中存的是旧数据,数据库中是新数据,二者数据不一致。


1.进程A删除缓存成功

2.进程B读取缓存失败

3.进程B读取数据库成功,得到旧的数据

4.进程B将旧的数据成功的更新到了缓存

5.进程A将新的数据成功更新到了数据库

②先更新数据库再删除缓存

在没有失败时可能出现的问题:在更新数据库和删除缓存之间,可能出现其他进程访问了旧缓存中的数据。最终缓存和数据库中的数据是一致的且都是新数据。


1.进程A更新数据库成功

2.进程B读取缓存成功

3.进程A更新数缓存成功


最终结论:先更新数据库,再删除缓存这种方案影响更小,如果第二步出现失败,可采用重试机制解决问题。


这种情况还可能出现的问题:

1.请求A访问缓存,缓存刚好失效

2.请求A查询数据库,得到一个旧值

3.请求B更新数据库,将新值写入数据库

4.请求B删除缓存

5.请求A将旧值写入缓存

这样,缓存中存的是旧值,数据库中存的是新值,缓存和数据库中的数据不一致。

但是,如果要发生这种情况,步骤3的写数据库操作,要比步骤2的读数据库操作耗时更短,才能使步骤4先于步骤5。但是数据库读操作的速度远远大于写操作的速度。因此步骤3比步骤2耗时更短,这一情形很难出现。


如果非要解决这种问题:采用先删除缓存,再更新数据库的延迟删除策略,保证读请求完成以后,再进行删除操作。


推荐方案:延迟双删

如果是先删除缓存,再更新数据库的情况,在第二步没有出现失败时,也可能会导致数据的不一致。

在删除缓存和更新数据库之间,其它进程访问了缓存,将数据库中旧数据存入了缓存。最终,缓存中存的是旧数据,数据库中是新数据,二者数据不一致。


解决方法:延迟双删

1.删除缓存

2.更新数据库

3.sleep N毫秒

4.再次删除缓存


阻塞一段时间之后,再次删除缓存,就可以把这个过程中缓存中不一致的数据删除掉。

但是,采用延迟删除缓存,在清空缓存之前还是会有很多请求查询到旧缓存中的数据。

解决方法:

1.采用加锁的方法解决。一次性不让太多的线程来请求。

2.可以尽量缩短第一次删除缓存和更新数据库的时间差,这样可以使其他事务第一时间获取到更新数据库之后的数据。



9. 如何利用Redis实现一个分布式锁

单节点的分布式锁

使用Redis实现分布式锁,在redis里存一份代表锁的数据,通常用字符串即可。

1. 在加锁时就要给锁设置一个标识,进程要记住这个标识。当进程解锁的时候,要进行判断,是自己持有的锁才能释放,否则不能释放。可以为key赋一个随机值,来充当进程的标识。

2. 解锁时要先判断、再释放,这两步需要保证原子性,否则第二步失败的话,就会出现死锁。而获取和删除命令不是原子的,这就需要采用Lua脚本,通过Lua脚本将两个命令编排在一起,而整个Lua脚本的执行是原子的。


多个节点的分布式锁

上述分布式锁的实现方案,是建立在单个主节点之上的。它的潜在问题如下图所示,如果进程A在主节点上加锁成功,然后这个主节点宕机了,则从节点将会晋升为主节点。若此时进程B在新的主节点上加锁成果,之后原主节点重启,成为了从节点,系统中将同时出现两把锁,这是违背锁的唯一性原则的。

总之,就是在单个主节点的架构上实现分布式锁,是无法保证高可用的。


若要保证分布式锁的高可用,则可以采用多个节点的实现方案。这种方案有很多,而Redis的官方给出的建议是采用RedLock算法的实现方案。该算法基于多个Redis节点,它的基本逻辑如下:

这些节点相互独立,不存在主从复制或者集群协调机制;

加锁:以相同的KEYN个实例加锁,只要超过一半节点成功,则认定加锁成功;

解锁:向所有的实例发送DEL命令,进行解锁;


RedLock算法的示意图如下,我们可以自己实现该算法,也可以直接使用Redisson框架。


10. Redis常见数据类型及底层实现

①String

ptr (long)

SDS raw/embstr

②list

ziplist

linkedList

③hash

ziplist

hashtable

④set

intset

hashtable

⑤zset

ziplist

skiplist



①String

String对象的编码可以是int、raw、embstr

1.如果一个字符串对象保存的是整数值,并且这个整数值可以使用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性中,并将字符串对象的编码设置为int


2.如果一个字符串对象保存的是字符串值,并且这个字符串的长度>39字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw


3.如果一个字符串对象保存的字符串值,并且这个字符串的长度<=39字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。(也是简单动态字符串)


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


②list

在3.2版本之前,list对象的编码可以是ziplist和linkedlist


ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点保存了一个列表元素。

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


当list对象可以同时满足以下两个条件时,list对象使用ziplist编码,不满足时使用linkedlist编码:

1.list对象保存的所有字符串元素的长度都小于64字节

2.list对象保存的元素数量小于512个


从3.2版本开始,list对象的编码升级为quicklist

quicklist编码的列表采用快速列表作为底层实现。quicklist是链表和压缩列表的结合。


③hash

hash对象的编码可以是ziplist或者hashtable


ziplist编码的hash对象使用ziplist作为底层实现。

hashtable编码的hash对象使用字典作为底层实现。


当hash对象可以同时满足以下两个条件时,hash对象使用ziplist编码,否则使用hashtable编码

1.hash对象保存的所有键值对的key和value的字符串长度都小于64字节

2.hash对象保存的键值对数量小于512个


④set

set对象的编码可以是intset或者hashtable


intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。

hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。


当set对象可以同时满足以下两个条件时,对象使用intset编码,否则使用hashtable编码

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

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


⑤zset

zset的编码可以是ziplist或者skiplist


ziplist编码的zset对象使用ziplist作为底层实现,每个集合元素使用两个紧挨在一起的ziplist节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。ziplist内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的位置,而分值较大的元素则被放置在靠近表尾的位置。


skiplist编码的zset对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。


当zset对象可以同时满足以下两个条件时,对象使用ziplist编码,否则使用skiplist编码

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

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



11. Redis的一致性Hash算法

一致性Hash算法主要应用于Redis分布式缓存的存储中

使用一致性Hash算法可以有效地解决在分布式存储结构下动态增加和删除节点后尽量有多的请求命中原来的服务器节点。

一致性hash主要用于分布式系统中,用于解决数据选择节点存储、选择节点访问、增删节点后数据的迁移和重分布问题。

一致性Hash算法针对的是几个独立的Redis节点

Redis集群并没有使用一致性hash,而是使用了hash槽来解决数据分配的问题。


场景描述:假设有3个redis节点用于存储数据,这3台Redis节点的编号是0,1,2。如果希望将数据均匀的存储在这3个节点上,可以采用对数据的key进行Hash计算,将Hash计算后的结果对redis节点的数量进行取模运行,通过取模后的结果,决定将数据存在哪一个Redis节点上。hash(key)% N

当下次访问这个数据时,只需要再次对数据的key进行Hash计算,即可得出数据在哪个redis上,然后从对应的redis节点上获取数据。

存在的问题:当增加redis节点或者当部分redis节点挂掉后,再进行hash计算然后对节点的数量取模时,就无法找到对应的数据,造成大量的缓存同时失效。

一致性Hash算法

一致性Hash算法是对232 取模。 Hash(服务器的IP地址) % 232 (余数:0~232-1)

将232个数想象成由(0~232-1)共232个点组成的圆环,把这个圆环称为Hash环。

将每台服务器的ip地址先进行hash计算,然后再对232取模 。通过上述方法,可将各服务器映射到hash环上,如图所示。

当存储数据时,对数据的key进行hash计算,然后再对232取模,将数据映射到hash环上,如图3所示。

然后在Hash环上,从数据的位置开始,沿顺时针方向遇到的第一个服务器,就将数据存储在这里。

假如有4个数据,如图4,数据1和2将会被存储在服务器A,数据3将会被存储在服务器B,数据4将会被存储在服务器C。

当服务器B挂掉后,按照执行hash算法的规则,数据3将会被存储在服务器C中,而其它的数据却没有改变,这就是一致性hash算法的优点。

如果使用之前的hash算法对服务器的数量取模,那么当服务器的数量发生改变时,就会找不到数据,所有的缓存将会同时时间失效。而使用一致性Hash算法,服务器数量如果发生改变,并不是所有缓存都会失效,而是只有部分缓存会失效。

Hash环的偏斜问题

假设在理想的情况下,3台服务器将会均匀的映射在Hash环上,但是现实可能是3台服务器在Hash环上的映射的位置挨的比较近。那么,将会有很多数据被存储在某一台服务器上。这样,其它服务器并没有得到平均的充分的利用。如果此时,那个节点出现故障,造成的损失也是最大的。

解决方法:

使用虚拟节点,虚拟节点是实际节点在Hash环上的一个复制品,一个实例节点可以对应多个虚拟节点。引入虚拟节点后,节点在Hash环上的分布就变得均衡了。虚拟节点越多,Hash环上的节点就越多,数据被均匀分布的概率就越大。



#高频知识点汇总##Java##学习路径#
全部评论
2022届秋招Java后端高频知识点汇总①--Java基础: https://www.nowcoder.com/discuss/819297 2022届秋招Java后端高频知识点汇总②--Java集合: https://www.nowcoder.com/discuss/819300 2022届秋招Java后端高频知识点汇总③--多线程: https://www.nowcoder.com/discuss/819302 2022届秋招Java后端高频知识点汇总④--Java中的锁: https://www.nowcoder.com/discuss/819304 2022届秋招Java后端高频知识点汇总⑤--JVM: https://www.nowcoder.com/discuss/819307 2022届秋招Java后端高频知识点汇总⑥--MySQL: https://www.nowcoder.com/discuss/819308 2022届秋招Java后端高频知识点汇总⑦--Redis: https://www.nowcoder.com/discuss/819310 2022届秋招Java后端高频知识点汇总⑧--计算机网络: https://www.nowcoder.com/discuss/819312 2022届秋招Java后端高频知识点汇总⑨--操作系统: https://www.nowcoder.com/discuss/819316 2022届秋招Java后端高频知识点汇总⑩--Spring: https://www.nowcoder.com/discuss/819319
点赞 回复 分享
发布于 2021-12-09 15:03
🎉恭喜牛友成功参与 【创作激励计划】高频知识点汇总专场! ------------------- 创作激励计划5大主题专场等你来写,最高可领取500元京东卡和500元实物奖品! 👉快来参加吧:https://www.nowcoder.com/discuss/804743
点赞 回复 分享
发布于 2021-12-09 20:00
2022届秋招Java后端企业面试真题汇总①:https://www.nowcoder.com/discuss/817566 2022届秋招Java后端企业面试真题汇总②:https://www.nowcoder.com/discuss/818250 2022届秋招Java后端企业面试真题汇总③:https://www.nowcoder.com/discuss/818255
点赞 回复 分享
发布于 2021-12-09 20:36
666
点赞 回复 分享
发布于 2021-12-13 20:22
Java后端面试高频问题:HashMap的底层原理:https://www.nowcoder.com/discuss/820700 Java后端面试高频问题:ConcurrentHashMap:https://www.nowcoder.com/discuss/820701 Java后端面试高频问题:BIO、NIO、AIO的区别:https://www.nowcoder.com/discuss/820703 Java后端高频面试问题:线程池:https://www.nowcoder.com/discuss/820704 Java后端高频面试问题:AQS和CAS:https://www.nowcoder.com/discuss/820706 Java后端高频面试问题:String相关:https://www.nowcoder.com/discuss/821375 Java后端高频面试问题:ArrayList相关:https://www.nowcoder.com/discuss/821377 Java后端高频面试问题:垃圾回收机制:https://www.nowcoder.com/discuss/822354 Java后端高频面试问题:MySQL索引和事务:https://www.nowcoder.com/discuss/823047
点赞 回复 分享
发布于 2021-12-18 15:32

相关推荐

点赞 评论 收藏
分享
斑驳不同:还为啥暴躁 假的不骂你骂谁啊
点赞 评论 收藏
分享
18 130 评论
分享
牛客网
牛客企业服务