工具和中间件——redis,从底层原理到开发实践

目录

一、前言

二、redis基础知识

2.1 从“处理器-缓存-内存”到“后台-redis-数据库”

2.2 不使用缓存与使用缓存(读操作+写操作)   

2.3 redis典型问题:缓存穿透、缓存雪崩和缓存击穿(以淘宝双11抢购为例)

2.3.1 缓存穿透,不存在的商品X

2.3.2 缓存雪崩,双十一抢购

2.3.3 缓存击穿,iphoneX上市了

三、redis五种类型的底层原理

3.1 redisObject对象(类型type+编码encoding)和sds(free+len+buf)

3.1.1 redisObject对象

3.1.2 类型type

3.1.3 编码encoding

3.1.4 sds(这个很重要,下面会用到)

3.2 字符串对象string

3.2.1 int编码

3.2.2 raw编码

3.2.3 embstr编码

3.3 列表对象list

3.3.1 ziplist编码

3.3.2 linkedlist编码

3.4 哈希对象hash(map)

3.4.1 ziplist编码

3.4.2 hashtable编码

3.5 集合对象set

3.5.1 intset编码

3.5.2 hashtable编码

3.6 有序集合对象sortedset

3.6.1 ziplist编码

3.6.2 skiplist编码

3.7 小结(五种基本类型的底层结构)

四、单机版redis及Java开发实践

4.1 单机版Redis安装

4.2 Java项目中使用redis

4.2.1 Junit测试使用Jedis单连接redis

4.2.2 进阶——Junit测试使用连接池连接redis

4.2.3 再次进阶——在spring中使用redis

4.4 小结(redis单机版)

五、集群版redis及Java开发实践

5.1 redis集群原理

5.1.1 Redis集群架构图

5.1.2  redis-cluster投票:容错

5.2 集群版redis(redis-cluster)安装

5.2.1 手把手搭建ruby环境

5.2.2 新建集群所需结点(6个)

5.2.3 启动所有redis结点(6个)

5.2.4 将6个redis结点集成

5.2.5 查询集群信息(结点信息+状态信息)

5.3 Java使用redis集群、Spring容器使用redis集群(jedisCluster)

5.3.1 Java使用Junit测试方法

5.3.2 Spring使用redis集群 

5.4 附加部分:关于centos上面的redis集群节点操作(包括:添加主节点、添加从节点、删除节点)

5.4.1 添加主节点

5.4.2 添加从节点

5.4.3 删除节点

5.5 小结(redis集群)

六、小结


一、前言

redis引入,什么是redis?

Redis 是一个开源的、使用ANSI C编写的、支持网络、基于内存的、可持久化的Key-Value 型的数据库,通过提供多种键值数据类型(5种基本数据类型 string hash list set sortedset)来适应不同场景下的存储需求,并且提供多种语言的API(当然包括Java语言的API)。redis官网如图:

 

理清几个易混淆的概念

SQL:全称为Structured Query Language,译为结构化查询语言,是一种计算机程序语言,一种解释型语言。

NoSQL:全称Not Only SQL,译为"不仅仅是SQL"(注意,NoSQL不是不使用SQL的意思),泛指所有的非关系型数据库。

关系型数据库:即RDB,全称为Relational Database,其实,一个更加常见的英文简称是RDBMS,Relational Database Management System,关系型数据库管理系统,所以,RDBMS就被认为是关系型数据库的简称。

非关系型数据库:不使用数据库表结构存储数据,用NoSQL表示。

相互对比辨析相近概念

SQL与关系型数据库:SQL是SQL,关系型数据库是关系型数据库,两者是完全不同的两个东西,SQL是一种解释型语言,关系型数据库是数据库的一种类型,两者的关系是关系型数据库的CRUD操作使用SQL语言来完成,SQL语言被认为是关系型数据库的一种特征。

NoSQL与非关系型数据库:对于程序员的工作中,NoSQL就是指非关系型数据库,即NoSQL==非关系型数据库,两者是同一个东西。

SQL与NoSQL:SQL是一种语言,NoSQL表示非关系型数据库,一个是语言,一个是数据库,两个不同关系。

关系型数据库与非关系型数据库:且看下表

  关系型数据库RDBMS 非关系型数据库NoSQL
存储格式支持 数据库表结构

不使用数据库表结构存储,包括列存储、文档存储、key-value存储、图存储、对象存储、xml存储

特点 高度组织化结构化数据
结构化查询语言(SQL) (SQL)
数据和关系都存储在单独的表中。
数据操纵语言,数据定义语言
严格的一致性
基础事务
代表着不仅仅是SQL
没有声明性查询语言
没有预定义的模式
-键 - 值对存储,列存储,文档存储,图形数据库
最终一致性,而非ACID属性
非结构化和不可预知的数据
CAP定理
高性能,高可用性和可伸缩性
设计原则

ACID

A (Atomicity) 原子性、C (Consistency) 一致性、I (Isolation) 独立性、D (Durability) 持久性

表示任何一个关系型数据库(使用表格式存储的数据库)必须同时满足四个特性要求

BASE原则(同时满足CAP中的CA)

Basically Availble --基本可用

Soft-state --软状态/柔性事务。 "Soft state" 可以理解为"无连接"的, 而 "Hard state" 是"面向连接"的

Eventual Consistency -- 最终一致性, 也是是 ACID 的最终目的。

分类

Mysql sqlserver oracle

1)列存储:按列存储数据的,如Hbase、Cassandra、Hypertable

2)文档存储:用类似json的格式存储,存储的内容是文档型的,如MongoDB、CouchDB

3)key-value存储:Tokyo Cabinet / Tyrant

、Berkeley DB、MemcacheDB、Redis

4)图存储:图形关系的最佳存储,如Neo4J、FlockDB

5)对象存储:通过对象的方式存取数据,如db4o、Versant

6)xml存储:存储XML数据,如Berkeley DB XML、BaseX

由上表可知,Redis是一种使用key-value键值对来存储数据的非关系型数据库

redis与NoSQL的关系:NoSQL可以表示非关系型数据库,redis一种使用key-value键值对存储的非关系型数据库,这就是两者的关系。

