栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

Redis学习之路(五)缓存、分布式锁

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Redis学习之路(五)缓存、分布式锁

文章目录
    • 一、Redis缓存
      • 1.1 使用缓存的好处和坏处
      • 1.2 缓存更新策略
      • 1.3 穿透优化
      • 1.4 无底洞优化
      • 1.5 雪崩优化
      • 1.6 热点key重建优化
    • 二、分布式
      • 2.1 Redis实现分布式锁
      • 2.2 如何解决 Redis 的并发竞争 Key 问题
      • 2.3 分布式环境下常见的应用场景
        • 2.3.1 分布式锁
        • 2.3.2 分布式自增ID
      • 2.4 使用Redis如何设计分布式锁?说一下实现思路?使用ZK可以吗?这两种有什么区别?
    • 十一、缓存异常
      • 11.1 缓存雪崩
      • 11.2 缓存穿透
      • 11.3 缓存击穿
      • 11.4 缓存预热
      • 11.5 缓存降级
      • 11.6 热点数据和冷数据
      • 11.7 缓存热点key
    • 三、一些问题
      • 3.1 如何保证缓存与数据库双写时的数据一致性?
      • 3.2 Redis常见性能问题和解决方案?
      • 3.3 Redis如何做大量数据插入?

  本系列文章:
    Redis进阶之路(一)5种数据类型、Redis常用命令
    Redis学习之路(二)Redis Java API、Redis客户端常用命令、持久化、事务
    Redis学习之路(三)主从复制、键过期删除策略、内存溢出策略、慢查询
    Redis学习之路(四)哨兵、集群、读写分离
    Redis学习之路(五)缓存、分布式锁

一、Redis缓存

  缓存能够有效地加速应用的读写速度,同时也可以降低后端负载。

1.1 使用缓存的好处和坏处

  使用缓存图示:

  使用缓存的优点:

  1、提升读写速度。
  2、降低后端负载。

  使用缓存的缺点:

  1、数据不一致性。
  2、代码维护成本和运维成本增加。

  缓存的使用场景基本包含如下两种:

  1、开销大的复杂计算。
  2、加速请求响应。

1.2 缓存更新策略
  • 1、LRU/LFU/FIFO算法删除
      使用场景。剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。
      一致性。要清理哪些数据是由具体算法决定,开发人员只能决定使用哪种算法,所以数据的一致性是最差的。
      维护成本。算法不需要开发人员自己来实现,通常只需要配置最大maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择适合自己的算法即可。
  • 2、超时删除
      使用场景。超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。
      一致性。存在一致性问题。
      维护成本。维护成本不是很高,只需设置expire过期时间即可。
  • 3、主动更新
      使用场景。应用方对于数据的一致性要求高,需要在真实数据更新后,
    立即更新缓存数据。
      一致性。一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。
      维护成本。维护成本会比较高,开发者需要自己来完成更新,并保证更新操作的正确性。

  三种常见更新策略的对比:

  • 4、最佳实践
      低一致性业务建议配置最大内存和淘汰策略的方式使用。
      高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。
1.3 穿透优化

  缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中。
  缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。
  造成缓存穿透的基本原因有两个。第一,自身业务代码或者数据出现问题,第二,一些恶意攻击、爬虫等造成大量空命中。
  解决这个问题的方法有两个:

  • 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;
		}
	}
  • 2、布隆过滤器拦截

      在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。如果布隆过滤器认为该用户id不存在,那么就不会访问存储层,在一定程度保护了存储层。
      这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。
      缓存空对象和布隆过滤器方案对比:
1.4 无底洞优化

  由于数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,所以无论是Memcache还是Redis的分布式,批量操作通常需要从不同节点上获取,相比于单机批量操作只涉及一次网络操作,分布式批量操作会涉及多次网络时间。
  无底洞问题分析:

  1、客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。
  2、网络连接数变多,对节点的性能也有一定影响。

  常见的优化思路:

  1、命令本身的优化,例如优化SQL语句等。
  2、减少网络通信次数。
  3、降低接入成本,例如客户端使用长连/连接池、NIO等。

  假设命令、客户端连接已经为最优,重点讨论减少网络操作次数。以Redis批量获取n个字符串为例,有三种实现方法:

