redis


Redis

使用阿里云hit账号数据库,密码首字母大写

Redis诞生于2009年全称是Remote Dictionary Server 远程词典服务器,是一个基于内存的键值型NoSQL数据库。

特征

  • 键值(key-value)型,value支持多种不同数据结构,功能丰富
  • 单线程,每个命令具备原子性
  • 低延迟,速度快(基于内存、IO多路复用、良好的编码)。
  • 支持数据持久化
  • 支持主从集群、分片集群
  • 支持多语言客户端

认识NoSQL

Redis是一种键值型的NoSql数据库,这里有两个关键字:

  • 键值型

  • NoSql

其中键值型,是指Redis中存储的数据都是以key、value对的形式存储,而value的形式多种多样,可以是字符串、数值、甚至json

NoSql可以翻译做Not Only Sql(不仅仅是SQL),或者是No Sql(非Sql的)数据库。也称为非关系型数据库

sql vs nosql:

  • 传统关系型数据库是结构化数据,每一张表都有严格的约束信息:字段名、字段数据类型、字段约束等等信息,插入的数据必须遵守这些约束。而NoSql则对数据库格式没有严格约束,往往形式松散,自由。可以是键值型,也可以是文档型或者图格式。

  • 传统数据库的表与表之间往往存在关联,例如外键。而非关系型数据库不存在关联关系,要维护关系要么靠代码中的业务逻辑,要么靠数据之间的耦合:

    {
      id: 1,
      name: "张三",
      orders: [
        {
           id: 1,
           item: {
    	 id: 10, title: "荣耀6", price: 4999
           }
        },
        {
           id: 2,
           item: {
    	 id: 20, title: "小米11", price: 3999
           }
        }
      ]
    }
  • 传统关系型数据库会基于Sql语句做查询,语法有统一标准;而不同的非关系数据库查询语法差异极大,五花八门各种各样。

  • 传统关系型数据库能满足事务ACID的原则。而非关系型数据库往往不支持事务,或者不能严格保证ACID的特性,只能实现基本的一致性

除了上述四点以外,在存储方式、扩展性、查询性能上关系型与非关系型也都有着显著差异,总结如下:

  • 存储方式
    • 关系型数据库基于磁盘进行存储,会有大量的磁盘IO,对性能有一定影响
    • 非关系型数据库,他们的操作更多的是依赖于内存来操作,内存的读写速度会非常快,性能自然会好一些
  • 扩展性
    • 关系型数据库集群模式一般是主从,主从数据一致,起到数据备份的作用,称为垂直扩展。
    • 非关系型数据库可以将数据拆分,存储在不同机器上,可以保存海量数据,解决内存大小有限的问题。称为水平扩展
    • 关系型数据库因为表之间存在关联关系,如果做水平扩展会给数据查询带来很多麻烦

初识redis

linux启动

默认启动

安装完成后,在任意目录输入redis-server命令即可启动Redis

这种启动属于前台启动,会阻塞整个会话窗口,窗口关闭或者按下CTRL + C则Redis停止。不推荐使用。

指定配置启动

如果要让Redis以后台方式启动,则必须修改Redis配置文件,就在我们之前解压的redis安装包下(/usr/local/src/redis-6.2.6),名字叫redis.conf

我们先将这个配置文件备份一份:

cp redis.conf redis.conf.bck

然后修改redis.conf文件中的一些配置:

# 允许访问的地址,默认是127.0.0.1,会导致只能在本地访问。修改为0.0.0.0则可以在任意IP访问,生产环境不要设置为0.0.0.0
bind 0.0.0.0
# 守护进程,修改为yes后即可后台运行
daemonize yes 
# 密码,设置后访问Redis必须输入密码
requirepass 123321

Redis的其它常见配置:

# 监听的端口
port 6379
# 工作目录,默认是当前目录,也就是运行redis-server时的命令,日志、持久化等文件会保存在这个目录
dir .
# 数据库数量,设置为1,代表只使用1个库,默认有16个库,编号0~15
databases 1
# 设置redis能够使用的最大内存
maxmemory 512mb
# 日志文件,默认为空,不记录日志,可以指定日志文件名
logfile "redis.log"

启动Redis:

# 进入redis安装目录 
cd /usr/local/src/redis-6.2.6
# 启动
redis-server redis.conf

停止服务:

# 利用redis-cli来执行 shutdown 命令,即可停止 Redis 服务,
# 因为之前配置了密码,因此需要通过 -u 来指定密码
redis-cli -u 123321 shutdown

开机自启

我们也可以通过配置来实现开机自启。

首先,新建一个系统服务文件:

vi /etc/systemd/system/redis.service

内容如下:

[Unit]
Description=redis-server
After=network.target

[Service]
Type=forking
ExecStart=/usr/local/bin/redis-server /usr/local/src/redis-6.2.6/redis.conf
PrivateTmp=true

[Install]
WantedBy=multi-user.target

然后重载系统服务:

systemctl daemon-reload

现在,我们可以用下面这组命令来操作redis了:

# 启动
systemctl start redis
# 停止
systemctl stop redis
# 重启
systemctl restart redis
# 查看状态
systemctl status redis

执行下面的命令,可以让redis开机自启:

systemctl enable redis

Redis桌面客户端

命令行客户端

Redis安装完成后就自带了命令行客户端:redis-cli,使用方式如下:

redis-cli [options] [commonds]

其中常见的options有:

  • -h 127.0.0.1:指定要连接的redis节点的IP地址,默认是127.0.0.1
  • -p 6379:指定要连接的redis节点的端口,默认是6379
  • -a 123321:指定redis的访问密码

其中的commonds就是Redis的操作命令,例如:

  • ping:与redis服务端做心跳测试,服务端正常会返回pong

不指定commond时,会进入redis-cli的交互控制台

图形化客户端

RedisDesktopManager:https://github.com/lework/RedisDesktopManager-Windows/releases

Redis默认有20个仓库,编号从0至19. 通过配置文件可以设置仓库数量,但是不超过16,并且不能自定义仓库名称。

Redis常见命令

Redis是典型的key-value数据库,key一般是字符串,而value包含很多不同的数据类型:

官方文档与数据类型分组:https://redis.io/docs/latest/commands/

keys

keys *	查看当前库所有key
 
exits key  判断某个key是否存在
 
type key  查看key是什么类型
 
del key	删除指定的key数据
 
unlink key	根据value选择非阻塞删除(仅将keys从keyspace元数据中删除,真正的删除会在后续异步操作)
 
expire key 10  为给定的key设置过期时间(10s)
 
ttl key	查看还有多少秒过期:-1表示永不过期,-2表示已经过期
 
select	切换数据库
 
dbsize	查看当前数据库的key数量
 
flushdb	清空当前库
 
flushall  通杀全部库

String

String类型,也就是字符串类型,是Redis中最简单的存储类型。其value是字符串,不过根据字符串的格式不同,又可以分为3类:

  • string:普通字符串
  • int:整数类型,可以做自增、自减操作
  • float:浮点类型,可以做自增、自减操作

不管是哪种格式,底层都是字节数组形式存储,只不过是编码方式不同。字符串类型的最大空间不能超过512m.

set <key><value>  添加或修改键值对(key存在时,set覆盖旧值)
 
get <key>  查询对应键值
 
append <key><value>  将给定的value追加到原值的末尾
 
strlen <key>  获得值的长度
 
setnx <key><value>  只有key不存在时,设置key值
 
incr <key>  将key中储存的数字值增1,===只能对数字值操作===,如果为空,新增值为1

incrby <key> xx 让一个整型的key自增并指定步长,例如:incrby num 2 让num值自增2

incrbyfloat <Key> xx 让一个浮点型的key自增并指定步长

decr <key>  将key中储存的数字值建减1,只能对数字值操作,如果为空,新增值为-1
 
incrby / decrvy <key><步长>  将key中储存的数字值增减,自定义步长
 
mset <key1><value1><key2><value2>……  批量添加一个或多个 key-value 对
 
mget <key1><key2><key3>……  批量获取一个或多个value
 
msetnax  <key1><value1><key2><value2>……  同时设置一个或多个key-value对,当且仅当所有给定key都不存在
 
getrange <key><起始位置><结束位置>  获得值的范围,类似java中的substring,前包,后包
 
setrange <key><起始位置><value><value>覆写<key>所存储的字符串值,从起始位置开始(索引从0开始)
 
setex <key><过期时间><value>  设置键值的同时,设置过期时间(单位:秒)
 
getset <key><value>  以新换旧,设置了新值的同时获得旧值

key结构

Redis没有类似MySQL中的Table的概念,我们该如何区分不同类型的key呢?

例如,需要存储用户、商品信息到redis,有一个用户id是1,有一个商品id恰好也是1,此时如果使用id作为key,那就会冲突了,该怎么办?

