Redis 缓存数据库

摘要:Redis 分布式缓存。

目录

[TOC]

Redis 缓存数据库

Redis:用 C 语言开发的数据库,数据存在内存中(即内存数据库),读写速度非常快。

  • 默认端口号为 6379

主要分为三大类:

  1. 数据类型、
  2. 线程模型,内存管理、持久化机制、事务
  3. 管道、集群、性能优化
  4. 缓存

Redis 缓存

为什么要用 Redis 缓存?

  • 高并发:操作缓存能承受的数据库请求数量远大于直接访问数据库的,所以可考虑把数据库中的部分数据转移到缓存中,这样用户的一部分请求会直接到缓存而不用经过数据库。进而,也就提高了系统整体的并发
  • 高性能:假如用户第一次访问数据库中的某些数据的话,从硬盘中读取、过程比较慢。但如果是高频数据且不会经常改变的话,则放入缓存,下一次再访问时可直接从缓存中获取。操作缓存就是直接操作内存,所以速度相当快。

使用场景、用途

用于:

  1. 数据缓存:处理(大量数据的)高访问负载;提升用户体验、应对更多的用户。见上。应用场景有:
    1. session、token、序列化后的对象的缓存。
    2. 计数:比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。
  2. 分布式锁:基于 Redisson 实现分布式锁;
  3. 消息队列Redis 自带的 list 数据结构可作为简单的队列使用。
    • Redis 5.0 中增加的 Stream 类型的数据结构更适合用来做消息队列,类似于 Kafka。支持:发布 / 订阅模式、按照消费者组进行消费、消息持久化( RDB 和 AOF)、ACK 机制。
    • 主要用于高效地处理流式数据,特别适用于消息队列、日志记录和实时数据分析等场景。
    • 不过,和专业的消息队列相比,还是有很多欠缺的地方。比如消息丢失堆积问题不好解决。
    • 因此,通常建议是不使用 Redis 来做消息队列,完全可以选择市面上比较成熟的一些消息队列,比如 RocketMQ、Kafka 等。
  4. 限流 :通过 Redis + Lua 脚本的方式来实现限流;

缓存数据的处理流程

通常为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间

简单来说就是:

  1. 查询用户请求的数据是否在缓存中,如果在缓存中直接返回查询结果;
  2. 如果不在缓存中的话(第一次访问数据、或缓存数据过期),就查询数据库中是否存在;
  3. 数据库中存在的话就返回查询结果、并更新缓存中的数据;
  4. 数据库中不存在的话就返回空数据。

分布式缓存

见最后缓存部分。

分布式缓存:主要解决单机缓存的容量受服务器限制、且无法保存通用信息的问题。因为本地缓存只在当前服务里有效,如果部署了两个相同的服务,两者间的缓存数据无法共享。

常见技术选型方案
  • 分布式缓存使用的比较多的主要是 Memcached 和 Redis。
  • 不过,基本没有用 Memcached 做缓存的,都是直接用 Redis。
Redis 和 Memcached

共同点 :

  1. 都是基于内存的数据库,一般都用来当做缓存使用;
  2. 都有过期策略
  3. 两者的性能都非常高;

区别 :

  1. 数据类型:Redis 支持更丰富的数据类型(更复杂的应用场景),不仅支持简单的 k/v 类型的数据,同时还提供 list, hash, set, zset 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. 线程模型:Redis 使用单线程的多路 IO 复用模型(Redis 6.0 引入了多线程 IO )。Memcached 是多线程,非阻塞 IO 复用的网络模型。
  3. 内存管理Redis 在服务器内存用完后,可将不用的数据放到磁盘上。Memcached 直接报异常。
  4. 过期数据的删除策略Redis 同时用了惰性删除与定期删除Memcached 只用了惰性删除。
  5. 持久化:Redis 支持数据的持久化,有灾难恢复机制,可将内存(缓存)中的数据持久化到磁盘中,重启时可再次加载使用。而 Memcached 把数据全部存在内存中,一旦宕机数据将丢失。
  6. 事务:Redis 支持发布订阅模型、Lua 脚本、事务等功能、更多编程语言。而 Memcached 不支持。
  7. 集群:Redis 原生支持 cluster 模式的集群。Memcached 没有原生的集群模式,需依靠客户端来实现往集群中分片写入数据。

Spring Data Redis

reddis_01

数据类型

Redis 常用的数据结构有哪些?

Redis 常见命令的用法

  • 5 种基础数据结构 :String(字符串)、List(列表)、Hash(散列)、Set(集合)、Zset(有序集合)。
  • 3 种特殊数据结构 :Bitmap(位存储)、HyperLogLog基数统计)、Geos’patial(地理位置)。

5大基础数据类型及应用场景

  1. String:简单的 key-value 类型。设置的值是一个简单的数字或者字符串。

    • 常用命令: set/get/del, setex, expire, ttl, mset, strlen, exists, incr/decr 等。
    • 应用场景: 常用在需计数的场景,如用户的访问次数、热点文章的点赞转发数,验证码等。
    • Rediskeyvalue天然支持幂等的,即任意多次执行所产生的影响均与一次执行的影响相同。用于消息队列中解决重复消费问题。
  2. List:即双向链表。支持反向查找和遍历。值是一个列表,存储多个元素。从底层实现上来说,有ziplistlinkedlist两种实现。

    • 常用命令:rpush/rpop, lpush/lpop, lrange, llen 等。

    • 应用场景:实现队列、栈,发布与订阅、消息队列、慢查询。

      redis list

  3. Hash:类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。是一个 String 类型的 field 和 value 的映射表。值本身就是一个字典。从底层实现上来说,有ziplisthashtable俩中实现

    • 常用命令: hset/hmset/hget, hgetall, hexists, hkeys, hvals 等。
    • 应用场景:特别适合用于存储对象
  4. Set:类似于 HashSet 。提供了判断某个成员是否在 set 集合内的重要接口,轻易实现交、并、差集的操作。值是一个集合(Set)。底层有intsethashtable两种实现。

    • 常用命令: sadd/spop, smembers, sismember, scard(长度), sinterstore(交集), sunion 等。
    • 应用场景:存放不重复的数据及需获取多个数据源交集和并集等场景。如:共同关注就是求交集、消息队列用 set 天然的幂等性校验处理重复消息。
    • 抽奖系统需要用到什么命令?
      • SPOP key count随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。
      • SRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。
  5. Zset/Sorted Set:比 set 增加了一个权重参数 score,使集合中的元素能按 score 进行有序排列,还可通过 score 的范围来获取元素的列表。有点像 HashMap 和 TreeSet 的结合体。值是一个有序的集合。底层有ziplistskiplist两种实现。

    • 常用命令: zadd, zcard, zscore, zrange, zrevrange, zrem 等。
    • 应用场景: 需对数据根据某个权重进行排序的场景。如直播间实时礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等。
    • 相关的一些 Redis 命令: ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。
