Redis
谈谈你对 Redis 的理解
- redis 是一种基于内存存储的 NoSQL 开源数据库,它提供了五种基本的数据类型:String、List、Hash、Set、Zset。
- 因为 Redis 基于内存存储,并且在数据结构上进行了大量的优化,所有它的 IO 性能比较好,因此,在实际开发中,我们会把它作为数据库和应用之间的缓存中间件。
- 并且因为它是非关系型数据库,所以不存在表结构之间的关联,这样能够很好的提升应用程序的数据 IO 效率。
- 在企业级开发中,它又提供了主从复制和哨兵,以及集群的方式去使用。在 Redis 集群中,它提供了 Hash 槽的方式去实现数据的分片,进一步提升了性能和可拓展性。
缓存
- 本地缓存:缓存在内存中。比如 LRUMap
- 优点:访问快
- 缺点:内存空间有限,缓存内容少
- 分布式缓存:
- 优点:解决了内存空间有限的问题
- 缺点:远程交互耗时
- 多级缓存:本地只保存需要高频率访问的热点数据,其他数据保存在分布式缓存中。
缓存淘汰机制
- FIFO: First-In-First-Out,删除最先缓存的。
- LRU: Least-Recently-Used,最近最少使用。删除掉最近不太可能访问到的数据。如果缓存中有些数据最近都没有被访问过,那就认为它之后被访问的概率低。
- 用 LinkedHashMap 实现
- 如果自己写节点的话,
LinkedNode
- int key;
- int value;
- LinkedNode pre;
- LinkedNode next;
- LFU: Least-Frequency-Used:最不经常使用。删除掉最近访问频率很低的数据。如果缓存中有数据最近被访问的次数很少,那就认为它以后被访问的概率也低。
Redis 为什么快?
- Redis 是基于内存的,而内存的读取速度要远大于磁盘的读取速度。
- Redis 是单线程模型,所以没有线程切换和上下文切换的开销。
- Redis 采用了 IO 多路复用模型,可以处理并发连接。
Redis 内存管理
Redis 是如何判断数据是否过期的呢?
用一个过期时间字典来维护数据的过期时间。
Redis 的过期删除策略
过期删除策略
- 惰性删除:只有取出 key 的时候,才判断是否需要过期删除。对 CPU 友好,对内存不友好。
- 定期删除:定期取出一部分 key 进行过期删除。对内存友好,对 CPU 不友好。
Redis 采用的过期删除策略
- 定期删除 + 惰性删除 + 内存淘汰机制
Redis 内存淘汰机制了解么?
相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?
Redis 提供 6 种数据淘汰策略:
- volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
4.0 版本后增加以下两种:
- volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
- allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
Redis 内存模型
Redis 单线程模型
基于 Reactor 模型开发的单线程模型, 称为文件事件处理器。它采用 IO 多路复用来监听多个套接字。
IO 多路复用
- IO 多路复用就是一个 IO 模型,他可以实现一个线程监听多个文件句柄,只要有一个文件句柄就绪,就可以通知应用程序进行相应的读写操作。如果没有文件句柄就绪,那么就会阻塞应用程序,交出 CPU。
文件句柄:操作系统对于打开的文件的唯一识别依据。
如何监听大量的客户端连接?
虽然文件事件处理器是单线程的,但是使用IO 多路复用来监听多个套接字。
BIO:同步阻塞。一个线程处理一个请求(连接),一个线程在处理一个连接时,其他的请求会被阻塞。
NIO:同步非阻塞。指 IO 多路复用,一个线程处理多个请求(连接)。
AIO:异步非阻塞。用户态访问内核态,内核态会立即返回一个标记,待处理完所有事件后,发送信号返回给用户进程。
Redis 数据持久化
RDB — 数据快照
- 是一个经过压缩后的二进制文件,默认开启。用于:
- 数据备份。
- 复制到从服务器进行主从复制。
- redis 会启动定时任务,定时去 fork 子进程来进行 RDB 备份。
(1) 同步方式
- bgsave 时,Redis 会 fork 一个子进程进行持久化,然后先将数据写入一个临时文件中,等数据全部写完后,再把临时文件替换掉上次持久化好的文件。
Fork
- Fork 是指复制一个和当前进程一样的子进程。新进程所有的数据(变量,环境变量,程序计数器等)和原来进程一样。一般来说,父进程和子进程共用一段物理内存。
- Fork 的时候,会阻塞主线程。会带来什么问题?
- redis 是单线程的,fork 时,主线程被阻塞,可能会丢失数据,所以可以用一个缓存区来保存 redis 主线程阻塞时接受的数据。
写时复制技术
属于Linux的一种技术,即数据同步之前先创建临时文件,将数据保存进临时文件,等同步完成后,再将临时文件替换掉同步的数据内容。
- 为什么要写时复制技术?
- 如果不采用临时复制技术,在同步过程中,发生程序中断,那么同步的数据就不完整。
(2) 同步指令
save
:立即同步,同时阻塞 redis,当同步完成后,再恢复 redisbgsave
:fork 出一个子线程来进行 RDB 同步。
AOF - 只追加文件
Append only file,默认关闭。可以在 redis.conf 文件中,通过 appendonly yes
打开。
- AOF 以日志形式保存 Redis 每个写操作(不保存读操作)。
- 只追加文件,不修改文件。
- 通过 AOF 缓冲区,而不是 fork 子进程来实现追加数据,但是文件太大时重写是通过 fork 进程实现的。
(1) 同步方式
- 客户端的请求写命令会被 append 到 AOF 缓冲区。
- AOF 缓冲区会根据持久层策略(always, everysec, no)将写操作追加到磁盘的 AOF 文件末尾。这里只追加文件,不修改文件。
- 在 Redis 服务启动时,会读取这个 AOF 文件,并且执行上面的写操作。
- 当文件大小大于 server.aof_rewrite_min_size,并且当前 AOF 文件大小和上一次重写时 AOF 文件大小差的比例达到 auto-aof-rewrite-percentage 时,则重写文件。
(2) AOF 策略
- always:
appendfsync always
,只要有写变化,就写入 appendonly.aof 日志 - everysec:
appendfsync everysec
,每秒同步一次数据到磁盘。(推荐) - no:
appendfsync no
,让操作系统决定何时将数据写入磁盘。
重写
- 当持久化的文件过大时,Redis 会 fork 子进程压缩并重写文件,然后把新文件替换掉原来的文件。
RDB和AOF同时开启,Redis选择哪一个?
- Redis默认选取AOF数据
Redis 数据类型
基本数据类型
String
- 常用指令:
- SET key value
- GET key
- INCR key:key 保存的值+1
- DECR key:key 保存的值-1
- 使用场景:
- 做用户的 Session 管理。
- 博客访问量情况
- 底层原理:
- SDS(Simple Dynamic String)struct sdshdr{ int len; // 字符串长度 int free; // 未使用字符数组长度 char[] buf; // 字符数组 }
- 避免缓冲区溢出:String 进行修改时,Redis会检查剩余空间是否满足要求,不满足的话会扩展到足够的空间来保存 String。
- 空间预分配:String 扩容时,Redis会给予足够多的内存空间。
- 惰性释放:字符串需要缩短时,所需空间不会直接缩短,而是用 free 保存剩余空间,以便再次分配。
- String 长度不能超过 512m
List
有序列表
- 常用指令
- LPUSH
- RPUSH
- LPOP
- RPOP
- LRANGE key start stop
- 使用场景:可以用来实现分页查询,或者读取用户评论数
- 底层原理:
3.0以前:用ziplist + linkedlist保存
- 当列表长度小于512,且所有元素都小于64字节时,用ziplist保存
- 否则,用LinkedList保存
LinkedList保存了头节点head,尾节点tail,列表长度
LinkedList:
3.0以后:用quicklist保存。这样可以节省双向列表保存pre和next指针的空间。
- quicklist 是 ziplist 和 linkedlist 的组合
- ziplist 是内存中一段连续的存储区域,元素用 entry 表示。
- linkedlist 是一个双向链表,节点保存了 pre、ziplist、next
Hash
- 常用指令:
- HSET key field value
- HGET key field
- HDEL key field
- HEXISTS key field
- HKEYS key
- 使用场景:保存一个对象,一个对象有多个属性。
- 我项目中用来保存用户分布地域情况
- 底层原理:
- 当 Hash 中键值对少于512对,且每个键值对大小不超过64字节时,用 ziplist 保存
ziplist:
- 内存中连续的存储空间
- 元素用entry表示
zlbytes: ziplist长度
zltail:ziplist尾部偏移量
zllen:entry个数
entry:元素
zlend:0xFF,标识结尾
- 否则用 HashTable 保存。
HashTable:
- 一个 dictht 指向一个数组
- 每个数组上的键值对用链表形式保存
- 扩容
- 基于原 Hash 表的2倍创建一个新的哈希表,然后把旧哈希表的内容转移到新哈希表里。
- 渐进式 rehash:
- 旧哈希表的内容不会一下子转移到新哈希表,而是渐进式转移过去,否则一下子转移过去,可能会导致服务器在一段时间内停止服务。
- 用一个rehashidx字段来记录,rehashidx为0表示开始迁移。
- 在rehash期间,每次对字典执行增删改查操作是,程序除了执行指定的操作以外,还会顺带将
ht[0]
哈希表在rehashindex
索引上的所有键值对 rehash 到 新哈希表,当 rehash 工作完成以后,rehashindex
的值 +1 - 当所有数据都从旧哈希表里转移过去后,释放旧哈希表的空间。rehashidx置为-1。
- 收缩
- 类似于扩容,但是收缩成原哈希表1/2倍的空间。
- 负载因子
Redis中,
loader_factor
:哈希表中键值对数量 / 哈希表长度
。HashMap中,
loader_factor
:哈希 table 已用的数量 / 哈希表长度
。
- 当redis没有执行持久化操作(BGSAVE or BGREWAITEAOF),负载因子是1
- 当redis执行持久化操作(BGSAVE or BGREWAITEAOF),负载因子是5
Hash 底层和 Java HashMap 的区别?
- 渐进式 hash
- 负载因子不同
Set
- 常用指令:
SADD
:添加SCARD
:获取集合成员数SDIFF key1 [key2]
:差集SINTER key1 [key2]
:交集
- 使用场景:Set可以做并集、交集、差集。可以用来查看好友的共同列表。
- 我在项目中用于文章点赞的信息
- articleListKey 中保存了每个用户点过赞的文章(set 结构,articleListKey, articleId)
- ARTICLE_LIKE_COUNT 保存了每个文章的点赞量(hash 结构,ARTICLE_LIKE_COUNT, articleId,赞数)
@Transactional(rollbackFor = Exception.class)
@Override
public void saveArticleLike(Integer articleId) {
// 判断是否点赞
String articleLikeKey = ARTICLE_USER_LIKE + UserUtils.getLoginUser().getUserInfoId();
// 已经点过赞了, 删除文章id
if (redisService.sIsMember(articleLikeKey, articleId)) {
// 取消点赞
redisService.sRemove(articleLikeKey, articleId);
// 文章点赞数-1
redisService.hDecr(ARTICLE_LIKE_COUNT, articleId.toString(), 1L);
} else {
// 未点赞则增加文章id
redisService.sAdd(articleLikeKey, articleId);
// 文章点赞量+1
redisService.hIncr(ARTICLE_LIKE_COUNT, articleId.toString(), 1L);
}
}
- 底层原理:
- set元素少于512个,用 intset 来存储
- 否则,存储类似 hashtable,只是 value 赋 null
Zset
- 常用指令:
ZADD key score member
ZCARD key
:获取成员数ZCOUNT key min max
: 获取指定分数区间(min到max)的成员数量
- 使用场景:
- 做排行榜
- 按照浏览量
- 按照视频播放量
- 按照点赞量
- 微博热搜榜,名称+热力值
- 底层原理:
- 当 zset 长度小于128,且每个元素大小小于64k,用 ziplist
- 否则,用跳跃表
跳表:和红黑树一样,插入、删除、搜索的时间复杂度都为O(logN)
(1) 查询
- 查询:时间复杂度是 跳表高度 h * 遍历的元素数量
- 那么跳表的每层都是上一层拿一半出来当索引,所以 L_1 = n,L_2 = n/2,L_3 = n/2^2,...,L_h = n/2^h
最后一层节点只有2个,于是 L_h = 2 = n/2^h,h=log_2n-1 - 又每层节点遍历次数不超过3,所以时间复杂度都为 O(3*(log_2N-1))=O(log_2n)
(2) 插入( 跳表何时增加高度?)
- ZSET 底层维护了一
randomLevel()
函数,该方法有 1/2 的概率返回 1、1/4 的概率返回 2、1/8的概率返回 3,以此类推。
- randomLevel() 方法返回 1 表示当前插入的该元素不需要建索引,只需要存储数据到原始链表即可(概率 1/2)
- randomLevel() 方法返回 2 表示当前插入的该元素需要建一级索引(概率 1/4)
- randomLevel() 方法返回 3 表示当前插入的该元素需要建二级索引(概率 1/8)
- randomLevel() 方法返回 4 表示当前插入的该元素需要建三级索引(概率 1/16)
- 并且,建立二级索引的时候,同时也会建立一级索引....以此类推
怎么保证每层索引节点个数都是前一层索引的一半呢?
- 凡是建索引,无论几级索引必然有一级索引,所以一级索引中元素个数占原始数据个数的比率为 randomLevel() 方法返回值 > 1 的概率。那 randomLevel() 方法返回值 > 1 的概率是多少呢?因为 randomLevel() 方法随机生成 1~MAX_LEVEL 的数字,且 randomLevel() 方法返回值 1 的概率为 1/2,则 randomLevel() 方法返回值 > 1 的概率为 1 - 1/2 = 1/2。即通过上述流程实现了一级索引中元素个数占原始数据个数的 1/2。
- 同理,当 randomLevel() 方法返回值 > 2 时,会建立二级或二级以上索引,都会在二级索引中增加元素,因此二级索引中元素个数占原始数据的比率为 randomLevel() 方法返回值 > 2 的概率。 randomLevel() 方法返回值 > 2 的概率为 1 减去 randomLevel() = 1 或 =2 的概率,即 1 - 1/2 - 1/4 = 1/4。OK,达到了我们设计的目标:二级索引中元素个数占原始数据的 1/4。
(3) 删除
- 跳表删除数据时,要把索引中对应节点也要删掉
(4) 使用 ZSET 实现游戏排行榜
设 lb 是排行榜名,user 有 user1,user2
- 设置玩家分数:
ZADD key score member
- ZADD lb 89 user1
- ZADD lb 91 user2
- ZADD lb 70 user3
- ZADD lb 98 user4
- 查看玩家分数:
ZSCORE key member
- ZSCORE lb user1 —> 89
- 按名次查看排行榜:
ZREVRANGE key start end [WITHSCORES]
- ZREVRANGE lb 0 -1 WITHSCORES
- -1 表示整个排行榜
(1) user4 (2) 98 (3) user2 (4) 91 (5) user1 (6) 89 (7) user3 (8) 70
- ZREVRANGE lb 0 2 WITHSCORES
- 查询前三的玩家
- 查看玩家的排名:
ZREVRANK key member
- 增减玩家分数:
ZINCRBY key incrScore member
- 移除玩家:
ZREM key member
- 删除排行榜:
DEL key
相同分数问题:
- 如果两个分数相同的用户想按照加入 zset 的先后顺序排序,可以考虑用时间戳
高级数据类型
Bitmap
- 按bit位存储。
- 二进制数字位的 0、1 数组。数组下标用 offset 维护。
- 可以用来保存状态信息。
HyperLogLog
- 基数统计。
- 用于去重(合并的时候,相同的数据只计算一次。)
Geospatial
提供地理位置信息。
其他
pub/sub
发布/订阅通信模式。可以用作消息队列(但性能不如 RabbitMQ、Kafka)
- client1,client2,client5都订阅了channel1。
- channel1广播message,订阅了它的client都能收到。
Pipeline
可以批量执行一系列操作,再一起返回结果。避免频繁请求响应。
Lua
redis可以使用Lua解释器来执行脚本。
Redis 事务
事务操作
multi
开启事务- 执行一系列操作,每个操作都会进入队列。
exec
按顺序执行事务
事务特性
- Redis 的事务不支持回滚
- 入队前的错误会使事务失败
- 入队后,如果发生错误,只有发生错误的事务会失败,其他事务仍然成功
- 可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。
分布式锁
基于 Redis 分布式锁
setnx(lock, uuid, 30, TimeUnit.second)
基于 Redisson 看门狗的分布式锁
前面说了,如果某些原因导致持有锁的线程在锁过期时间内,还没执行完任务,而锁因为还没超时被自动释放了,那么就会导致多个线程同时持有锁的现象出现,而为了解决这个问题,可以进行“锁续期”。其实,在 JAVA 的 Redisson 包中有一个"看门狗"机制,已经帮我们实现了这个功能。
- redisson原理:
- 在获取锁之后,redisson 会维护一个看门狗线程,当锁即将过期还没有释放时,会不断的延长锁 key 的生存时间(默认 30 秒)
- redisson 分布式锁是一个可重入排他锁。
- 加锁机制:
- 线程去获取锁,获取成功:执行 lua 脚本,保存数据到 redis 数据库。
- 线程去获取锁,获取失败:一直通过 while 循环尝试获取锁,获取成功后,执行 lua 脚本,保存数据到 redis 数据库。
- watch dog 自动延期机制:
- 看门狗启动后,对整体性能也会有一定影响,默认情况下看门狗线程是不启动的。如果使用 redisson 进行加锁的同时设置了锁的过期时间,也会导致看门狗机制失效。
- redisson 在获取锁之后,会维护一个看门狗线程,在每一个锁设置的过期时间的 1/3 处(默认 10 秒),如果线程还没执行完任务,则不断延长锁的有效期。看门狗的检查锁超时时间默认是 30 秒,可以通过 lockWactchdogTimeout 参数来改变。
- 加锁的时间默认是30秒,如果加锁的业务没有执行完,那么每隔 30 ÷ 3 = 10秒,就会进行一次续期,把锁重置成30秒,保证解锁前锁不会自动失效。
- 那万一业务的机器宕机了呢?如果宕机了,那看门狗线程就执行不了了,就续不了期,那自然30秒之后锁就解开了。
- redisson 分布式锁的关键点:
- 对 key 不设置过期时间,由 Redisson 在加锁成功后给维护一个 watchdog 看门狗,watchdog 负责定时监听并处理,在锁没有被释放且快要过期的时候自动对锁进行续期,保证解锁前锁不会自动失效
- 通过 Lua 脚本实现了加锁和解锁的原子操作
- 通过记录获取锁的客户端 id,每次加锁时判断是否是当前客户端已经获得锁,实现了可重入锁。
- Redisson的使用:
- 在方案三中,我们已经演示了基于Redisson的RedLock的使用案例,其实 Redisson 也封装可重入锁(Reentrant Lock)、公平锁(Fair Lock)、联锁(MultiLock)、红锁(RedLock)、读写锁(ReadWriteLock)、 信号量(Semaphore)、可过期性信号量(PermitExpirableSemaphore)、 闭锁(CountDownLatch)等,具体使用说明可以参考官方文档:Redisson的分布式锁和同步器
分布式缓存的问题
缓存穿透
当缓存和数据库里都没有一个数据时,用户恶意请求该数据,导致 redis 频繁请求数据库,给数据库造成极大的压力。比如,产品id不可能为-1,但用户频繁请求产品id为 -1 的数据,导致缓存穿透问题。
解决办法:
- 业务层增加filter接口,对请求进行合法性检查,过滤不合法的请求,比如请求 id 为 -1的数据。
- 缓存中设置一个 null 值,并设置较小的过期时间。当请求没有的数据时,就返回空。当数据库存入该数据后,及时更新缓存。
- 布隆过滤器
- bitmaps
1. 对于一个数key,通过多个hash函数,算出多个值,每个值都在布隆过滤器对应的位置上置1。 2. 当进来一个数,通过多个hash计算,去找对应位置上的值,如果该位置上有0,则该数一定不存在 - 如果位置上都是1,该数==<u>不一定</u>==存在。
优点:
1. 能说明一个数一定不存在
缺点:
1. 不能说明一个数一定存在
2. 不能删除数据。
3. 数据量大的时候,会出现误判
缓存击穿
数据库中有数据,缓存中的该数据过期了。大量用户请求该数据,导致redis频繁读取数据库,给数据库造成极大的压力,甚至崩溃。
解决办法:
- 给热点数据设置成永不过期。
- 对于缓存中没有的数据,如果收到大量请求,则进行加锁,只放一条请求进来,然后去读取数据库,并加载到内存中。接着,再放开这个锁。其他请求需要等待解锁,并且要设置一个阈值,如果等待时间过长,则直接返回空。
缓存雪崩
数据库里有数据,而缓存中正好有大量的数据过期。这时,大量用户请求这些数据,导致 redis 频繁读取数据库,给数据库造成极大的压力,甚至奔溃。
解决办法:
- 给热点数据设置成永不过期。
- 可以考虑给缓存的时间设置波动过期值,避免大量数据一起过期。
- 也可以考虑使用双缓存模式,A缓存中热点数据永不过期,B缓存中热点数据可以过期,当数据过期后,去A缓冲中读取数据。
主从复制
一主多从
特点
- 写读分离:主服务器写,从服务器读,从服务器不能写
- 从服务器挂掉:
- master服务器下的一个 slave1 服务器 s1 挂掉,s1 重启后,会自动变成一个 master 服务器。
把挂掉的服务器重新赋成 master 服务器的 slave,slave 仍然能读到 master 中的数据。
- 主服务器挂掉:
- master 服务器挂掉后,它的 slave 服务器仍然是它的 slave 服务器。
master 重启后,仍然是原来的 master。
主从复制原理
CAP原理:
- C - Consistent ,一致性
- A - Availability ,可用性
- P - Partition tolerance ,分区容忍性 分布式系统的节点往往都是分布在不同的机器上进行网络隔离开的,这意味着必然会有网络断开的风险,这个网络断开的场景的专业词汇叫着「网络分区」。
在网络分区发生时,两个分布式节点之间无法进行通信,我们对一个节点进行的修改操作将无法同步到另外一个节点,所以数据的「一致性」将无法满足,因为两个分布式节点的数据不再保持一致。除非我们牺牲「可用性」,也就是暂停分布式节点服务,在网络分区发生时,不再提供修改数据的功能,直到网络状况完全恢复正常再继续对外提供服务。
一句话概括 CAP 原理就是——网络分区发生时,一致性和可用性两难全。
- Redis 保证「最终一致性」,从节点会努力追赶主节点,最终从节点的状态会和主节点的状态将保持一致。如果网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢复,从节点会采用多种策略努力追赶上落后的数据,继续尽力保持和主节点一致。
主从复制过程
- slave 开启主从复制:设置主服务器 master 的 ip 和 port。主从复制开启全是由从服务器 slave 发起。
可以通过slaveof
命令来设置从服务器的主服务器。
有三种实现方式:
- conf文件中写入:
slaveof master_ip master _port
redis-server --slaveof master_ip master_port
redis-cli
后,输入slaveof master_ip master _port
- 建立 socket 连接:开启主从复制后,master 和 slave 之间建立 socket 连接。
- 检查连接:slave 向 master 发送一个 ping 请求,来检查两者的连接状态。
- 如果 slave 收到 pong,证明连接正常。
- 如果 slave 没有收到 pong,或者收到错误信息,则 m 和 s 断开socket连接,并重连。
- 身份验证:如果 master 和 slave 都没有设置密码或者密码相同,则可以进行同步。否则,断开socket,并且重连。
- 同步:连接正常并且完成身份验证后,slave 向 master 发送
psync
命令,然后 master 和 slave 之间同步数据。slave 把数据库状态同步到和 master 相同。
- 全量复制
- 部分复制
- 命令传播:同步完成后,master 进行的新操作都要传播给 slave。
- 延迟传播:master 积攒多个 tcp 包后,再发送给 slave,通常是 40ms 发一次。提高了性能,损失了一致性。
- 立即传播:master 每进行一次操作,立即把新操作发送给 slave。保证了一致性,降低了性能。
以上通过 master 配置中的 repl-disable-tcp-nodelay 来设置。yes为延迟传播,no为立即传播。
全量复制和部分复制
全量复制
- slave 给 master 发送 sync 命令,请求全量复制
- master 将执行 bgsave,fork 一个子进程来写 RDB 文件,并且使用一个缓存,缓存放入写完 RDB 之后的新操作。
- master 给 slave 发送 RDB 文件,slave 收到以后,删除旧数据,执行 RDB,与 master 进行同步。
- master 给 slave 发送缓存区的写操作,slave 执行并保持和 master 同步的状态。
- 如果 slave 开启了 AOF,则会执行 bgrewriteaof,更新AOF至最新的状态。
部分复制
Redis 2.8 以后,引入部分复制。slave 给 master 发送 psync 命令。然后决定是使用全量复制还是部分复制。
offset + runId
- offset
- master 和 slave 都维护了一个 offset 字段,保存的是现在数据的偏移量。如果 slave 的 offset 和 master 的 offset 一致,则不需要进行数据复制。如果 slave 的 offset 和 master 的 offset 不一致,则需要进行数据同步。
- runid
- master 和 slave 会各自维护一个runid,来决定是进行全量复制还是部分复制。slave 断线重连后,会将自己的 runid 和master 的 runid 进行比较,如果两者的 runid 一致,说明现在的 master 是 slave 断线重连前的 master,则可以进行部分复制。如果 runid 不一致,说明 slave 断线之前连接的 master 和现在的 master 不是一个,那应该进行全量复制。
- 复制挤压缓冲区
- master 维护了一个复制积压缓冲区,这个复制积压缓冲区是一个先进先出的队列。master 在进行命令传播的时候,会把新的写操作写入这个复制挤压缓冲区。旧的写操作会在对头弹出。当 slave 要求进行部分复制的时候,如果需要复制的内容存在于复制积压缓冲区,那么 master 会进行部分复制,如果要复制的内容已经不在复制积压缓冲区,或者已经不完整,那么 master 会进行全量复制。
部分复制过程:
如果 master 返回 -err,说明 master 版本是2.8之前,无法识别 psync 命令。
哨兵模式
- 哨兵每一秒都会向 master、slaves、其他哨兵发送 ping 命令。
- 如果节点回复时间超过设置的时间,则会被标记为主观下线。
如果 master 被标记为主观下线。那么其他的哨兵都会给 master 发送 ping。 - 如果多数哨兵都认为 master 主观下线,那么 master 会被标记为客观下线。
- 哨兵就要重新选举新的 master。新的 master 产生后,所有 slave 的配置文件进行修改,都变成新 master 的slave。
Redis 调优
定位 redis 变慢的原因
# 创建连接,可以用PING-PONG来检查连接是否OK redis-cli -h localhost -p 6379 # 监控Redis的连接和读写操作 redis-cli -h localhost -p 6379 monitor # Redis服务器的统计信息 redis-cli -h localhost -p 6379 info # 查找 bigkey redis-cli -h localhost -p 6379 --bigkeys # 阅读 redis 提供的 slowlog(慢日志),慢日志会记录运行较慢的指令。
可能变慢的原因
- 使用了时间复杂度较高的命令,比如 SORT 之类的。
- redis 返回数据时,有网络问题。
- value 太大导致的 ”bigkey“ 问题,这样 redis 会花费很多时间在分配内存和删除内存上。
redis-cli -h localhost -p 6379 --bigkeys
来查找 bigkey
- 数据集中过期。
- 数据集中过期,会导致 redis 大量执行删除过期 key 的操作,影响速度。
- 内存不够了,所以每次写入内存时,redis 要先进行内存的清理。
- 在 Redis 上执行 INFO 命令,查看 latest_fork_usec 项,单位微秒:这是进行持久化操作时,fork 一个子进程的花费时间。
- AOF 的刷盘机制选取问题:
appendfsync always
:主线程每次执行写操作后立即刷盘,此方案会占用比较大的磁盘 IO 资源,但数据安全性最高appendfsync no
:主线程每次写操作只写内存就返回,内存数据什么时候刷到磁盘,交由操作系统决定,此方案对性能影响最小,但数据安全性也最低,Redis 宕机时丢失的数据取决于操作系统刷盘时机appendfsync everysec
:主线程每次写操作只写内存就返回,然后由后台线程每隔 1 秒执行一次刷盘操作(触发fsync系统调用),此方案对性能影响相对较小,但当 Redis 宕机时会丢失 1 秒的数据
- redis 内存碎片太多。执行 INFO 命令,查看内存情况。
调优
- 避免使用太多时间复杂度较高的命令。有些操作可以在客户端完成后,再写入 redis。
- 避免 "bigkey"。
- 给 key 设置随机过期时间。
- 开启
lazy-free
(惰性删除/延迟释放)。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。 - 合理配置数据持久化策略。
- 降低主从库全量同步的概率。
- 选择合适的 AOF 的刷盘机制:一般来说,
appendfsync everysec
比较合适。 - 适当地进行 redis 内存碎片整理。(内存整理也会影响性能,要评估后进行)
- 优化网络情况。
Hash 槽
- Redis Cluster 包含了 16384 个哈希槽,每个 Key 通过计算后都会落在具体一个槽位上,而这个槽位是属于哪个存储节点的,则由用户自己定义分配。例如机器硬盘小的,可以分配少一点槽位,硬盘大的可以分配多一点。如果节点硬盘都差不多则可以平均分配。所以哈希槽这种概念很好地解决了一致性哈希的弊端。
Redis 集群部署
主从模式
哨兵模式
主节点只有一个,主节点写,从节点读
cluster 模式
- redis cluster是Redis的分布式解决方案,在3.0版本推出后有效地解决了redis分布式方面的需求
- 自动将数据进行分片,每个master上放一部分数据
- 提供内置的高可用支持,部分master不可用时,还是可以继续工作的
数据分布算法
hash算法
比如你有 N 个 redis实例,那么如何将一个key映射到redis上呢,你很可能会采用类似下面的通用方法计算 key的 hash 值,然后均匀的映射到到 N 个 redis上:
hash(key)%N
如果增加一个redis,映射公式变成了 hash(key)%(N+1)
如果一个redis宕机了,映射公式变成了 hash(key)%(N-1)
在这两种情况下,几乎所有的缓存都失效了。会导致数据库访问的压力陡增,严重情况,还可能导致数据库宕机。
一致性hash算法
一个master宕机不会导致大部分缓存失效,可能存在缓存热点问题
用虚拟节点改进
redis cluster 的 hash slot 算法
redis cluster 有固定的 16384 个 hash slot,对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot
redis cluster 中每个master都会持有部分 slot,比如有 3 个 master,那么可能每个 master 持有 5000 多个 hash slot
hash slot 让 node 的增加和移除很简单,增加一个 master,就将其他 master 的hash slot 移动部分过去,减少一个 master,就将它的 hash slot 移动到其他master 上去
移动hash slot的成本是非常低的
客户端的api,可以对指定的数据,让他们走同一个hash slot,通过hash tag来实现
127.0.0.1:7000>CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000 可以将槽0-5000指派给节点7000负责。
每个节点都会记录哪些槽指派给了自己,哪些槽指派给了其他节点。
客户端向节点发送键命令,节点要计算这个键属于哪个槽。
如果是自己负责这个槽,那么直接执行命令,如果不是,向客户端返回一个MOVED错误,指引客户端转向正确的节点。
Redis 和数据库一致性问题
Cache Aside Pattern(旁路缓存模式)
Cache Aside Pattern 中遇到写请求是这样的:更新 DB,然后直接删除 cache 。
如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:
- 缓存失效时间变短(不推荐,治标不治本) :我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
- 增加 cache 更新重试机制(常用): 如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将缓存中对应的 key 删除即可。