我们可以通过给key添加前缀加以区分:Redis的key允许有多个单词形成层级结构,多个单词之间用’:’隔开,格式如下:

项目名:业务名:类型:id

这个格式并非固定,也可以根据自己的需求来删除或添加词条。这样我们就可以把不同类型的数据区分开了。从而避免了key的冲突问题。

例如我们的项目名称叫 heima,有user和product两种不同类型的数据,我们可以这样定义key:

  • user相关的key:heima:user:1

  • product相关的key:heima:product:1

如果Value是一个Java对象,例如一个User对象,则可以将对象序列化为JSON字符串后存储:

KEY VALUE
heima:user:1 {“id”:1, “name”: “Jack”, “age”: 21}
heima:product:1 {“id”:1, “name”: “小米11”, “price”: 4999}

并且,在Redis的桌面客户端中,还会以相同前缀作为层级结构,让数据看起来层次分明,关系清晰:

Hash

Hash类型,也叫散列,其value是一个无序字典(Hash是一个String类型的FieldValue的映射表),类似于HashMap结构。

String结构是将对象序列化为JSON字符串后存储,当需要修改对象某个字段时很不方便;Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD。

hset <key><field><value><key>集合中的<filed>键赋值<value>,可以添加也可以修改
 
hget <key1><field><key1>集合<field>取出value
 
hmset <key1><field1><value1><field2><value2>……  批量设置hash的值

hmget <key1><field1><field2>...  批量获取hash的field的值
 
hexits <key1><filed>  查看哈希表key中,给定域field是否存在
 
hkeys <key>  列出该hash集合key的所有field
 
hvals <key>  列出该hash集合的所有value
 
hincrby <key><field><increment>  为哈希表key中的域field的值加上增量(自增自减)并指定步长
 
hsetnx <key><field><value>  将哈希表key中的域field的值设置为value,当且仅当域field不存在

hgetall <key> 获取一个hash类型的key中的所有的field和value

List

Redis中的List类型与Java中的LinkedList类似,可以看做是一个双向链表结构。既可以支持正向检索和也可以支持反向检索。

特征也与LinkedList类似:

  • 有序
  • 元素可以重复
  • 插入和删除快
  • 查询速度一般

常用来存储一个有序数据,例如:朋友圈点赞列表,评论列表等。

lpush / rpush <key><value1><value2><value3>……  从左边(头插法)/右边(尾插法)插入一个或多个值
 
lpop / rpop <key>  从左边/右边移除并返回第一个值,没有则返回null
 
rpoplpush <key1><key2><key1>列表右边移除一个值,插到<key2>列表左边
 
lrange <key><start><stop>  按照索引下标获得元素(从左到右) eg:lrange mylist 0 -1  0左边第一个,-1右边第一个(0 -1 表示获取所有)
 
lindex <key><index>  按照索引下标获得元素(从左到右)
 
llen <key>  获得列表长度
 
linsert <key> before <value><newvalue><value>后面插入<newvalue>插入值
 
lrem <key><n><value>  从左起删除n个vlaue(从左到右)
 
lset <key><index><value>  将列表key下标为index的值替换成value

示例:

LPUSH users 1 2 3
RPUSH users 4 5 6	结果是users中包含 3 2 1 4 5 6
LRANGE users 1 2	结果是["1","4"]

Set

Redis的Set结构与Java中的HashSet类似,可以看做是一个value为null的HashMap。因为也是一个hash表,因此具备与HashSet类似的特征:

  • 无序
  • 元素不可重复
  • 查找快
  • 支持交集.并集.差集等功能
sadd <key><value1><value2>……	将一个或多个元素加入到集合key中,已经存在的元素将被忽略

srem <key><value1><value2>……	删除集合中的指定元素
 
smembers <key>	取出该集合的所有值
 
sismember <key><value>	判断集合<key>是否包含该<value>值,有1,没有0
 
scard <key>	返回该集合的元素个数
 
spop <key>	随机从该集合中吐出一个值
 
srandmember <key><n>	随机从该集合中取出n个值,不会从集合中删除
 
smove <source><destination>value	把集合中的一个值从一个集合移动到另一额集合
 
sinter<key1><key2>	返回两个集合的交集元素
 
sunion <key1><key2>	返回两个集合的并集元素
 
sdiff <key1><key2>	返回两个集合的差集元素

具体命令:

127.0.0.1:6379> sadd s1 a b c
(integer) 3
127.0.0.1:6379> smembers s1
1) "c"
2) "b"
3) "a"
127.0.0.1:6379> srem s1 a
(integer) 1
    
127.0.0.1:6379> SISMEMBER s1 a
(integer) 0
    
127.0.0.1:6379> SISMEMBER s1 b
(integer) 1
    
127.0.0.1:6379> SCARD s1
(integer) 2

SortedSet(Zset)

Redis的SortedSet是一个可排序的set集合,与Java中的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加 hash表

SortedSet具备下列特性:

  • 可排序
  • 元素不重复
  • 查询速度快

因为SortedSet的可排序特性,经常被用来实现排行榜这样的功能。

zadd <key><score1><value1><score2><value2>……  将一个或多个member元素及其score值加入到有序集key中,若已存在则覆盖score值
 
zrange <key><start><stop> [WITHSCORES]  返回有序集key中,下标在strart到stop之间的元素(带WITHSCORES,可以让分数一起返回)
 
zrangebyscore key min max [withscores][limit offset count]  返回有序集key中,所有score值介于min和max之间的成员(从小到大)
 
zrevrangebyscore key max min [withscores][limit offet count]  同上,从大到小排序
 
zincrby <key><increment><value>  让sorted set中的指定元素自增,步长为指定的increment值
 
zrem <key><value>  删除该集合下,指定元素value
 
zcount <key><min><max>  统计该集合,分数区间内的元素个数
 
zrank <key><value>  返回该值在集合中的排名,从0开始

zscore <key><value> 获取sorted set中的指定元素的score值

zcard <key> 获取sorted set中的元素个数

ZDIFF.ZINTER.ZUNION:求差集.交集.并集

注意:所有的排名默认都是升序,如果要降序则在命令的Z后面添加REV即可,例如:

  • 升序获取sorted set 中的指定元素的排名:ZRANK key member
  • 降序获取sorted set 中的指定元素的排名:ZREVRANK key memeber

案例

将班级的下列学生得分存入Redis的SortedSet中:

Jack 85, Lucy 89, Rose 82, Tom 95, Jerry 78, Amy 92, Miles 76

并实现下列功能:

  • 删除Tom同学
  • 获取Amy同学的分数
  • 获取Rose同学的排名
  • 查询80分以下有几个学生
  • 给Amy同学加2分
  • 查出成绩前3名的同学
  • 查出成绩80分以下的所有同学
ZADD studs 85 Jack 89 Lucy 82 Rose 95 tom 78 Jerry 92 Amy 76 Miles
zrem studs tom
zrank studs Rose	排名2
zrevrank studs Rose	排名3(倒数4)
zcount studs 0 80	结果2人
zincrby studs 2 Amy	结果94.0分
zrange studs 0 2	结果["Miles","Rose","Jerry"]
zrangebyscore studs 0 80	结果["Miles","Jerry"]

Bitmaps

setbit <key><offset><value>  设置Bitmaps中某个偏移量的值(0或1)(offset:偏移量从0开始)
 
getbit <key><offset>  获取Bitmaps中某个偏移量的值
 
bitcount <key>[start end]  统计字符串从start字节到end字节比特值为1的量
 
bitop and(or/not/xor) <destkey> [key] 复合操作,可以做多个bitmaps的交集(and)、并集(or)、非(not)、异或(xor)操作并将结果保存在destkey中

HyperLogLog

pfadd <key><element>[element……]  添加指定元素到HyperLogLog中
 
pfcount <key> [key……]  计算HLL的近似基数,可以计算多个HLL(比如用HLL存储每天的UV,计算一周的UV可以使用7天的UV合并计算即可)
 
pfmerge <destkey><sourcekey>[sourcekey……]  将一个或多个HLL合并后的结果存储在另一个HLL中(比如每月活跃用户可以使用每天的活页用户来合并计算可得)

Geospatial

geoadd <key><longitude><latitude><member>[longitude lattitude member……] 添加地理位置(经度、维度、名称)
 
geopos <key><member>[member……]  获得指定地区的坐标值
 
geodist <key><member1><member2> [m|km|ft|mi]  获取两个位置之间的直线距离
 
georadius <key><longitude><latitude>radius m|km|ft|mi	以给定的经纬度为中心,找出某一半径内的元素

Redis的Java客户端

在Redis官网中提供了各种语言的客户端,地址:https://redis.io/docs/clients/

其中Java客户端也包含很多:

标记为*的就是推荐使用的java客户端,包括:

  • Jedis和Lettuce:这两个主要是提供了Redis命令对应的API,方便我们操作Redis,而SpringDataRedis又对这两种做了抽象和封装,因此我们后期会直接以SpringDataRedis来学习。
  • Redisson:是在Redis基础上实现了分布式的可伸缩的java数据结构,例如Map、Queue等,而且支持跨进程的同步机制:Lock、Semaphore等待,比较适合用来实现特殊的功能需求。

Jedis客户端

Jedis的官网地址: https://github.com/redis/jedis

引入依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.0.0</version>
</dependency>
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

建立连接

public class redisTest {
    private Jedis jedis;
    @BeforeEach
    void setUp() {
        // 建立连接
        jedis = new Jedis("ip", 6379);
        jedis.auth("name", "pwd");
        // 选择库
        jedis.select(0);
    }
}

测试连接

@Test
void testString() {
    // 存入数据
    String result = jedis.set("name", "虎哥");
    System.out.println("result = " + result);
    // 获取数据
    String name = jedis.get("name");
    System.out.println("name = " + name);
}

@Test
void testHash() {
    // 插入hash数据
    jedis.hset("user:2", "name", "Jack");
    jedis.hset("user:2", "age", "21");

    // 获取
    Map<String, String> map = jedis.hgetAll("user:2");
    System.out.println(map);
}

释放资源

@AfterEach
void tearDown() {
    if (jedis != null) {
        jedis.close();
    }
}

jedis连接池

Jedis本身是线程不安全的,并且频繁的创建和销毁连接会有性能损耗,因此我们推荐大家使用Jedis连接池代替Jedis的直连方式。

public class JedisConnectionFactory {

    private static JedisPool jedisPool;

    static {
        // 配置连接池
        JedisPoolConfig poolConfig = new JedisPoolConfig();
        poolConfig.setMaxTotal(8);
        poolConfig.setMaxIdle(8);	// 最大空闲连接
        poolConfig.setMinIdle(0);	// 最小空闲连接
        poolConfig.setMaxWaitMillis(1000);	// 没有连接时,最多等待1000ms
        // 创建连接池对象,参数:连接池配置、服务端ip、服务端端口、超时时间、密码
        jedisPool = new JedisPool(poolConfig, "ip", 6379, 1000, "pwd");
    }

    public static Jedis getJedis(){
        return jedisPool.getResource();
    }
}

SpringDataRedis客户端

SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:https://spring.io/projects/spring-data-redis

  • 提供了对不同Redis客户端的整合(Lettuce和Jedis)
  • 提供了RedisTemplate统一API来操作Redis
  • 支持Redis的发布订阅模型
  • 支持Redis哨兵和Redis集群
  • 支持基于Lettuce的响应式编程
  • 支持基于JDK、JSON、字符串、Spring对象的数据序列化及反序列化
  • 支持基于Redis的JDKCollection实现

SpringDataRedis中提供了RedisTemplate工具类,其中封装了各种对Redis的操作。并且将不同数据类型的操作API封装到了不同的类型中:

引入依赖

<dependencies>
    <!-- spring-data-redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!--common-pool-->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
    <!--Jackson依赖-->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>	

配置redis

application.properties

# REDIS (RedisProperties)
# Redis数据库索引(默认为0)
spring.data.redis.database=0
# Redis服务器地址
spring.data.redis.host=ip
# Redis服务器连接端口
spring.data.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.data.redis.password=pwd
# 连接池最大连接数(使用负值表示没有限制)
spring.data.redis.jedis.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.data.redis.jedis.pool.max-wait=1000
# 连接池中的最大空闲连接
spring.data.redis.jedis.pool.max-idle=8
# 连接池中的最小空闲连接
spring.data.redis.jedis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.data.redis.timeout=5000

注入RedisTemplate,测试

因为有了SpringBoot的自动装配,我们可以拿来就用:

@SpringBootTest
class SpringRedisApplicationTests {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    void contextLoads() {
    }
    @Test
    void testString() {
        // 写入string
        redisTemplate.opsForValue().set("name", "胡歌");
        // 获取string数据
        Object name = redisTemplate.opsForValue().get("name");
        System.out.println("name = " + name);
    }
    @Test
    void testHash() {
        redisTemplate.opsForHash().put("user:2", "name", "Jack");
        Object name = redisTemplate.opsForHash().get("user:2", "name");
        System.out.println("name = "+name);
    }
}

自定义序列化

RedisTemplate默认采用JDK的序列化工具,可以接收任意Object作为值写入Redis,序列化为字节形式,在redis中可读性很差且内存占用大。
修改默认的序列化方式为jackson

我们可以自定义RedisTemplate的序列化方式

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory){
        // 创建RedisTemplate对象
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        // 设置连接工厂
        template.setConnectionFactory(connectionFactory);
        // 创建JSON序列化工具
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = 
            							new GenericJackson2JsonRedisSerializer();
        // 设置Key的序列化
        template.setKeySerializer(RedisSerializer.string());
        template.setHashKeySerializer(RedisSerializer.string());
        // 设置Value的序列化
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);
        // 返回
        return template;
    }
}

这里采用了JSON序列化来代替默认的JDK序列化方式。最终结果如图:

整体可读性有了很大提升,并且能将Java对象自动的序列化为JSON字符串,并且查询时能自动把JSON反序列化为Java对象。不过,其中记录了序列化时对应的class名称,目的是为了查询时实现自动反序列化。这会带来额外的内存开销。

StringRedisTemplate

为了节省内存空间,我们可以不使用JSON序列化器来处理value,而是统一使用String序列化器,要求只能存储String类型的key和value。当需要存储Java对象时,手动完成对象的序列化和反序列化。

因为存入和读取时的序列化及反序列化都是我们自己实现的,SpringDataRedis就不会将class信息写入Redis了。

这种用法比较普遍,因此SpringDataRedis就提供了RedisTemplate的子类:StringRedisTemplate,它的key和value的序列化方式默认就是String方式。


@SpringBootTest
class RedisStringTests {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void testString() {
        // 写入一条String数据
        stringRedisTemplate.opsForValue().set("verify:phone:13600527634", "124143");
        // 获取string数据
        Object name = stringRedisTemplate.opsForValue().get("name");
        System.out.println("name = " + name);
    }
    // JSON序列化工具
    private static final ObjectMapper mapper = new ObjectMapper();

    @Test
    void testSaveUser() throws JsonProcessingException {
        // 创建对象
        User user = new User("虎哥", 21);
        // 手动序列化
        String json = mapper.writeValueAsString(user);
        // 写入数据
        stringRedisTemplate.opsForValue().set("user:200", json);

        // 获取数据
        String jsonUser = stringRedisTemplate.opsForValue().get("user:200");
        // 手动反序列化
        User user1 = mapper.readValue(jsonUser, User.class);
        System.out.println("user1 = " + user1);
    }

    @Test
    void testHash() {
        stringRedisTemplate.opsForHash().put("user:400", "name", "虎哥");
        stringRedisTemplate.opsForHash().put("user:400", "age", "21");

        Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries("user:400");
        System.out.println("entries = " + entries);
    }

    @Test
    void name() {
    }
}

八股

Redis为什么快

性能优化(1)Redis 基于内存,内存的访问速度比磁盘快很多;(2)Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到);(3)Redis 内置了多种优化过后的数据类型/结构实现,性能非常高。(4)Redis 通信协议实现简单且解析高效。

why-redis-so-fast

分布式缓存方案

Memcached

  • 简介:Memcached 是一个高性能的分布式内存对象缓存系统,它最初是为了加速动态 Web 应用程序而开发的,通过在内存中缓存数据和对象,减少对数据库等后端存储的访问,从而提高应用程序的响应速度。

  • 适用场景:适用于对缓存读写性能要求极高、数据更新频率不高且允许数据丢失的场景,如网页缓存、热门数据缓存等。

对比:

  • 数据类型:Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Memcached 只支持最简单的 k/v 数据类型。
  • 数据持久化:Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 把数据全部存在内存之中,一旦服务器重启或崩溃,内存中的数据将全部丢失。也就是说,Redis 有灾难恢复机制而 Memcached 没有。
  • 集群模式支持:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 自 3.0 版本起是原生支持集群模式的。
  • 线程模型:Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 针对网络数据的读写引入了多线程)
  • 特性支持:Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  • 过期数据删除:Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

redis优点

1、访问速度更快 传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。

2、高并发 一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数;

由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。

3、功能全面 Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,

底层数据结构

img

String底层

应用场景:

  • 常规数据(比如 Session、Token、序列化后的对象、图片的路径)的缓存;
  • 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数
  • 分布式锁(利用 SETNX key value 命令可以实现一个最简易的分布式锁);

存储对象时和Hash对比:

  • String适合存储嵌套结构较多或结构复杂的对象,可以直接存储整个对象序列化数据,操作简单,读写性能好,多数情况下更适合;
  • Hash适合需要频繁对指定字段做修改或查询的对象,占用内存比String小,特别是字段多且字段长度短的情况(底层ziplist存储优化),操作开销大
