1. 程式人生 > >Spring+ehcache+redis兩級快取--快取實戰篇(1)

Spring+ehcache+redis兩級快取--快取實戰篇(1)

在上篇《效能優化-快取篇》中已經從理論上介紹了快取,其實,快取簡單易用,更多精力關注的是根據實際業務的選擇快取策略。

本文主要介紹為什麼要構建ehcache+redis兩級快取?以及在實戰中如何實現?思考如何配置快取策略更合適?這樣的方案可能遺留什麼問題?JUST DO IT! GO!

問題描述

場景:我們的應用系統是分散式叢集的,可橫向擴充套件的。應用中某個介面操作滿足以下一個或多個條件:
1. 介面運行復雜代價大,
2. 介面返回資料量大,
3. 介面的資料基本不會更改,
4. 介面資料一致性要求不高(只需滿足最終一致)。

此時,我們會考慮將這個介面的返回值做快取。考慮到上述條件,我們需要一套高可用分散式的快取叢集,並具備持久化功能,備選的有ehcache叢集

redis主備(sentinel)

  • ehcache叢集因為節點之間資料同步通過組播的方式,可能帶來的問題:節點間大量的資料複製帶來額外的開銷,在節點多的情況下此問題越發嚴重,N個節點會出現N-1次網路傳輸資料進行同步。(見下圖,快取叢集中有三臺機器,其中一臺機器接收到資料,需要拷貝到其他機器,一次input後需要copy兩次,兩次copy是需要網路傳輸消耗的)
    這裡寫圖片描述
  • redis主備由於作為中心節點提供快取,其他節點都向redis中心節點取資料,所以,一次網路傳輸即可。(當然此處的一次網路代價跟組播的代價是不一樣的)但是,隨著訪問量增大,大量的快取資料訪問使得應用伺服器和快取伺服器之間的網路I/O消耗越大。(見下圖,同樣三臺應用伺服器,redis sentinel作為中心節點快取。所謂中心,即所有應用伺服器以redis為快取中心,不再像ehcache叢集,快取是分散存放在應用伺服器中,需要互相同步的,任何一臺應用伺服器的input,都會經過一次copy網路傳輸到redis,由於redis是中心共享的,那麼就可以不用同步的步驟,其他應用伺服器需要只需去get取即可。但是,我們會發現多了N臺伺服器的get的網路開銷。)

這裡寫圖片描述

提出方案

那麼要怎麼處理呢?所以兩級快取的思想誕生了,在redis的方案上做一步優化,在快取到遠端redis的同時,快取一份到本地程序ehcache(此處的ehcache不用做叢集,避免組播帶來的開銷),取快取的時候會先取本地,沒有會向redis請求,這樣會減少應用伺服器<–>快取伺服器redis之間的網路開銷。(見下圖,為了減少get這幾條網路傳輸,我們會在每個應用伺服器上增加本地的ehcache快取作為二級快取,即第一次get到的資料存入ehcache,後面output輸出即可從本地ehcache中獲取,不用再訪問redis了,所以就減少了以後get的網路開銷。get開銷只要一次,後續不需要了,除非本地快取過期需要再get。)
這裡寫圖片描述


如果用過j2cache的都應該知道,oschina用j2cache這種兩級快取,實踐證明了該方案是可行的。該篇使用spring+ehcache+redis實現更加簡潔。

方案實施

1、 spring和ehcache整合

主要獲取ehcache作為操作ehcache的物件。

ehcache.xml 程式碼如下:


