缓存管理方案 AutoLoadCache (spring注解管理缓存,可与redis,mencache等对接)

AutoLoadCache 是使用 Spring AOP 、 Annotation以及Spring EL表达式 来进行管理缓存的解决方案,同时基于AOP实现自动加载机制来达到数据“常驻内存”的目的。

现在使用的缓存技术很多,比如Redis、 Memcache 、 EhCache等,甚至还有使用ConcurrentHashMap 或HashTable 来实现缓存。但在缓存的使用上,每个人都有自己的实现方式,大部分是直接与业务代码绑定,随着业务的变化,要更换缓存方案时,非常麻烦。接下来我们就使用AOP + Annotation 来解决这个问题,同时使用自动加载机制来实现数据“常驻内存”。

Spring AOP这几年非常热门,使用也越来越多,但个人建议AOP只用于处理一些辅助的功能(比如:接下来我们要说的缓存),而不能把业务逻辑使用AOP中实现,尤其是在需要“事务”的环境中。

如下图所示:缓存管理方案 AutoLoadCache (spring注解管理缓存,可与redis,mencache等对接)

AOP拦截到请求后:

  1. 根据请求参数生成Key,后面我们会对生成Key的规则,进一步说明;

  2. 如果是AutoLoad的,则请求相关参数,封装到AutoLoadTO中,并放到AutoLoadHandler中。

  3. 根据Key去缓存服务器中取数据,如果取到数据,则返回数据,如果没有取到数据,则执行DAO中的方法,获取数据,同时将数据放到缓存中。如果是 AutoLoad的,则把最后加载时间,更新到AutoLoadTO中,最后返回数据;如是AutoLoad的请求,每次请求时,都会更新 AutoLoadTO中的 最后请求时间。

  4. 为了减少并发,增加等待机制:如果多个用户同时取一个数据,那么先让第一个用户去DAO取数据,其它用户则等待其返回后,去缓存中获取,尝试一定次数后,如果还没获取到,再去DAO中取数据。

AutoLoadHandler(自动加载处理器)主要做的事情:当缓存即将过期时,去执行DAO的方法,获取数据,并将数据放到缓存中。为了防止 自动加载队列过大,设置了容量限制;同时会将超过一定时间没有用户请求的也会从自动加载队列中移除,把服务器资源释放出来,给真正需要的请求。

使用自加载的目的:

  1. 避免在请求高峰时,因为缓存失效,而造成数据库压力无法承受;

  2. 把一些耗时业务得以实现。

  3. 把一些使用非常频繁的数据,使用自动加载,因为这样的数据缓存失效时,最容易造成服务器的压力过大。

分布式自动加载

如果将应用部署在多台服务器上,理论上可以认为自动加载队列是由这几台服务器共同完成自动加载任务。比如应用部署在A,B两台服务器上,A服务器自 动加载了数据D,(因为两台服务器的自动加载队列是独立的,所以加载的顺序也是一样的),接着有用户从B服务器请求数据D,这时会把数据D的最后加载时间 更新给B服务器,这样B服务器就不会重复加载数据D。

使用方法

1. Maven

?
1
2
3
4
5
<dependency>
  <groupId>com.github.qiujiayu</groupId>
  <artifactId>autoload-cache</artifactId>
  <version>2.2</version>
</dependency>

2. Spring AOP配置

从0.4版本开始增加了Redis及Memcache的PointCut 的实现,直接在Spring 中用aop:config就可以使用。

Redis 配置:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!-- Jedis 连接池配置 -->
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
  <property name="maxTotal" value="2000" />
  <property name="maxIdle" value="100" />
  <property name="minIdle" value="50" />
  <property name="maxWaitMillis" value="2000" />
  <property name="testOnBorrow" value="false" />
  <property name="testOnReturn" value="false" />
  <property name="testWhileIdle" value="false" />
</bean>
<bean id="shardedJedisPool" class="redis.clients.jedis.ShardedJedisPool">
  <constructor-arg ref="jedisPoolConfig" />
  <constructor-arg>
    <list>
      <bean class="redis.clients.jedis.JedisShardInfo">
      <constructor-arg value="${redis1.host}" />
      <constructor-arg type="int" value="${redis1.port}" />
      <constructor-arg value="instance:01" />
    </bean>
    <bean class="redis.clients.jedis.JedisShardInfo">
      <constructor-arg value="${redis2.host}" />
      <constructor-arg type="int" value="${redis2.port}" />
      <constructor-arg value="instance:02" />
    </bean>
    <bean class="redis.clients.jedis.JedisShardInfo">
      <constructor-arg value="${redis3.host}" />
      <constructor-arg type="int" value="${redis3.port}" />
      <constructor-arg value="instance:03" />
    </bean>
    </list>
  </constructor-arg>
