缓存

使用场景

  1. 缓存

    穿透, 击穿, 雪崩, 双写一致, 持久化, 数据过期, 淘汰策略

  2. 分布式锁

    setnx, redission

  3. 消息队列, 延迟队列

缓存穿透

查询不存在的数据, mysql 中查询不到也不会写入缓存, 导致每次都查询数据库

  1. 缓存空数据, 把空结果进行缓存

    • 简单
    • 消耗内存, 发生不一致的问题
  2. Redisson 布隆过滤器

    bitmap: 一个以bit为单位的数组, 每个单元只存储二进制的 0 或 1

    使用: 将数据库中存在的数据通过多个哈希函数获取哈希值, 并将数组对应位置改为 1. 对于请求参数进行相同的哈希获取数组对应元素的值, 如果有元素为 0 则说明该元素一定不存在, 全 1 则说明该元素可能存在. 存储的数据越多则数组中的 1 越多, 误判率越大.

    注意: 使用中可能需要注意定时(或增加数据时)更新布隆过滤器解决缓存一致性问题

    作用: 检索一个元素是否在一个集合中

    • 内存占用少
    • 实现复杂, 存在误判 (数组越大, 误判率越小, 但是消耗内存越多, 一般设为 1%)

缓存击穿

给某个 key 设置过期时间, 但恰好 key 过期时有大量并发请求, 这些请求会把数据库压垮

  1. 互斥锁

    缓存未命中时获取互斥锁: 获取成功则查询数据库缓存数据; 获取失败则休眠重试获取锁

    • 保证数据强一致性
    • 性能较差
  2. 逻辑过期

    设置一个expire字段表示过期时间, 后续检验这个字段判断是否过期

    缓存未命中时获取互斥锁: 获取成功则开启新线程查询数据库缓存数据, 但此时这个线程不用等待锁, 可以直接返回过期数据; 互斥锁获取失败则直接返回过期数据

    • 高可用, 性能好
    • 返回数据可能是过期的数据

缓存雪崩

同一时间大量的缓存 key 同时失效或者 redis 宕机, 导致大量请求到达数据库

  1. 给不同的 key 的过期时间添加随机值
  2. 使用 redis 级群 (哨兵模式, 级群模式)
  3. 给缓存业务添加降级限流策略 (nginx, spring cloud gateway)
  4. 添加多级缓存 (Guava, Caffeine)

双写一致性

修改了数据库的数据也要同时更新缓存的数据, 缓存和数据库的数据要保持一致性

读操作: 缓存命中, 直接返回; 未命中则查询数据库, 写入缓存, 设定超时时间

写操作:

  1. 延迟双删

    先删缓存, 操作数据库, 延迟一会然后再写入缓存 (延迟为了数据库的同步)

    延迟时间不好控制, 期间会出现脏数据

  2. 读写锁 (强一致性)

    读的时候添加共享锁: 读读不互斥, 读写互斥

    写的时候添加排他锁: 读读和读写均互斥, 避免脏数据

  3. 异步 mq (最终一致性)

    使用 MQ 中间件, 更新数据后通知缓存删除

  4. 使用 Canal (最终一致性)

    伪装成 mysql 的从节点, 通过读取 binlog 数据更新缓存, 不需要修改业务代码

redis 持久化

  1. RDB

    Redis Database Backup file, 将内存中的所有数据都记录到磁盘中, 当 redis 重启后从磁盘读取快照文件, 恢复数据

    bgsave 开始时 fork 主进程得到子进程, 子进程共享主进程的内存数据, 完成 fork 后读取内存数据并写入 RDB 文件

    fork 采用 copy-on-write 技术:

    • 主进程执行读操作时, 访问共享内存
    • 主进程执行写操作时, 拷贝一份数据, 执行写操作
    • 恢复速度快, 文件体积小
    • 大量消耗系统资源, 数据不完整, 两次备份之间的数据会丢失
  2. AOF

    Append Only File: 将每一个写命令记录在 AOF 文件中, 恢复数据时就是在执行一遍命令

    • 数据相对完整 (取决于刷盘策略), 平时消耗 cpu 和内存资源少, 主要消耗 IO 资源 (但恢复数据时会大量占用系统资源)
    • 文件体积大, 恢复速度较慢