<ehcache updateCheck="false" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://ehcache.sf.net/ehcache.xsd">

    <diskStore path="java.io.tmpdir/ehcache"/>

   <!--  預設的管理策略 
    maxElementsOnDisk: 在磁碟上快取的element的最大數目,預設值為0,表示不限制。 
    eternal:設定快取的elements是否永遠不過期。如果為true,則快取的資料始終有效,如果為false那麼還要根據timeToIdleSeconds,timeToLiveSeconds判斷。
    diskPersistent: 是否在磁碟上持久化。指重啟jvm後,資料是否有效。預設為false。 
    diskExpiryThreadIntervalSeconds:物件檢測執行緒執行時間間隔。標識物件狀態(過期/持久化)的執行緒多長時間執行一次。 
    -->
    <defaultCache maxElementsInMemory="10000"
                  eternal="false"
                  timeToIdleSeconds="3600"
                  timeToLiveSeconds="3600"
                  overflowToDisk="true"
                  diskPersistent="false"
                  diskExpiryThreadIntervalSeconds="120"
                  memoryStoreEvictionPolicy="LRU"/>

    <!-- 物件無過期,一個1000長度的佇列,最近最少使用的物件被刪除 -->
     <cache name="userCache"
           maxElementsInMemory="1000"
           eternal="true"
           overflowToDisk="false"
           timeToIdleSeconds="0"
           timeToLiveSeconds="0"
           memoryStoreEvictionPolicy="LFU">
     </cache>

    <!-- 組播方式:multicastGroupPort需要保證與其他系統不重複,進行埠註冊  -->
    <!-- 若因未註冊,配置了重複埠,造成許可權快取資料異常,請自行解決  -->
    <cacheManagerPeerProviderFactory
            class="net.sf.ehcache.distribution.RMICacheManagerPeerProviderFactory"
            properties="peerDiscovery=automatic,
                        multicastGroupAddress=230.0.0.1,
                        multicastGroupPort=4546, timeToLive=1"/>

<!-- replicatePuts=true | false – 當一個新元素增加到快取中的時候是否要複製到其他的peers. 預設是true。 -->
<!-- replicateUpdates=true | false – 當一個已經在快取中存在的元素被覆蓋時是否要進行復制。預設是true。 -->
<!-- replicateRemovals= true | false – 當元素移除的時候是否進行復制。預設是true。 -->
<!-- replicateAsynchronously=true | false – 複製方式是非同步的(指定為true時)還是同步的(指定為false時)。預設是true。 -->
<!-- replicatePutsViaCopy=true | false – 當一個新增元素被拷貝到其他的cache中時是否進行復制指定為true時為複製,預設是true。 -->
<!-- replicateUpdatesViaCopy=true | false – 當一個元素被拷貝到其他的cache中時是否進行復制(指定為true時為複製),預設是true。 -->

     <cache name="webCache_LT"
           maxElementsInMemory="10000"
           eternal="false"
           overflowToDisk="false"
           timeToIdleSeconds="3600"
           timeToLiveSeconds="3600"
           memoryStoreEvictionPolicy="LRU">
        <cacheEventListenerFactory
                class="net.sf.ehcache.distribution.RMICacheReplicatorFactory"
                properties="replicateRemovals=true"/>
         <bootstrapCacheLoaderFactory
                 class="net.sf.ehcache.distribution.RMIBootstrapCacheLoaderFactory"/> 
    </cache>

    <cache name="webCache_ST"
           maxElementsInMemory="1000"
           eternal="false"
           overflowToDisk="false"
           timeToIdleSeconds="300"
           timeToLiveSeconds="300"
           memoryStoreEvictionPolicy="LRU">
        <cacheEventListenerFactory
                class="net.sf.ehcache.distribution.RMICacheReplicatorFactory"
                properties="replicateRemovals=true"/>
        <bootstrapCacheLoaderFactory
                class="net.sf.ehcache.distribution.RMIBootstrapCacheLoaderFactory"/>
    </cache>

</ehcache>

spring.xml中注入ehcacheManager和ehCache物件,ehcacheManager是需要載入ehcache.xml配置資訊,建立ehcache.xml中配置不同策略的cache。


   <!-- ehCache 配置管理器 -->
    <bean id="ehcacheManager" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean">
        <property name="configLocation" value="classpath:ehcache.xml" />
        <!--true:單例,一個cacheManager物件共享;false:多個物件獨立  -->
        <property name="shared" value="true" />
        <property name="cacheManagerName" value="ehcacheManager" />
    </bean>

    <!-- ehCache 操作物件 -->
    <bean id="ehCache" class="org.springframework.cache.ehcache.EhCacheFactoryBean">
       <property name="cacheName" value="ehCache"/>
       <property name="cacheManager" ref="ehcacheManager"/>
    </bean>

2、 spring和redis整合

主要獲取redisTemplate作為操作redis的物件。

redis.properties配置資訊