</bean>
 
<bean id="autoLoadConfig" class="com.jarvis.cache.to.AutoLoadConfig">
  <property name="threadCnt" value="10" />
  <property name="maxElement" value="20000" />
  <property name="printSlowLog" value="true" />
  <property name="slowLoadTime" value="500" />
  <property name="sortType" value="1" />
  <property name="checkFromCacheBeforeLoad" value="true" />
</bean>
 
<bean id="hessianSerializer" class="com.jarvis.cache.serializer.HessianSerializer" />
 
<bean id="cachePointCut" class="com.jarvis.cache.redis.ShardedCachePointCut" destroy-method="destroy">
  <constructor-arg ref="autoLoadConfig" />
  <property name="serializer" ref="hessianSerializer" />
  <property name="shardedJedisPool" ref="shardedJedisPool" />
  <property name="namespace" value="test_hessian" />
</bean>

Memcache 配置:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<bean id="memcachedClient" class="net.spy.memcached.spring.MemcachedClientFactoryBean">
    <property name="servers" value="192.138.11.165:11211,192.138.11.166:11211" />
    <property name="protocol" value="BINARY" />
    <property name="transcoder">
        <bean class="net.spy.memcached.transcoders.SerializingTranscoder">
            <property name="compressionThreshold" value="1024" />
        </bean>
    </property>
    <property name="opTimeout" value="2000" />
    <property name="timeoutExceptionThreshold" value="1998" />
    <property name="hashAlg">
        <value type="net.spy.memcached.DefaultHashAlgorithm">KETAMA_HASH</value>
    </property>
    <property name="locatorType" value="CONSISTENT" />
    <property name="failureMode" value="Redistribute" />
    <property name="useNagleAlgorithm" value="false" />
</bean>
 
 
<bean id="hessianSerializer" class="com.jarvis.cache.serializer.HessianSerializer" />
<bean id="cachePointCut" class="com.jarvis.cache.memcache.CachePointCut" destroy-method="destroy">
  <constructor-arg ref="autoLoadConfig" />
  <property name="serializer" ref="hessianSerializer" />
  <property name="memcachedClient"ref="memcachedClient" />
  <property name="namespace" value="test" />
</bean>

AOP 配置:

?
1
2
3
4
5
6
7
8
9
10
<aop:config>
  <aop:aspect ref="cachePointCut">
    <aop:pointcut id="daoCachePointcut" expression="execution(public !void com.jarvis.cache_example.common.dao..*.*(..)) &amp;&amp; @annotation(cache)" />
    <aop:around pointcut-ref="daoCachePointcut" method="proceed" />
  </aop:aspect>
  <aop:aspect ref="cachePointCut" order="1000"><!-- order 参数控制 aop通知的优先级,值越小,优先级越高 ,在事务提交后删除缓存 -->
    <aop:pointcut id="deleteCachePointcut" expression="execution(* com.jarvis.cache_example.common.dao..*.*(..)) &amp;&amp; @annotation(cacheDelete)" />
    <aop:after-returning pointcut-ref="deleteCachePointcut" method="deleteCache" returning="retVal"/>
  </aop:aspect>
</aop:config>

通过Spring配置,能更好地支持,不同的数据使用不同的缓存服务器的情况。

实例代码

3. 将需要使用缓存操作的方法前增加 @Cache和 @CacheDelete注解(Redis为例子)

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package com.jarvis.example.dao;
import ... ...
public class UserDAO {
 
    /**
     * 添加用户的同时,把数据放到缓存中
     * @param userName
     * @return
     */
    @Cache(expire=600, key="'user'+#retVal.id", opType=CacheOpType.WRITE)
    public UserTO addUser(String userName) {
        UserTO user=new UserTO();
        user.setName(userName);
        Random rand=new Random();
        // 数据库返回ID
        Integer id=rand.nextInt(100000);
        user.setId(id);
        System.out.println("add User:" + id);
        return user;
    }
 
    /**
     
     * @param id
     * @return
     */
    @Cache(expire=600, autoload=true, key="'user'+#args[0]", condition="#args[0]>0")
    public UserTO getUserById(Integer id) {
        UserTO user=new UserTO();
        user.setId(id);
        user.setName("name" + id);
        System.out.println("getUserById from dao");
        return user;
    }
 
