缓存设计 - 969251639/study GitHub Wiki

开始之前

之前在项目中更新缓存都是用最老套的写法,先是在spring中配置aop

	<!-- 客户Redis操作切面 -->
	<bean id="xxxRedisAspectImpl" class="xxx.xxx.xxx.XxxRedisAspectImpl" />
	<!-- 新增同步Redis-->
	<aop:config proxy-target-class="true">
		<aop:pointcut id="addXxxPointcut" expression="execution(* xxx.xxx.xxx.IXxxServComponent.addXxx(..))" />
		<aop:aspect ref="xxxRedisAspectImpl">
			<aop:after pointcut-ref="addXxxPointcut" method="doAddAfter"/>
		</aop:aspect>
	</aop:config>
	
	<!-- 修改同步Redis-->
	<aop:config proxy-target-class="true">
		<aop:pointcut id="updateXxxPointcut" expression="execution(* xxx.xxx.xxx.IXxxServComponent.updateXxx(..))" />
		<aop:aspect ref="xxxRedisAspectImpl">
			<aop:after pointcut-ref="updateXxxPointcut" method="doEditAfter"/>
		</aop:aspect>
	</aop:config>
	
	<!-- 删除同步Redis-->
	<aop:config proxy-target-class="true">
		<aop:pointcut id="deleteXxxPointcut" expression="execution(* xxx.xxx.xxx.IXxxServComponent.deleteXxxById(..))" />
		<aop:aspect ref="xxxRedisAspectImpl">
			<aop:after pointcut-ref="deleteXxxPointcut" method="doDeleteAfter"/>
		</aop:aspect>
	</aop:config>

然后在代码中编写缓存同步

public class XxxRedisAspectImpl implements IXxxRedisAspect {
	...
	
	@Override
	public void doAddAfter(JoinPoint jp) {
	    ...
            jedis.hset(Map.Key, key, value);
            ...
	}

	@Override
	public void doEditAfter(JoinPoint jp) {
	    ...
            jedis.hset(Map.Key, key, value);
            ...
	}

	@Override
	public void doDeleteAfter(JoinPoint jp) {
	    ...
            jedis.hdel(Map.Key, key);
            ...
	}
}

上面的写法缺陷太多,开发效率低,多线程完全没考虑等

优化思路

在需要缓存的地方不管增删改查,直接在方法上配置即可,所以使用注解的方式解决,最终实现的代码如下

	@Override
	@Transactional
	@SyncRedis(type=SyncRedis.TYPE.HASH, key=CacheConstant.MAP_KEY, paramName="xxx", objField={"organizationId"})
	public int updateById(XxxVo xxx) throws ServiceException {
		return xxxDao.updateById(xxx);
	}

	@Override
	@GetRedisCache(type=GetRedisCache.TYPE.HASH, key=CacheConstant.MAP_KEY, paramName="xxxId")
	public Xxx queryById(String xxxId) throws ServiceException {
		return xxxDao.queryById(xxxId);
	}

使用起来就是简单方便,简单说下上面的效果

  1. SyncReids: tyep可以是hash类型或者key类型的缓存,key就是redis的key,因为是hash类型,那么参数paramName中的objField(可由多个组成)就是hash的key,当数据更新成功后会将该key删除掉(为什么是删除后面讲)

  2. GetRedisCache:
    tyep可以是hash类型或者key类型的缓存,key就是redis的key,因为是hash类型,那么参数paramName就是hash的key(如果是从参数对象提取key,需要和objField配合使用),另外GetRedisCache还可以支持配置分布式锁来控制缓存的读取只允许一个线程线程读,设置value可为null,设置过期时间的随机范围等

常见缓存问题

  1. 缓存穿透
    在高并发下,查询一个不存在的值时,缓存不会被命中,导致大量请求直接落到数据库上。

解决方案:

	@Override
	@GetRedisCache(type=GetRedisCache.TYPE.HASH, key=CacheConstant.MAP_KEY, paramName="xxxId", nullValueExpriedSecond=10)
	public Xxx queryById(String xxxId) throws ServiceException {
		return xxxDao.queryById(xxxId);
	}

在GetRedisCache上配置nullValueExpriedSecond,查询不到时会将null也设置到xxxId为hashkey的缓存中,nullValueExpriedSecond秒后过期

  1. 缓存击穿
    在高并发下,对一个特定的值进行查询,但是这个时候缓存正好过期了,缓存没有命中,导致大量请求直接落到数据库上。

解决方案:

	@Override
	@GetRedisCache(type=GetRedisCache.TYPE.HASH, key=CacheConstant.MAP_KEY, paramName="xxxId", nullValueExpriedSecond=10, cacheLock=@CacheLock(lockKey="xxx", expriedSecond=10))
	public Xxx queryById(String xxxId) throws ServiceException {
		return xxxDao.queryById(xxxId);
	}

在GetRedisCache上配置cacheLock,那么会使用分布式锁允许一个线程去load数据,load完后其他机器或本地线程就能直接get到数据

  1. 缓存雪崩
    在高并发下,大量的缓存key在同一时间失效,导致大量的请求落到数据库上。

