Redis面试题(3)

0    282    1

Tags:

👉 本文共约22885个字,系统预计阅读时间或需87分钟。

一般来讲在面试当中, 关于Redis相关的面试题频率出现比较高的几个关键词是适合哪些场景、数据结构、hash实现原理和如何扩容、如何做持久化、关系型数据库和非关系数据库对比等等。 把这几个点问完基本也差不多10~20分钟了(一般一轮面试1小时左右), 基本这些可以让面试官对你的Redis知识有一定的了解了。

Redis基础知识

什么是 Redis, 有哪些优缺点?

出现概率: ★★★★

Redis是一个非关系性数据库, 开源的、使用C语言编写、支持网络、可基于内存亦可持久化的日志型、key-value(键值对)数据库,是目前分布式架构中不可或缺的一环。

Redis服务器程序是单进程模型,也就是在一台服务器上可以同时启动多个Redis进程,而Redis的实际处理速度则完全依靠于主进程的的执行效率。若在服务器上只运行一个Redis进程,当多个客户端同时访问时,服务器的处理能力会有一定程度的下降,若在同一台服务器上开启多个Redis进程,Redis在提高并发处理能力的同时会给服务器的CPU造成很大压力。也就是说,在实际生产环境中,需要根据实际的需求来决定开启多少个Redis进程。若对高并发要求更高一些,可能会考虑在同一台服务器上开启多个进程。若CPU资源比较紧张,采用单进程即可。

Redis优点:

1)、性能极高, 读写性能优异,从内存当中进行IO读写速度快。

2)、支持数据的持久化(支持AOF和RDB两种持久化方式),对数据的更新采用Copy-on-write技术(写拷贝),可以异步的保存在磁盘上

由于Redis的数据都存放在内存中,如果没有配置持久化,redis重启后数据就全丢失了,于是需要开启redis的持久化功能,将数据保存到磁 盘上,当redis重启后,可以从磁盘中恢复数据。

redis提供两种方式进行持久化,一种是RDB持久化:指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。

还有一种是AOF持久化:以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录,可以打开文件看到详细的操作记录。

3)、支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。

4)、数据结构丰富:除了支持string类型的value外还支持string、hash、set、sortedset、list等数据结构。

5)、原子性:多个操作通过MULTI和EXEC指令支持事务

Redis缺点:

1)、主从同步,如果主机宕机,宕机前有一部分数据没有同步到从机,会导致数据不一致。

2)、主从同步,数据同步会有延迟。

3)、读写分离,主机写的负载量太大,也会导致主机的宕机

4)、数据库容量受到物理内存的限制,不能用作海量数据的高性能读写

redis-最适合的场景-可以简单的说说吗

出现概率: ★★★★

1、会话缓存(Session Cache)最常用的一种使用Redis的情景是会话缓存(session cache), Redis缓存会话比其他存储(如Memcached)的优势在于:Redis提供持久化。

2、排行榜/计数器

Redis在内存中对数字进行递增或递减的操作实现的非常好。集合(Set)和有序集合(Sorted Set)也使得我们在执行这些操作的时候变的非常简单,Redis只是正好提供了这两种数据结构。

3、发布/订阅

Redis的发布/订阅功能。发布/订阅的使用场景确实非常多。我已看见人们在社交网络连接中使用,还可作为基于发布/订阅的脚本触发器,甚至用Redis的发布/订阅功能来建立聊天系统!

4、缓存热数据

可以缓存一些高频读, 低频写的内容, 比如app首页一些设置等。

5、利用BitMap统计用户签到、统计活跃用户、用户在线状态等

Redis从2.2.0版本开始新增了setbit,getbit,bitcount等几个bitmap相关命令。虽然是新命令,但是并没有新增新的数据类型,因为setbit等命令只不过是在set上的扩展。

可以利用BitMap统计用户签到、统计活跃用户、用户在线状态

img

6、限速,接口访问频率限制:比如发送短信验证码的接口,通常为了防止别人恶意频刷,会限制用户每分钟获取验证码的频率,例如一分钟不能超过 5 次。

假设用于数据量上亿的场景下,例如几亿用户系统的签到,去重登录次数统计,某用户是否在线状态等等。腾讯10亿用户,要几个毫秒内查询到某个用户是否在线,能怎么做?

