面对缓存,有哪些问题需要思考?

移动开发
缓存是用于解决高并发场景下系统的性能及稳定性问题的银弹。本文主要是讨论我们经常使用的分布式缓存Redis在开发过程中需要考虑的问题。

缓存可以说是无处不在,比如:PC电脑中的内存、CPU中有二级缓存、http协议中的缓存控制、CDN加速技术 无不都是使用了缓存的思想来解决性能问题。

缓存是用于解决高并发场景下系统的性能及稳定性问题的银弹。

本文主要是讨论我们经常使用的分布式缓存Redis在开发过程中需要考虑的问题。

面对缓存,有哪些问题需要思考?

1. 如何将业务逻辑与缓存之间进行解耦?

大部分情况,大家都是把缓存操作和业务逻辑之间的代码交织在一起的,比如(代码一):

 

  1. public UserServiceImpl implements UserService { 
  2.     @Autowired 
  3.     private RedisTemplate<String, User> redisTemplate; 
  4.      
  5.     @Autowired 
  6.     private UserMapper userMapper; 
  7.      
  8.     public User getUserById(Long userId) { 
  9.         String cacheKey = "user_" + userId; 
  10.         User user = redisTemplate.opsForValue().get(cacheKey); 
  11.         if(null != user) { 
  12.             return user
  13.         } 
  14.         user = userMapper.getUserById(userId); 
  15.         redisTemplate.opsForValue().set(cacheKey, user); // 如果user 为null时,缓存就没有意义了 
  16.         return user
  17.     } 
  18.      
  19.     public void deleteUserById(Long userId) { 
  20.         userMapper.deleteUserById(userId); 
  21.         String cacheKey = "user_" + userId; 
  22.         redisTemplate.opsForValue().del(cacheKey); 
  23.     } 

从上面的代码可以看出以下几个问题:

  1. 缓存操作非常繁琐,产生非常多的重复代码;
  2. 缓存操作与业务逻辑耦合度非常高,不利于后期的维护;
  3. 当业务数据为null时,无法确定是否已经缓存,会造成缓存无法***;
  4. 开发阶段,为了排查问题,经常需要来回开关缓存功能,使用上面的代码是无法做到很方便地开关缓存功能;
  5. 当业务越来越复杂时,使用缓存的地方越来越多时,很难定位哪些数据要进行主动删除;
  6. 如果不想用Redis,换用别的缓存技术的话,那是多么痛苦的一件事。

因为高耦合带来的问题还很多,就不一一列举了。接下来介绍笔者开源的一个缓存管理框架:AutoLoadCache是如何帮助我们来解决上述问题的。

借鉴于Spring cache的思想使用AOP + Annotation 等技术实现缓存与业务逻辑的解耦。我们再用AutoLoadCache 来重构上面的代码,进行对比(代码二):

 

  1. public interface UserMapper { 
  2.     @Cache(expire = 120, key = "'user_' + #args[0]"
  3.     User getUserById(Long userId); 
  4.      
  5.     @CacheDelete({ @CacheDeleteKey(value = "'user' + #args[0].id") }) 
  6.     void updateUser(User user); 
  7.  
  8. public UserServiceImpl implements UserService { 
  9.      
  10.     @Autowired 
  11.     private UserMapper userMapper; 
  12.      
  13.     public User getUserById(Long userId) { 
  14.         return userMapper.getUserById(userId); 
  15.     } 
  16.     @Transactional(rollbackFor=Throwable.class) 
  17.     public void updateUser(User user) { 
  18.         userMapper.updateUser(user); 
  19.     } 
  20. }

AutoloadCache 在AOP拦截到请求后,大概的流程如下:

  1. 获取到拦截方法的@Cache注解,并生成缓存key;
  2. 通过缓存key,去缓存中获取数据;

如果缓存***,执行如下流程:

  1. 如果需要自动加载,则把相关信息保存到自动加载队列中;
  2. 否则判断缓存是否即将过期,如果即将过期,则会发起异步刷新;
  3. ***把数据返回给用户;

如果缓存没有***,执行如下流程:

  1. 选举出一个leader回到数据源中去加载数据,加载到数据后通知其它请求从内存中获取数据(拿来主义机制);
  2. leader负责把数据写入缓存;如果需要自动加载,则把相关信息保存到自动加载队列中;
  3. ***把数据返回给用户;

这里提到的异步刷新、自动加载、拿来主义机制,我们会在后面再说明。

2. 对缓存进行“包装”

上面代码一的例子中,当从数据源获取的数据为null时,缓存就没有意义了,所获取这个数据的请求,都会回到数据源去获取数据。当请求量非常大的话,会造成数据源负载过高而宕机。所以对于null的数据,需要做特殊处理,比如使用特殊字符串进行替换。而在AutoloadCache中使用了一个包装器对所有缓存数据进行包装(代码三):

 

  1. public class CacheWrapper<T> implements Serializable, Cloneable { 
  2.  
  3.     private T cacheObject; // 缓存数据 
  4.  
  5.     private long lastLoadTime; // 加载时间 
  6.  
  7.     private int expire; // 缓存时长 
  8.      
  9.     /** 
  10.      * 判断缓存是否已经过期 
  11.      * @return boolean 
  12.      */ 
  13.     public boolean isExpired() { 
  14.         if(expire > 0) { 
  15.             return (System.currentTimeMillis() - lastLoadTime) > expire * 1000; 
  16.         } 
  17.         return false
  18.     } 

在这上面的代码中,除了封装了缓存数据外,还封装了数据加载时间和缓存时长,通过这两项数据,很容易判断缓存是否即将过期或者已经过期。

3. 如何提升缓存key生成表达式性能?

使用Annotation解决缓存与业务之间的耦合后,我们最主要的工作就是如何来设计缓存KEY了,缓存KEY设计的粒度越小,缓存的复用性也就越好。

上面例子中我们是使用Spring EL表达式来生成缓存KEY,有些人估计会担心Spring EL表达式的性能不好,或者不想用Spring的情况该怎么办?

框架中为了满足这些需求,支持扩展表达式解析器:继承com.jarvis.cache.script. AbstractScriptParser后就可以任你扩展。

框架现在除了支持Spring EL表达式外,还支持Ognl,javascript表达式。对于性能要求非常高的人,可以使用Ognl,它的性能非常接近原生代码。

4. 如何解决缓存Key冲突问题?

在实际情况中,可能有多个模块共用一个Redis服务器或是一个Redis集群的情况,那么有可能造成缓存key冲突了。

为了解决这个问题AutoLoadCache,增加了namespace。如果设置了namespace就会在每个缓存Key最前面增加namespace(代码四):

 

  1. public final class CacheKeyTO implements Serializable { 
  2.  
  3.     private final String namespace; 
  4.  
  5.     private final String key;// 缓存Key 
  6.  
  7.     private final String hfield;// 设置哈希表中的字段,如果设置此项,则用哈希表进行存储 
  8.  
  9.     public String getCacheKey() { // 生成缓存Key方法 
  10.         if(null != this.namespace && this.namespace.length() > 0) { 
  11.             return new StringBuilder(this.namespace).append(":").append(this.key).toString(); 
  12.         } 
  13.         return this.key
  14.     } 

5. 压缩缓存数据及提升序列化与反序列化性能

我们希望缓存数据包越小越好,能减少内存占用,以及减轻带宽压力;同时也要考虑序列化与反序列化的性能。

AutoLoadCache为了满足不同用户的需要,已经实现了基于JDK、Hessian、JacksonJson、Fastjson、JacksonMsgpack等技术序列化及反序列工具。也可以通过实现com.jarvis.cache.serializer.ISerializer 接口自行扩展。

JDK自带的序列化与反序列化工具产生的数据包非常大,而且性能也非常差,不建议大家使用;JacksonJson 和 Fastjson 是基于JSON的,所有用到缓存的函数的参数及返回值都必须是具体类型的,不能是不确定类型的(不能是Object, List等),另外有些数据转成Json是其一些属性是会被忽略,存在这种情况时,也不能使用Json;

而Hessian 则是非常不错的选择,非常成熟和稳定性。阿里的dubbo和HSF两个RPC框架都是使用了Hessian进行序列化和返序列化。

6. 如何减少回源并发数?

当缓存未***时,都需要回到数据源去取数据,如果这时有多个并发来请求相同一个数据(即相同缓存key请求),都回到数据源加载数据,并写缓存,造成资源极大的浪费,也可能造成数据源负载过高而无法服务。

AutoLoadCache 使用拿来主义机制和自动加载机制来解决这个问题:

拿来主义机制

拿来主交机制,指的是当有多个用户请求同一个数据时,会选举出一个leader去数据源加载数据,其它用户则等待其拿到的数据。并由leader将数据写入缓存。

自动加载机制

自动加载机制,将用户请求及缓存时间等信息放到一个队列中,后台使用线程池定期扫这个队列,发现缓存即将过期,则去数据源加载***的数据放到缓存中。达到将数据长驻内存的效果。从而将这些数据的请求,全部引向了缓存,而不会回到数据源去获取数据。非常适合用于缓存使用非常频繁的数据,以及非常耗时的数据。

为了防止自动加载队列过大,设置了容量限制;同时会将超过一定时间没有用户请求的也会从自动加载队列中移除,把服务器资源释放出来,给真正需要的请求。

往缓存里写数据的性能相比读的性能差非常多,通过上面两种机制,可以减少写缓存的并发,提升缓存服务能力。

7. 异步刷新

AutoLoadCache 从缓存中获取到数据后,借助于上面提到的CacheWrapper,能很方便判断缓存是否即将过期, 如果即将过期,则会把发起异步刷新请求。

使用异步刷新的目的,提前将数据缓存起来,避免缓存失效后,大量请求穿透到数据源。

8. 支持多种缓存操作

大部分情况下,我们都是对缓存进行读与写操作,可有时,我们只需要从缓存中读取数据,或者只写数据,那么可以通过 @Cache 的 opType 指定缓存操作类型。现支持以下几种操作类型:

  1. READ_WRITE:读写缓存操:如果缓存中有数据,则使用缓存中的数据,如果缓存中没有数据,则加载数据,并写入缓存。默认是READ_WRITE;
  2. WRITE:从数据源中加载***的数据,并写入缓存。对数据源和缓存数据进行同步;
  3. READ_ONLY: 只从缓存中读取,并不会去数据源加载数据。用于异地读写缓存的场景;
  4. LOAD :只从数据源加载数据,不读取缓存中的数据,也不写入缓存。

另外在@Cache中只能静态指写缓存操作类型,如果想在运行时调整操作类型,需要通过CacheHelper.setCacheOpType()方法来进行调整。

9. 批量删除缓存

在很多时候,数据查询条件是比较复杂,我们无法获取或还原要删除的缓存key。

AutoLoadCache 为了解决这个问题,使用Redis的hash表来管理这部分的缓存。把需要批量删除的缓存放在同一个hash表中,如果需要需要批量删除这些缓存时,直接把这个hash表删除即可。这时只要设计合理粒度的缓存key即可。

通过@Cache的hfield设置hash表的key。

我们举个商品评论的场景(代码五):

 

  1. public interface ProuductCommentMapper { 
  2.     @Cache(expire=600, key="'prouduct_comment_list_'+#args[0]", hfield = "#args[1]+'_'+#args[2]"
  3.     // 例如:prouductId=1, pageNo=2, pageSize=3 时相当于Redis命令:HSET prouduct_comment_list_1 2_3  List<Long> 
  4.     public List<Long> getCommentListByProuductId(Long prouductId, int pageNo, int pageSize); 
  5.          
  6.     @CacheDelete({@CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId")})  
  7.     // 例如:#args[0].prouductId = 1时,相当于Redis命令: DEL prouduct_comment_list_1 
  8.     public void addComment(ProuductComment comment) ; 
  9.      

如果添加评论时,我们只需要主动删除前3页的评论(代码六):

 

  1. public interface ProuductCommentMapper { 
  2.     @Cache(expire=600, key="'prouduct_comment_list_'+#args[0]+'_'+#args[1]", hfield = "#args[2]"
  3.     public List<Long> getCommentListByProuductId(Long prouductId, int pageNo, int pageSize); 
  4.          
  5.     @CacheDelete({ 
  6.         @CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_1'"), 
  7.         @CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_2'"), 
  8.         @CacheDeleteKey(value="'prouduct_comment_list_'+#args[0].prouductId+'_3'"
  9.     })  
  10.     public void addComment(ProuductComment comment) ; 
  11.      

10. 双写不一致问题

在“代码二”中使用updateUser方法更新用户信息时, 同时会主动删除缓存中的数据。 如果在事务还没提交之前又有一个请求去加载用户数据,这时就会把数据库中旧数据缓存起来,在下次主动删除缓存或缓存过期之前的这一段时间内,缓存中的数据与数据库中的数据是不一致的。AutoloadCache框架为了解决这个问题,引入了一个新的注解:@CacheDeleteTransactional (代码七):

 

  1. public UserServiceImpl implements UserService { 
  2.      
  3.     @Autowired 
  4.     private UserMapper userMapper; 
  5.      
  6.     public User getUserById(Long userId) { 
  7.         return userMapper.getUserById(userId); 
  8.     } 
  9.     @Transactional(rollbackFor=Throwable.class) 
  10.     @CacheDeleteTransactional 
  11.     public void updateUser(User user) { 
  12.         userMapper.updateUser(user);  
  13.     } 
  14.  

使用@CacheDeleteTransactional注解后,AutoloadCache 会先使用ThreadLocal缓存要删除缓存KEY,等事务提交后再去执行缓存删除操作。其实不能说是“解决不一致问题”,而是缓解而已。

缓存数据双写不一致的问题是很难解决的,即使我们只用数据库(单写的情况)也会存在数据不一致的情况(当从数据库中取数据时,同时又被更新了),我们只能是减少不一致情况的发生。对于一些比较重要的数据,我们不能直接使用缓存中的数据进行计算并回写的数据库中,比如扣库存,需要对数据增加版本信息,并通过乐观锁等技术来避免数据不一致问题。

11. 与Spring Cache的比较

AutoLoadCache 的思想其实是源自 Spring Cache,都是使用 AOP + Annotation ,将缓存与业务逻辑进行解耦。区别在于:

  1. AutoLoadCache 的AOP不限于Spring 中的AOP技术,即可以脱离Spring 生态使用,比如成功案例nutz;
  2. Spring Cache不支持命名空间;
  3. Spring Cache没有自动加载、异步刷新、拿来主义机制;
  4. Spring Cache使用name 和 key的来管理缓存(即通过name和key就可以操作具体缓存了),而AutoLoadCache 使用的是namespace + key + hfield 来管理缓存,同时每个缓存都可以指定缓存时间(expire)。也就是说Spring Cache 比较适合用来管理Ehcache的缓存,而AutoLoadCache 更加适合管理Redis, Memcache,尤其是Redis,hfield 相关的功能都是针对它们进行开发的(因为Memcache不支持hash表,所以没办法使用hfield相关的功能)。
  5. Spring Cache不能针对每个缓存Key,进行设置缓存过期时间。而在缓存管理应用中,不同的缓存其缓存时间要尽量设置为不同的。如果都相同的,那缓存同时失效的可能性会比较大些,这样穿透到数据库的可能性也就更大了,对系统的稳定性是没有好处的;
  6. Spring Cache ***的缺点就是无法使用Spring EL表达式来动态生成Cache name,而且Cache name是的必须在Spring 配置时指定几个,非常不方便使用。尤其想在Redis中想精确清除一批缓存,是无法实现的,可能会误删除我们不希望被删除的缓存;
  7. Spring Cache只能基于Spring 中的AOP及Spring EL表达式来使用,而AutoloadCache 可以根据使用者的实际情况进行扩展;
  8. AutoLoadCache中使用@CacheDeleteTransactional 来减少双写不一致问题,而Spring Cache没有相应的解决方案;作者:家榆_77cd链接:http://www.jianshu.com/p/4f52d046c3d2來源:简书著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
责任编辑:未丽燕 来源: 简书
相关推荐

2018-03-13 09:20:05

数字化转型

2010-04-21 10:04:33

Oracle移植

2009-09-16 13:29:30

BSM

2019-06-05 15:23:09

Redis缓存存储

2020-09-17 06:47:23

缓存类型算法

2018-09-18 14:03:57

OpenStack知识难点

2019-10-23 06:09:18

DDos攻击清洗服务网络攻击

2013-04-03 15:42:46

2021-12-30 06:59:28

方法重写面试

2024-03-14 09:07:05

刷数任务维度后端

2019-05-07 18:17:26

Redis服务器数据

2020-12-28 11:11:26

前端开发语言

2020-07-01 11:29:52

云计算边缘计算疫情

2022-09-26 10:03:02

低代码开发

2019-04-11 08:17:36

2021-04-22 22:26:13

Java语言IT

2017-02-09 11:10:51

2011-07-05 09:28:57

vmware

2012-07-19 15:16:45

移动营销

2019-07-10 12:19:31

网络安全信息安全网络威胁
点赞
收藏

51CTO技术栈公众号