    /**
     
     * @param user
     */
    @CacheDelete({@CacheDeleteKey(value="'user'+#args[0].id", keyType=CacheKeyType.DEFINED)})
    public void updateUserName(UserTO user) {
        System.out.println("update user name:" + user.getName());
        // save to db
    }
 
    // 注意:因为没有用 SpEL表达式,所以不需要用单引号
    @CacheDelete({@CacheDeleteKey(value="user*", keyType=CacheKeyType.DEFINED)})
    public void clearUserCache() {
        System.out.println("clearUserCache");
    }
 
    // ------------------------以下是使用默认生成Key的方法--------------------
    @Cache(expire=600, autoload=true, condition="#args[0]>0")
    public UserTO getUserById2(Integer id) {
        UserTO user=new UserTO();
        user.setId(id);
        user.setName("name" + id);
        System.out.println("getUserById from dao");
        return user;
    }
 
    @CacheDelete({@CacheDeleteKey(cls=UserDAO.class, method="getUserById2", argsEl={"#args[0].id"}, keyType=CacheKeyType.DEFAULT)})
    public void updateUserName2(UserTO user) {
        System.out.println("update user name:" + user.getName());
        // save to db
    }
 
    @CacheDelete({@CacheDeleteKey(deleteByPrefixKey=true, cls=UserDAO.class, method="getUserById2", keyType=CacheKeyType.DEFAULT)})
    public void clearUserCache2() {
        System.out.println("clearUserCache");
        // save to db
    }
}

缓存Key的生成

  1. 使用Spring EL 表达式自定义缓存Key:CacheUtil.getDefinedCacheKey(String keySpEL, Object[] arguments),我们称之为自定义缓存Key:

     

    例如:

    ?
    1
    2
    @Cache(expire=600, key="'goods.getGoodsById'+#args[0]")
    public GoodsTO getGoodsById(Long id){...}

    注意:Spring EL表达式支持调整类的static 变量和方法,比如:"T(java.lang.Math).PI"。 所以对于复杂的参数,我们可以在Spring EL 表达式中使用:"T(com.jarvis.cache.CacheUtil).objectToHashStr(#args)",会生成一个比较短的 Hash字符串。

     

    为了使用方便,在Spring EL表达式,"$hash(...)"会被替换为:"T(com.jarvis.cache.CacheUtil).getUniqueHashStr(...)",例如:

    ?
    1
    2
    @Cache(expire=720, key="'GOODS.getGoods:'+$hash(#args)")
    public List<GoodsTO> getGoods(GoodsCriteriaTO goodsCriteria){...}

    生成的缓存Key为"GOODS.getGoods:xxx",xxx为args,的转在的字符串。

     

    在拼缓存Key时,各项数据最好都用特殊字符进行分隔,否则缓存的Key有可能会乱的。比如:a,b 两个变量a=1,b=11,如果a=11,b=1,两个变量中间不加特殊字符,拼在一块,值是一样的。

  2. 默认生成缓存Key的方法:CacheUtil.getDefaultCacheKey(String className, String method, Object[] arguments, String subKeySpEL)

    • className 类名称

    • method 方法名称

    • arguments 参数

    • subKeySpEL SpringEL表达式

生成的Key格式为:{类名称}.{方法名称}{.SpringEL表达式运算结果}:{参数值的Hash字符串}。

当@Cache中不设置key值时,使用默认方式生成缓存Key。

根据自己的情况选择不同的缓存Key生成策略,用自定义Key使用比较灵活,但维护成本会高些,而且不能出现笔误。

subKeySpEL 使用说明

根据业务的需要,将缓存Key进行分组。举个例子,商品的评论列表:

?
1
2
3
4
5
6
7
8
package com.jarvis.example.dao;
import ... ...
public class GoodsCommentDAO{
    @Cache(expire=600, subKeySpEL="#args[0]", autoload=true, requestTimeout=18000)
    public List<CommentTO> getCommentListByGoodsId(Long goodsId, int pageNo, int pageSize) {
        ... ...
    }
}

如果商品Id为:100,那么生成缓存Key格式为:com.jarvis.example.dao.GoodsCommentDAO.getCommentListByGoodsId.100:xxxx 在Redis中,能精确删除商品Id为100的评论列表,执行命令即可: del com.jarvis.example.dao.GoodsCommentDAO.getCommentListByGoodsId.100:*

SpringEL表达式使用起来确实非常方便,如果需要,@Cache中的expire,requestTimeout以及autoload参数都可以用SpringEL表达式来动态设置,但使用起来就变得复杂,所以我们没有这样做。

数据实时性

上面商品评论的例子中,如果用户发表了评论,要立即显示该如何来处理?

