redis-这个可以说是非常全面了

目录

前言

1.Redis有多快?

2.Redis为什么这么快?

3.Redis为什么是单线程的?

4.Redis五种存储类型及操作命令

    4.1 字符串   string    可存储 字符串、整数、浮点数

    4.2 列表 list

    4.3 集合 set

    4.4 散列 hash

    4.5 有序集合 zset

Redis一共有几种数据类型?五种吗?不止!其实除了我们面试常说的string、list、hash、set、zset这五种基本类型以外它还包括 Bitmaps,Hyperloglogs,GEO。

    4.6 Bitmaps

    4.7 HyperLogLogs

    4.8 GEO

5.通用命令

6.数据结构

 6.1 上图: 

 6.2 这里有必要说一下这个ziplist和inset: 这种数据结构 被称为 短结构

   6.2.1 ziplist:

 6.2.2 集合的整数集合编码inset:

7.Redis持久化。Redis咋保证数据安全?如果出现故障数据最多损失多少?

7.1持久化方式及配置

8.主从复制

 8.1如何配置从服务器

9.redis事务

9.1、相关命令

10.redis环境搭建及参数配置

  10.1单机版搭建步骤

10.2 集群环境搭建:


前言

Redis采用单线程来处理来自所有客户端的并发请求,把任务封闭在一个线程中从而避免了线程安全问题;

Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,

这会造成某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,对此redis选择了 I/O 多路复用模型来解决阻塞问题。

IO模型知识可以参考 https://blog.csdn.net/qq_32725403/article/details/100539974

下面举一个例子,模拟一个tcp服务器处理30个客户socket:
假设你是一个监考老师,让30个学生解答一道竞赛考题,然后负责验收学生答卷,你有下面几个选择:

 

1. 第一种选择:按顺序逐个验收,先验收A,然后是B,之后是C、D。。。这中间如果有一个学生卡住,全班都会被耽误。
这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。
2. 第二种选择:你创建30个分身,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
3. 第三种选择,你站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。
这种就是IO复用模型,Linux下的select、poll和epoll就是干这个的。将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用非阻塞模式。
这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。

 

针对上面的举例在Redis中表现为

 

有30个redis客户端(考生)与redis服务器的网络连接模块(监考老师)保持TCP连接,客户端会不定时的发送请求给服务器,当有一个redis客户端发起请求,会触发unix系统像epoll这样的系统调用,Redis的I/O 多路复用模块封装了底层的epoll这样的 I/O 多路复用函数,然后转发到相应的事件处理器。文件事件处理器使用 I/O 多路复用模块同时监听多个 FD(文件描述符),当 acceptreadwrite 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器。虽然整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,提高了网络通信模型的性能,同时也可以保证整个 Redis 服务实现的简单。

 

1.Redis有多快?

 Redis采用的是基于内存的采用的是单进程单线程模型的 KV 数据库,由C语言编写,官方提供的数据是可以达到100000+的QPS

横轴是连接数,纵轴是QPS。

2.Redis为什么这么快?

1、完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);

2、数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;

3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;

4、使用多路I/O复用模型,非阻塞IO;采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作

不会成为影响Redis性能的瓶颈,主要由以上几点造就了 Redis 具有很高的吞吐量

5、使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM 机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求;

3.Redis为什么是单线程的?

因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了

由于在单线程模式的情况下已经很快了,就没有必要在使用多线程了!但是,我们使用单线程的方式是无法发挥多核CPU 性能,不过我们可以通过在单机开多个Redis 实例来完善!

警告1:这里我们一直在强调的单线程,只是在处理我们的网络请求的时候只有一个线程来处理,一个正式的Redis Server运行的时候肯定是不止一个线程的,这里需要大家明确的

注意一下!例如Redis进行持久化的时候会以子进程或者子线程的方式执行

4.Redis五种存储类型及操作命令

    4.1 字符串   string    可存储 字符串、整数、浮点数

  string 是 redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。value其实不仅是String,也可以是数字。string 类型是二进制安全的。意思是 redis 的 string 可以包含任何数据。比如jpg图片或者序列化的对象。string 类型是 Redis 最基本的数据类  型,string 类型的值最大能存储 512MB。 

 

最基本的命令:          GET、SET、DEL        语法:GET key,SET key value   value如果有空格需要双引号以示区分

整数递增:                  INCR               语法:INCR key    默认值为0,所以首先执行命令得到 1 ,不是整型提示错误

增加指定的整数:       INCRBY           语法:INCRBY key increment

整数递减:                  DECR              语法:DECR key   默认值为0,所以首先执行命令得到 -1,不是整型提示错误

减少指定的整数:       DECRBY          语法:DECRBY key increment

增加指定浮点数:      INCRBYFLOAT 语法:INCRBYFLOAT key increment  与INCR命令类似,只不过可以递增一个双精度浮点数

向尾部追加值:         APPEND           语法:APPEND key value   redis客户端并不是输出追加后的字符串,而是输出字符串总长度

获取字符串长度:     STRLEN            语法:STRLEN key  如果键不存在返回0,注意如果有中文时,一个中文长度是3,redis是使用UTF-8编码中文的

获取多个键值:        MGET                语法:MGET key [key …]  例如:MGET key1 key2    如果不在一个哈希槽会报错

设置多个键值:        MSET                语法:MSET key value [key value …]  例如:MSET key1 1 key2 "hello redis" 如果不在一个哈希槽会报错

二进制指定位置值:GETBIT              语法:GETBIT key offset   例如:GETBIT key1 2 ,key1为hello 返回 1,返回的值只有0或1,当key不存在或超出实际长度时为0

设置二进制位置值:SETBIT              语法:SETBIT key offset value ,当前对象的value必须是二进制且set的数据也必须是二进制否则报错。返回该位置的旧值

二进制是1的个数:  BITCOUNT       语法:BITCOUNT key [start end] ,start 、end为开始和结束字节

