Redis 实践

Redis 作为一个 key-value 的 NoSQL 数据库,基于内存操作,提供高速的服务,常用于缓存,消息队列等场景;支持分布式以及持久化。

key-value 设计

采用拉链法来处理哈希冲突。

数据类型

value 支持多种数据类型。

value 数据类型

String

String 是采用二进制存储的数据类型,可以用来存储任何类型的数据, 比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。

  • SET key value
  • GET key
  • EXISTS key
  • DEL key
  • 针对数字操作
    • INCR key
    • DECR key

应用场景:

  • 存储 json 序列化后的对象数据,尤其是多层嵌套的复杂对象
  • 计数的场景,通过 redis 来提供整型自增 id

List

List 采用双向链表来实现,有序,元素可重复。

应用场景:

  • 消息队列

Set

无序集合,集合中的元素没有先后顺序但都唯一,添加,删除,查找的时间复杂度都是 O(1)。

  • SINTER/SINTERSTORE,交集
  • SUNION/SUNIONSTORE,并集
  • SDIFF/SDIFFSTORE,差集
  • SADD key member1 member2 ...,向指定集合添加一个或多个元素
  • SPOP key count,随机移除并获取指定集合中一个或多个元素,适合不允许重复的场景
  • SRANDMEMBER key count, 随机获取指定集合中指定数量的元素,适合允许重复的场景

主要通过多个 set 的集合运算来获取相关数据。

应用场景:

  • 通过交集获取共同数据
  • 通过差集获取推荐数据
  • 获取随机数据

Hash

采用 HashMap 实现,存储 key-value 数据,适用于写操作比较多的简单对象(非嵌套对象)的存储

应用场景:

  • 购物车

Sorted Set/Zset 有序集合

Sorted Set 中元素不可重复,但为每个元素增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列。

  • ZSCORE key member, 获取指定有序集合中指定元素的 score 值
  • ZREVRANK key member, 获取指定有序集合中指定元素的排名(score 从大到小排序)
  • ZRANK key member, 获取指定有序集合中指定元素的排名(score 从低到高)
  • ZRANGE key start end,获取指定有序集合 start 和 end 之间的元素(score 从低到高)
  • ZREVRANGE key start end, 获取指定有序集合 start 和 end 之间的元素(score 从高到底)

应用场景:

  • 排行榜

特殊数据类型

TODO

缓存

缓存过期

一般情况下,在缓存数据的时候都会设置一个过期时间,避免内存一直占用,最后可能导致 OOM。

设置随机过期时间(过期时间 = 固定时间 + 随机值),避免大量 key 同时过期,导致缓存雪崩

  • expire key number, 设置多少秒后过期
  • ttl key,查看过期时间

使用场景:

  • 数据只在一段时间内存在,比如验证码,用户 session 等

缓存雪崩

缓存雪崩指在同一时间大批量的数据的过期或者缓存服务宕机,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。

缓存雪崩

过期数据的删除策略

常用的过期数据的删除策略

  • 惰性删除:只会在查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  • 定期删除:周期性地随机从设置了过期时间的 key 中抽查一批,然后逐个检查这些 key 是否过期,过期就删除 key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。
  • 延迟队列:把设置过期时间的 key 放到一个延迟队列里,到期之后就删除 key。这种方式可以保证每个过期 key 都能被删除,但维护延迟队列太麻烦,队列本身也要占用资源。
  • 定时删除:每个设置了过期时间的 key 都会在设置的时间到达时立即被删除。这种方法可以确保内存中不会有过期的键,但是它对 CPU 的压力最大,因为它需要为每个键都设置一个定时器。

Redis 采用的是 定期删除+惰性删除 结合的策略,这也是大部分缓存框架的选择。 默认情况下,定期删除任务线程是在 Redis 主线程中执行的,如果存在大量过期的 key,就会导致客户端请求没办法被及时处理,响应速度会比较慢。

通过开启 lazy free 机制,Redis 会在后台异步删除过期的 key,不会阻塞主线程的运行,从而降低对 Redis 性能的影响。

1
2
3
4
5
6
7
8
9
10
11
# 当执行 DEL 命令时,是否使用 lazy free(默认 no)
lazyfree-lazy-user-del yes

# 当执行 FLUSHDB/FLUSHALL 时,是否使用 lazy free(默认 no)
lazyfree-lazy-server-del yes

# 过期 key 被驱逐时是否使用 lazy free(默认 no)
lazyfree-lazy-expire yes

# 内存淘汰时是否使用 lazy free(默认 no)
lazyfree-lazy-eviction yes

内存淘汰策略

