认识 Redis

Redis 是什么

  • 基于内存的数据库,读写速度快,高性能
  • 用于缓存,消息队列、分布式锁
  • 操作有原子性,执行命令由单线程负责,不存在并发竞争
  • 支持事务 、持久化、Lua 脚本、多种集群方案(主从复制模式、哨兵模式、切片机群模式)、发布/订阅模式,内存淘汰机制、过期删除机制

Redis 数据结构

image

  • String:缓存对象、常规计数、分布式锁、共享 Session 信息
  • List:消息队列(生产者需实现全局唯一 ID;无法实现消费组)
  • Hash:缓存对象、购物车
  • Set:聚合计算(并交差):点赞、共同关注、抽奖
  • Zset:排序场景:排行榜、姓名排序
  • BitMap:二值状态统计:签到、用户登录状态
  • HyperLogLog:海量数据技术统计场景:百万级网页 UV 计数
  • GEO:地理位置信息场景:打车、地图
  • Stream:消息队列(实现全局唯一 ID,支持消费组)

内部实现

image

String

String 底层由简单动态字符串(Simple Dynamic String, SDS) 实现

  • SDS 可以保存文本数据和二进制数据(如图片、视频等)

    • 使用 len​ 来判断字符串是否结束
  • SDS 以 O(1) 时间获取长度(len​)

  • SDS API 是安全的,拼接字符串不会导致缓冲区溢出

List

双向链表压缩列表

  • 元素个数小于 512,每个元素值小于 64 字节,使用压缩列表
  • 否则使用双向链表
  • 3.2 后使用 quicklist

Hash

压缩列表哈希表

  • 元素个数小于 512,每个元素值小于 64 字节,使用压缩列表
  • 否则使用哈希表
  • 7.0 后使用 listpack

Set

哈希表整数集合

  • 元素均为整数且个数小于 512,使用整数集合
  • 否则使用哈希表

ZSet

压缩列表跳表

  • 元素个数小于 128,每个元素值小于 64 字节,使用压缩列表
  • 否则使用跳表
  • 7.0 后使用 listpack

Redis 线程模型 ​​

单线程:“接收客户端请求 → 解析请求 → 进行数据读写等操作 → 发送数据给客户端”,指这个过程是由一个主线程完成的

但是 Redis 程序不是单线程的,启动时开启后台线程(BIO)

  • 2.6,两个后台线程:关闭文件、AOF 刷盘
  • 4.0,三个后台线程:关闭文件、AOF 刷盘、lazyfree 异步释放(del​ 主线程处理,unlink ​ 异步删除)

image

单线程模式

image

Redis 初始化

  1. epoll_create() ​ 创建 epoll 对象,socket() ​ 创建服务端 ​socket
  2. bind() ​ 绑定端口,listen() ​ 监听 socket
  3. epoll_ctl() ​ 将 listen socket ​ 加入 epoll,注册连接事件处理函数

事件循环函数

  1. 调用处理发送队列函数,查看发送队列中是否有任务
    • 存在发送任务,write() ​ 将客户端发送缓存区里的数据发送。若这一轮数据未发送完,注册写事件处理函数,等待 epoll_wait() ​ 发现可写后再处理
  2. 调用 epoll_wait() ​ 等待事件到来,以到来事件类型调用对应处理函数
    • 连接事件
      1. 调用 accpet ​ 获取已连接的 socket
      2. 调用 epoll_ctl ​ 将已连接的 socket 加入到 epoll
      3. 注册读事件处理函数
    • 读事件
      1. 调用 read() ​ 获取客户端发送数据
      2. 解析、处理命令
      3. 客户端对象添加到发送队列
      4. 执行结果写到发送缓存区等待发送
    • 写事件
      1. write() ​ 发送客户端发送缓存区的数据
      2. 若这一轮数据未发送完,继续注册写事件处理函数,等待 epoll_wait() ​ 发现可写后再处理