位运算:                   BITOP              语法:BITOP operation destkey key [key …]  ,operation支持AND、OR、XOR、NOT

偏移:                      BITPOS             语法:BITPOS key bit [start] [end]

    4.2 列表 list

列表类型存储了一个有序的字符串双向链表。常用的操作是向两端插入新的元素。时间复杂度为 O(1)。结构为一个链表。记录头和尾的地址。看到这里,Redis 数据类型的列表类型一个重大的作用呼之欲出,那就是队列。新来的请求插入到尾部,新处理过的从头部删除。另外,比如微博的新鲜事。比如日志。列表类型就是一个下标从 0 开始的数组。由于是链表存储,那么越靠近头和尾的元素操作越快,越靠近中间则越慢。

 

添加左边元素:           LPUSH                   语法:LPUSH key value [value …]  ,将元素推入列表的左端  返回添加后的列表元素的总个数

添加右边元素:           RPUSH                   语法:RPUSH key value [value …]  ,将元素推入列表的右端  返回添加后的列表元素的总个数

移除左边第一个元素:LPOP                      语法:LPOP key  ,从列表左端弹出元素 返回被移除的元素值

移除右边第一个元素:RPOP                      语法:RPOP key ,从列表右端弹出元素 返回被移除的元素值 

列表元素个数:           LLEN                       语法:LLEN key, 不存在时返回0,redis是直接读取现成的值,并不是统计个数

获取列表片段:           LRANGE                  语法:LRANGE key start stop,如果start比stop靠后时返回空列表,0 -1 返回整个列表正数时:start 开始索引值,stop结束索引值(索引从0开始)负数时:例如 lrange num -2 -1,-2表示最右边第二个,-1表示最右边第一个,

删除指定值:               LREM                      语法:LREM key count value,返回被删除的个数 count>0,从左边开始删除前count个值为value的元素count<0,从右边开始删除前|count|个值为value的元素 count=0,删除所有值为value的元素

索引元素值:               LINDEX                    语法:LINDEX key index ,返回索引的元素值,-1表示从最右边的第一位

设置元素值:               LSET                         语法:LSET key index value

保留列表片段:           LTRIM                       语法:LTRIM key start stop,start、top 参考lrange命令

一个列表转移另一个列表:RPOPLPUSH      语法:RPOPLPUSH source desctination ,从source列表转移到desctination列表,该命令分两步看,首先source列表RPOP右移除,再desctination列表LPUSH

 

    4.3 集合 set

集合类型是为了方便对多个集合进行操作和运算。集合中每个元素不同且没有顺序的概念,每个元素都是且只能是一个字符串。常用操作是对集合插入、删除、判断等操作。时间复杂度尾 O(1)。可以进行交集、并集、差集运算。例如文章 1 的有 3 个标签,是一个 Redis 数据类型集合类型存储。文章 2 有 3 个标签,有一个 Redis 数据类型集合类型存储。文章是 1 是 mysql,文章 2 是讲 redis。那么交集是不是就交出了一个数据库?(假设数据库这个tag在两篇文字都有)。集合类型在 redis 中的存储是一个值为空的散列表(哈希表)。

 

添加元素:            SADD               语法:SADD key member [member …] ,向一个集合添加一个或多个元素,因为集合的唯一性,所以添加相同值时会被忽略。 返回成功添加元素的数量。

删除元素:            SREM               语法:SREM key member [member …] 删除集合中一个或多个元素,返回成功删除的个数。

获取全部元素:     SMEMBERS     语法:SMEMBERS key ,返回集合全部元素

值是否存在:        SISMEMBER    语法:SISMEMBER key member ,如果存在返回1,不存在返回0

差运算:                SDIFF              语法:SDIFF key [key …] ,例如:集合A和集合B,差集表示A-B,在A里有的元素B里没有,返回差集合;多个集合(A-B)-C

交运算:                SINTER           语法:SINTER key [key …],返回交集集合,每个集合都有的元素

并运算:                SUNION   语法:SUNION key [key …],返回并集集合,所有集合的元素

集合元素个数:     SCARD           语法:SCARD key ,返回集合元素个数

集合运算后存储结果                    语法:SDIFFSTROE destination key [key …] ,差运算并存储到destination新集合中

                   SINTERSTROE destination key [key …],交运算并存储到destination新集合中

                                                                SUNIONSTROE destination key [key …],并运算并存储到destination新集合中

随机获取元素:SRANDMEMGER 语法:SRANDMEMBER key [count],根据count不同有不同结果,count大于元素总数时返回全部元素count>0 ,返回集合中count不重复的元素count<0,返回集合中count的绝对值个元素,但元素可能会重复

弹出元素:SPOP                          语法:SPOP key [count] ,因为集合是无序的,所以spop会随机弹出一个元素

    4.4 散列 hash

Redis 是以字典(关联数组)的形式存储的,一个 key 对应一个 value。在字符串类型中,value 只能是一个字符串。那么在散列类型,也叫哈希类型中,value 对应的也是一个字典(关联数组)。那么就可以理解,Redis 的哈希类型/散列类型中,key 对应的 value 是一个二维数组。但是字段的值只可以是字符串。也就是说只能是二维数组,不能有更多的维度。  每个hash可以存储2的32次方 -1个键值对(40多亿)

设置单个:                HSET                      语法:HSET key field value,不存在时返回1,存在时返回0,没有更新和插入之分

设置多个:               HMSET                    语法:HMSET key field value [field value …]

读取单个:               HGET                      语法:HGET key field,不存在是返回nil

读取多个:               HMGET                    语法:HMGET key field [field …]

读取全部:               HGETALL                 语法:HGETALL key,返回时字段和字段值的列表

判断字段是否存在:HEXISTS                   语法:HEXISTS key field,存在返回1 ,不存在返回0

字段不存在时赋值:HSETNX                   语法:HSETNX key field value,与hset命令不同,hsetnx是键不存在时设置值

增加数字:              HINCRBY                  语法:HINCRBY key field increment ,返回增加后的数,不是整数时会提示错误

