Redis 缓存进阶篇,缓存真实数据和缓存文件指针最佳实现?如何选择?

目录

[一. 场景再现、具体分析](#一. 场景再现、具体分析)

[二. 常见实现方案及方案分析](#二. 常见实现方案及方案分析)

[2.1 数据库字段最大存储理论分析](#2.1 数据库字段最大存储理论分析)

[2.2 最佳实践方式分析](#2.2 最佳实践方式分析)

[三. 接口选择、接口分析](#三. 接口选择、接口分析)

[四. 数据库设计](#四. 数据库设计)

[4.1 接口缓存表设计](#4.1 接口缓存表设计)

[4.1.1 建表SQL](#4.1.1 建表SQL)

[4.1.2 查询接口设计](#4.1.2 查询接口设计)

[4.2 调用日志记录表设计](#4.2 调用日志记录表设计)

[4.2.1 建表SQL](#4.2.1 建表SQL)

[4.2.2 查询SQL](#4.2.2 查询SQL)

[五. 项目代码落地实现](#五. 项目代码落地实现)

[5.1 通用代码编写](#5.1 通用代码编写)

[5.1.1 创建接口请求参数实体类](#5.1.1 创建接口请求参数实体类)

[5.1.2 创建接口响应通用返回结果类](#5.1.2 创建接口响应通用返回结果类)

[5.1.3 创建接口缓存表实体类](#5.1.3 创建接口缓存表实体类)

[5.1.4 创建接口缓存表Mapper接口](#5.1.4 创建接口缓存表Mapper接口)

[5.1.5 创建接口活动日志表实体类](#5.1.5 创建接口活动日志表实体类)

[5.1.6 创建接口活动记录表Mapper接口](#5.1.6 创建接口活动记录表Mapper接口)

[5.1.7 创建 Controller 层控制器类](#5.1.7 创建 Controller 层控制器类)

[5.1.8 Service 层方法公共变量和方法创建](#5.1.8 Service 层方法公共变量和方法创建)

[5.2 Redis 缓存真实数据业务代码实现](#5.2 Redis 缓存真实数据业务代码实现)

[5.3 Redis 缓存文件指针,通过指针再读取文件内容业务实现](#5.3 Redis 缓存文件指针,通过指针再读取文件内容业务实现)

[5.4 二者混合使用](#5.4 二者混合使用)

[六. 简要总结](#六. 简要总结)


一. 场景再现、具体分析

这里我们来设想以下场景。

假设你的个人项目或公司分给你的项目需求需要查询公司信息,所以对接了启信宝、企查查等第三方公司信息查询接口;此外,为了节省成本,当我们首次查询一个新公司的时候,如果调用企查查成功,则将企查查返回的数据保存到我们本地的数据库中,同时将数据库数据读到缓存中提高查询效率,后续我们再次查询此公司,则不会发起实际调用,而是从缓存获取数据返回。

对于缓存公司数据的方式,通常我们有以下两种方案。

**方案一:**将企查查接口返回的数据报文直接存储到数据库,可以使用 VARCHAR(MAX),TEXT,MEDIUMTEXT等字段存储,根据接口返回数据的大小选择;然后将接口原文直接缓存到 Redis 中;

**方案二:**将企查查接口返回的数据报文存储到文件中(文件可以存储在服务器上,也可以存储到 minio,阿里云OSS等云服务器上),然后将文件的指针(文件的地址)存储到数据库中,使用 VARCHAR 即可,同时为了提高查询效率,也将文件的指针缓存到 Redis 中;

再比如,以我们当前的 CSDN 网站为例,用户编写的文章,要进行暂存或发布,文章应该如何存储?是直接以文本格式存储到数据库中?还是存储到文本文件,然后将文件指针存储到数据中?

这种场景其实并不罕见,那么接下来,我们就来探讨一下,这两种文件存储方案的优缺点吧!

二. 常见实现方案及方案分析

2.1 数据库字段最大存储理论分析

如下表格所示,是MySQL中比较常见的几个存储文本的字段类型,目前比较常用的字符集有 utf8mb4 和 utf8mb3,更推荐使用 utf8mb4。

字段类型 最大字节数 计算基础 理论最大汉字数 utf8mb3,3字节) 理论最大汉字数 utf8mb4,4字节) 适用场景 使用频率
VARCHAR 65,533 65,535 - 2 21,844 字 16,383 字 短文本 极高
TEXT 65,535 固定限制 21,845 字 16,383 字 普通长文本 一般
JSON 1,073,741,823 LONGTEXT 级 357,913,941 字 268,435,455 字 JSON 数据 较低
MEDIUMTEXT 16,777,215 16MB 5,592,405 字 4,194,303 字 大文本 较低

重点来啦!!! 小编这里尽可能简单的说一下,在数据库的底层,所有的数据都是存储在数据页中 的,一张数据页的大小就是16KB ,即16384字节 ,并且数据库的 InnoDB 存储引擎大数据存储机制 ,当一条数据的某个字段,以VARCHAR为例,大于数据页的一半8K(8192字节)时 ,则不会将字段值真实存储在当前数据页,而是存储到**"溢出页"** ,然后数据库底层会把"溢出页"的指针值存储到字段值中,当我们读取数据的时候,数据库底层读取到大字段的值之后,会根据指针值进行IO操作,将**"溢出页"** 的值读出来然后进行返回;而TEXT、JSON、MEDIUMTEXT其它三者更不必多说,都是将数据存储在单独的数据页,然后记录中的字段值则是存储数据页的指针地址,具体见下表格。

行格式 溢出阈值 VARCHAR(10KB)存储 TEXT 存储 JSON 存储 MEDIUMTEXT 存储 版本建议
Redundant 完整存行内 完整存行内 完整存行内 完整存行内 已淘汰
Compact 768 字节 768B 前缀 + 溢出链 768B 前缀 + 溢出链 768B 前缀 + 溢出链 768B 前缀 + 溢出链 兼容旧系统
Dynamic 8 KB 20B 指针 + 完整溢出链 20B 指针 + 完整溢出链 20B 指针 + 完整溢出链 20B 指针 + 完整溢出链 MySQL 5.7+默认

不难发现,在 MySQL5.7 版本之后,行格式已经做了进一步优化,采用了 Dynamic 动态行格式**,可以简单理解为,当我们存储的一条数据某个字段大小小于 8K 时,MySQL选择直接存储完整数据到行内;但是当字段大小大于 8K时,则会将真实数据存储到其他数据页,字段则存储指针值。**当然了,数据库底层的设计极为精妙,也有可能某个数据页第一条要存储的数据就是大数据,此时有16KB的空间,空间足够,一般是全量存储;但如果一个数据页已经存储了100条数据,剩下6KB的大小,但要存储一行8KB的大小的记录,此时就有可能触发溢出存储机制,将大数据存储在"溢出页"。

所以我们本篇文章就以 MySQL5.7+ 之后的版本为例,可以得出以下结论,一定牢记!!!

当使用 VARCHAR、TEXT、JSON、MEDIUMTEXT 字段存储数据时,若字段大小大于8K,则数据库行内字段值只存储文件指针,真实数据存储在数据页;若字段大小小于8K,都会把真实数据存储在行内;唯一的区别就是它们的最大字节数据不同会导致数据页的数量不同仅此而已。数据页数量越多,也就意味着数据库要进行更多次的随机 IO 去读取数据页,可能会影响数据库的性能。

2.2 最佳实践方式分析

通过上面的一顿分析,我们对于这四种存储字段已经比较清楚了,那么现在,我们就从实用的角度来考虑到底选择哪种字段?

小编更推荐使用 VARCHAR 类型!

VARCHAR:可控可变长度,并且当数据小不需要额外存储数据页时,它直接存储完整数据,当数据量大需要数据页时,则存储文件地址,并且支持数据检查和内存临时表,并且是常见的类型,代码实现难度低,符合大众思路;

TEXT:相对来说比较好的一个字段,但还是不如 VARCHAR,不支持数据检查和内存临时表,另外一点就是,无论它是否使用到了额外的数据页,都会有一个20字节的文件指针,比较浪费空间,且此类型很少有人用,所以实际开发我们也不追求新鲜感,以通用型普遍性 VARCAHR 类型为主;

JSON:比较好的一个数据类型,但不建议使用。原因是需要使用 JSONObject 类型来接受,且部分开发人员未必对此字段类型熟悉,甚至可能SQL语法也不太清楚,需要额外进行学习,徒增开发压力;并且更重要的一点是,如果我们要在程序中获取JSON中的某个 Key 并进行操作,此时就显得较为麻烦,不如存储为字符串或文本类型,在经 JSONObject.parse() 转化;

MEDIUMTEXT:占用数据库存储较大,不易于管理,特别是当用户大量访问调用企查查接口时,会增加数据库存储压力,导致实际查询时,数据库底层会进行多次IO操作,如此一来还会如直接存储到数据库外的单独文件,在将文件指针存储到数据库。

总的来说,我们本篇文章着重考虑的就是是否要将真实数据存表,还是存文件指针,通过 MySQL 的动态优化策略,我们不难发现,当数据量大于 8K 时,即使我们不额外存到文件中,数据库底层还是会将数据存储到"数据页"中,在查询时需要进行额外的IO操作,既然如此,还不如当数据量小的数后,直接使用 VARCAHR 存储,当数据量大的时候,使用外部文件存储,然后使用 VARCHAR 存储外部文件指针。

所以,我们可以简单地把8K作为一个临界点,总结为以下表格,各位小伙伴可根据表格自行选择最优实现方案 (方案永远没有最好的,只有最合适的!)

决策因素 数据库字段缓存完整数据 数据库字段缓存文件指针
适用数据大小建议 < 8KB ≥ 8KB
用户查询频率建议 高、中、低频率均可 低频率(< 10次/分钟)
Redis 内存环境 专属 Redis/内存充足,内存充足,随意使用 多个项目或大型项目共享 Redis/内存紧张
实现复杂度对比 简单(直接缓存完整JSON数据) 中等(需文件存储+指针管理) 如果是分布式系统多台服务器,可能还需考虑文件共享
数据库存储建议 对数据存储无所谓,可接受大量数据直接存库 对数据库要求较高,不希望大量公司数据占用数据库存储
响应时间对比 1-5ms 50-300ms
程序效率优先级 ⭐⭐⭐⭐⭐ (要求越快越好) ⭐⭐ (可接受百毫秒级延迟)

三. 接口选择、接口分析

这里我们以对接启信宝第三方接口为例,如下图所示,可以发现官网对于接口有明确的标注接口ID------1.41 工商照面,查询公司的基本信息。

从官网可以得出接口的基本信息

接口地址:https://api.qixin.com/APIService/enterprise/getBasicInfo

数据格式:JSON

请求方式:HTTP/HTTPS的GET请求

请求示例:https://api.qixin.com/APIService/enterprise/getBasicInfo?keyword=开平达丰纺织印染服装有限公司

请求参数:Query,Query 参数内部有一个 keyword 属性

响应参数:标准的 status 状态码参数、message 响应描述、data 数据参数以及一个独有的数据签名参数 sign。

综合上述信息可以得知,要对接这个接口,至少需要创建一个请求参数Query,接口地址静态变量 private static final url = "",获取账号和密钥。

如果我们要对响应的数据进行操作,最好定义对应的 Java 实体类接收,然后通过 JSONObject.parse 转化为对应的实体类。

四. 数据库设计

经过上面一,二的分析,我们已经得出了结论,就是最为关键的"接口返回数据" 使用 VARCHAR 字段来存储,那么我们就开始设计数据库。

4.1 接口缓存表设计
4.1.1 建表SQL

每个字段的具体用处,小编都在注释中进行了说明,很好理解。

如果直接存储真实数据,则需要使用字段 "call_response_context";

如果存储文件指针,则需要使用字段 "bucket_name" 和 "file_name";

为了方便我都提前定义出来拉!

sql 复制代码
CREATE TABLE `interface_cache` (
  `id`                    BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键自增ID,也可以用UUID,只要保证唯一即可,项目中无实际用处',
  `consumer_service_name` VARCHAR(50)     DEFAULT NULL COMMENT '调用服务名称,如果用多个服务,方便以后对各个服务的真实调用次数做统计',
  `interface_id`          VARCHAR(20)     DEFAULT NULL COMMENT '接口唯一标识(ID),一般情况下官网都会有,这里指上面要对接的工商照面接口ID是1.41',
  `interface_name`        VARCHAR(100)    DEFAULT NULL COMMENT '第三方接口名称,比如查询公司详情这里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名称',
  `interface_param`       VARCHAR(255)    DEFAULT NULL COMMENT '接口入参,默认为公司名称/公司社会唯一信用码等,对应上方1.41接口的参数值keyword值',
  `bucket_name`           VARCHAR(50)     DEFAULT NULL COMMENT '若文件存储在本地,则指代文件所在的全路径名称;若存储于minio,则指代桶的名称(方案二要使用的字段)',
  `file_name`             VARCHAR(50)     DEFAULT NULL COMMENT '存储实际公司数据的文件名称,建议由UUID工具生成(方案二要使用的字段)',
  `call_response_context` VARCHAR(8192)   DEFAULT NULL COMMENT '接口调用响应报文,因为有些接口不使用data存储数据而是直接返回,所以我们直接存储整个响应,对应1.41接口响应的四个参数(方案一使用的字段)',
  `call_input_time`       DATETIME        COMMENT              '接口调用时间,作为记录',
  `create_time`           DATETIME        DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间,其实和接口调用时间一样,个人感觉可加可不加',
  `expired_time`          DATETIME        COMMENT              '数据过期时间(可定期清理过期数据),默认3个月有效期,直接在接口调用时间字段值上+3个月有效期',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方接口调用返回数据缓存表';
4.1.2 查询接口设计

后续我们查询数据库时,基本是通过 api_id + api_param + expired_time 来确认唯一一条已存在的公司数据。所以我们可以对这三个主要字段添加索引,这里就不详细展示了。

Mapper 层接口如下所示,这里也可以直接使用实体传参,我这里分成了三个,怎么写都行

java 复制代码
InterfaceCache findCacheByCondition(
    @Param("interfaceId") String interfaceId,
    @Param("interfaceParam") String interfaceParam,
    @Param("expiredTime") Date expiredTime
);
sql 复制代码
  <select id="findCacheByCondition" resultType="com.test.InterfaceCache">
    select *
        from interface_cache
    where interface_id = #{interfaceId}
      and interface_param = #{interfaceParam}
      and expired_time > #{expiredTime}
  </select>

插入SQL就直接继承 mybatisplus 的单行记录插入即可,就不多赘述了。

4.2 调用日志记录表设计
4.2.1 建表SQL
sql 复制代码
CREATE TABLE `interface_active_record` (
  `id`                    BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键自增ID,也可以用UUID,只要保证唯一即可,项目中无实际作用',
  `consumer_service_name` VARCHAR(50)     DEFAULT NULL COMMENT '调用服务名称,方便以后对各个服务的所有调用次数做统计(含缓存、数据库调用)',
  `interface_id`          VARCHAR(20)     DEFAULT NULL COMMENT '接口唯一标识(ID),一般情况下官网都会有,这里指上面要对接的工商照面接口ID是1.41',
  `interface_name`        VARCHAR(100)    DEFAULT NULL COMMENT '第三方接口名称,比如查询公司详情这里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名称',
  `interface_param`       VARCHAR(255)    DEFAULT NULL COMMENT '接口入参,默认为公司名称/公司社会唯一信用码等,对应上方1.41接口的参数值keyword值',
  `is_actually_call`      VARCHAR(10)     DEFAULT NULL COMMENT '是否实际调用启信查询(N:否,Y:是),也可以用tinyint类型码值0否,1是表示,看个人习惯',
  `call_description`      VARCHAR(255)    DEFAULT NULL COMMENT '接口调用响应描述(查询缓存返回数据?或查询数据库返回数据?或实际调用返回数据?',
  `call_response_status`  VARCHAR(20)     DEFAULT NULL COMMENT '接口调用响应状态码,例如200,201,404等,对应上方1.41接口的相应参数status的值',
  `call_response_message` VARCHAR(50)     DEFAULT NULL COMMENT '接口调用响应信息,对应上方1.41接口的响应参数message的值',
  `create_time`           DATETIME        DEFAULT CURRENT_TIMESTAMP COMMENT '当前日志信息创建(插入)时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方接口调用日志记录表';
4.2.2 查询SQL

方便我们后续查询日志表进行验证,这里先把日志表查询SQL写出来;

sql 复制代码
# 1. 查询最近的接口调用记录
select * from interface_active_record order by id desc;
# 2. 查询某个公司所有企查查接口的调用记录
select * from interface_active_record 
         where interface_param = '?' 
         order by id desc;
# 3. 查询某个公司特定的企查查接口调用记录
select * from interface_active_record 
         where interface_id = '?'
         and interface_param = '?' 
         order by id desc;

五. 项目代码落地实现

对于到底缓存完整公司数据,还是缓存文件指针,这都是我们请求成功后要做的操作,这两种方案,其实最本质的区别就在于业务层Service方法的处理逻辑略有区别,其它比如控制器层 Controller 类,控制器接口请求参数 Entity 类,控制器返回通用参数类型 CommonResponseDTO 类都是一样的。所以我们下面先把通用代码创建出来,在再分别去写两种方案的Service业务层方法即可。我们开始吧!

5.1 通用代码编写
5.1.1 创建接口请求参数实体类
java 复制代码
/**
 * 1.41 工商照面接口请求参数
 * */
@Data
public class BusinessDetailDTO {
    /**
     * 企业全名/注册号/统一社会信用代码
     * */
    private String keyword;
}
5.1.2 创建接口响应通用返回结果类
java 复制代码
/**
 * 通用返回结果
 * */
@Data
public class CommonResponseDTO {
    // 响应状态码,直接获取接口的响应状态码 status 的值
    private String status;
    // 响应消息描述,直接获取接口的响应消息 message 的值
    private String message;
    // 响应数据,直接获取接口的整个响应报文,即 status,message,data,sign 组成的JSON字符串
    private String context;
}
5.1.3 创建接口缓存表实体类
java 复制代码
/**
 * 第三方接口调用返回数据缓存表
 */
@Data
public class InterfaceCache {
    /**
     * 主键自增ID,也可以用UUID,只要保证唯一即可,项目中无实际用处
     */
    private Long id;
    /**
     * 调用服务名称,如果用多个服务,方便以后对各个服务的真实调用次数做统计
     */
    private String consumerServiceName;
    /**
     * 接口唯一标识(ID),一般情况下官网都会有,这里指上面要对接的工商照面接口ID是1.41
     */
    private String interfaceId;
    /**
     * 第三方接口名称,比如查询公司详情这里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名称
     */
    private String interfaceName;
    /**
     * 接口入参,默认为公司名称/公司社会唯一信用码等,对应上方1.41接口的参数值keyword值
     */
    private String interfaceParam;
    /**
     * 若文件存储在本地,则指代文件所在的全路径名称;若存储于minio,则指代桶的名称(方案二要使用的字段)
     */
    private String bucketName;
    /**
     * 存储实际公司数据的文件名称,建议由UUID工具生成(方案二要使用的字段)
     */
    private String fileName;
    /**
     * 接口调用响应报文,因为有些接口不使用data存储数据而是直接返回,所以我们直接存储整个响应,对应1.41接口响应的四个参数(方案一使用的字段)
     */
    private String callResponseContext;
    /**
     * 接口调用时间,作为记录
     */
    private Date callInputTime;
    /**
     * 记录创建时间,其实和接口调用时间一样,个人感觉可加可不加
     */
    private Date createTime;
    /**
     * 数据过期时间(可定期清理过期数据),默认3个月有效期,直接在接口调用时间字段值上+3个月有效期
     */
    private Date expiredTime;
}
5.1.4 创建接口缓存表Mapper接口

Mapper 接口不需要自定义

java 复制代码
@Mapper
public interface InterfaceCacheMapper2 extends BaseMapper<InterfaceCache> {
    InterfaceCache findCacheByCondition(
            @Param("interfaceId") String interfaceId,
            @Param("interfaceParam") String interfaceParam,
            @Param("expiredTime") Date expiredTime
    );
}
5.1.5 创建接口活动日志表实体类
java 复制代码
/**
 * 第三方接口调用日志记录表
 */
@Data
public class InterfaceActiveRecord {
    /**
     * 主键自增ID,也可以用UUID,只要保证唯一即可,项目中无实际作用
     */
    private Long id;
    /**
     * 调用服务名称,方便以后对各个服务的所有调用次数做统计(含缓存、数据库调用)
     */
    private String consumerServiceName;
    /**
     * 接口唯一标识(ID),一般情况下官网都会有,这里指上面要对接的工商照面接口ID是1.41
     */
    private String interfaceId;
    /**
     * 第三方接口名称,比如查询公司详情这里就是上面的"工商照面",可加可不加的字段,加上更易于理解接口名称
     */
    private String interfaceName;
    /**
     * 接口入参,默认为公司名称/公司社会唯一信用码等,对应上方1.41接口的参数值keyword值
     */
    private String interfaceParam;
    /**
     * 是否实际调用启信查询(N:否,Y:是),也可以用tinyint类型码值0否,1是表示,看个人习惯
     */
    private String isActuallyCall;
    /**
     * 接口调用响应描述(查询缓存返回数据?或查询数据库返回数据?或实际调用返回数据?
     */
    private String callDescription;
    /**
     * 接口调用响应状态码,例如200,201,404等,对应上方1.41接口的相应参数status的值
     */
    private String callResponseStatus;
    /**
     * 接口调用响应信息,对应上方1.41接口的响应参数message的值
     */
    private String callResponseMessage;
    /**
     * 当前日志信息创建(插入)时间
     */
    private Date createTime;
}
5.1.6 创建接口活动记录表Mapper接口
java 复制代码
@Mapper
public interface InterfaceActiveRecordMapper extends BaseMapper<InterfaceActiveRecord> {
}
5.1.7 创建 Controller 层控制器类
java 复制代码
@RestController
@RequestMapping("/qx")
public class QxController {
    @Autowired
    private QxService qxService;
    /**
     * @param businessDetailDTO 主要负责企业全名/注册号/统一社会信用代码
     * @return CommonResponseDTO 公共响应结果类对象
     */
    @PostMapping("/getCompanyInfo")
    public CommonResponseDTO getCompanyInfo(@RequestBody BusinessDetailDTO businessDetailDTO,HttpServletRequest request) {
        return qxService.getCompanyInfo(businessDetailDTO,request);
    }
}
5.1.8 Service 层方法公共变量和方法创建
java 复制代码
@Slf4j
@Service
public class QxService {
    // 接口活动跟踪记录Mapper
    @Autowired
    private InterfaceActiveRecordMapper interfaceActiveRecordMapper;
    // 接口数据缓存Mapper
    @Autowired
    private InterfaceCacheMapper interfaceCacheMapper;
    // redis缓存
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // 发送网络请求的restTemplate
    @Autowired
    private RestTemplate restTemplate;
    // appkey,secret_key正常来讲应该定义来yml文件中,这里懒省事,直接定义在业务类中
    private static final String appkey = "appkey";
    private static final String secret_key = "secret_key";
    // 缓存失效时间,默认7天,正常来讲也应该定义在 yml 文件中,这里懒省事,直接定义在业务类中
    private static final int cacheDays = 7;
    // 下面这几个静态常量类正常来讲应该定义在Constants常量类中,这里懒省事,直接定义在业务类中
    private static final String QX_GET_BUSINESS_DETAIL_URL = "https://api.qixin.com/APIService/enterprise/getBasicInfo";
    private static final String QX_GET_BUSINESS_DETAIL_CACHE_PREFIX = "QX:getBusinessDetail:";
    private static final String QX_GET_BUSINESS_DETAIL_INTERFACE_ID = "1.41:";
    private static final String QX_GET_BUSINESS_DETAIL_INTERFACE_NAME = "企业工商照面";
    /**
     * MD5加密方法,待会业务方法中会用到,提前定义出来。
     * 加密规则 :
     * appkey + timestamp + secret_key 组成的 32 位md5加密的小写字符串(实际加密不带入 '+')
     * */
    private String getMD5(String appkey, String timestamp, String secret_key) {
        byte[] digest = null;
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            String str = appkey + timestamp + secret_key;
            md.update(str.getBytes(StandardCharsets.UTF_8));
            digest = md.digest();
            StringBuilder hexString = new StringBuilder();
            for (byte b : digest) {
                // 保证两位十六进制
                hexString.append(String.format("%02x", b));
            }
            return hexString.toString();
        }catch (Exception e){
            throw new RuntimeException("MD5加密失败", e);
        }
    }
    /**
     * RestTemplate 发送请求方法,返回请求结果,待会业务方法中会用到,提前定义出来。
     * */
    private String httpsWithRestTemplate(String appkey, String timestamp, String secret_key,String url){
        // 创建请求头
        HttpHeaders headers = new HttpHeaders();
        headers.set("Auth-Version", "2.0"); // 官网固定传入2.0
        headers.set("appkey", appkey);
        headers.set("timestamp", timestamp);
        headers.set("sign", getMD5(appkey, timestamp, secret_key));
        // 封装请求头和空请求体
        HttpEntity<String> requestEntity = new HttpEntity<>(headers);
        // 发送 GET 请求
        ResponseEntity<String> responseEntity = restTemplate.exchange(
                url,
                HttpMethod.GET,
                requestEntity,
                String.class
        );
        // 获取响应体
        return responseEntity.getBody();
    }
    /**
     * 生成完整的查询工商照面接口地址方法
     * 启信接口 -"查询工商照面"
     * 官网接口ID:1.41
     * 接口地址:https://api.qixin.com/APIService/enterprise/getBasicInfo
     * 接口参数:keyword - 待查询的企业名称
     * 请求示例:https://api.qixin.com/APIService/enterprise/getBasicInfo?keyword=开平达丰纺织印染服装有限公司
     * */
    private String getBusinessBasicDetailUrl(String keyword) {
        StringBuffer url = new StringBuffer();
        if (StringUtils.isNotBlank(keyword)){
            url.append(QX_GET_BUSINESS_DETAIL_URL)
                    .append("?keyword=")
                    .append(keyword);
        }
        return url.toString();
    }
}
5.2 Redis 缓存真实数据业务代码实现

如下所示,就是小编个人编写的一段缓存接口真实数据业务层代码,仅供各位参考。

核心逻辑就三点:

第一:先查询缓存,缓存命中则直接返回;

第二:缓存未命中,查询数据库,数据库命中,回写缓存并返回;

第三:缓存、数据库均未命中,则发送网络请求查询数据,判断响应结果,保存至数据库并回写缓存;

java 复制代码
/**
 * 启信接口查询 - 1.41 工商照面
 * */
public CommonResponseDTO getCompanyInfo(BusinessDetailDTO businessDetailDTO,HttpServletRequest request) {
    // 创建方法返回对象
    CommonResponseDTO commonResponseDTO = new CommonResponseDTO();
    // 1. 创建接口活动跟踪记录对象并设置值
    InterfaceActiveRecord interfaceActiveRecord = new InterfaceActiveRecord();
    interfaceActiveRecord.setConsumerServiceName("XXXService"); // 服务名称,正常情况下应该从request中获取,这里写的比较随意
    interfaceActiveRecord.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);
    interfaceActiveRecord.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);
    interfaceActiveRecord.setCreateTime(new Date());
    // 2. 获取企业名称参数,同时赋值给缓存key和接口活动跟踪对象
    String cacheKey = businessDetailDTO.getKeyword();
    interfaceActiveRecord.setInterfaceParam(cacheKey);
    // 3. 查询缓存,判断缓存值是否为空,不为空直接返回结果
    String cacheCompanyJson = stringRedisTemplate.opsForValue().get(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey);
    if (StringUtils.isNotBlank(cacheCompanyJson)) {
        // 返回结果
        JSONObject companyJSON = JSON.parseObject(cacheCompanyJson);
        commonResponseDTO.setStatus(companyJSON.getString("status"));
        commonResponseDTO.setMessage(companyJSON.getString("message"));
        commonResponseDTO.setContext(cacheCompanyJson);
        // 接口活动跟踪记录对象设置值
        interfaceActiveRecord.setIsActuallyCall("N");
        interfaceActiveRecord.setCallDescription("查询Redis缓存返回公司数据");
        interfaceActiveRecord.setCallResponseStatus(companyJSON.getString("status"));
        interfaceActiveRecord.setCallResponseMessage(companyJSON.getString("message"));
        interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);
        return commonResponseDTO;
    }
    // 4. 缓存为空,则查询数据库,判断数据库数据是否为空
    InterfaceCache interfaceCache = null;
    interfaceCache = interfaceCacheMapper.findCacheByCondition(QX_GET_BUSINESS_DETAIL_INTERFACE_ID,cacheKey,new Date());
    if(interfaceCache != null) {
        // 写入缓存
        stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey,JSON.toJSONString(interfaceCache.getCallResponseContext()), cacheDays, TimeUnit.DAYS);
        // 返回结果
        JSONObject companyJSON = JSON.parseObject(interfaceCache.getCallResponseContext());
        commonResponseDTO.setStatus(companyJSON.getString("status"));
        commonResponseDTO.setMessage(companyJSON.getString("message"));
        commonResponseDTO.setContext(JSON.toJSONString(interfaceCache.getCallResponseContext()));
        // 接口活动跟踪记录对象设置值
        interfaceActiveRecord.setIsActuallyCall("N");
        interfaceActiveRecord.setCallDescription("查询数据库缓存返回公司数据");
        interfaceActiveRecord.setCallResponseStatus(companyJSON.getString("status"));
        interfaceActiveRecord.setCallResponseMessage(companyJSON.getString("message"));
        interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);
        return commonResponseDTO;
    }
    // 5. 数据库数据也为空,说明数据已过期或从未查询过,则调用启信官方接口查询数据
    String response = null;
    InterfaceCache interfaceCacheInsert = new InterfaceCache();
    try {
        log.info("调用启信查询1.41 工商照面接口入参\n{}", cacheKey);
        // 发送网络请求获取响应数据
        response = httpsWithRestTemplate(appkey,
                                         String.valueOf(System.currentTimeMillis()),
                                         secret_key,
                                         getBusinessBasicDetailUrl(cacheKey));
        log.info("调用启信查询1.41 工商照面接口返回数据\n{}", response);
        // 不管是否成功,都返回接口响应
        commonResponseDTO.setStatus(JSON.parseObject(response).getString("status"));
        commonResponseDTO.setStatus(JSON.parseObject(response).getString("message"));
        commonResponseDTO.setContext(String.valueOf(JSON.parseObject(response)));
        // 不管是否成功,接口活动跟踪记录对象设置值
        interfaceActiveRecord.setCallResponseStatus(JSON.parseObject(response).getString("status"));
        interfaceActiveRecord.setCallResponseMessage(JSON.parseObject(response).getString("message"));
        interfaceActiveRecord.setIsActuallyCall("Y");
        interfaceActiveRecord.setCallDescription("调用启信宝接口获取数据");
        // 判断状态是否为200,只有200写入数据库和缓存。因为可能出现201-余额不足;202-查询无结果等情况......
        if (response != null && "200".equals(JSON.parseObject(response).getString("status"))) {
            // 请求成功,将数据写入缓存
            stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey, response, cacheDays, TimeUnit.DAYS);
            // 接口缓存对象赋值
            interfaceCacheInsert.setConsumerServiceName("XXXService");
            interfaceCacheInsert.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);
            interfaceCacheInsert.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);
            interfaceCacheInsert.setInterfaceParam(cacheKey);
            interfaceCacheInsert.setBucketName(null);
            interfaceCacheInsert.setCallResponseContext(response);
            interfaceCacheInsert.setCallInputTime(new Date());
            interfaceCacheInsert.setCreateTime(new Date());
            interfaceCacheInsert.setExpiredTime(new Date(new Date().getTime() + 30L * 24 * 60 * 60 * 1000));
            // 将接口返回数据插入数据库
            interfaceCacheMapper.insert(interfaceCacheInsert);
        }
    } catch (Exception e) {
        interfaceActiveRecord.setIsActuallyCall("N");
        interfaceActiveRecord.setCallDescription("调用启信宝接口获取数据失败");
        interfaceActiveRecord.setCallResponseMessage(e.getMessage());
        throw new RuntimeException("企业基本信息数据写入本地缓存发生错误",e);
    } finally {
        // 6. 插入接口活动跟踪记录,不管调用是否成功,都要进行记录,放到 finally 块中
        interfaceActiveRecordMapper.insert(interfaceActiveRecord);
    }
    return commonResponseDTO;
}
5.3 Redis 缓存文件指针,通过指针再读取文件内容业务实现

因为要使用文件缓存接口响应数据,所以我们先写一个保存数据的方法。注释很详细,不过多解释啦。

这里我定义了一个文件前缀,就把文件保存到我的本地电脑磁盘上了,正常来讲公司的生产项目,通常会存储在运行项目的 Linux 服务器上,或者 minio 存储中间件上,或者阿里云OSS云存储等地方,这里就不整那么复杂啦,主要分享一个思路。同学们了解即可

java 复制代码
// 缓存文件前缀,公司数据缓存文件存放在/data/gateway-server/cache目录下。
private static final String CACHE_FILE_PREFIX = "/data/gateway-server/cache";

/**
 *  保存公司数据到文件并返回文件名
 *  @param response: 第三方接口返回的公司数据,返回 json 字符串
 *  return: 文件名和文件桶的Map集合···
 */
private Map<String,String> saveDataToFile(String response) throws Exception{
    Map<String,String> map = new HashMap<>();
    // 缓存文件日期前缀,精确到月份
    SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM");
    String date = sdf.format(new Date());
    // 随机生成缓存文件名
    String fileName = UUID.randomUUID().toString().concat(".json");
    // 缓存文件保存的目录
    String bucketName = CACHE_FILE_PREFIX.concat("/").concat(date).concat("/");
    // 若不存在则生成文件
    File dir = new File(bucketName);
    if(!dir.exists() && !dir.isDirectory()){
        dir.mkdirs();
    }
    // 缓存数据保存到本地磁盘
    try(
        // 创建一个`FileWriter`对象,用于向指定文件写入字符数据
        FileWriter fileWriter = new FileWriter(bucketName.concat(fileName));
        // 创建一个`BufferedWriter`对象,包装`FileWriter`以提高写入效率,默认缓存大小为8192字节(8K),与我们上面分析的8K大小节点刚好相同
        BufferedWriter bufferedWriter = new BufferedWriter(fileWriter)){
        // 执行实际写入操作
        bufferedWriter.write(response);
    } catch (IOException e){
        log.error("写入缓存数据失败:\n{}", e.getMessage());
    }
    map.put("fileName",fileName);
    map.put("bucketName",bucketName);
    return map;
}

将数据缓存到本地磁盘文件之后,我们再写一个从磁盘文件读取数据的的方法。

java 复制代码
/**
 * 通过文件指针获取公司数据
 * @param bucketName 存储桶名称,或文件存储路径
 * @param fileName 文件名称
 * return: 文件内容,公司数据,返回 json 字符串
 */
public String getDataByFile(String bucketName, String fileName)  {
    File file = new File(bucketName + fileName);
    StringBuffer sbf = new StringBuffer();
    BufferedReader reader = null;
    String response = "";
    log.info("缓存json读取的downloadPath:{}", bucketName + fileName);
    try {
        // 读取文件数据
        reader = new BufferedReader(new FileReader(file));
        String tempStr;
        while ((tempStr = reader.readLine()) != null) {
            sbf.append(tempStr);
        }
        reader.close();
        response = sbf.toString();
        return response;
    } catch (Exception e) {
        log.error("获取缓存数据失败:\n{}", e.getMessage());
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                log.error("关闭缓存文件失败:\n{}", e.getMessage());
                throw new RuntimeException(e);
            }
        }
    }
    return response;
}

编写完毕上面的两个方法,就可以正式来编写业务逻辑代码了,如下所示,大致逻辑其实和上面Redis 的思路一样的,只是在获取数据操作上加入了文件操作这一层,要求开发者对Java的IO代码编写有一定的基础。

java 复制代码
/**
 * 启信接口查询 - 1.41 工商照面
 * */
public CommonResponseDTO getCompanyInfo(BusinessDetailDTO businessDetailDTO,HttpServletRequest request) {
    // 1. 创建方法返回对象
    CommonResponseDTO commonResponseDTO = new CommonResponseDTO();
    // 1. 创建接口活动跟踪记录对象并设置值
    InterfaceActiveRecord interfaceActiveRecord = new InterfaceActiveRecord();
    interfaceActiveRecord.setConsumerServiceName("XXXService"); // 服务名称,正常情况下应该从request中获取,这里写的比较随意
    interfaceActiveRecord.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);
    interfaceActiveRecord.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);
    interfaceActiveRecord.setCreateTime(new Date());
    // 2. 获取企业名称参数,同时赋值给缓存key和接口活动跟踪对象
    String cacheKey = businessDetailDTO.getKeyword();
    interfaceActiveRecord.setInterfaceParam(cacheKey);
    // 3. 查询缓存,判断缓存值是否为空,不为空直接返回结果
    String interfaceCacheJson = stringRedisTemplate.opsForValue().get(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey);
    InterfaceCache interfaceCache = null;
    String response = null;
    if (StringUtils.isNotBlank(interfaceCacheJson)) {
        // 缓存不为空,则返回缓存数据,转化为 InterfaceCache 对象
        interfaceCache = JSON.parseObject(interfaceCacheJson, InterfaceCache.class);
        // 获取桶名和文件名称,从磁盘读取文件数据
        response = getDataByFile(interfaceCache.getBucketName(), interfaceCache.getFileName());
        // 解析 response 为 json 格式数据
        JSONObject jsonObject = JSON.parseObject(response);
        commonResponseDTO.setStatus(jsonObject.getString("status"));
        commonResponseDTO.setMessage(jsonObject.getString("message"));
        commonResponseDTO.setContext(response);
        // 接口活动跟踪记录对象设置值
        interfaceActiveRecord.setIsActuallyCall("N");
        interfaceActiveRecord.setCallDescription("查询Redis缓存返回公司数据");
        interfaceActiveRecord.setCallResponseStatus(jsonObject.getString("status"));
        interfaceActiveRecord.setCallResponseMessage(jsonObject.getString("message"));
        interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);
        return commonResponseDTO;
    }
    // 4. 缓存为空,则查询数据库,判断数据库数据是否为空
    interfaceCache = interfaceCacheMapper.findCacheByCondition(QX_GET_BUSINESS_DETAIL_INTERFACE_ID,cacheKey,new Date());
    if(interfaceCache != null) {
        // 数据库中有数据,根据桶名和文件名获取数据
        response = getDataByFile(interfaceCache.getBucketName(), interfaceCache.getFileName());
        // 返回结果
        JSONObject companyJSON = JSON.parseObject(interfaceCache.getCallResponseContext());
        commonResponseDTO.setStatus(companyJSON.getString("status"));
        commonResponseDTO.setMessage(companyJSON.getString("message"));
        commonResponseDTO.setContext(response);
        // 写入缓存
        stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey,JSON.toJSONString(interfaceCache), cacheDays, TimeUnit.DAYS);
        // 接口活动跟踪记录对象设置值
        interfaceActiveRecord.setIsActuallyCall("N");
        interfaceActiveRecord.setCallDescription("查询数据库缓存返回公司数据");
        interfaceActiveRecord.setCallResponseStatus(companyJSON.getString("status"));
        interfaceActiveRecord.setCallResponseMessage(companyJSON.getString("message"));
        interfaceActiveRecordMapper.insertWithParam(interfaceActiveRecord);
        return commonResponseDTO;
    }
    // 5. 数据库数据也为空,说明数据已过期或从未查询过,则调用启信官方接口查询数据
    InterfaceCache interfaceCacheInsert = new InterfaceCache();
    try {
        log.info("调用启信查询1.41 工商照面接口入参\n{}", cacheKey);
        // 发送网络请求获取响应数据
        response = httpsWithRestTemplate(appkey,
                                         String.valueOf(System.currentTimeMillis()),
                                         secret_key,
                                         getBusinessBasicDetailUrl(cacheKey));
        log.info("调用启信查询1.41 工商照面接口返回数据\n{}", response);
        // 不管是否成功,都返回接口响应
        commonResponseDTO.setStatus(JSON.parseObject(response).getString("status"));
        commonResponseDTO.setStatus(JSON.parseObject(response).getString("message"));
        commonResponseDTO.setContext(String.valueOf(JSON.parseObject(response)));
        // 不管是否成功,接口活动跟踪记录对象设置值
        interfaceActiveRecord.setCallResponseStatus(JSON.parseObject(response).getString("status"));
        interfaceActiveRecord.setCallResponseMessage(JSON.parseObject(response).getString("message"));
        interfaceActiveRecord.setIsActuallyCall("Y");
        interfaceActiveRecord.setCallDescription("调用启信宝接口获取数据");
        // 但判断状态是否为200,只有200写入数据库和缓存。因为可能出现201-余额不足;202-查询无结果等情况......
        if (response != null && "200".equals(JSON.parseObject(response).getString("status"))) {
            // 保存接口返回数据到本地文件
            Map<String, String> map = saveDataToFile(response);
            // 接口缓存对象赋值
            interfaceCacheInsert.setConsumerServiceName("XXXService");
            interfaceCacheInsert.setInterfaceId(QX_GET_BUSINESS_DETAIL_INTERFACE_ID);
            interfaceCacheInsert.setInterfaceName(QX_GET_BUSINESS_DETAIL_INTERFACE_NAME);
            interfaceCacheInsert.setInterfaceParam(cacheKey);
            interfaceCacheInsert.setBucketName(map.get("bucketName"));
            interfaceCacheInsert.setFileName(map.get("fileName"));
            interfaceCacheInsert.setCallInputTime(new Date());
            interfaceCacheInsert.setCreateTime(new Date());
            interfaceCacheInsert.setExpiredTime(new Date(new Date().getTime() + 30L * 24 * 60 * 60 * 1000));
            // 将接口返回数据插入数据库
            interfaceCacheMapper.insert(interfaceCacheInsert);
            // 插入数据库之后,interfaceCacheInsert 对象是一条带有主键ID值的完整数据,存入缓存
            stringRedisTemplate.opsForValue().set(QX_GET_BUSINESS_DETAIL_CACHE_PREFIX + cacheKey, JSON.toJSONString(interfaceCacheInsert), cacheDays, TimeUnit.DAYS);
        }
    } catch (Exception e) {
        interfaceActiveRecord.setIsActuallyCall("N");
        interfaceActiveRecord.setCallDescription("调用启信宝接口获取数据失败");
        interfaceActiveRecord.setCallResponseMessage(e.getMessage());
        throw new RuntimeException("企业基本信息数据写入本地缓存发生错误",e);
    } finally {
        // 6. 插入接口活动跟踪记录,不管调用是否成功,都要进行记录,放到 finally 块中
        interfaceActiveRecordMapper.insert(interfaceActiveRecord);
    }
    return commonResponseDTO;
}
5.4 二者混合使用

这一种方法,也不失为一种解决思路。

比如我们公司一共要对接10个企查查相关接口,有大接口返回大量数据(10~50K),有小接口返回少量数据(1~3K),此时我们就可以混合上面的两种方法,大接口采用文件指针的解决思路,小接口采用缓存直接存储的解决思路,可以达到部分接口提高响应效率,同时大接口又不会过度占用 Redis 内存。

代码就不详细举例了,只是将上面两种方案的代码都复制使用即可。不过这种方法,做起来复杂度较高就是了,各位开发者同学可以根据司机项目需求的需要,选择相对应的解决方案!

六. 简要总结

综上所述,可以简单总结为以下三句话。

(1) 对接第三方SDK接口时,如果响应体较小,且希望提高服务器响应效率,则可以将接口响应整个存储数据库和 Redis 缓存,实现复杂度低,响应效率高,缺点是大量请求时,可能导致 Redis 内存占用较高;

(2) 如果响应体较大,建议将响应体数据缓存到磁盘文件或指定存储服务器,将文件指针作为字段值存入数据库,读取文件时,先获取文件指针,然后通过文件指针读取文件缓存数据,响应给前端,缺点是因为需要进行文件IO操作,所以响应效率不如直接存储 Redis;

(3) 也可以结合二者,大接口使用文件指针,小接口直接存储 Redis,但编码复杂度较高,后续维护可能会略显复杂;

相关推荐
huihui45039 分钟前
一天一道Sql题(day01)
数据库
~尼卡~41 分钟前
软考(软件设计师)数据库原理:事务管理,备份恢复,并发控制
数据库·软件设计师-软考
八九燕来1 小时前
Django双下划线查询
数据库·django·sqlite
眠りたいです1 小时前
Mysql常用内置函数,复合查询及内外连接
linux·数据库·c++·mysql
paopaokaka_luck2 小时前
智能推荐社交分享小程序(websocket即时通讯、协同过滤算法、时间衰减因子模型、热度得分算法)
数据库·vue.js·spring boot·后端·websocket·小程序
He.ZaoCha2 小时前
函数-1-字符串函数
数据库·sql·mysql
二当家的素材网2 小时前
Centos和麒麟系统如何每天晚上2点10分定时备份达梦数据库
linux·数据库·centos
白仑色2 小时前
Oracle 存储过程、函数与触发器
数据库·oracle·数据库开发·存储过程·plsql编程
头发那是一根不剩了4 小时前
Spring Boot 多数据源切换:AbstractRoutingDataSource
数据库·spring boot·后端
草履虫建模4 小时前
Redis:高性能内存数据库与缓存利器
java·数据库·spring boot·redis·分布式·mysql·缓存