示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private RedisTemplate<String, Object> redisTemplate;

// String
redisTemplate.opsForValue().set(RedisConstantKey.MY_REDIS_VALUE.key, "人民");
Object o = redisTemplate.opsForValue().get(RedisConstantKey.MY_REDIS_VALUE.key);
if(Objects.nonNull(o)){
     System.out.println(o.toString());
}

// List
List<Object> language = Lists.newArrayList("java", "c++", "python", "js");
redisTemplate.opsForList().leftPushAll(RedisConstantKey.MY_REDIS_LIST.key, language);

// Hash,通过putAll方法以map集合的形式添加键值对
public void addHashData(String key, Map<String, Object> hashData) { // ?
    redisTemplate.opsForHash().putAll(key, hashData);
    Map newMap = new HashMap();  
    newMap.put("map3","map3-3");  
    newMap.put("map5","map5-5");
    redisTemplate.opsForHash().putAll("hashValue", newMap);
    map = redisTemplate.opsForHash().entries("hashValue");  
}

// Set
// RedisConstantKey.MY_REDIS_SET.key ?
redisTemplate.opsForSet().add(RedisConstantKey.MY_REDIS_SET.key, "aa");
redisTemplate.opsForSet().add(RedisConstantKey.MY_REDIS_SET.key, "bb");
// 这里加入一个重复的值,set集合自动去重
redisTemplate.opsForSet().add(RedisConstantKey.MY_REDIS_SET.key, "aa"); //?

Set<Object> members = redisTemplate.opsForSet().members(RedisConstantKey.MY_REDIS_SET.key);
Iterator<Object> it = members.iterator();
while (it.hasNext()){
	System.out.println(it.next());
}

//Zset

3 种特殊数据结构

  1. Bitmap(位存储):存储连续的二进制数字(0 和 1)。只需一个 bit 位来表示某个元素对应的值或状态,key 就是对应元素本身。 8 bit 可组成一个 byte,所以会极大的节省储存空间
    • 常用命令: setbit/getbit, bitcount, bitop
    • 应用场景: 需保存状态信息(如是否签到、是否登录…)并进一步进行分析的场景。
      1. 获取或统计用户在线状态:只需要一个 key,用户 ID 为 offset,如果在线就设置为 1。
      2. 统计活跃用户:用时间作为 key,用户 ID 为 offset,如果当日活跃过就设置为 1;
      3. 用户行为分析:分析喜好,需研究点赞过的内容;
  2. HyperLogLog(基数统计):
    • 应用场景:统计页面 UV
  3. Geos'patial(地理位置)

其它数据结构

字典

Redis 的字典 dict 中包含两个哈希表 dictht,这是为了方便进行 rehash 操作。

在扩容时,将其中一个 dictht 上的键值对 rehash 到另一个 dictht 上面,完成之后释放空间并交换两个 dictht 的角色。

跳跃表

是有序集合的底层实现之一。

跳跃表是基于多指针有序链表实现的,可以看成多个有序链表。

用 String 还是 Hash 存储对象

  • 如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,适合用 Hash:String 存储的是序列化后的对象数据,存放的是整个对象。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或添加部分字段,节省网络流量。
  • 如果系统对性能和资源消耗非常敏感的话,适合用 String:String 存储相对来说更加节省内存,缓存相同数量的对象数据,String 消耗的内存约是 Hash 的一半。并且,存储具有多层嵌套的对象时也方便很多。

在绝大部分情况,建议使用 String 来存储对象数据即可!

那根据你的介绍,购物车信息用 String 还是 Hash 存储更好呢?

由于购物车中的商品频繁修改和变动,比较适合用 Hash 存储:

  • 用户 id 为 key,商品 id 为 field,商品数量为 value。

用 Set 实现抽奖系统需要用到什么命令?

  • SPOP key count : 随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。
  • SRANDMEMBER key count : 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。

用 ZSet 实现一个排行榜

sorted set 经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。

相关的一些 Redis 命令: ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。

用 Bitmap 统计活跃用户

使用日期(精确到天)作为 key,用户 ID 为 offset,如果当日活跃过就设置为 1。

初始化数据:

1
2
3
4
5
6
> SETBIT 20210308 1 1
(integer) 0
> SETBIT 20210308 2 1
(integer) 0
> SETBIT 20210309 1 1
(integer) 0

统计 20210308~20210309 总活跃用户数:

1
2
3
4
> BITOP and desk1 20210308 20210309
(integer) 1
> BITCOUNT desk1
(integer) 1

统计 20210308~20210309 在线活跃用户数:

1
2
3
4
> BITOP or desk2 20210308 20210309
(integer) 1
> BITCOUNT desk2
(integer) 2

用 HyperLogLog 统计页面 UV

1、将访问指定页面的每个用户 ID 添加到 HyperLogLog 中。

1
PFADD PAGE_1:UV USER1 USER2 ...... USERn

2、统计指定页面的 UV。

1
PFCOUNT PAGE_1:UV

线程模型

IO 模型

Redis 线程模型总结:

  1. 对于读写命令来说,Redis 一直是单线程模型。
  2. 多线程:
    1. 不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作,
    2. Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。

单线程模型

img

Redis 基于 Reactor 模式的文件事件处理器,以单线程方式运行,通过I/O 多路复用程序 来监听多个(客户端连接)Socket 套接字,将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。

文件事件处理器(file event handler)主要包含 4 个核心部分:

  1. 多个 Socket(客户端连接);
  2. IO 多路复用程序(支持多个客户端连接的关键);
  3. 文件事件分派器(将 socket 关联到相应的事件处理器);
  4. 事件处理器(分为命令请求处理器、命令回复处理器、连接应答处理器)。
单线程怎么监听大量的客户端连接?

各个组件是怎么配合的:大致就是生产者—消费者模式

通过I/O 多路复用程序 来监听多个(客户端连接)Socket 套接字,将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。

  • 多路复用程序会监听不同套接字的事件,当某个事件,比如发来了一个请求,那么多路复用程序就把这个套接字丢过去套接字队列,事件分派器从队列里边找到套接字,丢给对应的事件处理器处理。

  • 好处:不需额外创建多余的线程监听客户端的大量连接,降低了资源的消耗。由于绝大多数的操作都是纯内存的,所以处理速度非常快

Redis 6.0 之前为什么不用多线程?

其实,在 Redis 4.0 版本之后就引入了多线程来执行一些大键值对的异步删除操作。

  1. 单线程编程容易且更易维护,方便开发和调试;
  2. 主要原因:Redis 的性能瓶颈不在 CPU 资源,主要在内存和网络(I/O操作);
    1. 使用多线程模型带来的性能提升并不能抵消开发和维护成本(可维护性低);
    2. 单线程模型(用 I/O 多路复用机制)也能并发处理来自客户端的多个连接,同时等待多个连接发送请求;
  3. 多线程存在死锁、线程上下文切换等问题,甚至会影响性能。

