注意:缓存穿透(Cache Penetration) 和缓存击穿(Cache Breakdown) 的区别。穿透强调大量恶意请求根本不存在的数据,因为这个时候就好像没有缓存层,流量全部到 DB,影响整个系统;击穿就表示有缓存,但是热点 key 失效了,主要是针对的热点数据。穿透-DB无数据,击穿-DB有数据。

缓存穿透

访问一个 DB 和缓存中都不存在的 key 时,请求会直接打到 DB 上,并且因为查不到数据,没法建立缓存,下一次请求还会打到 DB 上。这个时候缓存就像被“穿透”了一样,起不到任何作用,每次请求都会打到 DB 就好像没有缓存一样,大量这样的请求可能导致 DB 挂掉。

对于系统,假设一秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。黑客发出的那 4000个攻击,缓存中查不到,每次你去数据库里查,也查不到,举个栗子。数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。

缓存穿透问题

解决方案

  1. 接口校验。在正常业务中可能存在少量访问不存在key的情况,但是一般不会出现大量的这种情况,所以这种场景发生的最大可能是遭受了非法攻击。可以在最外层加上验证:用户鉴权、数据合法性验证等。例如商品查询中,商品是正整数,则可以直接对非正整数进行过滤
  2. 缓存空值。当缓存和DB中都没有查询到值得时候,可以将空值写进缓存,但是设置比较短的过期时间,该时间需要根据产品业务特性来设置
  3. 布隆过滤器。使用布隆过滤器存储所有可能访问的 key,不存在的 key直接被过滤,存在的 key则进一步查询缓存和 DB

缓存空对象

例如有以下业务代码获取商品子分类:

1
2
3
4
5
6
7
8
9
10
const catsStr = await redis.get('subCats' + catId);
if (!catsStr) {
const list = await mysql.getSubCatList(catId);
// 注意判空逻辑
if (list && list.length > 0) {
await redis.set('subCats' + catId,JSON.stringify(list),30);
}
return list;
}
return JSON.parse(catsStr);

以上代码的问题在于:如果恶意用户发送的 catId 不合法,那么这些请求都会穿透缓存达到 MySQL。最简单的解决方案是直接将上述代码的判空条件去掉:

1
2
3
4
5
6
7
8
9
// 缓存空对象的方式解决缓存穿透问题
const catsStr = await redis.get('subCats' + catId);
if (!catsStr) {
const list = await mysql.getSubCatList(catId);
// 去掉判空逻辑
await redis.set('subCats' + catId,JSON.stringify(list), 30);
return list;
}
return JSON.parse(catsStr);

布隆过滤器

布隆过滤器的特点是:判断不存在的时候一定不存在,判断存在的时候大概率存在。(原理是使用多个桶中的多个哈希函数,因此一旦一个哈希函数计算不存在就一定不存在;反之,因为哈希存在冲突的可能,可能一个并不存在的值,经过计算和另一个存在桶中的值哈希相同)。

image.png

缓存击穿

缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个key在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

解决方案

  1. 加锁互斥。在并发的多个请求中,只有第一个请求能拿到锁并执行 DB 查询操作,其他线程拿不到锁就阻塞这,等到第一个线程将数据写入缓存后直接走缓存。互斥锁选择大部分是采用 redis 的分布式锁,这可以保证只有一个请求走到 DB,这是一种思路。但是仔细想想的话,其实没有必要严格保证只有一个请求走 DB,只要保证走到 DB 的请求大大降低即可,使用 JVM 锁其实已经足够了,同时性能比分布式锁更好
  2. 热点数据不过期。直接将缓存设置为不过期,由定时任务异步加载数据更新缓存。这种方式适用于比较极端的情况,例如流量特别大的场景。使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,不要到时候缓存刷新不上,一直是脏数据,那就凉了。
  3. 热点数据提前续租。在接近失效的时候主动延长其缓存时间。
  4. 限制并发访问。

缓存雪崩

对于一个系统,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。缓存雪崩的事前事中事后的解决方案如下。

  • 事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。
  • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
  • 事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