删除字段:              HDEL                        语法:HDEL key field [field …] ,返回被删除字段的个数

只获取字段名:       HKEYS                      语法:HKEYS key ,返回键的所有字段名

只获取字段值:       HVALS                     语法:HVALS key  ,返回键的所有字段值

字段数量:              HLEN                       语法:HLEN key ,返回字段总数

    4.5 有序集合 zset

集合类型是无序的,每个元素是唯一的。那么有序集合就是有序的,每个元素是唯一的。有序集合类型和集合类型的差别是,有序集合为每个元素配备了一个属性:分数。有序集合就是根据分数来排序的。有序集合是使用散列表和跳跃表实现的。所以和列表相比,操作中间元素的速度也很快。时间复杂度尾 O(log(N))。Redis 数据类型中的有序集合类型比 Redis 数据类型中的列表类型更加耗费资源。

啥是跳跃表?  详细的咱们可以看这个博客  https://www.cnblogs.com/acfox/p/3688607.html  你就记住他是为了减少寻址次数就好。就是为了快!

 

添加集合元素:        ZADD                       语法:ZADD key [NX|XX] [CH] [INCR] score member [score member …],不存在添加,存在更新。

获取元素分数:        ZSCORE                    语法:ZSCORE key member ,返回元素成员的score 分数

元素小到大:            ZRANGE                   语法:ZRANGE key start top [WITHSCORES] ,参考LRANGE ,加上withscores 返回带元素,即元素,分数当分数一样时,按元素排序

元素大到小:           ZREVRANGE              语法:ZREVRANGE key start [WITHSCORES] ,与zrange区别在于zrevrange是从大到小排序

指定分数范围元素:ZRANGEBYSCORE     语法:ZRANGEBYSCORE key min max [WITHSCORE] [LIMIT offest count]

                                      返回从小到大的在min和max之间的元素,( 符号表示不包含,例如:80-100,(80 100,

                                         withscore返回带分数

                                         limit offest count 向左偏移offest个元素,并获取前count个元素

指定分数范围元素:ZREVRANGESCORE   语法:ZREVRANGEBYSCORE key max  min [WITHSCORE] [LIMIT offest count]与zrangebyscore类似,只不过该命令是从大到小排序的。

增加分数:              ZINCRBY                    语法:ZINCRBY key increment member ,注意是增加分数,返回增加后的分数;如果成员不存在,则添加一个为0的成员。

Redis一共有几种数据类型?五种吗?不止!其实除了我们面试常说的string、list、hash、set、zset这五种基本类型以外它还包括 Bitmaps,Hyperloglogs,GEO。

    4.6 Bitmaps

   

bitmaps不是一个真实的数据结构。而是String类型上的一组面向bit操作的集合。由于strings是二进制安全的blob,并且它们的最大长度是512m,所以bitmaps能最大设置2^32个不同的bit。

Bitmaps的最大优点就是存储信息时可以节省大量的空间。例如在一个系统中,不同的用户被一个增长的用户ID表示。40亿(2^32=4*1024*1024*1024≈40亿)用户只需要512M内存就能记住某种信息,例如用户是否登录过。

bit操作被分为两组:

1.恒定时间的单个bit操作,例如把某个bit设置为0或者1。或者获取某bit的值。

2.对一组bit的操作。例如给定范围内bit统计(例如人口统计)。

 

二进制指定位置值:GETBIT              语法:GETBIT key offset   例如:GETBIT key1 2 ,key1为hello 返回 1,返回的值只有0或1,当key不存在或超出实际长度时为0

设置二进制位置值:SETBIT              语法:SETBIT key offset value ,当前对象的value必须是二进制且set的数据也必须是二进制否则报错。返回该位置的旧值 SETBIT命令第一个参数是位编号,第二个参数是这个位的值,只能是0或者1。如果bit地址超过当前string长度,会自动增大string。

二进制是1的个数:   BITCOUNT       语法:BITCOUNT key [start end] ,start 、end为开始和结束字节  统计位的值为1的数量。

位运算:                   BITOP               语法:BITOP operation destkey key [key …]  ,operation支持AND、OR、XOR、NOT

偏移:                       BITPOS             语法:BITPOS key bit [start] [end]  寻址第一个为0或者1的bit的位置(寻址第一个为1的bit的位置:bitpos dupcheck 1; 寻址第一个为0的bit的位置:bitpos dupcheck 0).

bitmaps一般的使用场景:

各种实时分析.

存储与对象ID关联的节省空间并且高性能的布尔信息.

例如,想象一下你想知道访问你的网站的用户的最长连续时间。你开始计算从0开始的天数,就是你的网站公开的那天,每次用户访问网站时通过SETBIT命令设置bit为1,可以简单的用当前时间减去初始时间并除以3600*24(结果就是你的网站公开的第几天)当做这个bit的位置。

这种方法对于每个用户,都有存储每天的访问信息的一个很小的string字符串。通过BITCOUN就能轻易统计某个用户连续访问网站的天数。另外通过调用BITPOS命令,或者客户端获取并分析这个bitmap,就能计算出最长停留时间。
 

    4.7 HyperLogLogs

 

HyperLogLog是用于计算唯一事物的概率数据结构(从技术上讲,这被称为估计集合的基数)。如果统计唯一项,项目越多,需要的内存就越多。因为需要记住过去已经看过的项,从而避免多次统计这些项。

然而,有一组算法可以交换内存以获得精确度:在redis的实现中,您使用标准错误小于1%的估计度量结束。这个算法的神奇在于不再需要与需要统计的项相对应的内存,取而代之,使用的内存一直恒定不变。最坏的情况下只需要12k,就可以计算接近2^64个不同元素的基数。或者如果您的HyperLogLog(我们从现在开始简称它为HLL)已经看到的元素非常少,则需要的内存要要少得多。

在redis中HLL是一个不同的数据结构,它被编码成Redis字符串。因此可以通过调用GET命令序列化一个HLL,也可以通过调用SET命令将其反序列化到redis服务器。

HLL的API类似使用SETS数据结构做相同的任务,SETS结构中,通过SADD命令把每一个观察的元素添加到一个SET集合,用SCARD命令检查SET集合中元素的数量,集合里的元素都是唯一的,已经存在的元素不会被重复添加。

而使用HLL时并不是真正添加项到HLL中(这一点和SETS结构差异很大),因为HLL的数据结构只包含一个不包含实际元素的状态,API是一样的:

PFADD 命令用于添加一个新元素到统计中。

PFCOUNT 命令用于获取到目前为止通过PFADD命令添加的唯一元素个数近似值。

PFMERGE 命令执行多个HLL之间的联合操作。

127.0.0.1:6380> PFADD hll a b c d d c

(integer) 1

127.0.0.1:6380> PFCOUNT hll

(integer) 4

127.0.0.1:6380> PFADD hll e

 (integer) 1

127.0.0.1:6380> PFCOUNT hll

(integer) 5

PFMERGE命令说明:

PFMERGE destkey sourcekey [sourcekey …]

Merge N different HyperLogLogs into a single one.

用法(把hll1和hll2合并到hlls中):

1

2

3

4

5

6

7

127.0.0.1:6380> PFADD hll1 1 2 3

(integer) 1 

27.0.0.1:6380> PFADD hll2 3 4 5

(integer) 1 

27.0.0.1:6380> PFMERGE hlls hll1 hll2

OK

127.0.0.1:6380> PFCOUNT hlls

HLL数据结构的一个使用场景就是计算用户每天在搜索框中执行的唯一查询,即搜索页面UV统计。而Bitmaps则用于判断某个用户是否访问过搜索页面。这是它们用法的不同。 

ps:这个PFMERGE有个限制:所有操作对象必须在同一服务器。

 

 

    4.8 GEO

Redis的GEO特性在 Redis3.2版本中推出,这个功能可以将用户给定的地理位置(经度和纬度)信息储存起来,并对这些信息进行操作。GEO相关命令只有6个:

 

 

GEOADD:GEOADD key longitude latitude member [longitude latitude member …],将指定的地理空间位置(纬度、经度、名称)添加到指定的key中。例如:GEOADD city 113.501389 22.405556 shenzhen;

经纬度具体的限制,由EPSG:900913/EPSG:3785/OSGEO:41001规定如下:

有效的经度从-180度到180度。

有效的纬度从-85.05112878度到85.05112878度。

当坐标位置超出上述指定范围时,该命令将会返回一个错误。

 

GEOHASH:GEOHASH key member [member …],返回一个或多个位置元素的标准Geohash值,它可以在http://geohash.org/使用。查询例子:http://geohash.org/sqdtr74hyu0.(可以通过谷歌了解Geohash原理,或者戳Geohash基本原理:https://www.cnblogs.com/tgzhu/p/6204173.html)。

 

GEOPOS:GEOPOS key member [member …],从key里返回所有给定位置元素的位置(经度和纬度)。

 

GEODIST:GEODIST key member1 member2 [unit],返回两个给定位置之间的距离。GEODIST命令在计算距离时会假设地球为完美的球形。在极限情况下,这一假设最大会造成0.5%的误差。

指定单位的参数unit必须是以下单位的其中一个:

m 表示单位为米(默认)。

km 表示单位为千米。

mi 表示单位为英里。

ft 表示单位为英尺。

 

GEORADIUS:GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count],以给定的经纬度为中心, 返回键包含的位置元素当中, 与中心的距离不超过给定最大距离的所有位置元素。这个命令可以查询某城市的周边城市群。  

 

GEORADIUSBYMEMBER:GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count],这个命令和GEORADIUS命令一样,都可以找出位于指定范围内的元素,但是GEORADIUSBYMEMBER的中心点是由给定的位置元素决定的,而不是像 GEORADIUS那样,使用输入的经度和纬度来决定中心点。  