千万别说给每个用户建立一个key,然后挨个记(你可以算一下需要的内存会很恐怖,而且这种类似的需求很多。这里要用到位操作——使用setbit、getbit、bitcount命令。原理是:

redis内构建一个足够长的数组,每个数组元素只能是0和1两个值,然后这个数组的下标index用来表示用户id(必须是数字哈),那么很显然,这个几亿长的大数组就能通过下标和元素值(0和1)来构建一个记忆系统。

Redis key name 约定

Redis 数据结构

keysign:20220405sign:20220405sign:20220405 ...
offset (UserId)100010011002
value011
status未签到已签到已签到

使用经验

这里需要注意的是Redis中字符串限制最大为512MB,所以位图中最大可以设置2^32个不同的位(42.9亿个)。图位的最小单位是比特(bit),每个bit的值只能是0或1。 同时注意setbit时的偏移量,当偏移量很大时,可能会有较大耗时。 位图不是绝对的好,有时可能更浪费空间。

redis-相比-memcached-有哪些优势

出现概率: ★★★

如果简单地比较Redis与Memcached的区别,大多数都会得到以下观点:

1 、数据支持类型 Memcache 对数据类型支持相对简单。Redis 有复杂的数据类型。Redis不仅仅支持简单的k/v类型的数据,同时还提供list,set,zset,hash等数据结构的存储。

2 、Redis支持数据的备份,即master-slave模式的数据备份。

3 、存储方式 Memecache 把数据全部存在内存之中, 断电后会挂掉, 数据不能超过内存大小。Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用。

一个字符串类型的值能存储最大容量是多少

Redis中字符串限制最大为512MB

redis-读写分离

出现概率: ★★★

读取请求QPS(Queries Per Second)压力较大的服务, 可以采用Redis读写分离,可以提供高可用、高性能、灵活的读写分离服务,满足热点数据集中及高并发读取的业务需求,最大化地节约运维成本。

读写分离版采取链式复制架构,可以通过扩展只读实例个数使整体实例性能呈线性增长,同时基于源码层面对Redis复制流程的定制优化,可以最大程度地提升线性复制的系统稳定性,充分利用每一个只读节点的物理资源。

img

由于数据同步至只读节点存在一定延迟,且采用链式复制,只读节点数越多,靠近链路末端的只读节点数据延迟越大,因此选用此架构时,业务需要能接受一定程度的脏数据。如果对数据一致性要求较高,推荐选用集群架构。

你知道怎么用redis实现实现分布式锁

出现概率: ★★★★

Redis 官方站提出了一种权威的基于 Redis 实现分布式锁的方式名叫Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:

安全特性:互斥访问,即永远只有一个client能拿到锁

避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区

容错性:只要大部分 Redis 节点存活就可以正常提供服务

Redis数据结构

Redis的数据类型有哪些?

出现概率: ★★★★★

这个在面试的过程出现的概率特别高了。

Redis 支持五种常用的数据类型:string( 字符串),hash( 哈希), list( 列表), set( 集合) 及 zsetsorted set:有序集合)。

img

redis 的基本数据结构对应的底层实现如下图所示:

img

1)、Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,如图所示:

len 是当前字符串实际长度,capacity 是为字符串分配的可用空间,当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。字符串最大长度为 512M。

img

2)、hash

Redis Hash通过分桶的方式解决 hash 冲突。它是无序字典。内部实现结构是同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针。

img

3)、list

Redis 的列表相当于 Java 语言中的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。

list的特点是:

  • 有序
  • 可以重复
  • 右边进左边出或者左边进右边出,则列表可以充当队列
  • 左边进左边出或者右边进右边出,则列表可以充当栈

img

4)、set

set和字典非常类似,其内部实现就是上述的hashTable的特殊实现,与字典不同的地方有两点:

  • 只关注key值,所有的value都是NULL。
  • 在新增数据时会进行去重。

img

5)、zsetsorted set

zset是Redis非常有特色的数据结构,它是基于Set并提供排序的有序集合。其中最为重要的特点就是支持通过score的权重来指定权重。一些排行榜、延迟任务比如指定1小时后执行, 就是使用这个数据结构实现的。

img

6)、拓展篇

如果你说你还知道一些其他的几种数据结构比如: HyperLogLog、Geo、Pub/Sub、Redis Module,BloomFilter,RedisSearch,Redis-ML,面试官得眼睛就开始发亮了。

Redis5.0带来了Stream类型。从字面上看是流类型,但其实从功能上看,应该是Redis对消息队列(MQ,Message Queue)的完善实现。用过Redis做消息队列的都了解,基于Reids的消息队列实现有很多种,例如:

  • PUB/SUB,订阅/发布模式
  • 基于List的 LPUSH+BRPOP 的实现
  • 基于Sorted-Set的实现

Redis Stream的结构如图所示,它有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的ID和对应的内容。消息是持久化的,Redis重启后,内容还在。

