# Redis核心技术与实战实践篇

# 18 | 波动的响应延迟:如何应对变慢的Redis?

# 如何判断Redis真的变慢了?

当你发现 Redis 命令的执行时间突然就增长到了几秒,基本就可以认定 Redis 变慢了。

基于当前环境下的 Redis 基线性能做判断。所谓的基线性能呢,也就是一个系统在低压力、无干扰下的基本性能,这个性能只由当前的软硬件配置决定。

redis-cli 命令提供了**–intrinsic-latency** 选项,可以用来监测和统计测试期间内的最大延迟,这个延迟可以作为 Redis 的基线性能。

redis-cli --intrinsic-latency 120
Max latency so far: 1 microseconds.
Max latency so far: 7 microseconds.
Max latency so far: 12 microseconds.
Max latency so far: 18 microseconds.
Max latency so far: 756 microseconds.

Max latency so far: 758 microseconds.
Max latency so far: 763 microseconds.
^C
604732119 total runs (avg latency: 0.1984 microseconds / 198.43 nanoseconds per run).
Worst run took 3845x longer than the average latency.

如果你观察到的 Redis 运行时延迟是其基线性能的 2 倍及以上,就可以认定 Redis 变慢了。

# 如何应对Redis变慢

关注下面的红色模块

image

# Redis 自身操作特性的影响

# 1. 慢查询命令

了解每个命令的时间复杂度

Command reference – Redis (opens new window)

查询变慢的请求

SLOWLOG – Redis (opens new window)

聊聊redis的slowlog与latency monitor_weixin_34061482的博客-CSDN博客 (opens new window)

# 获取阈值
config get slowlog-log-slower-than
# 获取最近10条慢日志
SLOWLOG get 10
SLOWLOG get 2
1) 1) (integer) 2   # -> 序号
   2) (integer) 1612420788 # -> 时间戳
   3) (integer) 22428 # -> 耗时 微妙
   4) 1) "config" #-> 1到3为执行命令和参数
      2) "get"
      3) "slowlog-log-slower-than"
   5) "127.0.0.1:57022" #-> 客户端的ip和端口号
   6) "" # -> 客户端名称 Client name if set via the CLIENT SETNAME command (4.0 only).

Every entry is composed of four (or six starting with Redis 4.0) fields:

  • A unique progressive identifier for every slow log entry.
  • The unix timestamp at which the logged command was processed.
  • The amount of time needed for its execution, in microseconds.
  • The array composing the arguments of the command.
  • Client IP address and port (4.0 only).
  • Client name if set via the CLIENT SETNAME (opens new window) command (4.0 only).

有两种处理方式::

  1. 用其他高效命令代替。比如说,如果你需要返回一个 SET 中的所有成员时,不要使用 SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。
  2. 当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。
# 2. 过期 key 操作

Redis 每 100 毫秒会删除一些过期 key,具体的算法如下:

  1. 采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的 key 全部删除;
  2. 如果超过 25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下。

如果触发了第二条,则会一直删除,删除时阻塞的。 如何触发第二条 : 就是频繁使用带有相同时间参数的 EXPIREAT 命令设置过期 key,

如何处理?

检查是否使用了expire at 有相同的时间戳,尽量不要使用相同的,比如在时间戳后面加一个随机数来避免。

# 文件系统:AOF 模式

为了保证数据可靠性,Redis 会采用 AOF 日志或 RDB 快照。其中,AOF 日志提供了三种日志写回策略:no、everysec、always。这三种写回策略依赖文件系统的两个系统调用完成,也就是 write(直接写到缓冲区) 和 fsync(需要把日志记录写回到磁盘后才能返回,时间较长)。

image

everysec 允许丢失一秒的数据,使用子进程进行 fsync 操作

Always 不允许丢失一秒的数据,不能使用子进程,使用主进程

AOF重写,缩小AOF容量,也是使用子进程 fsync, 当AOF压力大的时候,会是fsync阻塞,虽然是子进程,但是主进程会监控子进程,如果发现上一次还没执行完,则会阻塞。

image

# 解决办法:

image

确认客户需要的数据安全级别,是不是需要always,对于一些缓存类的,其实不需要

也可考虑将下面设置为yes,也就是早AOF重写的时候,不进行fsync,刷盘,只是写入缓冲区,可能会引起数据丢失

no-appendfsync-on-rewrite yes

# 操作系统:swap

内存 swap 是操作系统里将内存数据在内存和磁盘间来回换入和换出的机制,涉及到磁盘的读写,所以,一旦触发 swap,无论是被换入数据的进程,还是被换出数据的进程,其性能都会受到慢速磁盘读写的影响。

# 何时会触发swap

触发 swap 的原因主要是物理机器内存不足,对于 Redis 而言,有两种常见的情况:

  1. Redis 实例自身使用了大量的内存,导致物理机器的可用内存不足;
  2. 和 Redis 实例在同一台机器上运行的其他进程,在进行大量的文件读写操作。文件读写本身会占用系统内存,这会导致分配给 Redis 实例的内存量变少,进而触发 Redis 发生 swap。

# 如何查看Swap占用

每一行 Size 表示的是 Redis 实例所用的一块内存大小,而 Size 下方的 Swap 和它相对应,表示这块 Size 大小的内存区域有多少已经被换出到磁盘上了。如果这两个值相等,就表示这块内存区域已经完全被换出到磁盘了。

$ redis-cli info | grep process_id
process_id: 5332

# 然后,进入 Redis 所在机器的 /proc 目录下的该进程目录中:
$ cd /proc/5332


$cat smaps | egrep '^(Swap|Size)'
Size: 584 kB
Swap: 0 kB
Size: 4 kB
Swap: 4 kB
Size: 4 kB
Swap: 0 kB
Size: 462044 kB
Swap: 462008 kB
Size: 21392 kB
Swap: 0 kB

# 解决方案

增加机器的内存或者使用 Redis 集群。

# 操作系统:内存大页

内存大页机制(Transparent Huge Page, THP),也会影响 Redis 性能。

Linux 内核从 2.6.38 开始支持内存大页机制,该机制支持 2MB 大小的内存页分配,而常规的内存页分配是按 4KB 的粒度来执行的。

