Redis+Guava实现高性能的二级缓存

39 篇文章 6 订阅
12 篇文章 0 订阅

1.前言

我们开发中经常用到 Redis 作为缓存,将高频数据放在 Redis 中能够提高业务性能,降低 MySQL 等关系型数据库压力,甚至一些系统使用 Redis 进行数据持久化,Redis 松散的文档结构非常适合业务系统开发,在精确查询,数据统计业务有着很大的优势。

同时我们在处理redis的热key时,最常用的解决方案便是通过本地缓存+redis缓存的方式,当然本地缓存的实现有很多,如:caffeine,ehcache,guava等,这样的话就可以将缓存的数据直接读到本地缓存了,本文通过google的guava来实现二级缓存。

很多人读到此处可能就在想我们直接使用ConcurrentMap 直接进行缓存不也一样吗,因为都是直接操作内存,ConcurrentMap 做的又是如此优秀,而且是线程安全的。

Guava Cache 是其中的一个专门用于处理本地缓存的轻量级框架,是全内存方式的本地缓存,而且是线程安全的。

和 ConcurrentMap 相比,Guava Cache 可以限制内存的占用,并可设置缓存的过期时间,可以自动回收数据,而 ConcurrentMap 只能通过静态方式来控制缓存,移除数据元素需要显示的方式来移除。

在这里插入图片描述

2.Redis 懒加载缓存设计

数据在新增到 MySQL 不进行缓存,在精确查找进行缓存,做到查询即缓存,不查询不缓存。

传统设计架构下,我们数据的加载流程如下:
在这里插入图片描述

即先去查看缓存,若缓存中没有数据则去访问数据库,同时将数据更新至缓存

在这种场景下

优点如下:

  • 保证最小的缓存量满足精确查询业务,避免冷数据占用宝贵的内存空间
  • 对增删改查业务入侵小、删除即同步
  • 可插拔,对于老系统升级,历史数据无需在启动时初始化缓存

缺点如下:

  • 数据量需可控,在无限增长业务场景不适用
  • 在微服务场景不利于全局缓存应用

总结:

  • 空间最小化
  • 满足精确查询场景
  • 总数据量可控推荐使用
  • 微服务场景不适用

3.Redis 结合本地缓存实现热点数据下的缓存的高可用

微服务场景下,多个微服务使用一个大缓存,流数据业务下,高频读取缓存对 Redis 压力很大,我们使用本地缓存结合 Redis 缓存使用,降低 Redis 压力,同时本地缓存没有连接开销,性能更优。

最典型的就是明星的绯闻,吃瓜群众纷纷去留言,就很容易导致评论的这个功能崩溃,这些短时间内,某些key访问量过大,然后请求到同一台数据分片上,最终会导致该分片不堪重负,最终导致缓存雪崩,进而引发我们的服务雪崩

怎么检测热key呢?

常规呢就是根据业务以往的数据做一个判定,比如说我们的促销活动,商品秒杀,热门话题等,我们可以写一个方法去判定如果见到某个key的QPS(每秒访问量)达到了1000,此时我们就要注意了,这个key很有可能就是个热key

所以为了解决以上问题,引入了本地缓存机制来实现一个二级缓存机制,把热点数据放到我们JVM的本地缓存中,本地缓存可以使用caffeine,ehcache,guava。本文则是通过guava进行实现的。

流程图如下:
在这里插入图片描述

所以总体架构设计如下:

如果是redis的单点设计,那么没什么好讨论的,但是如果是通过redis集群来实现的,我们可以通过一致性hash算法构建一个hash环,主要是防止某一个或者某些个redis结点宕机或者下线而导致数据丢失的问题以及我们后面动态进行扩容的问题。

ps:我个人还是比较推崇一致性hash算法

一致性hash是指将 “存储节点” 和 “数据” 都映射到一个首尾相连的hash环上。如果增删节点,仅影响该节点在hash环上顺时针相邻的后继节点,其他数据不会受到影响。
大家可以去读一下《千万级并发架构下,如何进行关系型数据库的分库分表》第五章,可以跳过前面的内容,直接去读一下第五章

