《优化接口设计的思路》系列:第九篇—用好缓存,让你的接口速度飞起来

时间:2024-03-18 22:20:20

一、前言

大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

作为一名从业已达六年的老码农,我的工作主要是开发后端Java业务系统,包括各种管理后台和小程序等。在这些项目中,我设计过单/多租户体系系统,对接过许多开放平台,也搞过消息中心这类较为复杂的应用,但幸运的是,我至今还没有遇到过线上系统由于代码崩溃导致资损的情况。这其中的原因有三点:一是业务系统本身并不复杂;二是我一直遵循某大厂代码规约,在开发过程中尽可能按规约编写代码;三是经过多年的开发经验积累,我成为了一名熟练工,掌握了一些实用的技巧。

前面的文章都是写接口如何设计、接口怎么验权以及一些接口常用的组件,这篇写点接口性能相关的。接口性能优化有很多途径,比如表建索引、SQL优化、加缓存、重构代码等等,本篇文章主要讲一下我是怎么在项目中使用缓存来提高接口响应速度的。我觉得缓存的使用主要有下面几个方面:

  • 缓存预热
    • 定时任务预热:定时任务在系统低峰期预加载数据到缓存中。
    • 启动预热:系统启动时预加载必要的数据到缓存中。
  • 缓存层次化
    • 多级缓存:实现本地缓存和分布式缓存相结合,例如,先在本地缓存中查询,如果没有再查询Redis等分布式缓存,最后才查询数据库。
    • 热点数据缓存:对频繁访问的数据进行缓存,如用户会话、热门商品信息、高频访问的内容等。

缓存提高接口响应速度主要是上面这些思路,不过我不是来讲概念的,那是面试要用的东西。我要讲的是如何用代码实现这些思路,把它们真正用到项目中来,水平有限,我尽力说,不喜勿喷。

由于文章经常被抄袭,开源的代码甚至被当成收费项,所以源码里面不是全部代码,有需要的同学可以留个邮箱,我给你单独发!

二、缓存预热:手撸一个缓存处理器

上面说了缓存预热主要是定时任务预热、启动预热,那么我们实现这个功能的时候,一般使用ConcurrentHashMapRedis来暂存数据,然后加上SpringBoot自带的@Scheduled定时刷新缓存就够了。虽然这样可以实现缓存预热,但缺陷很多,一旦需要预热的东西多起来就会变得越来越复杂,那么如何实现一个好的缓存处理器呢?接着看!

1、缓存处理器设计

(1)一个好的缓存处理器应该是这样搭建的

  1. DAL实现,产出DAO和DO对象,定义缓存领域模型
  2. 定义缓存名称,特别关注缓存的初始化顺序
  3. 编写数据仓库,通过模型转换器实现数据模型到缓存模型的转化
  4. 编写缓存管理器,推荐继承抽象管理器
  5. 根据业务需求,设计缓存数据接口(putAll,get,getCacheInfo等基础API)
  6. 完成bean配置,最好是可插拔的注册方式,缓存管理器和数据仓库、扩展点服务

(2)思路分析

2、代码实现

a. 每个处理器都有缓存名字、描述信息、缓存初始化顺序等信息,所以应该定义一个接口,名字为CacheNameDomain;

CacheNameDomain.java

package com.summo.demo.cache;

public interface CacheNameDomain {

    /**
     * 缓存初始化顺序,级别越低,越早被初始化
     * <p>
     * 如果缓存的加载存在一定的依赖关系,通过缓存级别控制初始化或者刷新时缓存数据的加载顺序<br>
     * 级别越低,越早被初始化<br>
     * <p>
     * 如果缓存的加载没有依赖关系,可以使用默认顺序<code>Ordered.LOWEST_PRECEDENCE</code>
     *
     * @return 初始化顺序
     * @see org.springframework.core.Ordered
     */
    int getOrder();

    /**
     * 缓存名称,推荐使用英文大写字母表示
     *
     * @return 缓存名称
     */
    String getName();