# 持久化是使用写时复制

Redis 为了提供数据可靠性保证,需要将数据做持久化保存。

这个写入过程由额外的线程执行,所以,此时,Redis 主线程仍然可以接收客户端写请求。客户端的写请求可能会修改正在进行持久化的数据。在这一过程中,Redis 就会采用写时复制机制,也就是说,一旦有数据要被修改,Redis 并不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。

如果采用了内存大页,那么,即使客户端请求只修改 100B 的数据,Redis 也需要拷贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。两者相比,你可以看到,当客户端请求修改或新写入数据较多时,内存大页机制将导致大量的拷贝,这就会影响 Redis 正常的访存操作,最终导致性能变慢。

# 解决方案

关闭内存大页

echo never /sys/kernel/mm/transparent_hugepage/enabled

# info命令

INFO – Redis (opens new window)

  • expired_keys: Total number of key expiration events
  • evicted_keys: Number of evicted keys due to maxmemory limit
  • latest_fork_usec: Duration of the latest fork operation in microseconds

# Kaito Redis变慢Checklist

1、使用复杂度过高的命令(例如SORT/SUION/ZUNIONSTORE/KEYS),或一次查询全量数据(例如LRANGE key 0 N,但N很大)

分析:a) 查看slowlog是否存在这些命令 b) Redis进程CPU使用率是否飙升(聚合运算命令导致)

解决:a) 不使用复杂度过高的命令,或用其他方式代替实现(放在客户端做)b) 数据尽量分批查询(LRANGE key 0 N,建议N<=100,查询全量数据建议使用HSCAN/SSCAN/ZSCAN)

2、操作bigkey

分析:a) slowlog出现很多SET/DELETE变慢命令(bigkey分配内存和释放内存变慢) b) 使用redis-cli -h $host -p $port --bigkeys扫描出很多bigkey

解决:a) 优化业务,避免存储bigkey b) Redis 4.0+可开启lazy-free机制

3、大量key集中过期

分析:a) 业务使用EXPIREAT/PEXPIREAT命令 b) Redis info中的expired_keys指标短期突增

解决:a) 优化业务,过期增加随机时间,把时间打散,减轻删除过期key的压力 b) 运维层面,监控expired_keys指标,有短期突增及时报警排查

4、Redis内存达到maxmemory

分析:a) 实例内存达到maxmemory,且写入量大,淘汰key压力变大 b) Redis info中的evicted_keys指标短期突增

解决:a) 业务层面,根据情况调整淘汰策略(随机比LRU快) b) 运维层面,监控evicted_keys指标,有短期突增及时报警 c) 集群扩容,多个实例减轻淘汰key的压力

5、大量短连接请求

分析:Redis处理大量短连接请求,TCP三次握手和四次挥手也会增加耗时

解决:使用长连接操作Redis

6、生成RDB和AOF重写fork耗时严重

分析:a) Redis变慢只发生在生成RDB和AOF重写期间 b) 实例占用内存越大,fork拷贝内存页表越久 c) Redis info中latest_fork_usec耗时变长

解决:a) 实例尽量小 b) Redis尽量部署在物理机上 c) 优化备份策略(例如低峰期备份) d) 合理配置repl-backlog和slave client-output-buffer-limit,避免主从全量同步 e) 视情况考虑关闭AOF f) 监控latest_fork_usec耗时是否变长

7、AOF使用awalys机制

分析:磁盘IO负载变高

解决:a) 使用everysec机制 b) 丢失数据不敏感的业务不开启AOF

8、使用Swap

分析:a) 所有请求全部开始变慢 b) slowlog大量慢日志 c) 查看Redis进程是否使用到了Swap

解决:a) 增加机器内存 b) 集群扩容 c) Swap使用时监控报警

9、进程绑定CPU不合理

分析:a) Redis进程只绑定一个CPU逻辑核 b) NUMA架构下,网络中断处理程序和Redis进程没有绑定在同一个Socket下

解决:a) Redis进程绑定多个CPU逻辑核 b) 网络中断处理程序和Redis进程绑定在同一个Socket下

10、开启透明大页机制

分析:生成RDB和AOF重写期间,主线程处理写请求耗时变长(拷贝内存副本耗时变长)

解决:关闭透明大页机制

11、网卡负载过高

分析:a) TCP/IP层延迟变大,丢包重传变多 b) 是否存在流量过大的实例占满带宽

解决:a) 机器网络资源监控,负载过高及时报警 b) 提前规划部署策略,访问量大的实例隔离部署

总之,Redis的性能与CPU、内存、网络、磁盘都息息相关,任何一处发生问题,都会影响到Redis的性能。

主要涉及到的包括业务使用层面和运维层面:业务人员需要了解Redis基本的运行原理,使用合理的命令、规避bigke问题和集中过期问题。运维层面需要DBA提前规划好部署策略,预留足够的资源,同时做好监控,这样当发生问题时,能够及时发现并尽快处理。

# 20 | 删除数据后,为什么内存占用率还是很高?

# 什么是内存碎片?

虽然操作系统的剩余内存空间总量足够,但是,应用申请的是一块连续地址空间的 N 字节,但在剩余的内存空间中,没有大小为 N 字节的连续空间了,那么,这些剩余空间就是内存碎片

image

# 内存碎片是如何形成的?

内存碎片的形成有内因和外因两个层面的原因。简单来说,内因是操作系统的内存分配机制,外因是 Redis 的负载特征。

# 内因:内存分配器的分配策略

内存分配器的分配策略就决定了操作系统无法做到“按需分配”。这是因为,内存分配器一般是按固定大小来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配。

# 外因:键值对大小不一样和删改操作

Redis 申请内存空间分配时,本身就会有大小不一的空间需求,键值对大小不同

内存分配器只能按固定大小分配内存,所以,分配的内存空间一般都会比申请的空间大一些,不会完全一致,这本身就会造成一定的碎片,降低内存空间存储效率。

image

第二个外因是,这些键值对会被修改和删除,

image

# 如何判断是否有内存碎片?