假如:我们redis集群里有3个结点,分别是:192.168.1.2:3069、192.168.1.2:3070、192.168.1.2:3071,那么这样的话我们可以通过一致性hash算法将其计算为一个个Hash环上的结点。

一致性hash算法的实现如下:

import java.util.LinkedList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;

public class ConsistentHashingWithVirtualNode {

    //待添加入Hash环的服务器列表
    private static final String[] servers = {"192.168.1.2:6379", "192.168.1.2:6380"};


    //key表示服务器的hash值,value表示服务器
    private static SortedMap<Integer, String> sortedMap = new TreeMap<Integer, String>();

    //程序初始化,将所有的服务器放入sortedMap中
    static {
        for (int i = 0; i < servers.length; i++) {
            int hash = getHash(servers[i]);
            System.out.println("[" + servers[i] + "]" + "加入集合中, 其Hash值为" + hash);
            sortedMap.put(hash, servers[i]);
        }
        System.out.println();
    }

    //得到应当路由到的结点
    public static String getServer(String key) {
        //得到该key的hash值
        int hash = getHash(key);
        //得到大于该Hash值的所有Map
        SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
        if (subMap.isEmpty()) {
            //如果没有比该key的hash值大的,则从第一个node开始
            Integer i = sortedMap.firstKey();
            //返回对应的服务器
            return sortedMap.get(i);
        } else {
            //第一个Key就是顺时针过去离node最近的那个结点
            Integer i = subMap.firstKey();
            //返回对应的服务器
            return subMap.get(i);
        }
    }

    //使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别
    private static int getHash(String str) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        // 如果算出来的值为负数则取其绝对值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }

    public static void main(String[] args) {
        String[] keys = {"userName", "userName1111", "asasass"};
        for (int i = 0; i < keys.length; i++)
            System.out.println("[" + keys[i] + "]" + "的hash值为" + getHash(keys[i])
                    + ",被路由到结点[" + getServer(keys[i]) + "]");
    }
}

在选择具体的redis结点时可以通过key计算出hash值,通过key的hash值顺序查找redis服务,从而实现流量的均摊。

下面我们导入redis依赖

   <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-redis</artifactId>
            <version>1.4.7.RELEASE</version>
        </dependency>

然后完成一个工具类来实现redis的一些相应的操作

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

import java.time.Duration;

public class RedisPoolConfig {

    private static JedisPoolConfig getPoolConfig() {
        JedisPoolConfig config = new JedisPoolConfig();
        //连接池最大阻塞等待时间(使用负值表示没有限制)
        config.setMaxWait(Duration.ofDays(-1));
        config.setMaxTotal(50);//设置最大连接数
        //连接池中的最大空闲连接
        config.setMaxIdle(10);
        //连接池中的最小空闲连接
        config.setMinIdle(0);
        return config;
    }

    /**
     * 获取redis连接对象
     *
     * @param url
     * @param port
     * @param password
     * @return
     */
    public static Jedis getJedis(String url, Integer port, String password) {
        //1.创建Jedis连接池对象
        JedisPool jedisPool = new JedisPool(getPoolConfig(), url, port);
        //2.获取连接
        Jedis jedis = jedisPool.getResource();
        jedis.auth(password);
        return jedis;
    }

}

我们的整体逻辑就是请求过来,然后向本地缓存去找数据,如果没有便更新本地缓存,值得我们注意的是,一旦有更新或者修改操作时,为了保证数据的缓存一致性,要更新数据库在去更新缓存。

添加guava依赖:

  <!--guava本地缓存依赖包-->
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>30.1.1-jre</version>
        </dependency>

获取本地缓存:

import com.example.secondcache.utils.ConsistentHashingWithVirtualNode;
import com.google.common.cache.*;
import org.springframework.util.StringUtils;
import redis.clients.jedis.Jedis;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

