跳转至

Redis高级

Redis高级

Redis单线程

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的

但Redis的其他功能,比如关闭文件、AOF 刷盘、释放内存等,其实是由额外的线程执行的。例如执行 unlink key / flushdb async/ flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。

Redis命令工作线程是单线程的,但是,整个Redis来说,是多线程的;

之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

Redis 采用单线程为什么还这么快?

  1. 基于内存操作: Redis 的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高;
  2. 数据结构简单: Redis 的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是 0(1),因此性能比较高;
  3. Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  4. 多路复用和非阻塞 I/O: Redis使用 I/O多路复用功能来监听多个 socket连接客户端,这样就可以使用一个线程来检查多个 Socket 的就绪状态,在单个线程中通过记录跟踪每一个 socket(I/O流)的状态来管理处理多个 I/O 流,减少线程切换带来的开销,同时也避免了 I/O 阻塞操作;

Redis6.0之前一直采用单线程的主要原因

  1. 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;
  2. 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是IO多路复用和非阻塞IO;
  3. 对于Redis系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。

为什么逐渐加入多线程特性?

删除一个很大的数据时,因为是单线程原子命令操作,这就会导致 Redis 服务卡顿,于是在 Redis 4.0 中就新增了多线程的模块,主要是为了解决删除数据效率比较低的问题的。

使用惰性删除可以有效的解决性能问题, 在Redis4.0就引入了多个线程来实现数据的异步惰性删除等功能

1
2
3
unlink key     
flushdb async  
flushall async 

但是其处理读写请求的仍然只有一个线程,所以仍然算是狭义上的单线程。

在Redis6/7中引入了I/0多线程的读写,这样就可以更加高效的处理更多的任务了,Redis只是将I/O读写变成了多线程,而命令的执行依旧是由主线程串行执行的,因此在多线程下操作 Redis不会出现线程安全的问题。

多线程开启:

  1. 设置io-thread-do-reads配置项为yes,表示启动多线程。
  2. 设置线程个数 io-threads。关于线程数的设置,官方的建议是如果为4核的CPU,建议线程数设置为2或3,如果为8核CPU建议线程数设置为6,安程数一定要小于机器核数,线程数并不是越大越好。

因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会额外创建 6 个线程这里的线程数不包括主线程):

  • Redis-server : Redis的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。

BigKey

多大算BigKey?

通常我们说的BigKey,不是在值的Key很大,而是指的Key对应的value很大

大 key 会带来以下四种影响:

  1. 内存占用过高。大Key占用过多的内存空间,可能导致可用内存不足,从而触发内存淘汰策略。
  2. 性能下降。对于大Key的操作,如读取、写入、删除等,都会消耗更多的CPU时间和内存资源,进一步降低系统性能。
  3. 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  4. 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  5. 主从同步延迟。由于大Key占用较多内存,同步过程中需要传输大量数据,这会导致主从之间的网络传输延迟增加,进而影响数据一致性。
  6. 数据倾斜。在Redis集群模式中,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。

如何发现BigKey?

redis-cli --bigkey

加上 -i 参数,每隔100 条 scan指令就会休眠0.1s. ops就不会剧烈抬升,但是扫描的时间会变长
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1

最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;

如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。

想查询大于10kb的所有key,--bigkeys参数就无能为力了,需要用到memory usage来计算每个键值的字节数

memory usage 键

BigKey如何删除?

分批次删除和异步删除

  • String:一般用del,如果过于庞大使用unlink key 删除

  • hash

使用 hscan 每次获取少量field-value,再使用 hdel 删除每个field, 最后删除field-value

  • list

使用 ltrim 渐进式逐步删除,直到全部删除完成

  • set

使用 sscan 每次获取部分元素,在使用 srem 命令删除每个元素

  • zset

使用 zscan 每次获取部分元素,在使用 zremrangebyrank 命令删除每个元素

大批量往redis里面插入2000W测试数据key

Linux Bash下面执行,插入100W数据

  1. 生成100W条redis批量设置kv的语句(key=kn,value=vn)写入到/tmp目录下的redisTest.txt文件中
for((i=1;i<=100*10000;i++)); do echo "set ksi v$i" >> /tmp/redisTest.txt ;done;
  1. 通过redis提供的管道-pipe命令插入100W大批量数据
cat /tmp/redisTest.txt | /opt/redis-7.0.0/src/redis-cli -h 127.0.0.1 -p 6379-a 111111 --pipe

生产上限制 keys * /flushdb/flushall等危险命令以防止误删误用?

通过配置设置禁用这些命令,redis.conf在SECURITY这一项中

1
2
3
rename-command keys ""
rename-command flushdb ""
rename-command flushall ""