INFO memory
# Memory
used_memory:1073741736 # Redis 为了保存数据实际申请使用的空间。 
used_memory_human:1024.00M
used_memory_rss:1997159792 # 操作系统实际分配给 Redis 的物理内存空间,包含了碎片的空间
used_memory_rss_human:1.86G 
…
mem_fragmentation_ratio:1.86 # 表示的就是 Redis 当前的内存碎片率。

# mem_fragmentation_ratio 计算

mem_fragmentation_ratio = used_memory_rss/ used_memory

# 判断经验值

  • mem_fragmentation_ratio 大于 1 但小于 1.5。这种情况是合理的。这是因为,刚才我介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由 Redis 负载决定,也无法限制。所以,存在内存碎片也是正常的。
  • mem_fragmentation_ratio 大于 1.5 。这表明内存碎片率已经超过了 50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了。

# 如何清理内存碎片?

# 重启redis(不太优雅,容易丢数据,恢复AOF也需要一段时间,此阶段无法提供服务)

# 搬家让位,合并空间

image

# 碎片清理有代价

redis单线程,需要等待清理完成才能继续执行操作

# 如何缓解

Redis 需要启用自动内存碎片清理,可以把 activedefrag 配置项设置为 yes,命令如下:

config set activedefrag yes

自动清理需要满足条件:

  • active-defrag-ignore-bytes 100mb:表示内存碎片的字节数达到 100MB 时,开始清理;
  • active-defrag-threshold-lower 10:表示内存碎片空间占操作系统分配给 Redis 的总空间比例达到 10% 时,开始清理。

# 控制CPU上下限,防止占用过多CPU

-active-defrag-cycle-min 25: 表示自动清理过程所用 CPU 时间的比例不低于 25%,保证清理能正常开展;

active-defrag-cycle-max 75:表示自动清理过程所用 CPU 时间的比例不高于 75%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞 Redis,导致响应延迟升高。

# 如果 mem_fragmentation_ratio 小于 1 了,Redis 的内存使用是什么情况呢?会对 Redis 的性能和内存空间利用率造成什么影响?

mem_fragmentation_ratio小于1,说明used_memory_rss小于了used_memory,这意味着操作系统分配给Redis进程的物理内存,要小于Redis实际存储数据的内存,也就是说Redis没有足够的物理内存可以使用了,这会导致Redis一部分内存数据会被换到Swap中,之后当Redis访问Swap中的数据时,延迟会变大,性能下降。

# 21 | 缓冲区:一个可能引发“惨案”的地方

# 什么是缓冲区

缓冲区的功能其实很简单,主要就是用一块内存空间来暂时存放命令数据,以免出现因为数据和命令的处理速度慢于发送速度而导致的数据丢失和性能问题

当缓冲区占用的内存超出了设定的上限阈值时,就会出现缓冲区溢出。

缓冲区在 Redis 中的一个主要应用场景,就是在客户端和服务器端之间进行通信时,用来暂存客户端发送的命令数据,或者是服务器端返回给客户端的数据结果。此外,缓冲区的另一个主要应用场景,是在主从节点间进行数据同步时,用来暂存主节点接收的写命令和数据。

# 客户端输入和输出缓冲区

服务器端给每个连接的客户端都设置了一个输入缓冲区和输出缓冲区,我们称之为客户端输入缓冲区和输出缓冲区。

image

# 如何应对输入缓冲区溢出?

# 可能的原因

  1. 写入了 bigkey,比如一下子写入了多个百万级别的集合类型数据;
  2. 服务器端处理请求的速度过慢,例如,Redis 主线程出现了间歇性阻塞,无法及时处理正常发送的请求,导致客户端发送的请求在缓冲区越积越多。

要查看和服务器端相连的每个客户端对输入缓冲区的使用情况,我们可以使用 CLIENT LIST 命令:

CLIENT LIST
id=5 addr=127.0.0.1:50487 fd=9 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client

如果qbuf持续增加,qbuf-free减少,就要关注了,redis是直接把客户端关闭,这样用户程序就无法读取数据了。

# 如何处理

我们可以从两个角度去考虑如何避免,一是把缓冲区调大,二是从数据命令的发送和处理速度入手。

# 无法调大缓冲区!!!!

客户端输入缓冲区大小的上限阈值,在代码中就设定为了 1GB。也就是说,Redis 服务器端允许为每个客户端最多暂存 1GB 的命令和数据

# 数据命令发送和处理速度入手

是前面提到的避免客户端写入 bigkey,以及避免 Redis 主线程阻塞。

# 如何应对输出缓冲区溢出?

既有 ok 响应,也有数据结果

# 包括两个部分

是一个大小为 16KB 的固定缓冲空间,用来暂存 OK 响应和出错信息;

另一部分,是一个可以动态增加的缓冲空间,用来暂存大小可变的响应结果。

# 何时溢出?
  1. 服务器端返回 bigkey 的大量结果;
  2. 执行了 MONITOR 命令;
    1. 会持续输出监控信息
    2. MONITOR 命令主要用在调试环境中,不要在线上生产环境中持续使用 MONITOR
  3. 缓冲区大小设置得不合理。
# 如何避免?
  1. 避免 bigkey 操作返回大量数据结果;
  2. 避免在线上环境中持续使用 MONITOR 命令。
  3. 使用 client-output-buffer-limit 设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限。

# 主从集群中的缓冲区

全量复制和增量复制,都会用到缓冲区

# 复制缓冲区的溢出问题

主向从传入RDB的时候,会继续接受客户端的请求,并把这些保存到缓冲区内

复制缓冲区一旦发生溢出,主节点也会直接关闭和从节点进行复制操作的连接,导致全量复制失败

image

# 如何避免?

  1. 控制主节点保存的数据量大小 2-4G比较合适
  2. client-output-buffer-limit 配置项
config set client-output-buffer-limit slave 512mb 128mb 60

slave 参数表明该配置项是针对复制缓冲区的。512mb 代表将缓冲区大小的上限设置为 512MB;128mb 和 60 代表的设置是,如果连续 60 秒内的写入量超过 128MB 的话,也会触发缓冲区溢出

  1. 主节点的复制缓冲区的大小等于所有从节点的内存占用的和,所以控制redis集群的规模

# 复制积压缓冲区的溢出问题

主节点在把接收到的写命令同步给从节点时,同时会把这些写命令写入复制积压缓冲区

使用的是环形缓冲区,一旦发生溢出,就会覆盖,从节点需要全量同步

image

# 23 | 旁路缓存:Redis是如何工作的?

# 缓存的特征

一个系统中的不同层之间的访问速度不一样,所以我们才需要缓存,这样就可以把一些需要频繁访问的数据放在缓存中,以加快它们的访问速度

image

# 计算机两种缓存

  1. CPU 里面的末级缓存,即 LLC,用来缓存内存中的数据,避免每次从内存中存取数据;

  2. 内存中的高速页缓存,即 page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。

# Redis 缓存处理请求的两种情况

  1. 缓存命中:Redis 中有相应数据,就直接读取 Redis,性能非常快。
  2. 缓存缺失:Redis 中没有保存相应数据,就从后端数据库中读取数据,性能就会变慢。而且,一旦发生缓存缺失,为了让后续请求能从缓存中读取到数据,我们需要把缺失的数据写入 Redis,这个过程叫作缓存更新

image

# Redis 作为旁路缓存的使用操作

使用redis作为缓存,需要增加三种代码

  1. 当应用程序需要读取数据时,我们需要在代码中显式调用 Redis 的 GET 操作接口,进行查询;
  2. 如果缓存缺失了,应用程序需要再和数据库连接,从数据库中读取数据;
  3. 当缓存中的数据需要更新时,我们也需要在应用程序中显式地调用 SET 操作接口,把更新的数据写入缓存。

# 缓存的类型

# 只读缓存

有数据读取,没数据,读数据库,再次写入,增删改数据后也会删除redis,下次读取,会再次从库里读取并缓存了

image

只读缓存直接在数据库中更新数据的好处是,所有最新的数据都在数据库中,而数据库是提供数据可靠性保障的,这些数据不会有丢失的风险,缓存图片,短视频

# 读写缓存

读写都会在redis中进行,速度非常快

最新数据在redis,redis是内存数据库,有数据丢失的风险

根据业务应用对数据可靠性和缓存性能的不同要求,我们会有**同步直写(可靠性)异步写回(快速响应)**两种策略

# 同步直写

写请求发给缓存的同时,也会发给后端数据库进行处理,等到缓存和数据库都写完数据,才给客户端返回 ,保证了可靠性,有性能损耗

# 异步写回

所有写请求都先在缓存中处理。等到这些增改的数据要被从缓存中淘汰出来时,缓存将它们写回后端数据库 ,如果掉电有数据丢失风险

image

# 如何选择

选择只读缓存,还是读写缓存,主要看我们对写请求是否有加速的需求。

  1. 如果需要对写请求进行加速,我们选择读写缓存;(商品大促,频繁修改库存)
  2. 如果写请求很少,或者是只需要提升读请求的响应速度的话,我们选择只读缓存。(视频,图片,很少变动的)

只读缓存是牺牲了一定的性能,优先保证数据库和缓存的一致性,它更适合对于一致性要求比较要高的业务场景。

而如果对于数据库和缓存一致性要求不高,或者不存在并发修改同一个值的情况,那么使用读写缓存就比较合适,它可以保证更好的访问性能。

# 24 | 替换策略:缓存满了怎么办?

# 设置多大的缓存容量合适?

蓝线。它表示的就是“八二原理”,有 20% 的数据贡献了 80% 的访问了,而剩余的数据虽然体量很大,但只贡献了 20% 的访问量。这 80% 的数据在访问量上就形成了一条长长的尾巴,我们也称为“长尾效应”。

如果“八二原理”来设置缓存空间容量,也就是把缓存空间容量设置为总数据量的 20% 的话,就有可能拦截到 80% 的访问。

image

红线。重尾远离

红线上,80% 的数据贡献的访问量,超过了传统的长尾效应中 80% 数据能贡献的访问量。原因在于,用户的个性化需求越来越多,在一个业务应用中,不同用户访问的内容可能差别很大,所以,用户请求的数据和它们贡献的访问量比例,不再具备长尾效应中的“八二原理”分布特征了。也就是说,20% 的数据可能贡献不了 80% 的访问,而剩余的 80% 数据反而贡献了更多的访问量,我们称之为重尾效应。

# 建议

建议把缓存容量设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销。

# 确认了最大内存,设置redis内存

CONFIG SET maxmemory 4gb

# Redis 缓存有哪些淘汰策略?

  • 默认是noevction,放不下了直接淘汰
  • 设置过期时间淘汰
    • volatile-ttl。从最早过期的进行淘汰
    • volatile-random 随机删除
    • volatile-lru 使用lru算法进行筛选
    • volatile-lfu 使用lfu
  • always
    • 同上面三种,范围扩大到所有的键值对

image

# LRU

LRU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据

LRU 算法选择删除数据时,都是从 LRU 端开始

有新数据进来,发现缓存已满,则需要两步

  1. 数据放到MRU端
  2. 把LRU端的数据一出

image

# 思想

  • 它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU 端;

  • 长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到 LRU 端,在缓存满时,就优先删除它

# 缺点

  • 需要用链表管理所有的缓存数据,这会带来额外的空间开销
  • 有数据访问,需要移动数据到MRU端,降低性能

# Redis实现

  • Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。
  • 然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合
  • 接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去

# 建议

  • 优先使用 allkeys-lru 策略 ,如果冷热相差很大
  • 如果业务应用中的数据访问频率相差不大,建议使用 allkeys-random 策略
  • 如果你的业务中有置顶的需求 使用 volatile-lru ,因为置顶的肯定不能删嘛

# 如何处理被淘汰的数据?

干净数据和脏数据的区别就在于,和最初从后端数据库里读取时的值相比,有没有被修改过。

image

# 缓存异常(上):如何解决缓存和数据库的数据不一致问题

# 缓存和数据库的数据不一致是如何发生的?

# 一致性两种情况

  1. 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;

  2. 缓存中本身没有数据,那么,数据库中的值必须是最新值。

# 只读缓存

如果有数据新增,会直接写入数据库;而有数据删改时,就需要把只读缓存中的数据标记为无效。

image

# 新增数据没问题
# 删改数据

用先删除缓存,再更新数据库,如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了

image

如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的

image

image

# 如何解决数据不一致问题?

  1. 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka 消息队列)。当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。

  2. 删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。

image

只读缓存处理

image

# 26 | 缓存异常(下):如何解决缓存雪崩、击穿、穿透难题?

# 缓存雪崩

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

# 原因

  1. 缓存中有大量数据同时过期,导致大量请求无法得到处理。
    1. image
    2. 解决方案
      1. 避免给大量的数据设置相同的过期时间 expire的时候增加随机数
      2. 服务降级,来应对缓存雪崩。
        1. 非核心服务,直接返回预定的数据 空值或者错误信息,不从数据库读取
        2. 核心数据 让然可以查询缓存和数据库
          1. image
  2. Redis 缓存实例发生故障宕机了
    1. Redis 支持数万tps吞吐量,而一般数据库只支持数千tps吞吐量
    2. 解决方案
      1. 业务系统中设置 服务熔断和限流
        1. 服务熔断,是指在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问,接口访问直接返回预置数据,不访问redis或者数据库
        2. image
        3. 请求限流。这里说的请求限流,就是指,我们在业务系统的请求入口前端控制每秒进入系统的请求数,避免过度请求进入
          1. image
      2. 事前预防
        1. 构建 Redis 缓存高可靠集群

# 缓存击穿

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。

发生原因: 热点数据失效了

img

# 解决方案

于访问特别频繁的热点数据,我们就不设置过期时间了。这样一来,对热点数据的访问请求,都可以在缓存中进行处理

# 缓存穿透

缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。这样缓存就失效了

如果有大量的请求,就会给缓存和数据库带来巨大的压力

image

# 发生原因

  1. 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
  2. 恶意攻击:专门访问数据库中没有的数据。

# 解决方案

  1. 第一种方案是,缓存空值或缺省值。没有数据,也要放入一个空值到redis中
  2. 第二种方案是,使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。
# 布隆过滤器

切记:bloomfilter 说存在,则可能存在可能不存在,bloomfilter说不存在,则一定是不存在的。

也就是: 你说你有钱,你不一定有钱,你说你没钱,你肯定是个穷鬼

布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:

  1. 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
  2. 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  3. 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。

image

只要有一个为 0,那么,X 就肯定不在数据库中。

关于布隆过滤器的使用,还有几点和大家分享。

1、布隆过滤器会有误判:由于采用固定bit的数组,使用多个哈希函数映射到多个bit上,有可能会导致两个不同的值都映射到相同的一组bit上。虽然有误判,但对于业务没有影响,无非就是还存在一些穿透而已,但整体上已经过滤了大多数无效穿透请求。

2、布隆过滤器误判率和空间使用的计算:误判本质是因为哈希冲突,降低误判的方法是增加哈希函数 + 扩大整个bit数组的长度,但增加哈希函数意味着影响性能,扩大数组长度意味着空间占用变大,所以使用布隆过滤器,需要在误判率和性能、空间作一个平衡,具体的误判率是有一个计算公式可以推导出来的(比较复杂)。但我们在使用开源的布隆过滤器时比较简单,通常会提供2个参数:预估存入的数据量大小、要求的误判率,输入这些参数后,布隆过滤器会有自动计算出最佳的哈希函数数量和数组占用的空间大小,直接使用即可。

3、布隆过滤器可以放在缓存和数据库的最前面:把Redis当作布隆过滤器时(4.0提供了布隆过滤器模块,4.0以下需要引入第三方库),当用户产生业务数据写入缓存和数据库后,同时也写入布隆过滤器,之后当用户访问自己的业务数据时,先检查布隆过滤器,如果过滤器不存在,就不需要查询缓存和数据库了,可以同时降低缓存和数据库的压力。

4、Redis实现的布隆过滤器bigkey问题:Redis布隆过滤器是使用String类型实现的,存储的方式是一个bigkey,建议使用时单独部署一个实例,专门存放布隆过滤器的数据,不要和业务数据混用,否则在集群环境下,数据迁移时会导致Redis阻塞问题。

最后一种方案前端进行请求检测

# 总结

image

# 27 | 缓存被污染了,该怎么办?

在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。

# 如何解决缓存污染问题?

# LRU 缓存策略

LRU 策略的核心思想:如果一个数据刚刚被访问,那么这个数据肯定是热数据,还会被再次访问。

。所谓的扫描式单次查询操作,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。也正是因为只看数据的访问时间,使用 LRU 策略在处理扫描式单次查询操作时,无法解决缓存污染。

image

所以,对于采用了 LRU 策略的 Redis 缓存来说,扫描式单次查询会造成缓存污染。

# LFU 缓存策略的优化

LRU 策略相比,LFU 策略中会从两个维度来筛选并淘汰数据:一是,数据访问的时效性(访问时间离当前时间的远近);二是,数据的被访问次数

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

和那些被频繁访问的数据相比,扫描式单次查询的数据因为不会被再次访问,所以它们的访问次数不会再增加。因此,LFU 策略会优先把这些访问次数低的数据淘汰出缓存。这样一来,LFU 策略就可以避免这些数据对缓存造成污染了。

LRU实现方法

LRU 策略时使用了两个近似方法:

  1. Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
  2. Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10 个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。

Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分。

  1. ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
  2. counter 值:lru 字段的后 8bit,表示数据的访问次数。

当 LFU 策略筛选数据时,Redis 会在候选集合中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰

。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。

Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255 这样是有问题的。可能有很多次被访问的,只能记录到255了,不行。

因此,在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。

计数规则是:每当数据被访问一次时,首先,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1

image

使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效地区分不同的访问次数,从而进行合理的数据筛选

Redis 在实现 LFU 策略时,还设计了一个 counter 值的衰减机制。 防止有的缓存,再大量使用后,就不在访问了,一直占用着缓存的问题。,

# 衰减策略

LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