Redis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发, 这个阈值是通过 redis.confmaxmemory/maxmemory-policy 参数来定义的。

生产环境必须配置 maxmemory 避免 OOM, 而针对不同场景,建议配置淘汰策略 maxmemory-policy

  • 纯缓存场景(所有数据可丢),建议配置 allkeys-lru
  • 缓存 + 持久化混合场景(部分 key 不能丢, 非缓存 key 不设 TTL), 建议配置 volatile-lru
1
2
maxmemory 2gb
maxmemory-policy allkeys-lru
  • config get maxmemory,查看最大内存阈值
  • config get maxmemory-policy, 查看内存淘汰策略

BigKey

如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey(大 Key)。

bigkey 会导致

  • 客户端超时阻塞,在操作大 key 时会比较耗时,导致很久没有响应
  • 网络阻塞,每次获取大 key 产生的网络流量较大
  • 工作线程阻塞,使用 del 删除大 key 时,会阻塞工作线程,就没办法处理后续的命令

尽量避免 Redis 中存在 bigkey

通过 --bigkeys 参数来查找 bigkey

1
2
# 扫描 Redis 中的所有 key, 每次扫描后休息的时间间隔为 3 秒
$ redis-cli -p 6379 --bigkeys -i 3

bigkey 的常见处理以及优化办法:

  • 分割 bigkey,将一个 bigkey 分割为多个小 key
  • 采用合适的数据结构

Hotkey

一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。

危害:

  • 处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理

可以使用 --hotkeys 参数来查找

1
redis-cli -p 6379 --hotkeys

hotkey 的常见处理以及优化办法

  • 读写分离:主节点处理写请求,从节点处理读请求
  • 分片,将热点数据分散存储在多个 Redis 节点上
  • 二级缓存,将 hotkey 存放一份到本地内存中

缓存击穿

缓存击穿一般指请求的热点数据的 key 对应的数据存在于数据库中,不存在于缓存中,通常是因为缓存中的那份数据已经过期。 这就可能会导致瞬时大量的请求直接到了数据库上,对数据库造成了巨大的压力。

缓存击穿

解决方法:

  • (推荐)提前预热,针对热点数据提前将其存入缓存中并设置合理的过期时间,保证在热点时间内不过其,比如秒杀场景下的数据在秒杀结束之前不过期。

缓存穿透

大量请求的 key 不存在于缓存中,也不存在于数据库中。

缓存穿透

解决方法:

  • (必须)应用程序做好参数校验
  • 布隆过滤器,快速判断 key 相关的数据是否不存在,需要提前把把所有可能存在的 key 存到布隆过滤器
  • 接口限流

缓存读写策略

缓存读写策略是为了保证缓存和数据库一致性。

Cache Aside Pattern(旁路模式)

Cache Aside Pattern(旁路模式)是一种非常常用的缓存读写策略

  • 读操作
    1. 先尝试从缓存读取数据·
    2. 如果缓存命中,直接返回数据
    3. 如果缓存未命中,从数据库查询数据,将查到的数据放入缓存并返回数据
  • 写操作
    1. 先更新数据库
    2. 再直接删除缓存中对应的数据

如果更新数据库成功,而删除缓存这一步失败的情况的话,一般采用缓存更新重试机制, 通过引入 MQ 实现异步重试,当删除缓存失败时,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试,直到成功。

Write Behind Pattern(异步写入)

把缓存视为主要数据存储,从中读取数据并将数据写入其中;然后异步批量的更新 db。

异步批量的更新 db 的方式:

  • 定时任务,定时查找更新过但未同步到 db 的数据,将其更新到 db
  • 消息队列,在 cache 的写操作成功后,发送消息到队列中,由专门的消费者来将数据写入到 db,可以结合定时任务

使用场景:

  • 适合一些数据经常变化又对数据一致性要求没那么高的场景

高性能

redis

快的原因:

  • 纯内存操作 (Memory-Based Storage),读写操作都发生在内存中,访问速度是纳秒级别
  • 高效的 I/O 模型 (I/O Multiplexing & Single-Threaded Event Loop) , 让单个线程可以同时处理多个网络连接上的 I/O 事件(如读写),避免了多线程模型中的上下文切换和锁竞争问题。
  • 优化的内部数据结构 (Optimized Data Structures) ,会根据数据大小和类型动态选择最合适的内部编码,以在性能和空间效率之间取得最佳平衡
  • 简洁高效的通信协议 (Simple Protocol - RESP)

慢查询命令

命令的执行可以通过 Round Trip Time(RTT,往返时间)来衡量。 而慢查询命令指那些执行时间较长的命令。