Redis 6.0 之后为什么引入多线程?

  1. 主要是为了提高网络 IO 读写性能(解决性能瓶颈)。
  2. 充分利用多核。

默认禁用,需手动打开。

事件

内存管理

通过两种方式管理内存:

  1. 过期时间
  2. 内存淘汰机制

键的过期时间

缓存数据设置过期时间
  • 有助于缓解内存占用过多:内存是有限的,如果缓存中的所有数据都一直保存,会有OOM

注意:

  1. Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,
  2. 其他方法都需要依靠 expire 命令来设置过期时间 。
  3. 另外, persist 命令可以移除一个键的过期时间。
1
2
3
4
5
6
> exp key 60 # 数据在 60s 后过期,expire 命令
(integer) 1
> setex key 60 value # 数据在 60s 后过期,用于String
OK
> ttl key # 查看数据还有多久过期
(integer) 56
Redis 判断数据过期

通过 过期字典(可看作是 hash 表)来保存数据过期的时间。

  • 键指向 Redis 数据库中的 key,值是一个 long long 类型的整数,保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

过期数据的删除方式

Redis 采用的是 惰性删除 + 定期删除,定期删除对内存更友好,惰性删除对 CPU 更友好。

  1. 惰性删除/懒汉式删除 :如果在访问某个 key 的时候,会检查其过期时间,如果已经过期,则会删除该键值对。
    • 缺点:可能造成太多过期 key 没有被删除。
  2. 定期删除:定期遍历数据库,检查过期的 key 并且执行删除。
    1. 特点:随机检查,点到即止。并不会一次遍历全部过期 key,然后删除,而是在规定时间内,能删除多少就删除多少。这是为了平衡 CPU 开销和内存消耗。

如果 Redis 开启了持久化和主从同步,那么 Redis 的过期处理要复杂一些。

  1. 在 RDB 之下,加载 RDB 会忽略已经过期的 key;(RDB 不读)
  2. 在 AOF 之下,重写 AOF 会忽略已经过期的 key;(AOF 不写)
  3. 主从同步之下,从服务器等待主服务器的删除命令;(从服务器啥也不干)

如果 Redis 开启了主从同步,那么从库对过期 key 的处理,不同版本有不同策略。对于写来说,从库都是等主库的删除命令,但是对于读来说:

  • 在 3.2 之前,Redis 从服务器会返回过期 key 的值,仿佛没有过期一样。
    • 读取从库过期 key 的策略:可以使用TTL命令来判断 key 究竟有没有过期。
  • 在 3.2 之后,Redis 从服务器会返回NULL,和主库行为一致

缺点:但是,仅仅通过给 key 设置过期时间还是可能漏掉了很多过期 key。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。

怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。

Redis 内存淘汰策略

相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?

Redis 提供 6 种数据淘汰策略:4.0 版本后增加两种 LFU。

  1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中,淘汰最近最少使用的数据。
  2. volatile-lfu(least frequently used):淘汰最不常用的数据。【4.0 版本后增加】
  3. volatile-random:淘汰随机选择的数据。
  4. volatile-ttl:淘汰将要过期的数据。
  5. allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key。最常用
  6. allkeys-lfu:移除最不常用的 key。【4.0 版本后增加】
  7. allkeys-random:从数据集中任意选择数据淘汰。
  8. no-eviction:禁止驱逐数据,即新写入操作会报错。没人用。

持久化机制

怎么保证 Redis 挂掉之后再重启数据可进行恢复?

持久化数据:将内存中的数据写入到硬盘。

  1. 重用数据(如机器故障、重启机器后恢复数据);
  2. 或为了防止系统故障而将数据备份到一个远程位置。

持久化方式

分为两种:

  • RDB(快照持久化):默认方式,通过创建快照来获得(存储在内存里的)数据在某个时间点上的副本。Redis 创建快照之后,
    1. 可以对快照进行备份
    2. 可以将快照复制到其他服务器、从而创建具有相同数据的服务器副本(Redis 主从复制,主要用来提高 Redis 性能),
    3. 还可以将快照留在原地以便重启服务器时使用。
    4. 因为这个过程很消耗资源,所以分成 SAVE 和 BG SAVE 两种。
  • AOF(append-only file,只追加文件):将 Redis 的命令逐条保留下来,而后通过重放这些命令来复原。
    • 每执行一条会更改 Redis 中数据的命令,就将该命令写入到内存缓存server.aof_buf)中,然后再根据(appendfsync)配置来决定何时将其同步到硬盘中的 AOF 文件
    • 可以通过重写 AOF 来减少资源消耗。
    • AOF 持久化的实时性更好,因此已成为主流的持久化方案。

RDB 持久化

save、bgsave 命令

RDB 创建快照的命令、会阻塞主线程吗?

Redis 提供了两个命令来生成 RDB 快照文件:

  • save : 主线程执行,会阻塞主线程;
  • bgsave : 子线程执行,不会阻塞主线程,默认选项。BG SAVE的核心是利用forkCOW 机制。
1
2
3
4
5
# RDB
#在900秒之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。
save 900 1  
save 300 10  #在300秒之后,如果至少有10个key发生变化。
save 60 10000  #在60秒之后,如果至少有10000个key发生变化。
COW 有什么缺陷?

有两个缺点:

  1. 引发缺页异常。如果物理内存紧张,还会引起大量的物理页置换;
  2. COW 的存在,导致我们需要预留一部分内存出来,Redis 无法全部利用服务器的内存;

AOF 持久化

三种 AOF 持久化方式

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式:

1
2
3
4
5
6
# 默认情况下 Redis 没有开启 AOF方式的持久化,可以通过 appendonly 参数开启:
appendonly yes

appendfsync always    # 1.每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec  # 2.每秒钟同步一次,显式地将多个写命令同步到硬盘
appendfsync no        # 3.让操作系统决定何时进行同步

为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。

  • 而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。

当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。

为什么启用了 AOF 还是会丢失数据?

答案:原因在于 AOF 的数据只写到了缓存,还没有写到磁盘。 AOF 有三个选项可以控制刷盘:

  1. always: 每次都刷盘
  2. everysec: 每秒,这意味着一般情况下会丢失一秒钟的数据。而实际上,考虑到硬盘阻塞(见后面**使用 everysec 输盘策略有什么缺点),那么可能丢失两秒的数据。
  3. no: 由操作系统决定

他们的数据保障逐渐变弱,但是性能变强。

everysec 策略刷盘有什么缺点:

  1. 刷盘阻塞的问题:如果数据太多,或者硬盘阻塞,导致一秒钟内无法把所有的数据都刷新到磁盘。Redis 如果发现上一次的刷盘还没结束,就会检查,距离上一次刷盘成功多久了,如果超过两秒,那么 Redis 会停下来等待刷盘成功。
  2. 可能导致丢失两秒数据,而且在同步等待的时候,Redis 的其它请求都被阻塞。
AOF 日志

AOF 日志是如何实现的?

关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令(写内存)之后再记录日志(到磁盘)。