image.png

用户发送一个请求,系统收到请求后,先查本地 ehcache 缓存,如果没查到再查 redis。如果 ehcache 和 redis 都没有,再查数据库,将数据库中的结果,写入 ehcache 和 redis 中。

限流组件,可以设置每秒的请求,有多少能通过组件,剩余的未通过的请求,怎么办?走降级!可以返回一些默认的值,或者友情提示,或者空白的值。

这样做有以下几个好处:

  • 数据库绝对不会死,限流组件确保了每秒只有多少个请求能通过。
  • 只要数据库不死,就是说,对用户来说,2/5 的请求都是可以被处理的。
  • 只要有 2/5 的请求可以被处理,就意味着你的系统没死,对用户来说,可能就是点击几次刷不出来页面,但是多点几次,就可以刷出来一次。

综上所述,总结为以下几点:

  • 缓存层高可用
  • 客户端降级
  • 提前演练是解决雪崩问题的重要方法

大量的热点 key 设置了相同的过期时间,在同一时刻大量失效。缓存雪崩其实有点像“升级版的缓存击穿”。缓存击穿是一个热点 key,而缓存雪崩是一组热点 key。因此缓存击穿的解决方案也适用于缓存雪崩:

  1. 加互斥锁
  2. 热点数据不过期
  3. 过期时间分散
  4. 限流和降级

热点key重建优化

问题描述:热点 key + 较长的重建时间(执行了较长时间的SQL)

image.png

如图所示,并发高德时候大量线程进行了缓存的重建,客户端响应就会很慢,存储层的压力也比较大。解决方案有:

  • 互斥锁
  • 数据永不过期:为每个value添加逻辑过期时间,使用一个线程去重建缓存(用过互斥锁)

互斥锁

互斥锁保证了只有一个线程进行缓存重建的问题,但是会导致大量线程hang住的问题

数据永不过期

image.png

无底洞问题

这个问题产生于facebook,2010年的时候fb有了3000个memcached节点,加机器造成了性能不升反降的问题。

问题的关键在于节点非常多的时候,节点间通信的网络IO成本就不容忽视了。

数据结构和内部编码

image.png
image.png

1条redis命令在内存中执行的时间大概是100ns。

慢查询

redis的慢查询是采用固定长度的队列结构(List):

image.png

主要有2个配置:

image.png

slowlog-log-slower-than通常设置为1ms(默认是10ms),因为redis通常的QPS是万级别的,平均时间是0.1ms,队列长度也不要设置太小(1000是比较合理的),定期持久化慢查询结果,因为这样对于定位历史问题非常有帮助。

pipeline

image.png

将命令批量打包后发给服务器执行然后批量返回执行的结果。有2点需要注意:

  1. redis的命令执行时间通常是微秒级别
  2. pipeline中命令的条数要控制(网络)

bitmap

image.png
image.png

持久化

image.png

主从复制

主从复制的一个作用是读写分离(高性能),还能一定程度上实现高可用、数据备份。在生产环境中主从不能在一台机器上,因为丧失了高可用,没有任何意义。主要命令是slaveof,从节点不能做任何写操作:slave-read-only设置为yes主从复制没有真正做到故障的自动转移。master节点宕机之后处理起来比较麻烦,没有实现master宕机之后slave升级为master。解决这个问题redis提供了sentinel机制。

主从复制因为所有的从节点都是依赖主节点,所以受限于主节点的存储能力和写能力。

哨兵

image.png

主节点配置
redis-7000.conf

1
2
3
4
5
port 7000
daemonize yes
pidfile /var/run/redis-7000.pid
logfile "7000.log"
dir "./"

从节点配置7001,7002

1
2
3
4
5
sed "s/7000/7001/g" redis-7000.conf > redis-7001.conf
sed "s/7000/7002/g" redis-7000.conf > redis-7002.conf

echo "slaveof 127.0.0.1 7000" >> redis-7001.conf
echo "slaveof 127.0.0.1 7000" >> redis-7002.conf