SDS

Redis基于C语言编写了 SDS(Simple Dynamic String,简单动态字符串) 作为底层实现。在 Redis 中,当存储的字符串长度小于 44 字节时(Redis 3.2 版本之前是 39 字节),默认使用 SDS 来存储。SDS 有多种不同的结构定义,以适应不同长度的字符串,常见的有 sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64。SDS 可以存储字符串,也可以存储二进制数据,包括空字符,因此在处理二进制数据时更为灵活,不受空字符的限制。

与C语言中的字符串相比:(1)可以避免缓冲区溢出(先根据len检查空间大小);(2)获取字符串长度的复杂度低(C要遍历);(3)动态扩容,减少内存分配次数(扩容时,空间预分配;字符串缩短时,惰性空间释放-即标记多余空间为可用,等待后续使用);(4)二进制安全(C语言以空字符\0作为结束标记)

采用了空间预分配和惰性空间释放的策略。在进行扩容时,除了分配实际需要的空间外,还会额外分配一些空间,以便后续的操作使用,减少了内存分配的次数。在进行字符串缩短操作时,不会立即释放多余的空间,而是将其标记为可用,等待后续使用

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;  // 字符串的实际长度 -- 获取串长度为O(1),C语言字符串是遍历计数,复杂度O(n)
    uint8_t alloc; // 分配的内存空间大小,不包含头部和结尾的空字符 -- 在进行字符串拼接等操作时,SDS 会先检查分配的内存空间是否足够,如果不够会自动进行扩容,避免了 C 字符串可能出现的缓冲区溢出问题。
    unsigned char flags; // 标志位,用于标识 SDS 的类型
    char buf[];  // 存储字符串的字符数组
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
ziplist 早期使用

在 Redis 早期版本中,对于长度较短的字符串,可能会使用压缩列表(ziplist)来存储。压缩列表是一种为了节省内存而设计的连续内存块数据结构,它将多个元素紧凑地存储在一起。不过,随着 Redis 的发展,现在对于较短字符串更多使用 SDS 来存储。

list底层

Redis 的 List 类型是一个有序的字符串列表,它的底层数据结构主要有两种:压缩列表(ziplist)和双向链表(linkedlist),在 Redis 3.2 版本之后,引入了快速列表(quicklist)作为 List 的底层实现。

ziplist 早期使用

压缩列表(ZipList)是一种用于在 Redis 中紧凑存储列表和哈希表数据的数据结构。它是由一系列特定编码格式连续内存块组成的顺序型数据结构,每个内存块称为一个节点(entry)。每个节点可以存储一个字节数组或整数值,并且可以根据实际存储的数据动态地调整节点的长度。列表或hash键元素较少且每个元素占用空间较小时使用压缩列表,目的是利用紧凑连续块来节省内存。

  • 优点:紧凑存储,可变长度,支持多种数据类型,适合小规模数据,需要遍历访问
  • 缺点:在插入或删除元素时,可能需要对内存进行重新分配和数据移动,时间复杂度较高。(插入或删除元素可能导致多个 entry 的 prevlen 字段扩展,触发连锁内存重分配(性能风险))
// ziplist 头部结构定义
typedef struct zlentry {
    unsigned int prevrawlensize; // 编码前一个节点长度所需的字节数
    unsigned int prevrawlen;     // 前一个节点的实际长度
    unsigned int lensize;        // 编码当前节点长度所需的字节数
    unsigned int len;            // 当前节点的实际长度
    unsigned int headersize;     // 当前节点的头部大小(prevrawlensize + lensize)
    unsigned char encoding;      // 当前节点的编码方式
    unsigned char *p;            // 指向当前节点的指针
} zlentry;

// ziplist 整体结构定义
// zlbytes: 记录整个压缩列表占用的内存字节数
// zltail: 记录压缩列表表尾节点距离压缩列表起始地址的偏移量--O(1)复杂度内找到表尾节点,支持反向遍历
// zllen: 记录压缩列表包含的节点(entry)数量
// entry: 压缩列表中的每个节点,存储具体的数据元素--每个元素存储为 [prevlen][encoding][content]。
// zlend: 一个特殊的结束标记,值为 0xFF

// ziplist 内存布局(无显式结构体,通过字节操作)-- redis3.0  ziplist.c
// <zlbytes><zltail><zllen><entry>...<entry><zlend>
双向链表 早期使用

当列表元素较多或者元素占用空间较大时,Redis 会使用双向链表作为 List 的底层数据结构。双向链表在插入和删除元素时具有较好的性能。

  • 内存不连续:每个节点独立分配内存,通过指针连接。意味着与压缩列表相比,⽆法很好利⽤ CPU 缓存
  • 操作高效:头尾插入/删除时间复杂度为 O(1),但随机访问为 O(n)。
  • 内存开销大:每个节点需额外存储两个指针(每个指针占 8 字节)。
// 数据结构定义(Redis 3.0,adlist.h)
// 双向链表节点
typedef struct listNode {
    struct listNode *prev;  // 前驱节点指针
    struct listNode *next;  // 后继节点指针
    void *value;            // 存储的实际数据
} listNode;

// 双向链表结构
typedef struct list {
    listNode *head;         // 头节点
    listNode *tail;         // 尾节点
    unsigned long len;      // 链表长度(元素数量)
    void *(*dup)(void *ptr);    // 节点值复制函数
    void (*free)(void *ptr);    // 节点值释放函数
    int (*match)(void *ptr, void *key); // 节点值比较函数
} list;
quicklist 新版

为了综合压缩列表和双向链表的优点,Redis 3.2 版本之后引入了快速列表作为 List 的底层实现。快速列表结合了压缩列表节省内存和双向链表插入删除高效的特点。

  • 快速列表是一个双向链表,链表中的**每个节点(quicklistNode)是一个压缩列表(ziplist)**。
  • 当插入元素导致 ziplist 超过 fill 限制时,节点会分裂;删除元素可能导致相邻节点合并。
  • headtail 指针使得 LPUSH、RPUSH、LPOP、RPOP 的时间复杂度为 **O(1)**。通过 prevnext 指针支持双向遍历。
  • 通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
// quicklist 结构(src/quicklist.h)
typedef struct quicklist {
    quicklistNode *head;        // 指向头节点的指针
    quicklistNode *tail;        // 指向尾节点的指针
    unsigned long count;        // 所有 ziplist 中的元素总数
    unsigned long len;          // quicklist 的节点数量(即 ziplist 的数量)
    int fill : QL_FILL_BITS;    // 单个 ziplist 的最大容量限制(由 list-max-ziplist-size 配置)
    unsigned int compress : 16; // 压缩深度(由 list-compress-depth 配置)
} quicklist;

// quicklist 节点结构
typedef struct quicklistNode {
    struct quicklistNode *prev; // 前驱节点指针
    struct quicklistNode *next; // 后继节点指针
    unsigned char *zl;          // 指向 ziplist 的指针(未压缩时)
    unsigned int sz;            // ziplist 的字节大小
    unsigned int count : 16;    // ziplist 的元素数量
    unsigned int encoding : 2;  // 编码方式:RAW(原始 ziplist)或 LZF(压缩存储)
    unsigned int container : 2; // 容器类型(固定为 2,表示使用 ziplist)
    unsigned int recompress : 1; // 是否正在被解压
    unsigned int attempted_compress : 1; // 测试用
    unsigned int extra : 10;     // 预留位
} quicklistNode;

hash底层

Ziplist+HashTable 早期

当哈希对象满足以下两个条件时,Redis 会使用压缩列表作为 Hash 的底层数据结构:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节(可以通过 hash-max-ziplist-value 配置项进行修改)。
  • 哈希对象保存的键值对数量小于 512 个(该阈值可以通过 hash-max-ziplist-entries 配置项进行修改)。

在压缩列表中,哈希对象的键值对是按顺序依次存储的。每个键值对由两个连续的节点表示,前一个节点存储键,后一个节点存储值。例如,对于哈希对象 {"name": "John", "age": 30},在压缩列表中的存储顺序可能是:"name", "John", "age", 30

Ziplist 的每个元素(entry)由三部分组成:

[prevlen][encoding][content]
  • **prevlen**:存储前一个元素的长度(支持反向遍历)。
  • **encoding**:当前元素的数据类型和长度。
  • **content**:实际数据。

局限:

  • 级联更新:插入或删除元素可能导致多个 entry 的 prevlen 字段扩展,触发连锁内存重分配(性能风险)。
  • 遍历效率低:查找特定键需要遍历整个 ziplist(时间复杂度 O(n))。

当哈希对象不满足使用压缩列表的条件时,Redis 会将底层数据结构转换为哈希表。

  • 渐进式 rehash:当哈希表需要扩容或缩容时,数据逐步迁移到新哈希表,避免一次性操作阻塞服务。
  • 高效随机访问:平均时间复杂度为 O(1)。
  • 链地址法解决冲突:哈希冲突时,通过链表将多个键值对连接在同一个桶中。