本文主要包括四个部分的内容,包括redis基础知识、redis底层原理、单机版redis及Java开发实践、集群版redis及Java开发实践。

二、redis基础知识

既然Redis是一种使用key-value键值对来存储数据的非关系型数据库,我们先来介绍这种非关系型数据库的基础知识。

2.1 从“处理器-缓存-内存”到“后台-redis-数据库”

回顾学生年代《计算机组成原理》,由于处理器CPU与内存的速度不匹配问题,所有我们在处理器和内存之间加一个高速缓存,

缓存的数据是主存中热点数据的副本,处理器读取数据时,优先读取缓存中的数据,缓存中没有,再到主存中取,同时这个数据成为热点数据,写入到缓存中,下次处理器直接从缓存中取。

对于写操作,为了保证主存缓存中数据一致性问题,有“写直达法”和“写回法”两种方式。

整个架构变化如图:

工作中,web项目开发,由于网络请求与数据库查询数据不匹配问题,所以我们在后台程序与数据库之间加一个redis/redis-cluster缓存,其读写操作与计算机的存储一样,优先读写redis缓存,整个架构变化如下:

这里用硬件对比软件后台,用高速缓存cache对比redis/redis-cluster,两者基本上是一样的,唯一不同的恐怕就是Cache是硬件,redis是软件。

2.2 不使用缓存与使用缓存(读操作+写操作)   

在介绍redis读写之前,引入一个知识,redis支持的数据类型(我们起码要知道读写操作,读写的是什么)

redis支持五种数据类型

目前为止Redis支持的键值数据类型一共五种,如下:String字符串类型、hash散列类型、list列表类型、set集合类型、sorted set有序集合类型。

不使用缓存读

不使用缓存写

使用缓存读

使用缓存写(先更新数据库,再更新redis缓存,类似写直达法)

使用缓存写(先更新redis缓存,再更新数据库,类似写回法)

2.3 redis典型问题:缓存穿透、缓存雪崩和缓存击穿(以淘宝双11抢购为例)

2.3.1 缓存穿透,不存在的商品X

从名称上来解释含义:传统意义上的穿透,即水滴石穿,滴水能把石穿透,就是说水滴穿透石头的整个过程。

这里的缓存穿透,是指网络请求查询一个数据库一定不存在的数据(假设为-1,一个无意义数字)。因为这是一个数据库中绝对不存在的数据,所有redis缓存中也一定不存在,执行过程中,因为redis中一定找不到,所有一定会去数据库中找,结果就是数据库也找不到。因为这个网络请求查询过程是 “前端/客户端/移动端---网络请求---redis缓存---数据库” ,整个过程穿透redis,直达数据库,与水滴石穿有类似之意,所以称为缓存穿透。

正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存(这是重点,查不到就不进入缓存,所以第二次请求同样的数据还是要查询数据库)。

缓存穿透的问题再哪里?在于它每次都要请求数据库,redis缓存形同虚设,起不到减少数据库查询、提升性能的作用。

我们知道,每一次查询数据库的代价是比较大的(所以我们引用了redis缓存),因为请求的是一个数据库一定不存在的数据,所有每一次都要查数据库,而且因为数据库查询为空,这次的数据也不会放入缓存,下一次还是查询这个数据又要到数据库中查询,不断循环,一个不存在的数据多次请求就可以让后台系统崩溃。

假如有恶意攻击,就可以利用这个漏洞(网络请求一个数据库中一定不存在的数据,不断请求),对数据库造成压力,甚至压垮数据库。即便是采用UUID,也是很容易找到一个不存在的KEY,进行攻击。

举例:  

解决方案:

思考:第一次请求redis中找不到,访问数据库不是什么大问题,后面N-1次都要访问数据库这就是个大问题了。核心在于:如果数据库查询对象为空,则不放进缓存。这是默认规则,如果能消除这条规则,即数据库查询为空也写入redis,后面进直接从redis中取,取不到就结束(因为数据库和redis已经同步了)。

解决:会采用缓存空值null的方式,如果从数据库查询的对象为空,也放入缓存,即将null放入缓存,将key:value=(x,null)写入redis,下一次查询key=x,直接在redis中返回value=null.

2.3.2 缓存雪崩,双十一抢购

从名称上来解释含义:传统意义上的雪崩就是指一种当山坡积雪内部的内聚力抗拒不了它所受到的重力拉引时,便向下滑动,引起大量雪体崩塌的自然现象。

雪崩之所以可怕,是因为其规模之大,局部雪崩可能引起全局雪崩,一次严重的雪崩可能造成整座雪山的崩塌。这里的缓存雪崩是指在某一个时间段,缓存集中过期失效,造成整个redis不可用(对应整座雪山崩塌)

关于缓存雪崩,粗体标记,注意两个词语,一是“集中”,二是“过期”,一是集中失效,二是过期失效

关于集中:redis默认有16个库,db0~db15,“集中”表示redis缓存中的大部分库是失效了

关于过期:表示redis缓存雪崩中多个库是由于缓存时间到期而失效的

缓存雪崩就是缓存失效,“集中”告诉我们是大部分库失效了,不是小部分或者个别;“过期”是指这种库失效是由于缓存时间到期而失效的(即是正常的失效),不是异常错误导致库失效

举例:产生雪崩的原因之一,以淘宝双十一抢购为例,假设淘宝后台将热门商品放入redis/redis-cluster(像淘宝这么大的肯定是redis-cluster redis集群喽),设置缓存时间为一小时(当然淘宝系统不会如此愚蠢,这里是假设,皮),那么午夜12点开始抢购,到了午夜1点,所有的热门商品的缓存都过期了,如果用户再购买商品,后台就是读写数据库(而不是直接读写redis)了,这样造成访问速度慢,带来无法容忍的用户体验。如图:

这样的缓存集体过期就是缓存雪崩,是使用缓存的一种危险,开发者一定要记住。

解决:

思考方式一:如果发生了集体缓存过期,即缓存雪崩,是非常可怕且很难挽救的,在发生后的一段时间内相当于没有使用redis缓存技术,退化为原始的持久层数据库操作。所以,我们的思考不是发生缓存雪崩之后如何解决,而是如何避免缓存雪崩的发生。

解决方案一——过期错开:将key的过期时间后面加上一个随机数,让key均匀的失效;或者使用一种特定的算法,使过期时间赋值更符合实际业务。

思考方式二:第一种方案均摊过期时间或使用特定算法,旨在最大程度在避免出现缓存雪崩,但是如果缓存雪崩确实发生了,程序如何应对呢?

解决方案二——排队处理:使用优先队列或者让程序执行在压力范围之内,如果访问量达到阈值,排队处理业务请求,即为了保证系统的不会崩溃,不要同时处理所有请求。

2.3.3 缓存击穿,iphoneX上市了

从名称上来解释含义:传统意义上的击穿(电压击穿)是指在电场作用下绝缘体内部产生破坏性的放电,绝缘电阻下降,电流增大,并产生破坏和穿孔的现象。

这里的缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存(类似电压穿破绝缘体),直接请求数据库,就像在一个屏障上凿开了一个洞。

缓存穿透与缓存击穿异同:

相同点:都是数据库承受不了巨大压力,导致崩溃。

不同点:

缓存穿透是指redis没作用,形同虚设,每一次访问都要查询数据库,导致崩溃,这是技术上可以解决的,redis中记录一个(x,null)键值对;

缓存击穿是指redis作用了,但是数据量实在是太大了,实在是承受不了这么大的数据量,数据库连带redis缓存一起崩溃,这时在固定的硬件成本下,缓存、数据库软件方面已经达到理论上的最优了,技术上解决不了。

举例:以iphoneX发布为例,一下子就成了热款,所有人通过淘宝线上购买,巨大的并发量某一时刻击穿缓存,直接请求数据库,而数据库又无法高速查表,导致系统崩溃。如图:

解决方案

思考:现在的问题是数据量实在太大了,redis和数据库的设计已经达到最优了。

步骤一:热卖商品redis有效期设置为永久,绝对不要出现过期问题,redis方面达到最优。

步骤二:在固定的硬件成本下,数据库(mysql或oracle)在表设计达到最优,框架(如mybatis)sql查询语句设计达到最优

步骤三:设置一个优先队列(如12306 买春运往返票),控制并发数,防止系统崩溃。

实际上,其实,大多数情况下这种爆款很难对数据库服务器造成压垮性的压力,能达到这种并发的可能也只有“12306春运购票”、“淘宝双十一” 、“春晚跨年”这样的事情了,从另外一个方面来讲,如果真的有某个单一商品销售量达到使用让redis、数据库崩溃,公司钱也赚了不少了,赶紧偷着笑吧!

三、redis五种类型的底层原理

3.1 redisObject对象(类型type+编码encoding)和sds(free+len+buf)

这个很重要,要看懂后面五个类型的底层结构,要先搞懂redisObjet和sds(sdshdr)的结构

3.1.1 redisObject对象

Redis基于以上的数据结构创建了一个对象体系,包含了字符串对象,列表对象,哈希对象,集合对象,有序集合对象这五种对象. 

Redis的对象体系还实现了基于引用计数技术的内存回收机制,同时基于引用计数技术实现了对象共享机制,在适当条件,通过多个数据库键共享同一个对象来节约内存.

Redis中的每一个对象都由一个redisObject结构表示,这个redisObject对象结构中和保存数据有关的三个属性:type属性、encoding属性、ptr属性,如下:

typedef struct redisObject {
    //类型
    unsigned type:4;
    
    //编码
    unsigned encoding:4;

    //指向底层实现数据结构的指针
    void *ptr;

    // ...
} robj;

下面分别对类型type、编码encoding、指针ptr分别介绍:

3.1.2 类型type

类型常量 对象名称 TYPE命令输出
REDIS_STRING 字符串对象string "string"
REDIS_LIST 列表对象list "list"
REDIS_HASH 哈希对象hash(map) "hash"
REDIS_SET 集合对象set "set"
REDIS_ZSET 有序集合对象sorted-set "zset"

注意,看这个表,一定要区分好“类型常量”、“对象名称”,如下:

1)当我们称呼一个数据库键为“字符串键”时,我们指的是“这个数据库键对应的值为字符串对象”;当我们称呼一个数据库键为“列表键”时,我们指的是“这个数据库键对应的值为列表对象”;

2)TYPE命令输出(上表第三列):当我们对一个数据库键执行TYPE命令时,命令返回的结果是数据库键对应的值对象的类型,而不是键对象的类型。

其实,这些东西都是一些概念理论上的纠结,实际开发中,我们以实现需求为主,也不一定要区分的这么清楚,当然面试中可能用得到。

3.1.3 编码encoding

编码常量 编码对应的底层数据结构 redis中具体类型(5种)

OBJECT ENCODING

命令输出

REDIS_ENCODING_INT long类型整数(编码常量后缀是INT,但是其实现的底层数据结构是long) REDIS_STRING "int"
REDIS_ENCODING_EMBSTR embstr编码的简单动态字符串(SDS simple dynamic string) REDIS_STRING "embstr"
REDIS_ENCODING_RAW 简单动态字符串(SDS simple dynamic string) REDIS_STRING "raw"
REDIS_ENCODING_HT 字典(编码常量后缀为HT,表示dictionary/hashtable,即字典) REDIS_HASH、REDIS_SET "hashtable"
REDIS_ENCODING_LINKEDLIST 双向链表/双端链表(linkedlist,见名达意,不解释) REDIS_LIST "linkedlist"
REDIS_ENCODING_ZIPLIST 压缩列表(ziplist,见名达意,不解释)

REDIS_LIST、

REDIS_HASH、REDIS_ZSET

"ziplist"
REDIS_ENCODING_INTSET 整型集合(intset,见名达意,不解释) REDIS_SET "intset"
REDIS_ENCODING_SKIPLIST 跳跃表和字典(skiplist,见名达意,不解释) REDIS_ZSET "skiplist"