# 28 | Pika:如何基于SSD实现大容量Redis?

Pika 在刚开始设计的时候,就有两个目标:

  1. 一是,单实例可以保存大容量数据,同时避免了实例恢复和主从同步时的潜在问题;
  2. 二是,和 Redis 数据类型保持兼容,可以支持使用 Redis 的应用平滑地迁移到 Pika 上。

# Pika 的整体架构

image

# 网络模块

网络框架主要负责底层网络请求的接收和发送

# 多线程模型

请求分发线程(DispatchThread)、一组工作线程(WorkerThread)以及一个线程池(ThreadPool)。

img

# Nemo 模块

它实现了 Pika 和 Redis 的数据类型兼容

Pika 可以不用大容量的内存,还避免了使用内存快照。

Pika 使用 binlog 机制记录写命令,用于主从节点的命令同步,避免了大内存实例在主从同步过程中的潜在问题。

# Pika 如何基于 SSD 保存更多数据?

image

Pika 需要保存数据时,RocksDB 会使用两小块内存空间(Memtable1 和 Memtable2)来交替缓存写入的数据。

image

Redis 会面临 RDB 生成和恢复的效率问题,以及主从同步时的效率和缓冲区溢出问题。那么,当 Pika 保存大量数据时,还会面临相同的问题吗?

  1. pika直接保存在文件中,不需要rdb,从库直接拉文件
  2. pika使用binlong机制实现增量同步 节省了内存,避免了缓冲区溢出的问题

Pika 使用 RocksDB 把大量数据保存到了 SSD,同时避免了内存快照的生成和恢复问题。而且,Pika 使用 binlog 机制进行主从同步,避免大内存时的影响,Pika 的第一个设计目标就实现了。

# Pika 如何实现 Redis 数据类型兼容?

RocksDB 只提供了单值的键值对类型,RocksDB 键值对中的值就是单个值,而 Redis 键值对中的值还可以是集合类型

Pika 的 Nemo 模块就负责把 Redis 的集合类型转换成单值的键值对

# List

image

# Set

image

# Hash

image

# Sorted Set

image

# Pika 的其他优势与不足

# 优势

  1. Pika 单实例能保存更多的数据了,实现了实例数据扩容。
  2. 实例重启快
  3. 主从库重新执行全量同步的风险低. 使用binlog,不需要缓冲区了。

# 缺点

  1. 降低数据的访问性能
  2. 我们还需要把 binlog 机制记录的写命令同步到 SSD 上,这会降低 Pika 的写性能。

image

# 29 | 无锁的原子操作:Redis如何应对并发访问?

保证并发访问的正确性,Redis 提供了两种方法:

  1. 加锁
    1. 操作前需要加锁,完成操作后释放
    2. 有性能开销,降低并发行,需要分布式锁
  2. 原子操作
    1. 原子操作是指执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样一来,既能保证并发控制,还能减少对系统并发性能的影响。

# 并发访问中需要对什么进行控制?

# RMW(读取修改写回)

下面的流程叫做“读取 - 修改 - 写回”操作(Read-Modify-Write,简称为 RMW

访问同一份数据的 RMW 操作代码,就叫做临界区代码。

基本流程分成两步:

  1. 客户端先把数据读取到本地,在本地进行修改;
  2. 客户端修改完数据后,再写回 Redis。

image

# 使用加锁

降低了并发性

image

# 两种原子操作

  1. 把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
    1. 当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。
    2. Redis 提供了 INCR/DECR 命令
  2. 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。
    1. 多个命令组成的复杂操作
    2. Redis 的 EVAL 命令来执行脚本

限流场景,每个ip每分钟只能访问指定次数


//获取ip对应的访问次数
current = GET(ip)
//如果超过访问次数超过20次,则报错
IF current != NULL AND current > 20 THEN
    ERROR "exceed 20 accesses per second"
ELSE
    //如果访问次数不足20次,增加一次访问计数
    value = INCR(ip)
    //如果是第一次访问,将键值对的过期时间设置为60s后
    IF value == 1 THEN
        EXPIRE(ip,60)
    END
    //执行其他操作
    DO THINGS
END
local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],60)
end
redis-cli  --eval lua.script  keys , args

在编写 Lua 脚本时,你要避免把不需要做并发控制的操作写入脚本中。

使用lua脚本时,还有一些注意点:

1、lua 脚本尽量只编写通用的逻辑代码,避免直接写死变量。变量通过外部调用方传递进来,这样 lua 脚本的可复用度更高。

2、建议先使用SCRIPT LOAD命令把 lua 脚本加载到 Redis 中,然后得到一个脚本唯一摘要值,再通过EVALSHA命令 + 脚本摘要值来执行脚本,这样可以避免每次发送脚本内容到 Redis,减少网络开销。

# 30 | 如何使用Redis实现分布式锁?

在分布式系统中,当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。

# 单机上的锁和分布式锁的联系与区别

对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示。

  • 变量值为 0 时,表示没有线程获取锁;
  • 变量值为 1 时,表示已经有线程获取到锁了。

和线程在单机上操作锁不同的是,在分布式场景下,锁变量需要由一个共享存储系统来维护,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值。

# 分布式锁的两个要求

  1. 加锁 释放锁 保证原子性
  2. 考虑 共享存储系统的可靠性,保证锁的可靠性 共享系统当机了咋搞

# 基于单个 Redis 节点实现分布式锁

加锁

image

释放锁

image

加锁包含三个操作

  1. 读取锁变量
  2. 判断锁变量值
  3. 把锁变量值设置为1

想要保证锁源自行 2个方法

  1. 使用redis的单命令操作
  2. 使用lua脚本

我们就可以用 SETNX 和 DEL 命令组合来实现加锁和释放锁操作

// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key

两个风险

  1. 某客户端执行setnx命令后,然后发生异常,一直没有使用del释放锁,其他客户端无法拿到锁 解决方法
    1. 给锁变量设置过期时间
  2. A执行了setnx加锁,B执行了DEL释放了锁,被误释放了,C申请加锁就能获得

解决办法