为什么是在执行完命令之后记录日志呢?

  1. 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
  2. 在命令执行完之后再记录,不会阻塞当前的命令执行。

这样也带来了风险:

  1. 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
  2. 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。
AOF 重写

用途?

AOF 重写可以产生一个新的 AOF 文件,和原有的 AOF 文件所保存的数据库状态一样,但体积更小。

AOF 重写:通过读取数据库中的键值对来实现,程序无须对现有 AOF 文件进行任何读入、分析或写入操作。

重写的流程

  1. 在执行 BgreWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令
  2. 当子进程完成创建之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得它保存的数据库状态与现有的数据库状态一致
  3. 最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

RDB 和 AOF 该如何选择?

分析:考察 RDB 和 AOF 的优缺点。

答案:选择的原则是:

  1. 如果数据不能容忍任何丢失,或者只能容忍少量丢失,那么用 AOF;
  2. 否则 RDB,即一般的数据备份和容灾,RDB就够了;

遇事不决 AOF,反正 RDB 可以的,AOF 肯定也可以。

Redis 4.0 对持久化机制做了什么优化?

Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-pre'amble 开启)。

如果把混合持久化打开,AOF 重写时就直接把 RDB 的内容写到 AOF 文件开头

  • 好处:可以结合 RDB 和 AOF 的优点,快速加载同时避免丢失过多的数据。
  • 缺点:AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

事务

不建议使用

可以将 Redis 中的事务就理解为 :Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。

事务命令

Redis 可以通过 MULTIEXECDISCARDWATCH 等命令来实现事务(transaction)功能。

  1. MULTI命令:可输入多个命令,Redis 不会立即执行这些命令,而是将它们放到事务队列,当调用了 EXEC 命令再执行所有命令;
  2. EXEC 命令:
  3. DISCARD命令:取消一个事务,清空事务队列中保存的所有命令;
  4. WATCH命令:用于监听指定的键;当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。

这个过程是这样的:

  1. 开始事务(MULTI)。
  2. 命令入队:批量操作 Redis 的命令,先进先出(FIFO)的顺序执行。
  3. 执行事务(EXEC)。
1
2
3
4
5
6
7
8
9
> MULTI #开始事务
OK
> SET USER "Guide哥" #命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行)
QUEUED
> GET USER
QUEUED
> EXEC #执行事务
1) OK
2) "Guide哥"

Redis 支持原子性吗?

Redis 的事务和我们平时理解的关系型数据库事务不同。

  • 我们知道事务具有四大特性(ACID): 1. A 原子性,2. C一致性,3. I 隔离性,4. D 持久性

Redis 事务其实是不满足原子性的(而且不满足持久性):

  1. Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。
  2. 并且,不支持回滚操作。
  3. 不满足“要么全部完成,要么完全不起作用”。

Lua 脚本

解决 Redis 事务的缺陷

Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。

  • 可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。

一段 Lua 脚本可以视作一条命令执行,执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。

如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的。因此,严格来说,通过 Lua 脚本来批量执行 Redis 命令也是不满足原子性的。

管道

管道原理、实现机制、基本步骤

Redis Pipeline 的原理是:

  1. 应用代码会持续不断的把请求发给 Redis Client;
  2. Redis Client 会缓存这些命令,等凑够了 N 个,就发送命令到 Redis 服务端。而 N 的取值对 Pipeline 的性能影响比较大(引导询问 N 的取值);
  3. Redis Server 收到命令之后进行处理,并且在处理完这 N 个命令之前,所有的响应都被缓存在内存里面。这里也可以看到,N 如果太大也会额外消耗 Redis Server 的内存(这里引导讨论内存消耗这个弊端);
  4. Redis Server 处理完了 Pipeline 发过来的一批命令,而后返回响应给 Redis Client;
  5. Redis Clinet 接收响应,并且将结果递交给应用代码;
  6. 如果此时还有命令,Redis Client 会继续发送剩余命令;

Pipeline 为什么快:Redis Pipeline 减少了网络 IO,也减少了 RTT,所以性能比较好。

优势

答:Redis Pipeline 相比普通的单个命令模式,性能要好很多。两个原因:网络 IO 和 RTT。

  • 单个命令执行的时候,需要两次 read 和 两次 send 系统调用,加上一个 RTT。如果有 N 个命令就是分别乘以 N。

  • 但是在 Pipeline 里面,一次发送,不管 N 多大,都是两次 read 和两次 send 系统调用,和一次 RTT。因而性能很好。

Redis 高可用

Redis 高可用有两种模式,Sentinel 和 Cluster。

  • Sentinel:本质上是主从模式,与一般的主从模式不同的是,主节点的选举,不是从节点完成的,而是通过 Sentinel 来监控整个集群模式,发起主从选举。因此本质上 Redis Sentinel 有两个集群,一个是 Redis 数据集群,一个是哨兵集群。

  • Redis Cluster:集成了对等模式和主从模式。Redis Cluster 由多个节点组成,每个节点都可以是一个主从集群。Redis 将 key 映射为 16384 个槽(slot),均匀分配在所有节点上。

两种模式下的主从同步都有全量同步和增量同步两种,一般情况下,应该尽量避免全量同步。

一般而言,如果数据量和复杂并不大的时候,想要保证高可用,就采用 Redis Sentinel;如果负载很大,或者说触及了 Redis 单机瓶颈,那么应该采用 Redis Cluster 模式。

全量同步和增量同步

Redis 主从之间是如何同步数据的?

答案:Redis 主从同步分成两种,全量同步和增量同步。

全量同步

全量同步的步骤是:

  1. 从服务器发起同步,主服务器开启 BG SAVE,生成 BG SAVE 过程中的写命令也会被放入一个缓冲队列;
  2. 主节点生成 RDB 文件之后,将 RDB 发给从服务器;
  3. 从服务器接收文件,清空本地数据,再入 RDB 文件;(这个过程会忽略已经过期的 key,参考过期部分的讨论)
  4. 主节点将缓冲队列命令发送给从节点,从节点执行这些命令;
  5. 从节点重写 AOF;

这时候已经同步完毕,之后主节点会源源不断把命令同步给从节点。

( 主生成 RDB 和 缓冲命令, 发给从,从加载 RDB,执行缓冲命令,重写 AOF )

全量同步面临的问题、缺点:从上面的步骤可以看出来,全量同步非常重,资源消耗很大,而且,大多数情况下,从服务器上是存在大部分数据的,只是短暂失去了连接。如果这个时候又发起全量同步,那么很容易陷入到无休止的全量同步之中。

增量同步

因此 Redis 引入了增量同步。增量同步的依赖于三个东西:

  1. 服务器ID:用于标识 Redis 服务器ID;
  2. 复制偏移量:主服务器用于标记它已经发出去多少;从服务用于标记它已经接收多少(从服务器的比较关键);
  3. 复制缓冲区:主服务器维护的一个 1M 的FIFO队列,近期执行的写命令保存在这里;