    /**
     * 缓存描述信息,用于打印日志
     *
     * @return 缓存描述信息
     */
    String getDescription();
}

b. 可以使用一个枚举类将不同的缓存处理器分开,有利于管理,取名为CacheNameEnum;

CacheNameEnum.java

package com.summo.demo.cache;

import org.springframework.core.Ordered;

/**
 * @description 缓存枚举
 */
public enum CacheNameEnum implements CacheNameDomain {
    /**
     * 系统配置缓存
     */
    SYS_CONFIG("SYS_CONFIG", "系统配置缓存", Ordered.LOWEST_PRECEDENCE),
    ;

    private String name;

    private String description;

    private int order;

    CacheNameEnum(String name, String description, int order) {
        this.name = name;
        this.description = description;
        this.order = order;
    }

    @Override
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    @Override
    public int getOrder() {
        return order;
    }

    public void setOrder(int order) {
        this.order = order;
    }
}

c. 缓存信息转换工具,以便dump出更友好的缓存信息,取名为CacheMessageUtil;

CacheMessageUtil.java

package com.summo.demo.cache;


import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * @description 缓存信息转换工具,以便dump出更友好的缓存信息
 */
public final class CacheMessageUtil {

    /** 换行符 */
    private static final char ENTERSTR  = '\n';

    /** Map 等于符号 */
    private static final char MAP_EQUAL = '=';

    /**
     * 禁用构造函数
     */
    private CacheMessageUtil() {
        // 禁用构造函数
    }

    /**
     * 缓存信息转换工具,以便dump出更友好的缓存信息<br>
     * 对于List<?>的类型转换
     *
     * @param cacheDatas 缓存数据列表
     * @return 缓存信息
     */
    public static String toString(List<?> cacheDatas) {
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < cacheDatas.size(); i++) {
            Object object = cacheDatas.get(i);
            builder.append(object);

            if (i != cacheDatas.size() - 1) {
                builder.append(ENTERSTR);
            }
        }

        return builder.toString();
    }

    /**
     * 缓存信息转换工具,以便dump出更友好的缓存信息<br>
     * 对于Map<String, Object>的类型转换
     *
     * @param map 缓存数据
     * @return 缓存信息
     */
    public static String toString(Map<?, ?> map) {
        StringBuilder builder = new StringBuilder();
        int count = map.size();
        for (Iterator<?> i = map.keySet().iterator(); i.hasNext();) {
            Object name = i.next();
            count++;

            builder.append(name).append(MAP_EQUAL);
            builder.append(map.get(name));

            if (count != count - 1) {
                builder.append(ENTERSTR);
            }
        }

        return builder.toString();
    }

}

d. 每个处理器都有生命周期,如初始化、刷新、获取处理器信息等操作,这应该也是一个接口,处理器都应该声明这个接口,名字为CacheManager;

CacheManager.java

package com.summo.demo.cache;

import org.springframework.core.Ordered;

public interface CacheManager extends Ordered {

    /**
     * 初始化缓存
     */
    public void initCache();

    /**
     * 刷新缓存
     */
    public void refreshCache();

    /**
     * 获取缓存的名称
     *
     * @return 缓存名称
     */
    public CacheNameDomain getCacheName();

    /**
     * 打印缓存信息
     */
    public void dumpCache();

    /**
     * 获取缓存条数
     *
     * @return
     */
    public long getCacheSize();
}

e. 定义一个缓存处理器生命周期的处理器,会声明CacheManager,做第一次的处理,也是所有处理器的父类,所以这应该是一个抽象类,名字为AbstractCacheManager;

AbstractCacheManager.java

package com.summo.demo.cache;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;

/**
 * @description 缓存管理抽象类,缓存管理器都要集成这个抽象类
 */
public abstract class AbstractCacheManager implements CacheManager, InitializingBean {

    /**
     * LOGGER
     */
    protected static final Logger LOGGER = LoggerFactory.getLogger(AbstractCacheManager.class);