执行下面的命令时,只有 key 不存在时,SET 才会创建 key,并对 key 进行赋值。另外,key 的存活时间由 seconds 或者 milliseconds 选项值来决定。

SET key value [EX seconds | PX milliseconds]  [NX]
// 有了 SET 命令的 NX 和 EX/PX 选项后,我们就可以用下面的命令来实现加锁操作了。

// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000

unique_vulue 是客户端唯一 标示,px过期时间 nx表示没有则设置,没有,则不做设置

释放锁的shell脚本 ,需要判断设置的值是不是当前客户端,避免误操作


//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

单机有风险,如果redis挂掉了,客户端就无法正常操作了。

# 基于多个 Redis 节点实现高可靠的分布式锁

为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。

基本思路 是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。

第一步是,客户端获取当前时间。 第二步是,客户端按顺序依次向 N 个 Redis 实例执行加锁操作。 第三步是,一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

# 31 | 事务机制:Redis能实现ACID属性吗?

事务是数据库的一个重要功能。所谓的事务,就是指对数据进行读写的一系列操作。事务在执行时,会提供专门的属性保证,包括原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是 ACID 属性。这些属性既包括了对事务执行结果的要求,也有对数据库在事务执行前后的数据状态变化的要求。

# 事务 ACID 属性的要求

  1. 原子性,一个事物多个操作必须都完成 最看重的一个属性
  2. 一致性 数据在执行前后是一致的。
  3. 隔离性 一个事物执行时,其他操作无法存取到正在执行食物访问的数据
  4. 持久性 数据修改被持久保存袭来,重启后,数据的值要是被修改的值

# Redis如何实现事物

Redis 提供了 MULTI、EXEC 两个命令来完成这三个步骤

  1. 显示执行一个命令 开启一个事物 MULTI
  2. 客户端吧事物中执行具体操作发送给服务器,get set, 虽然被发送到服务端,但是redis实例知识暂存到一个命令队列中,不会立即执行
  3. 客户端发送提交事物的命令 ,让数据库实际执行。 EXEC命令。

#开启事务
127.0.0.1:6379> MULTI
OK
#将a:stock减1,
127.0.0.1:6379> DECR a:stock
QUEUED
#将b:stock减1
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务
127.0.0.1:6379> EXEC
1) (integer) 4
2) (integer) 9

# Redis 的事务机制能保证哪些属性?

# 原子性

  1. 第一种情况 在执行 EXEC 命令前,客户端发送的操作命令本身就有错误 在命令入队时,Redis 就会报错并且记录下这个错误。此时,我们还能继续提交命令操作。等到执行了 EXEC 命令之后,Redis 就会拒绝执行所有提交的命令操作,返回事务失败的结果。这样一来,事务中的所有命令都不会再被执行了,保证了原子性。

#开启事务
127.0.0.1:6379> MULTI
OK
#发送事务中的第一个操作,但是Redis不支持该命令,返回报错信息
127.0.0.1:6379> PUT a:stock 5
(error) ERR unknown command `PUT`, with args beginning with: `a:stock`, `5`, 
#发送事务中的第二个操作,这个操作是正确的命令,Redis把该命令入队
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务,但是之前命令有错误,所以Redis拒绝执行
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
  1. 第二种情况 事务操作入队时,命令和操作的数据类型不匹配,但 Redis 实例没有检查出错误

但是,在执行完 EXEC 命令以后,Redis 实际执行这些事务操作时,就会报错。不过,需要注意的是,虽然 Redis 会对错误命令报错,但还是会把正确的命令执行完。在这种情况下,事务的原子性就无法得到保证了

#开启事务
127.0.0.1:6379> MULTI
OK
#发送事务中的第一个操作,LPOP命令操作的数据类型不匹配,此时并不报错
127.0.0.1:6379> LPOP a:stock
QUEUED
#发送事务中的第二个操作
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务,事务第一个操作执行报错
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) (integer) 8

有疑惑,传统数据库Mysql 事物执行失败,有回滚机制,所有操作撤销,已经修改的也会回复到执行钱的状态。 Redis并没有回滚机制 Discard只是放弃事物,起不到回滚的效果的。

  1. 第三种情况 在执行事务的 EXEC 命令时,Redis 实例发生了故障,导致事务执行失败。 如果开启了AOF日志,需要使用 redis-check-aof 工具检查,可以把未完成的事物操作从aof文件中去除,保证了原子性,如果没有aof则,无法保证原子性

Redis 对事务原子性属性的保证情况,我们来简单小结下:

  1. 命令入队时就报错,会放弃事务执行,保证原子性;
  2. 命令入队时没报错,实际执行时报错,不保证原子性;E
  3. XEC 命令执行时实例故障,如果开启了 AOF 日志,可以保证原子性。

# 一致性

情况一:命令入队时就报错 本身就放弃执行,保证数据一致性 情况二:命令入队时没报错,实际执行时报错 在这种情况下,有错误的命令不会被执行,正确的命令可以正常执行,也不会改变数据库的一致性。 情况三:EXEC 命令执行时实例发生故障

在命令执行错误或 Redis 发生故障的情况下,Redis 事务机制对一致性属性是有保证的。

# 隔离性

事务的隔离性保证,会受到和事务一起执行的并发操作的影响。而事务执行又可以分成命令入队(EXEC 命令执行前)和命令实际执行(EXEC 命令执行后)两个阶段

  1. 并发操作在 EXEC 命令前执行,此时,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证;
  2. 并发操作在 EXEC 命令后执行,此时,隔离性可以保证。

有 watch机制 image

没有watch机制,无法保证隔离性

image

并发操作在 EXEC 命令之后被服务器端接收并执行。 因为redis是单线程执行命令,exec执行后,redis保证先把命令中所有命令执行完,所以这种情况不会破坏隔离性 如下图所示

image

# 持久性

所以,不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。

image

Redis 的事务机制可以保证一致性和隔离性,但是无法保证持久性。 redis是内存数据库,持久性不是一个必须的属性 原子性比较复杂,只有当使用命令语法有误的时候,原子性得不到保证,其他情况下,原子性可以保证