3.1.4 sds(这个很重要,下面会用到)

sds英文全称 simple dynamic string,这里是简单动态字符串

struct sdshdr{
// 记录buf数组中未使用字节的数量
int free;
// 记录buf数组中已使用字节的数量,等于sds所保存字符串的长度
int len;
// 字节数组,用于保存字符串
char buff[];
}

结构(下面介绍五种基本类型底层结构会用到):

free:0  表示这个sds没有分配任何未使用空间;
len:5 表示这个sds保存了一个5个字节的字符串;
buf:Hello 表示一个char类型的数组,数组的前五个字节分别保存了‘H’‘e’‘l’‘l’‘o’,最后一个字符保存了空字符‘\0’.

3.2 字符串对象string

由上面的编码表可以知道,字符串编码包括三种 int raw embstr,三者对比:

如果保存的是整数值,且可用long类型表示,那么编码设为int;

如果保存的是一个字符串,并且长度大于32字节,那么使用SDS(simple dynamic string,简单动态字符串)保存,编码设为raw;

如果保存的是一个字符串,并且长度小于或等于32字节,那么编码设为embstr;

3.2.1 int编码

对于这个图的解释:
redisObject:  
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为string
encoding,redis任何一种基本类型至少有两种编码,这里是int
ptr:指针,指向底层数据结构的指针,这里指向底层long类型数据7758258

3.2.2 raw编码

对于这个图的解释:
redisObject:  分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为string
encoding,redis任何一种基本类型至少有两种编码,这里是raw
ptr:指针,指向底层数据结构的指针,这里指向sdshdr
sdshdr :
sds simple dynamic string,简单动态字符串 ; hdr High available Data Replication,高可用性复制 ; 合在一起 sds hdr  简单动态字符串高可用性复制,包括三个 free 表示
free:0  表示这个sds没有分配任何未使用空间;
len:36表示这个sds保存了一个36个字节的字符串;
buf:Hello 表示一个char类型的数组,数组的前36个字节分别保存了'H’‘e’‘l’‘l’‘o’‘  ’‘w’‘o’‘r’‘l’‘d’'H’‘e’‘l’‘l’‘o’‘  ’‘w’‘o’‘r’‘l’‘d’'H’‘e’‘l’‘l’‘o’‘  ’‘w’‘o’‘r’‘l’‘d’'.’‘.’‘.’,
最后一个字符保存了空字符‘\0’.

3.2.3 embstr编码

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为string
encoding,redis任何一种基本类型至少有两种编码,这里是embstr
ptr:指针,指向底层数据结构的指针,这里指向sdshdr
sdshdr :
sds simple dynamic string,简单动态字符串
hdr High available Data Replication,高可用性复制
合在一起 sds hdr  简单动态字符串高可用性复制,包括三个 free 表示
free:0  表示这个sds没有分配任何未使用空间;
len:5 表示这个sds保存了一个5个字节的字符串;
buf:Hello 表示一个char类型的数组,数组的前五个字节分别保存了‘H’‘e’‘l’‘l’‘o’,最后一个字符保存了空字符‘\0’.

最后点一下,用long double类型表示的浮点数在redis中也是字符串来表示的,了解即可。

3.3 列表对象list

由上面的编码表可以知道,字符串编码包括两种:linkedlist  ziplist

3.3.1 ziplist编码

zlbytes:表示的是总长度,总字节数

zllen:表示的是数据部分的长度

两个不一样的。

3.3.2 linkedlist编码

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为list
encoding,redis任何一种基本类型至少有两种编码,这里是linkedlist
ptr:指针,指向底层数据结构的指针

1                            表示ziplist第一个元素
“three”            表示ziplist第二个元素
5                             表示ziplist第三个元素

3.4 哈希对象hash(map)

由上面的编码表可以知道,字符串编码包括两种:ziplist  hashtable

3.4.1 ziplist编码

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为list
encoding,redis任何一种基本类型至少有两种编码,这里是ziplist
ptr:指针,指向底层数据结构的指针
zlbytes:  表示整个ziplist的字节数(总长度)
zltail: 表示整个ziplist的头部
zllen: 表示整个ziplist 数据部分的长度
(key,value)=(name,Tom)           表示ziplist第一个元素
(key,value)=(age,25)         表示ziplist第二个元素
(key,value)=(career,Programmer)           表示ziplist第三个元素
zlled 表示整个ziplist的尾部

3.4.2 hashtable编码

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为list
encoding,redis任何一种基本类型至少有两种编码,这里是hashtable
ptr:指针,指向底层数据结构的指针

3.5 集合对象set

由上面的编码表可以知道,字符串编码包括两种:intset hashtable

3.5.1 intset编码

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为set
encoding,redis任何一种基本类型至少有两种编码,这里是intset
ptr:指针,指向底层数据结构的指针

3.5.2 hashtable编码

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为set
encoding,redis任何一种基本类型至少有两种编码,这里是hashtable
ptr:指针,指向底层数据结构的指针

3.6 有序集合对象sortedset

由上面的编码表可以知道,字符串编码包括两种:ziplist skiplist

3.6.1 ziplist编码

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

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为sorted set
encoding,redis任何一种基本类型至少有两种编码,这里是ziplist
ptr:指针,指向底层数据结构的指针
zlbytes:  表示整个ziplist的字节数(总长度)
zltail: 表示整个ziplist的头部
zllen: 表示整个ziplist 数据部分的长度
(key,value)=(apple,8.5)           表示ziplist第一个元素
(key,value)=(banana,5.0)         表示ziplist第二个元素
(key,value)=(cherry,6.0)           表示ziplist第三个元素
zlled 表示整个ziplist的尾部

3.6.2 skiplist编码

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

typedef struct zset{
dict *dict;                // 字典dict
zskiplist  *zsl;           // 跳跃表zsl
}zset;   //zset是有序集合,同时由字典dict和跳跃表zsl实现

为什么有序集合zset(sorted set)要同时由字典dict和跳跃表实现?