比较简单的方法就是,在发表评论成功后,立即把缓存中的数据也清除,这样就可以了。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.jarvis.example.dao;
import ... ...
public class GoodsCommentDAO{
 
    @Cache(expire=600, subKeySpEL="#args[0]", autoload=true, requestTimeout=18000)
    public List<CommentTO> getCommentListByGoodsId(Long goodsId, int pageNo, int pageSize) {
        ... ...
    }
    @CacheDelete({@CacheDeleteKey(cls=GoodsCommentDAO.class, method="getCommentListByGoodsId", deleteByPrefixKey=true, subKeySpEL=subKeySpEL="#args[0].goodsId" , keyType=CacheKeyType.DEFAULT)})
    public void addComment(Comment comment) {
        ... ...// 省略添加评论代码
    }
    }
}

使用自定义缓存Key的方案:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.jarvis.example.dao;
import ... ...
public class GoodsCommentDAO{
    @Cache(expire=600, key="'goods_comment_'+#args[0]+'.list__'+#args[1]+'_'+#args[2]", autoload=true, requestTimeout=18000)
    public List<CommentTO> getCommentListByGoodsId(Long goodsId, int pageNo, int pageSize) {
        ... ...
    }
 
    @CacheDelete({@CacheDeleteKey(value="'goods_comment_'+#args[0].goodsId+'*'", keyType=CacheKeyType.DEFINED)}) // 删除当前所属商品的所有评论,不删除其它商品评论
    public void addComment(Comment comment) {
        ... ...// 省略添加评论代码
    }
}

删除缓存AOP 配置:

?
1
2
3
4
5
<aop:aspect ref="cachePointCut" order="1000">
  <aop:pointcut id="deleteCachePointcut"
    expression="execution(* com.jarvis.cache_example.common.dao..*.*(..)) &amp;&amp; @annotation(cacheDelete)" />
  <aop:after-returning pointcut-ref="deleteCachePointcut" method="deleteCache" returning="retVal"/>
</aop:aspect>

@Cache

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Cache {
 
    /**
     * 缓存的过期时间,单位:秒
     */
    int expire();
 
    /**
     * 自定义缓存Key,如果不设置使用系统默认生成缓存Key的方法
     * @return
     */
    String key() default "";
 
    /**
     * 是否启用自动加载缓存
     * @return
     */
    boolean autoload() default false;
 
    /**
     * 自动缓存的条件,可以为空,使用 SpEL 编写,返回 true 或者 false,优化级高级autoload,例如:null != #args[0].keyword,当第一个参数的keyword属性为null时设置为自动加载。
     * @return
     */
    String autoloadCondition() default "";
 
    /**
     * 当autoload为true时,缓存数据在 requestTimeout 秒之内没有使用了,就不进行自动加载数据,如果requestTimeout为0时,会一直自动加载
     * @return
     */
    long requestTimeout() default 36000L;
 
    /**
     * 使用SpEL,将缓存key,根据业务需要进行二次分组
     * @return
     */
    String subKeySpEL() default "";
    /**
     * 缓存的条件,可以为空,使用 SpEL 编写,返回 true 或者 false,只有为 true 才进行缓存,例如:"#args[0]==1",当第一个参数值为1时,才进缓存。
     * @return
     */
    String condition() default "";
    /**
     * 缓存的操作类型:默认是READ_WRITE,先缓存取数据,如果没有数据则从DAO中获取并写入缓存;如果是WRITE则从DAO取完数据后,写入缓存
     * @return CacheOpType
    */
    CacheOpType opType() default CacheOpType.READ_WRITE;
}

AutoLoadConfig 配置说明

  • threadCnt 处理自动加载队列的线程数量,默认值为:10;

  • maxElement 自动加载队列中允许存放的最大容量, 默认值为:20000

  • printSlowLog 是否打印比较耗时的请求,默认值为:true

  • slowLoadTime 当请求耗时超过此值时,记录目录(printSlowLog=true 时才有效),单位:毫秒,默认值:500;

  • sortType 自动加载队列排序算法, 0:按在Map中存储的顺序(即无序);1 :越接近过期时间,越耗时的排在最前;2:根据请求次数,倒序排序,请求次数越多,说明使用频率越高,造成并发的可能越大。更详细的说明,请查看代码com.jarvis.cache.type.AutoLoadQueueSortType

  • checkFromCacheBeforeLoad 加载数据之前去缓存服务器中检查,数据是否快过期,如果应用程序部署的服务器数量比较少,设置为false, 如果部署的服务器比较多,可以考虑设置为true