Q&A-08 Redis

参考链接:
「查缺补漏」巩固你的Redis知识体系
CS-Notes Redis.md
Redis 缓存雪崩、击穿、穿透
图解redis五种数据结构底层实现(动图哦)

缓存和数据库不一致

不一致原因

1. 先写数据库,再淘汰缓存(这种很容易导致不一致,不采用)

第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致【如下图:db中是新数据,cache中是旧数据】。

2. 先淘汰缓存,再写数据库

在分布式环境下,数据的读写都是并发的,上游有多个应用,通过一个服务的多个部署(为了保证可用性,一定是部署多份的),对同一个数据进行读写,在数据库层面并发的读写并不能保证完成顺序,也就是说后发出的读请求很可能先完成(读出脏数据):

a)发生了写请求A,A的第一步淘汰了cache(如上图中的1)
b)A的第二步写数据库,发出修改请求(如上图中的2)
c)发生了读请求B,B的第一步读取cache,发现cache中是空的(如上图中的步骤3)
d)B的第二步读取数据库,发出读取请求,此时A的第二步写数据还没完成,读出了一个脏数据放入cache(如上图中的步骤4)
即在数据库层面,后发出的请求4比先发出的请求2先完成了,读出了脏数据,脏数据又入了缓存,缓存与数据库中的数据不一致出现了。

解决方法:

由于数据库层面的读写并发,引发的数据库与缓存数据不一致的问题(本质是后发生的读请求先返回了),可能通过两个小的改动解决:

  • 修改服务Service连接池,id取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上
  • 修改数据库DB连接池,id取模选取DB连接,能够保证同一个数据的读写在数据库层面是串行的

参考链接:
https://zhuanlan.zhihu.com/p/83164058

布隆过滤器

布隆过滤器说某个元素存在,⼩概率会误判。
布隆过滤器说某个元素不在,那么这个元素⼀定不在。

我们先来看⼀下,当⼀个元素加⼊布隆过滤器中的时候,会进⾏哪些操作:

  1. 使⽤布隆过滤器中的哈希函数对元素值进⾏计算,得到哈希值(有⼏个哈希函数得到⼏个哈希值)。
  2. 根据得到的哈希值,在位数组中把对应下标的值置为 1。

我们再来看⼀下,当我们需要判断⼀个元素是否存在于布隆过滤器的时候,会进⾏哪些操作:

  1. 对给定元素再次进⾏相同的哈希计算;
  2. 得到值之后判断位数组中的每个元素是否都为 1:如果值都为 1,那么说明这个值可能在布隆过滤器中;如果存在⼀个值不为 1,说明该元素一定不在布隆过滤器中。

⼀定会出现这样⼀种情况:不同的字符串可能哈希出来的位置相同。 (可以适当增加位数组⼤⼩或者调整我们的哈希函数来降低概率)

更多关于布隆过滤器的内容可以看我的这篇原创:《不了解布隆过滤器?⼀⽂给你整的明明⽩⽩!》 。

Redis是单线程还是多线程

image.png

Redis 存在线程安全问题吗

image.png

string

string 数据结构是简单的 key-value 类型。虽然 Redis 是⽤ C 语⾔写的,但是 Redis 并没有使⽤ C 的字符串表示,⽽是⾃⼰构建了⼀种 简单动态字符串(simple dynamic string,SDS)。

  • 相⽐于 C 的原⽣字符串,Redis 的 SDS 不光可以保存⽂本数据还可以保存⼆进制数据;
  • 并且获取字符串⻓度复杂度为 O(1)(C 字符串为 O(N));
  • Redis 的SDS API 是安全的,不会造成缓冲区溢出。

skiplist

跳表在redis中用于zset和集群节点。

跳跃表的实现

#define ZSKIPLIST_MAXLEVEL 32 //最大层数
#define ZSKIPLIST_P 0.25 //P

typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward; //后向指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;//每一层中的前向指针
        unsigned int span;//x.level[i].span 表示节点x在第i层到其下一个节点需跳过的节点数。注:两个相邻节点span为1
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;//节点总数
    int level;//总层数
} zskiplis

每个节点都带有一个高度为 1 层的后退指针,用于从表尾方向向表头方向迭代。

