Mybatis的缓存过期机制和RedisCache
MyBatis的缓存过期机制, flushInterval参数
在实际测试中, 发现Redis中的缓存数据TTL为-1, 在Hash中的key也无过期时间信息, 怀疑RedisCache的实现是否能正常处理缓存过期, 因此一路追查到了MyBatis的代码.
MyBatis在每个Mapper中, 可以设置参数 flushInterval 用来控制缓存的过期时间, 这个参数, 在 MapperBuilderAssistant 中, 被设置为Cache的clearInternal
public Cache useNewCache(Class<? extends Cache> typeClass, Class<? extends Cache> evictionClass, Long flushInterval, Integer size, boolean readWrite, boolean blocking, Properties props) { Cache cache = new CacheBuilder(currentNamespace) .implementation(valueOrDefault(typeClass, PerpetualCache.class)) .addDecorator(valueOrDefault(evictionClass, LruCache.class)) .clearInterval(flushInterval) .size(size) .readWrite(readWrite) .blocking(blocking) .properties(props) .build(); configuration.addCache(cache); currentCache = cache; return cache; }
而后在CacheBuilder中, 会根据这个参数, 判断是否生成代理类ScheduledCache
private Cache setStandardDecorators(Cache cache) { try { MetaObject metaCache = SystemMetaObject.forObject(cache); if (size != null && metaCache.hasSetter("size")) { metaCache.setValue("size", size); } if (clearInterval != null) { cache = new ScheduledCache(cache); ((ScheduledCache) cache).setClearInterval(clearInterval); } if (readWrite) { cache = new SerializedCache(cache); } cache = new LoggingCache(cache); cache = new SynchronizedCache(cache); if (blocking) { cache = new BlockingCache(cache); } return cache; } catch (Exception e) { throw new CacheException("Error building standard cache decorators. Cause: " + e, e); } }
ScheduledCache内部存储了一个变量lastClear, 用来记录最后一次清空缓存的时间, 在get, put, remove等各个操作前, 会判断是否需要清空, 注意是整个namespace的缓存清空.
private boolean clearWhenStale() { if (System.currentTimeMillis() - lastClear > clearInterval) { clear(); return true; } return false; } @Override public void putObject(Object key, Object object) { clearWhenStale(); delegate.putObject(key, object); } @Override public Object getObject(Object key) { return clearWhenStale() ? null : delegate.getObject(key); }
由此可以看出, MyBatis的缓存过期管理机制还是比较粗糙的, 并且依赖本地实例中记录的时间, 同样的LRU机制也是依赖本地.
在分布系统中使用MyBatis如果使用Redis作为缓存, 需要注意这个问题,
1. 默认情况下, Redis中的缓存时间为-1永不过期, 根据各个实例中的计时进行过期清除, 在节点数超过2的情况下, 建议关闭mapper中的flushInterval
2. 如果使用Github中beta3版本的代码, 那么可以在mapper中增加一个timeout来设置Redis key的过期时间, 这个可以在flushInterval关闭的情况下, 通过redis自身进行缓存的过期清理, 但是这个过期时间对应的是一个namespace, 意味着每隔一段时间, 这整个namespace中的缓存全部失效, 哪怕这个查询结果一秒前刚刚被缓存, 这一秒就被清空了.
3. 缓存的主动失效由insert, update, delete发起, 这个在分布式环境下的触发依然是有效的. 但是对于通过多表join得到的结果, 如果未共享namespace, 容易出现缓存未更新而拿到旧数据的情况. 建议在sql编写中通过1+N形式完成复杂查询, 尽量不用使用join
针对Redis缓存的优化方案
1. 关闭 MyBatis 的 flushInterval , 避免各个节点互相干扰的问题, 将缓存的过期控制交给Redis管理
2. 对每一个缓存结果, 在序列化和反序列化时增加一个时间戳, 在读取缓存的时候判断是否过期, 如果过期就返回空(等同于缓存未命中或缓存失效), 将过期时间粒度细化到单个结果.
3. 实现了2之后可以关闭key的timeout, 仅由增删改来触发整个key的清理.
MyBatis RedisCache
http://mybatis.org/redis-cache/
https://github.com/mybatis/redis-cache
这是MyBatis官方的二级缓存的Redis实现, 不支持Redis Cluster, 因为其依赖于Jedis和固定的redis.properties, 和Spring Boot集成较为麻烦, 在Spring Boot 2.1.x中使用还会报RedisConfig初始化错误.
https://github.com/MiltonLai/redis-cache
魔改后的版本, 支持Cluster, 并且支持单个查询结果的过期控制, 未经高强度验证, 请谨慎使用.
使其正常运行
首先不要用pom的jar包引入, 直接到github项目地址上下载源代码, 需要的只是 src/main/java/org/mybatis/caches/redis/ 目录下的文件, 将其放到自己的项目里.
其次, 现在的源码中, 对redis.properties要求其中各项配置名称要以redis.为前缀, 和jar包引用时的要求不一样.
这样基本就能启动运行了
在beta3之后增加了timeout参数, 可以通过redis自身的ttl设置缓存失效时间, 在mapper中的配置方式为
XML
<cache type="org.mybatis.caches.redis.RedisCache"> <property name="timeout" value="3" /> </cache>
Annotation
@CacheNamespace(properties = { @Property(name = "timeout", value = "3") })
如果需要支持Redis Cluster, 可以使用这个版本
Spring Boot中的配置
在Spring Boot中, 也可以通过redis.properties配置.
使用RedisTemplate
如果希望使用SpringBoot的RedisTemplate, 可以加上一个静态引用, 例如
/** * Cons: * 1. Memory issues: if you redeploy the WAR without restarting the VM, you end up with 2 application contexts in the * same VM: the one attached to the static field of ApplicationContextHolder and the new one that is stored in the * ServletContext. This is just the same issue as the commons-logging memory issue. * 2. Tests: if you use spring tests, you will have multiple application contexts in the same VM when running a suite, * but only the one loaded from the first test is stored in the static field. * 3. Application context hierarchy: It is quite common to have a "services application context" and a "web application * context" (and a DispatcherServlet application context), each one being a child of the previous one. Only the root * (services) application context will be stored in the static variable, and thus you have a lot of beans that are not * accessible. * * Though, it's safe to use this in a java -jar application. */ @Component public class ApplicationContextHolder implements ApplicationContextAware { private static ApplicationContext context; /** * Returns the Spring managed bean instance of the given class type if it exists. * Returns null otherwise. */ public static <T> T getBean(Class<T> beanClass) { return context.getBean(beanClass); } @SuppressWarnings("unchecked") public static <T> T getBean(String name) { return (T) context.getBean(name); } @Override public void setApplicationContext(ApplicationContext context) throws BeansException { // store ApplicationContext reference to access required beans later on synchronized (this) { if (ApplicationContextHolder.context == null) { ApplicationContextHolder.context = context; } } } }
这时候需要自己重写RedisCache.java, 在方法中引用RedisTemplate. 因为在mapper初始化的时候给redisTemplate赋值有可能会失败, 所以使用getRedisTemplate()方法, 在调用时再赋值.
代码参考 https://programmer.help/blogs/spring-boot-mybatis-redis-secondary-cache.html, 这个实现是使用整个db存kv实现的, 跟mybatis redis的实现(用hash)不一样, 这个的好处是自带过期时间, 但是在namespace缓存清空时, 会影响所有的namespace. 如果要在正式环境使用, 需要改一下.
private RedisTemplate redisTemplate; private RedisTemplate getRedisTemplate() { if (redisTemplate == null) { redisTemplate = ApplicationContextHolder.getBean("redisTemplate"); } return redisTemplate; } ... public void putObject(Object key, Object value) { ValueOperations opsForValue = getRedisTemplate().opsForValue(); opsForValue.set(key, value, timeout, TimeUnit.SECONDS); } ...
将过期控制应用到单个查询结果
在序列化/反序列化中增加时间戳
public byte[] serialize(long timestamp, Object object) { try ( ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos)) { oos.writeObject(object); baos.write(longToBytes(timestamp)); return baos.toByteArray(); } catch (Exception e) { throw new CacheException(e); } } public long getTimestamp(byte[] bytes) { if (bytes == null || bytes.length < 8) { return -1; } byte[] copy = new byte[8]; System.arraycopy(bytes, bytes.length - 8, copy, 0, 8); return bytesToLong(copy); } public Object unserialize(byte[] bytes) { if (bytes == null || bytes.length < 8) { return null; } try (ByteArrayInputStream bais = new ByteArrayInputStream(Arrays.copyOf(bytes, bytes.length - 8)); ObjectInputStream ois = new ObjectInputStream(bais)) { return ois.readObject(); } catch (Exception e) { throw new CacheException(e); } }
在缓存读写时增加过期判断
@Override public void putObject(final Object key, final Object value) { final byte[] idBytes = id.getBytes(); long ts = 0; if (timeout != null) { ts = System.currentTimeMillis() + timeout * 1000; } final byte[] objBytes = redisConfig.getSerializer().serialize(ts, value); client.hset(idBytes, key.toString().getBytes(), objBytes); } @Override public Object getObject(final Object key) { byte[] objBytes = client.hget(id.getBytes(), key.toString().getBytes()); if (objBytes == null || objBytes.length < 8) return null; long ts = redisConfig.getSerializer().getTimestamp(objBytes); if (ts > 0 && ts < System.currentTimeMillis()) { client.hdel(id, key.toString()); return null; } else { return redisConfig.getSerializer().unserialize(objBytes); } }