public class GuavaLocalCache {
    /**
     * 本地缓存
     */
    private static Cache<String, String> localCache = CacheBuilder.newBuilder()
            .concurrencyLevel(16) // 并发级别
            .initialCapacity(1000) // 初始容量
            .maximumSize(10000) // 缓存最大长度
            .expireAfterAccess(1, TimeUnit.HOURS) // 缓存1小时没被使用就过期
            .build();

    public static String loadString(String key) throws ExecutionException {
        String ans = localCache.get(key, new Callable<String>() {//如果本地缓存没有数据,便从redis缓存中拉取数据
            @Override
            public String call() throws Exception {
                String ipAndPort = ConsistentHashingWithVirtualNode.getServer(key);//通过key来决定访问哪一个redis结点
                String[] ipAndPorts = ipAndPort.split(":");
                String ip = ipAndPorts[0];
                String port = ipAndPorts[1];
                //从redis中获取值
                Jedis jedis = RedisPoolConfig.getJedis(ip, Integer.valueOf(port), null);
                System.out.println("本地缓存未获取到数据,需要从redis缓存中获取");
                return jedis.get(key);
            }
        });
        return ans;
    }

}

Callable的作用便是若本地缓存才会执行的操作。
此处我们按照我们的设计思路,即本地缓存无法获取的话便从redis缓存中获取

我们写个接口进行测试:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import redis.clients.jedis.Jedis;

import java.util.concurrent.ExecutionException;

@RestController
public class TestController {
    @GetMapping("add")
    public Object addKey(@RequestParam(value = "key") String key,
                         @RequestParam(value = "answer") String answer) {
        String ipAndPort = ConsistentHashingWithVirtualNode.getServer(key);
        String[] ip_port = ipAndPort.split(":");
        Jedis jedis = RedisPoolConfig.getJedis(ip_port[0], Integer.valueOf(ip_port[1]), null);
        jedis.set(key, answer);
        return "已将数据放至ip:" + ip_port[0] + "-port:" + ip_port[1];
    }

    @GetMapping("get")
    public String getKey(@RequestParam(value = "key") String key) throws ExecutionException {
        String val = GuavaLocalCache.loadString(key);
        return val;

    }
}

先访问:http://localhost:8080/add?key=userName&answer=ninesun
在这里插入图片描述

在访问:http://localhost:8080/get?key=userName
我们第一次访问,由于二级缓存中也就是我们的本地缓存中并没有数据,所以一定会从redis中获取
在这里插入图片描述

在访问一次http://localhost:8080/get?key=userName,就会发现控制台已经不再打印从redis中获取数据的提示。

4.缓存数据一致性的问题

多级缓存有一个比较大的缺陷,便是缓存一致性,解决缓存一致性的思路便是,当有update或者delete操作时,先更新数据库,再删除缓存中的数据,至于为何这样设计,可以看一下:《如何保证数据库和缓存双写一致性》

4.1 guava的数据回收策略

对于guava的数据删除分为被动移除主动移除两种

被动移除

基于大小的移除

看字面意思就知道就是按照缓存的大小来移除,如果即将到达指定的大小,那就会把不常用的键值对从cache中移除。

guava提供了两个基于时间移除的方法:

  • expireAfterAccess(long, TimeUnit) 这个方法是根据某个键值对最后一次访问之后多少时间后移除
  • expireAfterWrite(long, TimeUnit) 缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的
基于引用的移除

这种移除方式主要是基于java的垃圾回收机制,根据键或者值的引用关系决定移除。

主动移除数据

主动移除有三种方法:

  • 单独移除 Cache.invalidate(key)
  • 批量移除 Cache.invalidateAll(keys)
  • 移除所有 Cache.invalidateAll()