img

hash如何实现o-1-的查询和设置速度-以及扩容原理

出现概率: ★★★★★

Redis Hash通过分桶的方式解决 hash 冲突。它是无序字典。内部实现结构是同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针。

因为是通过数组取模的方式, 可以实现O(1)的查询和设置速度。

img

不过如果概率多时, 链表长度过长时,查询时间复杂度会降低到O(n)。这个需要进行扩容了。

大字典的扩容是非常耗时间的,需要重新申请新的数组,正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个 O(n)级别的操作,Redis 使用渐进式 rehash 扩容,分多次来慢慢的将旧数组中的键值对rehash到新数组的操作就称之为渐进式rehash。渐进式rehash可以避免了集中式rehash带来的庞大计算量,在渐进式rehash过程中,因为还可能会有新的键值对存进来,此时Redis的做法是新添加的键值对统一放入ht[1]中,这样就确保了ht[0]键值对的数量只会减少,当执行rehash操作时需要执行查询操作,此时会先查询ht[0],查找不到结果再到ht[1]中查询。

说说-redis-哈希槽的概念

出现概率: ★★★

Redis集群没有使用一致性hash,而是引入了哈希槽的概念,Redis集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽。

布隆过滤器

出现概率: ★★★

这个在面试中出现的概率, 主要看面试官的偏好, 不过如果问到,而且你能回答比较好的, 估计是一个比较好的加分项。

布隆过滤器是 Redis 的高级功能,虽然这种结构的去重率并不完全精确,但和其他结构一样都有特定的应用场景,比如当处理海量数据时,就可以使用布隆过滤器实现去重。

一些场景: 百度爬虫系统每天会面临海量的 URL 数据,我们希望它每次只爬取最新的页面,而对于没有更新过的页面则不爬取,因策爬虫系统必须对已经抓取过的 URL 去重,否则会严重影响执行效率。但是如果使用一个 set(集合)去装载这些 URL 地址,那么将造成资源空间的严重浪费。

布隆过滤器(Bloom Filter)是一个高空间利用率的概率性数据结构,由二进制向量(即位数组)和一系列随机映射函数(即哈希函数)两部分组成。

布隆过滤器使用exists()来判断某个元素是否存在于自身结构中。当布隆过滤器判定某个值存在时,其实这个值只是有可能存在;当它说某个值不存在时,那这个值肯定不存在,这个误判概率大约在 1% 左右。

Bloom Filter的原理

1)、工作流程-添加元素

布隆过滤器主要由位数组和一系列 hash 函数构成,其中位数组的初始状态都为 0。

下面对布隆过滤器工作流程做简单描述,如下图所示:

img

2)、工作流程-判定元素是否存在

当我们需要判断一个元素是否存时,其流程如下:首先对给定元素再次执行哈希计算,得到与添加元素时相同的位数组位置,判断所得位置是否都为 1,如果其中有一个为 0,那么说明元素不存在,若都为 1,则说明元素有可能存在。

3)、 为什么是可能“存在”

您可能会问,为什么是有可能存在?其实原因很简单,那些被置为 1 的位置也可能是由于其他元素的操作而改变的。比如,元素1 和 元素2,这两个元素同时将一个位置变为了 1(图1所示)。在这种情况下,我们就不能判定“元素 1”一定存在,这是布隆过滤器存在误判的根本原因。

Bloom Filter的缺点

bloom filter牺牲了判断的准确率、删除的便利性 ,才做到在时间和空间上的效率比较高,是因为

1)、存在误判,可能要查到的元素并没有在容器中,但是hash之后得到的k个位置上值都是1。如果bloom filter中存储的是黑名单,那么可以通过建立一个白名单来存储可能会误判的元素。

2)、删除数据。一个放入容器的元素映射到bit数组的k个位置上是1,删除的时候不能简单的直接置为0,可能会影响其他元素的判断。可以考虑Counting Bloom Filter

Redis事务

怎么理解 Redis 事务?

出现概率: ★★★★

Redis事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行的执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

总结说:Redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

1)、Redis事务相关命令和使用

MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事务相关的命令。

  • MULTI: 开启事务,redis会将后续的命令逐个放入队列中,然后使用EXEC命令来原子化执行这个命令系列。
  • EXEC: 执行事务中的所有操作命令。
  • DISCARD: 取消事务,放弃执行事务块中的所有命令。
  • WATCH: 监视一个或多个key,如果事务在执行前,这个key(或多个key)被其他命令修改,则事务被中断,不会执行事务中的任何命令。
  • UNWATCH: 取消WATCH对所有key的监视。