    /**
     * 获取可读性好的缓存信息,用于日志打印操作
     *
     * @return 缓存信息
     */
    protected abstract String getCacheInfo();

    /**
     * 查询数据仓库,并加载到缓存数据
     */
    protected abstract void loadingCache();

    /**
     * 查询缓存大小
     *
     * @return
     */
    protected abstract long getSize();

    /**
     * @see InitializingBean#afterPropertiesSet()
     */
    @Override
    public void afterPropertiesSet() {
        CacheManagerRegistry.register(this);
    }

    @Override
    public void initCache() {

        String description = getCacheName().getDescription();

        LOGGER.info("start init {}", description);

        loadingCache();

        afterInitCache();

        LOGGER.info("{} end init", description);
    }

    @Override
    public void refreshCache() {

        String description = getCacheName().getDescription();

        LOGGER.info("start refresh {}", description);

        loadingCache();

        afterRefreshCache();

        LOGGER.info("{} end refresh", description);
    }

    /**
     * @see org.springframework.core.Ordered#getOrder()
     */
    @Override
    public int getOrder() {
        return getCacheName().getOrder();
    }

    @Override
    public void dumpCache() {

        String description = getCacheName().getDescription();

        LOGGER.info("start print {} {}{}", description, "\n", getCacheInfo());

        LOGGER.info("{} end print", description);
    }

    /**
     * 获取缓存条目
     *
     * @return
     */
    @Override
    public long getCacheSize() {
        LOGGER.info("Cache Size Count: {}", getSize());
        return getSize();
    }

    /**
     * 刷新之后,其他业务处理,比如监听器的注册
     */
    protected void afterInitCache() {
        //有需要后续动作的缓存实现
    }

    /**
     * 刷新之后,其他业务处理,比如缓存变通通知
     */
    protected void afterRefreshCache() {
        //有需要后续动作的缓存实现
    }
}

f. 当有很多缓存处理器的时候,那么需要一个统一注册、统一管理的的地方,可以实现对分散在各处的缓存管理器统一维护,名字为CacheManagerRegistry;

CacheManagerRegistry.java

package com.summo.demo.cache;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.OrderComparator;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @description 缓存管理器集中注册接口,可以实现对分散在各处的缓存管理器统一维护
 */
@Component
public final class CacheManagerRegistry implements InitializingBean {

    /**
     * LOGGER
     */
    private static final Logger logger = LoggerFactory.getLogger(CacheManagerRegistry.class);

    /**
     * 缓存管理器
     */
    private static Map<String, CacheManager> managerMap = new ConcurrentHashMap<String, CacheManager>();

    /**
     * 注册缓存管理器
     *
     * @param cacheManager 缓存管理器
     */
    public static void register(CacheManager cacheManager) {
        String cacheName = resolveCacheName(cacheManager.getCacheName().getName());
        managerMap.put(cacheName, cacheManager);
    }

    /**
     * 刷新特定的缓存
     *
     * @param cacheName 缓存名称
     */
    public static void refreshCache(String cacheName) {
        CacheManager cacheManager = managerMap.get(resolveCacheName(cacheName));
        if (cacheManager == null) {
            logger.warn("cache manager is not exist,cacheName=", cacheName);
            return;
        }

        cacheManager.refreshCache();
        cacheManager.dumpCache();
    }

    /**
     * 获取缓存总条数
     */
    public static long getCacheSize(String cacheName) {
        CacheManager cacheManager = managerMap.get(resolveCacheName(cacheName));
        if (cacheManager == null) {
            logger.warn("cache manager is not exist,cacheName=", cacheName);
            return 0;
        }
        return cacheManager.getCacheSize();
    }

    /**
     * 获取缓存列表
     *
     * @return 缓存列表
     */
    public static List<String> getCacheNameList() {
        List<String> cacheNameList = new ArrayList<>();
        managerMap.forEach((k, v) -> {
            cacheNameList.add(k);
        });
        return cacheNameList;
    }

