- 一、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学习之路(五)缓存、分布式锁
缓存能够有效地加速应用的读写速度,同时也可以降低后端负载。
1.1 使用缓存的好处和坏处 使用缓存图示:
使用缓存的优点:
1、提升读写速度。
2、降低后端负载。
使用缓存的缺点:
1、数据不一致性。
2、代码维护成本和运维成本增加。
缓存的使用场景基本包含如下两种:
1.2 缓存更新策略1、开销大的复杂计算。
2、加速请求响应。
- 1、LRU/LFU/FIFO算法删除
使用场景。剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。
一致性。要清理哪些数据是由具体算法决定,开发人员只能决定使用哪种算法,所以数据的一致性是最差的。
维护成本。算法不需要开发人员自己来实现,通常只需要配置最大maxmemory和对应的策略即可。开发人员只需要知道每种算法的含义,选择适合自己的算法即可。 - 2、超时删除
使用场景。超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。
一致性。存在一致性问题。
维护成本。维护成本不是很高,只需设置expire过期时间即可。 - 3、主动更新
使用场景。应用方对于数据的一致性要求高,需要在真实数据更新后,
立即更新缓存数据。
一致性。一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。
维护成本。维护成本会比较高,开发者需要自己来完成更新,并保证更新操作的正确性。
三种常见更新策略的对比:
- 4、最佳实践
低一致性业务建议配置最大内存和淘汰策略的方式使用。
高一致性业务可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中。
缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。
造成缓存穿透的基本原因有两个。第一,自身业务代码或者数据出现问题,第二,一些恶意攻击、爬虫等造成大量空命中。
解决这个问题的方法有两个:
- 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不存在,那么就不会访问存储层,在一定程度保护了存储层。
这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。
缓存空对象和布隆过滤器方案对比:
由于数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,所以无论是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代码示例:
ListserialMGet(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代码示例:
MapserialIOMget(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代码示例:
MapparallelIOMget(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代码示例:
ListhashTagMget(String[] hashTagKeys) { return jedisCluster.mget(hashTagKeys); }
四种批量操作解决方案对比:
缓存雪崩:由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。
预防和解决缓存雪崩问题,可以从以下三个方面进行着手。
- 1、保证缓存层服务高可用性
- 2、依赖隔离组件为后端限流并降级
- 3、提前演练
在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。
开发人员使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:
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的解决方法对比:
Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系,因此Redis中可以使用setnx命令实现分布式锁。
setnx(set if not exists)命令的作用:当且仅当 key 不存在,将 key 的值设为 value。;若制定的 key 已经存在,则不做任何操作。
setnx命令的返回值:设置成功,返回 1 。设置失败,返回 0 。
使用setnx完成同步锁的流程及事项如下:
2.2 如何解决 Redis 的并发竞争 Key 问题1、使用setnx命令获取锁,若返回0(key已存在,锁已存在)则获取失败,反之获取成功。
2、为了防止获取锁后程序出现异常,导致其他线程/进程调用SETNX命令总是返回0而进入死锁状态,需要为该key设置一个“合理”的过期时间。
3、释放锁,使用del命令将锁数据删除。
并发竞争key这个问题简单讲就是:同时有多个客户端去set一个key。
解决方法常见的有四种:
- 1、乐观锁
乐观锁适用于大家一起抢着改同一个key,对修改顺序没有要求的场景。watch 命令可以方便的实现乐观锁。watch 命令会监视给定的每一个key,当 exec 时如果监视的任一个key自从调用watch后发生过变化,则整个事务会回滚,不执行任何动作。
需要注意的是,如果Redis使用了数据分片的方式,那么这个方法就不再适用。
- 2、分布式锁
适合分布式环境,不用关心Redis是否为分片集群模式。在业务层进行控制,操作Redis之前,先去申请一个分布式锁,拿到锁的才能操作。分布式锁的实现方式很多,比如 ZooKeeper、Redis 等。
如果不存在 Redis的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能。
基于zookeeper临时有序节点可以实现的分布式锁。大致思想为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。完成业务流程后,删除对应的子节点释放锁。在实践中,从以可靠性为主,所以首推Zookeeper。 - 3、时间戳
适合有序需求场景。 - 4、消息队列
在并发量很大的情况下,可以通过消息队列进行串行化处理。这在高并发场景中是一种很常见的解决方案。
总结这几种方案的话,适用场景:
| 实现方式 | 适用场景 |
|---|---|
| 乐观锁 | 不要在分片集群中使用 |
| 分布式锁 | 适合分布式系统环境 |
| 时间戳 | 适合有序场景 |
| 消息队列 | 串行化处理 |
分布式锁可以避免不同进程重复相同的工作,减少资源浪费。 同时分布式锁可以避免破坏数据正确性的发生, 例如多个进程对同一个订单操作,可能导致订单状态错误覆盖。应用场景如下:
- 1、定时任务重复执行
随着业务的发展,业务系统势必发展为集群分布式模式。如果我们需要一个定时任务来进行订单状态的统计。比如每 15 分钟统计一下所有未支付的订单数量。那么我们启动定时任务的时候,肯定不能同一时刻多个业务后台服务都去执行定时任务, 这样就会带来重复计算以及业务逻辑混乱的问题。
这时候,就需要使用分布式锁,进行资源的锁定。那么在执行定时任务的函数中,首先进行分布式锁的获取,如果可以获取的到,那么这台机器就执行正常的业务数据统计逻辑计算。如果获取不到则证明目前已有其他的服务进程执行这个定时任务,就不用自己操作执行了,只需要返回就行了。如下图所示:
- 2、避免用户重复下单
分布式实现方式有很多种:
- 数据库乐观锁方式
- 基于 Redis 的分布式锁
- 基于 ZK 的分布式锁
分布式锁实现要保证几个基本点:
2.3.2 分布式自增ID
- 互斥性:任意时刻,只有一个资源能够获取到锁。
- 容灾性:能够在未成功释放锁的的情况下,一定时限内能够恢复锁的正常功能。
- 统一性:加锁和解锁保证同一资源来进行操作。
以电商为例,随着用户以及交易量的增加, 可能会针对用户数据,商品数据,以及订单数据进行分库分表的操作。这时候由于进行了分库分表的行为,所以 MySQL 自增 ID 的形式来唯一表示一行数据的方案不可行了。 因此需要一个分布式 ID 生成器,来提供唯一 ID 的信息。
通常对于分布式自增 ID 的实现方式有下面几种:
- 利用数据库自增 ID 的属性
- 通过 UUID 来实现唯一 ID 生成
- Twitter 的 SnowFlake 算法
- 利用 Redis 生成唯一 ID
Redis 是单进程单线程架构,不会因为多个客户端的 INCR 命令导致取号重复。因此,基于 Redis的 INCR 命令实现序列号的生成基本能满足全局唯一与单调递增的特性。
2.4 使用Redis如何设计分布式锁?说一下实现思路?使用ZK可以吗?这两种有什么区别?- 1、Redis
- 线程A setnx(上锁的对象,超时时的时间戳 t1),如果返回 true,获得锁。
- 线程 B 用 get 获取 t1,与当前时间戳比较,判断是是否超时,没超时 false,若超时执行第 3步;
- 计算新的超时时间 t2,使用 getset 命令返回 t3(该值可能其他线程已经修改过),如果t1==t3,获得锁,如果 t1!=t3 说明锁被其他线程获取了。
- 获取锁后,处理完业务逻辑,再去判断锁是否超时,如果没超时删除锁,如果已超时,不用处理(防止删除其他线程的锁)。
- 2、ZK
- 客户端对某个方法加锁时,在 zk 上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点 node1;
- 客户端获取该路径下所有已经创建的子节点,如果发现自己创建的 node1 的序号是最小的,就认为这个客户端获得了锁。
- 如果发现 node1 不是最小的,则监听比自己创建节点序号小的最大的节点,进入等待。
- 获取锁后,处理完逻辑,删除自己创建的 node1 即可。
区别:ZK性能差一些,开销大,实现简单。
十一、缓存异常 11.1 缓存雪崩 当缓存重启或者大量的缓存在某一时间段失效,这样就导致大批流量直接访问数据库,对 DB 造成压力, 从而引起 DB 故障,系统崩溃。
缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
缓存失效的几种情况:
1、缓存服务器挂了
2、高峰期缓存局部失效
3、热点缓存失效
解决方案:
1、缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
2、增加互斥锁,控制数据库请求,重建缓存。
3、提高缓存的HA(高可用性),如:redis集群。
4、给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。
解决方案:
- 将商品根据品类热度分类, 购买比较多的类目商品缓存周期长一些, 购买相对冷门的类目商品,缓存周期短一些;
- 在设置商品具体的缓存生效时间的时候, 加上一个随机的区间因子, 比如说 5~10 分钟之间来随意选择失效时间;
- 提前预估 DB 能力, 如果缓存挂掉,数据库仍可以在一定程度上抗住流量的压力。
一般访问缓存的流程,如果缓存中存在查询的商品数据,那么直接返回。 如果缓存中不存在商品数据, 就要访问数据库。
由于不恰当的业务功能实现,或者外部恶意攻击不断地请求某些不存在的数据内存,由于缓存中没有保存该数据,导致所有的请求都会落到数据库上,对数据库可能带来一定的压力,甚至崩溃。
缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方案:
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一般用于在大数据量的集合中判定某元素是否存在。
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案
- 1、设置热点数据永远不过期。
- 2、加互斥锁,互斥锁。
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
解决方案
- 1、直接写个缓存刷新页面,上线时手工操作一下;
- 2、数据量不大,可以在项目启动的时候自动进行加载;
- 3、定时刷新缓存;
解决方案:
- 数据量不大的时候,工程启动的时候进行加载缓存动作;
- 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
- 数据量太大的时候,优先保证热点数据进行提前加载到缓存。
降级的情况,就是缓存失效或者缓存服务挂掉的情况下,我们也不去访问数据库。我们直接访问内存部分数据缓存或者直接返回默认数据。
当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
缓存降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。
在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:
- 1、一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
- 2、警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
- 3、错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
- 4、严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
11.6 热点数据和冷数据 热点数据,缓存才有价值。
对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。频繁修改的数据,看情况考虑使用缓存
对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。
数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。
那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力,比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。
缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案:
对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询
你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?
一般来说,就是如果你的系统不是严格要求缓存+数据库必须一致性的话,缓存可以稍微的跟数据库偶尔有不一致的情况,最好不要做这个方案,读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况
串行化之后,就会导致系统的吞吐量会大幅度的降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
**为什么是删除缓存,而不是更新缓存?**原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。另外更新缓存的代价有时候是很高的。
问题1:先修改数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
解决思路1:先删除缓存,再修改数据库。如果数据库修改失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,则读数据库中旧数据,然后更新到缓存中。
问题2:数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。完了,数据库和缓存中的数据不一样了。
解决思路2:更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个 jvm 内部队列中。一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。
- 1、Master最好不要做任何持久化工作,包括内存快照和AOF日志文件,特别是不要启用内存快照做持久化。
- 2、如果数据比较关键,某个Slave开启AOF备份数据,策略为每秒同步一次。
- 3、为了主从复制的速度和连接的稳定性,Slave和Master最好在同一个局域网内。
- 4、尽量避免在压力较大的主库上增加从库
- 5、为了Master的稳定性,主从复制不要用图状结构,用单向链表结构更稳定,即主从关系为:Master<–Slave1<–Slave2<–Slave3…,这样的结构也方便解决单点故障问题,实现Slave对Master的替换,也即,如果Master挂了,可以立马启用Slave1做Master,其他不变。
Redis2.6开始,redis-cli支持一种新的被称之为pipe mode的新模式用于执行大量数据插入工作。
使用pipe mode模式的执行命令如下:
cat data.txt | redis-cli --pipe
pipe mode的工作原理:
- redis-cli –pipe试着尽可能快的发送数据到服务器
- 读取数据的同时,解析它
- 一旦没有更多的数据输入,它就会发送一个特殊的echo命令,后面跟着20个随机的字符。我们相信可以通过匹配回复相同的20个字符是同一个命令的行为
- 一旦这个特殊命令发出,收到的答复就开始匹配这20个字符,当匹配时,就可以成功退出了