单线程为什么这么快

  • 大部分操作在内存中完成,CPU 并不是制约 Redis 性能表现的瓶颈所在
  • 单线程模型避免多线程竞争,减少上下文切换,避免死锁
  • I/O 多路复用机制处理大量客户端 Socket 请求
    • select/epoll 机制
    • 同时监听多个 socket,一旦有请求到达就会交给 Redis 处理

6.0 引入多线程

  • 使用多个 I/O 线程处理网络请求
  • 但是对于命令执行仍是单线程

一般会创建以下线程:

  • Redis-server:Redis 的主线程,主要负责执行命令;
  • bio_close_filebio_aof_fsyncbio_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 的压力

Redis 持久化

3 种持久化方式:

  • AOF 日志:每执行一条写操作,追加该命令到一个文件
  • RDB 快照:将某一时刻的数据写入磁盘
  • 混合持久化方式:4.0 新增,结合两者

AOF 日志

Append Only File

  • 每执行一条写操作,追加该命令到一个文件。
  • Redis 重启时读取该文件,逐一执行命令恢复数据

image

日志内容

image

  • *3 ​ 表示当前命令有三个部分,每部分都是以 +数字 ​ 开头,后面跟具体的命令、键或值。
  • 数字表示这部分中的命令、键或值一共有多少字节

执行顺序

  • Redis 先执行命令,再把命令追加到 server.aof_buf​ 缓冲区
  • write() ​ 系统调用,缓冲区数据写入 AOF 文件(拷贝到内核缓冲区)
  • 内核将数据写入硬盘

优点:

  • 避免额外检查开销
  • 不会阻塞写操作命令

缺点:

  • 数据可能丢失(在两个操作间隙 Redis 宕机)
  • 阻塞其他操作(AOF 日志在主线程中执行)

AOF 写回策略

即控制写入硬盘的策略

  • Always:每次执行完写操作后写入硬盘
  • Everysec:每隔一秒写入硬盘
  • No:由操作系统决定写入硬盘时机

image

AOF 重写

扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志 ​

重写机制

AOF 重写机制:当文件大小超过阈值,压缩 AOF 文件