#host 寫入redis伺服器地址
redis.ip=127.0.0.1
#Port  
redis.port=6379
#Passord  
#redis.password=123456
#連線超時30000
redis.timeout=30
#最大分配的物件數  
redis.pool.maxActive=100
#最大能夠保持idel狀態的物件數  
redis.pool.maxIdle=30
#當池內沒有返回物件時,最大等待時間  
redis.pool.maxWait=1000
#當呼叫borrow Object方法時,是否進行有效性檢查
redis.pool.testOnBorrow=true
#當呼叫return Object方法時,是否進行有效性檢查  
redis.pool.testOnReturn=true

spring注入jedisPool、redisConnFactory、redisTemplate物件


<!-- 載入redis.propertis -->
    <bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer"> 
        <property name="locations" value="classpath:redis.properties"/>
    </bean>

    <!-- Redis 連線池 -->
    <bean id="jedisPool" class="redis.clients.jedis.JedisPoolConfig">
        <property name="maxTotal" value="${redis.pool.maxActive}" />
        <property name="maxIdle" value="${redis.pool.maxIdle}" />
        <property name="testOnBorrow" value="${redis.pool.testOnBorrow}" />
        <property name="testOnReturn" value="${redis.pool.testOnReturn}" />
        <property name="maxWaitMillis" value="${redis.pool.maxWait}" />
    </bean>

    <!-- Redis 連線工廠 -->
    <bean id="redisConnFactory"
        class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
        <property name="hostName" value="${redis.ip}" />
        <property name="port" value="${redis.port}" />
        <!-- property name="password" value="${redis.password}" -->
        <property name="timeout" value="${redis.timeout}" />
        <property name="poolConfig" ref="jedisPool" />
    </bean>

    <!-- redis 操作物件 -->
    <bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate">
        <property name="connectionFactory" ref="redisConnFactory" />
    </bean>

3、 spring整合ehcache和redis

通過上面兩步注入的ehcache和redisTemplate我們就能自定義一個方法將兩者整合起來。詳見EhRedisCache類。

EhRedisCache.java


/**
 * 兩級快取,一級:ehcache,二級為redisCache
 * @author yulin
 *
 */
public class EhRedisCache implements Cache{


    private static final Logger LOG = LoggerFactory.getLogger(UserServiceImpl.class);

    private String name;

    private net.sf.ehcache.Cache ehCache;

    private RedisTemplate<String, Object> redisTemplate;

     private long liveTime = 1*60*60; //預設1h=1*60*60

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public Object getNativeCache() {
        return this;
    }

    @Override
    public ValueWrapper get(Object key) {
         Element value = ehCache.get(key);
         LOG.info("Cache L1 (ehcache) :{}={}",key,value);
         if (value!=null) {
             return (value != null ? new SimpleValueWrapper(value.getObjectValue()) : null);
         } 
         //TODO 這樣會不會更好?訪問10次EhCache 強制訪問一次redis 使得資料不失效
         final String keyStr = key.toString();  
         Object objectValue = redisTemplate.execute(new RedisCallback<Object>() {  
            public Object doInRedis(RedisConnection connection)  
                    throws DataAccessException {  
                byte[] key = keyStr.getBytes();  
                byte[] value = connection.get(key);  
                if (value == null) {  
                    return null;  
                }  
                //每次獲得,重置快取過期時間
                if (liveTime > 0) {  
                    connection.expire(key, liveTime);  
                }  
                return toObject(value);  
            }  
        },true);  
         ehCache.put(new Element(key, objectValue));//取出來之後快取到本地
         LOG.info("Cache L2 (redis) :{}={}",key,objectValue);
         return  (objectValue != null ? new SimpleValueWrapper(objectValue) : null);

    }

    @Override
    public void put(Object key, Object value) {
        ehCache.put(new Element(key, value));
        final String keyStr =  key.toString(); 
        final Object valueStr = value;  
        redisTemplate.execute(new RedisCallback<Long>() {  
            public Long doInRedis(RedisConnection connection)  
                    throws DataAccessException {  
                byte[] keyb = keyStr.getBytes();  
                byte[] valueb = toByteArray(valueStr);  
                connection.set(keyb, valueb);  
                if (liveTime > 0) {  
                    connection.expire(keyb, liveTime);  
                }  
                return 1L;  
            }  
        },true);  

    }

