前几天发现某个系统对某个远程调用接口的调用量大幅上升,涨幅不可思议。根据接口调用上升的时间点和发布记录,查看SVN提交记录,发现是在系统主路径中添加了这个接口的调用,难道这个接口没有做Cache吗?仔细一看,倒是也做了Cache,并且这个RPC对应的DB表的数据量非常小,按理说是能全部被缓存起来的。那么为什么会反复调用,看起来仿佛没有Cache一样呢?
直觉是缓存被不存在的数据击穿了,马上验证。
通过对系统方法的追踪,发现每次调用传入的参数都是0,再去DB里面查,0对应的结果确实为空。
所以这是一个典型的因为空记录导致的缓存被击穿的案例。
解决方法很简单,对不存在的记录做一个null的Cache,下次就不会落到远端了。不过这里结合业务的特定场景,我只是加了一个判断,当值大于0才会去查询,这样连一次查询Cache的开销也省掉了。
这个简单的问题可以衍生出一些思考。
一、何时做put
通常的缓存put策略有两种:
1、查询时put:先查Cache,若不命中,则查存储(例如DB),查到后put进Cache。
2、写入时put:当数据被插入或修改时,主动put一份到Cache。
实践中其实第一种用法更普遍,只有当数据被用到了才会进入Cache。
二、如果DB没查到,是否要put null
这个就跟具体的业务场景相关了。
如果你的数据变化不频繁,那么put一个null,就可以有效起到用Cache减轻后端查询压力的作用。
但如果你的数据变化很频繁,那么put null的结果很可能导致业务上的不一致性,此时就不该做null的Cache。
即便是数据变化不频繁的情况下,如果在null的Cache失效之前,DB中又写入了新的值造成了非null的情况,这时的不一致也是不能接受的。所以在做了null的Cache后,写入的时候应该做到主动失效。
三、异常情况的处理
如果在查询DB的时候抛出了异常,例如连接拿不到、超时等等异常的时候,不应该做null的Cache。
因为此时你并不知道DB中究竟是否存在你要查的数据,如果放了一个null,当DB恢复后,就造成了数据不一致。
实践中这个问题更常见的场景在于,有时候我们的DAO没有把该抛的Exception抛出来,而是直接return null。这时外界的调用方就无法区分,你到底是没查到还是查的过程中出了异常。所以说,该抛的异常应该抛出去,不要什么情况都自己吃掉了。
四、How to put null
存放null的方法有很多,鉴于很多Cache不允许value为null,可以直接放一个跟正常对象不一致的对象,例如boolean, int这种基本类型,然后用instanceof去判断。
不过这种方式总显得不那么优雅,我觉得这里用存放Option的方式就不错。
Option本身可以认为是一个存放对象的小容器,它有两种状态:
1、存放了一个非null的对象
2、null
获取Option的时候判断是否为null就很方便了。
以Guava的库为例子,实现如下:
public Object get(String key) { Optional<Object> result = getFromCache(key); if (result.isPresent()) { // 如果不为null return result.get(); } else { Object obj = queryFromDB(key); putToCache(key, Optional.fromNullable(obj)); // Option可能有值可能为null return obj; } } public Optional<Object> getFromCache(String key) { return cache.get(key); } public void putToCache(String key, Optional<Object> value) { cache.put(key, value); } public abstract Object queryFromDB(String key);