客户端n次get:n次网络+n次get命令本身。
客户端1次pipeline get:1次网络+n次get命令本身。
客户端1次mget:1次网络+1次mget命令本身。

  • 1、串行命令
      逐次执行n个get命令,这种操作时间复杂度较高,它的操作时间=n次网络通信时间+n次命令执行时间。
      Java代码示例:
	List serialMGet(List keys) {
		List values = new ArrayList();
		for (String key : keys) {
			String value = jedisCluster.get(key);
			values.add(value);
		}
		return values;
	}
  • 2、串行IO
      获取每个节点的key子列表,之后对每个节点执行mget或者Pipeline操作,它的操作时间=node次网络时间+n次命令时间,网络次数是node的个数。

      这种方案比第一种要好很多,但是如果节点数太多,还是有一定的性能问题。
      Java代码示例:
	Map serialIOMget(List keys) {
		// 结果集
		Map keyValueMap = new HashMap();
		// 属于各个节点的 key 列表 ,JedisPool 要提供基于 ip 和 port 的 hashcode 方法
		Map> nodeKeyListMap = 
			new HashMap>();
		// 遍历所有的 key
	 	for (String key : keys) {
			// 使用 CRC16 本地计算每个 key 的 slot
			int slot = JedisClusterCRC16.getSlot(key);
			// 通过 jedisCluster 本地 slot->node 映射获取 slot 对应的 node
			JedisPool jedisPool = 
				jedisCluster.getConnectionHandler().getJedisPoolFrom
			Slot(slot);
			// 归档
			if (nodeKeyListMap.containsKey(jedisPool)) {
				nodeKeyListMap.get(jedisPool).add(key);
			} else {
				List list = new ArrayList();
				list.add(key);
				nodeKeyListMap.put(jedisPool, list);
			}
		}
		// 从每个节点上批量获取,这里使用 mget 也可以使用 pipeline
		for (Entry> entry : nodeKeyListMap.entrySet()) {
			JedisPool jedisPool = entry.getKey();
			List nodeKeyList = entry.getValue();
			// 列表变为数组
			String[] nodeKeyArray = 
				nodeKeyList.toArray(new String[nodeKeyList.size()]);
			// 批量获取,可以使用 mget 或者 Pipeline
			List nodevalueList = jedisPool.getResource().mget(nodeKeyArray);
			// 归档
			for (int i = 0; i < nodeKeyList.size(); i++) {
				keyValueMap.put(nodeKeyList.get(i), nodevalueList.get(i));
			}
		}
		return keyValueMap;
	}
  • 3、并行IO
      此方案是将方案2中的最后一步改为多线程执行,它的复杂度是:max_slow(node 网络时间 )+n 次命令时间。

      Java代码示例:
	Map parallelIOMget(List keys) {
		// 结果集
		Map keyValueMap = new HashMap();
		// 属于各个节点的 key 列表
		Map> nodeKeyListMap = 
			new HashMap>();
		//和前面一样
	 	// 多线程 mget ,最终汇总结果
		for (Entry> entry : nodeKeyListMap.entrySet()) {
			// 多线程实现
		}
		return keyValueMap;
	}
  • 4、hash_tag实现
      hash_tag功能,它可以将多个key强制分配到一个节点上,它的操作时间=1次网络时间+n次命令时间。将多个key分配到一个节点:

      执行key操作图示:

      Java代码示例:
	List hashTagMget(String[] hashTagKeys) {
		return jedisCluster.mget(hashTagKeys);
	}

  四种批量操作解决方案对比:

1.5 雪崩优化

  缓存雪崩:由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。
  预防和解决缓存雪崩问题,可以从以下三个方面进行着手。

  • 1、保证缓存层服务高可用性
  • 2、依赖隔离组件为后端限流并降级
  • 3、提前演练
      在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。