example:

画重点, Redis事务不支持Rollback

事实上Redis命令在事务执行时可能会失败,但仍会继续执行剩余命令而不是Rollback(事务回滚)。如果你使用过关系数据库,这种情况可能会让你感到很奇怪。然而针对这种情况Redis官方也给出了解释:

Redis命令可能会执行失败,仅仅是由于错误的语法被调用(命令排队时检测不出来的错误),或者使用错误的数据类型操作某个Key: 这意味着,实际上失败的命令都是编程错误造成的,都是开发中能够被检测出来的,生产环境中不应该存在。(这番话,彻底甩锅,“都是你们自己编程错误,与我们无关”。)

由于不必支持Rollback,Redis内部简洁并且更加高效。

Redis事务执行过程

出现概率: ★★★

一个Redis事务从开始到执行会经历以下三个阶段:

  • 1)开始事务。
  • 2)命令放入Queue。
  • 3)执行事务。

1)开始事务

MULTI命令的执行标记着事务的开始:

这个命令唯一做的就是, 将客户端的 REDIS_MULTI 选项打开, 让客户端从非事务状态切换到事务状态。

2)命令放入Queue

当客户端处于非事务状态下时, 所有发送给服务器端的命令都会立即被服务器执行:

但是, 当客户端进入事务状态之后, 服务器在收到来自客户端的命令时, 不会立即执行命令, 而是将这些命令全部放进一个事务队列里, 然后返回QUEUED, 表示命令已入队:

3)执行事务

当客户端进入事务状态之后, 客户端发送的命令就会被放进事务队列里。

EXEC 、 DISCARD 、 MULTI 和 WATCH 这四个命令 —— 当这四个命令从客户端发送到服务器时, 它们会像客户端处于非事务状态一样, 会被服务器立即执行。

4)、关于WATCH命令

WATCH指令,有点类似乐观锁,事务提交时,如果 key 的值已被别的客户端改变,比如某个 list 已被别的客户端push/pop 过了,整个事务队列都不会被执行。(当然也可以用 Redis 实现分布式锁来保证安全性,属于悲观锁)

WATCH机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用EXEC命令执行时,WATCH机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。

WATCH机制的具体实现是由WATCH命令实现的,我给你举个例子,你可以看下面的图,进一步理解下WATCH命令的使用。

img

example:

提交事务,但k值不会被修改为3,因为在事务开启之前k的值被修改了。

Redis事务的一些使用场景

出现概率: ★★★

可利用watch命令监听key,实现乐观锁,来保证不会出现冲突,应用场景比如秒杀来防止超卖。

秒杀场景伪代码如下:

这块代码其实还没有想好, 如果有经验的朋友,欢迎留言, 后续我研究透彻会也专门写一篇秒杀场景的文章。

Redis事务与Redis pipeline的区别

出现概率: ★★★

Redis Pipeline主要用于批量发送命令,一次性发送多个请求,一次性读取所有返回结果。可以节省连接->发送命令->返回结果这个过程所产生的时间(RTT),减少read()和write()的系统调用(从用户态到内核态之间的切换)次数。

Redis事务、Redis Pipeline表面上它们可以作为批量执行命令的方式,但实际也有许多区别。

1)、命令缓冲队列方式不同
Redis事务包含的命令是缓冲在服务端内存队列,而Redis Pipeline则是缓冲在客户端本地内存中;

2)、请求次数不同
Redis事务中每个命令都需要发送到服务端,而Pipeline只需要发送一次,请求次数更少;

3)、原子性保证
Redis事务可以保证命令原子化执行,而Pipeline不保证原子性。

Redis事务、Pipeline都只能作用于单个节点。集群环境下,执行Redis命令时,会根据key计算出一个槽位(slot),然后根据槽位重定向到特定的节点上执行操作。

4)、在使用事务时,建议配合 Pipeline 使用。

a) 如果不使用 Pipeline,客户端是先发一个 MULTI 命令到服务端,客户端收到 OK,然后客户端再发送一个个操作命令,客户端依次收到 QUEUED,最后客户端发送 EXEC 执行整个事务(文章例子就是这样演示的),这样消息每次都是一来一回,效率比较低,而且在这多次操作之间,别的客户端可能就把原本准备修改的值给修改了,所以无法保证隔离性。

b) 而使用 Pipeline 是一次性把所有命令打包好全部发送到服务端,服务端全部处理完成后返回。这么做好的好处,一是减少了来回网络 IO 次数,提高操作性能。二是一次性发送所有命令到服务端,服务端在处理过程中,是不会被别的请求打断的(Redis单线程特性,此时别的请求进不来),这本身就保证了隔离性。我们平时使用的 Redis SDK 在使用开启事务时,一般都会默认开启 Pipeline 的,可以留意观察一下。

