缓存

Riicarus大约 15 分钟中间件缓存缓存Redis布隆过滤器

缓存

为什么要用缓存?

项目里怎么用的? 为啥用? 没用好会有啥后果?

用缓存的理由:

  • 高性能: 在内存中, 只有简单的 k-v 对, 性能高.
  • 高并发: MySQL 较重, 并发支持不好, 使用缓存简化操作, 提高并发量.

如何使用的:

  • 结合项目进行阐述, 比如将热点商品放入缓存, 验证码放入缓存等等.

缓存问题: 数据一致性

主要是缓存和数据库双存储双写, 会导致数据一致性问题.

严格缓存/数据库一致性:

  • 读写请求串行化, 都放到同一个内存队列.
  • 效率很低

Cache Aside Pattern:

  • 读操作: 读缓存, 缓存没有就去数据库读, 然后取出数据放入缓存.
  • 写操作: 先写数据库, 然后删除缓存.
    • 为什么直接删除缓存: 更新缓存可能涉及很复杂的计算路基, 而且不能保证该缓存会被频繁访问. 用到缓存再去算缓存, 这样开销更少.

Cache Aside Pattern

先写数据库, 再删除缓存:

  • 如果缓存删除失败, 那么数据库中是新数据, 缓存中是旧数据, 数据不一致.
  • 先删缓存, 再写数据库. 先删缓存不会导致数据不一致, 只是数据库写失败之后是旧数据(可以重新请求操作).
  • 延迟双删: 先写数据库, 再删缓存, delay 一段时间再删除一次缓存.

高并发读写数据, 删除缓存后到数据库找, 但是上一次的数据库写操作还没有执行完, 读取到了旧数据, 更新到缓, 数据库写操作结束后, 缓存和数据库消息不一致.

  • 写数据库时, 根据数据唯一 id, 将对相同数据的操作路由到同一个同步队列, 对应一个工作线程, 串行执行应操作. 如果数据不在缓存中, 那么把"读取数据 + 更新缓存"的操作以同样的方式放入另一个同步队列.
  • 优化: 避免相连的更新缓存请求进入队列, 直接让后续的更新请求自旋等待前面的更新操作完成.
  • 排队带来的阻塞问题: 读请求排队可能会使请求阻塞较长时间, 可以引入超时机制, 如果排队超时, 就直接去旧数据. 但是过多读请求排队超时会让请求直接走数据库, 致使缓存击穿.
    • 解决: 加机器, 注意热点商品路由, 避免路由到同一台机器的同一个队列.

缓存问题: 缓存雪崩

缓存必问问题, 出现之后很致命.

出现情况: 缓存最多抗住 4000 QPS, 高峰请求 5000 QPS, 但是缓存挂了. 5000 请求全部落到数据库, 数据库扛不住, 挂掉, 重启后还是扛不住, 继续挂.

解决方案:

  • 事前: Redis 高可用, 主从 + 哨兵, Redis Cluster, 避免全盘崩溃.
  • 事中: 本地 EhCache 缓存 + hystrix 限流/降级, 避免 MySQL 扛不住.
  • 事后: Redis 持久化, Redis 重启后, 自动从磁盘加载数据, 快速恢复缓存数据.

限流: 限制单位时间内能够通过的请求数.

降级: 不真正处理请求, 而是返回一些默认值或者提示.

这样做的优点是 MySQL 一定不会挂掉, 至少能满足一部分用户的请求.

缓存问题: 缓存穿透

出现情况: 5000 QPS, 但是 4000 QPS 是恶意攻击, 在缓存中查不到(比如 id 为负数). 每次请求都直接走数据库, 不经过缓存, 就像"穿透了缓存"一样. 然后数据库就 G 啦.

解决方案:

  • 缓存写空值:
    • 每次只要从数据没有查到数据, 就向缓存中对应的 key 写一个空值, 然后设置过期时间.
    • 问题: 如果每次攻击使用不同的 id, 写空值就无效了.
  • 布隆过滤器:
    • 在缓存之前添加布隆过滤器, 将数据库所有可能的数据 hash 映射到布隆过滤器中.
    • 对每个请求进行如下判断:
      • 如果请求数据的 key 不在布隆过滤器中, 那么也一定不在数据库中, 直接返回不存在.
      • 如果请求数据的 key 在布隆过滤器中, 就执行正常查询流程.

缓存问题: 缓存击穿

针对热点 key 的问题, 如果在集中式高并发场景下, 当 key 失效, 大量请求击穿缓存, 直接请求数据库, 数据库也就 G 啦.

按照场景确定解决方式:

  • 如果缓存的数据几乎不会发生更新, 那么直接将热点数据设置为永不过期.
  • 如果缓存的数据更新不频繁, 并且刷新缓存耗时少, 可以使用分布式中间件的分布式互斥锁/本地锁来保证只有少量请求可以请求数据库, 并且刷新缓存, 其余线程等待锁释放后可以访问新缓存.
  • 如果缓存的数据更新频繁或者刷新缓存耗时长, 可以利用定时线程在缓存过期前主动重新构建缓存或者延长缓存过期时间, 保证所有请求能够访问到对应的缓存.
  • 避免同时很多 key 失效, 将 key 的过期时间加上一个随机数.

缓存问题: 大 key 问题

大 key: 一个 Redis key 中存储了过大或者过多的数据. 如: MB 级别的 String, W 级别的数据量.

导致的问题:

  • Client 执行命令速度变慢.
  • 内存溢出或者重要的 key 被清除.
  • 集群下数据分片使用率不均等, 难以负载均衡.
  • 处理大 key 时, Redis 自身服务变慢, 波及相关服务.
  • 处理大 key 时, 主库可能长时间阻塞, 导致同步中断甚至主从切换.

解决方案:

  • 拆分大 key.
  • 清理大 key, 使用其他存储方式进行存储.
  • 定期清理过期的数据.
  • 监控 Redis 内存水位.

缓存问题: 并发竞争

多客户端同时并发写一个 key, 导致数据错误.

等同于问: Redis 事务的 CAS 方案.

解决方案:

  • 基于 Zookeeper 实现分布式锁, 每个系统都通过 Zookeeper 获取分布式锁, 保证同一时间只有一个系统实例在操作某个 key.
  • 读入缓存时加入版本号或者时间戳, 比较版本号/时间戳是否比缓存中的更新, 避免用旧的数据覆盖新数据.

Redis 线程模型

Redis 线程模型是什么? 为什么 Redis 单线程却能够支持高并发?

Redis 是单线程工作模型. 只使用单核进行处理.

为什么是单核的?

  • Redis 内部使用文件事件处理器(FileEventHandler), 这个文件事件处理器是单线程的.

为什么支持多个 Socket?

  • 采用 I/O 多路复用机制, 同时监听多个 Socket, 将产生事件的 Socket 压入内存队列, 经由事件分派器进行处理.
  • 事件分派器每次从队列中取出一个 Socket 进行处理.

文件事件处理器结构?

  • 多个 Socket
  • I/O 多路复用程序
  • 文件事件分派器
  • 事件处理器
    • 连接应答处理器(产生 AE_READABLE 事件)
    • 命令请求处理器(处理 AE_READABLE 事件)
    • 命令回复处理器(处理 AE_WRITABLE)

为什么 Redis 单线程效率也那么高?

  • 纯内存操作
  • 非阻塞的 I/O 多路复用机制
  • C 语言实现
  • 避免多线程上下文切换问题, 预防线程间的竞争

为什么 Redis 6.0 引入了多线程?

  • 只是在处理网络数据的读写和协议解析时使用多线程
  • 执行命令任然是单线程
  • 性能瓶颈在网络 I/O 上, 多线程减少对 Redis 主线程的阻塞时间

Redis 数据结构

主要是对各种 Redis 功能的了解.

// 用 go 来描述, String 类型的 RedisObject

// redis object definition
type RedisObject struct {
  Type      string
  Encoding  EncodingType
  Ptr       *SDS
  //...
}

// encoding
type (
  EncodingType string
  StringEncodingType EncodingType
)

const (
  // 保存 32 bit 以下的整形
  INT StringEncodingType    = "int"
  // 保存大于 32 bit 的字符串
  RAW StringEncodingType    = "raw"
  // 保存短字符串(不大于 32 bit), 浮点数
  EMBSTR StringEncodingType = "embstr"
)

// SDB
type SimpleDynamicString struct {
  // buf 剩余的未使用空间
  free  int
  // buf 已使用空间, 当前字符串的长度
  len   int
  // 字符串缓冲区, 减少字符串长度变化时, 内存分配次数
  buf   []char
}