跳跃表本质上是个有序的链表,其通过在节点上随机的添加辅助连接使得查找的时间复杂度从O(N)变为平均O(logN),最坏O(N)。

查找

从顶层开始查找;
如果目标值>当前节点的值,查找下一个节点;
如果到达末尾或者目标值<当前节点的值,下降到下一层,还是从当前节点开始查找;
如果到最底层也没找到目标值就是没找到。

image.png

举个例子,在上图中跳跃表寻找 58 的过程如下:
初始时,位于头指针的第三层辅助节点。
通过当前辅助节点的next指针发现下一个节点的值为 12。
因为12比目标值小,所以直接移动到 12 的第三层辅助节点。
因为 12 是最后一个第三层辅助节点,所以下降到第二层。
通过当前节点的next指针发现下一个节点为 78。
因为 78 比目标值大,所以继续下降到第一层辅助节点。
通过当前节点的next指针发现下一个节点为 56。
因为 56 比目标值小,所以向后移动到 56 的第一层辅助节点。
因为当前节点的数据比目标值小,且下一个节点的值(78)比目标值大,且此时已位于最低一层辅助节点,所以判定目标值不存在于该跳跃表中。查找结束。

插入

层数:随机。

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level

直观上期望的目标是 50% 的概率被分配到 Level 1,25% 的概率被分配到 Level 2,12.5% 的概率被分配到 Level 3,以此类推...有 2-63 的概率被分配到最顶层,因为这里每一层的晋升率都是 50%。

Redis 跳跃表默认允许最大的层数是 32,被源码中 ZSKIPLIST_MAXLEVEL 定义,当 Level[0] 有 264 个元素时,才能达到 32 层,所以定义 32 完全够用了。

插入的步骤和搜索的套路类似,只是需要在插入过程中更新对应层的 next 指针,具体的流程如下:

  1. 首先判断待插入数据 x 是否已经存在于跳表中,如果存在则插入失败。
  2. 其次和链表的插入类似,需要先 new 一个结点 q 用于存储数据。
  3. 插入开始前,设指针 p 位于头结点的最高层,设当前层数为 cl。
  4. 移动 p,直到 p->next[cl] 为空或者 p->data 小于 p->next[cl]->data。
  5. 如果q在当前有指针,那么更新指针:
    q->next[cl] = p->next[cl]->next[cl]
    p->next[cl] = q
  6. 如果此时已位于最后一层,则插入结束。否则下降一层,即 cl -= 1,然后跳转步骤 4。

删除

删除过程由源码中的 t_zset.c/zslDeleteNode 定义,和插入过程类似,都需要先把这个 "搜索路径" 找出来,然后对于每个层的相关节点重排一下前向后向指针,同时还要注意更新一下最高层数 maxLevel,直接放源码 (如果理解了插入这里还是很容易理解的)。

更新

当我们调用 ZADD 方法时,如果对应的 value 不存在,那就是插入过程,如果这个 value 已经存在,只是调整一下 score 的值,那就需要走一个更新流程。

假设这个新的 score 值并不会带来排序上的变化,那么就不需要调整位置,直接修改元素的 score 值就可以了。但是如果排序位置改变了,那就需要调整位置:删除再插入。

参考链接:

  • https://blog.csdn.net/Time_Limit/article/details/107290161
  • https://segmentfault.com/a/1190000022028505

缓存穿透、缓存雪崩、缓存击穿

缓存穿透

请求大量不存在的key,导致大量请求落到数据库上。

解决办法:

  1. 缓存不存在的key,并设置过期时间
  2. 过滤key,尽量过滤掉不存在的key,如用布隆过滤器
  3. 在接口层增加校验

缓存雪崩

Redis服务宕机、重启,或大量缓存同时失效,导致大量请求落到数据库上。

解决办法:

  1. 针对Redis服务宕机:集群,对sql数据库限流
  2. 针对大量缓存同时失效:设置不同的缓存过期时间(随机值),或设置热点数据永远不过期,有更新操作就更新缓存

缓存击穿

缓存击穿,跟缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿不同的是缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。

  1. 设置热点数据永远不过期
  2. 加上互斥锁
image.png

如何保证缓存和数据库数据的⼀致性