数据过期策略

redis 对数据设置有效时间, 数据过期以后就需要从内存中删除, 删除数据可以有不同的规则

  1. 惰性删除

设置 key 过期时间后在使用 key 时进行检查是否过期, 如果过期就删除, 否则返回 key

  • cpu 友好, 对于用不到的 key 不会浪费时间检查
  • 内存不友好, 长时间不用且已过期的数据就会一直存在内存中不会删除
  1. 定期删除

每隔一段时间对一些 key 检查, 删除过期的 key (从一定数量的数据库中取出一定数量的随机 key 进行检查, 删除过期的 key)

SLOW 模式: 定时任务, 执行频率默认 10hz, 每次不超过 25ms

FAST 模式: 执行频率不固定, 两次间隔不低于 2ms, 每次耗时不超过 1ms

  • 可以限制删除操作执行的时长和频率来减少操作对 CPU 的影响, 定期删除能有效释放过期键占用的内存
  • 难以确定删除操作执行的时长和频率

数据淘汰策略

当 redis 内存不够用时, 此时再向 redis 中添加新的 key, 那么 redis 就会按照某一种规则将内存中的数据删除掉, 这种删除数据的规则称之为内存的淘汰策略

  • noeviction: 不淘汰任何 key, 内存满时不允许写入新数据, 默认策略
  • volatile-ttl: 对设置了 ttl 的 key, 比较 key 的剩余 ttl, ttl 越小越先淘汰
  • allkeys-random: 对全体 key 随机淘汰, 访问频率差别不大, 没有冷热数据区分
  • volatile-random: 对设置了 ttl 的 key 随机淘汰
  • allkeys-lru: 对全体 key 基于 LRU 算法淘汰, 优先使用, 尤其是数据有明显冷热区分
  • volatile-lru: 对设置了 ttl 的 key 基于 LRU 算法淘汰, 有置顶需求, 置顶数据不设置过期时间
  • allkeys-lfu: 对全体 key 基于 LFU 算法淘汰, 短时高频访问
  • volatile-lfu: 对设置了 ttl 的 key 基于 LFU 算法淘汰, 短时高频访问

分布式锁

底层实现

使用 setnx (set if not exists) 实现

获取锁:

# NX 互斥  EX 设置超时时间
SET lock value NX EX 10

释放锁:

DEL key

redisson 实现

redisson 使用 watch dog 机制给锁续期 (默认每 10s 查看一次), 从而合理控制锁的时间

加锁、设置过期时间等操作都是基于 lua 脚本完成, 保证操作的原子性

可重入锁

redisson 的锁是可重入锁, 多个锁重入需要判断是否是当前线程, 在 redis 中使用 hash 结构 来存储 线程信息和重入次数

主从数据一致性

可以使用redisson 提供的红锁解决主从数据一致性, 实现是对多个 $(\frac{n}{2} + 1)$ 节点同时创建锁, 但是性能太低实现复杂运维繁琐, 所以不推荐

redisAP 思想, 如果要保证数据的强一致性建议使用 CP 思想的 zookeeper

CAP: C (一致性), A (可用性), P (分区容错)

集群

主从复制

单节点 Redis 的并发能力有上限, 搭建主从集群, 实现读写分离可以提高 Redis 并发能力

  1. 全量同步

    replid: Replication Id, 数据集标志, 每个 master 有唯一的 id, slave 继承 masterreplid

    offset: 偏移量, 随着记录在 repl_backlog 中的数据增多而增大, slave 完成同步时也会记录当前同步的 offset, 如果 slaveoffset 小于 master 的则说明需要更新

  2. 增量同步

  • 无法保证集群的高可用