    public void startup() {
        try {

            deployCompletion();

        } catch (Exception e) {

            logger.error("Cache Component Init Fail:", e);

            // 系统启动时出现异常,不希望启动应用
            throw new RuntimeException("启动加载失败", e);
        }
    }

    /**
     * 部署完成,执行缓存初始化
     */
    private void deployCompletion() {

        List<CacheManager> managers = new ArrayList<CacheManager>(managerMap.values());

        // 根据缓存级别进行排序,以此顺序进行缓存的初始化
        Collections.sort(managers, new OrderComparator());

        // 打印系统启动日志
        logger.info("cache manager component extensions:");
        for (CacheManager cacheManager : managers) {
            String beanName = cacheManager.getClass().getSimpleName();
            logger.info(cacheManager.getCacheName().getName(), "==>", beanName);
        }

        // 初始化缓存
        for (CacheManager cacheManager : managers) {
            cacheManager.initCache();
            cacheManager.dumpCache();
        }
    }

    /**
     * 解析缓存名称,大小写不敏感,增强刷新的容错能力
     *
     * @param cacheName 缓存名称
     * @return 转换大写的缓存名称
     */
    private static String resolveCacheName(String cacheName) {
        return cacheName.toUpperCase();
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        startup();
    }
}

3、使用方式

项目结构如下:

这是完整的项目结构图,具体的使用步骤如下:
step1、在CacheNameEnum中加一个业务枚举,如 SYS_CONFIG("SYS_CONFIG", "系统配置缓存", Ordered.LOWEST_PRECEDENCE)
step2、自定义一个CacheManager继承AbstractCacheManager,如public class SysConfigCacheManager extends AbstractCacheManager
step3、实现loadingCache()方法,这里将你需要缓存的数据查询出来,但注意不要将所有的数据都放在一个缓存处理器中,前面CacheNameEnum枚举类的作用就是希望按业务分开处理;
step4、在自定义的CacheManager类中写自己的查询数据方法,因为不同业务的场景不同,查询参数、数据大小、格式、类型都不一致,所以AbstractCacheManager并没有定义统一的取数方法,没有意义;

下面是一个完整的例子
SysConfigCacheManager.java

package com.summo.demo.cache.manager;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import com.summo.demo.cache.AbstractCacheManager;
import com.summo.demo.cache.CacheMessageUtil;
import com.summo.demo.cache.CacheNameDomain;
import com.summo.demo.cache.CacheNameEnum;
import org.springframework.stereotype.Component;

/**
 * 系统配置管理器
 */
@Component
public class SysConfigCacheManager extends AbstractCacheManager {

    /**
     * 加个锁,防止出现并发问题
     */
    private static final Lock LOCK = new ReentrantLock();

    /**
     * 底层缓存组件,可以使用ConcurrentMap也可以使用Redis,推荐使用Redis
     */
    private static ConcurrentMap<String, Object> CACHE;

    @Override
    protected String getCacheInfo() {
        return CacheMessageUtil.toString(CACHE);
    }

    @Override
    protected void loadingCache() {
        LOCK.lock();
        try {
            //存储数据,这里就模拟一下了
            CACHE = new ConcurrentHashMap<>();
            CACHE.put("key1", "value1");
            CACHE.put("key2", "value2");
            CACHE.put("key3", "value3");
        } finally {
            LOCK.unlock();
        }

    }

    @Override
    protected long getSize() {
        return null == CACHE ? 0 : CACHE.size();
    }

    @Override
    public CacheNameDomain getCacheName() {
        return CacheNameEnum.SYS_CONFIG;
    }

    /**
     * 自定义取数方法
     *
     * @param key
     * @return
     */
    public static Object getConfigByKey(String key) {
        return CACHE.get(key);
    }
}

三、缓存层次化:使用函数式编程实现

1、先举个例子

现有一个使用商品名称查询商品的需求,要求先查询缓存,查不到则去数据库查询;从数据库查询到之后加入缓存,再查询时继续先查询缓存。

(1)思路分析

可以写一个条件判断,伪代码如下:

//先从缓存中查询
String goodsInfoStr = redis.get(goodsName);
if(StringUtils.isBlank(goodsInfoStr)){
	//如果缓存中查询为空,则去数据库中查询
	Goods goods = goodsMapper.queryByName(goodsName);
	//将查询到的数据存入缓存
	goodsName.set(goodsName,JSONObject.toJSONString(goods));
	//返回商品数据
	return goods;
}else{
	//将查询到的str转换为对象并返回
	return JSON.parseObject(goodsInfoStr, Goods.class);
}

流程图如下

上面这串代码也可以实现查询效果,看起来也不是很复杂,但是这串代码是不可复用的,只能用在这个场景。假设在我们的系统中还有很多类似上面商品查询的需求,那么我们需要到处写这样的if(...)else{...}。作为一个程序员,不能把类似的或者重复的代码统一起来是一件很难受的事情,所以需要对这种场景的代码进行优化。

上面这串代码的问题在于:入参不固定、返回值也不固定,如果仅仅是参数不固定,使用泛型即可。但最关键的是查询方法也是不固定的,比如查询商品和查询用户肯定不是一个查询方法吧。

所以如果我们可以把一个方法(即上面的各种查询方法)也能当做一个参数传入一个统一的判断方法就好了,类似于:

/**
 * 这个方法的作用是:先执行method1方法,如果method1查询或执行不成功,再执行method2方法
 */
public static<T> T selectCacheByTemplate(method1,method2)

想要实现上面的这种效果,就不得不提到Java8的新特性:函数式编程

2、什么是函数式编程

在Java中有一个package:java.util.function ,里面全部是接口,并且都被@FunctionalInterface注解所修饰。

Function分类

  • Consumer(消费):接受参数,无返回值
  • Function(函数):接受参数,有返回值
  • Operator(操作):接受参数,返回与参数同类型的值
  • Predicate(断言):接受参数,返回boolean类型
  • Supplier(供应):无参数,有返回值

具体我就不再赘述了,可以参考:https://blog.csdn.net/hua226/article/details/124409889

3、代码实现

核心代码非常简单,如下

/**
  * 缓存查询模板
  *
  * @param cacheSelector    查询缓存的方法
  * @param databaseSelector 数据库查询方法
  * @return T
  */
public static <T> T selectCacheByTemplate(Supplier<T> cacheSelector, Supplier<T> databaseSelector) {
  try {
    log.info("query data from redis ······");
    // 先查 Redis缓存
    T t = cacheSelector.get();
    if (t == null) {
      // 没有记录再查询数据库
      return databaseSelector.get();
    } else {
      return t;
    }
  } catch (Exception e) {
    // 缓存查询出错,则去数据库查询
    log.info("query data from database ······");
    return databaseSelector.get();
    }
}

这里的Supplier 就是一个加了@FunctionalInterface注解的接口。

4、使用方式

使用方式也非常简单,如下

@Component
public class UserManager {

    @Autowired
    private CacheService cacheService;

    public Set<String> queryAuthByUserId(Long userId) {
        return BaseUtil.selectCacheByTemplate(
            //从缓存中查询
            () -> this.cacheService.queryUserFromRedis(userId),
            //从数据库中查询
            () -> this.cacheService.queryUserFromDB(userId));
    }
}

这样就可以做到先查询Redis,查询不到再查询数据库,非常简单也非常好用,我常用于查询一些实体信息的场景。不过这里有一个注意的点:缓存一致性。因为有时候底层数据会变化,需要做好一致性,否则会出问题。

四、小结一下

首先,缓存确实可以提高API查询效率,这点大家应该不会质疑,但缓存并不是万能的,不应该将所有数据都缓存起来,应当评估数据的访问频率和更新频率,以决定是否缓存。
其次,在实施缓存策略时,需要平衡缓存的开销、复杂性和所带来的性能提升。此外,缓存策略应该根据实际业务需求和数据特征进行定制,不断调整优化以适应业务发展。
最后,缓存虽好,但不要乱用哦,否则会出现令你惊喜的BUG!????