1.6 热点key重建优化

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

  1、当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。
  2、重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等。

  在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。
  需要制定如下目标:要解决这个问题,需要制定以下目标:

  1、减少重建缓存的次数。
  2、数据尽可能一致。
  3、较少的潜在危险。

  • 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、永远不过期
      “永远不过期”包含两层意思:

  1、从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
  2、从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

  此方法有效杜绝了热点key产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况。
  Java代码示例:

	String get(final String key) {
		V v = redis.get(key);
		String value = v.getValue();
		// 逻辑过期时间
		long logicTimeout = v.getLogicTimeout();
		// 如果逻辑过期时间小于当前时间,开始后台构建
		if (v.logicTimeout <= 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产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

  两种热点key的解决方法对比:

二、分布式 2.1 Redis实现分布式锁

  Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系,因此Redis中可以使用setnx命令实现分布式锁。
  setnx(set if not exists)命令的作用:当且仅当 key 不存在,将 key 的值设为 value。;若制定的 key 已经存在,则不做任何操作。
  setnx命令的返回值:设置成功,返回 1 。设置失败,返回 0 。

  使用setnx完成同步锁的流程及事项如下:

  1、使用setnx命令获取锁,若返回0(key已存在,锁已存在)则获取失败,反之获取成功。
  2、为了防止获取锁后程序出现异常,导致其他线程/进程调用SETNX命令总是返回0而进入死锁状态,需要为该key设置一个“合理”的过期时间。
  3、释放锁,使用del命令将锁数据删除。

2.2 如何解决 Redis 的并发竞争 Key 问题

  并发竞争key这个问题简单讲就是:同时有多个客户端去set一个key。
  解决方法常见的有四种:

  • 1、乐观锁
      乐观锁适用于大家一起抢着改同一个key,对修改顺序没有要求的场景。watch 命令可以方便的实现乐观锁。watch 命令会监视给定的每一个key,当 exec 时如果监视的任一个key自从调用watch后发生过变化,则整个事务会回滚,不执行任何动作。

需要注意的是,如果Redis使用了数据分片的方式,那么这个方法就不再适用。

  • 2、分布式锁
      适合分布式环境,不用关心Redis是否为分片集群模式。在业务层进行控制,操作Redis之前,先去申请一个分布式锁,拿到锁的才能操作。分布式锁的实现方式很多,比如 ZooKeeper、Redis 等。
      如果不存在 Redis的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能。
      基于zookeeper临时有序节点可以实现的分布式锁。大致思想为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。完成业务流程后,删除对应的子节点释放锁。在实践中,从以可靠性为主,所以首推Zookeeper。
  • 3、时间戳
      适合有序需求场景。
  • 4、消息队列
      在并发量很大的情况下,可以通过消息队列进行串行化处理。这在高并发场景中是一种很常见的解决方案。

  总结这几种方案的话,适用场景:

实现方式适用场景
乐观锁不要在分片集群中使用
分布式锁适合分布式系统环境
时间戳适合有序场景
消息队列串行化处理
2.3 分布式环境下常见的应用场景 2.3.1 分布式锁

  分布式锁可以避免不同进程重复相同的工作,减少资源浪费。 同时分布式锁可以避免破坏数据正确性的发生, 例如多个进程对同一个订单操作,可能导致订单状态错误覆盖。应用场景如下:

  • 1、定时任务重复执行
      随着业务的发展,业务系统势必发展为集群分布式模式。如果我们需要一个定时任务来进行订单状态的统计。比如每 15 分钟统计一下所有未支付的订单数量。那么我们启动定时任务的时候,肯定不能同一时刻多个业务后台服务都去执行定时任务, 这样就会带来重复计算以及业务逻辑混乱的问题。
      这时候,就需要使用分布式锁,进行资源的锁定。那么在执行定时任务的函数中,首先进行分布式锁的获取,如果可以获取的到,那么这台机器就执行正常的业务数据统计逻辑计算。如果获取不到则证明目前已有其他的服务进程执行这个定时任务,就不用自己操作执行了,只需要返回就行了。如下图所示:
  • 2、避免用户重复下单
      分布式实现方式有很多种:
  1. 数据库乐观锁方式
  2. 基于 Redis 的分布式锁
  3. 基于 ZK 的分布式锁

  分布式锁实现要保证几个基本点:

  1. 互斥性:任意时刻,只有一个资源能够获取到锁。
  2. 容灾性:能够在未成功释放锁的的情况下,一定时限内能够恢复锁的正常功能。
  3. 统一性:加锁和解锁保证同一资源来进行操作。
2.3.2 分布式自增ID

  以电商为例,随着用户以及交易量的增加, 可能会针对用户数据,商品数据,以及订单数据进行分库分表的操作。这时候由于进行了分库分表的行为,所以 MySQL 自增 ID 的形式来唯一表示一行数据的方案不可行了。 因此需要一个分布式 ID 生成器,来提供唯一 ID 的信息。
  通常对于分布式自增 ID 的实现方式有下面几种:

  1. 利用数据库自增 ID 的属性
  2. 通过 UUID 来实现唯一 ID 生成
  3. Twitter 的 SnowFlake 算法
  4. 利用 Redis 生成唯一 ID

  Redis 是单进程单线程架构,不会因为多个客户端的 INCR 命令导致取号重复。因此,基于 Redis的 INCR 命令实现序列号的生成基本能满足全局唯一与单调递增的特性。

2.4 使用Redis如何设计分布式锁?说一下实现思路?使用ZK可以吗?这两种有什么区别?
  • 1、Redis
  1. 线程A setnx(上锁的对象,超时时的时间戳 t1),如果返回 true,获得锁。
  2. 线程 B 用 get 获取 t1,与当前时间戳比较,判断是是否超时,没超时 false,若超时执行第 3步;
  3. 计算新的超时时间 t2,使用 getset 命令返回 t3(该值可能其他线程已经修改过),如果t1==t3,获得锁,如果 t1!=t3 说明锁被其他线程获取了。
  4. 获取锁后,处理完业务逻辑,再去判断锁是否超时,如果没超时删除锁,如果已超时,不用处理(防止删除其他线程的锁)。
  • 2、ZK
  1. 客户端对某个方法加锁时,在 zk 上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点 node1;
  2. 客户端获取该路径下所有已经创建的子节点,如果发现自己创建的 node1 的序号是最小的,就认为这个客户端获得了锁。
  3. 如果发现 node1 不是最小的,则监听比自己创建节点序号小的最大的节点,进入等待。
  4. 获取锁后,处理完逻辑,删除自己创建的 node1 即可。

  区别:ZK性能差一些,开销大,实现简单。

十一、缓存异常 11.1 缓存雪崩

  当缓存重启或者大量的缓存在某一时间段失效,这样就导致大批流量直接访问数据库,对 DB 造成压力, 从而引起 DB 故障,系统崩溃。
  缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
  缓存失效的几种情况:

  1、缓存服务器挂了
  2、高峰期缓存局部失效
  3、热点缓存失效

  解决方案:

  1、缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  2、增加互斥锁,控制数据库请求,重建缓存。
  3、提高缓存的HA(高可用性),如:redis集群。
  4、给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。

 解决方案:

  1. 将商品根据品类热度分类, 购买比较多的类目商品缓存周期长一些, 购买相对冷门的类目商品,缓存周期短一些;
  2. 在设置商品具体的缓存生效时间的时候, 加上一个随机的区间因子, 比如说 5~10 分钟之间来随意选择失效时间;
  3. 提前预估 DB 能力, 如果缓存挂掉,数据库仍可以在一定程度上抗住流量的压力。
11.2 缓存穿透

  一般访问缓存的流程,如果缓存中存在查询的商品数据,那么直接返回。 如果缓存中不存在商品数据, 就要访问数据库。

  由于不恰当的业务功能实现,或者外部恶意攻击不断地请求某些不存在的数据内存,由于缓存中没有保存该数据,导致所有的请求都会落到数据库上,对数据库可能带来一定的压力,甚至崩溃。
  缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
  解决方案:

   1、接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
   2、缓存空对象,从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
   3、采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力

  对于空间的利用到达了一种极致,那就是Bitmap和布隆过滤器(Bloom Filter)。

  • 1、Bitmap
       典型的就是哈希表。缺点是,Bitmap对于每个元素只能记录1bit信息,如果还想完成额外的功能,恐怕只能靠牺牲更多的空间、时间来完成了。
  • 2、布隆过滤器(推荐)
      就是引入了k(k>1)k(k>1)个相互独立的哈希函数,保证在给定的空间、误判率下,完成元素判重的过程。
      它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
      Bloom-Filter算法的核心思想就是利用多个不同的Hash函数来解决“冲突”。
      Hash存在一个冲突(碰撞)的问题,用同一个Hash得到的两个URL的值有可能相同。为了减少冲突,我们可以多引入几个Hash,如果通过其中的一个Hash值我们得出某元素不在集合中,那么该元素肯定不在集合中。只有在所有的Hash函数告诉我们该元素在集合中时,才能确定该元素存在于集合中。这便是Bloom-Filter的基本思想。
       Bloom-Filter一般用于在大数据量的集合中判定某元素是否存在。
11.3 缓存击穿

  缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
  解决方案

  • 1、设置热点数据永远不过期。
  • 2、加互斥锁,互斥锁。
11.4 缓存预热

  缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
  解决方案

  • 1、直接写个缓存刷新页面,上线时手工操作一下;
  • 2、数据量不大,可以在项目启动的时候自动进行加载;
  • 3、定时刷新缓存;

 解决方案:

  1. 数据量不大的时候,工程启动的时候进行加载缓存动作;
  2. 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
  3. 数据量太大的时候,优先保证热点数据进行提前加载到缓存。
11.5 缓存降级

  降级的情况,就是缓存失效或者缓存服务挂掉的情况下,我们也不去访问数据库。我们直接访问内存部分数据缓存或者直接返回默认数据。
  当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
  缓存降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。
  在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:

  • 1、一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
  • 2、警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
  • 3、错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
  • 4、严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

  服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。

11.6 热点数据和冷数据

  热点数据,缓存才有价值。
  对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。频繁修改的数据,看情况考虑使用缓存
  对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。
  数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。
  那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力,比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。

11.7 缓存热点key

  缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
  解决方案:
    对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询

三、一些问题 3.1 如何保证缓存与数据库双写时的数据一致性?

  你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?
  一般来说,就是如果你的系统不是严格要求缓存+数据库必须一致性的话,缓存可以稍微的跟数据库偶尔有不一致的情况,最好不要做这个方案,读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况
  串行化之后,就会导致系统的吞吐量会大幅度的降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
  最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存。

  **为什么是删除缓存,而不是更新缓存?**原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。另外更新缓存的代价有时候是很高的。
  问题1:先修改数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
  解决思路1:先删除缓存,再修改数据库。如果数据库修改失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,则读数据库中旧数据,然后更新到缓存中。
  问题2:数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。完了,数据库和缓存中的数据不一样了。
  解决思路2:更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个 jvm 内部队列中。一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。

3.2 Redis常见性能问题和解决方案?
  • 1、Master最好不要做任何持久化工作,包括内存快照和AOF日志文件,特别是不要启用内存快照做持久化。
  • 2、如果数据比较关键,某个Slave开启AOF备份数据,策略为每秒同步一次。
  • 3、为了主从复制的速度和连接的稳定性,Slave和Master最好在同一个局域网内。
  • 4、尽量避免在压力较大的主库上增加从库
  • 5、为了Master的稳定性,主从复制不要用图状结构,用单向链表结构更稳定,即主从关系为:Master<–Slave1<–Slave2<–Slave3…,这样的结构也方便解决单点故障问题,实现Slave对Master的替换,也即,如果Master挂了,可以立马启用Slave1做Master,其他不变。
3.3 Redis如何做大量数据插入?

  Redis2.6开始,redis-cli支持一种新的被称之为pipe mode的新模式用于执行大量数据插入工作。
  使用pipe mode模式的执行命令如下:

cat data.txt | redis-cli --pipe  

 pipe mode的工作原理:

  • redis-cli –pipe试着尽可能快的发送数据到服务器
  • 读取数据的同时,解析它
  • 一旦没有更多的数据输入,它就会发送一个特殊的echo命令,后面跟着20个随机的字符。我们相信可以通过匹配回复相同的20个字符是同一个命令的行为
  • 一旦这个特殊命令发出,收到的答复就开始匹配这20个字符,当匹配时,就可以成功退出了
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/299013.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号