不用keys *避免卡顿,那该用什么? scan, sscan, hscan, zscan

scan cursor [MATCH pattern] [COUNT count]
  • cursor : 游标
  • pattern:匹配的模式
  • count:指定数据集返回多少数据,默认10

SCAN 命令是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。

SCAN的遍历顺序非常特别,它不是从第一维数组的第零位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。

分别说说三种写回策略,在持久化 BigKey 的时候,会影响什么?

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是永不执行 fsync() 函数;

当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的

当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。

当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程

AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程):

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
  • 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。

缓存双写一致性(缓存更新策略)

对于读数据,我会选择旁路缓存策略,如果 cache 不命中,会从 db 加载数据到 cache。对于写数据,我会选择更新 db 后,再删除缓存。

常见的缓存更新策略

  • Cache Aside(旁路缓存)策略;
  • Read/Write Through(读穿 / 写穿)策略;
  • Write Back(写回)策略;

Cache Aside(旁路缓存)

Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。

写策略的步骤:

  • 先更新数据库中的数据,再删除缓存中的数据。

读策略的步骤:

  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

Read/Write Through(读穿 / 写穿)策略

Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。

Read Through 策略:

先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。

Write Through 策略:

当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:

  • 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
  • 如果缓存中数据不存在,直接更新数据库,然后返回;

Write Back(写回)策略

Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。

Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。

Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险

数据库和缓存一致性的几种更新策略

1. 先更新数据库,再更新缓存

问题:

  1. 更新数据库成功,更新缓存失败,读到redis脏数据-数据不一致问题

  2. 缓存利用率不高:每次更新都放入缓存,不一定用到,浪费缓存

  3. 多线程下

【正常逻辑】
1 A update mysql 100
2 A update redis 100
3 B update mysql 80
4 B update redis 80
=============================
【异常逻辑】多线程环境下,A、B两个线程有快有慢,有前有后有并行
1 A update mysql 100
3 B update mysql 80
4 B update redis 80
2 A update redis 100
=============================
最终结果,mysql和redis数据不一致: mysql80,redis100

2. 先更新缓存,再更新数据库

问题:

  1. 数据不一致问题
  2. 缓存利用率不高

  3. 多线程下

【正常逻辑】
1 A update redis 100
2 A update mysql 100
3 B update redis 80
4 B update mysql 80
====================================
【异常逻辑】多线程环境下,A、B两个线程有快有慢有并行
A update redis  100
B update redis  80
B update mysql 80
A update mysql 100
====================================
----mysql100,redis80

3. 先删除缓存,再更新数据库

问题:

  1. 多线程下
A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。
于是,在缓存中的数据还是老的数据,数据库的数据是新数据

解决方案:

  1. 延时双删策略:
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

线程A删除并更新数据库后等待一段时间,B将数据库数据写入缓存后,A再删除。
等待时间大于B读取并写入时间。如何获取?评估耗时;后台监控程序(WatchDog)
第二次删除可以使用异步删除,可以增加吞吐量

4. 先更新数据库,再删除缓存

问题:

  1. 假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。

  2. 多线程下

假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。

但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入

针对删除缓存异常的情况,解决方案:

  1. 重试机制:引入消息队列把删除缓存要操作的数据加入消息队列,删除缓存失败则从队列中重新读取数据再次删除,删除成功就从队列中移除
  2. 订阅MySql binlog,再操作缓存:更新数据库成功,就会产生一条变更日志,记录在 binlog 里。订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除。Canal。

缓存预热/缓存雪崩/缓存击穿/缓存穿透

缓存预热

将热点数据提前加载到redis缓存中

缓存雪崩

redis故障或者redis中大量的缓存数据同时失效,导致大量的请求直接打到数据库或其他后端系统,造成系统性能急剧下降甚至宕机的现象。

和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决:

  1. 大量数据同时过期:

  2. 均匀设置过期时间或不过期:设置过期时间时可以加上一个随机数

  3. 互斥锁:保证同一时间只有一个请求访问数据库来构建缓存

  4. 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。

  5. redis故障

  6. 缓存集群高可用:哨兵、集群、持久化

  7. 服务降级、熔断

缓存穿透

缓存穿透是指缓存和数据库中都没有某些数据,而这些数据却被频繁地请求。由于缓存中不存在这些数据,请求每次都会直接打到数据库,导致数据库压力增大,甚至崩溃。这种情况通常发生在攻击者故意发送一些不存在的键值,绕过缓存层直接攻击数据库。