redis> GEOADD Sicily 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"

(integer) 2

  

redis> GEOHASH Sicily Palermo Catania

1"sqc8b49rny0"

2"sqdtr74hyu0"

  

redis> GEOPOS Sicily Palermo Catania NonExisting

11"13.361389338970184"

   2"38.115556395496299"

21"15.087267458438873"

   2"37.50266842333162"

3) (nil)

  

redis> GEODIST Sicily Palermo Catania

"166274.15156960039"

  

redis> GEORADIUS Sicily 15 37 100 km

1"Catania"

redis> GEORADIUS Sicily 15 37 200 km

1"Palermo"

2"Catania"

  

redis> GEORADIUSBYMEMBER Sicily Agrigento 100 km

1"Agrigento"

2"Palermo"

5.通用命令

TTL命令

expire key seconds  设置键过期时间 Redis 支持对  添加 过期时间,当超过过期时间后,会 自动删除键

127.0.0.1:6379> set hello world

OK

127.0.0.1:6379> expire hello 10

(integer) 0

ttl 命令会返回键的 剩余过期时间,它有 3 种返回值:

 

  • 大于等于 0 的整数:表示键 剩余 的 过期时间
  • 返回 -1: 没设置 过期时间
  • 返回 -2: 不存在。

 

可以通过 ttl 命令观察  hello 的 剩余过期时间

# 还剩7秒

127.0.0.1:6379> ttl hello(integer)

(integer) 7

如果一个键是过期的那它到了过期事件之后是不是马上就从内存中被删除呢??如果不是,那过期之后到底什么时候被删除呢?这个问题是不是很熟悉?是不是一脸黑线的想起了某次面试。。

过期键的删除策略:

其实有三种不同的删除策略:

1.立即删除。在设置键的过期时间是创建一个回调事件,当过期时间到达时,由事件处理器自动执行键的删除操作。  这个对cpu是最不友好的。因为删除操作会占用cpu时间,而且它不管你忙不忙。

2.惰性删除。键过期了就过期了,不管。每次从dict中按键取值时看到它过期了就删除它然后返回nil,如果没有就返回键值。 这个对内存是不友好的,因为过期数据在下次访问前仍一直存在于内存中。

3.定时删除。每个一段时间,对expires字典进行检查,删除里面的过期值。  上面俩都不好使,就剩这一个了,这个也是比较折中的一个删除策略了。

有没有更好的解决办法呢?

redis中对于过期的数据选择的是惰性删除和定时删除配合使用 这样 浪费的内存不会浪费太长时间,cpu也不会每次响应过期操作。

 

type key  键的数据结构类型

127.0.0.1:6379> set a b

OK

127.0.0.1:6379> type a

string

性能查看:这个命令不是cli命令因此需要命令行执行。

redis-benchmark -h 192.168.189.131 -p 7001 -q -d 10000  #SET/GET 100 bytes 检测host为127.0.0.1 端口为6379的redis服务器性能
redis-benchmark -h 127.0.0.1 -p 6379 -c 5000 -n 100000  5000个并发连接,100000个请求,检测host为127.0.0.1 端口为6379的redis服务器性能 

如果不给参数的话,即:redis-benchmark 那么默认的会以50个客户端进行性能测试。

6.数据结构

    6.1 上图: 

对于每种 数据结构,实际上都有自己底层的 内部编码 实现,而且是 多种实现。这样 Redis 会在合适的 场景 选择合适的 内部编码3.0之后list键已经不直接用ziplist和linkedlist作为底层实现了,取而代之的是quicklist   A doubly linked list of ziplists)  

想看详细的  看这个 https://blog.csdn.net/men_wen/article/details/70229375

可以看到,每种 数据结构 都有 两种以上 的 内部编码实现。例如 list 数据结构 包含了 linkedlist 和 ziplist 两种 内部编码。同时有些 内部编码,例如 ziplist,可以作为 多种外部数据结构 的内部实现,可以通过 object encoding 命令查询 内部编码

127.0.0.1:6379> object encoding hello

"embstr"

127.0.0.1:6379> object encoding mylist

"quicklist"

 

 6.2 这里有必要说一下这个ziplist和inset: 这种数据结构 被称为 短结构

   6.2.1 ziplist:

在列表、散列和有序集合的长度较短或体积较小的时候,Redis可以选择使用一种名为压缩列表(ziplist)的紧凑存储方式来存储这些结构。压缩列表是列表、散列有序集合的三种不同类型的对象的非结构化表示。压缩列表会以序列化的方式存储数据。因此读取和存入需要编码和解码。

为什么说ziplist可以节省内存呢?

拿列表为例:在典型的双向链表里面,链表包含的每个值都会由一个node节点表示,每个节点都会带有指向链表中前一个节点和后一个节点的指针,以及一个当前节点包含的字符串值的指针。每个节点包含的字符串值都会分为3部分进行存储:第一部分存储的是字符串的长度,第二部分存储的是字符串值中剩余可用的字节数量,而最后一部分存储的则是以空字符串结尾的字符串本身。

例如一个较长的双向链表的一部分的三个元素分别为 one  two  ten 他们在链表里的存储方式:

图中展示的三个3字符长的字符串,每个都需要空间来存储3个指针、两个整数(一个字符串长度,一个剩余可用空间大小)、字符串本身以及一个额外的字节。在32位平台上,每存储这样的3字节长的字符串,就需要付出21字节的额外开销,而这还只是保守的估计值,实际的额外开销还会更多一些。

压缩列表是由节点组成的序列(sequence),每个节点都有两长度值和一个字符串组成。第一个长度值纪录的是前一个节点的长度,这个长度值将被用于对压缩列表进行从后向前的遍历,第二个长度值纪录了当前节点的长度,而位于节点最后的则是被存储的字符串值。这样使用压缩列表存储每个节点只会有两个字节的额外开销。通过避免存储额外的指针和元数据来达到节省内存的目的。 这个东西理论上可以将内存压缩至原数据结构的1/20.

ziplist数据结构:

  • zlbytes: ziplist的长度(单位: 字节),是一个32位无符号整数
  • zltail: ziplist最后一个节点的偏移量,反向遍历ziplist或者pop尾部节点的时候有用。
  • zllen: ziplist的节点(entry)个数
  • entry: 节点
  • zlend: 值为0xFF,用于标记ziplist的结尾

ziplist元素结构:  这玩意极其复杂  想看细致的 可以去简书研究https://www.jianshu.com/p/afaf78aaf615  这里你就记住它是将其存储为有长度信息描述的内存空间连续的线性结构就行了,但是这有个必须面对的问题就是删改数据造成的内存重分配,所以衍生除了quicklist。详见上面数据结构6.1部分。

为了确保压缩列表只会在有需要降低内存占用的情况下使用,Redis引入了 配置选项,这些选项决定了列表、散列和有序集和会在什么情况下使用压缩列表表示。

list-max-ziplist-entries 512
list-max-ziplist-value 64

 

 

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

 

hash-max-zipmap-entries 512
hash-max-zipmap-value 64

 

entries选项说明在被编码为压缩列表的情况下,允许包含的最大元素数量;

value选项说明这个压缩列表最大体积是多少字节。当这些选项设置的限制条件中的任意一个被突破的时候,Redis就会将相应的列表、散列、有序集和从压缩列表编码转换为其他结构,而内存占用也会因此而增加。

 

判断一个结构是否被表示为压缩列表的方法:

debug object  key    encoding即为其结构 

 6.2.2 集合的整数集合编码inset:

跟列表、散列和有序结合一样,体积较小的集合也有自己的紧凑表示:如果整数包含的所有成员都可以被解释为十进制整数,而这些数字又处于平台的有符号整数范围内,并且集合成员的数量又足够少的话,

那么Redis就会以有序整数数组的方式存储集合,而这种存储方式又被称为整数集合(inset)