延时双删:先删缓存,后更新数据库,等待一段时间再删除一遍缓存

参考链接:如何保证缓存和数据的双写一致性

Redis实现分布式锁

参考链接:
https://www.jianshu.com/p/d6f1f0724d33?utm_campaign=hugo&utm_medium=reader_share&utm_content=note&utm_source=weixin-friends
https://segmentfault.com/a/1190000022360788

set key value NX EX 过期时长
ThreadLocal CAS

String set = jedis.set(KEY, uuid, "NX", "EX", 1000);
RedisContextHolder.setValue(uuid);

SETNX key value
SET if Not eXists,如果不存在set成功返回int的1,这个key存在了返回0。

SETEX key seconds value
将值 value 关联到 key ,并将 key 的生存时间设为 seconds (以秒为单位)。
如果 key 已经存在,setex命令将覆写旧值。
SETEX是一个原子性(atomic)操作,关联值和设置生存时间两个动作会在同一时间内完成

/***
 * 核心思路:
 *     分布式服务调用时setnx,返回1证明拿到,用完了删除,返回0就证明被锁,等...
 *     SET KEY value [EX seconds] [PX milliseconds] [NX|XX]
 *     EX second:设置键的过期时间为second秒
 *     PX millisecond:设置键的过期时间为millisecond毫秒
 *     NX:只在键不存在时,才对键进行设置操作
 *     XX:只在键已经存在时,才对键进行设置操作
 *
 * 1.设置锁
 *     A. 分布式业务统一Key
 *     B. 设置Key过期时间
 *     C. 设置随机value,利用ThreadLocal 线程私有存储随机value
 *
 * 2.业务处理
 *     ...
 *
 * 3.解锁
 *     A. 无论如何必须解锁 - finally (超时时间和finally 双保证)
 *     B. 要对比是否是本线程上的锁,所以要对比线程私有value和存储的value是否一致(避免把别人加锁的东西删除了)
 */
@RequestMapping("/redisLock")
public String testRedisLock () {
    try {
        for(;;){
            RedisContextHolder.clear();
            String uuid = UUID.randomUUID().toString();

            String set = jedis.set(KEY, uuid, "NX", "EX", 1000);
            RedisContextHolder.setValue(uuid);

            if (!"OK".equals(set)) {
                // 进入循环-可以短时间休眠
            } else {
                // 获取锁成功 Do Somethings....
                break;
            }
        }
    } finally {
        // 解锁 -> 保证获取数据,判断一致以及删除数据三个操作是原子的, 因此如下写法是不符合的
        /*if (RedisContextHolder.getValue() != null && jedis.get(KEY) != null && RedisContextHolder.getValue().equals(jedis.get(KEY))) {
                jedis.del(KEY);
            }*/

        // 正确姿势 -> 使用Lua脚本,保证原子性
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
        Object eval = jedis.eval(luaScript, Collections.singletonList(KEY), Collections.singletonList(RedisContextHolder.getValue()));
    }
    return "锁创建成功-业务处理成功";
}

Redis是如何判断数据是否过期的呢?

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

缓存的回收和淘汰

回收

没有超内存,正常删除过期key

过期字典
后台轮询

  • 定时删除:在设置键过去的时间同时,创建一个定时器,让定时器在键过期时间来临,立即执行对键的删除操作。
  • 惰性删除:放任键过期不管,但是每次从键空间获取键时,都会检查该键是否过期,如果过期的话,就删除该键。
  • 定期删除:每隔一段时间,程序都要对数据库进行一次检查,删除里面的过期键,至于要删除多少过期键,由算法而定。

淘汰

内存不够了,淘汰没过期的key

可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。

Redis 具体有 6 种淘汰策略:

策略描述
volatile-lru 从已设置过期时间的数据集中挑选最近最久未使用的数据淘汰
volatile-ttl 从已设置过期时间的数据集中挑选将要过期的数据淘汰
volatile-random 从已设置过期时间的数据集中任意选择数据淘汰
allkeys-lru 从所有数据集中挑选最近最少使用的数据淘汰
allkeys-random 从所有数据集中任意选择数据进行淘汰
noeviction 禁止驱逐数据

