前言
一般而言,首先能想到后台缓存有以下几种方案:
- 使用guava等第三方工具类提供的缓存能力
- 自己基于集合类实现
- 内存缓存配合本地文件系统实现
- 使用Redis缓存中间件
使用本地内存实现缓存都优点是缓存数据更靠近用户端,以空间换时间. 但是由于数据是分散存储的,如果数据有变更则必须同时更新所有应用实例的缓存数据,否则会出现数据不一致的情况.
而使用缓存中间件可以利用Nosql数据库进行集中式管理缓存数据,一般数据变更后删除缓存,下次查询数据再更新进缓存. 优点是引进中间件提供通用缓存功能,各应用无需自己实现. 缺点需要维护额外的中间件,如果中间件是多应用共用,一个应用缓存使用不当会影响到其他应用.当然我们也可以采取一些措施来减少这种影响. 另外一个缺点就是如果有比较多的大Key再会影响Redis的缓存性能.
基于MySQL实现的缓存方案.
为什么要这么做?
我们实际后台中经常会出现比较大的数据集,比如XXX排行榜,XXX结构体之类的.这些数据的特点是不经常更新,数据比较大.缓存Key数量也就百数量级以内了.
针对这种场景,我们一般不太想使用Redis等缓存中间件来增加系统复杂性. 但是使用本地缓存,又必须在应用启动时把数据加载到内存中. 增加了应用启动的负担,降低开发效率. 假如我们的数据又是基于大数据, 我们知道大数据查询的API响应时间一般比较长. 此时我们也常常会考虑使用文件系统来缓冲数据, 启动直接读本地缓存. 然后定时更新数据,更新文件.
这样做在物理机部署时问题不大,但是一旦我们系统上云了. 则可能面对每次启动服务都需要创建一次缓存文件. 这会使情况变得更为糟糕
需要解决什么问题
既然是缓存,那么就必须要解决缓存都几个问题即:
- 缓存数据存储
- 缓存更新
我的方案是如何做的呢?
- 关于数据存储: 使用Gson等Json工具将Collection Map Object转换成字符串, 字符串通过getBytes(StandardCharsets.UTF_8)转换成byte[] 存储到MySQL到 BLOB字段里. 为什么要转换成byte[]. 我们知道二进制用来传输数据,没有中间转换环节,是非常安全的,这里说的不是网络安全,了解中文乱码的同学应该会深有感触
为什么使用Gson, 第一是API简单,第二是增减对象字段不会反序列化失败 (这点很重要). 笔者曾考虑使用SerializationUtils, 但是要求model实现Serializable接口. 但List Map等没有实现啊.这也不难,使用一个实现了序列化接口的对象包装List Map也可以, 但是增减字段那就没办法了.
2022-04-10更新:实际使用时,我碰到过比较大的集合对象,达到几十M。像List Map互相嵌套那种,这个时候如果实时用Gson去反序列化,效率会非常低,可能要去到10秒以上。而使用Serializable方式效率高很多。我看到有的博主说Json反序列化一般性能优于JDK序列化。这很有可能是基于简单POJO对象测试的,使用场景是跨进程调用的序列化(http rpc等),这个时候确实对性能要求较高,很明显,这篇文章不是这个场景
- 关于缓存更新: 如果直接查mysql,其实也不存在缓存数据更新问题. 但是因为我们缓存Value大且更新可能是几个小时一次,甚至一天一次. 所以可以使用内存二级缓存来提升性能. 这就有缓存更新的问题了. 实现原理也很简单,依然每次都去查数据库. 但是只是比对数据是否有更新, 使用版本号,或更新时间均可. 那么查询速度会非常快, 满足后台场景绰绰有余.
核心代码
import com.google.common.collect.Maps;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import lombok.AllArgsConstructor;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Objects;
@Slf4j
@Component
@AllArgsConstructor
public class ObjectCacheService {
private final CacheMapper cacheMapper;
private final Map<String, CachePO> localCache = Maps.newHashMapWithExpectedSize(96);
private final Gson GSON = new Gson(); // 多线程安全
public <T> void save(String key, T t) {
CachePO entity = getEntity(key);
String s = GSON.toJson(t);
entity.setObjectCache(s.getBytes(StandardCharsets.UTF_8));
cacheMapper.save(entity);
}
public <T> T get(String key, TypeToken<T> typeToken) {
if (Objects.isNull(key)) {
return null;
}
CachePO po = cacheMapper.findByKey(key);
if (Objects.nonNull(po) && Objects.nonNull(po.getObjectCache())) {
return GSON.fromJson(new String(po.getObjectCache(), StandardCharsets.UTF_8), typeToken.getType());
}
return null;
}
public <T> T getLocalCached(@NonNull String key, TypeToken<T> typeToken) {
CachePO entity = getCachePO(key);
if (Objects.nonNull(entity) && Objects.nonNull(entity.getObjectCache())) {
return GSON.fromJson(
new String(entity.getObjectCache(),StandardCharsets.UTF_8), typeToken.getType());
}
log.warn("no-object-cache for {}", key);
return null;
}
private CachePO getEntity(String key) {
CachePO entity = cacheMapper.findByKey(key);
if (Objects.isNull(entity)) {
entity = new CachePO();
entity.setKey(key);
}
return entity;
}
/**
* 有最新的获取最新,没有就拿缓存里的
*/
private CachePO getCachePO(String key) {
boolean needUseRemote = false; // 如果需要使用MySQL 中的数据,设置为true
CachePO CachePO = localCache.get(key);
if (Objects.isNull(CachePO)) {
needUseRemote = true; // 缓存为空
} else {
// 有新的缓存
Date cacheTime = CachePO.getUpdatedAt();
int count = cacheMapper.countByKeyAndUpdatedAtAfter(key, cacheTime);
if (count > 0) {
needUseRemote = true;
}
}
if (needUseRemote){
CachePO entity = cacheMapper.findByKey(key);
localCache.put(key, entity);
}
return localCache.get(key);
}
}
CREATE TABLE `object_cache` (
`cache_key` varchar(50) NOT NULL COMMENT 'key值',
`cache_value` mediumblob COMMENT 'value值', -- 请关注blob mediumblob longblob大小
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`cache_key`)
)DEFAULT CHARSET=utf8 COMMENT='缓存表'