启动主节点7000,两个从节点7001和7002:

image.png

编辑sentinel配置文件:

redis-sentinel-26379.conf

1
2
3
4
5
6
7
8
9
10
11
port 26379
dir "/tmp"
logfile "26379.log"
daemonize yes
sentinel monitor mymaster 127.0.0.1 7000 2
# Generated by CONFIG REWRITE
sentinel config-epoch mymaster 0
sentinel leader-epoch mymaster 0
sentinel known-slave mymaster 127.0.0.1 7002
sentinel known-slave mymaster 127.0.0.1 7001
sentinel current-epoch 0

测试sentinel26379节点

1
2
3
redis-server redis-sentinel-26379.conf --sentinel
redis-cli -p 26379 ping
PONG

配置sentinel 26380和26381节点:

1
2
sed "s/26379/26380/g" redis-sentinel-26379.conf > redis-sentinel-26380.conf
sed "s/26379/26381/g" redis-sentinel-26379.conf > redis-sentinel-26381.conf

image.png

演示master节点的故障转移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const util = require('util')
const Redis = require('ioredis')

const redis = new Redis({
sentinels: [
{host: "localhost", port: 26379},
{host: "localhost", port: 26380},
{host: "localhost", port: 26381},
],
name: "mymaster",
});

setInterval(async () => {
const i = await redis.incr('i')
util.log('get i', i)
}, 1000);

image.png
image.png

sentinel节点只是配置中心而不是代理,客户端连接到sentinel节点拿到master节点连接信息。

cluster

集群解决了单机QPS有限(10W QPS)和容量有限的问题。

数据分区

image.png

节点取余算法:基于客户端分片,节点伸缩的时候,大量数据(50%-80%)需要迁移(rehash)。
一致性哈希:memcached中广泛使用。优化取余,只影响邻近节点,但是还是有数据迁移。
虚拟槽分区:redis cluster采用。每一个槽映射一个数据子集,一般比节点数大;良好的hash函数,例如CRC16;服务端管理节点、槽和数据。

image.png

集群搭建

准备配置文件

配置文件redis-[7000-7005].conf一共6个配置文件,其中一个文件如下:

1
2
3
4
5
6
7
8
9
port 7000
daemonize yes
pidfile "/var/run/redis-7000.pid"
logfile "7000.log"
dir "./"
dbfilename "dump-7000.rdb"
cluster-enabled yes
cluster-config-file nodes-7000.conf
cluster-require-full-coverage no

启动6个节点(主节点7000-7002),然后随便连接一个客户端:

image.png

可以发现集群还不可用,需要分配槽:

image.png

节点握手meet

通过此操作,各个节点可以彼此感知。

image.png
image.png

分配槽

16384个槽分配到3个主节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
start=0
end=16384

startPort=7000
endPort=7002
step=5461
startSlot=0

for port in `seq ${startPort} ${endPort}`
do
endSlot=`expr ${startSlot} + ${step}`
for slot in `seq ${startSlot} ${endSlot}`
do
echo "port:${port},slot:${slot}"
#redis-cli -p ${port} cluster delslots ${slot}
redis-cli -p ${port} cluster addslots ${slot}
done
startSlot=`expr ${endSlot} + 1`
done

image.png
image.png

设置主从关系

设置7003为7000的从节点

image.png

用相同的命令设置7004和7005从节点:

image.png

接下来我们就可以从集群中读取和写入数据了:

image.png

key命中哪个slot是根据key算出crc16然后对16384取余得到,使用CLUSTER KEYSLOT hello可以查看key命中在哪个slot。

image.png

如果没有命中槽,服务器会返回move异常,这时候需要客户端重新连接目标节点发送命令。

生产环境中使用以下的配置错开配置来节省机器资源

1
2
3
4
# 主节点        从节点
10.0.0.1:7000 10.0.0.2:7003
10.0.0.2:7001 10.0.0.3:7004
10.0.0.3:7002 10.0.0.1:7005