方式:在重写时,读取数据库所有键值对,将每一个键值对用一条命令记录到新的 AOF 文件(即只保留,对同一键值对的多个写操作中,最新的一条

重写过程

由后台子进程 bgrewriteaof 执行

  • 并行处理,避免阻塞主进程
  • 子进程带有主进程的数据副本(不是线程)。父子进程共享内存数据(只读,写时复制)

触发重写机制后,主进程创建重写 AOF 子进程,重写子进程只读数据,将内存数据键值对转为成命令,再写入日志

重写缓冲区

image

在重写 AOF 期间,当 Redis 执行完一个写命令后(修改数据,发生写时复制,父子进程不再共享相同数据),会同时将该命令追加入 AOF 缓冲区AOF 重写缓冲区

  • 当子进程完成 AOF 重写工作,会向主进程发送异步信号
  • 主进程收到信号,调用信号处理函数
    • 将 AOF 重写缓冲区的所有内容追加到新文件
    • 新文件改名,覆盖旧文件

RDB 快照

Redis Database
记录某一时刻的内存数据

做快照过程

  • save​:在主线程生成 RDB 文件
  • bgsave​:创建子进程来生成

Redis 的快照为全量快照,会保存内存中的所有数据

写时复制

在做快照过程中,主进程可同时处理命令(数据可修改),可见 xv6 进程部分

  • 只读:共享数据
  • 发生写操作:进行复制,对副本数据做快照

混合持久化

  • RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。
  • AOF 优点是丢失数据少,但是数据恢复不快。

混合持久化:

  • 在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件
  • 然后主线程处理的操作命令会被记录在重写缓冲区
  • 重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件
  • 写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件

即 AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。
优点:

  • 开头为 RDB 的格式,使得 Redis 可以更快的启动
  • 结合 AOF 的优点,有减低了大量数据丢失的风险

缺点:

  • AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差
  • 兼容性差(4.0 版本后)

Redis 集群

服务高可用

主从复制

image

读写分离

  • 主服务器可进行读写操作
    • 当发生写操作时自动将写操作发送给从服务器
  • 从服务器只读,接收主服务器的写操作

主服务器向从服务器发送写命令,不会等待从服务器响应(异步),无法实现强一致性

哨兵模式

主从服务出现宕机需要手动恢复

哨兵模式:监控主从服务器,提供主从节点故障转移
image

切片集群

当缓存数据量过大,需要使用切片集群,将数据分布在多个服务器上

  • 采用哈希槽(Hash Slot)处理数据和节点的映射关系
    • 根据 key,以 CRC16 算法计算 16bit 的值
    • 该值对 16384 取模

映射方案

  • 平均分配:使用 cluster create ​ 创建集群时,自动平均分配哈希槽
  • 手动分配:cluster meet ​ 手动创建节点连接,组成集群。cluster addslots ​ 指定哈希槽数量(且必须将 16384 个哈希槽全部进行分配)

image

集群脑裂问题

集群脑裂会产生数据丢失

  1. 在 Redis 主从架构中,一般使用一主多从的部署方式。
  2. 如果发生主节点网络丢失,和从节点失联,但是主节点和客户端仍然正常通信,客户端向失联的主节点写入数据。主节点将新写入的数据缓存到缓冲区内,但是由于网络问题无法将数据同步给从节点
  3. 之后哨兵发现主节点失联,会在从节点中重新选择一个节点,此时就会存在两个主节点
  4. 这样当网络恢复正常时,旧的主节点重新接入,但是由于哨兵已经选取出了新的主节点,那么旧主节点就需要降级为从节点(记为 A 节点)
  5. 当 A 节点被降级后,会向新的主节点请求数据同步。而第一次数据同步为全量同步,此时 A 结点会先清空本地数据,再做全量同步,会导致网络丢失阶段写入 A 节点的数据全部丢失,即集群产生脑裂数据丢失

解决方案

当主节点发现从节点下线或者通信超时的总数量小于阈值时,禁用主节点进行写数据,并返回错误回客户端

  • min-slaves-to-write x​:主节点必须有至少 x 个从节点连接,否则会禁止写数据
  • min-slaves-max-lag x​:主从数据复制和同步的延迟不能超过 x 秒,否则会禁止写数据

即设置:主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了

这样无论是主节点发生故障还是网络故障,原主节点都会被限制接收客户端写请求,随后选举产生新主节点,由新主节点进行处理,就不会导致脑裂数据丢失问题了

Redis 过期删除与内存淘汰 Evict(驱逐)

过期删除实现

每当对一个 key 设置了过期时间,Redis 会将该 key 以及过期时间存储到过期字典(expires dict)

  • 查询 key 时,首先检查该 key 是否存在于该过期字典中
    • 如果不在,正常读取键值
    • 如果存在,获取过期时间,与当前系统时间比对,大于系统时间则没有过期,否则过期

过期删除策略

惰性删除 + 定期删除

惰性删除策略

不主动删除过期键,只当访问 key 时,判断是否过期,如果过期则删除
image
优点:

  • 每次访问时才会检查过期情况,CPU 资源占用小

缺点:

  • 已过期 key 仍然保留在内存中,内存空间浪费

定期删除策略

每个一段时间随机从数据库中取出一定数量的 key 进行检查,并删除其中过期的 key
流程:

  1. 从过期字典中随机抽取 20 个 key
  2. 检查这 20 个 key 是否过期,并删除已过期的 key
  3. 如果本轮检查的已过期 key 数量占全部抽取数量 25% 以上,则重复步骤一

image
优点:

  • 限制删除操作执行的时长和频率,减少删除操作对 CPU 的影响,同时也能删除一部分过期数据,减少过期键对空间的无效占用

缺点:

  • 难以确定删除操作执行的时长和频率
    • 执行太频繁:CPU 不友好
    • 执行太少:退化成惰性删除,占用内存

持久化时处理过期键

AOF

  • AOF 文件写入阶段:如果数据库某个过期键没被删除,AOF 文件会保留此键,当该过期键被删除后,Redis 向 AOF 文件追加 DEL ​ 命令显式删除该键
  • AOF 重写阶段:过期键不会被保存在重写后的 AOF 文件中,即不会对 AOF 重写造成任何影响

RDB

  • RDB 文件生成阶段:过期键不会被保存在 RDB 文件中
  • RDB 加载阶段:
    • 主节点:载入 RDB 文件时,检查键,不会载入过期键
    • 从节点:载入所有键,但是主从节点进行数据同步时,从服务器的数据会被清空,所以过期键不会造成影响

主从模式处理过期键

  • 主节点:key 过期,在 AOF 文件中增加 DEL ​ 指令,并同步到所有从节点
  • 从节点:不进行过期扫描,等待主节点 DEL ​ 命令同步时才删除

核心就是从节点只读,所有写操作都由主节点同步而来

Redis 内存淘汰

maxmemory

内存淘汰策略

八大策略

不进行数据淘汰策略

  • noeviction:当前运行内存超过最大设置内存时,不淘汰任何数据,但是不再提供服务,直接返回错误(Redis3.0 后的默认策略)

进行数据淘汰策略

  • 设置过期时间的数据中淘汰

    • volatile-random:随机淘汰设置了过期时间的任意键值
    • volatile-ttl:优先淘汰更早过期的键值
    • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值
    • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值
  • 所有数据范围内淘汰

    • allkeys-random:随机淘汰任意键值
    • allkeys-lru:淘汰整个键值中最久未使用的键值
    • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值

LRU

最近最少使用,Redis 实际实现是近似 LRU 的算法

  • 在对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间
  • 进行内存淘汰时,使用随机采样的方式淘汰数据,随机取多个值,淘汰最久未使用的数据

优点:

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

但是无法解决缓存污染问题:一次读取大量数据,难以批量删除,长期存储在缓存中。因此引入 LFU

LFU

最近最不常用的:根据访问次数来淘汰数据

LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,增加该数据访问次数

Redis 多记录了数据访问频次信息

typedef struct redisObject {
	...
	// 24 bits,用于记录对象的访问信息
	unsigned lru:24;
	...
} robj;
  • Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同

    • LRU:记录 key 的访问时间戳
    • LFU:被分成两段来存储
      • 高 16bit 存储 ldt(Last Decrement Time),记录 key 的访问时间戳
      • 低 8bit 存储 logc(Logistic Counter),记录 key 的访问频次

Redis 缓存设计

缓存雪崩

image

缓存雪崩:大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,导致数据库压力骤增,可能导致数据库宕机

  • 缓存失效时间随机打散:增加随机值
  • 设置缓存不过期:通过后台服务来更新缓存数据

缓存击穿

image
缓存击穿:如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮(可以认为是缓存雪崩的子集)

  • 互斥锁:setnx ​ 保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么返回空值或默认值
  • 不设置过期时间,由后台异步更新;或者在即将过期前通知后台线程更新缓存,并重新设置过期时间

缓存穿透

image
缓存穿透:当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增

发生情况:

  • 业务误操作:误删除缓存和数据库中的数据
  • 恶意攻击

应对方案:

  • 限制非法请求:在 API 入口处判断请求参数是否合理:请求参数是否含有非法值、请求字段是否存在
  • 返回空值或默认值:当出现缓存穿透时,在缓存中加载空值或者默认值,而不会继续查询数据库
  • 布隆过滤器:在写入数据库数据时,使用布隆过滤器进行标记,查询时可以快速判断数据是否存在

Redis 缓存策略

热点数据动态缓存

核心思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据

  1. 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前
  2. 同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中
  3. 这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回

可以使用 zadd ​ 和 zrange ​ 来完成排序队列和获取商品操作

缓存更新策略

  • 旁路缓存 Cache Aside:Redis 实际应用
  • 读穿/写穿策略 Read/Write Through
  • 写回策略 Write Back

Cache Aside

应用程序直接与数据库、缓存交互,并负责对缓存的维护。可细分为读策略和写策略

image

  • 写策略
    • 先更新数据库中的数据,再删除缓存中的数据(不能颠倒顺序,否则会导致缓存和数据库数据不一致)

  • 读策略
    • 读取的数据命中缓存,直接返回数据
    • 没有命中缓存,从数据库中读取数据,写入缓存,再返回

如果颠倒写策略,会出现缓存和数据库数据不一致情况

但实际上也会出现以下情况,只不过缓存写入速度远快于写入数据库速度,所以出现概率极低

image

Cache Aside 策略适合读多写少的场景,不适合写多的场景

  • 当写入比较频繁时,缓存中的数据会被频繁地清理,会对缓存的命中率有一些影响
  • 如果对于缓存命中率要求高
    • 更新数据时也更新缓存,在更新缓存前加分布式锁,只允许同一时间只有一个线程更新缓存(影响写入性能)
    • 更新数据时也更新缓存,但是给缓存较短的过期时间,即使出现缓存不一致的情况也会很快过期,对业务影响较小(看业务需求)

Read/Write Through

应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理

Read Through

先查询缓存中数据是否存在:

  • 如果存在直接返回;
  • 如果不存在,由缓存查询数据库,并写入缓存,再返回

Write Through

数据更新时,查询写入的数据在缓存中是否存在:

  • 如果存在,则更新缓存,由缓存更新到数据库,并告知更新完成情况
  • 如果不存在,则直接更新数据库,返回

在使用本地缓存时可以考虑使用

Redis、Memcached 不提供写入数据库和自动加载数据库数据的功能,无法使用

Write Back 写回策略

  • 在更新数据时,只更新缓存,将缓存数据设为脏数据,不更新数据库,立即返回
    • 数据库会异步进行批量更新
  • Redis 无异步更新数据库功能,一般是用于 CPU 缓存、操作系统中的文件系统缓存

适合写多场景,只需要更新缓存,再批量更新到数据库(硬盘)

但是数据不是强一致性、持久化的,存在数据丢失问题

Redis 实战

实现延迟队列

将当前要做的任务推迟处理

ZSet,使用 score 记录延迟执行的时间

  • zadd score1 value1 添加任务
  • zrangebyscore 查询符合条件的(将 score 与当前时间对比)、待处理的任务,取出处理

处理大 Key

大 key 指对应的 value 很大

  • String 大于 10KB
  • Hash、List、Set、ZSet 元素超过 5000 个

影响

  • 客户端超时阻塞:Redis 执行命令为单线程,操作大 key 耗时长
  • 网络阻塞:获取大 key 产生的网络流量大
  • 工作线程阻塞:使用 del 删除大 key 时,会阻塞工作线程
  • 内存分布不均:集群模型在 slot 分片均匀的情况下,会出现数据和查询倾斜情况,有大 key 的节点占用内存多,QPS 会更大

查询大 key

  • redis-cli --bigkeys
  • SCAN
  • RdbTools 工具

删除大 key

分批次删除

  • 大 Hash: hscan,每次获取 100 个字段;再用 hdel 每次删除 1 个字段
  • 大 List: ltrim,每次删除少量元素
  • 大 Set: sscan,每次获取 100 个元素;再用 srem 每次删除 1 个键
  • 大 ZSet: zremrangebyrank,每次删除 top100 个元素

异步删除

Redis4.0 后,可以用 unlink 替代 del 来删除,会将这个 key 放入异步线程中进行删除,不会阻塞主线程

也可以配置参数,当达到某些条件时自动进行异步删除

lazyfree-lazy-eviction
lazyfree-lazy-expire
lazyfree-lazy-server-del
noslave-lazy-flush

默认都是关闭的

  • lazyfree-lazy-eviction:当 Redis 运行内存超过 maxmemory 时,进行 lazy free 机制删除
  • lazyfree-lazy-expire:当设置了过期时间的键值过期后,进行 lazy free 删除
  • lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除
  • noslave-lazy-flush:针对从节点(slave)进行全量数据同步,slave 在加载  master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free 机制删除

管道

Pipeline,用于一次处理多个 Redis 命令。由客户端提供,不是 Redis 服务端功能

普通交互模式:

管道模式:

使用管道技术可以解决多个命令执行时的网络等待

事务

Redis 中并没有提供回滚机制discard 能放弃事务执行,清空命令队列,但不会回滚。所以 Redis 不能保证原子性

分布式锁

分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景

Redis 的 SET 命令有个 NX 参数可以实现 key 不存在才插入,所以可以用它来实现分布式锁

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功
  • 如果 key 存在,则显示插入失败,可以用来表示加锁失败

加锁

实现分布式锁时,对于加锁操作,需要满足以下条件:

  1. 加锁包括了读取、检查、设置锁变量三个操作,需要保证原子性,使用 SET NX 保证
  2. 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,使用 SET EXSET PX 来设置过期时间
  3. 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时出现误释放操作,每个客户端使用一个唯一的值用来标识
SET lock_key unique_value NX PX 10000

解锁

删锁就是将 lock_key 删除,需要保证执行删除的客户端也是执行加锁的客户端,即先读 unique_value 的值,再删除 lock_key,需要两步操作,所以要使用 Lua 脚本来保证原子性

if redis.call("get", KEYS[1]) == ARGV[1] then
	return redis.call("del", KEYS[1])
else
	return 0
end

优缺点

优点:

  • 性能高效(选择缓存实现分布式锁最核心的出发点)
  • 实现方便,直接使用 SET NX 即可
  • 避免单点故障:Redis 扩集群部署

缺点:

  • 超时时间不好设置:如果锁的超时时间设置过长,会影响性能;如果过短,则不能保护共享资源(比如业务代码执行时间比较长,超过了锁的超时时间,另一个线程就可能拿到锁)
    • 基于续约的方式设置超时时间:先设置超时时间,再启动一个守护线程,守护线程在锁快要失效时,进行续约加锁,在主线程执行完毕后销毁续约锁
  • Redis 主从复制模式中的数据是异步复制的,分布式锁存在不可靠性:主节点获取锁之后宕机,且没有同步到其他节点,新的主节点仍然可以获取锁,多个服务就可以同时获取锁了

集群情况下分布式锁的可靠性

使用 Redlock(红锁)

  • 基于多个 Redis 节点,即使有节点发生故障,锁变量仍然存在
  • 而且每个节点都是主节点,相互无关系,都是独立的节点

Redlock:让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,就认为客户端成功获得了分布式锁,否则加锁失败

加锁过程

  • 客户端获取当前时间($t1$)
  • 客户端按顺序依次向 N 个 Redis 节点执行加锁操作
    • SET NX EXSET NX PX 并带上客户端的唯一标识
    • 为了保证算法在某个 Redis 节点故障的情况下仍然能够运行,为加锁操作设置超时时间(而不是锁),加锁操作的超时时间远小于锁的过期时间(一般设置为几十毫秒)
  • 一旦客户端从超过半数(大于等于 $N/2 + 1$)的 Redis 节点上成功获取了锁,就再次获取当前时间($t2$),然后计算整个加锁过程的总耗时($t2-t1$)
    • 如果 $t2-t1$ 小于锁的过期时间,则认为加锁成功,否则认为加锁失败

即加锁成功需要满足 2 个条件

  • 客户端从超过半数的 Redis 节点上成功获取到锁
  • 客户端从大多数节点获取锁的总耗时小于锁的过期时间

加锁成功

加锁成功后,客户端重新计算锁的有效时间,结果为 $\text{最初的过期时间}-(t2-t1)$。根据计算结果来判断是否来得及完成共享数据的操作,如果来不及就直接释放锁,避免出现还没进行操作锁就过期的情况。

加锁失败

加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,即对各个节点执行上面的 Lua 脚本