Redis 4.0 引入了 volatile-lfu 和 allkeys-lfu 淘汰策略,LFU 策略通过统计访问频率,将访问频率最少的键值对淘汰。

LRU(Least Recently Used)最近最久未使用
LFU(Least Frequently Used)最近最少使用
TTL( Time to live)剩余存活时间/距离到期时间时长

缓存预热

缓存预热的思路

a.提前给redis中嵌入部分数据,再提供服务

b.肯定不可能将所有数据都写入redis,因为数据量太大了,第一耗费的时间太长了,第二redis根本就容纳不下所有的数据

c.需要更具当天的具体访问情况,试试统计出频率较高的热数据

d.然后将访问频率较高的热数据写入到redis,肯定是热数据也比较多,我们也得多个服务并行的读取数据去写,并行的分布式的缓存预热

e.然后将嵌入的热数据的redis对外提供服务,这样就不至于冷启动,直接让数据库奔溃了

具体的实时方案:

  1. nginx+lua将访问量上报到kafka中

要统计出来当前最新的实时的热数据是哪些,我们就得将商品详情页访问的请求对应的流量,日志,实时上报到kafka中,

  1. storm从kafka中消费数据,实时统计出每个商品的访问次数,访问次数基于LRU内存数据结构的存储方案

优先用内存中的一个LRUMap去存放,性能高,而且没有外部依赖

否则的话,依赖redis,我们就是要防止reids挂掉数据丢失的情况,就不合适了;用mysql,扛不住高并发读写;用hbase,hadoop生态系统,维护麻烦,太重了,其实我们只要统计出一段时间访问最频繁的商品,然后对它们进行访问计数,同时维护出一个前N个访问最多的商品list即可

计算好每个task大致要存放的商品访问次数的数量,计算出大小,然后构建一个LURMap,apache commons collections有开源的实现,设定好map的最大大小,就会自动根据LRU算法去剔除多余的数据,保证内存使用限制,即使有部分数据被干掉了,然后下次来重新开始技术,也没什么关系,因为如果他被LRU算法干掉,那么它就不是热数据,说明最近一段时间很少访问,

  1. 每个storm task启动的时候,基于zk分布式锁,将自己的id写入zk的一个节点中

  2. 每个storm task负责完成自己这里的热数据的统计,比如每次计数过后,维护一个钱1000个商品的list,每次计算完都更新这个list

  3. 写一个后台线程,每个一段时间,比如一分钟,将排名钱1000的热数据list,同步到zk中

参考链接:https://blog.csdn.net/mn_kw/article/details/80704611

持久化

持久化的线程是fork线程,不是主线程

RDB

将某个时间点的所有数据都存放到硬盘上。

AOF

同步选项:
always 每个写命令都同步
everysec 每秒同步一次
no 让操作系统来决定何时同步

AOF重写:把aof里的多个命令用一行命令替换,简化aof文件,去除冗余命令

事务

multi
多个需要打包执行的命令(要么都执行,要么都不执行)
exec

还有DISCARD 和 WATCH

  1. MULTI

用于标记事务块的开始。Redis会将后续的命令逐个放入队列中,然后才能使用EXEC命令原子化地执行这个命令序列。
这个命令的返回值是一个简单的字符串,总是OK。

  1. EXEC

在一个事务中执行所有先前放入队列的命令,然后恢复正常的连接状态。
当使用WATCH命令时,只有当受监控的键没有被修改时,EXEC命令才会执行事务中的命令,这种方式利用了检查再设置(CAS)的机制。
这个命令的返回值是一个数组,其中的每个元素分别是原子化事务中的每个命令的返回值。 当使用WATCH命令时,如果事务执行中止,那么EXEC命令就会返回一个Null值。

  1. DISCARD

清除所有先前在一个事务中放入队列的命令,然后恢复正常的连接状态。
如果使用了WATCH命令,那么DISCARD命令就会将当前连接监控的所有键取消监控
这个命令的返回值是一个简单的字符串,总是OK。

  1. WATCH

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

这个命令的运行格式如下所示:
WATCH key [key ...]

这个命令的返回值是一个简单的字符串,总是OK。
对于每个键来说,时间复杂度总是O(1)。

  1. UNWATCH