redis官方提供了搭建集群的ruby脚本,redis5.0之后创建集群就变得非常容易

smart客户端

使用直连的方式而不是代理追求卓越性能

  1. 从集群中选择一个可以运行的节点,使用cluster slots初始化槽和节点映射
  2. 将cluster slots的结果映射到本地,为每个节点创建连接池
  3. 准备执行命令
  4. 注意兼容move异常对本地缓存进行刷新

因为执行命令的时候key必须在一个槽上,因此mget,mset这样的对多个key执行命令执行命令在cluster模式下执行就会报错:CROSSSLOT Keys in request don't hash to the same slot.

解决这个问题主要有以下几种方案:

  1. 将mget分解为多次get
  2. 将mget中的key按照crc分组,确保一组mget中的key落在一个slot上
  3. 使用hash tag将多个key落在同一个slot里面

hash_tag:当一个key包含 {} 的时候,就不对整个key做hash,而仅对 {} 包括的字符串做hash。

如何处理请求倾斜的问题:

  1. 避免big key
  2. 热键不要使用hash tag
  3. 当一致性要求不高的时候,可以使用本地缓存加MQ

集群模式下并不建议使用读写分离,因为从节点每次连接都需要执行readonly命令,读写分离并不能解决复制延迟、读取过期数据、从节点故障的问题,成本非常高。

分布式redis虽然解决了容量和性能的扩展性,但是很多业务其实“不需要”,它主要有以下的几个痛点:

  1. 客户端维护更复杂:SDK和应用本身的消耗(例如更多的连接池),客户端性能会降低
  2. lua,事务和有些命令无法跨节点使用:mget,keys,scan,flush,sinter等

很多场景下Sentinel其实已经足够好了,它解决了高可用的问题。

缓存设计和优化

缓存的成本:

  1. 数据不一致:缓存层和数据层有时间窗口不一致,和更新策略有关
  2. 代码维护成本:多了一层缓存维护逻辑
  3. 运维成本:例如 Redis Cluster

超时剔除和主动更新结合,最大内存和缓存淘汰策略兜底。

使用 Redis 实现限流

  1. 基于计数器和过期时间实现的计数器算法:使用一个计数器存储当前请求量(新请求使用 INCR),并设置一个过期时间,计数器在一段时间内自动清零。计数器未到达限流值的时候可以继续运行,反之拒绝。
  2. 基于 Zset 实现的滑动窗口算法:将请求都存入 ZSet 中,在 score 中存储请求时间。使用 zrange 方法可以轻松获取 2 个时间窗口内的请求量。
  3. 基于 List 实现的令牌桶算法:在程序中使用定时任务将令牌添加到 List 中,程序使用 lpop 成功获取令牌才进行放行。

场景题

百万数据存入 Redis 有哪些实现方案?

存储大量数据到 Redis 的方案

前置工作:预处理和压缩

  1. 数据预处理:例如去重、格式转换等,可以减少实际写入的数据量
  2. 数据压缩:可以考虑使用 Redis 的压缩功能(LZF,Snappy 等压缩算法)

插入方案

  1. 批处理:使用 pipeline允许客户端发送多个命令到服务器,而不需要等待每个命令的回复。这减少了网络延迟的影响,提高了写入速度;使用 MSET, HMSET 等批量操作命令
  2. 数据分片:集群模式下,数据可以分布在多个节点上,从而分散负载并提高写入吞吐量
  3. 使用 lua 脚本:将多个操作组合成一个原子操作,减少客户端与服务器之间的通信次数
  4. 异步加载:将一个大任务分成多个小任务,然后再通过异步加载的方式批量写入 Redis,这样可以避免阻塞主线程,提高应用的整体响应性。

优化建议

除了以上手段之外,我们还可以通过以下手段优化 Redis:

  1. 调整 Redis 配置参数:根据实际情况调整 Redis 的内存限制、持久化策略等参数,以提高性能和稳定性。
  2. 监控内存使用情况:使用 Redis 的监控工具,实时监控内存使用情况,避免内存溢出。

参考链接