以有序集合的方式存储集合不仅可以降低内存消耗,还可以提升所有标准集合操作的执行速度。那么一个集合要符合什么条件才能被存储为整数集合呢?配置选项:

set-max-inset-entries 512

只要集合存储的整数数量没有超过配置设定的大小,Redis就会使用整数集合表示以减小数据的体积。

7.Redis持久化。Redis咋保证数据安全?如果出现故障数据最多损失多少?

7.1持久化方式及配置

Redis是可以完全基于内存存储的,但是这样有一个问题就是数据的持久化问题和容灾问题。为此Redis提供了复制和持久化选项。Redis提供了两种不同的持久化方法来将数据存储到硬盘里面。使得数据在Redis重启之后仍然存在。

一种方法叫做快照(snapshotting)它可以将存在于某一时刻的所有数据都写入到硬盘里面。另一种方法叫做只追加文件(append-only file AOF),他会在执行写命令时,将被执行的写命令复制到硬盘里面。这两种持久化方法

既可以同时使用又可以单独使用。

将内存中的数据存储到硬盘的一个主要原因是为了在之后重用数据,或者是为了防止系统故障而将数据备份到一个远程位置。另外,存储在redis中的数据有可能是长时间计算得出的,或者有程序在使用redis存储的数据进行计算,

所以用户希望这些数据存储起来以便之后使用,这样就不必重新计算了。

 

 

#快照持久化选项

save 60 1000             触发同步保存操作的条件。 这个可以配置多个:save 3600 10 300 100  60 10000     这个东西redis是这么判断的:当前时间距离上次快照备份已经超过了60秒,且在这期间内发生了大于1000次写操作。那么执行bgsave。

stop-writes-on-bgsave-error no    #执行快照持久化时是否继续执行写命令

rdbcompression yes                       #是否压缩快照文件

dbfilename dump.rdb                    #持久化文件名

快照持久化是Redis内存中的数据在某一个时间点进行备份,在创建快照之后,用户可以对快照进行备份,需要注意的是:快照是某个时间点执行的,并不是每次更新都会创建快照。系统发生崩溃时,只能保存上次快照的数据,

因此快照持久化适用于数据不经常修改或者丢失部分数据影响不大的场景。

 

快照面临的大数据问题:

当Redis存储的数据量只有几个GB的时候,使用快照来保存数据是没有问题的。Redis会创建子进程并将数据保存到硬盘里面。生成快照所用的时间可能比你读这段话的时间还要短。但随着Redis占用的内存越来越多,BGSAVE在创建子进程的

好费时间也会越来越多。这会导致系统停顿,也可能引发系统大量的使用虚拟内存,从而导致redis的性能降低至无法使用的程度。

执行BGSAVE而导致的停顿时间长短取决于redis所在的系统:

真实硬件、VMWare、KVM虚拟机中Redis进程中每占用1G的内存,创建该进程所需的时间就要增加10-20毫秒;

Xen虚拟机(云供应商很多用这种虚拟机) 则需要200-400毫秒;也就是说如果有20G数据  那么快照将使Redis停顿4-6秒!!!

为了防止redis因为创建子进程而出现停顿,我们可以考虑关闭自动保存,转而通过手动发送BGSAVE或者SAVE来进行持久化。手动发送BGSAVE一样会造成卡顿,但好处是我们能自己决定允许卡顿的时间,比如用户影响最小的月黑风高的夜晚。

另一方面SAVE会一直阻塞Redis知道快照生成完毕,但是因为他不用创建子进程,所以不会因为创建进程而造成的卡顿;并且因为没有子进程抢夺资源它创建快照的速度也会更快。

 

#AOF持久化选项

配置参数:

appendonly no                               #是否使用AOF持久化

appendfsync everysec                    #可选参数: always(每个写操作都要同步到硬盘)   everysec(每秒同步一次)  no(让操作系统决定应该何时进行同步)

no-appendfsync-on-rewrite no      #对AOF文件压缩时是否执行写操作

auto-aof-rewrite-percentage 100  #多久执行一次AOF压缩

auto-aof-rewrite-min-size 64mb   #新写入多大内存数据出发一次AOF压缩

 

dir ./                                                持久化文件存放目录

AOF 的运作方式是不断地将命令追加到文件的末尾, 所以随着写入命令的不断增加, AOF 文件的体积也会变得越来越大。

可以通过设置auto-aof-rewrite-percentage 和 auto-aof-rewrite-min-size 选项来自动执行BGREWRITEAOF。

AOF 文件有序地保存了对数据库执行的所有写入操作, 这些写入操作以 Redis 协议的格式保存, 因此 AOF 文件的内容非常容易被人读懂, 对文件进行分析(parse)也很轻松。而且这种操作将数据丢失窗口时间可以降低至一秒以内。

AOF面临的硬盘性能问题:

频繁的AOF操作能将发生系统崩溃时出现的数据丢失减到最小。但这也意味着对硬盘进行大量的写入操作,所以redis的处理命令的速度会受到硬盘性能的限制:转盘式硬盘在这种同步频率下每秒只能处理200个写命令,而固态硬盘每秒大概也只能处理几万个名利。

固态硬盘能几万已经接近10万的标准了是不是很开心?警告:使用固态硬盘的用户应谨慎使用appendsync always 选项,因为这个选项一次只写一个命令会造成写入放大,这种不断写入会让你的硬盘寿命从原来的几年降低为几个月。

AOF面临的文件提及问题:

快照存的是数据而AOF刚才我们说了它存储的是一行一行的Redis 协议的格式写入脚本,随着redis的不断运行,AOF文件的体积也会不断的增长,在极端情况下,aof文件甚至能写满硬盘可用空间。还有一个问题就是存储了这么多东西是为了还原redis数据用的,AOF文件体积

这么大,还原操作的执行时间就可能非常长!

