从一次线上问题聊聊缓存使用

最近运维转发过来一封线上告警邮件,大概是说,我们负责的系统线上环境有一个接口隔一段时间就频繁报错,但持续十分钟左右就自动恢复了。从监控工具看到,每次报错出现时,都是同一台实例报出来的,此时其他实例是正常的,但下一次可能是另一台实例出现相同的问题,非常奇怪。

问题特点

收到这个邮件,我梳理了一下问题的特点:

  1. 不定期出现,出现时间点没有规律,业务高峰期更多一些
  2. 一次持续十分钟左右
  3. 每次出问题的都是同一台实例,与此同时其他实例正常
  4. 每一台实例都可能出问题

问题分析

之后看了问题接口的逻辑,是从 db 查询一批资讯的数据,因为调用量比较大,所以设计了二级缓存。基本思想是先使用 redis 作为集中一级缓存,再使用 ehcache 作为本地二级缓存,若两级缓存都没有命中,再查询 db。与此同时,还有一个定时任务每10分钟定期把 db 的数据刷新到 redis 中,避免请求直接访问db。

根据问题特点,初步猜测问题很可能是出现在 ehcache 本地二级缓存中,因为若是代码逻辑有问题或者 redis 出现问题,应该所有实例同时都会有问题才对。

通过线上 error 日志和追踪工具分析发现,报错点是某一个字段报 NullPointerException,诡异的是,这个字段是受数据库查询条件约束的,正常情况下根本不可能为空。

1
2
3
-- 以下SQL查询结果的 item 字段不可能为空
SELECT t.* FROM info_table t
WHERE t.item like '05%';

之后在又一次复现时,查询了当时 db 和 redis 的数据,确定了该字段确实不为空,那么问题很明显只能出在 ehcache 本地缓存中了。此时另一位开发同事提醒我,这个缓存不仅仅是一个接口在使用,其他接口也可能用到。

于是问题原因就很明显了:其他接口拿到ehcache缓存的数据后,把缓存里的数据改掉了,导致这个接口从缓存中拿到了错误的数据。之后,定时任务又把正确的数据刷进缓存中,于是接口又自动恢复正常了。


问题原因

走查代码发现,确实有另一个接口拿到ehcache缓存的数据后把数据改掉了,于是乎,暂时先把该缓存对象的开关关闭(还好当初开发时设计了开关),之后排期整改:从 ehcache 取出缓存数据后,如果要对数据进行修改,必须先进行一次深拷贝

至于深拷贝和浅拷贝的问题,之前也在 Java中的浅拷贝和深拷贝 这篇文章中分享过了。


从本地缓存和集中缓存取数据的区别

我们知道,在 Java 中,在方法中声明的变量和引用都是在栈中分配的,不存在线程安全问题,除非通过参数传递进来或者直接使用类变量,才可能导致线程安全问题(一个线程访问的数据被另一个线程改了)。平时的开发中,从 redis 和数据库取数据,取出来的数据也不用担心线程安全问题。

1
2
3
4
5
6
7
public List<Info> get1(){
List<Info> list = jedisUtils.get("info");
}

public List<Info> get2(){
List<Info> list = jedisUtils.get("info");
}

像上面的例子,两个线程分别通过 get1()get2()从 redis 取同一份数据,取出来的两个 list 是两份一模一样的数据,在堆内存中有两个一样的对象,不会互相影响。但同样的场景,换到从 ehcache 取就不一样了。

1
2
3
4
5
6
7
public List<Info> get1(){
List<Info> list = cache.get("info");
}

public List<Info> get2(){
List<Info> list = cache.get("info");
}

从 ehcache 取出来的数据,如果你修改了 get1() 列表里面的数据,get2() 列表里的数据也会被修改,因为他们本质上是指向堆里的同一个对象。这也是为什么要用本地缓存的原因之一,减少重复内存占用,但如果开发时一不小心,修改了缓存里的对象,就会栽进坑里。


引申:缓存和CAP原理

最近负责的这个项目的特点是接口访问量比较大,但提供的是一些产品和资讯的信息,对一致性的要求没有很高。所以很多接口都采用了 redis一级集中缓存 + ehcache二级本地缓存 + 定时任务定时刷新缓存 的方案。

使用本地二级缓存的优点是明显的,第一是减少网络传输开销,第二是减少一份数据多个地方使用时的内存占用(前提是不要修改),第三是一定程度减少缓存穿透问题。

但是如果我们在一个需要强一致性的场景中,那么直接使用集中式缓存可能会更好一点。如果非要加上本地缓存,要么用定时轮询+版本号的方式刷新本地缓存,要么使用消息队列主动通知,但这些手段最多也只能达到准实时,根据 CAP 原理,鱼和熊掌不可兼得。

说到 CAP 原理,我看过比较好的一篇文章是 CAP理论该怎么理解?为什么是三选二?为什么是CP或者AP? ,可以作为引申阅读。