所以,我给你一个小建议:严格按照 Redis 的命令规范进行程序开发,并且通过 code review 确保命令的正确性。这样一来,Redis 的事务机制就能被应用在实践中,保证多操作的正确执行。

关于 Redis 事务的使用,有几个细节我觉得有必要补充下,关于 Pipeline 和 WATCH 命令的使用。

1、在使用事务时,建议配合 Pipeline 使用。

a) 如果不使用 Pipeline,客户端是先发一个 MULTI 命令到服务端,客户端收到 OK,然后客户端再发送一个个操作命令,客户端依次收到 QUEUED,最后客户端发送 EXEC 执行整个事务(文章例子就是这样演示的),这样消息每次都是一来一回,效率比较低,而且在这多次操作之间,别的客户端可能就把原本准备修改的值给修改了,所以无法保证隔离性。

b) 而使用 Pipeline 是一次性把所有命令打包好全部发送到服务端,服务端全部处理完成后返回。这么做好的好处,一是减少了来回网络 IO 次数,提高操作性能。二是一次性发送所有命令到服务端,服务端在处理过程中,是不会被别的请求打断的(Redis单线程特性,此时别的请求进不来),这本身就保证了隔离性。我们平时使用的 Redis SDK 在使用开启事务时,一般都会默认开启 Pipeline 的,可以留意观察一下。

2、关于 WATCH 命令的使用场景。

a) 在上面 1-a 场景中,也就是使用了事务命令,但没有配合 Pipeline 使用,如果想要保证隔离性,需要使用 WATCH 命令保证,也就是文章中讲 WATCH 的例子。但如果是 1-b 场景,使用了 Pipeline 一次发送所有命令到服务端,那么就不需要使用 WATCH 了,因为服务端本身就保证了隔离性。

b) 如果事务 + Pipeline 就可以保证隔离性,那 WATCH 还有没有使用的必要?答案是有的。对于一个资源操作为读取、修改、写回这种场景,如果需要保证事物的原子性,此时就需要用到 WATCH 了。例如想要修改某个资源,但需要事先读取它的值,再基于这个值进行计算后写回,如果在这期间担心这个资源被其他客户端修改了,那么可以先 WATCH 这个资源,再读取、修改、写回,如果写回成功,说明其他客户端在这期间没有修改这个资源。如果其他客户端修改了这个资源,那么这个事务操作会返回失败,不会执行,从而保证了原子性。

# 32 | Redis主从同步与故障切换,有哪些坑?

# 主从数据不一致

主从数据不一致,就是指客户端从从库中读取到的值和主库中的最新值并不一致。

其实这是因为主从库间的命令复制是异步进行的。

主库收到鞋命令,发从库,是异步的,不会等待完成。如果从库未来得及同步,就存在不一致了。

原因

  1. 主从之间有网络延迟
  2. 从库收到了,但是在执行其他复杂度搞的命令(集合操作)阻塞。

应对

  1. 硬件层,尽量保证网络连接状况良好。避免把从库部署在不同机房。 避免和 网络通信密集的应用部署在一起
  2. 开发一个外部程序来监控主从库之间的复制进度。
    1. info replication
    2. master_repl_offset - slave_repl_offset 查看差值
    3. 某slaver差值过大,则不从该slaver读取了。

image

# 读取过期数据

数据 X 的过期时间是 202010240900,但是客户端在 202010240910 时,仍然可以从从库中读到数据 X

是由 Redis 的过期数据删除策略引起的。

Redis 同时使用了两种策略来删除过期的数据,分别是惰性删除策略和定期删除策略。

  1. 惰性删除:数据过期并不会立即删除,二十等到有请求来读写数据时候,检查,发现过期,删除 好处:尽量禁烧CPU资源使用。坏处:导致大量已经过期数据u留存在内存中。所以还是用了第二种策略
  2. 定期删除:每隔一段时间(100ms) 随机选择一定数量数据,检查是否过期,删除过期的数据。

读取过期数据的原因

  1. 虽然定时删除,但是每次删除数量不多,如果过期数据很多,则没来得及删除,就会有过期数据,主要原因
  2. 惰性删除,主库读写会删除,从库不会,3.2之前 ,从库返回过期的数据,之后返回控制,所以主从集群 尽量使用 3.2以上版本

redis3.2 之后,就不会读区过期数据了么?非也 主要和 设置过期时间的命令有关系

image

两类

  1. EXPIRE 和 PEXPIRE:它们给数据设置的是从命令执行时开始计算的存活时间;EXPIREAT 和
  2. PEXPIREAT:它们会直接把数据的过期时间设置为具体的一个时间点。 第一类,在主库同步给从库后,会有一定的时间延迟,所以过期时间也会比主库晚,所以可能读取到过期的数据

建议:在业务应用中使用 EXPIREAT/PEXPIREAT 命令,把数据的过期时间设置为具体的时间点,避免读到过期数据。 同时要使用 相同的时钟,比如使用 NTP服务器进行时钟同步

# 不合理配置项导致的服务挂掉

1.protected-mode 配置项

这个配置项的作用是限定哨兵实例能否被其他服务器访问。当这个配置项设置为 yes 时,哨兵实例只能在部署的服务器本地进行访问。当设置为 no 时,其他服务器也可以访问这个哨兵实例。 所以在配置哨兵的时候要配置城 no ,否则主库下线,无法判定

2.cluster-node-timeout 配置项 这个配置项设置了 Redis Cluster 中实例响应心跳消息的超时时间。

执行主从切换的实例超过半数,而主从切换时间又过长的话,就可能有半数以上的实例心跳超时,从而可能导致整个集群挂掉。所以,我建议你将 cluster-node-timeout 调大些(例如 10 到 20 秒)。

image

一个小建议:Redis 中的 slave-serve-stale-data 配置项设置了从库能否处理数据读写命令,你可以把它设置为 no。这样一来,从库只能服务 INFO、SLAVEOF 命令,这就可以避免在从库中读到不一致的数据了。

注意下这个配置项和 slave-read-only 的区别,slave-read-only 是设置从库能否处理写命令,slave-read-only 设置为 yes 时,从库只能处理读请求,无法处理写请求,你可不要搞混了。