// 哈希表结构
typedef struct dictht {
    dictEntry **table;      // 哈希桶数组
    unsigned long size;      // 哈希表大小(桶的数量)
    unsigned long sizemask;  // 哈希掩码(用于计算索引,等于 size-1)
    unsigned long used;      // 已使用的桶数量(键值对总数)
} dictht;

// 字典结构(Redis 哈希的顶层结构)
typedef struct dict {
    dictType *type;         // 类型特定函数(如哈希函数)
    void *privdata;         // 私有数据
    dictht ht[2];           // 两个哈希表(用于渐进式 rehash)
    long rehashidx;         // rehash 进度索引(-1 表示未进行 rehash)
    unsigned long iterators; // 当前运行的迭代器数量
} dict;

// 哈希表节点(键值对)
typedef struct dictEntry {
    void *key;              // 键
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;                    // 值
    struct dictEntry *next; // 指向下一个节点(解决哈希冲突)
} dictEntry;
img
listpack+HashTable 新版

在 Redis 的最新版本(如 7.0 及以上)中,listpack 已逐步替代 ziplist,成为某些数据结构的底层实现,以解决 ziplist 的级联更新问题,并提升内存效率和操作性能。

// listpack 内存布局(源码:listpack.c)
// <总字节数><元素数量><element>...<element><结束符>

unsigned char *lpInsert(unsigned char *lp, unsigned char *el, uint32_t size, unsigned char *p, int where) {
    // 计算新元素所需内存
    uint32_t poff = p ? p - lp : 0;
    uint64_t enclen = lpEncodeGetType(el, size, &enctype);
    uint32_t backlen_size = lpEncodeBacklen(NULL, enclen);
    uint64_t old_listpack_bytes = lpGetTotalBytes(lp);
    uint32_t new_elements = lpGetNumElements(lp) + 1;

    // 分配新内存并插入元素
    unsigned char *newlp = lp_realloc(lp, old_listpack_bytes + enclen + backlen_size);
    // 更新元素位置和元数据...
    return newlp;
}
  • 总字节数(4 Bytes):整个 listpack 占用的内存大小。
  • 元素数量(2 Bytes):元素个数(若超过 65535,需遍历统计)。
  • 元素(element):每个元素独立编码,格式为 [编码类型][数据内容][元素长度]
  • 结束符(1 Byte):固定值 0xFF

每个元素(entry)的元数据(如长度)存储在其自身末尾,而非依赖前驱节点,彻底消除了级联更新的可能性。级联更新(Cascade Update) 指的是在数据结构中,修改一个元素可能导致后续多个元素的内存布局被迫调整,从而引发连锁反应。

例如,插入新元素时,仅需修改自身及后续元素的长度字段,无需回溯前驱节点。

特性 Listpack Ziplist
级联更新 (长度信息存储在自身末尾) 有(依赖前驱节点的 prevlen 字段)
内存连续性 连续内存块 连续内存块
插入/删除性能 稳定 O(n) 最坏 O(n²)(级联更新时)
适用场景 高频插入、删除的紧凑数据 已逐步被 listpack 替代

消除级联更新的本质:

  • 解耦元素长度依赖:每个元素的长度信息仅影响自身,不依赖前驱或后继元素。
  • 内存操作局部化:插入或删除操作仅需处理当前元素及后续元素的位置,不触发连锁更新。

Set底层

Redis 的集合(Set)底层数据结构根据元素类型和数量动态选择 intset(整数集合)hashtable(哈希表),以优化内存和性能。

在redis.conf中配置set-max-intset-entries 512,表示当元素数量<=512且所有元素为整数时,使用intset。

  • 小规模整数集合:使用 intset(内存紧凑,连续存储)。
  • 其他情况:使用 hashtable(支持任意类型元素,高效随机访问)。
intset 整数集合

intset是一个由整数组成的有序集合,可以进行二分查找。整数集合在存储整数值的过程中,会根据需要动态调整存储空间,避免了固定大小数组可能带来的空间浪费或溢出问题。用途:用于存储用户 ID、商品 ID 等。由于其高效的存储方式和快速的查找性能,使得整数集合成为了处理整数集合类数据的首选数据结构之一。

typedef struct intset {
    uint32_t encoding;  // 编码类型(INTSET_ENC_INT16/32/64)
    uint32_t length;    // 元素数量
    int8_t contents[];  // 实际存储元素的数组
} intset;
  • 内存连续:元素按升序存储在 contents 数组中,无指针开销。
  • 编码升级:插入新元素时,若超出当前编码范围(例如插入 int32int16 集合),自动升级编码并重新分配内存。

集合元素包含非整数或数量超过阈值时,使用与 Redis 哈希类型相同的 dict 结构(源码:dict.h),但 值部分为 NULL(仅存储键):

typedef struct dict {
    dictType *type;     // 类型特定函数
    void *privdata;
    dictht ht[2];       // 双哈希表(用于渐进式 rehash)
    long rehashidx;
    // ...
} dict;

// 哈希表节点(仅存储键)
typedef struct dictEntry {
    void *key;          // 集合元素(值存储在 key 字段)
    union { ... } v;    // 值字段未被使用(设为 NULL)
    struct dictEntry *next;
} dictEntry;

SortedSet底层

zset中的**每个元素包含数据本身和一个对应的分数(score)**。经典例子:一个zset的key是”math”,代表数学课的成绩,然后可以往这个key里插入很多数据。输入数据的时候,每次需要输入一个姓名和一个对应的成绩。那么这个姓名就是数据本身,成绩就是它的score。

zset的数据本身不允许重复,但是score允许重复。

zset底层实现原理:

  • 数据少时,使用ziplist:ziplist占用连续内存,每项元素都是(数据+score)的方式连续存储,按照score从小到大排序。ziplist为了节省内存,每个元素占用的空间可以不同,对于大的数据(long long),就多用一些字节来存储,而对于小的数据(short),就少用一些字节来存储。因此查找的时候需要按顺序遍历。ziplist省内存但是查找效率低。
    • 在有序集合元素小于 64 字节且个数小于 128 的时候,会使用 ziplist。
zset-max-listpack-entries 128  # 元素数量 ≤ 128 时,使用 listpack(Redis 7.0+)
zset-max-listpack-value 64     # 元素值长度 ≤ 64 字节时,使用 listpack
  • 数据多时,使用字典+跳表。字典用来根据数据查score,跳表用来根据score查找数据(查找效率高)。成员和分值在跳表和字典中各存一份,以空间换时间。
img
dict哈希字典+skiplist跳表
img

跳跃表是一种随机化的数据结构,它通过在每个节点中维护多个指向其他节点的指针,从而实现快速的查找、插入和删除操作。跳跃表的平均时间复杂度为O(log n) ,与平衡二叉树相当,但实现起来更加简单。

  • 多层结构:跳跃表由多个层(level)组成,每层都是一个有序的链表。最底层的链表包含了所有的元素,而上面的层则是下面层的索引。通过这种分层的结构,查找元素时可以从高层开始快速定位到大致的位置,然后再逐层向下查找,降低了查找的时间复杂度。
  • 随机层数每个节点的层数是随机确定的,通常是通过一个随机函数来决定。这样可以保证跳跃表的平衡性,避免出现极端情况下的性能下降。
  • 分数有序:跳跃表中的节点按照分数从小到大排序,如果分数相同,则按照元素的字典序排序。这种排序方式使得有序集合可以方便地进行范围查找,如根据分数范围获取元素。

时间复杂度:时间复杂度 = 索引的层数 * 每层索引遍历元素的个数。

首先看索引层数,假设每两个节点抽一个出来作为上一级索引的结点,而且最高一级索引有3个节点,则索引层数为log2(n)。

然后看每层遍历多少个元素,首先最高层最多遍历3个节点,就能往下走了,同理,次高层也最多遍历三个节点,就能往下走。取平均之后,可以认为每层遍历2个节点。

因此时间复杂度=2log2(n),同理,如果是每k个节点取一个索引的话,就是klogk(n)

空间复杂度:也是以每两个节点取一个索引为例,第一层n个节点,第二层n/2,第三次n/4,等比序列求和,或者取极限,可以认为索引节点数量无限接近于n,所以空间复杂度为O(n)。

// 有序集合结构(源码:server.h)
typedef struct zset {
    dict *dict;          // 字典(和哈希结构里的dict一样):存储 member -> score 的映射(O(1) 查找)
    zskiplist *zsl;      // 跳表:按分值排序,支持范围操作
} zset;