    @Override
    public void evict(Object key) {
        ehCache.remove(key);
        final String keyStr =  key.toString();  
        redisTemplate.execute(new RedisCallback<Long>() {  
            public Long doInRedis(RedisConnection connection)  
                    throws DataAccessException {  
                return connection.del(keyStr.getBytes());  
            }  
        },true); 
    }

    @Override
    public void clear() {
        ehCache.removeAll();
        redisTemplate.execute(new RedisCallback<String>() {  
            public String doInRedis(RedisConnection connection)  
                    throws DataAccessException {  
                connection.flushDb();  
                return "clear done.";  
            }  
        },true);
    }

    public net.sf.ehcache.Cache getEhCache() {
        return ehCache;
    }

    public void setEhCache(net.sf.ehcache.Cache ehCache) {
        this.ehCache = ehCache;
    }

    public RedisTemplate<String, Object> getRedisTemplate() {
        return redisTemplate;
    }

    public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public long getLiveTime() {
        return liveTime;
    }

    public void setLiveTime(long liveTime) {
        this.liveTime = liveTime;
    }

    public void setName(String name) {
        this.name = name;
    }
    /** 
     * 描述 : Object轉byte[]. <br> 
     * @param obj 
     * @return 
     */  
    private byte[] toByteArray(Object obj) {  
        byte[] bytes = null;  
        ByteArrayOutputStream bos = new ByteArrayOutputStream();  
        try {  
            ObjectOutputStream oos = new ObjectOutputStream(bos);  
            oos.writeObject(obj);  
            oos.flush();  
            bytes = bos.toByteArray();  
            oos.close();  
            bos.close();  
        } catch (IOException ex) {  
            ex.printStackTrace();  
        }  
        return bytes;  
    }  

    /** 
     * 描述 :  byte[]轉Object . <br> 
     * @param bytes 
     * @return 
     */  
    private Object toObject(byte[] bytes) {  
        Object obj = null;  
        try {  
            ByteArrayInputStream bis = new ByteArrayInputStream(bytes);  
            ObjectInputStream ois = new ObjectInputStream(bis);  
            obj = ois.readObject();  
            ois.close();  
            bis.close();  
        } catch (IOException ex) {  
            ex.printStackTrace();  
        } catch (ClassNotFoundException ex) {  
            ex.printStackTrace();  
        }  
        return obj;  
    }  
}

spring注入自定義快取


 <!-- 自定義ehcache+redis-->
   <bean id="ehRedisCacheManager" class="org.springframework.cache.support.SimpleCacheManager">  
        <property name="caches">  
            <set>  
               <bean  id="ehRedisCache" class="org.musicmaster.yulin.ercache.EhRedisCache">  
                     <property name="redisTemplate" ref="redisTemplate" />  
                     <property name="ehCache" ref="ehCache"/> 
                     <property name="name" value="userCache"/> 
                <!-- <property name="liveTime" value="3600"/>  --> 
                </bean>
            </set>  
        </property>  
    </bean>  

    <!-- 註解宣告 -->
    <cache:annotation-driven cache-manager="ehRedisCacheManager" 
            proxy-target-class="true"  /> 

4、 模擬問題中提到的介面

此處假設該介面滿足上述條件。

UserService.java


public interface UserService {

    User findById(long id);

    List<User> findByPage(int startIndex, int limit);

    List<User> findBySex(Sex sex);

    List<User> findByAge(int lessAge);

    List<User> findByUsers(List<User> users);

    boolean update(User user);

    boolean deleteById(long id);
}

UserServiceImpl.java


@Service
public class UserServiceImpl implements UserService{

    private static final Logger LOG = LoggerFactory.getLogger(UserServiceImpl.class);

    @Cacheable("userCache")
    @Override
    public User findById(long id) {
        LOG.info("visit business service findById,id:{}",id);
        User user = new User();
        user.setId(id);
        user.setUserName("tony");
        user.setPassWord("******");
        user.setSex(Sex.M);
        user.setAge(32);
        //耗時操作
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return user;
    }