从服务器将自己的复制偏移量发给主服务器,如果主服务器发现,该偏移量还在复制缓冲区,那么就执行增量复制,将偏移量后面的命令同步给从服务器;否则执行全量同步;

(其实就是,从服务器记录了一下自己同步到哪里,然后找主服务器同步,主服务器一看,这个数据还在缓冲区,ok,可以增量同步)

使用全量还是增量同步?

Redis 如何决定是使用全量同步还是增量同步?

答案:当且仅当,从服务器从相同的主服务器里面同步,偏移量对应的命令还在缓冲区,执行增量同步。

  1. 从服务器发现自己从来没有同步过,那么执行全量同步;
  2. 从服务器发起同步命令(PSYNC),但是主服务器发现从服务器上次同步的对象不是自己,(服务器ID不匹配),于是执行全量同步;
  3. 从服务器发起同步命令(PSYNC),主服务器发现偏移量太古老了,数据已经不在复制缓冲区了,全量同步;
  4. 从服务器发起同步命令(PSYNC),主服务器发现偏移量对应的数据还在复制缓冲区,执行增量同步;
服务器重启

可能引发什么问题?

答案:服务器重启,分成主服务器重启和从服务器重启。

  • 对于从服务器来说,因为重启会使它丢失了上一次同步的主服务器的ID,所以只能发起全量同步;

  • 对于主服务器重启来说,因为服务器ID发生变化,所有的从服务器都需要执行全量同步;

针对这种情况,Redis 引入了一种安全重启机制,这种机制下重启不会变更服务器ID,可以避免全量同步。

主从之间网络不稳定

答案:主从之间网络不稳定可能引起三种情况:

  1. 未超时:短暂网络抖动,那么从服务器可以通过 ACK 机制重新补充丢失的数据(参考后面的心跳机制);
  2. 超时但是复制缓冲还在:超时,但是从服务器发过来的偏移量还在缓冲区,增量复制;
  3. 超时没救了:超时,偏移量不在缓冲区,全量复制;
全量同步缺点

答案:全量同步是利用 BG SAVE 来完成的,所以具体的开销有:

  1. 从 CPU 和 内存的角度来说:会发起fork系统调用,在单机内存很大的时候,这会引起很大的延迟,并且因为 COW 的原因,引发大量的缺页中断;
  2. 磁盘 IO:BG SAVE 的文件写入到磁盘,会增大磁盘负载;
  3. 网络传输:BG SAVE 在网络中传输,会导致短时间内网络负载飙升;
  4. 潜在可能失败导致无休止的全量同步:更重要的是,因为全量同步非常复杂,这段时间可能从服务器再次和主服务器失去连接。等下次重连的时候,又触发一遍全量同步,循环往复;

因为这么多缺点,所以需要引入增量同步。

如何避免全量同步

引发全量同步的几个原因有:

  1. 主服务器宕机重连
  2. 主服务器没有安全启动
  3. 主从同步超时,导致缓冲区溢出(就是偏移量对应的数据不再缓冲区了)
  4. 从服务器重启

这些情况大部分是避免不了。能做的大概就是两件事:

  1. 主服务器使用安全重启机制,避免ID变化;
  2. 增大复制缓冲区
  3. 调大超时

然后就是加强网络建设了。

心跳机制

Redis 的心跳机制,是两个方向的:

  • 一个是主服务器向从服务器发送心跳,用于检测网络和从服务器存活;

  • 另外一个是从服务器像主服务器发送 REPLCONF ACK,这个 ACK 会带上自身的复制偏移量。因此,如果服务器发现从服务器的偏移量比较落后,可以将丢失的数据重新补上。

这就是类似于 TCP 的 ACK 机制。ACK 会告诉发送端下一次期望的数据报,而后发送端进行重发。

不过 TCP 引入了滑动窗口协议,因此可以简单处理失序报文,但是 Redis 的同步,是要求严格的顺序的,并且从服务器并不具备处理失序命令的能力。

Sentinel 模式

Sentinel 是如何监控主从集群的?

分析:考察 Sentinel 模式的基本特点。

答案:Sentinel 本身有三个定时任务(重要):

  1. 获取主从结构信息,所以能够做到主从结构动态更新;
  2. 获取其它 Sentinel 节点的看法;
  3. 对主从节点的心跳检测;

整个过程可以理解为:核心就是主观下线 -> 客观下线 -> 主节点故障转移。

  • 首先 Sentinel 获取了主从结构的信息,而后向所有的节点发送心跳检测,如果这个时候发现某个节点没有回复,就把它标记为主观下线
  • 如果这个节点是主节点,那么 Sentinel 就询问别的 Sentinel 节点主节点信息。如果大多数都 Sentinel 都认为主节点已经下线了,就认为主节点已经客观下线
  • 当主节点已经客观下线,就要步入故障转移阶段。故障转移分成两个步骤,一个是 Sentinel 要选举一个 leader,另外一个步骤是 Sentinel leader 挑一个主节点。

Sentinel leader 选举是使用 raft 算法的, 选举出 leader 之后,leader 从健康从节点之中依据 <优先级, 偏移量, 服务器ID> 进行排序,挑出一个节点作为主节点。

找出主节点之后,Sentinel 要命令其它从节点连接新的主节点,同时保持对老的主节点的关注,在它恢复过来之后把它标记为从节点,命令它去同步新的主节点。

因为可能存在多个从节点,因此我们需要控制同时进行控制转移的从节点的数量,也就是paralle-syncs参数。该参数如果设置过小,会导致故障转移时间很长;但是如果该参数设置过大,会导致多数从节点不可用。

脑裂

为什么会发生脑裂?有什么危害?如何解决?

脑裂不是只有 Redis 才有的,而是所有的主从模式都会有类似的问题。比如说 Zookeeper,所以可以结合 zookeeper 来说。

  • 原因:大部分主从模式都会遇到脑裂问题。它的根源在于,当我们把一个主节点标记位从节点之后,它自己认为自己还是主节点。
  • 危害:如果这个时候客户端还是连上了这个主节点,那么就会导致在错误的主节点上执行了写命令,导致数据不一致。
  • 解决:zookeeper 也有类似的问题。彻底解决这个问题其实不太可能,只能尽量缓解。在 Redis 里面有一个参数,控制主节点至少要有多少个从节点才会接受写请求,把这个值设置比较大,能够缓解问题。
  • 上面的参数是无法根绝这个问题的。因为事情就可能那么凑巧,恰巧整个集群一分为二,然后两边各有一个主节点,然后都认为自己是主节点,而且从节点数也达标。这时候,如果将参数设置为超过一半,那么就可以避免这个问题。

脑裂也是现在制约主从模式的一个很大的问题,因此最近涌现出来了很多的对等集群。

为什么引入 Sentine

Redis 为什么不直接使用普通的 Master-Slave 模式

