前言

缓存是日常开发常用的技巧,可以有效的加速应用的读写速度,同时也可以降低后端的负载。而加入缓存之后同时也会带来一些其他问题,本文主要交流一下面对这些问题的常用做法。

缓存的基本使用场景

  1. QPS较高的情况,用于加速请求响应:即使是单条后端数据查询足够快(例如 select × from user where id = ?),依然可以考虑使用缓存,redis缓存每秒可以完成数万次读写,并且如果提供批量的操作的话,可以提高整个io链的响应时间。
  2. 开销大的复杂计算:一些复杂的操作或者计算(例如大量联表操作,一些分组计算等),如果不加缓存,不但无法满足高并发量,同时也会给数据库带来巨大的负担。

缓存的更新策略

缓存中的数据和数据源中的真实数据有一段时间窗口的不一致,这时就需要利用一些策略进行更新。

  1. LRU/LFU/FIFO 算法剔除

使用场景:剔除算法通常用于缓存使用量超过了预设的最大值的时候,如何对现有的数据进行剔除。redis使用maxmemory-policy参数配置策略。这种配置清理数据由算法决定,无法由程序指定,一致性较差,但维护成本较低。

  1. 超时删除

使用场景:通过给缓存数据设置过期时间,让其在过期时间后自动删除,用expire命令可以指定过期时间。如果业务可以容忍一段时间内,缓存层的数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。例如一个商品的描述信息可以容忍几分钟的不一致,但是涉及交易方面的业务,可能要再考虑考虑。

  1. 主动更新

使用场景:应用方对数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。可以利用消息系统通知实现缓存的立即更新。这种做法一致性最高,但如果主动更新发生了问题,那这条数据可能很长一段时间不会更新,所以建议结合超时删除一起使用效果最好;当然维护成本会高一些,需要自己完成更新并保证更新操作的正确性。

缓存粒度的控制

缓存对象的全部属性还是只缓存部分属性的问题,可以从通用性、空间占用、代码维护三个角度考虑:

  1. 通用性,缓存全部数据比部分数据更加通用,但从实际经验来看,很长时间内应用只需要几个重要的属性。
  2. 空间占用,缓存全部数据要比部分数据占用更多的空间,可能存在以下问题:
  • 造成内存浪费
  • 网络传输流量比较大,耗时相对大,极端情况下可能阻塞网络
  • 序列化和反序列化的CPU开销更大
  1. 代码维护,全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还要刷新缓存数据。

从以上几个方面考虑,再根据业务需求,一般可以确定缓存粒度问题。

缓存问题处理

1. 穿透优化

缓存穿透是指查询一个根本不存在的数据。这种情况下,如果缓存层没有缓存空结果,直接到存储层查询,就会导致缓存层失去保护后端存储的意义,通常有两种解决方式:

  • 缓存空对象

缓存空对象会有两个问题:第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存(如果是攻击,问题更严重),比较有意义的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那这段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统等方式清除缓存层中的空对象。以下是缓存空对象的代码参考:

String get(String key) {
	// 从缓存中获取数据
	String cacheValue = cache.get(key);
	// 缓存为空
	if (StringUtils.isBlank(cacheValue)) {
		// 从存储中获取
		String storageValue = storage.get(key);
		cache.set(key, storageValue);
		// 如果缓存数据为空,需要设置一个过期时间,如:300秒
		if (storageValue == null) {
			cache.expire(key, 60 * 5);
		}
		return storageValue;
	} else {
		// 缓存非空
		return cacheValue;
	}
}
  • 布隆过滤器拦截

处理缓存穿透的另一个方式是加入布隆过滤,在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。例如一个系统有2亿个用户id,每个用户的历史行为数据会提前计算后放到存储层中,用户访问后就放到缓存层,但最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可以将所有用户id做成布隆过滤器。如果布隆过滤器认为该用户id不存在,那么就不会访问存储层,一定程度上保护了存储层。这种方法适用于数据命中不高、数据相对固定、实时性低且数据集较大的场景,代码维护也较为复杂,但缓存的空间占用较少。

2. 热点key重建优化

使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成极大的影响:

  • 当前key是一个热点key(例如微博的热搜),并发量非常大;
  • 重建缓存不能在短时间内完成,可能是一个复杂计算,例如复杂的sql,多次io,多个依赖等;

这时有两个可以考虑的方法:

1) 互斥锁

只允许一个线程重建该缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。可以用redis的setnx实现,代码如下:

String get(String key) {
	// 从redis中获取数据
	String value = redis.get(key);
	// 如果value为空,则开始重构缓存
	if (value == null) {
		// 只允许一个线程重构缓存,使用nx,并设置过期时间ex
		String mutexKey = "mutext:key:" + key;
		if (redis.set(mutexKey, "1", "ex 180", "nx")) {
			// 从数据源获取数据
			value = db.get(key);
			// 回写redis,并设置过期时间
			redis.setex(key, timeout, value);
			// 删除 key_mutex
			redis.delete(mutexkey);
		}
		// 其他线程休息50毫秒后重试
		else {
			Thread.sleep(50);
			get(key);
		}
	}
	return value;
}

这种方法思路比较简,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好的降低后端的存储负载,并在一致性上做的比较好。

2) 永远不过期

对热点key设置一个逻辑过期时间,当发现超过逻辑过期时间后,使用单独的线程去构建缓存,让这个key值一直存在。这个方法有效杜绝了热点key产生的问题,唯一不足的是重构缓存期间,会出现数据不一致的情况,这取决于应用是否容忍这种不一致。以下为redis实现的参考代码:

String get(final String key) {
	V v = redis.get(key);
	String value = v.getValue();
	// 逻辑过期时间
	long logicTimeout = v.getLogicTimeout();
	// 如果逻辑过期时间,正常应小于 ex 时间
	if (v.getLogicTimeout <= System.currentTImeMillis()) {
		String mutexKey = "mutex:key:" + key;
		if (redis.set(mutexKey, "1", "ex 180", "nx")) {
			// 重构缓存
			threadPool.execute(new Runnable() {
				public void run() {
					String dbValue = db.get(key);
					redis.set(key, (dbValue, newLogicTimeout));
					redis.delete(mutexKey);
				}
			});
		}
	}
	return value;
}

这种方式没有真正的过期时间,因为逻辑过期时间小于实际过期时间,在真正过期前已经重构,形成一个循环看起来像无过期,但会存在数据不一致的情况,同时代码复杂度还会增大。

总结

以上主要介绍缓存的基本场景、缓存的更新策略、缓存的粒度控制和两个问题场景,除了上面提到的缓存穿透问题,热点key问题,还有无底洞问题、雪崩问题等等问题,平时多看一些问题场景可以让我们少走一点弯路,也是不错的。

by 赖泽坤

参考资料:《redis开发与运维》