缓存穿透

访问一个 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. 热点数据提前续租。在接近失效的时候主动延长其缓存时间。

缓存雪崩

对于一个系统,假设每天高峰期每秒 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

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

参考链接