集群模式下Redis事务如何保证原子性

出现概率: ★★★

Redis事务中每个命令都需要发送到服务端, 不过Redis事务可以保证命令原子化执行

Redis事务只能作用于单个节点。集群环境下,执行Redis命令时,会根据key计算出一个槽位(slot),然后根据槽位重定向到特定的节点上执行操作。

Redis数据持久化

为什么 Redis 需要把所有数据放到内存中?

出现概率: ★★★

Redis为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。所以Redis具有快速和数据持久化的特性。如果不将数据放到内存中,磁盘的I/O速度会严重影响redis的性能。在内存越来越便宜的今天,redis将会越来越受欢迎。不过也可以设置了最大使用的内存, 则数据已有记录数达到内存限值后将不能继续插入新值。

Redis如何做持久化的?

出现概率: ★★★★★

bgsave做镜像全量持久化,AOF做增量持久化。因为bgsave会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要AOF来配合使用。在redis实例重启时,优先使用AOF来恢复内存的状态,如果没有AOF日志,就会使用RDB文件来恢复。

如果再问AOF文件过大恢复时间过长怎么办?你告诉面试官,Redis会定期做AOF重写,压缩AOF文件日志大小。如果面试官不够满意,再拿出杀手锏答案,Redis4.0之后有了混合持久化的功能,将bgsave的全量和AOF的增量做了融合处理,这样既保证了恢复的效率又兼顾了数据的安全性。这个功能甚至很多面试官都不知道,他们肯定会对你刮目相看。

如果对方追问那如果突然机器掉电会怎样?取决于AOF日志sync属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的要求下每次都sync是不现实的,一般都使用定时sync,比如1s1次,这个时候最多就会丢失1s的数据。

img

1)、RDB工作原理

既然说RDB是Redis中数据集的时间点快照,在Redis内完成RDB持久化的方法有rdbSave和rdbSaveBackground两个函数方法(源码文件rdb.c中),先简单说下两者差别:

  • rdbSave:是同步执行的,方法调用后就会立刻启动持久化流程。由于Redis是单线程模型,持久化过程中会阻塞,Redis无法对外提供服务;
  • rdbSaveBackground:是后台(异步)执行的,该方法会fork出子进程,真正的持久化过程是在子进程中执行的(调用rdbSave),主进程会继续提供服务;

RDB持久化的触发必然离不开以上两个方法,触发的方式分为手动和自动。手动触发容易理解,是指我们通过Redis客户端人为的对Redis服务端发起持久化备份指令,然后Redis服务端开始执行持久化流程,这里的指令有save和bgsave。

整个持久化的过程中,主进程不进行任何 io 操作,全程都有子进程来完成,这就确保了极高的性能。如果需要进行大规模的数据恢复,且对数据恢复的完整性不是非常敏感,那么 rdb 方式要比 AOF 方式更加的高效,rdb 的缺点是最后一次持久化的数据可能会丢失。

img

2)、AOF 工作原理

AOF 持久化全称 append only file,以日志形式记录每个写操作,将 redis 执行过得所有写操作指令记录下来(读操作不记录)。只许追加文件但不可以改写文件,redis 启动之初会读取该文件重新构建数据,换言之,redis 重启的话就根据日志文件的内容将写操作指令从前到后执行一次以完成数据的恢复工作。

AOF 默认保存的是 appendonly.AOF 文件,此文件具有可读性。

AOF 的工作原理其实类似于 mysql 的 binlog 日志语句复制。是以日志的形式记录服务器所处理的每一个写,删除操作,查询操作不会记录,以文本的方式进行记录,该文件具有可读性。

数据同步有三种同步策略:修改同步、每秒同步、不主动调用 fsync 同步。

本人提供Oracle(OCP、OCM)、MySQL(OCP)、PostgreSQL(PGCA、PGCE、PGCM)等数据库的培训和考证业务,私聊QQ646634621或微信dbaup66,谢谢!
AiDBA后续精彩内容已被站长无情隐藏,请输入验证码解锁本文!
验证码:
获取验证码: 请先关注本站微信公众号,然后回复“验证码”,获取验证码。在微信里搜索“AiDBA”或者“dbaup6”或者微信扫描右侧二维码都可以关注本站微信公众号。

标签:

Avatar photo

小麦苗

学习或考证,均可联系麦老师,请加微信db_bao或QQ646634621

您可能还喜欢...

发表回复