跳跃表利于执行范围操作(跳跃表是排好序的),而字典有利于执行分值查找操作。同时由于Redis里的跳跃表和字典元素很多都是用指针实现的,所以不会浪费内存。

对于这个图的解释:
redisObject:
分为三个 类型type 编码encoding 指针ptr
type为五种基本类型,这里为sorted set
encoding,redis任何一种基本类型至少有两种编码,这里是skiplist
ptr:指针,指向底层数据结构的指针

zset   表示sorted set 实体
dict  表示字典
zsl   表示sorted skiplist 跳跃表
……  表示跳跃
(key,value)=(apple,8.5)           表示ziplist第一个元素
(key,value)=(banana,5.0)         表示ziplist第二个元素
(key,value)=(cherry,6.0)           表示ziplist第三个元素

3.7 小结(五种基本类型的底层结构)

对于redis的五种基本类型(string hash list set sortedset),是redis最基本的知识,本文第三部分介绍这五种基本类型的底层实现,给读者一个进一步理解的空间。

四、单机版redis及Java开发实践

4.1 单机版Redis安装

步骤一:安装相关依赖。

yum install gcc-c++

步骤二:官网下载redis源码包、解压

官网下载redis源码包

解压:tar -zxvf redis-3.0.0.tar.gz

步骤三:编译和安装

解压后,进入解压目录,编译安装

[root@bogon redis-3.0.0]# make 

[root@bogon redis-3.0.0]# make install PREFIX=/usr/local/redis   (要安装的目录 PREFIX一定要大写)

步骤四:启动(进入刚刚安装位置启动)

cd  /usr/local/redis/bin

./redis-server       (启动)

成功标志:

看到这个图片为启动成功。

附:redis两种启动方式  (前端启动+后端启动)

1、默认启动方式为前端启动

cd /usr/local/redis/bin/     (这里指进入redis安装目录,bin目录下)

./redis-server                   (启动)

默认是前端启动模式,端口是6379

2、后端启动

(1)从redis的解压目录中复制redis.conf到redis的安装目录(本文中/usr/local/redis)

(2)修改安装目录下刚刚复制过来的配置文件:

         daemonize yes    (表示使用后端模式启动)

(3)[root@bogon bin]# ./redis-server redis.conf  启动

单机版redis安装完成。

步骤五:测试使用(centos上测试使用)

127.0.0.1:6379> set a 10

OK

127.0.0.1:6379> get a

"10"

可以看到,redis可以正常使用。

4.2 Java项目中使用redis

4.2.1 Junit测试使用Jedis单连接redis

导入依赖

<dependency>

     <groupId>redis.clients</groupId>

     <artifactId>jedis</artifactId>

     <version>2.7.0</version>

</dependency>

test使用

​
​
​
    // Junit测试连接redis和基本的set-get操作
	@Test
	public void testJedisSingle() {

		Jedis jedis = new Jedis("192.168.101.3", 6379);    
        //这里表示centos IP为192.168.101.3
		jedis.set("name", "bar");
		String name = jedis.get("name");
		System.out.println(name);
		jedis.close();

	}

​

​

​

附:如果centos上的redis正常启动而且测试可以完成set-get基本操作,但是本地无法连接上centos上的redis,查看防火墙是否关闭。正确的操作是关闭防火墙或开启6379号端口。centos7.0防火墙操作如下:

 

使用命令:systemctl status firewalld.service查看防火墙状态‘’

使用命令:systemctl stop firewalld.service 关闭防火墙

使用命令:systemctl start firewalld.service 开启防火墙

 

添加
firewall-cmd --zone=public --add-port=6379/tcp --permanent    (--permanent永久生效,没有此参数重启后失效)
重新载入
firewall-cmd --reload
查看
firewall-cmd --zone= public --query-port=6379/tcp

注:关闭防火墙和打开6379号端口只要两者选一即可。

4.2.2 进阶——Junit测试使用连接池连接redis

通过单实例连接redis不能对redis连接进行共享,可以使用连接池对redis连接进行共享,提高资源利用率,使用jedisPool连接redis服务,如下代码:

​
​
@Test
	public void pool() {
		JedisPoolConfig config = new JedisPoolConfig();
		//最大连接数
		config.setMaxTotal(30);
		//最大连接空闲数
		config.setMaxIdle(2);
		
		JedisPool pool = new JedisPool(config, "192.168.101.3", 6379);
		Jedis jedis = null;

		try  {
			jedis = pool.getResource();
			
			jedis.set("name", "lisi");
			String name = jedis.get("name");
			System.out.println(name);
		}catch(Exception ex){
			ex.printStackTrace();
		}finally{
			if(jedis != null){
				//关闭连接
				jedis.close();
			}
		}
		
	}

​

​

4.2.3 再次进阶——在spring中使用redis

上面的都是使用Junit的测试代码,不是项目中真正运行有效代码,这里介绍spring中整合redis

配置spring配置文件applicationContext.xml

​
​
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:mvc="http://www.springframework.org/schema/mvc"
	xmlns:context="http://www.springframework.org/schema/context"
	xmlns:aop="http://www.springframework.org/schema/aop" xmlns:tx="http://www.springframework.org/schema/tx"
	xsi:schemaLocation="http://www.springframework.org/schema/beans 
		http://www.springframework.org/schema/beans/spring-beans-3.2.xsd 
		http://www.springframework.org/schema/mvc 
		http://www.springframework.org/schema/mvc/spring-mvc-3.2.xsd 
		http://www.springframework.org/schema/context 
		http://www.springframework.org/schema/context/spring-context-3.2.xsd 
		http://www.springframework.org/schema/aop 
		http://www.springframework.org/schema/aop/spring-aop-3.2.xsd 
		http://www.springframework.org/schema/tx 
		http://www.springframework.org/schema/tx/spring-tx-3.2.xsd ">

