redis的缓存设计

我们的系统引入redis之后虽然会带来很多收益,但是同时也会增加很多负担,所以这就需要我们对缓存根据需求进行一定的设计。

缓存引入带来的收益:

1.加速读写

2.降低后端负载

成本

1.一个窗口期内的数据不一致

2.代码有维护成本

3运维成本

下面就几个方面来说明缓存的设计

-------------------------------------------------------------------------------------

缓存更新策略

       缓存中的数据通常都是有生命周期的,需要在指定时间后被删除或更 新,这样可以保证缓存空间在一个可控的范围。但是缓存中的数据会和数据 源中的真实数据有一段时间窗口的不一致,需要利用某些策略进行更新。下 面将分别从使用场景、一致性、开发人员开发/维护成本三个方面介绍三种 缓存的更新策略。

1.LRU/LFU/FIFO算法剔除

使用场景。

剔除算法通常用于缓存使用量超过了预设的最大值时候,如 何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内 存最大值后对于数据的剔除策略。

一致性。要清理哪些数据是由具体算法决定,开发人员只能决定使用哪 种算法,所以数据的一致性是最差的。

维护成本。算法不需要开发人员自己来实现,通常只需要配置最大 maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择 适合自己的算法即可。

2.超时剔除

使用场景。超时剔除通过给缓存数据设置过期时间,让其在过期时间后 自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓 存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期 后,再从真实数据源获取数据,重新放到缓存并设置过期时间。

一致性。一段时间窗口内(取决于过期时间长短)存在一致性问题,即 缓存数据和真实数据源的数据不一致。

维护成本。维护成本不是很高,只需设置expire过期时间即可,当然前 提是应用方允许这段时间可能发生的数据不一致。

3.主动更新

使用场景。应用方对于数据的一致性要求高,需要在真实数据更新后, 立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。

一致性。一致性最高,但如果主动更新发生了问题,那么这条数据很可 能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。 维护成本。

维护成本会比较高,开发者需要自己来完成更新,并保证更 新操作的正确性。

有两个建议:

低一致性业务建议配置最大内存和淘汰策略的方式使用。

高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新 出了问题,也能保证数据过期时间后删除脏数据。

缓存粒度控制:

                                                                                                                                                                                                               举个简单例子,假如我们要查询 用户信息。用select * from user_info wher id=..

我们可以选择把全部数据存入redis,也可以选择把部分字段存入redis                                                                                                                                                                                                          

缓存粒度问题是一个容易被忽视的问题,如果使用不当,可能会造成很 多无用空间的浪费,网络带宽的浪费,代码通用性较差等情况,需要综合数 据通用性、空间占用比、代码维护性三点进行取舍

数据类型 通用性 空间 代码维护
全部数据 简单
部分数据 相对复杂

                 

 缓存穿透预防                                                                                               

    缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命 中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层,整个过程分为如下3步:、

1)缓存层不命中

 2)存储层不命中,不将空结果写回缓存。

3)返回空结果。              

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓 存保护后端存储的意义。 缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高 并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用 数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是 出现了缓存穿透问题。 造成缓存穿透的基本原因有两个。第一,自身业务代码或者数据出现问 题,第二,一些恶意攻击、爬虫等造成大量空命中。

业界有两种方案解决缓存穿透问题:

缓存空对象

      当从mysql中没有查询到数据时候,可以返回一个null,存储在redis中。

      缓存空对象会有两个问题:

    第一,空值做了缓存,意味着缓存层中存了 更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的 方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

    第二,缓存 层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。 例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间 就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方 式清除掉缓存层中的空对象

使用布隆过滤器

在访问缓存层和存储层之前,将存在的key用布隆过滤 器提前保存起来,做第一层拦截。

关于布隆过滤器,可以查看我Hbase相关的文章。

 防雪崩优化

        由于缓存层承载着大量请求,有效地 保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请 求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情 况。        

    预防和解决缓存雪崩问题,业界有两种解决方案、

1.保证缓存层的高可用。常见的方法就是主从备份

2.依赖隔离组件为后端限流并降级。

无底洞优化

redis的集群模式会导致键值数据库由于通常采用哈希函数将 key映射到各个节点上,造成key的分布与业务无关,但是由于数据量和访问 量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的 节点上,所以无论是Memcache还是Redis的分布式,批量操作通常需要从不 同节点上获取,相比于单机批量操作只涉及一次网络操作,分布式批量操作 会涉及多次网络时间。

用一句通俗的话总结就是,更多的节点不代表更高的性能,所谓“无底 洞”就是说投入越多不一定产出越多。但是分布式又是不可以避免的,因为 访问量和数据量越来越大,一个节点根本抗不住,所以如何高效地在分布式 缓存中批量操作是一个难点。

 

串行命令

     由于n个key是比较均匀地分布在Redis Cluster的各个节点上,因此无法 使用mget命令一次性获取,所以通常来讲要获取n个key的值,最简单的方法 就是逐次执行n个get命令,这种操作时间复杂度较高,它的操作时间=n次络时间+n次命令时间,网络次数是n。很显然这种方案不是最优的,但是实 现起来比较简单