// 跳表节点(源码:server.h)
typedef struct zskiplistNode {
    sds ele;                         // 成员(member)
    double score;                    // 分值(score)
    struct zskiplistNode *backward;  // 后向指针(双向链表)
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前向指针
        unsigned long span;             // 跨度(用于计算排名)
    } level[];                          // 多层索引:从原始节点逐层往上记录其后继元素(即forward)
} zskiplistNode;

// 跳表结构
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 头尾节点
    unsigned long length;                 // 元素数量
    int level;                            // 最大层数
} zskiplist;

Redis 跳表的特点

  1. 采用双向链表,存在一个回退指针。主要用于简化操作,例如删除某个元素时,还需要找到该元素的前驱节点,使用回退指针会非常方便。
  2. score 值可以重复,如果 score 值一样,则按照 ele(节点存储的值,为 sds)字典排序
  3. Redis 跳跃表默认允许最大的层数是 32,被源码中 ZSKIPLIST_MAXLEVEL 定义。

插入(平均 O(logn):依赖跳表的多层索引,快速跳过无关节点):

1.计算随机层数:生成新节点的层数 level(1~32),遵循 幂次定律(越高层数概率越小)。

2.查找插入位置:从跳表的最高层开始,向右遍历,找到每一层中最后一个 小于新节点分值 的节点(或同分值但成员字典序较小的节点),记录到 update 数组中。

3.创建新节点:分配内存,存储 memberscore,并初始化各层的 forward 指针。

4.调整指针和跨度:基于update数组将新节点的各层 forward 指针指向后续节点,并更新前驱节点的 forward 指针指向新节点。更新各层的 跨度(span),维护后续节点的排名信息。

5.更新跳表元数据:如果新节点的层数超过当前跳表的最大层数,更新跳表的 level 字段。更新跳表长度 length

删除(O(logn)):

1.查找待删除节点:从最高层开始遍历,记录每层中 可能指向待删除节点前驱节点update 数组。

2.验证节点存在性:检查找到的节点是否匹配 memberscore(避免哈希冲突误删)。

3.调整指针和跨度:遍历各层,将前驱节点的 forward 指针指向待删除节点的后续节点。更新各层的 跨度(span),减去被删除节点的跨度值。

4.释放内存:释放节点内存,并更新跳表长度 length。如果删除的是头节点或尾节点,更新跳表的 headertail 指针。

按成员查询分值(ZSCORE):直接通过哈希表。ZSET 的 dict 哈希表存储了 member -> score 的映射,时间复杂度 O(1)。

查询–按分值的范围查询(ZRANGE):

1.定位起始节点:从跳表的最高层开始,向右遍历找到 第一个 ≥ 最小分值 的节点。

2.遍历链表:从起始节点开始,沿底层(level[0])指针向右遍历,直到节点分值超过最大分值。收集遍历路径上的所有节点。

按排名查询(ZRANK/ZREVRANK)利用跨度(span)字段:在查找节点的过程中,累加各层的跨度值,得到节点的排名。

为什么ZSet底层用跳表

Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?

与平衡树相比

  • 平衡树我们又会称之为 AVL 树,是一个严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1,即平衡因子为范围为 [-1,1])。平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)
  • 对于范围查询来说,它也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。
  • 跳表是一种可以用来代替平衡树的数据结构。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。

与红黑树相比:

  • 红黑树(Red Black Tree)也是一种自平衡二叉查找树,它的查询性能略微逊色于 AVL 树,但插入和删除效率更高。红黑树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)

  • 按照区间查找数据这个操作,红黑树的效率没有跳表高。跳表可以定位区间的起点,然后在原始链表中顺序向后查询就可以了。

  • 相比于红黑树,跳表还具有代码更容易实现、可读性好、不容易出错、更加灵活等优点。

  • 插入、删除时跳表只需要调整少数几个节点,红黑树需要颜色重涂和旋转,开销较大。(比起AVL要好一点)

与B+树相比:

  • 节省内存:B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存
  • 简单好用,容易实现:而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并

Redis线程模型

Redis 的线程模型是其高性能设计的核心,其核心思想是 单线程处理命令 + 多线程辅助任务

对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。

  • 单线程主逻辑:所有客户端命令的执行(如 GET、SET、INCR 等)由 单个主线程 顺序处理。

    • 避免多线程竞争锁的开销,简化实现。天然保证操作的原子性,无需考虑并发问题。基于事件循环(Event Loop)和 I/O 多路复用(如 epoll)实现高并发。
  • 多线程辅助任务:后台异步任务:如持久化(RDB/AOF)、大键删除(UNLINK)、网络 I/O(Redis 6.0+)等,由后台线程处理。

    • 避免主线程阻塞,保持低延迟。充分利用多核CPU资源。
    • Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。

Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。

过期删除策略

Redis 通过过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

  • 缓解内存的消耗:如果不设置TTL,内存占用会不断增长,可能导致OOM。设置TTL可以自动删除暂时不需要的数据,腾出空间;
  • 临时数据存储:部分业务场景下数据只在一段时间内有效,如短信验证码,用户登录token。

过期字典是存储在 redisDb 这个结构里的。在查询一个 key 的时候,Redis 首先检查该 key 是否存在于过期字典中(时间复杂度为 O(1)),如果不在就直接返回,在的话需要判断一下这个 key 是否过期,过期直接删除 key 然后返回 null。

typedef struct redisDb {
    ...

    dict *dict;     //数据库键空间,保存着数据库中所有键值对
    dict *expires   // 过期字典,保存着键的过期时间
    ...
} redisDb;
Redis 过期字典

过期删除策略

常用的过期数据的删除策略就下面这几种:

  1. 惰性删除:只会在取出/查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除周期性地随机从设置了过期时间的 key 中抽查一批,然后逐个检查这些 key 是否过期,过期就删除 key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。
  3. 延迟队列:把设置过期时间的 key 放到一个延迟队列里,到期之后就删除 key。这种方式可以保证每个过期 key 都能被删除,但维护延迟队列太麻烦,队列本身也要占用资源。
  4. 定时删除:每个设置了过期时间的 key 都会在设置的时间到达时立即被删除。这种方法可以确保内存中不会有过期的键,但是它对 CPU 的压力最大,因为它需要为每个键都设置一个定时器

Redis 采用的是 定期删除+惰性/懒汉式删除 结合的策略,这也是大部分缓存框架的选择。定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,结合起来使用既能兼顾 CPU 友好,又能兼顾内存友好。

Redis 的定期删除过程是随机的(周期性地随机从设置了过期时间的 key 中抽查一批),所以并不保证所有过期键都会被立即删除。这也就解释了为什么有的 key 过期了,并没有被删除。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

另外,定期删除还会受到执行时间和过期 key 的比例的影响:

  • 执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。
  • 如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。

expire.c中定义了每次随机抽查的数量,Redis 7.2 版本为 20 ,也就是说每次会随机选择 20 个设置了过期时间的 key 判断是否过期。定期删除的频率是由 hz 参数控制的。hz 默认为 10,代表每秒执行 10 次,也就是每秒钟进行 10 次尝试来查找并删除过期的 key。hz 的取值范围为 1~500。增大 hz 参数的值会提升定期删除的频率。如果你想要更频繁地执行定期删除任务,可以适当增加 hz 的值,但这会加 CPU 的使用率。根据 Redis 官方建议,hz 的值不建议超过 100

内存淘汰策略

Redis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发,这个阈值是通过redis.confmaxmemory参数来定义的。64 位操作系统下,maxmemory 默认为 0 ,表示不限制内存大小。32 位操作系统下,默认的最大内存值是 3GB。

Redis 提供了 6 种内存淘汰策略:

  1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。
  4. allkeys-lru(least recently used):从数据集(server.db[i].dict)中移除最近最少使用的数据淘汰。
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰。
  6. no-eviction(默认内存淘汰策略):禁止驱逐数据,当内存不足以容纳新写入数据时,新写入操作会报错。

4.0 版本后增加以下两种:

  1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。
  2. allkeys-lfu(least frequently used):从数据集(server.db[i].dict)中移除最不经常使用的数据淘汰。

allkeys-xxx 表示从所有的键值中淘汰数据,而 volatile-xxx 表示从设置了过期时间的键值中淘汰数据。

Redis事务

Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断(满足隔离性)。

  • 原子操作,不满足原子性:事务内的命令按顺序执行,但 不支持回滚(若某个命令失败,后续命令仍会执行)。
  • 满足隔离性:事务执行期间不会被其他客户端命令打断。
  • 不满足一致性:执行事务前后数据不一定一致,命令可能部分成功。
  • 持久性:Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:快照(snapshotting,RDB)、只追加文件(append-only file, AOF)、RDB 和 AOF 的混合持久化(Redis 4.0 新增)。AOF 持久化的fsync策略为 no、everysec 时都会存在数据丢失的情况 。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。因此,Redis 事务的持久性也是没办法保证的。
MULTI
SET key1 value1
SET key2 value2
EXEC

Redis 事务实际开发中使用的非常少。除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互(性能较差),这是比较浪费资源的行为。Redis 事务是不建议在日常开发中使用的。