为了解决AOF体积问题,用户可以向redis发送 BGREWRITEAOF 命令,这个命令会通过一处AOF文件中的冗余命令来重写AOF文件以压缩体积。BGREWRITEAOF  和 BGSAVE 命令创建快照原理类似:创建子进程,然后重写。所以他也存在卡顿问题。所以压缩不宜频繁。

 

两种持久化方案讲完了,最后提醒一下,备份是为了容灾,所以持久化的服务器最好不要和redis服务器为同一台,别把鸡蛋全放一个篮子里…

 

8.主从复制

复制可以让其他服务器拥有一个不断地更新的数据副本,从而使得拥有数据副本的服务器可以用于处理客户端发送的读请求。关系型数据库通常会使用一个主服务器向多个从服务器发送更新,并使用从服务器来处理所有读请求。redis也采用了同样的方法来实现自己的复制特性,并将其用作一种拓展性能的手段。

可以通过设置额外的redis从服务器来保存数据集的副本。在接收到主服务器发送的数据初始副本之后,客户端每次向主服务器进行写入时,从服务器都会实时的得到更新。在部署好从服务器后,客户端就可以向任意一个从服务器发送读请求了。

 8.1如何配置从服务器

1.redis.conf 中配置:slaveof host port 即可使其成为 从服务器

2.正在运行的redis服务可以发送 SLAVEOF NO ONE 即可让服务器终止复制操作 或发送 SLAVEOF host port 来让服务器开始复制一个新的主服务器。

 配置一下或者发个命令就成从服务器了吗?这期间经历了啥?

从服务器在连接一个主服务器的时候,主服务器会创建一个快照文件并将其发送至从服务器,但这只是中从复制执行过程中的异步,具体操作是这样的:

 

步骤

主服务器操作

从服务器操作

步骤

主服务器操作

从服务器操作

1 (等待命令进入) 连接(或者重连接)主服务器,发送sync命令
2 开始执行BGSAVE,并使用缓冲区纪录BGSAVE之后执行的所有写命令 根据配置选项来决定是否继续使用现有的数据(如果有的话)来处理客户端的命令请求,还是向发送请求的客户端返回错误
3 BGSAVE执行完毕,向从服务器发送快照文件,并在发送期间继续使用缓冲区纪录被执行的写命令 丢弃所有的旧数据(如果有的话),开始载入主服务器发来的快照文件
4 快照文件发送完毕,开始向从服务器发送存储在缓冲区的写命令 完成对快照文件的解释操作,像往常一样开始接受命令请求
5 缓存区存储的写命令发送完毕;从现在开始,每执行一个写命令,就向从服务器发送相同的写命令 执行主服务器发送来的所有存储在缓冲区里面的写命令;并从现在开始,接受并执行主服务器传来的每个写命令

从服务器在进行同步时,会清空自己的所有数据

Redis不支持主主复制。相互设置成主服务器的两个实例会持续占用处理器资源尝试与对方进行通信,根据客户端连接的服务器不同客户端可能会取到不一致的数据,或者完全得不到数据。

复制操作的bgsave会影响主服务器的写性能原因前面说过了,所以实现上我们习惯以树状结构来分担主服务器的复制压力:

原:

树状结构:

 

9.redis事务

MULTI、EXEC、DISCARD和WATCH、UNWATCH命令是Redis事务功能的基础。Redis事务允许在一次单独的步骤中执行一组命令,并且可以保证如下两个重要事项:

Redis会将一个事务中的所有命令序列化,然后按顺序执行。Redis不可能在一个Redis事务的执行过程中插入执行另一个客户端发出的请求。这样便能保证Redis将这些命令作为一个单独的隔离操作执行。 

在一个Redis事务中,Redis要么执行其中的所有命令,要么什么都不执行。因此,Redis事务能够保证原子性。EXEC命令会触发执行事务中的所有命令。

 

PS:在事务运行期间,虽然Redis命令可能会执行失败,但是Redis仍然会执行事务中余下的其他命令,而不会执行回滚操作。

1.redis足够快,所以发生需要回滚的概率很低。

2.键值数据库造成错误更多的是程序bug,而这种问题一般不会出现在生产上 比如改错键 增错步长等。

 

 

9.1、相关命令

1. MULTI

用于标记事务块的开始。Redis会将后续的命令逐个放入队列中,然后才能使用EXEC命令原子化地执行这个命令序列。

这个命令的运行格式如下所示:

MULTI

这个命令的返回值是一个简单的字符串,总是OK。

2. EXEC

在一个事务中执行所有先前放入队列的命令,然后恢复正常的连接状态。

当使用WATCH命令时,只有当受监控的键没有被修改时,EXEC命令才会执行事务中的命令,这种方式利用了检查再设置(CAS)的机制。

这个命令的运行格式如下所示:

EXEC

这个命令的返回值是一个数组,其中的每个元素分别是原子化事务中的每个命令的返回值。 当使用WATCH命令时,如果事务执行中止,那么EXEC命令就会返回一个Null值。

3. DISCARD

清除所有先前在一个事务中放入队列的命令,然后恢复正常的连接状态。

如果使用了WATCH命令,那么DISCARD命令就会将当前连接监控的所有键取消监控。

这个命令的运行格式如下所示:

DISCARD

这个命令的返回值是一个简单的字符串,总是OK。

4. WATCH

当某个事务需要按条件执行时,就要使用这个命令将给定的键设置为受监控的。

这个命令的运行格式如下所示:

WATCH key [key ...]

这个命令的返回值是一个简单的字符串,总是OK。

对于每个键来说,时间复杂度总是O(1)。

5. UNWATCH

清除所有先前为一个事务监控的键。

如果你调用了EXEC或DISCARD命令,那么就不需要手动调用UNWATCH命令。

这个命令的运行格式如下所示:

UNWATCH

这个命令的返回值是一个简单的字符串,总是OK。

时间复杂度总是O(1)。

demo:

 折叠源码

expand source?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

redis 127.0.0.1:6379> MULTI

OK

redis 127.0.0.1:6379> SET book-name "Mastering C++ in 21 days"