    @Override
    public List<User> findByPage(int startIndex, int limit) {
        return null;
    }

    @Cacheable("userCache")
    @Override
    public List<User> findBySex(Sex sex) {
        LOG.info("visit business service findBySex,sex:{}",sex);
        List<User> users = new ArrayList<User>();
        for (int i = 0; i < 5; i++) {
            User user = new User();
            user.setId(i);
            user.setUserName("tony"+i);
            user.setPassWord("******");
            user.setSex(sex);
            user.setAge(32+i);
            users.add(user);
        }
        return users;
    }

    @Override
    public List<User> findByAge(int lessAge) {
        // TODO Auto-generated method stub
        return null;
    }

    //FIXME 此處將list引數的地址作為key儲存,是否有問題?
    @Cacheable("userCache")
    @Override
    public List<User> findByUsers(List<User> users) {
        LOG.info("visit business service findByUsers,users:{}",users);
        return users;
    }


    @CacheEvict("userCache")
    @Override
    public boolean update(User user) {
        return true;
    }

    @CacheEvict("userCache")
    @Override
    public boolean deleteById(long id) {
        return false;
    }

}


User.java

public class User implements Serializable {

    private static final long serialVersionUID = 1L;
    public enum Sex{
        M,FM
    }
    private long id;
    private String userName;
    private String passWord;
    private int age;
    private Sex sex;

    public long getId() {
        return id;
    }
    public void setId(long id) {
        this.id = id;
    }
    public String getUserName() {
        return userName;
    }
    public void setUserName(String userName) {
        this.userName = userName;
    }
    public String getPassWord() {
        return passWord;
    }
    public void setPassWord(String passWord) {
        this.passWord = passWord;
    }
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public Sex getSex() {
        return sex;
    }
    public void setSex(Sex sex) {
        this.sex = sex;
    }
    @Override
    public String toString() {
        return "User [id=" + id + ", userName=" + userName + ", passWord="
                + passWord + ", age=" + age + ", sex=" + sex + "]";
    }

}

實施結果

我們寫個測試類來模擬下

TestEhRedisCache.java


public class TestEhRedisCache{

    public static void main(String[] args) {
        ApplicationContext context = new ClassPathXmlApplicationContext("spring-ehRedisCache.xml");
        UserService userService= (UserService) context.getBean("userServiceImpl");
        System.out.println(userService.findById(5l));
        System.out.println(userService.findById(5l));
        System.out.println(userService.findById(5l));
        System.out.println(userService.findById(5l));
        System.out.println(userService.findById(5l));
    }
}

TEST1 輸出結果:


Cache L1 (ehcache) :UserServiceImpl/findById/5=null
Cache L2 (redis) :UserServiceImpl/findById/5=null
visit business service findById,id:5
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]

上面第一次訪問,一級快取ehcache和二級快取redis都沒有資料,訪問介面耗時操作,列印日誌:

visit business service findById,id:5

第二次之後的訪問,都會訪問一級快取ehcache,此時響應速度很快。

TEST2 在TEST1結束後,我們在liveTime的時間內,也就是redis快取還未過期再次執行,會出現以下結果


Cache L1 (ehcache) :UserServiceImpl/findById/5=null
Cache L2 (redis) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]
Cache L1 (ehcache) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=******, age=32, sex=M]
User [id=5, userName=tony, passWord=******, age=32, sex=M]

由於TEST1執行完結束後,ehcache為程序間的快取,自然隨著執行結束而釋放,所以TEST2出現:

Cache L1 (ehcache) :UserServiceImpl/findById/5=null

然而在第二次訪問二級快取redis,還未到快取過期時間,所以在redis中找到資料(同時資料入一級快取ehcache):

Cache L2 (redis) :UserServiceImpl/findById/5=User [id=5, userName=tony, passWord=**, age=32, sex=M]

此處不會visit….沒有經過介面的耗時操作,接下來資料都可以在本地快取ehcache中獲取。

總結

經過demo實踐結果符合預期效果,還需更大規模的測試。遺留了幾個問題,在程式碼處的TODO和FIXME中,留給大家一起來思考,一起來探討解決。問題解決和原始碼下載:《spring + ehcache + redis兩級快取實戰篇(2)》