持久化

Redis的读写操作都是在内存中,所以Redis性能才会高,但是当Redis重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在Redis重启就能够从磁盘中恢复原有的数据。Redis共有三种数据持久化的方式:

  • AOF日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
  • RDB快照:将某一时刻的内存数据,以二进制的方式写入磁盘;

AOF日志

AOF(Append-Only File):Redis在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后Redis重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。

img

AOF 提供了三种同步策略(通过 appendfsync 配置):

  • always:每次写操作都同步到磁盘,数据安全性最高,但性能最差。
  • everysec:每秒同步一次,性能和安全性平衡(默认配置)。
  • no:由操作系统决定何时同步,性能最好,但数据丢失风险最高。

优点:

  • 数据安全性高:AOF 可以做到秒级数据持久化,数据丢失风险低。即使文件损坏,还提供了redis-check-aof工具修复。
  • 可读性强:AOF 文件是文本文件,记录了所有写操作,便于分析和修复。

缺点:

  • 文件体积大:AOF 文件通常比 RDB 文件大(记录了每一个写操作)。
  • 性能影响:频繁的磁盘IO操作(特别是always同步策略)影响Redis的写入性能。
  • 恢复速度慢:AOF 文件需要逐条执行命令来恢复数据,速度较慢。

RDB快照

RDB(Redis Database):RDB 是 Redis 默认的持久化方式,它通过生成数据快照(Snapshot)将内存中的数据保存到磁盘上的二进制文件(.rdb 文件)中。

优点:

  • 性能高:RDB 文件是紧凑的二进制文件,恢复速度快。
  • 适合备份:可以定期生成 RDB 文件,用于数据备份和灾难恢复。
  • 文件体积小:相比 AOF,RDB 文件占用的磁盘空间更小。

缺点:

  • 数据丢失风险、数据不一致:如果 Redis 崩溃,最后一次快照之后的数据会丢失。
  • 不适合实时持久化:RDB 是定时快照,无法做到秒级数据持久化。

Redis性能优化

批量操作

使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。

原生批量操作:mget,mset,hmget,sadd……单条批量命令本身是原子的。分片集群方案下,存在问题MGET 无法保证所有的 key 都在同一个 hash slot(哈希槽)上,MGET可能还是需要多次网络传输,原子操作也无法保证。

整个步骤的简化版如下(通常由 Redis 客户端实现,无需我们自己再手动实现):

  1. 找到 key 对应的所有 hash slot;
  2. 分别向对应的 Redis 节点发起 MGET 请求获取数据;
  3. 等待所有请求执行结束,重新组装结果数据,保持跟入参 key 的顺序一致,然后返回结果。

Redis Cluster 并没有使用一致性哈希,采用的是 哈希槽分区 ,每一个键值对都属于一个 hash slot(哈希槽) 。当客户端发送命令请求的时候,需要先根据 key 通过上面的计算公式找到的对应的哈希槽,然后再查询哈希槽和节点的映射关系,即可找到目标 Redis 节点。

pipeline:对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。

  • 与原生操作对比:原生批量操作命令是原子操作,pipeline 是非原子操作(命令逐个执行)。pipeline 可以打包不同的命令,原生批量操作命令不可以。原生批量操作命令是 Redis 服务端支持实现的,而 pipeline 需要服务端和客户端的共同实现。

  • 与事务对比:事务是原子操作,pipeline 是非原子操作。两个不同的事务不会同时运行,而 pipeline 可以同时以交错方式执行。Redis 事务中每个命令都需要发送到服务端,而 Pipeline 只需要发送一次,请求次数更少。

    • 事务可以看作原子操作,但并不满足原子性。当我们提到 Redis 中的原子操作时,主要指的是这个操作(比如事务、Lua 脚本)不会被其他操作(比如其他事务、Lua 脚本)打扰。

Lua脚本:它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。支持复杂逻辑(条件判断、循环、组合多命令)。

  • 如果 Lua 脚本运行时出错并中途结束,后续命令是不会被执行的(和事务对比!!)。并且,出错之前执行的命令是无法被撤销的。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。

Redis 7.0 新增了 Redis functions 特性,可以看作是比 Lua 更强大的脚本。

对比总结

特性 事务 原生批量操作 Pipeline Lua 脚本
原子性 部分(无回滚) 单命令原子 全原子
网络开销 高(多次往返) 低(单次往返) 低(单次往返) 低(单次往返)
灵活性 低(仅顺序执行) 低(固定命令) 高(任意命令组合) 高(支持复杂逻辑)
性能 最高 中(依赖脚本复杂度)
错误处理 需手动处理 自动回滚 需解析响应 脚本内处理
适用场景 简单批量操作 同类型数据批量处理 高吞吐非原子操作 复杂原子操作

Bigkey

单个 Key 的 Value 占用内存过大,或数据结构中元素过多。例如:

  • String 类型:Value 超过1MB。
  • Hash/List/Set/ZSet:元素数量超过 1 万,或总内存超过 1MB。

bigkey 通常是由于下面这些原因产生的:

  • 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
  • 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
  • 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。

危害:

  1. 内存不均衡:导致集群数据倾斜,部分节点内存压力大
  2. 阻塞主线程:删除 BigKey 时可能触发内存回收阻塞(如删除大 Hash)。
  3. 网络拥塞:序列化/反序列化耗时增加,影响吞吐量。
  4. 持久化问题fork 子进程生成 RDB 时,内存拷贝延迟高。

检测方法:

  1. redis-cli --bigkeys 命令缺点:线上慎用(全表扫描可能阻塞服务)。

    redis-cli -h 127.0.0.1 -p 6379 --bigkeys
  2. 自定义扫描(SCAN + 类型分析)

    # 扫描 Hash 类型 Key 的元素数量
    redis-cli --scan --pattern '*' | xargs -L 1 redis-cli type | grep hash | awk '{print $1}' | xargs -L 1 redis-cli hlen
  3. 监控工具RedisInsight:可视化分析内存分布。监控报警:通过 INFO memory 观察内存波动。

解决方案:

  1. 拆分 Key:将大 Hash 拆分为多个子 Hash(如 user:1000:infouser:1000:base + user:1000:contact)。
  2. 使用合适的数据结构:替代大 String:使用 Hash 存储字段。替代大 List:分片为多个 List(如 list:part1list:part2)。
  3. 异步删除:使用 UNLINK 替代 DEL(Redis 4.0+)。
  4. 设置 TTL:对非核心数据设置过期时间,避免长期驻留。

Hotkey

某个 Key 的访问频率远高于其他 Key(如每秒数万次读/写),通常出现在缓存击穿、高频计数器等场景。

危害:

  1. 单节点压力大:若 Key 集中在某一节点(如集群模式),导致 CPU 和网络过载。
  2. 性能瓶颈:高并发访问可能触发连接数限制或线程阻塞。
  3. 缓存击穿:热 Key 突然过期,大量请求直接穿透到数据库。

检测方法:

  1. redis-cli --hotkeys 命令(需启用 LFU 策略):

    # 配置 LFU(redis.conf)
    maxmemory-policy volatile-lfu
    redis-cli --hotkeys
  2. 监控工具Redis 监控系统(如 Prometheus + Grafana):观察 QPS 分布。业务日志分析:统计高频访问的 Key。

  3. 代理层统计:通过代理(如 Redis Cluster Proxy)记录 Key 访问频率。

解决方案:

  1. 本地缓存:客户端缓存热 Key(如 Guava Cache),降低 Redis 压力。设置合理的本地缓存过期时间,避免数据不一致。
  2. 分片打散:对热 Key 增加随机后缀(如 hotkey:1234hotkey:1234_{1..N}),分散到多个 Key。
  3. 读写分离:读操作分流到从节点(注意数据同步延迟)。
  4. 使用 Redis 集群:通过集群模式分散热 Key 到不同节点(需确保 Hash Tag 合理)。

慢查询

执行时间超过阈值的命令(默认 10ms),常见于复杂操作或 BigKey 操作。

危害:

  1. 阻塞主线程:单线程模型下,慢查询会阻塞后续所有命令。
  2. 超时风险:客户端等待响应时间过长,触发连接超时。
  3. 资源消耗:长时间占用 CPU 和内存。

检测方法:

  1. 慢查询日志

    # 配置慢查询阈值(redis.conf)
    slowlog-log-slower-than 10000  # 单位微秒(默认 10ms)
    slowlog-max-len 128            # 最多记录 128 条慢查询
    # 查看慢查询日志
    SLOWLOG GET 10  # 获取最近 10 条慢查询
  2. 监控系统:通过 INFO commandstats 统计命令耗时。集成 APM 工具(如 SkyWalking)追踪 Redis 操作。