<!-- 连接池配置 -->
	<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
		<!-- 最大连接数 -->
		<property name="maxTotal" value="30" />
		<!-- 最大空闲连接数 -->
		<property name="maxIdle" value="10" />
		<!-- 每次释放连接的最大数目 -->
		<property name="numTestsPerEvictionRun" value="1024" />
		<!-- 释放连接的扫描间隔(毫秒) -->
		<property name="timeBetweenEvictionRunsMillis" value="30000" />
		<!-- 连接最小空闲时间 -->
		<property name="minEvictableIdleTimeMillis" value="1800000" />
		<!-- 连接空闲多久后释放, 当空闲时间>该值 且 空闲连接>最大空闲连接数 时直接释放 -->
		<property name="softMinEvictableIdleTimeMillis" value="10000" />
		<!-- 获取连接时的最大等待毫秒数,小于零:阻塞不确定的时间,默认-1 -->
		<property name="maxWaitMillis" value="1500" />
		<!-- 在获取连接的时候检查有效性, 默认false -->
		<property name="testOnBorrow" value="true" />
		<!-- 在空闲时检查有效性, 默认false -->
		<property name="testWhileIdle" value="true" />
		<!-- 连接耗尽时是否阻塞, false报异常,ture阻塞直到超时, 默认true -->
		<property name="blockWhenExhausted" value="false" />
	</bean>
	
	<!-- redis单机 通过连接池 -->
	<bean id="jedisPool" class="redis.clients.jedis.JedisPool" destroy-method="close">
		<constructor-arg name="poolConfig" ref="jedisPoolConfig"/>
		<constructor-arg name="host" value="192.168.101.3"/>
		<constructor-arg name="port" value="6379"/>
	</bean>

​

​

测试代码:

private ApplicationContext applicationContext;

	@Before
	public void init() {
		applicationContext = new ClassPathXmlApplicationContext(
				"classpath:applicationContext.xml");
	}

	@Test
	public void testJedisPool() {
	JedisPool pool = (JedisPool) applicationContext.getBean("jedisPool");
			try  {
			jedis = pool.getResource();
			
			jedis.set("name", "lisi");
			String name = jedis.get("name");
			System.out.println(name);
		}catch(Exception ex){
			ex.printStackTrace();
		}finally{
			if(jedis != null){
				//关闭连接
				jedis.close();
			}
		}
	}

4.4 小结(redis单机版)

redis单机版使用起来的并不难,下载对应版本,在centos上安装好,设置好防火墙和端口,即可使用jedis连接使用。

五、集群版redis及Java开发实践

5.1 redis集群原理

5.1.1 Redis集群架构图

redis集群架构细节:

(1)所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽.

(2)节点的fail是通过集群中超过半数的节点检测失效时才生效.

(3)客户端与redis节点直连,不需要中间proxy层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可

(4)redis-cluster把所有的物理节点映射到[0-16383]slot上,cluster 负责维护node<->slot<->value

Redis 集群中内置了 16384 个哈希槽,当需要在 Redis 集群中放置一个 key-value 时,redis 先对 key 使用 crc16 算法算出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点

5.1.2  redis-cluster投票:容错

redis集群的容错机制——相关问题:

(1)领着投票过程是集群中所有master参与,如果半数以上master节点与master节点通信超过(cluster-node-timeout),认为当前master节点挂掉.

(2):什么时候整个集群不可用(cluster_state:fail)? 

    a:如果集群任意master挂掉,且当前master没有slave.集群进入fail状态,也可以理解成集群的slot映射[0-16383]不完成时进入fail状态. ps : redis-3.0.0.rc1加入cluster-require-full-coverage参数,默认关闭,打开集群兼容部分失败.

    b:如果集群超过半数以上master挂掉,无论是否有slave集群进入fail状态.

  ps:当集群不可用时,所有对集群的操作做都不可用,收到((error) CLUSTERDOWN The cluster is down)错误

5.2 集群版redis(redis-cluster)安装

5.2.1 手把手搭建ruby环境

redis集群管理工具redis-trib.rb依赖ruby环境,故首先需要在centos上安装ruby环境:

安装ruby环境:

yum install ruby

yum install rubygems

安装好ruby环境后,安装ruby和redis的接口程序:

拷贝redis-3.0.0.gem至/usr/local下

执行:

gem install /usr/local/redis-3.0.0.gem

5.2.2 新建集群所需结点(6个)

这里在同一台服务器用不同的端口表示不同的redis服务器,如下:

主节点:192.168.101.3:7001 192.168.101.3:7002 192.168.101.3:7003

从节点:192.168.101.3:7004 192.168.101.3:7005 192.168.101.3:7006

在/usr/local下创建redis-cluster目录,其下创建7001、7002。。7006目录,命令如下:

cd /usr/local        (进入/usr/local目录)

mkdir redis-cluster     (创建redis-cluster目录)

cd redis-cluster         (进入redis-cluster目录)

mkdir 7001 7002 7003 7004 7005 7006       (创建7001 7002 7003 7004 7005 7006目录)

运行结果如下:

将redis安装目录bin下的文件拷贝到每个700X(一共7001~7006  6个)目录内,同时将redis源码目录src下的redis-trib.rb拷贝到redis-cluster目录下。

修改每个700X目录下的redis.conf配置文件:

port XXXX   (指定redis端口,但是记住,6个redis的端口不同,避免端口冲突)

#bind 192.168.101.3

cluster-enabled yes        (允许构成集群)

5.2.3 启动所有redis结点(6个)

这里使用后端启动,分别进入7001、7002、...7006目录,执行:

./redis-server ./redis.conf

然后查看6个是否都已经成功启动:

ps aux|grep redis

运行结果,如下则为6个都启动成功(7001~7006)(PS:ps aux|grep 进程名,查看进程启动情况,可以在任意目录下执行此句,不一定要在7006目录下) 

5.2.4 将6个redis结点集成

执行redis-trib.rb,此脚本是ruby脚本,它依赖ruby环境。

./redis-trib.rb create --replicas 1 192.168.101.3:7001 192.168.101.3:7002 192.168.101.3:7003 192.168.101.3:7004 192.168.101.3:7005  192.168.101.3:7006 

(嗯,你没有想错,6个redis创建一个redis集群只需要这仅仅一条命令)

输出(创建集群输出)如下:

>>> Creating cluster

Connecting to node 192.168.101.3:7001: OK

