Redis缓存设计与性能优化

172 阅读11分钟

常见问题

缓存击穿

由于大批量缓存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大 甚至挂掉,所以在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。

private Integer genProductCacheTimeout() {
    return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(5) * 60 * 60;
}

缓存穿透

缓存穿透是指查询一个根本不存在的数据, 缓存层和存储层都不会命中。 缓存穿透将导致不存在的数据每次请求都要到存储层(数据库)去查询, 失去了缓存保护后端存储的意义。

造成缓存穿透的基本原因有两个:

第一, 自身业务代码或者数据出现问题(数据库中数据被删除)。

第二, 一些恶意攻击、 爬虫等造成大量空命中(恶意攻击不存在的数据)。

解决办法

  • 缓存空对象,加上过期时间
  • 布隆过滤器

缓存雪崩

缓存雪崩指的是缓存层支撑不住或宕掉后, 流量会全部打向后端存储层。 造成存储层也会级联宕机的情况。

预防和解决缓存雪崩问题, 可以从以下三个方面进行着手。

1) 保证缓存层服务高可用性,比如使用Redis SentinelRedis Cluster

2) 依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件。

当业务应用访问的是非核心数据(例如电商商 品属性,用户信息等)时,直接返回预定义的默认降级信息、空值或是 错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失, 也可以继续通过数据库读取。

3) 提前演练。 在项目上线前, 演练缓存层宕掉后, 应用以及后端的负载情况以及可能出现的问题, 在此基 础上做一些预案设定。

冷门数据突发流量(热点key重建)

冷门数据没有缓存,高并发流量同时请求到数据库,重建索引,造成数据库挂掉

加分布式锁拦截流量,只允许一个线程执行加锁逻辑(对同一个商品)

//加锁,其他线程等待
RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
hotCacheLock.lock();
//3s内加锁成功返回true,没成功返回false;3s后线程1已经建立索引,其他线程直接查缓存返回数据(省略每个线程的加锁和解锁逻辑,提升效率),存在问题:3s后没成功建立索引,缓存击穿
//boolean result = hotCacheLock.tryLock(3, TimeUnit.SECONDS);
try {
    if(cache != null){
        return cache
    }
    //业务逻辑,重建索引
    
} finally {
    //解锁
    hotCacheLock.unlock();
}

缓存、数据库双写不一致

双写不一致

image.png

最后的结果数据库里是10,缓存是6

读写不一致

image.png

线程3在查完数据库10,更新缓存时卡了一下,线程2写数据库6,并删除缓存;线程3把10更新到了缓存,出现读写不一致问题

解决方案:

  1. 对应并发量小的数据,几乎不存在;可以设置过期时间,每隔一段时间触发读的主动更新即可。
  2. 就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期 时间依然可以解决大部分业务对于缓存的要求。
  3. 如果不能容忍缓存数据不一致,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队,读读的 时候相当于无锁。
  4. 也可以用阿里开源的canal通过监听数据库的binlog日志及时的去修改缓存,但是引入了新的中间件,增加 了系统的复杂度

分布式读写锁

适用于读多写少场景 使用读写锁,读操作可以并行执行,加快效率;读写写写会互斥

RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
//RLock wLock = readWriteLock.writeLock(); //写锁
//wLock.lock();
RLock rLock = readWriteLock.readLock(); //读锁
rLock.lock();
try {
    product = productDao.get(productId);
    if (product != null) {
        redisUtil.set(productCacheKey, JSON.toJSONString(product),
                genProductCacheTimeout(), TimeUnit.SECONDS);
        //多级缓存,放到JVM内存(只存储热点中的热点,实时计算系统),问题:容量有限,多台机器间存在缓存不一致问题(可通过消息队列同步消息);
        productMap.put(productCacheKey, product);      
    } else {
        redisUtil.set(productCacheKey, EMPTY_CACHE, genEmptyCacheTimeout(), TimeUnit.SECONDS);
    }
} finally {
    rLock.unlock();
}

写锁加锁逻辑

image.png 判断hash中mode=='write',如果不存在写锁,就设置锁,返回nil,如果存在写锁,执行重入锁逻辑,次数+1,并重新设置超时时间,返回剩余超时时间

image.png

总结

以上我们针对的都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那 就没必要加缓存了,可以直接操作数据库。

当然,如果数据库抗不住压力,还可以把缓存作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。

放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一 致性做大量的过度设计和控制,增加系统复杂性!

多级缓存

image.png

开发规范

一、键值设计