常见慢查询场景:

  1. 大 Key 操作HGETALL 大 Hash、LRANGE 大 List。
  2. 复杂命令ZUNIONSTORESINTER 等多集合操作。
  3. 低效查询KEYS *FLUSHALL(阻塞式命令)。

解决方案:

  1. 优化命令:使用 SCAN 替代 KEYSHSCAN 替代 HGETALL避免在循环中执行 Redis 命令。
  2. 拆分操作:将 ZUNIONSTORE 分批执行,或提前计算并缓存结果。
  3. 使用 Pipeline/Lua 脚本:减少网络往返(但需控制脚本复杂度)。

生产问题

缓存穿透、击穿、雪崩

  • 穿透:大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。大量请求打到数据库。

    • 解决:做好参数校验,防止非法参数请求。缓存无效Key,缓解非法key变化不频繁的情况。布隆过滤器,请求值不存在直接返回参数错误。接口限流,根据用户或ip的异常频繁访问,采取黑名单机制。
  • 击穿:热点key存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。导致瞬时大量请求打到数据库。

    • 解决:延长过期时间。提前预热(推荐)。互斥锁保证失效后只有一个请求去查询数据库并更新缓存。
  • 雪崩:多个缓存在同一时间大面积失效,导致大量请求落到数据库。

    • 解决:redis集群。多级缓存。随机失效时间。提前预热。持久缓存策略。

常见的缓存预热方式有两种:

  1. 使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
  2. 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。

缓存一致性

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

如果删除了缓存更新数据库的操作没有成功,此时查询数据的请求会把旧数据存储到缓存中。

2、先更新数据库,再删除缓存。

  • 如果更新了数据库,删除缓存的操作失败了,此时查询数据的请求查到的数据仍然是旧数据。

  • 线程A读取商品数据,刚好缓存失效了,去查询数据库数据,刚要执行放入redis缓存时,CPU发生上下文切换,线程A暂时得不到执行,此时线程B修改数据,执行update商品操作,然后删除缓存,线程A执行时,将之前查询到的旧的数据存到redis缓存中,此时就会出现redis和数据库的数据不一致的情况。

为什么是删除缓存而不是更新缓存?

3、延迟双删,先删除缓存、再更新数据库,再延迟一定的时间去删除缓存。

  • 为什么要两次删除缓存,因为有可能第一次删除缓存后其它查询请求将旧数据存储到了缓存。

  • 为什么要延迟一定的时间去删除缓存,为了给mysql主向从同步的时间,如果立即删除缓存很可能其它请求读到的数据还是旧数据。

  • 延迟的时间不好确定,延迟双删仍然可能导致脏数据。

所以结论:以上方案当存在高并发时都无法解决数据库和缓存强一致性的问题。

如何做缓存一致性?需要根据需求来定:

1、实现强一致性 需要使用分布式锁控制,修改数据和向缓存存储数据使用同一个分布式锁。(数据修改的同时更新缓存-同一个事务)

2、实现最终一致性,缓存数据要加过期时间,即使出现数据不致性当过期时间一到缓存失效又会从数据库查询最新的数据存入缓存。

3、对于实时性要求强的,要实现数据强一致性要尽量避免使用缓存,可以直接操作数据库。

使用工具对数据进行同步方案如下:

1、使用任务表加任务调度的方案进行同步。

2、使用Canal基于MySQL的binlog进行同步。

集群

主从同步

主从同步是 Redis 实现 数据冗余高可用性 的核心机制。通过将一台 Redis 服务器(主节点,Master)的数据复制到其他服务器(从节点,Replica),实现以下目标:

  • 数据备份:从节点作为主节点的数据副本,防止数据丢失。
  • 读写分离:主节点处理写请求,从节点处理读请求,提升吞吐量。
  • 故障恢复:当主节点宕机时,从节点可提升为新的主节点,保障服务可用性。

主从同步分为 全量同步(Full Sync)增量同步(Partial Sync) 两种模式:

  • 全量同步:发生在 初次同步/从服务器数据丢失/主服务器数据发生变化 这些情况下。流程如下:

    img
    1. 初次连接:从节点向主节点发送 PSYNC 命令请求同步。
    2. 生成 RDB 快照:主节点执行 BGSAVE 生成当前数据的 RDB 文件。
    3. 传输 RDB:主节点将 RDB 文件发送给从节点,从节点加载到内存。
    4. 主节点缓存写命令:主节点在生成 RDB 期间,将新的写命令缓存到 replication buffer
    5. 同步增量数据:RDB 传输完成后,主节点将缓冲的写命令发送给从节点,确保数据一致性。
  • 增量同步
    当从节点与主节点短暂断开后重新连接时,主节点仅发送断开期间缺失的写命令,依赖以下机制:

    img
    • Replication ID:主节点的唯一标识,重启或切换主节点时会改变。
    • Offset:记录主从节点的数据同步偏移量。若从节点的 Offset 仍在主节点的复制积压缓冲区(repl_backlog)范围内,则触发增量同步。

主要有三个步骤:

  • 从服务器在恢复网络后,会发送psync命令给主服务器,此时的psync命令里的offset参数不是-1;
  • 主服务器收到该命令后,然后用CONTINUE响应命令告诉从服务器接下来采用增量复制的方式同步数据;
  • 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。
增量数据

在 Redis 的主从同步中,增量同步(Partial Sync) 是通过 复制偏移量(Replication Offset)复制积压缓冲区(Replication Backlog) 来实现的。

复制偏移量(Replication Offset)

  • 主节点:每次执行写操作后,会记录一个全局的复制偏移量(master_repl_offset),表示当前写操作的字节位置。
  • 从节点:从节点也会记录自己已经复制的偏移量(slave_repl_offset),表示从节点已经同步到主节点的哪个位置。

复制积压缓冲区(Replication Backlog)

  • 作用:主节点会将最近的写操作命令存储在一个固定大小的环形缓冲区(repl_backlog)中,用于支持增量同步。
  • 大小:通过 repl-backlog-size 参数配置,默认大小为 1MB。
  • 内容:缓冲区中存储的是写操作的字节流,而不是具体的命令。

复制 ID(Replication ID)

  • 作用:每个主节点都有一个唯一的复制 ID,用于标识主节点的复制流。
  • 变化:当主节点重启或发生主从切换时,复制 ID 会改变。

增量同步的触发条件是:

  1. 从节点与主节点的复制 ID 一致:表示从节点之前是从该主节点同步的。
  2. 从节点的复制偏移量仍在主节点的复制积压缓冲区范围内:即 slave_repl_offsetmaster_repl_offset - repl_backlog_sizemaster_repl_offset 之间。

如果满足以上条件,主节点会从复制积压缓冲区中提取从节点缺失的数据,并发送给从节点。

  1. 从节点连接主节点
    从节点向主节点发送 PSYNC 命令,携带自己的复制 ID 和复制偏移量。

    PSYNC <replication-id> <offset>
  2. 主节点检查复制 ID 和偏移量

    • 如果复制 ID 匹配且偏移量在复制积压缓冲区范围内,主节点回复 +CONTINUE,表示可以执行增量同步。
    • 否则,主节点回复 +FULLRESYNC,表示需要执行全量同步。
  3. 主节点发送增量数据

    • 主节点从复制积压缓冲区中提取从节点缺失的数据(从 slave_repl_offsetmaster_repl_offset 之间的数据)。
    • 将这些数据以字节流的形式发送给从节点。
  4. 从节点应用增量数据

    • 从节点接收到增量数据后,将其应用到自己的数据库中。
    • 更新自己的复制偏移量(slave_repl_offset)。

哨兵机制

Redis 哨兵(Sentinel)是 Redis 官方提供的 高可用性(HA)解决方案,用于管理主从架构中的故障自动检测与恢复。通过哨兵机制,Redis 可以实现主节点的自动故障转移(Failover)、配置中心化和客户端服务发现。

img

Redis在2.8版本以后提供的哨兵(Sentinel)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

哨兵其实是一个运行在特殊模式下的Redis进程,所以它也是一个节点。从“哨兵”这个名字也可以看得出来,它相当于是“观察者节点”,观察的对象是主从节点。当然,它不仅仅是观察那么简单,在它观察到有异常的状况下,会做出一些”动作”,来修复异常状态。

哨兵节点主要负责三件事情:监控、选主、通知。

参考

黑马程序员redis:https://www.bilibili.com/video/BV1cr4y1671t/?p=16&spm_id_from=pageDriver&vd_source=bf952648bf410c0b9b23bf213e3d24ba

redis常用命令:https://blog.csdn.net/weixin_49851451/article/details/134311296

Redis常见面试题总结(上) | JavaGuide

数据结构:一文彻底搞懂Redis底层数据结构-CSDN博客 博客部分内容有误,借鉴文字

图源:redis的5种数据结构及其底层实现原理_redis5种数据类型对应底层结构-CSDN博客


文章作者: wolf-ll
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 wolf-ll !
  目录