解决:

  1. 空对象缓存或缺省值:如果发生了缓存穿透,我们可以针对要查询的数据,在Redis里存一个和业务部门商量后确定的缺省值(比如,零、负数、defaultNull等)。
  2. 布隆过滤器:Google布隆过滤器Guava解决缓存穿透 guava
  3. 参数校验:在接口层对请求参数进行严格校验,过滤掉明显不合法的请求
  4. 增强id复杂度,避免被猜测id规律(可以采用雪花算法)
  5. 做好数据的基础格式校验
  6. 加强用户权限校验
  7. 做好热点参数的限流

缓存击穿

缓存击穿是指某些热点数据由于访问量大,且该数据的缓存刚好在某一时刻失效,导致大量并发请求同时击中数据库,从而造成数据库瞬时压力过大甚至宕机的现象。与缓存雪崩不同,缓存击穿主要集中在某一条或少量几条缓存失效的热点数据上。

解决:

  1. 差异失效时间:开辟两块缓存,设置不同的缓存过期时间,主A从B,先更新B再更新A,先查询A没有再查询B
  2. 加锁策略,保证同一时间只有一个业务线程更新缓存
  3. 不给热点数据设置过期时间
  4. 后台更新缓存
  5. 接口限流、熔断与降级

缓存过期删除/内存淘汰策略

redis默认内存多少可用?

如果不设置最大内存或者设置最大内存大小为0,在64位操作系统下不限制内存大小,在32位操作系统下最多使用3GB内存

注意:在64bit系统下,maxmemory设置为0表示不限制redis内存使用

一般推荐Redis设置内存为最大物理内存的¾

如何修改redis内存设置

  1. 通过修改文件配置 maxmemory

  2. 通过命令修改,但是redis重启后会失效 config set maxmemory SIZE

什么命令查看redis内存使用情况

info memory

config get maxmemory

过期删除策略

  1. 立即/定时删除:在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。对内存友好,对CPU不友好
  2. 惰性删除:不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。对内存不友好,对CPU友好。开启惰性删除淘汰,lazyfree-lazy-eviction=yes
  3. 定期删除:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。超过一定比例则重复此操作。Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。缺点是难以确定删除操作执行的时长和频率。

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

定期删除的流程:

  1. 从过期字典中随机抽取 20 个 key;
  2. 检查这 20 个 key 是否过期,并删除已过期的 key;
  3. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

内存淘汰策略

超过redis设置的最大内存,就会使用内存淘汰策略删除符合条件的key

LRU:最近最少使用页面置换算法,淘汰最长时间未被使用的页面,看页面最后一次被使用到发生调度的时间长短,首先淘汰最长时间未被使用的页面。

LFU:最近最不常用页面置换算法,淘汰一定时期内被访问次数最少的页面,看一定时间段内页面被使用的频率,淘汰一定时期内被访问次数最少的页

淘汰策略有哪些(Redis7版本):

  1. noeviction:不淘汰任何key,这时如果有新的数据写入,会报错通知禁止写入
  2. 对设置了过期时间的数据中进行淘汰
  3. LRU
  4. LFU
  5. random
  6. TTL:优先淘汰更早过期的key
  7. 全部数据进行淘汰
  8. random
  9. LRU
  10. LFU

如何修改 Redis 内存淘汰策略?

  1. config set maxmemory-policy <策略> 设置之后立即生效,不需要重启 Redis 服务,重启 Redis 之后,设置就会失效。

  2. 通过修改 Redis 配置文件修改,设置“maxmemory-policy <策略>”,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。

Redis 是如何实现 LRU 算法的?

传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。

Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:

  • 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
  • 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 N 个值,然后淘汰最久没有使用的那个

Redis 实现的 LRU 算法的优点:

  • 不用为所有的数据维护一个大链表,节省了空间占用;
  • 不用在每次数据访问时都移动链表项,提升了缓存的性能;

Redis 是如何实现 LFU 算法的?

LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。

1
2
3
4
5
6
7
typedef struct redisObject {
    ...

    // 24 bits,用于记录对象的访问信息
    unsigned lru:24;  
    ...
} robj;

Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。

在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。

在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。

布隆过滤器

布隆过滤器由「初始值都为 0 的 bit 数组」和「 N 个哈希函数」两部分组成,用来快速判断集合是否存在某个元素。

当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。

布隆过滤器会通过 3 个操作完成标记:

  • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;

一个元素如果判断结果:存在→元素可能存在;不存在→元素一定不存在

布隆过滤器只能添加元素,不能删除元素,因为布隆过滤器的bit位可能是共享的,删掉元素会影响其他元素导致误判率增加

应用场景:

  1. 解决缓存穿透问题
  2. 黑白名单校验

为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世。https://www.cs.cmu.edu/~binfan/papers/conext14_cuckoofilter.pdf#:~:text=Cuckoo%20%EF%AC%81lters%20support%20adding%20and%20removing%20items%20dynamically,have%20lower%20space%20overhead%20than%20space-optimized%20Bloom%20%EF%AC%81lters.