解决方案:

	@Override
	@GetRedisCache(type=GetRedisCache.TYPE.HASH, key=CacheConstant.MAP_KEY, paramName="xxxId", nullValueExpriedSecond=10, cacheLock=@CacheLock(lockKey="xxx", expriedSecond=10), expriedSecond=randomTime)
	public Xxx queryById(String xxxId) throws ServiceException {
		return xxxDao.queryById(xxxId);
	}

在GetRedisCache上配置expriedSecond,那么加载完数据后该缓存的有效时间是expriedSecond秒,这里的expriedSecond用随机函数去生成

其实上面的实现也不难,无非就是通过拦截器拦截上面的两个注解,然后解析注解里面的参数进行处理,实现的拦截器必须加Order注解,Order必须尽量大,一定先保证事务拦截器先提交事务后在去做操作缓存
代码就不贴了,比较简单

之前写的代码只考虑了Redis做缓存,如果缓存其他缓存的话扩展性就不友好,应该实现一个缓存管理器和统一接口适配,其实Spring已经有该部分的实现了,有兴趣可以去搜下SpringCache(也是和上面一样的注解实现)

最后说下为什么在上面的数据更新时选择先更新后删除的方式。
场景:如果数据更新了,那么缓存如果没更新,那么肯定会出现数据不一致问题(缓存和数据库非原子级的事务操作),那么为了解决缓存数据不一致问题,一般都会想到下面的几种方式:

  1. 先更新缓存,然后更新数据库
  2. 先更新数据库,然后更新缓存
  3. 先删除缓存,然后更新数据库,读时再加载
  4. 先更新数据库,然后删除缓存,读时再更新

先更新缓存,然后更新数据库
如果更新缓存成功,但更新数据库失败,那么数据不一致问题从根上就出现了错误,数据库都没更新,缓存就已经有了,会造成业务的绝对数据异常,该方式绝对不推荐使用

先更新数据库,然后更新缓存
该方式可以更新完数据之后,立马同步到缓存,是比较理想的方式,但有些问题需要考虑
假如两个线程并发,执行如下

  • A线程update DB
  • B线程update DB
  • B线程update cache
  • A线程update cache 这种如果在分布式环境更易发生,机器之间基本都不可见,这样很明显会造成缓存的数据是老数据而引起业务异常
    其实出现上面的无非就是无法保证执行顺序性,所以只要将上面的DB操作保持顺序即可,也就是不管是哪台机器上线程,谁先提交了更新数据,谁先去update cache,这样的需求很容易想到队列的先进先出,所以可以考虑用redis的List去实现
  1. begin update DB
  2. 生成Redis队列的value至少包含存储key,缓存value,顺序号
  3. push list
  4. commit

考虑一个这样的问题,A线程update DB后还没提交,B线程update DB提交,然后先去push到redis队列,最后结果也一样乱套。其最终的根本原因还是无法保证redis队列和数据库的原子性,为了解决这种问题也很容易联想到用版本号,而上面的队列的value中的顺序号就是版本号,A线程操作前先获取版本号,然后再提交事务后把这个带版本号的value push到队列,而B线程因为在A线程后面进入,那么获取到的版本号肯定比B大,那么后面不管谁先push到队列,只要版本号大的都能更新,小于当前的版本号丢弃操作。

其实不用队列也是一样,但都要维护这个版本号到缓存内容中,而如何保证版本号在分布式环境的原子递增也是个问题,所以不管怎样该方案可以使用,但实现复杂度高,考虑的点也多,成本高,但有一种情况是完全可以使用的,一般在更新一些后台字典等这种后台人为的且没有并发的

先删除缓存,然后更新数据库,读时再加载

  • A线程delete cache,然后update DB,事务没提交
  • B线程发现cache为null,query数据
  • B线程将query到的数据放到cache
  • A线程提交事务
    最后面两步可以替换,不影响最后的不一致现象
    这种方案的不一致依旧比较麻烦,先delete了cache后,读肯定没法保证能对到最终的update后的数据

先更新数据库,然后删除缓存,读时再更新(该方案是项目中使用的方案)

  • A线程update DB,然后delete cache,事务没提交
  • B线程发现cache为null,query数据
  • B线程将query到的数据放到cache
  • A线程提交事务
    最后面两步可以替换,不影响最后的不一致现象

如果将缓存的删除放到事务提交后执行,那么上面的问题就可以解决,但如果update DB成功后,没有来得及删缓存而导致宕机,那么数据也一样不一致,还有一种情况是如果A线程提交完事务后删除缓存,而项目又是读写分离,写的数据还没有更新到从库,有一个线程又去同步查从库,是旧值,也一样出现不一致,这种情况解决方法也比较直接,发现没有缓存的话,强制去主库查,但牺牲了主从性能

所以最终的执行流程是

  • A线程update DB,事务没提交
  • B线程发现cache为null,query数据
  • B线程将query到的数据放到cache
  • A线程提交事务,然后delete cache
    这时有需要去query数据加载到缓存,但肯定能读到最新的数据

该方案如果在缓存过期时刚好有个线程去读,然后又有个线程去更新,更新后删,然后第一个读到旧数据的线程加载缓存而造成数据不一致,只不过概率很低很低

总结:
不管哪种方式其实都有它的优缺点,但其最核心最根本的问题就是缓存和数据库不是原子操作而引起的,结合项目的业务,选择一个合适的即可,如果真碰到极低概率发生的问题,做好日志输出,人工干预,可以省很多开发成本