数据类型:

  • String(Simple Dynamic String, SDS)
  • Hash
  • List
  • Set
  • Sorted Set

String:

  • 简单 k-v get/set
  • SDS {len, free, buf[]}
  • encoding: int/raw/embstr

Hash:

  • 底层:
    • ziplist:
      • 所有 k-v 字符串长度小于 64 字节
      • k-v 对数量小于 512 个
    • hashtable

List:

  • 有序列表
  • 底层:
    • ziplist:
      • 所有 k-v 字符串长度小于 64 字节
      • k-v 对数量小于 512 个
    • linkedlist

Set:

  • 无须集合, 自动去重
  • 底层
    • intset
      • 所有元素都是整数值
      • 元素数量小于 512 个
    • hashtable

Sorted Set:

  • 有序集合, 自动去重
  • 底层:
    • ziplist
      • 元素数量小于 128 个
      • 所有元素长度小于 64 字节
    • skiplist

为什么用 Skiplist 而不用 红黑树/平衡树?

查看 SkipList

Redis 数据结构在业务中的使用

  • List: 关注列表, 最新消息排行, 消息队列...
  • Set: 集合相关操作, 交集/并集/差集...
  • Zset: 集合排序/延时队列/Top K/范围查找...
  • String: 计数器/分布式锁(setnx)/分布式全局 id/验证码...
  • Hash: 购物车/商品信息...

商品信息: hmset COMMODITY:BIZ_TYPE:ID field value...

Redis 过期策略

有哪些过期策略? 有哪些内存淘汰机制? LRU 代码实现?

设置过期方法?

对于 hash 来说, hash 的过期时间既可以作用在 field 上, 也可以作用在 key 上. 使用如下指令:

# 设置 key 在 timestamp s 之后过期, 单位为 s
EXPIRE key timestamp
# 设置 key 在 timestamp ms 之后过期, 单位为 ms
PEXPIRE key timestamp

# 设置 hashKey 某个 field 在 timestamp s 之后过期, 单位为 s
HEXPIRE hashKey timestamp
# 设置 hashKey 某个 field 在 timestamp ms 之后过期, 单位为 ms
HPEXPIRE hashKey timestamp

# 设置 key 在 timestamp s 时过期, 单位为 s
EXPIREAT key timestamp
# 设置 key 在 timestamp ms 时过期, 单位为 ms
PEXPIREAT key timestamp

# 移除 key 过期时间
PERSIST key

过期策略?

  • 定期删除 + 惰性删除
  • 定期删除:
    • 每隔一定时间就从 expires 字典中检查一些(随机抽取)设置了过期时间的 key, 如果过期了, 就将其删除.
  • 惰性删除:
    • 获取 key 时, 检查 key 是否设置了过期时间并且过期了? 如果过期就删除.
  • 大量过期 key 堆积问题:
    • 使用内存淘汰机制.

内存淘汰机制?

  • noeviction: 当内存不足以容纳新写入数据时, 新写入操作会报错.
  • allkeys-lru: 当内存不足以容纳新写入数据时, 在键空间中, 移除最近最少使用的 key(这个是最常用的)
  • allkeys-random: 当内存不足以容纳新写入数据时, 在键空间中, 随机移除某个 key.
  • volatile-lru: 当内存不足以容纳新写入数据时, 在设置了过期时间的键空间中, 移除最近最少使用的 key.
  • volatile-random: 当内存不足以容纳新写入数据时, 在设置了过期时间的键空间中, 随机移除某个 key.
  • volatile-ttl: 当内存不足以容纳新写入数据时, 在设置了过期时间的键空间中, 有更早过期时间的 key 优先移除.

LRU 代码实现?

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
  private int capacity;

  public LRUCache(int capacity) {
    super(capacity, 0.75f, true)
    this.capacity = capacity;
  }

  @Override
  protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    return size() > capacity;
  }
}

Redis 高并发和高可用

Redis 单机能承载多少并发? 如果单机抗不住怎么扩容?

Redis 会挂掉吗? 怎么保证高可用?

Redis 实现高并发主要依靠主从架构, 一主多从. 单主用于写数据, 多从用于读数据.

要高并发 + 容纳大量数据, 就要搭建 Redis 集群.

缓存主要是用来支持读高并发的.