分析:这个问题也有一点强行解释的意味。这个问题源于这么一种朴素的认知,就是其实从服务器完全可以自己发起选举,选出一个 leader 来,也就是主节点。Sentinel 却是引入了哨兵,由哨兵选出哨兵 leader,由哨兵 leader 选出一个主节点。但是在 Cluster 里面,主节点是直接由从节点选举出来的。强行解释,没啥好说的。

答案:(可能)是出于性能考虑。Sentinel 可以单独部署,那么 Sentinel 在选举 leader,挑选主节点的时候,并不会影响到 Redis 数据集群的性能。

Cluster模式

Redis Cluster 是如何运作的?

Redis Cluster 是如何分片的?

答案:Redis Cluster 主要是利用 key 的哈希值,将其分成 16384 个槽,而后每个槽被分配到不同的服务器上。这些服务器,本身也是一个主从模式的主服务器。

请求路由问题:Redis Cluster 是peer-to-peer,每个节点都能提供读写服务。在这种情况下,如果客户端请求的某个 key 不在该服务器上,该服务器就会返回一个move错误,让客户端再一次请求正确的服务器(类似于HTTP的重定向)。

智能客户端smart client):可见,在这种情况下,如果我们能够在客户端维持一份槽映射表,我们的就不必经过这么一份转发,这就是所谓的智能路由。

  • 智能客户端:指,客户端将槽到服务器的映射关系维持在内存中,并且在收到move错误的时候更新信息。如果在使用连接池的情况下,它会对每一个主节点建立一个池。
  • 这种模式,极大减少了move错误发生的概率,并且即便真的发生了槽迁移,也很快就能修正自己的映射关系。

槽迁移:但是,分布式环境下,可能会扩容缩容。因此槽就会出现迁移,从一台服务器挪到另外一台服务器。

  • Redis 提供了槽迁移的命令,主要步骤就是让目标节点准备好接收,源节点准备迁移。热后小批量迁移key。

迁移过程中key的访问:因此在迁移过程中,一个槽的部分 key 可能在源节点,一部分在目标节点。因此如果请求过来,打到源节点,源节点发现已经迁移了,就会返回一个 ASK 错误,这个错误会引导客户端直接去访问目标节点。

集群

负载均衡算法

见分布式文档中的负载均衡部分。

负载均衡转发实现
集群下的 Session 管理

性能优化

bigkey

bigkey:key 对应的 value 所占用的内存较大。

  • 如 string 类型的 value 超过 10 kb,
  • 复合类型的 value 包含的元素超过 5000 个(对于复合类型的 value,不一定包含的元素越多,占用的内存就越多)。
危害

除了会消耗更多的内存空间,bigkey 对性能也会有比较大的影响。因此,应该尽量避免写入 bigkey!

如何发现 bigkey?
  1. 使用 Redis 自带的 --bigkeys 参数来查找。这个命令会扫描 Redis 中的所有 key,会对 Redis 的性能有一点影响。
    • 并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 string 数据类型,包含元素最多的复合数据类型)。
  2. 通过分析 RDB 文件来找出 big key。前提是 Redis 采用的是 RDB 持久化。网上有现成的代码/工具可以直接拿来使用:
    • redis-rdb-tools :Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具;
    • rdb_bigkeys : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。

大量 key 集中过期问题(缓存雪崩)

在内存管理中,对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。

  • 定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。
  • 这就导致客户端请求没办法被及时处理,响应速度会比较慢。

下面是两种常见的解决方法:

  1. 给 key 设置随机过期时间。
  2. 开启 lazy-free(惰性删除/延迟释放) :指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。是 Redis 4.0 开始引入的

缓存

CPU 多级缓存见操作系统文档内存管理部分。

命中率:当某个请求能够通过访问缓存而得到响应时,称为缓存命中

  • 缓存命中率越高,缓存的利用率也就越高。

最大空间:缓存通常位于内存中,内存的空间通常比磁盘空间小的多,因此缓存的最大空间不可能非常大。

  • 当缓存存放的数据量超过最大空间时,就需要淘汰部分数据来存放新到达的数据。

缓存淘汰策略

见【数据结构和手撕算法】文档中的【缓存淘汰算法】部分。

  1. FIFOFirst In First Out)先进先出策略:优先淘汰最先进入的数据(最晚的数据)。在实时性的场景下,需要经常访问最新的数据。
  2. LRULeast Recently Used)最近最久未使用策略:优先淘汰最久未使用的数据,即上次被访问时间距离现在最久的数据。可以保证内存中的数据都是热点数据(经常被访问的数据),从而保证缓存命中率。基于 双向链表 + HashMap 的 LRU 算法实现。
    1. 访问某个节点时,将其从原来的位置删除,并重新插入到链表头部。这样就能保证链表尾部存储的就是最近最久未使用的节点,当节点数量大于缓存最大空间时就淘汰链表尾部的节点。
    2. 为了使删除操作时间复杂度为 O(1),就不能采用遍历的方式找到某个节点。HashMap 存储着 Key 到节点的映射,通过 Key 就能以 O(1) 的时间得到节点,然后再以 O(1) 的时间将其从双向链表中删除。
  3. LFULeast Frequently Used)最不经常使用策略:优先淘汰一段时间内使用次数最少的数据。

缓存分类及位置

  1. 浏览器:当 HTTP 响应允许进行缓存时,浏览器会缓存静态资源(HTML、CSS、JavaScript、图片等)。
  2. 网络服务提供商(ISP):是网络访问的第一跳,通过将数据缓存在 ISP 中能够大大提高用户的访问速度。
  3. 反向代理:位于服务器之前,请求与响应都需要经过反向代理。通过将数据缓存在反向代理,在用户请求反向代理时就可以直接使用缓存进行响应。
  4. 服务器本地缓存:使用 Guava Cache 将数据缓存在服务器本地内存中,服务器代码可以直接读取本地内存中的缓存,速度非常快。
  5. 分布式缓存:使用 Redis、Memcache 等分布式缓存缓存数据。
    • 相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。
    • 不仅如此,服务器集群都可以访问分布式缓存,而本地缓存需要在服务器集群之间进行同步,实现难度和性能开销上都非常大。
  6. 数据库缓存:MySQL 等数据库管理系统具有自己的查询缓存机制来提高查询效率。
  7. Java 内部的缓存:Java 为了优化空间,提高字符串、基本数据类型包装类的创建效率,设计了字符串常量池及 Byte、Short、Character、Integer、Long、Boolean 这六种包装类缓冲池(常量池)
  8. CPU 多级缓存:CPU 为了解决运算速度与主存 IO 速度不匹配的问题,引入了多级缓存结构,同时使用 MESI 等缓存一致性协议来解决多核 CPU 缓存数据一致性的问题。

缓存雪崩、击穿、穿透

Redis 生产问题,缓存异常

命名容易引起歧义,因为穿透和击穿在中文语境下区别就不大。

缓存穿透、击穿和雪崩,本质上就是同一个问题:缓存没起效果(导致数据库崩溃)。只不过根据不起效的原因进行了进一步的细分。