我们呢肯定不能依赖于被动的移除,只能靠主动删除

  • 3
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
二级缓存是指在应用程序中同时使用两种不同的缓存技术,通常是将本地缓存和分布式缓存结合使用,以提高缓存的效率和可靠性。GuavaRedis都是常用的缓存库,下面介绍它们如何实现二级缓存。 1. Guava实现二级缓存 Guava是一个开源的Java工具库,其中包含了许多常用的工具类和数据结构,包括本地缓存Guava本地缓存是指将数据存储在应用程序内存中的缓存,可以用于提高应用程序的性能和响应速度。但是,本地缓存的生命周期受到应用程序的生命周期限制,一旦应用程序结束,缓存中的数据也就不存在了。为了解决这个问题,我们可以将Guava本地缓存和分布式缓存结合使用,实现二级缓存。 具体实现方法如下: 1)创建Guava本地缓存 ```java LoadingCache<String, Object> localCache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<String, Object>() { @Override public Object load(String key) throws Exception { // 从数据库或其他数据源中加载数据 return loadDataFromDatabase(key); } }); ``` 2)创建Redis分布式缓存 ```java JedisPool jedisPool = new JedisPool("localhost", 6379); Jedis jedis = jedisPool.getResource(); ``` 3)在应用程序中使用二级缓存 ```java public Object getObject(String key) { Object value = null; try { // 先从本地缓存中获取数据 value = localCache.get(key); } catch (Exception e) { e.printStackTrace(); } if (value == null) { // 如果本地缓存中没有数据,则从Redis缓存中获取数据 byte[] bytes = jedis.get(key.getBytes()); if (bytes != null) { value = SerializationUtils.deserialize(bytes); // 将数据存储到本地缓存中 localCache.put(key, value); } } if (value == null) { // 如果Redis缓存中也没有数据,则从数据库或其他数据源中加载数据 value = loadDataFromDatabase(key); // 将数据存储到Redis缓存和本地缓存中 byte[] bytes = SerializationUtils.serialize(value); jedis.set(key.getBytes(), bytes); localCache.put(key, value); } return value; } ``` 2. Redis实现二级缓存 Redis是一个开源的内存数据库,可以用于存储和管理缓存数据Redis分布式缓存的优点是可以存储大量的数据,并且可以跨多个应用程序共享数据,但是它的缺点是需要额外的硬件和网络资源来支持,同时也存在单点故障的风险。为了解决这个问题,我们可以将Redis缓存和本地缓存结合使用,实现二级缓存。 具体实现方法如下: 1)创建Redis缓存客户端 ```java JedisPool jedisPool = new JedisPool("localhost", 6379); Jedis jedis = jedisPool.getResource(); ``` 2)创建Guava本地缓存 ```java LoadingCache<String, Object> localCache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<String, Object>() { @Override public Object load(String key) throws Exception { // 从Redis缓存中加载数据 byte[] bytes = jedis.get(key.getBytes()); if (bytes != null) { return SerializationUtils.deserialize(bytes); } // 如果Redis缓存中没有数据,则从数据库或其他数据源中加载数据 return loadDataFromDatabase(key); } }); ``` 3)在应用程序中使用二级缓存 ```java public Object getObject(String key) { Object value = null; try { // 先从本地缓存中获取数据 value = localCache.get(key); } catch (Exception e) { e.printStackTrace(); } if (value == null) { // 如果本地缓存中没有数据,则从Redis缓存中获取数据 byte[] bytes = jedis.get(key.getBytes()); if (bytes != null) { value = SerializationUtils.deserialize(bytes); // 将数据存储到本地缓存中 localCache.put(key, value); } } if (value == null) { // 如果Redis缓存中也没有数据,则从数据库或其他数据源中加载数据 value = loadDataFromDatabase(key); // 将数据存储到Redis缓存和本地缓存中 byte[] bytes = SerializationUtils.serialize(value); jedis.set(key.getBytes(), bytes); localCache.put(key, value); } return value; } ``` 以上就是GuavaRedis实现二级缓存的方法。需要注意的是,二级缓存实现需要综合考虑应用程序的性能、复杂度、可靠性和安全性等方面的因素,选择合适的缓存技术和策略,才能达到最优的效果。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZNineSun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值