Connecting to node 192.168.101.3:7002: OK

Connecting to node 192.168.101.3:7003: OK

Connecting to node 192.168.101.3:7004: OK

Connecting to node 192.168.101.3:7005: OK

Connecting to node 192.168.101.3:7006: OK

>>> Performing hash slots allocation on 6 nodes...

Using 3 masters:

192.168.101.3:7001

192.168.101.3:7002

192.168.101.3:7003

Adding replica 192.168.101.3:7004 to 192.168.101.3:7001

Adding replica 192.168.101.3:7005 to 192.168.101.3:7002

Adding replica 192.168.101.3:7006 to 192.168.101.3:7003

M: cad9f7413ec6842c971dbcc2c48b4ca959eb5db4 192.168.101.3:7001

   slots:0-5460 (5461 slots) master

M: 4e7c2b02f0c4f4cfe306d6ad13e0cfee90bf5841 192.168.101.3:7002

   slots:5461-10922 (5462 slots) master

M: 1a8420896c3ff60b70c716e8480de8e50749ee65 192.168.101.3:7003

   slots:10923-16383 (5461 slots) master

S: 69d94b4963fd94f315fba2b9f12fae1278184fe8 192.168.101.3:7004

   replicates cad9f7413ec6842c971dbcc2c48b4ca959eb5db4

S: d2421a820cc23e17a01b597866fd0f750b698ac5 192.168.101.3:7005

   replicates 4e7c2b02f0c4f4cfe306d6ad13e0cfee90bf5841

S: 444e7bedbdfa40714ee55cd3086b8f0d5511fe54 192.168.101.3:7006

   replicates 1a8420896c3ff60b70c716e8480de8e50749ee65

Can I set the above configuration? (type 'yes' to accept): yes

>>> Nodes configuration updated

>>> Assign a different config epoch to each node

>>> Sending CLUSTER MEET messages to join the cluster

Waiting for the cluster to join...

>>> Performing Cluster Check (using node 192.168.101.3:7001)

M: cad9f7413ec6842c971dbcc2c48b4ca959eb5db4 192.168.101.3:7001

   slots:0-5460 (5461 slots) master

M: 4e7c2b02f0c4f4cfe306d6ad13e0cfee90bf5841 192.168.101.3:7002

   slots:5461-10922 (5462 slots) master

M: 1a8420896c3ff60b70c716e8480de8e50749ee65 192.168.101.3:7003

   slots:10923-16383 (5461 slots) master

M: 69d94b4963fd94f315fba2b9f12fae1278184fe8 192.168.101.3:7004

   slots: (0 slots) master

   replicates cad9f7413ec6842c971dbcc2c48b4ca959eb5db4

M: d2421a820cc23e17a01b597866fd0f750b698ac5 192.168.101.3:7005

   slots: (0 slots) master

   replicates 4e7c2b02f0c4f4cfe306d6ad13e0cfee90bf5841

M: 444e7bedbdfa40714ee55cd3086b8f0d5511fe54 192.168.101.3:7006

   slots: (0 slots) master

   replicates 1a8420896c3ff60b70c716e8480de8e50749ee65

[OK] All nodes agree about slots configuration.

>>> Check for open slots...

>>> Check slots coverage...

[OK] All 16384 slots covered.

附加说明1:语句中replicas指定为1表示每个主节点有一个从节点。实际上,任何一个redis集群至少需要3个主节点,这里因为一共6个结点,拿出3个主节点后,所以3个从节点,每一个主节点有一个从节点。

附加说明2:

若执行时报如下错误:

[ERR] Node XXXXXX is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0

解决方法是删除生成的配置文件nodes.conf,如果不行则说明现在创建的结点包括了旧集群的结点信息,需要删除redis的持久化文件后再重启redis,比如:appendonly.aof、dump.rdb

5.2.5 查询集群信息(结点信息+状态信息)

步骤一:集群创建成功登陆任意redis结点查询集群中的节点情况。

客户端以集群方式登陆:./redis-cli -c -h 192.168.101.3 -p 7001

如下:

说明:

./redis-cli -c -h 192.168.101.3 -p 7001 ,其中-c表示以集群方式连接redis,-h指定ip地址,-p指定端口号

登录之后,查询结点信息和状态信息:

cluster nodes 查询集群结点信息

cluster info 查询集群状态信息

5.3 Java使用redis集群、Spring容器使用redis集群(jedisCluster)

5.3.1 Java使用Junit测试方法

// 连接redis集群
	@Test
	public void testJedisCluster() {

		JedisPoolConfig config = new JedisPoolConfig();
		// 最大连接数
		config.setMaxTotal(30);
		// 最大连接空闲数
		config.setMaxIdle(2);

		//集群结点
		Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
		jedisClusterNode.add(new HostAndPort("192.168.101.3", 7001));
		jedisClusterNode.add(new HostAndPort("192.168.101.3", 7002));
		jedisClusterNode.add(new HostAndPort("192.168.101.3", 7003));
		jedisClusterNode.add(new HostAndPort("192.168.101.3", 7004));
		jedisClusterNode.add(new HostAndPort("192.168.101.3", 7005));
		jedisClusterNode.add(new HostAndPort("192.168.101.3", 7006));
		JedisCluster jc = new JedisCluster(jedisClusterNode, config);
		
		JedisCluster jcd = new JedisCluster(jedisClusterNode);
		jcd.set("name", "zhangsan");
		String value = jcd.get("name");
		System.out.println(value);
	}

5.3.2 Spring使用redis集群 

代码1——Spring配置文件applicationContext.xml配置:

<!-- 连接池配置 -->
	<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
		<!-- 最大连接数 -->
		<property name="maxTotal" value="30" />
		<!-- 最大空闲连接数 -->
		<property name="maxIdle" value="10" />
		<!-- 每次释放连接的最大数目 -->
		<property name="numTestsPerEvictionRun" value="1024" />
		<!-- 释放连接的扫描间隔(毫秒) -->
		<property name="timeBetweenEvictionRunsMillis" value="30000" />
		<!-- 连接最小空闲时间 -->
		<property name="minEvictableIdleTimeMillis" value="1800000" />
		<!-- 连接空闲多久后释放, 当空闲时间>该值 且 空闲连接>最大空闲连接数 时直接释放 -->
		<property name="softMinEvictableIdleTimeMillis" value="10000" />
		<!-- 获取连接时的最大等待毫秒数,小于零:阻塞不确定的时间,默认-1 -->
		<property name="maxWaitMillis" value="1500" />
		<!-- 在获取连接的时候检查有效性, 默认false -->
		<property name="testOnBorrow" value="true" />
		<!-- 在空闲时检查有效性, 默认false -->
		<property name="testWhileIdle" value="true" />
		<!-- 连接耗尽时是否阻塞, false报异常,ture阻塞直到超时, 默认true -->
		<property name="blockWhenExhausted" value="false" />
	</bean>	
	<!-- redis集群 -->
	<bean id="jedisCluster" class="redis.clients.jedis.JedisCluster">
		<constructor-arg index="0">
			<set>
				<bean class="redis.clients.jedis.HostAndPort">
					<constructor-arg index="0" value="192.168.101.3"></constructor-arg>
					<constructor-arg index="1" value="7001"></constructor-arg>
				</bean>
				<bean class="redis.clients.jedis.HostAndPort">
					<constructor-arg index="0" value="192.168.101.3"></constructor-arg>
					<constructor-arg index="1" value="7002"></constructor-arg>
				</bean>
				<bean class="redis.clients.jedis.HostAndPort">
					<constructor-arg index="0" value="192.168.101.3"></constructor-arg>
					<constructor-arg index="1" value="7003"></constructor-arg>
				</bean>
				<bean class="redis.clients.jedis.HostAndPort">
					<constructor-arg index="0" value="192.168.101.3"></constructor-arg>
					<constructor-arg index="1" value="7004"></constructor-arg>
				</bean>
				<bean class="redis.clients.jedis.HostAndPort">
					<constructor-arg index="0" value="192.168.101.3"></constructor-arg>
					<constructor-arg index="1" value="7005"></constructor-arg>
				</bean>
				<bean class="redis.clients.jedis.HostAndPort">
					<constructor-arg index="0" value="192.168.101.3"></constructor-arg>
					<constructor-arg index="1" value="7006"></constructor-arg>
				</bean>
			</set>
		</constructor-arg>
		<constructor-arg index="1" ref="jedisPoolConfig"></constructor-arg>
	</bean>

代码2——spring容器测试redis集群 

private ApplicationContext applicationContext;

	@Before
	public void init() {
		applicationContext = new ClassPathXmlApplicationContext(
				"classpath:applicationContext.xml");
	}

	//redis集群
	@Test
	public void testJedisCluster() {
	JedisCluster jedisCluster = (JedisCluster) applicationContext
					.getBean("jedisCluster");
			
			jedisCluster.set("name", "zhangsan");
			String value = jedisCluster.get("name");
			System.out.println(value);
	}

5.4 附加部分:关于centos上面的redis集群节点操作(包括:添加主节点、添加从节点、删除节点)

5.4.1 添加主节点

集群创建成功后可以向集群中添加节点,下面是添加一个master主节点

添加7007结点

执行下边命令:

./redis-trib.rb add-node  192.168.101.3:7007 192.168.101.3:7001

进入redis结点,查看集群结点发现7007已添加到集群中:

centosIP:redis端口号>cluster nodes

添加完主节点需要对主节点进行hash槽分配这样该主节才可以存储数据。

redis集群有16384个槽,集群中的每个结点分配自已槽,通过查看集群结点可以看到槽占用情况。

给刚添加的7007结点分配槽:

第一步:连接上集群

./redis-trib.rb reshard 192.168.101.3:7001(连接集群中任意一个可用结点都行)

第二步:输入要分配的槽数量

输入 500表示要分配500个槽

第三步:输入接收槽的结点id

这里准备给7007分配槽,通过cluster nodes查看7007结点id为15b809eadae88955e36bcdbb8144f61bbbaf38fb

输入:15b809eadae88955e36bcdbb8144f61bbbaf38fb

第四步:输入源结点id

这里输入all

第五步:输入yes开始移动槽到目标结点id

5.4.2 添加从节点

集群创建成功后可以向集群中添加节点,下面是添加一个slave从节点。

添加7008从结点,将7008作为7007的从结点。

./redis-trib.rb add-node --slave --master-id 主节点id 添加节点的ip和端口 集群中已存在节点ip和端口

执行如下命令:

./redis-trib.rb add-node --slave --master-id cad9f7413ec6842c971dbcc2c48b4ca959eb5db4  192.168.101.3:7008 192.168.101.3:7001

cad9f7413ec6842c971dbcc2c48b4ca959eb5db4  是7007结点的id,可通过cluster nodes查看。

注意:如果原来该结点在集群中的配置信息已经生成cluster-config-file指定的配置文件中(如果cluster-config-file没有指定则默认为nodes.conf),这时可能会报错:

[ERR] Node XXXXXX is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0

解决方法是删除生成的配置文件nodes.conf,删除后再执行./redis-trib.rb add-node指令

查看集群中的结点,刚添加的7008为7007的从节点:

5.4.3 删除节点

./redis-trib.rb del-node 127.0.0.1:7005 4b45eb75c8b428fbd77ab979b85080146a9bc017

删除已经占有hash槽的结点会失败,报错如下:

[ERR] Node 127.0.0.1:7005 is not empty! Reshard data away and try again.

需要将该结点占用的hash槽分配出去。

5.5 小结(redis集群)

本部分介绍redis集群的底层原理、redis集群安装、redis集群使用、附加部分,帮助读者充分理解redis-cluster原理及使用。

六、小结

本文主要介绍四个知识,包括redis基础知识、redis底层原理、单机版redis、集群版redis(redis-cluster),从基础知识、底层原理到开发实践,帮助读者全面学习redis。

天天打码,天天进步!

 

发布了183 篇原创文章 · 获赞 34 · 访问量 9万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览