redis 提供的慢查询日志 (Slow Log) 专门用来记录执行时间超过指定阈值的命令。 可通过 redis.conf 中的 slowlog-log-slower-than 参数设置耗时命令的阈值和 slowlog-max-len 参数设置耗时命令的最大记录条数。 也可以通过命令来设置。

1
2
3
4
5
# 超过 10000 微妙(即10毫秒)就会被记录
CONFIG SET slowlog-log-slower-than 10000
CONFIG SET slowlog-max-len 128
# 获取最近 10 条慢查询日志
SLOWLOG GET 10

Redis 命令的执行简化为以下几步:

  1. 发送命令;
  2. 命令排队;
  3. 命令执行;
  4. 返回结果。

高可用

持久化

持久化机制,默认只开启 RDB:

  • RDB, 快照(snapshotting)
  • AOF, 只追加文件(append-only file),实时性
  • RDB 和 AOF 的混合持久化(Redis 4.0 新增), 默认不开启,需要通过手动配置 aof-use-rdb-preamble yes
1
2
3
4
5
6
7
# RDB 持久化:默认启用
save 900 1 # 900 秒内至少有 1 个 key 改变,触发快照
save 300 10 # 300 秒内至少有 10 个 key 改变
save 60 10000 # 60 秒内至少有 10000 个 key 改变

# AOF 持久化:默认关闭
appendonly no

针对不同应用场景,选择合适的持久化策略:

  • 纯缓存场景,数据可以从 DB 中恢复,不开启持久化机制
  • 高并发场景并且数据不能丢失,支持最终一致性,将 Redis 作为第一级存储,异步写入 DB,采用 AOF + RDB 混合持久化

RDB

在某一个时间点,对 Redis 内存中的所有数据进行全量备份

  • (默认配置)自动触发, 通过 redis.conf 中配置 save m n,即在m秒内有n次修改时,自动触发bgsave生成rdb文件
  • 手动触发,通过save/bgsave

RDB中的核心思路是 Copy-on-Write,来保证在进行快照操作的这段时间,需要压缩写入磁盘上的数据在内存中不会发生变化。

RDB
  • 备份文件会经过压缩,远小于内存中的数据大小
  • 数据恢复速度
  • 不够实时性,无法做到秒级持久化

AOF

AOF 日志是先写内存,执行成功后写入 aof_buf 日志缓冲区,保证了写入操作的正确性,最后将 aof_buf 日志缓冲区的内容写入到磁盘上的 AOF 日志文件中。

写回磁盘的策略:

  • No,由操作系统决定何时写回
  • (推荐) Everysec,每秒写回,支持秒级数据恢复
  • Always,每次写操作后立即写回,性能差
1
2
3
4
5
6
7
8
9
10
11
12
13
# appendonly 参数开启AOF持久化
appendonly no

# AOF持久化的文件名,默认是appendonly.aof
appendfilename "appendonly.aof"

# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./

# 写回策略
# appendfsync always
appendfsync everysec
# appendfsync no

数据恢复

TODO

Redis 集群

集群通过主从模式以及读写分离来实现高可用。

通过主 - 从 - 从模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

主从模式

同步的流程:

  1. 从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。
  2. 主库将所有数据通过生成 RDB 文件同步给从库。从库收到数据后,在本地完成数据加载。
  3. 主库会把第二阶段执行过程中收到的写命令(保存在 replication buffer 中),再发送给从库,从库再重新执行这些操作进行回放。

Redis Sentinel 哨兵模式

在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机制,它主要负责的就是三个任务:

  • 监控,指哨兵进程在运行时,周期性地检测所有的主从库是否仍然在线运行。如果从库下线,则将其标记为下线状态;如果主库下线,则开启选主流程。
  • 选主,当主库下线后,需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。
  • 通知,把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

哨兵机制通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。 哨兵实例之间通过 Redis 提供的 pub/sub 机制(发布/订阅机制)相互发现。

哨兵模式
主观下线和客观下线

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。 如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。

引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。 同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,然后才会开启选主流程。

“客观下线”的标准就是,当有 N 个哨兵实例时,最好要有 N/2 + 1 个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。 这样一来,就可以减少误判的概率,也能避免误判带来的无谓的主从库切换。

哨兵选主会通过“筛选 + 打分”,基于以下的规则:

  1. 按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库
  2. 优先级最高的从库得分高
  3. 和旧主库同步程度最接近的从库得分高,即数据完整度高的得分高
  4. ID 号小的从库得分高

Reedis Cluster 集群模式

TODO

Reference