1、key名设计

  • 以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id(trade:order:1
  • 保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视
  • 不要包含特殊字符(含空格、换行、单双引号以及其他转义字符)

2、value设计 (1) 拒绝bigkey(防止占满网卡流量、慢查询) - 字符串类型:它的big体现在单个value值很大,一般认为超过10KB就是bigkey(最大512M) - 非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多(不超过5000个)

非字符串的bigkey,不要使用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del操作,造 成阻塞) bigkey的危害

  • 导致redis阻塞(redis操作是单线程)
  • 网络拥塞(bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问 量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务 器来说简直是灭顶之灾)
  • 过期删除(如果没有使用Redis 4.0的过期异步删除(lazyfree-lazy-expire yes),就会存在阻塞Redis的可能性。)

如何优化bigkey

  • 拆(比如一个大的key,假设存了1百万的用户数据,可以拆分成 200个key,每个key下面存放5000个用户数据)
  • 思考一下要不要每次把所有元素都取出来(用hmget而不是hgetall)

(2)选择适合的数据类型。

(3) 控制key的生命周期(设置过期时间),redis不是垃圾桶

二、命令使用

1.【推荐】 O(N)命令关注N的数量

例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有 遍历的需求可以使用hscan、sscan、zscan代替。

2.【推荐】:禁用命令 禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的 方式渐进式处理。

3.【推荐】合理使用select redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还 是单线程处理,会有干扰,也会影响业务间性能。

4.【推荐】使用批量操作提高效率 1 原生命令:例如mget、mset。 2 非原生命令:可以使用pipeline提高效率。 但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)。

注意两者不同:

  1. 原生命令是原子操作,pipeline是非原子操作。
  2. pipeline可以打包不同的命令,原生命令做不到
  3. pipeline需要客户端和服务端同时支持。

5.【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代

三、客户端使用

使用带有连接池的数据库,不用频繁创建和释放连接,可以有效控制连接,同时提高效率 连接池常用参数设置

maxTotal:最大连接数,

根据系统节点数和期望并发量估算具体设置多少
1. nodes * maxTotal不能超过redis的maxclients
2. 一次命令时间(borrow|return resource + Jedis执行命令(含网络) )的平均耗时约为 1ms,
一个连接的QPS大约是1000,业务期望的QPS是50000,那理论上需要的资源池大小是50000 / 1000 = 50个,
实际要比理论值大一倍

maxIdle:实际是业务需要的最大连接数,maxTotal是为了给出余量
假设maxIdle=20,超过20的空闲连接要释放掉

连接池的最佳性能是maxTotal = maxIdle,这样就避免连接池伸缩带来的性能干扰。
但是如果 并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。
高并发场景下,一般推荐maxIdle可以设置 为按上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍



minIdle(最小空闲连接数),
假设minIdle=10,最好要保留10个空闲连接

"至少需要保持的空闲连接数",在使用连接的过程中,如果连接数超过了minIdle,那么继续建立连接
如果超过了 maxIdle,当超过的连接执行完业务后会慢慢被移出连接池释放掉。

连接池预热,防止服务重启后,高并发场景下,突然创建大量redis连接

高并发下建议客户端添加熔断功能(例如sentinel、hystrix)

设置合理的密码,如有必要可以使用SSL加密访问

Redis缓存过期策略

  1. 被动(惰性)删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期 key
  2. 主动删除:由于惰性删除策略无法保证冷数据被及时删掉(没有人访问过期key),所以Redis会定期主动淘汰一 批已过期的key
  3. 当前已用内存超过maxmemory限定时,触发主动清理策略

主动删除策略共有8种

a) 针对设置了过期时间的key做处理:

  1. volatile-ttl:在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删 除,越早过期的越先被删除。
  2. volatile-random:就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
  3. volatile-lru:会使用 LRU 算法筛选设置了过期时间的键值对删除。
  4. volatile-lfu:会使用 LFU 算法筛选设置了过期时间的键值对删除。

b) 针对所有的key做处理:

  1. allkeys-random:从所有键值对中随机选择并删除数据。
  2. allkeys-lru:使用 LRU 算法在所有数据中进行筛选删除。
  3. allkeys-lfu:使用 LFU 算法在所有数据中进行筛选删除。

c) 不处理:

  1. noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息"(error) OOM command not allowed when used memory",此时Redis只响应读操作。

LRU 算法(Least Recently Used,最近最少使用)

淘汰很久没被访问过的数据,以最近一次访问时间作为参考。

LFU 算法(Least Frequently Used,最不经常使用)

淘汰最近一段时间被访问次数最少的数据,以次数作为参考。 当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。这时使用LFU可能更好点。

根据自身业务类型,配置好maxmemory-policy(默认是noeviction),推荐使用volatile-lru。如 果不设置最大内存,当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交 换 (swap),会让 Redis 的性能急剧下降。 当Redis运行在主从模式时,只有主结点才会执行过期删除策略,然后把删除操作”del key”同 步到从结点删除数据。

布隆过滤器