JPress的ehcache缓存方案、以及踩过ehcache的坑

技术

2016-09-12

3352

0

      最近为了提高JPress的性能,减少数据查询的次数,JPress大量使用了ehcache缓存作为起内置缓存,同时session也是基于ehcache重新实现的支持分部署的session解决方案。

 

     因为JPress是基于JFinal快速开发框架,而JFinal又内置了ehcache的插件,使用起来及其简单。

 

        1、JFinal里配置ehcachePlugin插件。

public void configPlugin(Plugins me) {
        me.add(new EhCachePlugin());
		
	//添加其他插件
}

        2、在classPath下添加ehcache的配置文件ehcache.xml

<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="ehcache.xsd"
         updateCheck="false" monitoring="autodetect"
         dynamicConfig="true">
         
    <diskStore path="java.io.tmpdir"/>
    
    <defaultCache
           maxEntriesLocalHeap="10000"
           eternal="false"
           overflowToDisk="true"
           timeToIdleSeconds="20"
           timeToLiveSeconds="60">
    </defaultCache>

    <!--
    Sample cache named sampleCache1
    This cache contains a maximum in memory of 10000 elements, and will expire
    an element if it is idle for more than 5 minutes and lives for more than
    10 minutes.

    If there are more than 10000 elements it will overflow to the
    disk cache, which in this configuration will go to wherever java.io.tmp is
    defined on your system. On a standard Linux system this will be /tmp"
    -->
    <cache name="sampleCache1"
           maxEntriesLocalHeap="10000"
           maxEntriesLocalDisk="1000"
           eternal="false"
           overflowToDisk="true"
           diskSpoolBufferSizeMB="20"
           timeToIdleSeconds="300"
           timeToLiveSeconds="600"
           memoryStoreEvictionPolicy="LFU"
           transactionalMode="off"
            />
            
    <!--
    Sample cache named sampleCache2
    This cache has a maximum of 1000 elements in memory. There is no overflow to disk, so 1000
    is also the maximum cache size. Note that when a cache is eternal, timeToLive and
    timeToIdle are not used and do not need to be specified.
    -->
    <cache name="sampleCache2"
           maxEntriesLocalHeap="1000"
           eternal="true"
           overflowToDisk="false"
           memoryStoreEvictionPolicy="FIFO"
            />

    <!--
    Sample cache named sampleCache3. This cache overflows to disk. The disk store is
    persistent between cache and VM restarts. The disk expiry thread interval is set to 10
    minutes, overriding the default of 2 minutes.
    -->
    <cache name="sampleCache3"
           maxEntriesLocalHeap="500"
           eternal="false"
           overflowToDisk="true"
           timeToIdleSeconds="300"
           timeToLiveSeconds="600"
           diskPersistent="true"
           diskExpiryThreadIntervalSeconds="1"
           memoryStoreEvictionPolicy="LFU"
            />
               
</ehcache>

        3、直接使用EhcacheKit操作缓存。

public void yourMethod() {
       Cachekit.put("cacheName","key","value")

}

 

        到此,一切很顺利的进行着,但随着JPress在大量的使用ehcache,ehcache的缓存数据操作与更新就变成了一个棘手的问题,更新数据库数据了,缓存若得不到及时更新,就会导致程序在运行的过程中有大量的bug、各种莫名其妙的问题。此时、缓存数据的更新,就需要一个良好更新的计划和方案。

 

         首先,是数据颗粒度的问题,我们在缓存数据的时候,可能是根据数据库的ID,对单个model(单条数据)进行缓存,这种缓存以model的ID作为key进行缓存,这种缓存的颗粒度极细。

 

       因此,我们在做数据的更新的时候非常简单,只需在model的更新和删除的时候从ehcache删除该ID即可。针对这一的问题,我们只需要重写Model的update和delete方法,删除其缓存。

 

        代码如下:

@Table(tableName = "content", primaryKey = "id")
public class Content extends BaseContent<Content> {

	private static final long serialVersionUID = 1L;

	@Override
	public boolean update() {
		removeCache(getId());//移除ehcache缓存
		return super.update();
	}

	@Override
	public boolean delete() {
		removeCache(getId());//移除ehcache缓存
		return super.delete();
	}

}

       通过这种方式,我们在通过ID来查询该数据的时候,不用担心缓存于数据库不同步的问题,因为我们在更新、删除的时候就已经把ehcache的缓存数据给清除掉了,当查询的时候发现ehcache里没有数据,自动会去数据库会获取,从而保证了ehcache的数据与数据库保持一致。

 

        但是,我们在缓存数据的时候,不只是对单个model进行缓存,在程序的各种业务场景中,大量会使用到列表的查询,因此我们在存储的时候,肯定也会多列表进行缓存。例如:

public List<Content> findByModule(final String module, final BigInteger parentId, String orderby) {
		final StringBuilder sqlBuilder = new StringBuilder("select * from content c");
		sqlBuilder.append(" sql ....");

		return DAO.getFromListCache("cacheName","key", new IDataLoader() {
			@Override
			public Object load() {
				return DAO.find(sqlBuilder.toString(), module,parentId);
			}
		});
		
	}

   

        但是,一旦缓存了列表,问题就来了?这个列表的数据什么时候会被更新呢?这个缓存到ehcache的某条数据可能会被随时更新或删除了,怎么来同步?

 

        一种粗糙的方案是:把所有缓存都缓存到同一个cacheName中,然后在model的update或delete的时候,对这个cacheName不管三七二十一直接全部清除,如下代码:

@Table(tableName = "content", primaryKey = "id")
public class Content extends BaseContent<Content> {

	private static final long serialVersionUID = 1L;

	@Override
	public boolean update() {
		removeCache(getId());//移除ehcache缓存
        removeAllListCache(); //移除所有保存列表数据的缓存
		return super.update();
	}

	@Override
	public boolean delete() {
		removeCache(getId());//移除ehcache缓存
         removeAllListCache(); //移除所有保存列表数据的缓存
		return super.delete(); 
	}

}

         

        虽然这是一种粗糙的方案,但是也是有效解决了列表数据不同步的问题;其粗糙的原因是,当我们清除数据的时候,把所有的列表都删除了,这样会导致很多没有没有该列表的数据也被清楚了...

 

        所以,更有效的解决方案应该是保留和该ID没有关系的数据,而只清除有关的数据。

 

        那问题来了,什么数据才是该ID有关的数据呢?

        1、列表有该ID的数据。

        2、列表的排序等会受到该ID影响的数据,比如谋条数据的orderby_number更新了,可能某个缓存的列表数据虽然没有该ID,但是该ID更新后,可能是orderby_number,由于缓存的列表数据是根据orderby_number来排序的,此时该数据应该出现在列表里。

        3、分页数据,比如某条数据被删除或更新了,可能分页的页码数据就会被改变。

 

         那如何才能找到该ID关联的数据呢?

        这是一个困难的问题,每个业务系统不一样,关联的数据肯定也不一样。在JPress里,每个content都有一个module字段,表示该数据所属的模型。

 

       因此,在JPress的内容分类里,JPress针对某种类型的数据,都按照一定的规则来建立这个存储的key,比如文章模型的列表在存储的时候,存储的key值大概为:module:article-xxx-xxx这样的key。

       当文章模型的数据被更新的时候,会去便利所有列表数据的key,如果发现key是以module:article开头,表示该数据是文章列表的缓存数据,应该清除。

 

       于是,就有了如下的代码:

public void removeAllListCache() {
		List<Object> list = CacheKit.getKeys(CACHE_NAME);
		if (list != null && list.size() > 0) {
			for (Object keyObj : list) {
				String keyStr = (String) keyObj;

				if (!keyStr.startsWith("module:")) {
					CacheKit.remove(CACHE_NAME, keyStr);
					continue;
				}

				// 不清除其他模型的内容
				if (keyStr.startsWith("module:" + getModule())) {
					CacheKit.remove(CACHE_NAME, keyStr);
				}
			}
		}
	}

 

         大功告成,测试、运行。

 

        然而,踩坑才刚刚开始。

   

        ehcache的坑1:getKeys("cacheName")为空数据。

        本以为理想的解决了我的方案,兴高采烈的查看测试结果,然而发现了一个致命的问题,更新或删除单条数据后,缓存的列表数据没有被更新,debug后才发现,通过CacheKit.getKeys("cacheName")得到的数据总是不正确,绝大多数的情况下返回了空列表,开始以为是JFinal的问题,然后跟进源代码后,JFinal根本没有对getKeys进行任何的操作,而直接返回了。

        在查询资料的过程中,也曾发现在oschina上有人提供类型的问题:http://www.oschina.net/question/2298963_2141262 ,然后没有一个较好的答复。在spring的网站上(https://jira.spring.io/browse/SPR-8878) 找到了这么一句话。

Consider a cache with 100k items - if you ask for the keys, most likely you'll end up with an OOM.

        大概意思是,如果保存了很多数据,当去获取所有数据的keys的时候,可能会造成内存溢出。但无论如何,我始终觉得这是ehcache的一个大坑,如果ehcache的作者始终这么考虑的话,完全不用提供这个getKeys这个方法好了,为毛还要提供出来呢?

 

        那getKeys这条路行不通,那我们就必须自己去维护这个keys。也就是自己来就来我记录我存了哪些key。

 

        于是,在保存到cache的时候,有了如下的代码:

public List<Content> findByModule(final String module, final BigInteger parentId, String orderby) {
		final StringBuilder sqlBuilder = new StringBuilder("select * from content c");
		sqlBuilder.append(" sql ....");

		return DAO.getFromListCache("cacheName","key", new IDataLoader() {
			@Override
			public Object load() {
				return DAO.find(sqlBuilder.toString(), module,parentId);
			}
		});
		
	}

public <T> T getFromListCache(Object key, IDataLoader dataloader) {
		List<String> inCacheKeys = CacheKit.get(CACHE_NAME, "cachekeys");

		List<String> cacheKeyList = new ArrayList<String>();
		if (inCacheKeys != null) {
			cacheKeyList.addAll(inCacheKeys);
		}

		cacheKeyList.add(key.toString());
		CacheKit.put(CACHE_NAME, "cachekeys", cacheKeyList);

		return CacheKit.get("content_list", key, dataloader);
	}

 

        在保存的时候,把keys全部保存到一个单独的缓存里面;

        在删除缓存的时候,不通过getKeys了,而是去这个缓存里面查看有哪些key。代码如下:

public void removeAllListCache() {
		List<Object> list = CacheKit.get(CACHE_NAME, "cachekeys");
		if (list != null && list.size() > 0) {
			for (Object keyObj : list) {
				String keyStr = (String) keyObj;

				if (!keyStr.startsWith("module:")) {
					CacheKit.remove("taxonomy_list", keyStr);
					continue;
				}

				// 不清除其他模型的内容
				if (keyStr.startsWith("module:" + getModule())) {
					CacheKit.remove("taxonomy_list", keyStr);
				}
			}
		}
	}

 

       到此,ehcache的getKeys坑总算是告了一个段落。

 

ehcache的坑2:存储的list列表数据小心复用(或不能复用)。

     

       大喜之余,Ehcache的坑又接踵而来。在使用的过程中,莫名其妙的不定时的出了一个错误....

net.sf.ehcache.CacheException: Failed to serialize element due to ConcurrentModificationException. This is frequently the result of inappropriately sharing thread unsafe object (eg. ArrayList, HashMap, etc) between threads
    at net.sf.ehcache.store.disk.DiskStorageFactory.serializeElement(DiskStorageFactory.java:405)
    at net.sf.ehcache.store.disk.DiskStorageFactory.write(DiskStorageFactory.java:385)
    at net.sf.ehcache.store.disk.DiskStorageFactory$DiskWriteTask.call(DiskStorageFactory.java:477)
    at net.sf.ehcache.store.disk.DiskStorageFactory$PersistentDiskWriteTask.call(DiskStorageFactory.java:1071)
    at net.sf.ehcache.store.disk.DiskStorageFactory$PersistentDiskWriteTask.call(DiskStorageFactory.java:1055)

    

        一看,麻蛋!!多线程的问题啊...错误log没有具体到我自己项目中的哪一行代码,此项想到在JPress的设计中,由于为了解耦,JPress自行开发了一套消息机制,默认情况下全是开辟新的线程去执行的...... 此时,想哭。

 

        抽了根烟后,脑子中灵光乍现,不对啊,在tomcat对servlet的处理模型中,每个请求其实都是开辟了新的线程去处理单独的请求,每个请求也都有可能对ehcache进行操作....不可能是多线程的问题。

 

       此时,已经是深夜2点。

 

       赶紧打开电脑,看看stackoverflow(一个国外知名的编程问答网站)上是否有有人遇到过类似的问题。经过半小时的检索阅读后,终于在http://stackoverflow.com/questions/35816456/error-serializing-element-in-ehcache 找到了蛛丝马迹。

 

        由于我们存储到ehcache的数据列表可能是一个list数据,此时的list数据可能还保存在内存里,读取的代码如下:

public List<Content> findByModule(final String module, final BigInteger parentId, String orderby) {
		final StringBuilder sqlBuilder = new StringBuilder("select * from content c");
		sqlBuilder.append(" sql ....");

		return DAO.getFromListCache("cacheName","key", new IDataLoader() {
			@Override
			public Object load() {
				return DAO.find(sqlBuilder.toString(), module,parentId);
			}
		});
		
	}

 

        在如上的代码中,DAO.getFromListCache可能得到的是内存里的数据,然而调用这个方法的controller很多,每个controller都有自己的业务逻辑,也就是说每个controller都有可能对保存在ehcache内存里的list进行操作(修改、删除、添加),因而出现了 "net.sf.ehcache.CacheException: Failed to serialize element due to ConcurrentModificationException. This is frequently the result of inappropriately sharing thread unsafe object (eg. ArrayList, HashMap, etc) between threads" 这个错误。

    

       如果真的是这样,就好办了....赶紧修改代码测试。

public List<Content> findByModule(final String module, final BigInteger parentId, String orderby) {
		final StringBuilder sqlBuilder = new StringBuilder("select * from content c");
		sqlBuilder.append(" sql ... ");

		// 略...
		List<Content> data = DAO.getFromListCache(buildKey(module, parentId, orderby), new IDataLoader() {
			@Override
			public Object load() {
				return DAO.find(sqlBuilder.toString(), params.toArray());
			}
		});
		if (data == null)
			return null;
		
		return new ArrayList<Content>(data);
	}

 

        如果能够从缓存中得到数据,重新new一个新的list返回。

 

        经过两个小时的测试后,这个问题再也没有出现。

 

        在使用ehcache中,记得一个小伙伴又给我反馈了一个问题,就是在他的一台服务器里,部署了多个JPress,导致后来出现了ehcache数据重合的情况,JPress应用A读到了JPress应用B的缓存数据。

 

         不开源、不知道,开源吓一跳。

 

        虽然是一个"小"问题,但是也很棘手,两个应用同时使用了一份ehcache的数据,原因就是ehcache把数据存储到磁盘的时候,存储到了同一个地方了,在ehcache的配置文件ehcache.xml中,如下代码:

<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="ehcache.xsd"
         updateCheck="false" monitoring="autodetect"
         dynamicConfig="true">
         
    <diskStore path="java.io.tmpdir"/>
    
    <!--其他略...-->
               
</ehcache>

 

       在ehcache的配置中,diskStore都是指向了同一个地方,如果这个同学在部署的时候自己修改了diskStore,指定到具体的位置就可以。但是有没有什么办法让每个JPress应用的ehcache缓存保存在自己应用的webRoot目录下呢?这样,就无需用户自己去配置了。

 

       赶紧去ehcache官网看看,怎么配置diskStore,才能让缓存保存在自己的webRoot目录....

 

       在ehcache的官方文档:http://www.ehcache.org/generated/2.10.2/pdf/Ehcache_Configuration_Guide.pdf的第15页中,找到了如下的内容:

 

        可以配置 user.home(用户的家目录)、user.dir(用户当前的工作目录)、java.io.tmpdir(默认的临时目录)、ehcache.disk.store.dir(ehcache的配置目录)和具体的目录,却不能配置成webRoot的目录....

 

        于是,我想到了自己去加载这个配置文件,然后自由指定diskStore的目录;

 

         于是,在JFinal的配置文件中,就有了如下的代码:

public void configPlugin(Plugins plugins) {
		plugins.add(createEhCachePlugin());

		//其他插件略...
	}

	public EhCachePlugin createEhCachePlugin() {
		String ehcacheDiskStorePath = PathKit.getWebRootPath();
		File pathFile = new File(ehcacheDiskStorePath, ".ehcache");

		Configuration cfg = ConfigurationFactory.parseConfiguration();
		cfg.addDiskStore(new DiskStoreConfiguration().path(pathFile.getAbsolutePath()));
		return new EhCachePlugin(cfg);
	}

       成功的把ehcache的存储目录保存在了webRoot的.ehcache目录下.... 此时,也感叹JFinal的ehcachePlugin插件的足够灵活。

 

       到此,JPress在遇到的ehcache坑中解决完毕,终于松了一口气,美美吃上了老婆给我准备的早餐....

 

 

发表评论

全部评论:0条

杨福海

世界上最好的语言是Java语言...