高并发环境下,数据库要承受非常大的压力,我们不能奢求每一次都只依赖分布式结构的读写分离数据库来解决问题,所以引入了数据库缓存的概念,这里的缓存不是具体的memcache或是redis,可能只是一块内存区域。此文介绍Mybatis的缓存机制。
名词解释
SQLSession
SqlSession是Mybatis创建数据库链接的会话,当度使用Mybatis需要对SqlSesssion的生命周期有一个把控,但是在Spring的集成中这个会话会被自动创建,周期只是对应一个方法(例如Service层的一个方法),所以每个请求就会对应一个或是多个SqlSession,SQLSession的主要实现是其中的Exector,对应了三种策略:
BatchExecutor专门用于执行批量sql操作,ReuseExecutor会重用statement执行sql操作,SimpleExecutor只是简单执行sql没有什么特别的。开启cache的话,就会创建CachingExecutor,它以前面创建的Executor作为唯一参数。CachingExecutor在查询数据库前先查找缓存,若没找到的话调用delegate(就是构造时传入的Executor对象)从数据库查询,并将查询结果存入缓存中。
一级缓存
Mybatis默认开启一级缓存,它的作用域是SqlSession,作用条件是相同的SQL,key为hashCode+sqlId+Sql语句,当重复执行同一个SQL语句时,会从缓存中读取结果。当然为了保证数据的可靠性,用户进行任何的修改(update、add、delete)操作都会导致缓存清空,并且就作用域而言,一级缓存能够派上用场的地方其实非常有限,因为我们业务中经常遇到的是不同的SqlSession查找相同的数据。
二级缓存
在Mybatis中设置cacheEnabled为true,则会开启二级缓存。二级缓存默认是关闭的,他的生效范围是整个mapper(namespace),除了同个Mapper带来的修改请求外,也会定时进行缓存清理,这主要依托于用户的参数设定。
Mybatis二级缓存的实现(默认)
我们可以直接通过添加配置项开启Mybatis的二级缓存,他会默认使用本机的内存,同时自动清除缓存,操作很便捷:
对于xml配置,我们需要直接在config文件中配置开启缓存,默认是关闭的,需要在config.xml中添加这么一行setting:
<setting name="cacheEnabled"value="true"/>
同时在mapper的xml中添加cache标签启动相应namespace的缓存空间:
<cache flushInterval="100000" type="RedisCache4BlogConf" size="1024" readOnly="false" eviction="LRU"/>
这样默认namespace下的每一个select语句都会优先从缓存中读取数据,读不到则会在查库后将数据放入缓存中。而update、insert、delete 语句则会无差别的清空缓存(这也就是频繁改动的数据缓存命中率不高的原因)。不过这些设置也可以他修改,例如,下面的flushCache为true代表执行此条SQL的时候刷新缓存,此处的insert语句则不会清空缓存,而useCache为true代表次条SQL会使用缓存(select设置才有用),此处代表select语句不会从缓存中读取数据:
<insert id="saveUser" parameterType="User" flushCache="true"> …… </insert> <select id="getUser" parameterType="String" userCache="false"> …… </select>
若是使用Springboot配置,开启缓存是要在启动类上方添加注解@EnableCaching,同时在相应的mapper上添加注解@CacheNameSpace
@CacheNameSpace(blocking = false,size=1024,readWrite = true,implementation = RedisCache4BlogConf.class, eviction = LruCache.class,)
对应的xml缓存设置为@Options注解:
@Options(useCache = true,flushCache = Options.FlushCachePolicy.FALSE) @Select(…………) //…………
几个主要的参数:
size:缓存的list空间大小,超过size的数据会按回收策略回收,默认为1024
flushInterval:缓存刷新的时间间隔(ms),每隔这段时间会主动刷新(默认清空)缓存,默认为0即不刷新缓存
readOnly/readWrite:缓存是否返回对象本身,readOnly为false或readWrite为true时表示返回对象的拷贝,以避免因对象的修改引起缓存的改变,这是默认值,会牺牲一丢丢的性能却能换回安全性。
eviction:回收策略,决定缓存空间满的时候的移除数据方式,主要有:FIFO,按照插入顺序先后丢弃。LRU,先丢弃使用最少的。SOFT,先丢弃软引用。WEAK,先丢弃弱引用。
blocking:默认为false,若为true在取值是会阻塞直到缓存中有数据。
implementation:重写缓存类取代默认的缓存规则。
tip:关于缓存的刷新
Mybatis缓存是基于执行的SQL类型刷新的,他并不能判断出来我们的修改涉及的数据集合,默认情况下,当我们进行Insert、Update、Delete后执行的操作是删除这个namespace下的所有现有的缓存,即使后面我们使用自定义的二级缓存配置,二级缓存也没办法按照指定的数据集更新或是清除缓存,只能全盘删除。
使用基于Redis的二级缓存
添加获取bean的工具类
Mybatis的缓存是新建实例使用的,而不是作为bean注入,所以我们需要工具类来使普通类能获取到redisTemplate的bean:
@Configuration public class ApplicationContextHandler implements ApplicationContextAware { private static ApplicationContext applicationContext; @Override public void setApplicationContext(ApplicationContext context) throws BeansException { applicationContext = context; } public static <T> T getBean(Class<T> clazz) { //先判断是否为空 if (applicationContext == null) { return null; } return applicationContext.getBean(clazz); } public static <T> T getBean(String name, Class<T> clazz) { if (applicationContext == null) { return null; } return applicationContext.getBean(name, clazz); } }
重写缓存实现类
实际的缓存基本都是自定义的实现,将缓存存在分布式环境下的共用存储介质(例如redis),以下是最简单的实现。
重写的缓存类,实现Cache接口,具体的方法可以前去Cache接口源码注释中查看:
public class RedisCache4BlogConf implements Cache{ @Autowired RedisTemplate redisTemplate; //定义一个前缀以便于区分缓存内容 private final String COMMON_CACHE_KEY = "COM:"; private final Logger logger = LoggerFactory.getLogger(this.getClass()); private String id; public RedisCache4BlogConf(final String id){ this.id=id; } //是缓存的标识,默认就是namespace @Override public String getId() { return id; } //首次查询后会执行的缓存存入 @Override public void putObject(Object key, Object value) { getRedisTemplate().opsForValue().set(COMMON_CACHE_KEY+key,value); } //通过缓存获取数据,没有则为null @Override public Object getObject(Object key) { return getRedisTemplate().opsForValue().get(COMMON_CACHE_KEY+key); } //仅用于异常回滚,因为Mybatis的刷新缓存行为只能是针对所有缓存,所以此方法不用于缓存的刷新 @Override public Object removeObject(Object key) { getRedisTemplate().delete(COMMON_CACHE_KEY+key); return null; } //清除缓存,在执行增删改事务SQL并且flushCache不为false时触发,由于我们不知道波及的数据,只能直接删除所有涉及前缀的k-v @Override public void clear() { getRedisTemplate().delete(redisTemplate.keys(COMMON_CACHE_KEY+"*")); } //用于获取当前缓存的size,与前面配置的size配合使用,以便于执行回收策略 @Override public int getSize() { return getRedisTemplate().keys(COMMON_CACHE_KEY+"*").size(); } //这个方法在3.2.6版本后已经被废弃,缓存并发所使用的读写锁,现在已经不再使用 @Override public ReadWriteLock getReadWriteLock() { return null; } private RedisTemplate<String,Object> getRedisTemplate() { RedisTemplate redisTemplate = ApplicationContextHandler.getBean("redisTemplate",RedisTemplate.class); return redisTemplate; } }
而后,将mapper中的implementation定位此类即可。
二级缓存的局限性
在分布式架构下,高并发的SQL请求使用缓存会产生脏读问题,例如其他服务器中修改了数据但是本地缓存中没有产生修改请求,此时本地的缓存不会更新数据,就产生了脏读问题。
同时我并也不建议仅使用Mybatis自带的二级缓存机制,一方面复杂的查询会使namespace有表的交叉导致缓存不及时清理,一方面清理规则比较模糊,不清楚涉及到哪些数据的我们只能按表清除缓存。建议使用集中式自定缓存服务解决数据库缓存问题,这时我们就需要自定缓存规则,根据具体的业务存储分布式数据缓存,可靠性和数据安全性也能得到保障。
总之,二级缓存局限性依旧很多:
不能自定义缓存存储的结构,虽然是经过优化的key值,但是在不重新处理key值的基础上,想要查看缓存的存储内容将会变得十分困难(因为二级缓存的key具有一场长串标示hash值的序列码)
不适合修改较多的场景。因为毫无差别的缓存清除在增删改很多的场景下会使得缓存变得没什么作用。
基于namespace(mapper)的缓存模型逻辑处理可能较为复杂。有时mapper的设计可能不是很优雅,这种情况下使用缓存会导致缓存的结构设计变得很困难。
在第二条的基础上,缓存也没有能够真正刷新的解决方案,缓存的数据只能删除而不能修改,因为我们不知道相应的SQL修改了哪些数据。
二级缓存的取代方案
相比Mybatis自带的二级缓存,总有更靠谱的相关解决方案。
首先,如果是对实时性要求相对不高的系统(例如每小时更新的热度榜单),可以采用不更新缓存(上文中flushCache),只设置过期时间的方式。
手动定义Redis缓存规则的实现
我们可以手动定义缓存规则,在业务不是特别复杂的情况下,可以参考redis的二级缓存,在业务的关键节点进行缓存的部分刷新(业务的复杂性决定了真正的更新缓存其实是很难的,但是我们可以通过一个异步模型来实现缓存的实时刷新),防止刷新掉多余的缓存,同时我们也可以在MVC的任何一层实现缓存,并能自定义缓存的key、value规则相对更加自由。这里举一个较为简单的例子,注意在实际的业务中,这一处理可能会复杂得多。
定义模型层
假定我们有一个支持增删改查的用户系统,提供了Service层的以下方法(方法具体在DAO层和controller层的实现略),定义一个POJO:
public class User{ // 用户id private String id; // 用户昵称 private String name; // 用户权限,用户与权限是多对多的关系 private Set<String> auth; // 注册时间 private String registertime; // email private String email; //加密后密码 private String pswd; //get set方法略 }
Service方法定义
然后定义一个Service,假定相应的DAO已经实现了,为了方便,我们不再写接口了:
@Service public class UserServiceImpl{ public User deregisterUser(String UserId){ // 注销用户,逻辑删除某数据 update 1 } public User addUser(User user){ // 添加新用户 insert } public void updateById(User user){ // 修改用户的基本信息 update } public void updateAuthById(String auth,String userId){ // 更新用户的权限,不更新权限表 update } public List<User> getUserBatchByName(String userName){ //模糊匹配用户列表 select like }fe public User getUserById(String userId){ //查询指定用户的信息 select } public List<String> getUserIdListByAuth(String[] auth){ // 通过权限查询用户的id列表 } }
分析缓存模式
这里的缓存可以有两种,一是用户实体,主键可以用用户id作为标识,用于大部分方法;二是最后一个方法,可以定义为一个set,动态的去维护。而对于根据用户名模糊匹配的用户列表,由于name不像权限一样是一个枚举,即使使用缓存命中率也不会很高,所以此处不用缓存。
编写缓存实现类
以bean的方式实现:
@Component public class Cache4User{ public static final String USER_CAHCE= "user" public static final String USER_AUTH_CAHCE= "user-auth" private final Logger logger = LoggerFactory.getLogger(this.getClass()); //cache 的key前缀 private static final String CACHE_KEY="CACHE:"; @Autowired RedisTemplate redisTemplate; /** * 构建key * */ private String generateKey(String key){ return CACHE_KEY + key.toString(); } /** * 添加value缓存条目 * */ public void putObject(String key,Object... value) { redisTemplate.opsForValue().set(generateKey(key),value); } /** * 获取value缓存条目 * */ public Object getObject(String key) { return redisTemplate.opsForValue().get(generateKey(key)); } /** * 移除value缓存条目 * */ public void removeObject(String key) { redisTemplate.delete(generateKey(key)); } /** * 刷新缓存,即移除指定匹配的缓存 * */ public void clear(String key) { redisTemplate.delete(redisTemplate.keys(generateKey(key)); } /** * 添加set缓存条目 * */ public void putSetObject(String key,Object value) { redisTemplate.opsForSet().add(generateKey(key), value); } /** * 获取set缓存所有条目 * */ public Set getSetAll(String key) { return redisTemplate.opsForSet().members(generateKey(key)); } /** * 移除set缓存指定条目 * */ public void removeSetObject(String key,String value) { redisTemplate.opsForSet().remove(generateKey(key),value); } }
投入使用
使用缓存类实现上面定义的Service:
@Service public class UserServiceImpl{ @Autowired Cache4User cache4User; public void deregisterUser(String UserId){ //TODO 注销用户,逻辑删除某数据 update 1 // 如果设置了过期时间其实不必删除 cache4User.removeObject(Cache4User.USER_CAHCE+userId); // 同时需要修改相应的权限用户集合,建议异步操作并且建议使用线程池 new Thread(() -{ List<String> auth =……; //查询出用户的权限,可能有多个String,注意实际操作前需要判空 for(String a:auth){ cache4User.removeSetObject(Cache4User.USER_AUTH_CAHCE+a,userId); } }).start() } public void addUser(User user){ //TODO 添加新用户 insert // 此处可以设置缓存,也可以在首次查询时懒加载 cache4User.putObject(Cache4User.USER_CAHCE+user.getId(),user); } public void updateById(User user){ //TODO 修改用户的基本信息 update // 此处可以更新缓存,也可以清除缓存 cache4User.putObject(Cache4User.USER_CAHCE+user.getId(),user); } public void updateAuthById(String auth,String userId){ //TODO 更新用户的权限,不更新权限表 update cache4User.putObject(Cache4User.USER_CAHCE+user.getId(),user); // 此处操作较为麻烦,如果执行不多 可以重新刷新set的权限缓存,同样建议异步操作并使用线程池 new Thread(() -{ cache4User.clear(Cache4User.USER_AUTH_CAHCE); //可选,重新刷新缓存 List<String> auth =……; //查出所有的auth以便于刷新缓存 for(String a:auth){ getUserIdListByAuth(a); } }).start(); } public List<User> getUserBatchByName(String userName){ //模糊匹配用户列表 select like //TODO 这里不使用缓存 return } public User getUserById(String userId){ User user = cache4User.getObject(Cache4User.USER_CAHCE+userId); if(user==null){ //TODO 查询指定用户的信息 select //存入缓存 cache4User.putObject(Cache4User.USER_CAHCE+userId,user); return user; }else{ return user; } } public Set<String> getUserIdListByAuth(String auth){ Set<String> set = cache4User.getSetObject(Cache4User.USER_AUTH_CAHCE+auth); if(set.isEmpty()){ //TODO 通过权限查询用户的id列表 //存入缓存 cache4User.putSetObject(Cache4User.USER_AUTH_CAHCE+auth+userId,set); return set; }else{ return set; } } }
附:缓存带来的问题和解决方案
缓存使用不当会产生很多问题,常见的有:
缓存穿透
缓存本是为了缓解数据库的压力,若是缓存未击中,仍会去查询数据库,频繁的未击中数据会给数据库服务器带来很大的压力,解决方案是针对未击中数据也存在redis中,只是存储空值。还可以设定主键的规则(布隆过滤器),不符合规则的请求均视为非法请求,将不进行查询。
缓存雪崩
redis作为数据库缓存一般不进行删除和更新,而是设定过期时间,当缓存数据集中过期时,突然发来了很多请求(流量激增、恶意攻击等),这些请求会一并转发到数据库中,引起数据库服务器“雪崩”。redis服务失效(例如redis服务器宕机)也可称作缓存雪崩,解决方案:
单个数据过期一般不会引起缓存雪崩,所以尽量将数据的过期时间区分开,不要设置较为集中的过期时间
熔断,有性能问题的情况下(内存占用高、慢查询、线程数多等)将服务暂时断绝开,后续的调用直接返回,释放资源
隔离,隔离不同的服务类型,比如不同的SQL请求,拆分服务并且分析问题所在
限流,提供阈值(QPS),超过阈值的请求直接返回
缓存击穿
区别于缓存雪崩,缓存击穿强调单条热点数据的过期,当一个热点key值保持着持续的高并发,而此时该条缓存失效,瞬时就会有大量请求涌入,后果不堪想象(举个例子:商品秒杀),解决方案:设定一个合适的过期时间(甚至可以不过期,后面手动删除),或是维持另一个setnx的键,当缓存中不存在热点数据时,存入键,当做数据锁,每次只有单一线程访问,直到新值被存入缓存中。