清除所有先前为一个事务监控的键。
如果你调用了EXEC或DISCARD命令,那么就不需要手动调用UNWATCH命令。

参考链接:https://blog.csdn.net/u013967628/article/details/84501515

事件

Redis 服务器是一个事件驱动程序。

文件事件

服务器通过套接字与客户端或者其它服务器进行通信,文件事件就是对套接字操作的抽象。
Redis 基于 Reactor 模式开发了自己的网络事件处理器,使用 I/O 多路复用程序来同时监听多个套接字,并将到达的事件传送给文件事件分派器,分派器会根据套接字产生的事件类型调用相应的事件处理器。

image.png

时间事件

服务器有一些操作需要在给定的时间点执行,时间事件是对这类定时操作的抽象。

时间事件又分为:
定时事件:是让一段程序在指定的时间之内执行一次;
周期性事件:是让一段程序每隔指定时间就执行一次。

Redis 将所有时间事件都放在一个无序链表中,通过遍历整个链表查找出已到达的时间事件,并调用相应的事件处理器。

复制(主从)

哨兵

分片(即分库分表)

有不同的方式来选择一个指定的键存储在哪个实例中:

  • 范围分片:例如用户 id 从 0~1000 的存储到实例 R0 中,用户 id 从 1001~2000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。
  • 哈希分片:使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。

根据执行分片的位置,可以分为三种分片方式:

  • 客户端分片:客户端使用一致性哈希等算法决定键应当分布到哪个节点。
  • 代理分片:将客户端请求发送到代理上,由代理转发请求到正确的节点上。
  • 服务器分片:Redis Cluster。

主从不一致

场景描述,对于主从库,读写分离,如果主从库更新同步有时差,就会导致主从库数据的不一致。

  • 忽略这个数据不一致,在数据一致性要求不高的业务下,未必需要时时一致性。
  • 强制读主库,使用一个高可用的主库,数据库读写都在主库,添加一个缓存,提升数据读取的性能。
  • 选择性读主库,添加一个缓存,用来记录必须读主库的数据,将哪个库,哪个表,哪个主键,作为缓存的key,设置缓存失效的时间为主从库同步的时间,如果缓存当中有这个数据,直接读取主库,如果缓存当中没有这个主键,就到对应的从库中读取。

问题原因:
网络信息不同步,数据发送有延迟

根本上解决:

  • 优化主从间的网络环境,通常放置在同一个机房部署,如使用阿里云等云服务器时要注意此现象
  • 监控主从节点延迟(通过offset)判断,如果slave延迟过大,暂时屏蔽程序对该slave的数据访问

参考链接:https://www.cnblogs.com/dalianpai/p/14273403.html

Redis 和 Memcached 的区别和共同点

image.png

Redis:
数据类型更多;
可以持久化->支持灾难恢复;
单线程(Redis 在 4.0 之后的版本中加⼊了对多线程的⽀持,Redis6.0 引⼊多线程主要是为了提⾼⽹络 IO 读写性能);
惰性删除与定期删除。
有原生的集群模式。

一致性哈希

Distributed Hash Table(DHT) 是一种哈希分布方式,其目的是为了克服传统哈希分布在服务器节点数量变化时大量数据迁移的问题。
将哈希空间 [0, 2n-1] 看成一个哈希环,每个服务器节点都配置到哈希环上。每个数据对象通过哈希取模得到哈希值之后,存放到哈希环中顺时针方向第一个大于等于该哈希值的节点上。
一致性哈希在增加或者删除节点时只会影响到哈希环中相邻的节点,例如下图中新增节点 X,只需要将它前一个节点 C 上的数据重新进行分布即可,对于节点 A、B、D 都没有影响。

上面描述的一致性哈希存在数据分布不均匀的问题,节点存储的数据量有可能会存在很大的不同。
数据不均匀主要是因为节点在哈希环上分布的不均匀,这种情况在节点数量很少的情况下尤其明显。
解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得多,那么虚拟节点在哈希环上分布的均匀性就会比原来的真实节点好,从而使得数据分布也更加均匀。

参考链接:CyC2018/CS-Notes/blob/master/notes 一致性哈希

你可能感兴趣的:(Q&A-08 Redis)