其实,这三个就是描述了三种场景:

  1. 缓存穿透:数据库本来就没数据。所以请求来的时候,肯定是查询数据库的。但是因为数据库里面没有数据,所以不会刷新回去,也就是说,缓存里面会一直没有。因此,如果有一些黑客,一直发一些请求,这些请求都无法命中缓存,那么数据库就会崩溃。
  2. 缓存击穿:数据库有数据,但是缓存里面没有。理论上来说,只要有人请求数据,就会刷新到缓存里面。问题就在于,如果突然来了一百万个请求,一百万个线程都尝试从数据库捞数据,然后刷新到缓存,那么数据库也会崩溃。
  3. 缓存雪崩:缓存本来有数据,但是突然一大批缓存集体过期了。一般情况下都不会有问题,但是如果突然之间几百万个 key 都过期了,那么接下来的请求也几乎全部命中数据库,也会导致数据库崩溃。
缓存雪崩

缓存雪崩:缓存在同一时刻大面积失效、或充当缓存的Redis宕机,请求都直接落到数据库上,造成数据库短时间内承受大量请求而宕机。

  • 如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。

解决方法:

  1. 针对大量数据同时过期的情况:
    1. 错开设置不同的缓存失效时间,如随机设置,可以避免因为采用相同的过期时间导致的缓存雪崩。让他们尽量不同时过期。尤其是在缓存预热的时候,更需要这样做。
    2. 互斥锁setnx命令),保证同一个时间内只有一个请求来构建缓存,构建完后释放锁,未能获取到锁的请求,要么等锁释放后重新读取缓存,要么返回空或默认值。
      • 如果缓存失效的情况,只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,防止数据库打死。当然这样会导致系统的性能变差。
    3. 后台定时更新缓存:业务线程不再负责更新缓存,对于热点的key可以设置永不过期的key,让缓存“永久有效”,让后台线程定时更新缓存。适合用于对于缓存一致性要求不特别严格的场景。
    4. key 策略:备用 key 永久不过期,主 key 过期时返回备用 key 的内容。
  2. Redis 服务不可用、宕机导致的:
    1. 采用 Redis 集群、构建Redis主从和哨兵来保证可靠性,避免单机出现问题整个缓存服务都无法使用;
    2. 服务熔断或进行请求限流,避免同时处理大量的请求。
      1. 熔断机制:当流量到达一定的阈值时,就暂停业务,直接返回“系统拥挤”之类的错误提示,防止过多的请求打在数据库上。等Redis恢复正常后,再允许业务进行。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。
      2. 启用限流只允许少部分请求访问数据库,大于能承受的压力的请求直接拒绝服务。等到Redis正常且预热完毕,再解除限流。
    3. 提高数据库的容灾能力,可以使用分库分表、读写分离的策略。

例如:

  • 当多个商品缓存同时失效时会雪崩,导致大量查询数据库。还有就是秒杀刚开始的时候缓存里没有数据。
  • 解决方案:缓存预热,错开缓存失效时间
缓存击穿

缓存击穿:是一个热点的Key(比如秒杀活动),有大并发集中对其进行访问,突然间这个Key失效了,导致大量并发请求全部落在数据库上,导致数据库很容易被击穿。

实际上缓存击穿与缓存雪崩都是key失效的问题,也可以认为缓存击穿缓存雪崩的子集。

二者的区别在于:雪崩针对很多 key 失效,而击穿只针对于某一个热点 key 失效。

解决方法:与缓存雪崩类似

  1. 使用互斥锁
  2. 后台更新缓存。
缓存穿透

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

原因:业务操作错误,缓存的数据或数据库的数据被删除,或者意外被用户访问到不存在的数据。

解决方法:

  1. 限制非法请求、无效请求、参数校验:不合法的参数请求直接抛出异常信息返回给客户端。如查询的数据库 id 不能小于 0、传入的邮箱格式错误等;
  2. 缓存无效 key,缓存空值、或默认值:如果缓存和数据库都查不到某个 key 的数据,就写入 Redis 并设置过期时间,可解决请求的 key 变化不频繁的情况;
  3. 使用布隆过滤器:快速判断给定数据是否存在于海量数据中(哈希函数),避免通过查询数据库来判断数据是否存在。加入布隆过滤器之后的缓存处理流程
    1. 新加入:把所有可能存在的请求的值都存放在布隆过滤器中,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端;
    2. 存在的话判断缓存中是否存在对应的数据,存在的话直接返回;
    3. 不存在的话判断数据库中是否存在对应的数据,存在的话返回数据、并更新缓存的数据,不存在的话返回空数据。

如何保证缓存和 DB 数据的一致性

缓存一致性:要求数据更新的同时,缓存数据也能够实时更新。

原因

缓存一致性的问题根源于两个原因:

  1. 不同线程并发更新 DB 和缓存;
  2. 即便是同一个线程,更新 DB 和更新缓存是两个操作,容易出现一个成功一个失败的情况;

最常用的是三种必然会引起不一致的方案,这三种方案大同小异。都是更新顺序导致的:

  1. 先更新 DB,再更新缓存。B 覆盖了 A 在 DB 中的数据,A 覆盖了 B 在缓存中的数据。
  2. 先更新缓存,再更新 DB。
  3. 先更新 DB,再删除缓存。A 从数据库中读取数据1,B 更新数据库为2,B 删除缓存,A 更新缓存为1,此时缓存中数据为1,数据库中数据为2。
解决方案

缓存和 DB 一致性的问题可以说是无最优解的。无论选择哪个方案,总是会有一些缺点。

对应解决方案:

  1. 在数据更新的同时立即去更新缓存;
  2. 在读缓存之前,先判断缓存是否是最新的,如果不是最新的先进行更新。
  3. 与这三个类似的一个方案是利用 CDC 接口,异步更新缓存。但是本质上,也是要忍受一段时间的不一致性。比如说典型的,应用只更新 MySQL,然后监听 MySQL 的 binlog,更新缓存。

如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说两个解决方案:

  1. 让缓存数据失效时间变短(不推荐,治标不治本) :缓存会从数据库中加载数据。不适用先操作缓存后操作数据库的场景。
  2. 增加 cache 更新重试机制(常用):
    1. 如果 cache 服务当前不可用导致缓存删除失败,就隔一段时间重试
    2. 如果多次重试还是失败,可把当前更新失败的 key 存入队列中,等缓存服务可用后,再将缓存中对应的 key 删除即可。

缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹

如何解决缓存和 DB 的一致性问题

分析:日经题,每次面,但凡简历上出现了缓存两个字眼,就会问。甚至于,只要面试官公司用了缓存,他们就会问。