分布式锁

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

满足这三个条件的分布式命令如下:

SET lock_key unique_value NX PX 10000 

而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

1
2
3
4
5
6
7
8
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
  -- 一致,则删除锁
  return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

基于 Redis 实现分布式锁的优点

  1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。
  2. 实现方便。因为 Redis 提供了 setnx 方法,实现分布式锁很方便。
  3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。

基于 Redis 实现分布式锁的缺点

  1. 超时时间不好设置。可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间

  2. Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。

Redisson

Redisson分布式锁原理

可重入:利用hash结构记录线程id和重入次数

可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制

超时续约:利用watchDog,每个一段时间(releaseTime),重置超时时间。

  1. 依赖
1
2
3
4
5
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>
  1. 配置类
@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
        .setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}
  1. 使用分布式锁
@Resource
private RedissonClient redissonClient;

@Test
void testRedisson() throws InterruptedException {
    //获取可重入锁
    RLock lock = redissonClient.getLock("anyLock");
    //尝试获取锁,三个参数分别是:获取锁的最大等待时间(期间会重试),锁的自动释放时间,时间单位
    boolean success = lock.tryLock(1,10, TimeUnit.SECONDS);
    //判断获取锁成功
    if (success) {
        try {
            System.out.println("执行业务");
        } finally {
            //释放锁
            lock.unlock();
        }
    }
}

Redisson可重入锁原理

在分布式锁中,采用hash结构来存储锁,其中外层key表示这把锁是否存在,内层key则记录当前这把锁被哪个线程持有。

lock -> {thread: state} #如果持有这把锁的人(同一线程下)再次持有这把锁,那么state会+1

获取锁的逻辑:

local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 锁不存在
if (redis.call('exists', key) == 0) then
  -- 获取锁并添加线程标识,state设为1
  redis.call('hset', key, threadId, '1');
  -- 设置锁有效期
  redis.call('expire', key, releaseTime);
  return 1; -- 返回结果
end;
-- 锁存在,判断threadId是否为自己
if (redis.call('hexists', key, threadId) == 1) then
  -- 锁存在,重入次数 +1,这里用的是hash结构的incrby增长
  redis.call('hincrby', key, thread, 1);
  -- 设置锁的有效期
  redis.call('expire', key, releaseTime);
  return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败

释放锁的逻辑:

local key = KEYS[1];
local threadId = ARGV[1];
local releaseTime = ARGV[2];
-- 如果锁不是自己的
if (redis.call('HEXISTS', key, threadId) == 0) then
    return nil; -- 直接返回
end;
-- 锁是自己的,锁计数-1,还是用hincrby,不过自增长的值为-1
local count = redis.call('hincrby', key, threadId, -1);
-- 判断重入次数为多少
if (count > 0) then
    -- 大于0,重置有效期
    redis.call('expire', key, releaseTime);
    return nil;
else
    -- 否则直接释放锁
    redis.call('del', key);
    return nil;
end;

MutiLock锁

Redisson提出来了MutiLock锁,使用这把锁的话,那我们就不用主从了,每个节点的地位都是一样的,都可以当做是主机,那我们就需要将加锁的逻辑写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获取锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性.

RedLock

它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。

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

这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

Redlock 算法加锁三个过程:

  1. 第一步是,客户端获取当前时间(t1)。

  2. 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:

  3. 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。

  4. 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。

  5. 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

客户端只有在满足下面的这两个条件时,才能认为是加锁成功:

  1. 客户端从超过半数(大于等于N/2+1)的Redis节点上成功获取到了锁;

  2. 客户端获取锁的总耗时没有超过锁的过期时间。

过期键

Redis 持久化时,对过期键会如何处理的?

Redis 持久化文件有两种格式:RDB(Redis Database)和 AOF(Append Only File)。

RDB:

  • RDB 文件生成阶段:从内存状态持久化成 RDB文件的时候,会对 key 进行过期检查,过期的键不会被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。

  • RDB 文件加载阶段

  • 主服务器模式运行:在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键不会被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响;
  • 从服务器模式运行:在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。

AOF:

  • AOF 文件写入阶段:当 Redis 以 AOF 模式持久化时,如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。
  • AOF 重写阶段:执行 AOF 重写时,会对 Redis 中的键值对进行检查,已过期的键不会被保存到重写后的 AOF 文件中,因此不会对 AOF 重写造成任何影响。

Redis 主从模式中,对过期键会如何处理?

服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制:

  • 主服务器删除一个过期键后,会向从服务器发送DEL命令告知删除过期键。
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会删除,而是像处理未过期键一样处理过期键。
  • 从服务器只有接收到主服务器的DEL命令后,才会删除过期键。