读写分离: 主节点负责写操作, 并且将数据复制到其他从节点, 从节点只负责读.

所有读请求全走从节点, 容易实现水平扩容, 支撑读高并发.

这部分比较难, 等待后期补坑

Redis 持久化

Redis 的持久化有哪几种方式? 不同的持久化机制都有什么优缺点? 持久化机制具体底层是如何实现的?

为什么要进行持久化? 如果 Redis 只将数据存储在内存中, 如果 Redis 挂掉, 那么重启后 Redis 中就没有任何数据, 变得不可用了.

如果将 Redis 数据持久化到磁盘中, 那么即使 Redis 挂掉, 重启 Redis 之后还可以加载持久化的数据, 可以继续使用.

Redis 持久化的方式?

  • RDB 持久化: 对 Redis 中的数据执行周期性的持久化. SAVE 主进程阻塞进行持久化, BGSAVE 新建一个子进程异步持久化.
  • AOF 持久化: 将每条写命令作为日志, 以 append-only 的模式写入一个 AOF 文件中. 在 Redis 重启时, 可以回放 AOF 日志中的写入命令来重新构建整个缓存数据库.
  • 当两种持久化方式都存在时, 会优先使用 AOF 日志.

AOF 优缺点?

  • Redis 可以更完好的保护数据不丢失, 默认 AOF 会每隔 1s, 通过后台线程执行一次 fsync 操作, 也就是最多会丢失 1s 的数据.
  • AOF 文件是 append-only 的写入方式, 没有磁盘寻址的开销, 写入性能高, 文件不容易破损.
  • AOF 日志文件即使过大的时候, 出现后台重写操作, 也不会影响客户端的读写. 重写时有 AOF 写缓冲区, 当新 AOF 创建完成之后, 再将缓冲区中的日志写入.
  • AOF 日志文件的命令通过可读较强的方式进行记录, 这个特性非常适合做灾难性的误删除的紧急恢复.
  • AOF 日志文件通常比 RDB 数据快照文件更大.
  • AOF 开启后, 支持的写 QPS 会比 RDB 支持的写 QPS 低, 因为每秒要执行一次 fsync 操作, 占用部分处理器资源.

如何选择 AOF 和 RDB?

  • 不能只使用 RDB, 可能会用较大的数据丢失.
  • 仅仅使用 AOF 会导致两个问题:
    • AOF 冷备份速度没有 RDB 快.
    • RDB 生成的数据库快照更完备和健壮, 可以避免 AOF 复杂备份和恢复机制带来的 BUG.
  • 建议使用 AOF + RDB, AOF 保证数据不丢失, RDB 用作冷备份, 作为 AOF 出问题的后备数据恢复手段.

Redis Rehash

Redis 的 hashtable 初始容量有限, 不足时需要扩容, 扩容就需要对数据进行 rehash, 如果一次进行全部 rehash 操作, 肯定会阻塞整个 Redis.

那就用, 渐进式 Rehash !

备用哈希表:

  • Redis 的 hashtable 中有两个哈希表, index 分别为 0 和 1, 正常只使用 ht[0], ht[1] 闲置.
  • 进行 Rehash 操作时, 启用 ht[1], 将 ht[0] 中的数据渐进式的 Rehash, 放入 ht[1] 中.
  • Rehash 时, 读写据会同时从两张表中读, 写数据只会向 ht[1] 中写, ht[0] 中的数据只减不增, 最后变为空表.

rehash 移动的是 key 指向的 value 的地址, 所以 ht[0] rehash 之后 key 会指向 null, 最后变成空表.

容量变化:

  • 扩容到第一个大于等于需要扩容的哈希表的键值对数量的 2n2^n.
  • 缩容到第一个大于等于需要缩容的哈希表的键值对数量的 2n2^n.

渐进式 Rehash 原理:

  • 维持一个索引计数器变量 rehashidx, 初始化为 0, 代表 rehash 开始.
  • 在每次对字典进行增删改查操作时, 不仅完成应有的操作, 还会将 ht[0] 在 rehashidx 索引上的所有键值对 rehash 到 ht[1] 上, 完成后, rehashidx ++.
  • 随着字典操作不断进行, 最终 ht[0] 的数据会被完整的 rehash 到 ht[1] 中, 此时将 rehashidx 设置为 -1. 同时删除 ht[0], 将 ht[1] 作为新的 ht[0].