裸泳的猪

沾沾自喜其实最可悲

0%

redis要点梳理和实战

Redis

简介

REmote DIctionary Server(Redis) 是一个由 Salvatore Sanfilippo 写的 key-value 存储系统,是跨平台的非关系型数据库。Redis 是一个开源的使用 ANSI C 语言编写、遵守 BSD 协议、支持网络、可基于内存、分布式、可选持久性的键值对(Key-Value)存储数据库,并提供多种语言的 API。

Redis 通常被称为数据结构服务器,因为值(value)可以是字符串(String)、哈希(Hash)、列表(list)、集合(sets)和有序集合(sorted sets)等类型。

image-20240504123201947

对于密集写入的场景,特别是缓存相关的需求,Redis 可以节省成本,性能也很好。Redis 不只是缓存,而是一种不同形态的数据库,适合很多性能比正确性要求更高的场景。 —-antirez


概念、特性、基本使用

1.Redis快的原因:

1.1基于内存

redis是基于内存的,内存的读写速度非常快;

内存测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mkdir /mnt/ramdisk
*# 创建一个名为/mnt/ramdisk的tmpfs,大小为1GB*
sudo mount -t tmpfs -o size=1G tmpfs /mnt/ramdisk

*# 进入创建的内存盘目录*
cd /mnt/ramdisk

*# 创建一个测试文件*
dd if=/dev/zero of=testfile bs=1M count=1024

*# 测试写入速度*
time dd if=/dev/zero of=testfile bs=1M count=1024

*# 测试读取速度*
dd if=testfile of=/dev/null bs=1M

*# 测试写入速度*
time dd if=testfile of=/dev/null bs=1M

*# 清理测试文件*
rm testfile

1.2单线程

  • 使用单线程模型使Redis的开发和维护更简单省去了很多上下文切换线程的时间
  • 虽然使用的是单线程,但也可以并发处理多客户端的请求(IO多路复用和非阻塞IO)
  • 对于Redis系统来说,主要的性能瓶颈是内存/网络带宽,而非CPU

Redis一直被大家熟知的就是它的单线程架构,虽然从Redis4.0开始使用了多线程,也是为了处理数据删除、快照删除等耗时操作,从网络IO处理到实际的读写命令处理都是由主线程独自处理的。

在Redis 6/7中,Redis全面支持了多线程。这是由于随着硬件性能的提升,Redis的性能瓶颈主要出现在网络IO上,就是完全靠单个主线程处理网络请求的速度跟不上底层网络硬件的速度,于是采用多个线程处理网络IO,提高网络请求处理的并行度。

Redis的多IO线程只是用来处理网络请求的,对于读写操作命令 Redis 仍然使用单线程来处理。

1.3多路复用技术

可以处理并发的连接。 非阻塞IO 内部实现采用epoll,采用了epoll+自己实现的简单的事件框架

Redis 使用的是客户端-服务器(CS)模型和请求/响应协议的 TCP 服务器。这意味着通常情况下一个请求会遵循以下步骤:

  1. 客户端向服务端发送一个查询请求,并监听 Socket 返回,通常是以阻塞模式,等待服务端响应。
  2. 服务端处理命令,并将结果返回给客户端。
  3. redis使用多路复用技术,可以处理并发的连接。非阻塞IO 内部实现采用epoll,采用了epoll+自己实现的简单的事件框架。epoll中的读、写、关闭、连接都转化成了事件,然后利用epoll的多路复用特性,绝不在io上浪费一点时间。

1.4高效的数据结构

image-20240504123518672

参考链接:https://juejin.cn/post/6978280894704386079

2.Redis基础命令

2.1Key

  1. keys * 查询所有数据

  2. exists key名 判断key名是否存在

  3. move key名 数据库号(0-15) 移动数据key名到相应的数据库

  4. expire key名 秒 过多少秒key名失效(删除)

  5. ttl key名 查询key名还有多久过期 -1永不过期 -2已过期(或不存在)

  6. type key名 判断key名是什么类型