哨兵模式

  • 监控: sentinel 不断检查 masterslave 是否正常工作
  • 自动故障恢复: master 故障时 sentinel 会将一个 slave 提升为 master, 原 master恢复后也以新的master 为主
  • 通知: sentinel 充当 redis 客户端发现来源, 集群发生故障转移时会将最新的信息推送给 redis 客户端
  1. 服务状态监控

    哨兵基于心跳检测服务, 每隔 1 秒向集群的每个实例发送一次 ping 命令

    • 主观下线: 某个实例未在规定时间内响应, 则认为该实例主观下线
    • 客观下线: 超过指定数量 (quorum) 的哨兵认为该实例主观下线, 则该实例客观下线, quorum 最好超过哨兵数量的一半
  2. 哨兵选主原则

    1. 判断主与从节点断开时间长短, 超过指定值则排除该从节点
    2. 判断从节点优先级 (slave-priority), 值越小优先级越高
    3. 优先级一样则判断从节点的 offset 值, 越大证明数据越多, 则优先级越高
    4. 根据从节点运行 id 选择, 越小优先级越高
  3. 脑裂

    由于网络等原因可能会出现脑裂的情况, 比如, 由于 redis 的 master 节点和 salve 节点和 sentinel 处于不同的网络分区,使得 sentinel 没有能够心跳感知到 master,所以通过选举的方式提升了一个 salve 为 master,这样就存在了两个 master, 这种现象称之为脑裂. 当网络恢复后, sentinel 会将 old master 降级为 slave, 以新的 master 为主. 由于期间客户端仍会向 old master 节点中写入数据, 所以这样会导致大量数据丢失.

    设置至少有一个从节点才能写入数据: min-replicas-to-write 1

    设置主从数据复制和同步的延迟时间: min-replicas-max-lag 5

分片集群

优点

  • 集群中有多个 master, 每个 master 保存不同数据
  • 每个 master 都可以有多个 slave
  • master 之间通过 ping 检测彼此健康状态
  • 客户端请求可以访问集群任意节点, 最终都会转发到正确节点

实现

set key value # 使用 CRC16 算法计算 key 的 hash 值取模进行分片
set {aaa} key value # 使用 CRC16 算法计算 aaa 的 hash 值取模进行分片
  • 分片集群引入哈希槽概念, 共 16384 个哈希槽
  • 将所有哈希槽分配到不同实例
  • 根据上述规则计算哈希值并对 16384 取余, 余数作为插槽, 寻找插槽所在的实例

单线程

单线程为什么那么快

  • Redis 纯内存操作, 执行速度快 (所以性能瓶颈在于 网络延迟 而不是执行速度)
  • 单线程避免不必要的上下文切换可竞争条件, 多线程需要考虑线程安全问题 (锁)
  • 使用 I/O 多路复用模型, 非阻塞 I/O

I/O 多路复用模型

利用单个线程来同时监听多个 Socket, 并在某个 Socket 可读、可写时得到通知, 从而避免无效的等待, 充分利用 CPU 资源. 目前的 I/O 多路复用都是采用的 epoll 模式实现 (以前是 select 和 poll), 它会在通知用户进程 Socket 就绪的同时, 把已就绪的 Socket 写入用户空间, 不需要挨个遍历 Socket 来判断是否就绪, 提升了性能.

Redis 网络模型

使用 I/O 多路复用结合事件处理器应对多个 Socket 请求

  • 连接应答处理器
  • 命令回复处理器, 在 Redis 6.0 之后, 为了提升更好的性能, 使用了多线程来处理回复事件
  • 命令请求处理器, 在 Redis 6.0 之后, 在命令的转换 (将请求数据转换成 Redis 命令) 使用了多线程, 增加命令转换速度, 但是在命令执行的时候依然是单线程