QUEUED

redis 127.0.0.1:6379> GET book-name

QUEUED

redis 127.0.0.1:6379> SADD tag "C++" "Programming" "Mastering Series"

QUEUED

redis 127.0.0.1:6379> SMEMBERS tag

QUEUED

redis 127.0.0.1:6379> EXEC

1) OK

2) "Mastering C++ in 21 days"

3) (integer) 3

4)

1) "Mastering Series"

2) "C++"

3) "Programming"

 

10.redis环境搭建及参数配置

  10.1单机版搭建步骤

linux版的可以到https://redis.io/或者是http://www.redis.cn/ 下载需要版本的源代码。

因为是源代码且底层是c开发的,所以需要c的编译环境。

如果make命令能够使用,则证明已经有了c环境了。没有c环境则可以使用yum install gcc-c++

wget http://103.78.124.156:83/2Q2WB244B2029332D80C415CBEE56A6C1AE4974EDC31_unknown_CD91975374E1DFF333B4DC4BBF173C7966F8F0F3_1/download.redis.io/releases/redis-5.0.4.tar.gz

进入解压目录    make install

cp /usr/local/redisinstall/redis-5.0.4/redis.conf  ./redis.conf

改 bind  bind 0.0.0.0
改 守护线程   daemonize yes
改公网可访问  protected-mode no

开放防火墙6379端口

起服务
redis-server   redis.conf

远程测试
telnet ip  6379

ok!

 

10.2 集群环境搭建:

安装位置:/usr/local/redis/redis-cluster

1、Linux下redis 安装:
redis 官网链接:https://redis.io
1.1.首先进入自己的文件目录,下载一个安装包,解压缩,进入redis解压目录,执行make指令

  1. wget http://download.redis.io/releases/redis-4.0.2.tar.gz
  2. tar xzf redis-4.0.2.tar.gz
  3. cd redis-4.0.2
  4. make

1.2. 编译完成后,cd到src 目录下,安装
cd src
make install

 

如果make命令能够使用,则证明已经有了c环境了。没有c环境则可以使用yum install gcc-c++

2、组建集群:
2.1.拷贝六份项目 至 /usr/local/redis/redis-cluster  分别命名 redis01 redis02
redis03 redis04 redis05   redis06

拷贝脚本:
cp -r redis-4.0.2 ./redis01
cp -r redis-4.0.2 ./redis02
cp -r redis-4.0.2 ./redis03
cp -r redis-4.0.2 ./redis04
cp -r redis-4.0.2 ./redis05
cp -r redis-4.0.2 ./redis06
误创建删除脚本:
rm -rf redis06
2.2./usr/local/redis/redis-cluster下创建conf文件夹  用于存放六个服务器配置文件;
创建脚本:
mkdir conf
cd /usr/local/redis/redis-cluster/conf
cp ../redis-4.0.2/redis.conf  redis01.conf
cp ../redis-4.0.2/redis.conf  redis02.conf
cp ../redis-4.0.2/redis.conf  redis03.conf
cp ../redis-4.0.2/redis.conf  redis04.conf
cp ../redis-4.0.2/redis.conf  redis05.conf
cp ../redis-4.0.2/redis.conf  redis06.conf

修改每个conf配置文件:
修改端口号  分别为7001 7002 7003 7004 7005 7006
改 bind  bind 0.0.0.0
改 守护线程   daemonize yes
改公网可访问  protected-mode no
允许集群:cluster-enable yes

2.3防火墙放行:
vi /etc/sysconfig/iptables
加入如下脚本:
-A INPUT -m state –state NEW -m tcp -p tcp –dport 7001 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 7002 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 7003 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 7004 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 7005 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 7006 -j ACCEPT

ps:如果跨服务器需要开通开启集群总线端口:  所有端口+10000
-A INPUT -m state –state NEW -m tcp -p tcp –dport 17001 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 17002 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 17003 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 17004 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 17005 -j ACCEPT
-A INPUT -m state –state NEW -m tcp -p tcp –dport 17006 -j ACCEPT
然后重启一下防火墙
service iptables restart

2.4编写启停脚本:
批量启动:
startall.sh

##!/bin/bash
clusterpath="/usr/local/redis/redis-cluster/"
cd $clusterpath
redis_arr=(01 02 03 04 05 06)
for num in ${redis_arr[@]}
do
    echo "start ${num}nth redis server…"
    cd redis${num}/
    redis-server ../conf/redis${num}.conf
    cd ..

done

批量终止:
stopall.sh

#!/bin/bash
pid_name="redis"
echo "杀死的进程名称:" $pid_name

pid=$(ps -ef|grep $pid_name|grep -v grep|awk '{print $2}')

echo "pid列表:" $pid

for item in $pid
do
echo  "杀死进程pid=" $item
kill -9  $item
done

showall.sh

ps aux | grep redis

给他们赋权可执行: 
赋权脚本:
chmod +x startall.sh
chmod +x stopall.sh
chmod +x showall.sh

启动所有服务:
./startall.sh
检查是否成功:

./showall.sh

 

2.5安装ruby 环境
yum install ruby

装好以后测试一下
ruby -v
ruby 1.8.7 (2012-06-29 patchlevel 370) [i386-linux]
ok 已经变成1.8.7 了

2.6安装rubygems
yum install rubygems
gem 查看是否安装成功

2.7安装redis集群需要的包
gem install redis -v 3.3.3  
2.8.创建集群
ruby redis01/src/redis-trib.rb create –replicas 1 192.168.189.131:7001 192.168.189.131:7002 192.168.189.131:7003 192.168.189.131:7004 192.168.189.131:7005  192.168.189.131:7006

输入yes

2.9测试连接: telnet 192.168.189.131 7002

已连入集群的服务器如果想重新搭建集群:
1.清空所有实例数据
2.删除实例跟目录下node.conf文件 
重新执行2.8即可

 

 

Published by

风君子

独自遨游何稽首 揭天掀地慰生平

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注