答:缓存和 DB 的一致性问题,没有什么特别好的解决方案,主要就是一个取舍的问题。

  • 如果能够忍受短时间的不一致,那么可以考虑只更新 DB,等缓存自然过期。大多数场景其实没有那么强的一致性需求,这样做就够了。
  • 进一步也可以考虑先更新 DB 再更新缓存,或者先更新缓存再更新 DB,或者更新 DB 之后删除缓存,本质上都会有不一致的可能,但是至少不会比只更新 DB 更差。
  • 另外一种思路是利用 CDC 接口,比如说监听 MySQL 的binlog,然后更新缓存。应用是只更新 MySQL,丝毫不关心缓存更新的问题。(引导面试官问 CDC 问题,或者 MySQL binlog,或者说这种模式和别的思路比起来有什么优缺点)
  • 至于说其它的比如说 cache-aside, write-through, read-through, write-back 也对一致性问题,毫无帮助。(引导面试官问之后几个 pattern)

仅仅依靠缓存和DB是做不到一致性的,要结合别的组件。

如果追求强一致性,那么可行的方案有两个:两个方案的本质都是确保只有一个线程操作特定的 key。

  1. 利用分布式锁:适用于写请求特别少的例子。
    • 因为在读上没必要加锁,在写的时候加锁。
    • 读上没必要加锁原因是:在同一个时刻,有人更新数据,有人读数据,那么读的人,读到哪个数据都是可以的。如果写已经完成,那么读到的肯定是新数据,如果写没有完成,读到的肯定是老数据。
    • 读完全没有必要加分布式锁,即便此时有人正在更新缓存或者 DB,当前的请求要么读到更新前的,要么读到更新后的,不会有什么问题。
    • 如果要是缓存过期,然后用 DB 的数据更新缓存,同样要参与抢夺这个分布式锁。
    • 另外,一种可行的优化方案是:在单机上引入 singleflight 来减少锁竞争。确保一个实例针对一个特定的 key 只会有一个线程去参与抢全局的分布式锁。
      • 那么更新某个 key 的时候,同一个实例上的线程自己竞争一下,决出一个线程去参与抢全局分布式锁。在写频繁的时候,这种优化能够有些减轻分布式锁的压力。
  2. 利用负载均衡算法结合单机 singleflight
    • 可以考虑对 key 采用哈希一致性算法(来作为负载均衡算法),那么可以确保,同一个 key 的请求永远会落到同一台实例上。
    • 结合 singleflight,那么可以确保全局只有一个线程去更新(缓存或者 DB)数据,那么自然就不存在一致性的问题了。
    • 这种方案的问题在于:哈希一致性算法因为扩容、缩容、重启/重新部署,会导致 key 迁移到别的机器上,所以可能出现不一致的问题。可以考虑两种方案:
      1. 第一种方案是:扩容或者缩容的时候在原本实例上禁用这些迁移 key 的缓存;
      2. 另外一种方案是:目标实例先不开启读这些迁移 key 的缓存,等一小段时间,确保原本实例上的这些迁移 key 的请求都被处理完了,然后再开启缓存。
强一致性

真正寻求强一致性,还要进一步解决更新 DB 和更新缓存一个成功一个失败的问题。

只有三个选项:

  1. 追求强一致性,选用分布式事务;
  2. 追求最终一致性,可以引入重试机制;
  3. 如果可以使用本地事务,那么应该是:开启本地事务-更新DB-更新缓存-提交事务.

用了分布式事务,还需要分布式锁吗?

  • 答案是,要的。因为分布式事务既解决不了多个线程同时更新的问题,也解决不了一个线程更新,一个线程从数据库读数据刷缓存的问题。

缓存模式、缓存读写策略

选择何种缓存模式,是一个业务层面上考虑的问题,大多数时候,选取任何一种模式都不会有问题。

缓存模式:主要是所谓的 cache-aside, read-through, write-through, write-back, refresh ahead 以及更新缓存使用到的 singleflight 模式

write-back 模式

因为标准的 write-back 是在缓存过期的时候,然后再将缓存刷新到 DB 里面。

  • 因此,它的弊端就是,在缓存刷新到 DB 之前,如果缓存宕机了,比如说 Redis 集群崩溃了,那么数据就永久丢失了;
  • 但是好处就在于,因为过期才把数据刷新到 DB 里面,因为读写都操作的是缓存。如果缓存是 Redis 这种集中式的,那么意味着大家读写的都是同一份数据,也就没有一致性的问题。
  • 但是,如果你设置了过期时间,那么缓存过期之后重新从数据库里面加载的同时,又有一个线程更新缓存,那么两者就会冲突,出现不一致的问题;
refresh ahead 模式

其实就是利用 CDC 的方案

singleflight 模式

singleflight:如果多个线程(协程)去做同一件事,那么可以从多个线程里面挑出来一个去做,其余的线程就停下来等结果。

  • 在更新缓存的时候,控制住(同一个 key)只有一个线程去更新缓存。
  • 防止在缓存未命中的时候,多个线程同时访问 DB,给 DB 造成巨大的压力。
  • singleflight 只在单机层面上应用,而不是在全局上。
    • 另外一个是,在分布式环境下,我们只做单机层面上的控制。也就是说,如果有多台机器,我们会保证一个机器只有一个线程去更新特定一个 key 的缓存。比如说,针对 key1,如果有三台机器,那么最多会有三个线程去更新缓存。
    • 不做全局的原因很简单,在分布式环境下,数据库至少要能撑住这种多台机器同时发起请求的负载。而做全局的 singleflight 本质上就是利用分布式锁,这个东西非常消耗性能。
Cache Aside(旁路缓存模式)

适合读请求较多的场景。

写请求:

  1. 先更新 DB;
  2. 然后直接删除 cache 。

先删除 cache ,后更新 DB,可能会造成数据库和缓存数据不一致的问题。

读请求 :

  1. 从 cache 中读取数据,读取到就直接返回;
  2. cache中读取不到的话,就从 DB 中读取数据返回,再把数据放到 cache 中。

缺陷:

  1. 首次请求数据一定不在 cache 的问题:可将热点数据提前放入cache 中。

  2. 写操作较频繁导致cache中的数据会被频繁被删除,影响缓存命中率 。

解决办法:

  • 数据库和缓存数据强一致场景 :更新DB时同样更新cache,不过需加一个锁/分布式锁来保证更新cache时不存在线程安全问题。
  • 可短暂地允许数据库和缓存数据不一致的场景 :更新DB时同样更新cache,但给缓存加一个较短的过期时间,可保证即使数据不一致影响也比较小。
Read/Write Through(读写穿透)

服务端把 cache 视为主要数据存储,从中读写数据。cache 服务负责将此数据读取和写入 DB,从而减轻应用程序的职责。少见。

写(Write Through):

  1. 先查 cache,cache 中不存在,直接更新 DB。
  2. cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)。

读(Read Through):

  1. 从 cache 中读取数据,读取到就直接返回 。
  2. 读取不到的话,先从 DB 加载,写入到 cache 后返回响应。
Write Behind(异步缓存写入)

和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。

但又有很大不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,改为异步批量的方式来更新 DB。少见。

0%