|
|
|
@ -3462,26 +3462,44 @@ FIFO是英文First In First Out 的缩写,是一种先进先出的数据缓存
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 缓存策略
|
|
|
|
|
## 更新策略
|
|
|
|
|
|
|
|
|
|
缓存更新的策略主要分为三种:
|
|
|
|
|
|
|
|
|
|
- **Cache-Aside**:通常会先更新数据库,然后再删除缓存,为了兜底通常还会将数据设置缓存时间
|
|
|
|
|
- **Read/Write through**:一般是由一个 Cache Provider 对外提供读写操作,应用程序不用感知操作的是缓存还是数据库
|
|
|
|
|
- **Write-Behind**:即延迟写入,Cache Provider 每隔一段时间会批量输入数据库,优点是应用程序写入速度非常快
|
|
|
|
|
- **Cache Aside Pattern(旁路缓存)**
|
|
|
|
|
- **Read/Write Through Pattern(读写穿透)**
|
|
|
|
|
- **Write Behind Caching Pattern(异步写入)**
|
|
|
|
|
|
|
|
|
|
### Cache-Aside
|
|
|
|
|
|
|
|
|
|
`Cache-Aside(旁路缓存)`的提出是为了尽可能地解决缓存与数据库的数据不一致问题。
|
|
|
|
|
|
|
|
|
|
**缓存使用场景**
|
|
|
|
|
|
|
|
|
|
**分布式系统中要么通过2PC、3PC或Paxos协议保证强一致性,要么就是拼命的降低并发时脏数据的概率**。缓存系统适用的场景就是非强一致性的场景,所以它属于CAP中的AP,只能做到BASE理论中说的**最终一致性**。异构数据库本来就没办法强一致,我们只是**尽可能减少不一致的时间窗口,达到最终一致性**。同时结合设置过期时间的兜底方案。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**缓存场景分析**
|
|
|
|
|
|
|
|
|
|
- 对于读多写少的数据,请使用缓存
|
|
|
|
|
- 为了保持数据库和缓存的一致性,会导致系统吞吐量的下降
|
|
|
|
|
- 为了保持数据库和缓存的一致性,会导致业务代码逻辑复杂
|
|
|
|
|
- 缓存做不到绝对一致性,但可以做到最终一致性
|
|
|
|
|
- 对于需要保证缓存数据库数据一致的情况,请尽量考虑对一致性到底有多高要求,选定合适的方案,避免过度设计
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Cache Aside(旁路缓存)
|
|
|
|
|
|
|
|
|
|
`Cache Aside(旁路缓存)` 是最广泛使用的缓存模式之一,如果能正确使用 `Cache Aside` 的话,能极大的提升应用性能,`Cache Aside`可用来读或写操作。`Cache Aside`的提出是为了尽可能地解决缓存与数据库的数据不一致问题。
|
|
|
|
|
|
|
|
|
|
#### Read Cache Aside
|
|
|
|
|
|
|
|
|
|
`Cache-Aside` 的读请求流程如下:
|
|
|
|
|
`Cache Aside` 的读请求流程如下:
|
|
|
|
|
|
|
|
|
|
![Cache-Aside读请求](images/Solution/Cache-Aside读请求.jpg)
|
|
|
|
|
|
|
|
|
|
- 读的时候,先读缓存,缓存命中的话,直接返回数据
|
|
|
|
|
- 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应
|
|
|
|
|
- **读的时候,先读缓存,缓存命中的话,直接返回数据**
|
|
|
|
|
- **缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -3491,54 +3509,142 @@ FIFO是英文First In First Out 的缩写,是一种先进先出的数据缓存
|
|
|
|
|
|
|
|
|
|
![Cache-Aside写请求](images/Solution/Cache-Aside写请求.jpg)
|
|
|
|
|
|
|
|
|
|
- 更新的时候,先**更新数据库,然后再删除缓存**
|
|
|
|
|
- **更新的时候,先更新数据库,然后再删除缓存**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Read/Write-Through
|
|
|
|
|
### Read/Write Through(读写穿透)
|
|
|
|
|
|
|
|
|
|
`Read/Write-Through(读写穿透)` 模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过**抽象缓存层**完成的。
|
|
|
|
|
`Read/Write Through(读写穿透)` 模式中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过**抽象缓存层**完成的。
|
|
|
|
|
|
|
|
|
|
#### Read-Through
|
|
|
|
|
#### Read Through
|
|
|
|
|
|
|
|
|
|
`Read-Through`的简要流程如下
|
|
|
|
|
`Read Through` 和 `Cache Aside` 很相似,不同点在于程序不需要再去管理从哪去读数据(缓存还是数据库)。相反它会直接从缓存中读数据,该场景下是缓存去决定从哪查询数据。当我们比较两者的时候这是一个优势因为它会让程序代码变得更简洁。`Read Through`的简要流程如下
|
|
|
|
|
|
|
|
|
|
![Read-Through简要流程](images/Solution/Read-Through简要流程.png)
|
|
|
|
|
|
|
|
|
|
- 从缓存读取数据,读到直接返回
|
|
|
|
|
- 如果读取不到的话,从数据库加载,写入缓存后,再返回响应
|
|
|
|
|
- **从缓存读取数据,读到直接返回**
|
|
|
|
|
- **如果读取不到的话,从数据库加载,写入缓存后,再返回响应**
|
|
|
|
|
|
|
|
|
|
这个简要流程是不是跟`Cache-Aside`很像呢?其实`Read-Through`就是多了一层`Cache-Provider`而已,流程如下:
|
|
|
|
|
|
|
|
|
|
![Read-Through流程](images/Solution/Read-Through流程.png)
|
|
|
|
|
|
|
|
|
|
`Read-Through`实际只是在`Cache-Aside`之上进行了一层封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。
|
|
|
|
|
该模式只在 `Cache Aside` 之上进行了一层封装,它会让程序代码变得更简洁,同时也减少数据源上的负载。流程如下:
|
|
|
|
|
|
|
|
|
|
![Read-Through流程](images/Solution/Read-Through流程.png)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### Write-Through
|
|
|
|
|
#### Write Through
|
|
|
|
|
|
|
|
|
|
`Write-Through`模式下,当发生写请求时,也是由**缓存抽象层**完成数据源和缓存数据的更新,流程如下:
|
|
|
|
|
`Write Through` 模式下的所有写操作都经过缓存,每次向缓存中写数据时,缓存会把数据持久化到对应的数据库中去,且这两个操作都在一个事务中完成。因此,只有两次都写成功才是最终写成功。用写延迟保证了数据一致性。当发生写请求时,也是由**缓存抽象层**完成数据源和缓存数据的更新,流程如下:
|
|
|
|
|
|
|
|
|
|
![Write-Through](images/Solution/Write-Through.png)
|
|
|
|
|
|
|
|
|
|
- **向缓存中写数据,并向数据库写数据**
|
|
|
|
|
- **使用事务保证一致性,两者都写成功才成功**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
当使用 `Write Through `的时候一般都配合使用 `Read Through`。`Write Through `适用情况有:
|
|
|
|
|
|
|
|
|
|
- **需要频繁读取相同数据**
|
|
|
|
|
- **不能忍受数据丢失(相对 `Write Behind` 而言)和数据不一致**
|
|
|
|
|
|
|
|
|
|
**`Write Through` 的潜在使用例子是银行系统。**
|
|
|
|
|
|
|
|
|
|
### Write-Behind
|
|
|
|
|
|
|
|
|
|
`Write-Behind`在一些地方也被成为`Write back`, 简单理解就是:应用程序更新数据时只更新缓存, `Cache Provider`每隔一段时间将数据刷新到数据库中。说白了就是`延迟写入`。
|
|
|
|
|
|
|
|
|
|
`Write-Behind(异步写入)` 和 `Read/Write-Through` 有相似的地方,都是由`Cache Provider`来负责缓存和数据库的读写。它们又有个很大的不同:`Read/Write-Through`是同步更新缓存和数据的,`Write-Behind`则是只更新缓存,不直接更新数据库,通过**批量异步**的方式来更新数据库。
|
|
|
|
|
### Write Behind(异步写入)
|
|
|
|
|
|
|
|
|
|
`Write Behind(异步写入,又叫Write Back)` 和 `Read/Write Through` 相似,都是由 `Cache Provider` 来负责缓存和数据库的读写。它们又有个很大的不同:`Read/Write Through` 是同步更新缓存和数据的,`Write Behind` 则是只更新缓存,不直接更新数据库,通过**批量异步**的方式来更新数据库。
|
|
|
|
|
|
|
|
|
|
![WriteBehind流程](images/Solution/WriteBehind流程.png)
|
|
|
|
|
|
|
|
|
|
这种方式下,缓存和数据库的一致性不强,**对一致性要求高的系统要谨慎使用**。但是它适合频繁写的场景,MySQL的**InnoDB Buffer Pool机制**就使用到这种模式。如上图,应用程序更新两个数据,Cache Provider 会立即写入缓存中,但是隔一段时间才会批量写入数据库中。优缺点如下:
|
|
|
|
|
这种方式下,缓存和数据库的一致性不强,**对一致性要求高的系统要谨慎使用**。但是它适合频繁写的场景,MySQL的**InnoDB Buffer Pool 机制**就使用到这种模式。如上图,应用程序更新两个数据,Cache Provider 会立即写入缓存中,但是隔一段时间才会批量写入数据库中。优缺点如下:
|
|
|
|
|
|
|
|
|
|
- **优点**:是数据写入速度非常快,适用于频繁写的场景
|
|
|
|
|
- **缺点**:是缓存和数据库不是强一致性,对一致性要求高的系统慎用
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 数据一致性
|
|
|
|
|
|
|
|
|
|
![缓存双写一致性](images/Solution/缓存双写一致性.png)
|
|
|
|
|
|
|
|
|
|
一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。
|
|
|
|
|
|
|
|
|
|
- **强一致性**:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
|
|
|
|
|
- **弱一致性**:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
|
|
|
|
|
- **最终一致性**:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型
|
|
|
|
|
|
|
|
|
|
### 业务延时双删
|
|
|
|
|
|
|
|
|
|
先删除缓存,再更新数据库中如何避免脏数据?采用延时双删策略。
|
|
|
|
|
|
|
|
|
|
![延时双删流程](images/Solution/延时双删流程.png)
|
|
|
|
|
|
|
|
|
|
- **先删除缓存**
|
|
|
|
|
- **再写数据库**
|
|
|
|
|
- **休眠1秒后再次删除缓存**(这1秒=业务可能最大耗时,主要是等待正在加载脏数据的请求完成)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**① 读写分离架构**
|
|
|
|
|
|
|
|
|
|
读写架构中,先删除缓存,再更新数据库中如何避免脏数据?采用延时双删策略。
|
|
|
|
|
|
|
|
|
|
- **先淘汰缓存**
|
|
|
|
|
- **再写数据库**
|
|
|
|
|
- **休眠1秒后再次淘汰缓存**(这1秒=主从同步可能最大耗时+业务可能最大耗时,主要是等待正在加载脏数据的请求完成)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**② 延时双删导致吞吐量降低**
|
|
|
|
|
|
|
|
|
|
延时双删的方式同步淘汰策略导致了吞吐量降低如何解决?
|
|
|
|
|
|
|
|
|
|
- **将第二次删除作为异步**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### MQ重试机制
|
|
|
|
|
|
|
|
|
|
不管是**延时双删**还是**Cache-Aside的先操作数据库再删除缓存**,都可能会存在第二步的删除缓存失败,导致的数据不一致问题,可以引入**删除缓存重试机制**来解决。
|
|
|
|
|
|
|
|
|
|
![删缓存失败-解决方案一](images/Solution/删缓存失败-解决方案一.png)
|
|
|
|
|
|
|
|
|
|
![缓存一致性-基于MQ的解决方案](images/Solution/缓存一致性-基于MQ的解决方案.jpg)
|
|
|
|
|
|
|
|
|
|
流程如下:
|
|
|
|
|
|
|
|
|
|
- **更新数据库数据**
|
|
|
|
|
- **删除缓存中的数据,可此时缓存服务出现不可用情况,造成无法删除缓存数据**
|
|
|
|
|
- **当删除缓存数据失败时,将需要删除缓存的 Key 发送到消息队列 (MQ) 中**
|
|
|
|
|
- **应用自己消费需要删除缓存 Key 的消息**
|
|
|
|
|
- **应用接收到消息后,删除缓存,如果删除缓存确认 MQ 消息被消费,如果删除缓存失败,则让消息重新入队列,进行多次尝试删除缓存操作**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### biglog异步删除
|
|
|
|
|
|
|
|
|
|
重试删除缓存机制会造成好多**业务代码入侵**。其实还可以这样优化:通过数据库的**binlog来异步淘汰key**。
|
|
|
|
|
|
|
|
|
|
![删缓存失败-解决方案二](images/Solution/删缓存失败-解决方案二.png)
|
|
|
|
|
|
|
|
|
|
![缓存一致性-基于Canal的解决方案](images/Solution/缓存一致性-基于Canal的解决方案.jpg)
|
|
|
|
|
|
|
|
|
|
流程如下:
|
|
|
|
|
|
|
|
|
|
- **更新数据库数据**
|
|
|
|
|
- **MySQL将数据更新日志写入binlog中**
|
|
|
|
|
- **Canal订阅&消费MySQL binlog,并提取出被更新数据的表名及ID**
|
|
|
|
|
- **调用应用删除缓存接口**
|
|
|
|
|
- **删除缓存数据**
|
|
|
|
|
- **Redis 不可用时,将更新数据的表名及 ID 发送到 MQ 中**
|
|
|
|
|
- **应用接收到消息后,删除缓存,如果删除缓存确认 MQ 消息被消费,如果删除缓存失败,则让消息重新入队列,进行多次尝试删除缓存操作,直到缓存删除成功为止**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 策略选择
|
|
|
|
|
|
|
|
|
|
### 删除or更新
|
|
|
|
@ -3593,6 +3699,75 @@ A先把数据库更新为 123,由于网络问题,更新缓存的动作慢了
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 缓存更新
|
|
|
|
|
|
|
|
|
|
除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),还可以根据具体的业务需求进行自定义的缓存淘汰。常见的更新策略如下:
|
|
|
|
|
|
|
|
|
|
- **LRU/LFU/FIFO**:都是属于当**缓存不够用**时采用的更新算法
|
|
|
|
|
|
|
|
|
|
适合内存空间有限,数据长期不变动,基本不存在数据一不致性业务。比如一些一经确定就不允许变更的信息。
|
|
|
|
|
|
|
|
|
|
- **超时剔除**:给缓存数据设置一个过期时间
|
|
|
|
|
|
|
|
|
|
适合于能够容忍一定时间内数据不一致性的业务,比如促销活动的描述文案。
|
|
|
|
|
|
|
|
|
|
- **主动更新**:如果数据源的数据有更新,则主动更新缓存
|
|
|
|
|
|
|
|
|
|
对于数据的一致性要求很高,比如交易系统,优惠劵的总张数。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
常见数据更新方式有两大类,其余基本都是这两类的变种:
|
|
|
|
|
|
|
|
|
|
**方式一:先删缓存,再更新数据库**
|
|
|
|
|
|
|
|
|
|
![缓存更新-先删缓存再更新数据库](images/Solution/缓存更新-先删缓存再更新数据库.png)
|
|
|
|
|
|
|
|
|
|
这种做法是遇到数据更新,我们先去删除缓存,然后再去更新DB,如左图。让我们来看一下整个操作的流程:
|
|
|
|
|
|
|
|
|
|
- A请求需要更新数据,先删除对应的缓存,还未更新DB
|
|
|
|
|
- B请求来读取数据
|
|
|
|
|
- B请求看到缓存里没有,就去读取DB并将旧数据写入缓存(脏数据)
|
|
|
|
|
- A请求更新DB
|
|
|
|
|
|
|
|
|
|
可以看到B请求将脏数据写入了缓存,如果这是一个读多写少的数据,可能脏数据会存在比较长的时间(要么有后续更新,要么等待缓存过期),这是业务上不能接受的。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**方式二:先更新数据库,再删除缓存**
|
|
|
|
|
|
|
|
|
|
![缓存更新-先更新数据库再删除缓存](images/Solution/缓存更新-先更新数据库再删除缓存.png)
|
|
|
|
|
|
|
|
|
|
上图的右侧部分可以看到在A更新DB和删除缓存之间B请求会读取到老数据,因为此时A操作还没有完成,并且这种读到老数据的时间是非常短的,可以满足数据最终一致性要求。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**删除缓存而非更新缓存原因**
|
|
|
|
|
|
|
|
|
|
上图可以看到我们用的是删除缓存,而不是更新缓存,原因如下图:
|
|
|
|
|
|
|
|
|
|
![缓存更新-删除缓存原因](images/Solution/缓存更新-删除缓存原因.png)
|
|
|
|
|
|
|
|
|
|
上图我用操作代替了删除或更新,当我们做删除操作时,A先删还是B先删没有关系,因为后续读取请求都会从DB加载出最新数据;但是当我们对缓存做的是更新操作时,就会对A先更新缓存还是B先更新缓存敏感了,如果A后更新,那么缓存里就又存在脏数据了,所以 go-zero 只使用删除缓存的方式。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**缓存更新请求处理流程**
|
|
|
|
|
|
|
|
|
|
我们来一起看看完整的请求处理流程:
|
|
|
|
|
|
|
|
|
|
![缓存更新-请求处理流程](images/Solution/缓存更新-请求处理流程.png)
|
|
|
|
|
|
|
|
|
|
**注意**:不同颜色代表不同请求。
|
|
|
|
|
|
|
|
|
|
- 请求1更新DB
|
|
|
|
|
- 请求2查询同一个数据,返回了老的数据,这个短时间内返回旧数据是可以接受的,满足最终一致性
|
|
|
|
|
- 请求1删除缓存
|
|
|
|
|
- 请求3再来请求时缓存里没有,就会查询数据库,并回写缓存再返回结果
|
|
|
|
|
- 后续的请求就会直接读取缓存了
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 缓存问题
|
|
|
|
|
|
|
|
|
|
### 缓存雪崩
|
|
|
|
@ -3677,171 +3852,101 @@ A先把数据库更新为 123,由于网络问题,更新缓存的动作慢了
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 缓存预热
|
|
|
|
|
|
|
|
|
|
缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
|
|
|
|
|
|
|
|
|
|
**缓存预热思路:**
|
|
|
|
|
|
|
|
|
|
- **数据量不大的时候**:工程启动的时候进行加载缓存动作
|
|
|
|
|
- **数据量大的时候**:设置一个定时任务脚本,进行缓存的刷新
|
|
|
|
|
- **数据量太大的时候**:优先保证热点数据进行提前加载到缓存
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**缓存预热解决方案:**
|
|
|
|
|
|
|
|
|
|
- 直接写个缓存刷新页面,上线时手工操作下
|
|
|
|
|
- 数据量不大,可以在项目启动的时候自动进行加载
|
|
|
|
|
- 定时刷新缓存
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 缓存降级
|
|
|
|
|
|
|
|
|
|
缓存降级是指当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
|
|
|
|
|
|
|
|
|
|
**降级的最终目的是保证核心服务可用,即使是有损的**。而且有些服务是无法降级的(如加入购物车、结算)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**分级降级预案:**
|
|
|
|
|
|
|
|
|
|
- **一般**:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级
|
|
|
|
|
- **警告**:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警
|
|
|
|
|
- **错误**:比如可用率低于90%,或数据库连接池被打爆,或访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级
|
|
|
|
|
- **严重错误**:比如因为特殊原因数据错误了,此时需要紧急人工降级
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 缓存更新
|
|
|
|
|
|
|
|
|
|
除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),还可以根据具体的业务需求进行自定义的缓存淘汰。常见的更新策略如下:
|
|
|
|
|
|
|
|
|
|
- **LRU/LFU/FIFO**:都是属于当**缓存不够用**时采用的更新算法
|
|
|
|
|
|
|
|
|
|
适合内存空间有限,数据长期不变动,基本不存在数据一不致性业务。比如一些一经确定就不允许变更的信息。
|
|
|
|
|
|
|
|
|
|
- **超时剔除**:给缓存数据设置一个过期时间
|
|
|
|
|
|
|
|
|
|
适合于能够容忍一定时间内数据不一致性的业务,比如促销活动的描述文案。
|
|
|
|
|
|
|
|
|
|
- **主动更新**:如果数据源的数据有更新,则主动更新缓存
|
|
|
|
|
|
|
|
|
|
对于数据的一致性要求很高,比如交易系统,优惠劵的总张数。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
常见数据更新方式有两大类,其余基本都是这两类的变种:
|
|
|
|
|
|
|
|
|
|
**方式一:先删缓存,再更新数据库**
|
|
|
|
|
|
|
|
|
|
![缓存更新-先删缓存再更新数据库](images/Solution/缓存更新-先删缓存再更新数据库.png)
|
|
|
|
|
|
|
|
|
|
这种做法是遇到数据更新,我们先去删除缓存,然后再去更新DB,如左图。让我们来看一下整个操作的流程:
|
|
|
|
|
|
|
|
|
|
- A请求需要更新数据,先删除对应的缓存,还未更新DB
|
|
|
|
|
- B请求来读取数据
|
|
|
|
|
- B请求看到缓存里没有,就去读取DB并将旧数据写入缓存(脏数据)
|
|
|
|
|
- A请求更新DB
|
|
|
|
|
|
|
|
|
|
可以看到B请求将脏数据写入了缓存,如果这是一个读多写少的数据,可能脏数据会存在比较长的时间(要么有后续更新,要么等待缓存过期),这是业务上不能接受的。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Hot Key
|
|
|
|
|
|
|
|
|
|
**方式二:先更新数据库,再删除缓存**
|
|
|
|
|
### 产生原因
|
|
|
|
|
|
|
|
|
|
![缓存更新-先更新数据库再删除缓存](images/Solution/缓存更新-先更新数据库再删除缓存.png)
|
|
|
|
|
- **用户消费的数据远大于生产的数据**(热卖商品、热点新闻、热点评论、明星直播)
|
|
|
|
|
|
|
|
|
|
上图的右侧部分可以看到在A更新DB和删除缓存之间B请求会读取到老数据,因为此时A操作还没有完成,并且这种读到老数据的时间是非常短的,可以满足数据最终一致性要求。
|
|
|
|
|
在日常工作生活中一些突发的的事件,例如:双十一期间某些热门商品的降价促销,当这其中的某一件商品被数万次点击浏览或者购买时,会形成一个较大的需求量,这种情况下就会造成热点问题。同理,被大量刊发、浏览的热点新闻、热点评论、明星直播等,这些典型的读多写少的场景也会产生热点问题。
|
|
|
|
|
|
|
|
|
|
- **请求分片集中,超过单 Server 的性能极限**
|
|
|
|
|
|
|
|
|
|
在服务端读数据进行访问时,往往会对数据进行分片切分,此过程中会在某一主机 Server 上对相应的 Key 进行访问,当访问超过 Server 极限时,就会导致热点 Key 问题的产生。
|
|
|
|
|
|
|
|
|
|
**删除缓存而非更新缓存原因**
|
|
|
|
|
|
|
|
|
|
上图可以看到我们用的是删除缓存,而不是更新缓存,原因如下图:
|
|
|
|
|
|
|
|
|
|
![缓存更新-删除缓存原因](images/Solution/缓存更新-删除缓存原因.png)
|
|
|
|
|
### 问题危害
|
|
|
|
|
|
|
|
|
|
上图我用操作代替了删除或更新,当我们做删除操作时,A先删还是B先删没有关系,因为后续读取请求都会从DB加载出最新数据;但是当我们对缓存做的是更新操作时,就会对A先更新缓存还是B先更新缓存敏感了,如果A后更新,那么缓存里就又存在脏数据了,所以 go-zero 只使用删除缓存的方式。
|
|
|
|
|
当某一热点 Key 的请求在某一主机上超过该主机网卡上限时,由于流量的过度集中,会导致服务器中其它服务无法进行。如果热点过于集中,热点 Key 的缓存过多,超过目前的缓存容量时,就会导致缓存分片服务被打垮现象的产生。当缓存服务崩溃后,此时再有请求产生,会缓存到后台 DB 上,由于DB 本身性能较弱,在面临大请求时很容易发生请求穿透现象,会进一步导致雪崩现象,严重影响设备的性能。
|
|
|
|
|
|
|
|
|
|
- **流量集中,达到物理网卡上限**
|
|
|
|
|
- **请求过多,缓存分片服务被打垮**
|
|
|
|
|
- **DB 击穿,引起业务雪崩**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**缓存更新请求处理流程**
|
|
|
|
|
|
|
|
|
|
我们来一起看看完整的请求处理流程:
|
|
|
|
|
|
|
|
|
|
![缓存更新-请求处理流程](images/Solution/缓存更新-请求处理流程.png)
|
|
|
|
|
### 发现热key
|
|
|
|
|
|
|
|
|
|
**注意**:不同颜色代表不同请求。
|
|
|
|
|
- 预估热key,如秒杀商品,火爆新闻
|
|
|
|
|
- 在客户端进行统计
|
|
|
|
|
- 可用Proxy,如Codis可以在Proxy端收集
|
|
|
|
|
- 利用redis自带命令,monitor,hotkeys。**执行缓慢,不推荐使用**
|
|
|
|
|
- 利用流式计算引擎统计访问次数,如Storm、Spark Streaming、Flink
|
|
|
|
|
|
|
|
|
|
- 请求1更新DB
|
|
|
|
|
- 请求2查询同一个数据,返回了老的数据,这个短时间内返回旧数据是可以接受的,满足最终一致性
|
|
|
|
|
- 请求1删除缓存
|
|
|
|
|
- 请求3再来请求时缓存里没有,就会查询数据库,并回写缓存再返回结果
|
|
|
|
|
- 后续的请求就会直接读取缓存了
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 解决方案
|
|
|
|
|
|
|
|
|
|
### 缓存Hot Key
|
|
|
|
|
通常的解决方案主要集中在对客户端和 Server 端进行相应的改造。
|
|
|
|
|
|
|
|
|
|
对于突发事件,大量用户同时去访问热点信息,这个突发热点信息所在的缓存节点就很容易出现过载和卡顿现象,甚至 Crash,我们称之为缓存热点。这个在新浪微博经常遇到,某大V明星出轨、结婚、离婚,瞬间引发数百千万的吃瓜群众围观,访问同一个key,流量集中打在一个缓存节点机器,很容易打爆网卡、带宽、CPU的上限,最终导致缓存不可用。
|
|
|
|
|
#### 读写分离
|
|
|
|
|
|
|
|
|
|
一般使用 **缓存+过期时间** 的策略来加速读写,又保证数据的定期更新,这种模式基本能满足绝大部分需求。但是如果有两个问题同时出现,可能会对应用造成致命的伤害:
|
|
|
|
|
![读写分离方案解决热读](images/Solution/读写分离方案解决热读.jpg)
|
|
|
|
|
|
|
|
|
|
- **当前key是一个hot key**。比如热点娱乐新闻,并发量非常大
|
|
|
|
|
- **重建缓存不能在短时间完成,可能是一个复杂计算**。例如复杂的SQL、多次IO、多个依赖等
|
|
|
|
|
架构中各节点的作用如下:
|
|
|
|
|
|
|
|
|
|
当缓存失效的瞬间,将会有大量线程来重建缓存,造成后端负载加大,甚至让应该崩溃。
|
|
|
|
|
- **SLB 层做负载均衡**
|
|
|
|
|
- **Proxy 层做读写分离自动路由**
|
|
|
|
|
- **Master 负责写请求**
|
|
|
|
|
- **ReadOnly 节点负责读请求**
|
|
|
|
|
- **Slave 节点和 Master 节点做高可用**
|
|
|
|
|
|
|
|
|
|
实际过程中 Client 将请求传到 SLB,SLB 又将其分发至多个 Proxy 内,通过 Proxy 对请求的识别,将其进行分类发送。例如,将同为 Write 的请求发送到 Master 模块内,而将 Read 的请求发送至 ReadOnly 模块。而模块中的只读节点可以进一步扩充,从而有效解决热点读的问题。读写分离同时具有可以灵活扩容读热点能力、可以存储大量热点Key、对客户端友好等优点。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**如何发现热key?**
|
|
|
|
|
|
|
|
|
|
- 预估热key,如秒杀商品,火爆新闻
|
|
|
|
|
- 在客户端进行统计
|
|
|
|
|
- 可用Proxy,如Codis可以在Proxy端收集
|
|
|
|
|
- 利用redis自带命令,monitor,hotkeys。**执行缓慢,不推荐使用**
|
|
|
|
|
- 利用流式计算引擎统计访问次数,如Storm、Spark Streaming、Flink
|
|
|
|
|
#### 热点数据
|
|
|
|
|
|
|
|
|
|
![热点数据解决方案](images/Solution/热点数据解决方案.jpg)
|
|
|
|
|
|
|
|
|
|
该方案通过主动发现热点并对其进行存储来解决热点 Key 的问题。首先 Client 也会访问 SLB,并且通过 SLB 将各种请求分发至 Proxy 中,Proxy 会按照基于路由的方式将请求转发至后端的 Redis 中。在热点 key 的解决上是采用在服务端增加缓存的方式进行。具体来说就是在 Proxy 上增加本地缓存,本地缓存采用 LRU 算法来缓存热点数据,后端 DB 节点增加热点数据计算模块来返回热点数据。Proxy 架构的主要有以下优点:
|
|
|
|
|
|
|
|
|
|
解决方案主要包括以下几种:
|
|
|
|
|
- **Proxy 本地缓存热点,读能力可水平扩展**
|
|
|
|
|
- **DB 节点定时计算热点数据集合**
|
|
|
|
|
- **DB 反馈 Proxy 热点数据**
|
|
|
|
|
- **对客户端完全透明,不需做任何兼容**
|
|
|
|
|
|
|
|
|
|
- 首先能先找到这个`热key`来,比如通过`Spark`实时流分析,及时发现新的热点key
|
|
|
|
|
- 将集中化流量打散,避免一个缓存节点过载。由于只有一个key,我们可以在key的后面拼上`有序编号`,比如`key#01`、`key#02`......`key#10`多个副本,这些加工后的key位于多个缓存节点上。每次请求时,客户端随机访问一个即可
|
|
|
|
|
|
|
|
|
|
- **本地缓存**
|
|
|
|
|
|
|
|
|
|
变更分布式缓存为本地缓存,减少网络开销,提高吞吐量。
|
|
|
|
|
### 热点key处理
|
|
|
|
|
|
|
|
|
|
- **互斥锁**
|
|
|
|
|
#### 热点数据的读取
|
|
|
|
|
|
|
|
|
|
具体做法是只允许一个线程重建缓存,其它线程等待重建缓存的线程执行完,重新从缓存获取数据即可。
|
|
|
|
|
![热点数据的读取](images/Solution/热点数据的读取.jpg)
|
|
|
|
|
|
|
|
|
|
- **方案风险**:重建的时间太长或者并发量太大,将会大量的线程阻塞,同样会加大系统负载
|
|
|
|
|
在热点 Key 的处理上主要分为写入跟读取两种形式,在数据写入过程当 SLB 收到数据 K1 并将其通过某一个 Proxy 写入一个 Redis,完成数据的写入。假若经过后端热点模块计算发现 K1 成为热点 key 后, Proxy 会将该热点进行缓存,当下次客户端再进行访问K1时,可以不经Redis。最后由于Proxy是可以水平扩充的,因此可以任意增强热点数据的访问能力。
|
|
|
|
|
|
|
|
|
|
- **优化方案**:除了重建线程之外,其它线程拿旧值直接返回
|
|
|
|
|
|
|
|
|
|
比如Google的Guava Cache的refreshAfterWrite采用的就是这种方案避免雪崩效应。
|
|
|
|
|
|
|
|
|
|
- **永不过期**
|
|
|
|
|
#### 热点数据的发现
|
|
|
|
|
|
|
|
|
|
这种就是缓存更新操作是独立的,可以通过跑定时任务来定期更新,或者变更数据时主动更新。
|
|
|
|
|
![热点数据的发现](images/Solution/热点数据的发现.jpg)
|
|
|
|
|
|
|
|
|
|
- **限流熔断**
|
|
|
|
|
对于 db 上热点数据的发现,首先会在一个周期内对 Key 进行请求统计,在达到请求量级后会对热点 Key 进行热点定位,并将所有的热点 Key 放入一个小的 LRU 链表内,在通过 Proxy 请求进行访问时,若 Redis 发现待访点是一个热点,就会进入一个反馈阶段,同时对该数据进行标记。DB 计算热点时,主要运用的方法和优势有:
|
|
|
|
|
|
|
|
|
|
以上两种方案都是建立在我们事先知道hot key的情况下,如果事先知道哪些是hot key,其实问题都不是很大。问题是我们不知道的情况,既然hot key的危害是因为有大量的重建请求落到了后端,如果后端自己做了限流,只有部分请求落到了后端, 其它的都打回去了。一个hot key只要有一个重建请求处理成功了,后面的请求都是直接走缓存了,问题就解决了。
|
|
|
|
|
- **基于统计阀值的热点统计**
|
|
|
|
|
- **基于统计周期的热点统计**
|
|
|
|
|
- **基于版本号实现的无需重置初值统计方法**
|
|
|
|
|
- **DB 计算同时具有对性能影响极其微小、内存占用极其微小等优点**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 缓存Big Key
|
|
|
|
|
## Big Key
|
|
|
|
|
|
|
|
|
|
Big Key指数据量大的key,由于其数据大小远大于其它key,导致经过分片之后,某个具体存储这个Big Key的实例内存使用量远大于其他实例,造成内存不足,拖累整个集群的使用。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**常见场景**
|
|
|
|
|
### 常见场景
|
|
|
|
|
|
|
|
|
|
- 热门话题下的讨论
|
|
|
|
|
- 大V的粉丝列表
|
|
|
|
@ -3850,7 +3955,7 @@ Big Key指数据量大的key,由于其数据大小远大于其它key,导致
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**大Key影响**
|
|
|
|
|
### 大Key影响
|
|
|
|
|
|
|
|
|
|
- 大key会大量占用内存,在Redis集群中无法均衡
|
|
|
|
|
- Reids性能下降,影响主从复制
|
|
|
|
@ -3858,14 +3963,14 @@ Big Key指数据量大的key,由于其数据大小远大于其它key,导致
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**如何发现大key**
|
|
|
|
|
### 如何发现大Key
|
|
|
|
|
|
|
|
|
|
- redis-cli --bigkeys命令。可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。但如果Redis的key比较多,执行该命令会比较慢
|
|
|
|
|
- 获取生产Redis的rdb文件,通过rdbtools分析rdb生成csv文件,再导入MySQL或其他数据库中进行分析统计,根据size_in_bytes统计bigkey
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
**解决方案**
|
|
|
|
|
### 解决方案
|
|
|
|
|
|
|
|
|
|
优化big key的原则就是string减少字符串长度,而list、hash、set、zset等则减少成员数:
|
|
|
|
|
|
|
|
|
@ -3878,78 +3983,51 @@ Big Key指数据量大的key,由于其数据大小远大于其它key,导致
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 数据一致性
|
|
|
|
|
|
|
|
|
|
![缓存双写一致性](images/Solution/缓存双写一致性.png)
|
|
|
|
|
|
|
|
|
|
一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。
|
|
|
|
|
|
|
|
|
|
- **强一致性**:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
|
|
|
|
|
- **弱一致性**:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
|
|
|
|
|
- **最终一致性**:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## 其它问题
|
|
|
|
|
|
|
|
|
|
数据库和缓存数据如何保持强一致?实际上,没办法做到数据库与缓存**绝对的一致性**。
|
|
|
|
|
|
|
|
|
|
- 加锁可以吗?并发写期间加锁,任何读操作不写入缓存?
|
|
|
|
|
- 缓存及数据库封装CAS乐观锁,更新缓存时通过lua脚本?
|
|
|
|
|
- 分布式事务,3PC?TCC?
|
|
|
|
|
|
|
|
|
|
其实,这是由**CAP理论**决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。**个人觉得,追求绝对一致性的业务场景,不适合引入缓存**。
|
|
|
|
|
|
|
|
|
|
> CAP理论,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
但是,通过一些方案优化处理,是可以**保证弱一致性,最终一致性**的。有3种方案保证数据库与缓存的一致性:**缓存延时双删、删除缓存重试机制和读取biglog异步删除缓存**。
|
|
|
|
|
### 缓存预热
|
|
|
|
|
|
|
|
|
|
缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
|
|
|
|
|
|
|
|
|
|
**缓存预热思路**
|
|
|
|
|
|
|
|
|
|
### 缓存延时双删
|
|
|
|
|
- **数据量不大的时候**:工程启动的时候进行加载缓存动作
|
|
|
|
|
- **数据量大的时候**:设置一个定时任务脚本,进行缓存的刷新
|
|
|
|
|
- **数据量太大的时候**:优先保证热点数据进行提前加载到缓存
|
|
|
|
|
|
|
|
|
|
有人可能会说,并不一定要先操作数据库呀,采用**缓存延时双删**策略,就可以保证数据的一致性啦。什么是延时双删呢?
|
|
|
|
|
|
|
|
|
|
![延时双删流程](images/Solution/延时双删流程.png)
|
|
|
|
|
|
|
|
|
|
1. 先删除缓存
|
|
|
|
|
2. 再更新数据库
|
|
|
|
|
3. 休眠一会(比如1秒),再次删除缓存
|
|
|
|
|
**预热解决方案**
|
|
|
|
|
|
|
|
|
|
这个休眠一会,一般多久呢?都是1秒?
|
|
|
|
|
- 直接写个缓存刷新页面,上线时手工操作下
|
|
|
|
|
- 数据量不大,可以在项目启动的时候自动进行加载
|
|
|
|
|
- 定时刷新缓存
|
|
|
|
|
|
|
|
|
|
> 这个休眠时间 = 读业务逻辑数据的耗时 + 几百毫秒
|
|
|
|
|
>
|
|
|
|
|
> 为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
|
|
|
|
|
|
|
|
|
|
这种方案还算可以,只有休眠那一会(比如就那1秒),可能有脏数据,一般业务也会接受的。但是如果**第二次删除缓存失败**呢?缓存和数据库的数据还是可能不一致,对吧?给Key设置一个自然的expire过期时间,让它自动过期怎样?那业务要接受**过期时间**内,数据的不一致咯?还是有其他更佳方案呢?
|
|
|
|
|
|
|
|
|
|
**缓存加载策略**
|
|
|
|
|
|
|
|
|
|
- **使用时加载缓存**。当需要使用缓存数据时,就从数据库中查出,第一次查出后,接下来的请求都能从缓存中查询到数据
|
|
|
|
|
- **预加载缓存**。在项目启动的时候,预加载类似“国家信息、货币信息、用户信息,新闻信息”等不是经常变更的数据
|
|
|
|
|
|
|
|
|
|
### 删除缓存重试机制
|
|
|
|
|
|
|
|
|
|
不管是**延时双删**还是**Cache-Aside的先操作数据库再删除缓存**,都可能会存在第二步的删除缓存失败,导致的数据不一致问题。可以使用这个方案优化:删除失败就多删除几次呀,保证删除缓存成功就可以了呀~ 所以可以引入**删除缓存重试机制**
|
|
|
|
|
|
|
|
|
|
![删除缓存重试流程](images/Solution/删除缓存重试流程.png)
|
|
|
|
|
### 缓存降级
|
|
|
|
|
|
|
|
|
|
1. 写请求更新数据库
|
|
|
|
|
2. 缓存因为某些原因,删除失败
|
|
|
|
|
3. 把删除失败的key放到消息队列
|
|
|
|
|
4. 消费消息队列的消息,获取要删除的key
|
|
|
|
|
5. 重试删除缓存操作
|
|
|
|
|
缓存降级是指当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
|
|
|
|
|
|
|
|
|
|
**降级的最终目的是保证核心服务可用,即使是有损的**。而且有些服务是无法降级的(如加入购物车、结算)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### 读取biglog异步删除缓存
|
|
|
|
|
|
|
|
|
|
重试删除缓存机制还可以吧,就是会造成好多**业务代码入侵**。其实,还可以这样优化:通过数据库的**binlog来异步淘汰key**。
|
|
|
|
|
**分级降级预案:**
|
|
|
|
|
|
|
|
|
|
![读取biglog异步删除缓存](images/Solution/读取biglog异步删除缓存.png)
|
|
|
|
|
- **一般**:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级
|
|
|
|
|
- **警告**:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警
|
|
|
|
|
- **错误**:比如可用率低于90%,或数据库连接池被打爆,或访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级
|
|
|
|
|
- **严重错误**:比如因为特殊原因数据错误了,此时需要紧急人工降级
|
|
|
|
|
|
|
|
|
|
以mysql为例:
|
|
|
|
|
|
|
|
|
|
- 可以使用阿里的canal将binlog日志采集发送到MQ队列里面
|
|
|
|
|
- 然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|