业务维护用户合同数据表,当合同表数据量造成某些性能瓶颈(B+树深度增加,带来的磁盘IO性能开销;数据库实例的负载瓶颈;DBA数据运维压力,备份和恢复耗时)的时候,通常就会采取分库表+缓存等方案为数库;这篇不可不打算聊分库表,分库表参考:2、mysql数据分库表实践 ;这篇博客主要聊通过Redis缓存合同数据的一些事儿;
当我们缓存合同数据的时候,缓存使用的众多问题中——击穿、穿透、雪崩、预热、限流、降级、存储pojo、一致性中等,结合实际业务场景我们对一致性和存储POJO方面的问题定制可行方案;这两个方面我应该考虑哪些问题?
- 更新缓存数据我们常用的 Cache Aside Pattern 原理&优点、缺陷是什么?
- 如何解决Cache Aside Pattern存在的一致性缺陷问题?
- 存储POJO时,我们会面临哪些序列化问题?如何解决?
我们为什么使用Cache Aside Pattern(旁路缓存)更新缓存?
首先看一下Cache Aside Pattern更新缓存的过程:
Cache Aside Pattern模式的方案设计的核心优秀思想:
(1)、删除缓存,而非更新缓存(lazy计算的思想)
- 避免了复杂业务场景,缓存数据不单单是数据库中直接获取数据;例如,更新某个表的字段,需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值;
- 频繁更新一个,没有使用的缓存是一件很不划算的事儿;举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据;
(2)、先删除缓存,再修改数据库
- 如果先修改库,删除缓存失败,会导致读取到的缓存中的旧数据,出现数据不一致;
- 先删除缓存,只是删除成功才会去更新数据库;即使更新库失败了,读取缓存的时候还会重新读库、入缓存;(高并发情况没考虑);
Cache Aside Pattern模式的方案设计的缺陷&待解决:
- 当并发量高的情况;即使是先删除缓存,后更新数据库,当数据还没有完成修改时;一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。数据库和缓存中的数据不一样了。。。
如何解决Cache Aside Pattern带来的问题?
围绕这个核心不一致问题,业务合同数据缓存的解决方案核心流程://todo 此处少一个流程图
- 通过分布式锁+事务增强+更新操作持久化同步队列+定时补单 共同保证DB和Redis数据的最终一致性;
1、合同数据获取核心逻辑
/**
* 走缓存策略加载数据
* @param customId
* @param productNo
* @return
*/
@Override
public ContractCacheInfo loadFromCache(String customId, String productNo) {
// 获取缓存数据,若缓存数据不存在做更新
ContractCacheInfo cacheInfo=(ContractCacheInfo)defaultTradeCache.query(calculator.getQueryKey(customId, productNo),() -> cacheService.loadFromDB(customId, productNo));
return cacheInfo;
}
@Override
public T query(String key, Supplier<T> dbSupplier) {
...
// 校验是否存在锁。如果检测lock发生异常,认为有锁
boolean locked = true;
try {
locked = redisLock.existsLock(lockerKeyCalculator.apply(key));
} catch (Throwable t) {
}
if (locked) {
return dbSupplier.get();
}
T info = null;
try {
// 校验锁不存在,则先从缓存中获取数据,反序列化
byte[] data = redis.get(key.getBytes(Charsets.UTF_8));
if (data != null) {
info = serializer.decode(data, clazz);
}
} catch (Throwable ex) {
}
if (info != null) {
return info;
}
// 缓存不存在;查询DB后,load data to db
return getAndCache(key, dbSupplier, serializer, conf);
}
/**
* 加载DB数据到,写入缓存(加分布式锁)
*/
private <T> T getAndCache(String key,Supplier<T> dbSupplier,
Serializer<T> serializer,TradeCacheConfig conf) {
String lockKey = lockerKeyCalculator.apply(key);
// 获取分布式锁;加锁失败,直接读库,不加载缓存;加锁成功,才读库且加载缓存
boolean locked = redisLock.tryLock(lockKey, conf.lockExpireInMs());
try {
if (!locked) {
return dbSupplier.get();
}
// 查询DB数据 (DB 操作异常,直接对外抛出异常)
T data = dbSupplier.get();
if (data != null) {
redis.setex(key.getBytes(Charsets.UTF_8), Math.max(conf.cacheExpireInMs() / 1000, 1),serializer.encode(data));
}
return data;
}catch(Exception e){
...
}finally {
if (locked) {
//解锁失败,不进行处理,这样可能会导致下次加锁加不上
try {
redisLock.unLock(lockKey);
} catch (Throwable t) {
...
}
}
}
}
- 获取缓存数据前,先校验是否有用户数据的分布式锁;如果有数据变更添加的锁,使用DB获取数据直接返回,不做缓存更新;
- 若不存在锁,则首先读取缓存数据;若缓存二进制数据存在,进行反序列化后返回;
- 若获取缓存数据不存在,则添加用户数据变更的分布式锁,加锁成功时使用DB获取数据,并序列化为二进制数据后setex到Redis;若加锁失败,只使用DB获取数据返回,不做Redis缓存更新;
- 最后记得释放锁;
2、合同数据变更核心逻辑
/**
* 更新合同数据
*/
private void procPaySwitch() {
...
defaultTradeCache.execute(calculator.getCacheKey(pc.getCustomId(), pc.getProductNo().getProductNo()),template, new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
// 更新数据
platContractService.updateByIdAndPlatStatus(pc, tarPlatStatus, descStatus, Boolean.TRUE);
platContractHistoryMapper.save(pcHis);
}
});
}
@Override
public <TR> TR execute(Set<String> cacheKeys,
TransactionTemplate transTemplate,
TransactionCallback<TR> action)
throws TransactionException {
if (cacheKeys == null || cacheKeys.size() == 0) {
cacheKeys = Collections.emptySet();
}
// 添加需要处理的 key
cacheThreadLocal.addCacheKeys(cacheKeys);
// 加锁,加锁失败,依然可以正常进行,加锁会进行必要的重试
cacheKeys.forEach(cacheKey -> cacheThreadLocal.setCacheLock(cacheKey, lockBeforeTrans(cacheKey)));
return transTemplate.execute((status) -> {
try {
// 注册事物事务处理监听,从而实现事物提交后做处理。
if (!cacheThreadLocal.hasCacheSynchronizer()) {
JdbcTemplate jt = new JdbcTemplate(getDataSource(transTemplate));
TransactionSynchronizationManager.registerSynchronization(
new CacheTransactionSynchronizer(
this.cacheThreadLocal,
t -> CacheKeyStore.save(jt, t),
t -> deleteCache(t, jt.getDataSource()),
() -> {
if (conf instanceof DefaultTradeCacheConfig) {
((DefaultTradeCacheConfig) conf).clearOnceSwitch();
}
}));
cacheThreadLocal.setCacheSynchronizer();
}
} catch (Exception e) {
Metrics.sum("trade_cache", "add_cache_syncer:error:count");
}
// 执行DB 操作
return action.doInTransaction(status);
});
}
/**
* 添加即将缓存的cache key
*
* @param cacheKeySet
*/
public void addCacheKeys(Set<String> cacheKeySet) {
if (CollectionUtils.isEmpty(cacheKeySet)) {
return;
}
Set<CacheKey> keys = cacheKeys.get();
if (CollectionUtils.isEmpty(keys)) {
cacheKeys.set(cacheKeySet.stream().map(s -> new CacheKey(s)).collect(Collectors.toSet()));
} else {
cacheKeySet.stream().forEach(c -> {
CacheKey newkey = new CacheKey(c);
if (!keys.contains(newkey)) {
keys.add(newkey);
}
});
}
}
/**
* 事物前加锁操作
* 尝试加锁3次,每次间隔200ms,加锁成功返回true,失败返回false
*/
private boolean lockBeforeTrans(String cacheKey) {
//缓存当前不可操作 ,不进行加锁
if (!(redis.isOnline() && conf.setEnable())) {
return false;
}
//加锁前先确认是否已经加锁,已经加锁直接返回(嵌套事务相关)
boolean hasLocker = cacheThreadLocal.hasCacheLock(cacheKey);
if(hasLocker){
return true;
}
//加锁逻辑
int tryCounts = 0;
while (tryCounts++ < 3) {
try {
boolean locked = redisLock.tryLock(lockerKeyCalculator.apply(cacheKey), conf.lockExpireInMs());
if (locked) {
return locked;
}
} catch (Exception ex) {
log.error("setCacheLock has error, key:{}", cacheKey, ex);
}
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
}
if (tryCounts > 1) {
Metrics.sum("trade_cache", "lockBeforeTrans:try:count");
}
}
return false;
}
/**
* 删除缓存
*/
private void deleteCache(CacheKey cacheKey, DataSource dataSource) {
JdbcTemplate jt = new JdbcTemplate(dataSource);
boolean hasLocker = cacheThreadLocal.hasCacheLock(cacheKey.getCacheKey());
log.debug("deleteCache hasLocker:{},cacheKey.getCacheKey:{}", hasLocker, cacheKey.getCacheKey());
// 同步处理若成功
if (!deleteSync(cacheKey, hasLocker, jt)) {
// 异步处理
deleteAsync(cacheKey, jt);
}
}
/**
* 同步清除缓存
*/
private boolean deleteSync(final CacheKey cacheKey, boolean hasLock, JdbcTemplate jdbc) {
if (!redis.isOnline()) {
return true;
}
String lockerKey = lockerKeyCalculator.apply(cacheKey.getCacheKey());
//是否已经加过锁
boolean locked = hasLock;
if (!hasLock) {
try {
locked = redisLock.tryLock(lockerKey, conf.lockExpireInMs());
} catch (Throwable t) {
Metrics.sum("trade_cache", "delsynctrylock:error:count");
}
if (!locked) {
return false;
}
}
try {
try {
// 删除缓存
this.redis.del(cacheKey.getCacheKey());
// 删除DB
CacheKeyStore.delete(jdbc, cacheKey);
return true;
} catch (Throwable t) {
Metrics.sum("trade_cache", "delsyncdel:error:count");
log.error("delete cache error from redis and db, errMsg={}", t.getMessage(), t);
}
} finally {
if (locked) {
try {
redisLock.unLock(lockerKey);
} catch (Throwable t) {
Metrics.sum("trade_cache", "delsyncunlock:error:count");
}
}
}
return false;
}
/**
* 异步清除缓存
*/
private void deleteAsync(final CacheKey cacheKey, JdbcTemplate jdbc) {
// 线程异步处理
executor.execute(() -> {
// 异步重试
for (int i = 0; i < conf.deleteCacheRetryIntervalsInMs().length; i++) {
try {
//间歇时间
Thread.sleep(conf.deleteCacheRetryIntervalsInMs()[i]);
if (deleteSync(cacheKey, false, jdbc)) {
Metrics.sum("trade_cache", "async_del_cache_retry_times:count");
break;
}
} catch (Throwable e) {
Metrics.sum("trade_cache", "delasync:error:count");
log.error("asyncDelCache has exp", e);
}
}
});
}
- 合同数据变更的编程事务进行增强;事务开启前先对操作数据加分布式锁;可以批量添加;
private final ThreadLocal<Set<CacheKey>> cacheKeys = new ThreadLocal<>();
private final ThreadLocal<Map<String, Boolean>> cacheLock = new ThreadLocal<>();
private final ThreadLocal<Boolean> cacheSynchronizer = new ThreadLocal<>();
- 注册事物事务处理监听,从而实现事物提交后做处理。
/**
* 事务提交前后进行一些必要的处理
*/
public class CacheTransactionSynchronizer extends TransactionSynchronizationAdapter {
public CacheTransactionSynchronizer(CacheThreadLocal cacheThreadLocal,
Consumer<CacheKey> saveCacheKey,
Consumer<CacheKey> deleteCache,
Runnable clearFunc) {
this.cacheThreadLocal = cacheThreadLocal;
this.saveCacheKey = saveCacheKey;
this.deleteCache = deleteCache;
this.clearFunc = clearFunc;
}
@Override
public void beforeCommit(boolean readOnly) {
Set<CacheKey> cacheKeys = cacheThreadLocal.getCacheKeys();
if (CollectionUtils.isEmpty(cacheKeys)) return;
try {
//将这些cache 可以插入到待删除的列表中
cacheKeys.forEach(cacheKey -> saveCacheKey.accept(cacheKey));
Metrics.sum("trade_cache", "before_commit:save_cache_key:succ:count");
} catch (Exception ex) {
TradeCacheLog.log.error("beforeCommit ex", ex);
Metrics.sum("trade_cache", "before_commit:save_cache_key:fail:count");
}
}
@Override
public void afterCommit() {
// 删除缓存处理
Set<CacheKey> cacheKeys = cacheThreadLocal.getCacheKeys();
if (CollectionUtils.isEmpty(cacheKeys)) return;
try {
cacheKeys.forEach(cacheKey -> deleteCache.accept(cacheKey));
Metrics.sum("trade_cache", "after_commit:clear_cache:succ:count");
} catch (Exception ex) {
TradeCacheLog.log.error("afterCommit has exp.", ex);
Metrics.sum("trade_cache", "after_commit:clear_cache:fail:count");
}
if (clearFunc != null) {
clearFunc.run();
}
}
@Override
public void afterCompletion(int status) {
Metrics.sum("trade_cache", "after_completion:clear_thread_local:count");
cacheThreadLocal.clear();
}
private CacheThreadLocal cacheThreadLocal;
private Consumer<CacheKey> saveCacheKey;
private Consumer<CacheKey> deleteCache;
private Runnable clearFunc;
}
- 事务提交前(beforeCommit):将这些cache 删除操作,插入到待删除的同步队列中;队列存储介质这里使用的一个数据库表;列表起队列的作用,主要是数据变更操作串行化;
- 事务提交后(afterCommit):删除Redis缓存,并删除缓存变更的操作队列DB数据;完成后解锁;
- 事务完成后(afterCompletion):清理ThreadLocol中的数据标识、锁定标识;
3、合同数据变更补定时逻辑
由于读请求进行了非常轻度的异步化,所以一定要注意读超时的问题,每个读请求必须在超时时间范围内返回,该解决方案,最大的风险点在于说,可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库。务必通过一些模拟真实的测试,看看更新数据的频率是怎样的。
另外一点,因为一个队列中,可能会积压针对多个数据项的更新操作,因此需要根据自己的业务情况进行测试,可能需要部署多个服务,每个服务分摊一些数据的更新操作。
如果一个DB数据队列积压100个合同变更操作,每个用户合同修改操作要耗费 10ms 去完成,那么最后一个用户合同的读请求,可能等待 10 * 100 = 1000ms = 1s 后,才能得到数据,这个时候就导致读请求的长时阻塞。
一定要做根据实际业务系统的运行情况,去进行一些压力测试,和模拟线上环境,去看看最繁忙的时候,内存队列可能会挤压多少更新操作,可能会导致最后一个更新操作对应的读请求,会 hang 多少时间
如果读请求在 200ms 返回,如果你计算过后,哪怕是最繁忙的时候,积压 10 个更新操作,最多等待 200ms,那还可以的。
如果一个内存队列中可能积压的更新操作特别多,那么你就要加机器,让每个机器上部署的服务实例处理更少的数据,那么每个内存队列中积压的更新操作就会越少。
其实根据之前的项目经验,一般来说,数据的写频率是很低的,因此实际上正常来说,在队列中积压的更新操作应该是很少的。像这种针对读高并发、读缓存架构的项目,一般来说写请求是非常少的,每秒的 QPS 能到几百就不错了。
DB更新用户合同数据修改操作队列,补处理定时代码,每隔1s执行一次:
/**
* 离线删除缓存
*/
@QSchedule("fintech.contract.online.del.cache")
public void onlineProcDelKey(Parameter parameter) {
final TaskParamDto<String> dto = builderTaskProcessUtil()
.analyParam(parameter, "online.del.cache", LOGGER, LOGGER, "");
builderTaskProcessUtil().handle(dto);
}
private TaskProcessUtil<CacheKey, String> builderTaskProcessUtil() {
return new TaskProcessUtil<CacheKey, String>() {
DataSource dataSource = ((DataSourceTransactionManager) template.getTransactionManager()).getDataSource();
@Override
public void consume(CacheKey queue, TaskParamDto<String> taskParamDto) {
try {
defaultTradeCache.delete(queue.getCacheKey(), dataSource);
LOGGER.info("online.del.cache cacheQueue:{}", queue);
} catch (Exception ex) {
LOGGER.error("online.del.cache has exp, cacheQueue:{}", queue, ex);
}
}
@Override
public List<CacheKey> produce(TaskParamDto<String> paramDto) {
// 默认一次处理 200 条数据
Integer pageSize = paramDto.getPageSize();
if (pageSize == 0) {
pageSize = 200;
}
return CacheKeyStore.query(new JdbcTemplate(dataSource), pageSize);
}
};
}
@Override
public void delete(String cacheKey, DataSource dataSource) {
deleteCache(new CacheKey(cacheKey), dataSource);
}
/**
* 删除缓存
*/
private void deleteCache(CacheKey cacheKey, DataSource dataSource) {
JdbcTemplate jt = new JdbcTemplate(dataSource);
boolean hasLocker = cacheThreadLocal.hasCacheLock(cacheKey.getCacheKey());
log.debug("deleteCache hasLocker:{},cacheKey.getCacheKey:{}", hasLocker, cacheKey.getCacheKey());
// 同步处理若成功
if (!deleteSync(cacheKey, hasLocker, jt)) {
// 异步处理
deleteAsync(cacheKey, jt);
}
}
参考文档: