最近为了提高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坑中解决完毕,终于松了一口气,美美吃上了老婆给我准备的早餐....
没有评论:
发表评论