2.2String

  1. set (添加)、 get (获取值)、del(删除) 、append(追加) 、strlen (获取长度)
    SETNX key value
    (只有在 key 不存在时设置 key 的值)。
  2. incr (增加1)、decr(减少1) 、incrby(按多少增加) 、decrby (按多少减少)
  3. setrang(命令用指定的字符串覆盖给定 key 所储存的字符串值,覆盖的位置从偏移量 offset 开始。)
    1
    2
    3
    4
    5
    6
    redis 127.0.0.1:6379> SET key1 "Hello World"
    OK
    redis 127.0.0.1:6379> SETRANGE key1 6 "Redis"
    (integer) 11
    redis 127.0.0.1:6379> GET key1
    "Hello Redis"

2.3List(单值多value)

  1. lpush (队列左先入栈)、 rpush(队列右先入栈) 、Larange
  2. lpop(左出)、rpop(右出)注意:这里和开始进入的有关系,即:lpush 、rpush
  3. lindex(按照索引下标元素获取值。从上到下)

2.4hash

  1. HSET key field value(将哈希表 key 中的字段 field 的值设为 value ),

HGET key field 获取存储在哈希表中指定字段的值。

HMSET key field1 value1 [field2 value2 ]同时将多个 field-value (域-值)对设置到哈希表 key 中,

HMGET key field1 [field2] 获取多个给定字段的值

HGETALL key 获取在哈希表中指定 key 的所有字段和值

2. HDEL key field1 [field2] 删除一个或多个哈希表字段

HVALS key 获取哈希表中所有值。
HGETALL key

HKEYS key 获取所有哈希表中的字段

HLEN key 获取哈希表中字段的数量

2.5set

  1. SADD key member1 [member2] 向集合添加一个或多个成员

    SREM key member1 [member2] 移除集合中一个或多个成员

    SCARD key 获取集合的成员数

    2.6 sorted set

    ZADD key score1 member1 [score2 member2] 向有序集合添加一个或多个成员,或者更新已存在成员的分数

    3.常见使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #连接到本机的redis
    $ redis-cli
    #中文乱码时加raw
    $ redis-cli --raw
    #执行 PING 命令,该命令用于检测 redis 服务是否启动。
    redis 127.0.0.1:6379> PING
    #键命令
    redis 127.0.0.1:6379> SET runoobkey redis
    OK
    redis 127.0.0.1:6379> GET runoobkey
    "redis"
    redis 127.0.0.1:6379> DEL runoobkey
    (integer) 1
    #hash
    127.0.0.1:6379> HMSET runoobkey name "redis tutorial" description "redis basic commands for caching" likes 20 visitors 23000
    OK
    127.0.0.1:6379> HGETALL runoobkey
    1) "name"
    2) "redis tutorial"
    3) "description"
    4) "redis basic commands for caching"
    5) "likes"
    6) "20"
    7) "visitors"
    8) "23000"

4.redis典型问题

4.1 KEY VS SCAN

  1. key :假如Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如何将它们全部找出来?
  • 使用keys指令可以扫出指定模式的key列表。
  • redis是单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。

    4.2 Redis分布式锁

分布式应用进行逻辑处理时经常会遇到并发问题。比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。这个时候就要使用到分布式锁来限制程序的并发执行。 分布式锁本质上要实现的目标就是在 Redis 里面占位,当别的进程也要来占时,发现已经有人 位,就只好放弃或者稍后再试。

  • 先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。
  • set 命令可以合并setnx和expire 保证原子性
  • 调用 del 指令释放(为避免程序逻辑执行到中间产生 异常导致未执行 del 指令的情况,通常需要在锁上增加过期时间,使用 expire 指令)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    set key value [EX seconds] [PX milliseconds] [NX|XX]
    EX seconds:设置失效时长,单位秒
    PX milliseconds:设置失效时长,单位毫秒
    NX:key不存在时设置value,成功返回OK,失败返回(nil)
    XX:key存在时设置value,成功返回OK,失败返回(nil)


    > set name p7+ ex 100 nx
    OK
    > get name
    "p7+"
    > ttl name
    (integer) 94

4.2.1 分布式锁误解除问题

问题:

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。

解决方案:

通过在 value 中设置当前线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是当前线 程持有。可生成一个 UUID 标识当前线程,使用 lua 脚本做验证标识和解锁操作。

4.2.2 分布式锁超时解锁导致并发问题

问题:

分布式锁超时解锁导致并发问题。如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执 行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。A、 B 两个线程发生并发显然是不被允许的。

解决方案:

一般有两种方式解决该问题:一是将过期时间设置足够长, 确保代码逻辑在锁释放之前能够执行完成;二是为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。

redisson这个客户端工具,内部有一个监控锁的看门狗,默认情况下,加锁的时间是30秒.如果加锁的业务没有执行完,那么到 30-10 = 20秒的时候,就会进行一次续期,把锁重置成30秒。

4.2.3 分布式锁可重入问题

问题:

分布式锁可重入问题。当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次 加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加 锁会失败。

解决方案:

Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。本 地记录重入次数虽然高效,但如果考虑到过期时间和本地、Redis 一致性的问题,就会增加代码的 复杂性。另一种方式是 Redis Map 数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。 例如第三方分布式锁库 Redisson 就采用了这种机制。

4.2.4 分布式锁无法解决锁释放问题

问题:

上述锁机制执行都是立即返回的,客户端快速失败,如果客户端需要支持等待锁释放就无法使用。

解决方案:

可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁, 直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的 效率。另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功 后释放时,发送锁释放消息

  1. Redis做异步队列
  • 一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep(list还有个指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来。)一会再重试。
  • 使用pub/sub主题订阅者模式,可以实现 1:N 的消息队列。
  • 延时队列 : 使用sortedset,拿时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。

4.3 限流

限流算法在分布式领域是一个经常被提起的话题,当系统的处理能力有限时,如何阻止计划外 的请求继续对系统施压,这是一个需要重视的问题。除了控制流量,限流还有一个应用目的是用于 控制用户行为,避免垃圾请求,用户操作行为要严格受控,一般要严格限定某行为在规定时间内允 许的次数,超过了次数即非法行为。对非法行为,业务必须规定适当的惩处策略。

常见的限流算法有计数器、漏桶和令牌桶

滑动窗口计数器

顾名思义就是来一个记一个,判断在有限时间窗口内的数量是否超过限制即可,实现思想是使用 Redis zset 有序集合数据结构。例如需要控制相同用户相同请求的次数,可将用户ID 和请求做为键, 将时间戳分别做为分值和键值插入有序集合,移除该集合中时间窗口之前的行为记录,剩下的都是时间窗口内的,再统计稽核内的行为数量是否合规。

漏桶

漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水 (接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求, 可以看出漏桶算法能强行限制数据的传输速率。利用 Redis 的 hash 数据机构存储漏桶基本属性(容 量、流水速率、剩余空间、上一次漏水时间),在每次灌水前调用以触发漏水,为漏桶腾出空间, 灌水时将字段取出进行逻辑运算后再存入 hash 结构中即可完成一次行为频度的检测。由于 Redis 无 法保证整个逻辑操作的原子性,需要使用 lua 脚本实现。也可使用 Redis-cell 限流模块

令牌桶

令牌桶算法(Token Bucket)和漏桶算法效果一样但方向相反,更加容易理解。随着时间 流逝,系统会按恒定 1/QPS 时间间隔(如果 QPS=100,则间隔是 10ms)往桶里加入 Token(想象和漏洞漏 水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了。新请求到来时,会拿走一个 Token, 如果没有 Token 可拿就阻塞或者拒绝服务。令牌桶的另外一个好处是可以方便的改变速率。一旦需 要提高速率,则按需提高放入桶中的令牌的速率。一般会定时(比如 100 毫秒)往桶中增加一定数量的 令牌,有些变种算法则实时的计算应该增加的令牌的数量。

4.4 如何使用使得性能最佳

4.4.1节省内存

4.4.1.1 控制 key 的长度

**在开发业务时,你需要提前预估整个 Redis 中写入 key 的数量,如果 key 数量达到了百万级别,那么,过长的 key 名也会占用过多的内存空间。

所以,你需要保证 key 在简单、清晰的前提下,尽可能把 key 定义得短一些。

1
例如,原有的 key 为 user:mathbooks:123,则可以优化为 u:bk:123。
4.4.1.2 避免存储 bigkey

除了控制 key 的长度之外,你同样需要关注 value 的大小,如果大量存储 bigkey,也会导致 Redis 内存增长过快。

所以,你要避免在 Redis 中存储 bigkey,我给你的建议是:

  • String:大小控制在 10KB 以下
  • List/Hash/Set/ZSet:元素数量控制在 1 万以下
4.4.1.3 选择合适的数据类型

Redis 提供了丰富的数据类型,这些数据类型在实现上,也对内存使用做了优化。具体来说就是,一种数据类型对应多种数据结构来实现:

img



例如,String、Set 在存储 int 数据时,会采用整数编码存储。Hash、ZSet 在元素数量比较少时(可配置),会采用压缩列表(ziplist)存储,在存储比较多的数据时,才会转换为哈希表和跳表。作者这么设计的原因,就是为了进一步节约内存资源。
那么你在存储数据时,就可以利用这些特性来优化 Redis 的内存。这里我给你的建议如下:

  • String、Set:尽可能存储 int 类型数据
  • Hash、ZSet:存储的元素数量控制在转换阈值之下,以压缩列表存储,节约内存
4.4.1.4 把 Redis 当作缓存使用

Redis 数据存储在内存中,这也意味着其资源是有限的。你在使用 Redis 时,要把它当做缓存来使用,而不是数据库。

所以,你的应用写入到 Redis 中的数据,尽可能地都设置「过期时间」。

业务应用在 Redis 中查不到数据时,再从后端数据库中加载到 Redis 中。

img



采用这种方案,可以让 Redis 中只保留经常访问的「热数据」,内存利用率也会比较高。

4.4.1.5 实例设置 maxmemory + 淘汰策略

虽然你的 Redis key 都设置了过期时间,但如果你的业务应用写入量很大,并且过期时间设置得比较久,那么短期间内 Redis 的内存依旧会快速增长。

如果不控制 Redis 的内存上限,也会导致使用过多的内存资源。

对于这种场景,你需要提前预估业务数据量,然后给这个实例设置 maxmemory 控制实例的内存上限,这样可以避免 Redis 的内存持续膨胀。

配置了 maxmemory,此时你还要设置数据淘汰策略,而淘汰策略如何选择,你需要结合你的业务特点来决定:

  • volatile-lru / allkeys-lru:优先保留最近访问过的数据
  • volatile-lfu / allkeys-lfu:优先保留访问次数最频繁的数据(4.0+版本支持)
  • volatile-ttl :优先淘汰即将过期的数据
  • volatile-random / allkeys-random:随机淘汰数据
4.4.1.6 数据压缩后写入 Redis

例如:GZIP算法

4.4.2 避免性能下降的措施

4.4.2.1 避免bigkey

存储 bigkey 除了前面讲到的使用过多内存之外,对 Redis 性能也会有很大影响。
由于 Redis 处理请求是单线程的,当你的应用在写入一个 bigkey 时,更多时间将消耗在「内存分配」上,这时操作延迟就会增加。同样地,删除一个 bigkey 在「释放内存」时,也会发生耗时。
而且,当你在读取这个 bigkey 时,也会在「网络数据传输」上花费更多时间,此时后面待执行的请求就会发生排队,Redis 性能下降。



所以,你的业务应用尽量不要存储 bigkey,避免操作延迟发生。

如果你确实有存储 bigkey 的需求,你可以把 bigkey 拆分为多个小 key 存储。

4.4.2.2开启 lazy-free 机制

如果你无法避免存储 bigkey,那么我建议你开启 Redis 的 lazy-free 机制。(4.0+版本支持)
当开启这个机制后,Redis 在删除一个 bigkey 时,释放内存的耗时操作,将会放到后台线程中去执行,这样可以在最大程度上,避免对主线程的影响。使用unlink命令。

img



4.4.2.3 不使用复杂度过高的命令

Redis 是单线程模型处理请求,除了操作 bigkey 会导致后面请求发生排队之外,在执行复杂度过高的命令时,也会发生这种情况。因为执行复杂度过高的命令,会消耗更多的 CPU 资源,主线程中的其它请求只能等待,这时也会发生排队延迟。
所以,你需要避免执行例如 SORT、SINTER、SINTERSTORE、ZUNIONSTORE、ZINTERSTORE 等聚合类命令。对于这种聚合类操作,我建议你把它放到客户端来执行,不要让 Redis 承担太多的计算工作。

4.4.2.4 执行 O(N) 命令时,关注 N 的大小

规避使用复杂度过高的命令,就可以高枕无忧了么?
答案是否定的。
当你在执行 O(N) 命令时,同样需要注意 N 的大小。
如果一次性查询过多的数据,也会在网络传输过程中耗时过长,操作延迟变大。

所以,对于容器类型(List/Hash/Set/ZSet),在元素数量未知的情况下,一定不要无脑执行 LRANGE key 0 -1 / HGETALL / SMEMBERS / ZRANGE key 0 -1。

在查询数据时,你要遵循以下原则:

  1. 先查询数据元素的数量(LLEN/HLEN/SCARD/ZCARD)

  2. 元素数量较少,可一次性查询全量数据

  3. 元素数量非常多,分批查询数据(LRANGE/HASCAN/SSCAN/ZSCAN)

4.4.2.5 关注 DEL 时间复杂度

你没看错,在删除一个 key 时,如果姿势不对,也有可能影响到 Redis 性能。
删除一个 key,我们通常使用的是 DEL 命令,回想一下,你觉得 DEL 的时间复杂度是多少?
O(1) ?其实不一定。

当你删除的是一个 String 类型 key 时,时间复杂度确实是 O(1)。
但当你要删除的 key 是 List/Hash/Set/ZSet 类型,它的复杂度其实为 O(N),N 代表元素个数。
也就是说,删除一个 key,其元素数量越多,执行 DEL 也就越慢!原因在于,删除大量元素时,需要依次回收每个元素的内存,元素越多,花费的时间也就越久!而且,这个过程默认是在主线程中执行的,这势必会阻塞主线程,产生性能问题。
那删除这种元素比较多的 key,如何处理呢?
我给你的建议是,分批删除

  • List类型:执行多次 LPOP/RPOP,直到所有元素都删除完成

  • Hash/Set/ZSet类型:先执行 HSCAN/SSCAN/SCAN 查询元素,再执行 HDEL/SREM/ZREM 依次删除每个元素

4.4.2.6 批量命令代替单个命令
当你需要一次性操作多个 key 时,你应该使用批量命令来处理。

批量操作相比于多次单个操作的优势在于,可以显著减少客户端、服务端的来回网络 IO 次数。

所以我给你的建议是:

  • String / Hash 使用 MGET/MSET 替代 GET/SET,HMGET/HMSET 替代 HGET/HSET

  • 其它数据类型使用 Pipeline,打包一次性发送多个命令到服务端执行

    

    img

    

4.4.2.7 避免集中过期 key

Redis 清理过期 key 是采用定时 + 懒惰的方式来做的,而且这个过程都是在主线程中执行。
如果你的业务存在大量 key 集中过期的情况,那么 Redis 在清理过期 key 时,也会有阻塞主线程的风险。
想要避免这种情况发生,你可以在设置过期时间时,增加一个随机时间,把这些 key 的过期时间打散,从而降低集中过期对主线程的影响。

4.4.2.8 使用长连接操作 Redis,合理配置连接池

你的业务应该使用长连接操作 Redis,避免短连接
当使用短连接操作 Redis 时,每次都需要经过 TCP 三次握手、四次挥手,这个过程也会增加操作耗时。
同时,你的客户端应该使用连接池的方式访问 Redis,并设置合理的参数,长时间不操作 Redis 时,需及时释放连接资源。

4.5 缓存问题

4.5.1缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

举例:
简单来说就是你数据库的id都是1开始然后自增的,那我知道你接口是通过id查询的,我就拿负数去查询,这个时候,会发现缓存里面没这个数据,我又去数据库查也没有,一个请求这样,100个,1000个,10000个呢?你的DB基本上就扛不住了

解决方案:

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;

  2. Bloom Filter 布隆过滤器

,把缓存中有的key做哈希存起来,如果是布隆过滤器里面匹配不到的直接返回。

3.数据库查询不到,设置一个过期时间较短的key,返回null;

4.5.2 缓存击穿

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
解决方案:

  1. 设置热点数据永远不过期。
  2. 加互斥锁,保证只有一个线程去查数据库,其他等待一段时间再查缓存。

4.5.3 缓存雪崩

是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

如果大量的key过期时间设置的过于集中,到过期的那个时间点,redis可能会出现短暂的卡顿现象。严重的话会出现缓存雪崩,

解决方案:

  1. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
  2. 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
  3. 设置热点数据永远不过期

4.6 双写一致性问题

4.6.1 缓存和数据库在双写场景下,一致性是如何保证?

比如在现实生活中的购物网站场景:假设用户A在购买一件库存仅剩1件的商品,系统在接收到请求后,先将MySQL中的库存减少1,然后出现了网络延迟或系统故障,Redis中的库存没有减少。此时,用户B看到的是还有1件商品,也发起了购买请求,如果系统又首先更改了MySQL,那么就会出现超卖的情况,即实际库存已经没有,但因为缓存中的信息不准确,导致系统销售了更多的商品。

严格意义上任何非原子操作都不可能保证一致性,除非用阻塞读写实现强一致性,所以对于缓存架构我们追求的目标是最终一致性。

  • 严格要求 “缓存+数据库” 必须保持一致性 : 读请求和写请求串行化

    Cache Aside Pattern(旁路缓存方案)

  • 最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。

    读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。

    img

    更新的时候,先更新数据库,然后再删除缓存

    • 它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。旁路缓存模式中服务端需要同时维护DBCache,并且是以DB的结果为准。

    • 注:(区别于更新缓存,如果是更新,在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:(1)请求1先操作数据库,请求2后操作数据库(2)请求2先set了缓存,请求1后set了缓存导致,数据库与缓存之间的数据不一致。)

4.6.1.1延时双删

上面这种方案,是先更新数据库,再删除缓存,但是改了库,清理缓存前,有部分事务还是会拿到旧缓存,这样如果再更新了缓存之后就还是不对的。可以采用延时双删策略。

只在更新后删缓存:

只在更新后删缓存

普通双删:

在这里插入图片描述

在更新数据库前,清理缓存,再执行更新操作,然后第二次清空缓存之前,多延时一会儿,等B更新缓存结束了,再删除缓存,这样就缓存就不存在了,其他事务查询到的为新缓存。

延时双删:

在这里插入图片描述

延时是确保 修改数据库 -> 清空缓存前,其他事务的更改缓存操作已经执行完。

这个时间在分布式和高并发场景下,其实是很难评估的。很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率

采用延时删最后一次缓存,但这其中难免还是会大量的查询到旧缓存数据的。

这个时候可以采用通过加锁来解决,一次性不让太多的线程都来请求,另外从图上看,我们可以尽量缩短第一次删除缓存更新数据库的时间差。

4.6.1.2 订阅数据库变更日志,再操作缓存

我们的应用在修改数据时,只需要修改数据库,不用操作缓存,而操作缓存是交给数据库的变更日志实现。

比如,MySQL中修改一条数据,MySQL 就会产生一条变更日志(Bin Log),我们可以订阅这个日志,获取到具体的操作数据,然后再根据这条日志数据,去删除对应的缓存。 (此方案不适用于postgresql,PG无Bin log,PG需进一步自己实现,参考:https://blog.csdn.net/weixin_37598243/article/details/128056610)

5. 持久化、集群高可用

![image-20240504140049755](redis要点梳理和实战/:Users:seven:Library:Application Support:typora-user-images:image-20240504140049755.png)

5.1持久化

5.1.1持久化方式

  1. RDB(Redis数据库):RDB持久化在指定的时间间隔内对数据集执行时间点快照。

  2. AOF(追加写入文件):AOF持久化记录服务器接收到的每个写入操作。这些操作可以在服务器启动时重新播放,从而重建原始数据集。命令使用与Redis协议本身相同的格式记录。

  3. 无持久化:你可以完全禁用持久化。有时在进行缓存时会使用这种选项。

  4. RDB + AOF:你也可以在同一个实例中同时使用AOF和RDB。

5.1.2建议方式

一般建议同时使用两种持久化方法。

如果你非常关心你的数据,但在灾难情况下可以容忍几分钟的数据丢失,那么你可以简单地只使用RDB。

有很多用户仅使用AOF,但我们不建议这样做,因为定期使用RDB快照是进行数据库备份、加快重新启动速度以及在AOF引擎出现错误时的一个很好的方法。

5.2 高可用

![image-20240504140321895](redis要点梳理和实战/:Users:seven:Library:Application Support:typora-user-images:image-20240504140321895.png)

6.redis监控和插件

6.1 redisinsight

官网:https://redis.io/insight/

redisinsight1

redisinsight2

-------------本文结束感谢您的阅读-------------