缓存穿透

对于系统,假设一秒5000个请求,结果其中4000个请求是黑客发出的恶意攻击。黑客发出的那4000个攻击,缓存中查不到,每次你去数据库里查,也查不到。

举个栗子。数据库id是从1开始的,结果黑客发过来的请求id全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。

image.png
image.png

如何发现缓存穿透问题

  1. 业务响应时间
  2. 业务本身问题
  3. 相关指标:总调用数、缓存层命中数、存储层命中数

解决方案

缓存空对象

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

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。最简单的解决方案是:可以直接将上述代码的判空条件去掉:

// 缓存空对象的方式解决缓存穿透问题
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
image.png

使用很小的内存实现对数据的过滤,解决了例如从10亿个电话号码中判断某个电话号码是否存在的问题。布隆过滤器对于变动很小的数据是比较友好的。

缓存雪崩

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

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

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

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

这样做有以下几个好处:

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

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

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

缓存击穿

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

解决方式也很简单,可以将热点数据设置为永远不过期;或者基于 redis or zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据。

热点key重建优化

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

image.png
image.png

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

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

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

数据永不过期
数据永不过期
image.png
image.png

无底洞问题

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

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

数据结构和内部编码

image.png
image.png
image.png
image.png

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

慢查询

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

image.png
image.png

主要有2个配置:

image.png
image.png

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

pipeline

image.png
image.png

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

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

bitmap

image.png
image.png
image.png
image.png

持久化

image.png
image.png

主从复制

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

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

哨兵

image.png
image.png

主节点配置
redis-7000.conf

port 7000
daemonize yes
pidfile /var/run/redis-7000.pid
logfile "7000.log"
dir "./"

从节点配置7001,7002

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
image.png

编辑sentinel配置文件:

redis-sentinel-26379.conf

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节点

redis-server redis-sentinel-26379.conf --sentinel
redis-cli -p 26379 ping
PONG

配置sentinel 26380和26381节点:

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
image.png

演示master节点的故障转移:

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
image.png
image.png

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

cluster

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

数据分区

image.png
image.png

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

image.png
image.png

集群搭建

准备配置文件

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

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

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

image.png
image.png

节点握手meet

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

image.png
image.png
image.png
image.png

分配槽

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

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
image.png
image.png

设置主从关系

设置7003为7000的从节点

image.png
image.png

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

image.png
image.png

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

image.png
image.png

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

image.png
image.png

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

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

# 主节点        从节点
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

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

参考链接