List serialMGet(List keys) {
 // 结果集
 List values = new ArrayList();
 // n次串行get
 for (String key : keys) {
 String value = jedisCluster.get(key);
 values.add(value);
 }
 return values;
}

串行IO

 

Redis Cluster使用CRC16算法计算出散列值,再取对16383的余数就可以  算出slot值,同时Smart客户端会保存slot和节点的对应关 系,有了这两个数据就可以将属于同一个节点的key进行归档,得到每个节 点的key子列表,之后对每个节点执行mget或者Pipeline操作,它的操作时间 =node次网络时间+n次命令时间,网络次数是node的个数,很明显这种方案比第一种要好很多,但是如果节点数太多,还是有 一定的性能问题

 Map serialIOMget(List keys) {
        // 结果集
        Map keyValueMap = new HashMap();
        // 属于各个节点的key列表,JedisPool要提供基于ip和port的hashcode方法
        Map> nodeKeyListMap = new HashMap>();
        // 遍历所有的key
        for (String key : keys) {
            // 使用CRC16本地计算每个key的slot
            int slot = JedisClusterCRC16.getSlot(key);
            // 通过jedisCluster本地slot->node映射获取slot对应的node
            JedisPool jedisPool = jedisCluster.getConnectionHandler().getJedisPoolFrom
            Slot(slot);
            // 归档
            if (nodeKeyListMap.containsKey(jedisPool)) {
                nodeKeyListMap.get(jedisPool).add(key);
            } else {
                List list = new ArrayList();
                list.add(key);
                nodeKeyListMap.put(jedisPool, list);
            }
        }
        // 从每个节点上批量获取,这里使用mget也可以使用pipeline
        for (Entry> entry : nodeKeyListMap.entrySet()) {
            JedisPool jedisPool = entry.getKey();
            List nodeKeyList = entry.getValue();
            // 列表变为数组
            String[] nodeKeyArray = nodeKeyList.toArray(new String[nodeKeyList.size()]);
            // 批量获取,可以使用mget或者Pipeline
            List nodeValueList = jedisPool.getResource().mget(nodeKeyArray);
            // 归档
            for (int i = 0; i < nodeKeyList.size(); i++) {
                keyValueMap.put(nodeKeyList.get(i), nodeValueList.get(i));
            }
        }
        return keyValueMap;
    }

 

热点Key重建和优化

       使用“缓存+过期时间”的策略既可以加速数据读写,又保证数 据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果 同时出现,可能就会对应用造成致命的危害:

      当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常 大。 ·重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次IO、多个依赖等。 在缓存失效的瞬间,有大量线程来重建缓存(如图11-16所示),造成 后端负载加大,甚至可能会让应用崩溃。 要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来 更多的麻烦,所以需要制定如下目标: ·减少重建缓存的次数。

1.互斥锁(mutex key) 此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行 完,重新从缓存获取数据即可

String get(String key) {
        // 从Redis中获取数据
        String value = redis.get(key);
        // 如果value为空,则开始重构缓存
        if (value == null) {
            // 只允许一个线程重构缓存,使用nx,并设置过期时间ex
            String mutexKey = "mutext:key:" + key;
            if (redis.set(mutexKey, "1", "ex 180", "nx")) {
                // 从数据源获取数据
                value = db.get(key);
                // 回写Redis,并设置过期时间
                redis.setex(key, timeout, value);
                // 删除key_mutex
                redis.delete(mutexKey);
            }
            // 其他线程休息50毫秒后重试
            else {
                Thread.sleep(50);
                get(key);
            }
        }
        return value;
    }

2.永远不过期

“永远不过期”包含两层意思: ·从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期 后产生的问题,也就是“物理”不过期。 ·从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻 辑过期时间后,会使用单独的线程去构建缓存

 String get(final String key) {
        V v = redis.get(key);
        String value = v.getValue();
        // 逻辑过期时间
        long logicTimeout = v.getLogicTimeout();
        // 如果逻辑过期时间小于当前时间,开始后台构建
        if (v.logicTimeout <= System.currentTimeMillis()) {
            String mutexKey = "mutex:key:" + key;
            if (redis.set(mutexKey, "1", "ex 180", "nx")) {
                // 重构缓存
                threadPool.execute(new Runnable() {
                    public void run() {
                        String dbValue = db.get(key);
                        redis.set(key, (dbvalue,newLogicTimeout));
                        redis.delete(mutexKey);
                    }
                });
            }
        }
        return value;
    }

·互斥锁(mutex key):这种方案思路比较简单,但是存在一定的隐 患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻 塞的风险,但是这种方法能够较好地降低后端存储负载,并在一致性上做得 比较好。

·“永远不过期”:这种方案由于没有设置真正的过期时间,实际上已经 不存在热点key产生的一系列危害,但是会存在数据不一致的情况,同时代 码复杂度会增大。

你可能感兴趣的:(java)