这周在做线上查询优化的时候,用到了spring boot cache,结果就踩到了坑里,追查了好久最终发现了问题。这次使用的是spring boot cache 和 Ehcache. 不得不说,spring boot的注解式的使用cache非常方便,只需要在方法或者类上面加一个注解,这个方法就被增加了缓存,你自己根本不需要去if get or null put的逻辑,一起都给你做好了。
一、 为什么选了ehcache呢?在spring boot cache的支持中,支持如下的cache, 但是ehcache比较简单,而且关键他支持配置失效时间和失效策略,正好适合我的场景。因为我没有监听更新的消息,并且主动更新缓存的场景。你只需要给我缓存一段时间,过期自动失效就可以了,我不需要这么高的时效性。当然他也是支持这种的, 我没有细看。
- Generic
- JCache (JSR-107) (EhCache 3, Hazelcast, Infinispan, etc)
- EhCache 2.x
- Hazelcast
- Infinispan
- Couchbase
- Redis
- Caffeine
- Guava (deprecated)
- Simple
二、遇到了奇怪的问题,上线到日常之后,第一次查询有结果,再一次查询必然无结果。我缓存的一个方法返回的是一个List<Item>这么个东西,第一次请求正常,第二次请求从缓存一定拿不到结果。
从打出来的日志来看,过程如下
setp1 . 请求A(线程名称 http-nio-thread1)进来,第一次调用函数没有走缓存,耗时100ms,并且查到了149个结果
第二次调用函数命中缓存,耗时4ms,取回结果149个。
setp2. 请求B(线程名称线程名称 http-nio-thread2), 第一次调用函数,命中缓存,耗时4ms,返回结果0个!
第二次调用函数命中缓存,耗时4ms, 返回结果0个!!!
什么问题呢? 为什么我的缓存结果不见了? 成了一个空的List, 谁修改了我的缓存?
因为着急着上线,所以我想了一个解决方案:
三、 解决方案一,修改缓存的key,加入当前的线程名称。
spring boot cache的 org.springframework.cache.interceptor.KeyGenerator他的默认key的算法是根据方法签名和函数的参数来的,既然我是下一次线程取不到结果,那么我每个线程的缓存结果只给自己用不就行了吗? 所以我写了一个custom的KeyGenerator,在缓存的key中加入当前的线程名称不就可以了吗? Thread.currentThread.getName()。
改好上线到日常,刷了几次没有再复现,完美!!所以我回去睡觉了,只等明天来了以后上预发。
正如所料,事情永远都没有这么简单,上到预发之后, 刷啊刷,刷新几下,又来了,结果又空了!!!!
看来得找到问题的根源啊,不能为了急着上线,就临时想一个方案。把代码在本地单步调试,跟进到spring boot cache的内部源码去看看,每次调用一次函数,我都去看看缓存的内容变成啥了。
四、 是不是序列化的问题?
我在思考一个问题,会不会因为返回的是List是不可序列化的,导致反序列化失败,给我结果搞没了呢? 现在在想象不可能啊,如果反序列化失败,我的对象都会成null,咋会成为一个空的List呢?
为什么我会想到这个呢?因为tair有反序列化的过程(因为他走了网络),现在再想想这个ehcache根本没走网络,又没有写入到本地磁盘,咋会有序列化和反序列化过程呢?
这些都不重要我要验证我的思想。
于是我把List包在了一个SeachResult对象里,这个对象可被序列化,并且把调用函数当时的时间戳也写入SearchResult里,看看我到底缓存了几个对象。
于是过程来了:
请求A进来,cache key中带了http-nio-threadA的名字,写入的SearchResult@56889的对象,@后面是对象的内存地址,结果是149条数据
请求B进来,cache key中带了http-nio-threadB的名字,写入的是SearchResult@09876的对象,结果是149条数据。然后对象SearchResult@56889里的List成了0!
请求C进来,cache key中带了http-nio-threadC的名字,写入的是SearchResult@6uoiu的对象,结果是149条数据。然后对象SearchResult@09876里的List成了0!
请求D进来。。
请求E进来。。。
直到请求F进来,命中了前面任意一个线程名字相同(http的线程复用),好了,bug出现了,两次从缓存中拿到的数据都是空List.
然后我观察到,对象没变,包括SearchResult里的List对象的内存地址都没变,唯一变化是List里的元素不见了。
真尼玛神奇嘞,到底是谁改了List里的内容?这个也解释了第一次key中加了线程名,在日常就正常了。
因为日常有2台机器,那么落入到同一个机器,并且线程名称相同的概率大大降低,以为bug被解掉了。而预发只有一台机器,所以越到后面,命中前面线程名称同名的概率越高,直到所有的线程(10个?看tomcat的设置了)都被缓存了,就100%复现了。
五、 是命中了ehcache的bug了?翻看了下文档再加上这个开源这么久了,不可能啊,我立即否定了这个想法。
肯定是我程序的问题,我如此想。不过我还是跟踪调试了下的,发现putCache的方法调用没有异常,仅仅在缓存取不到的时候,耗时100ms取到结果之后才会写入缓存。我还怀疑过,是不是ehcache的异步putCache导致的混乱? 网上也没有查到任何有用信息,看来还是得自己分析了。
六、 对了,这个对象既然内存地址都没变,会不会是业务层拿到了这个对象后又对这个对象进行了修改?
嗯???? 由于业务逻辑比较复杂,不好验证到底在哪里修改了对象?看起来都是在读啊。
这个时候,spring boot cache的坑就显露出来了,我想验证我的这个想法。如果每次从缓存里取的时候,都不是对象本身,而是缓存对象的一个副本,管你外面怎么修改,都不会影响我缓存的对象,不就好了吗?可惜啊可惜,spring boot cache不支持啊,他没开给你接口去实现这个想法。
因为他的缓存操作是通过动态代理实现了, 他代理了你的方法。如果从缓存里能取到,直接返回,如果取不到,则执行你的函数,再拿到结果,放到缓存里。没有给你copy对象的机会啊。
既然都想到这里了,我找到了我认为最可疑的代码。
public void addList(List<T> lista) { synchronized (SearchResult.class) { if (this.listb == null) { this.listb = new ArrayList<T>(); } } if (lista != null) { lista.removeAll(this.listb); this.listb.addAll(lista); } }
这段代码本来想求个并集。在lista.removeAll(this.listb)的时候,由于lista和listb是完全相等的,所以这个时候listb的元素全部被remove掉了,所以他成了空。而这个listb又是从缓存里取出来的,这个时候就解释了为什么下一个请求进来,缓存里的内容就成空List,那是因为第二次函数调用之后取到了149个结果,在到这里remove下,不就全部空了么。。。。
解决办法就是第一次从缓存里拿到结果的时候,copy一个list,而不是用缓存里的那个对象的应用
public void setItemList(List<T> list) { //不要用原来的list引用 this.listb = new ArrayList<T>(); this.listb.addAll(list); }
到此,所有谜团终于解开了。
spring boot cache用的时候小心,不然你会发现,我的缓存怎么莫名被改了?
第一次缓存好的,
第二次缓存变了。。。