最近在业务开发中经常使用到缓存,那么我们就聊聊缓存相关的应用实践。如果系统没有使用缓存技术,处理业务的性能将受到极大制约,我们知道从用户端请求数据,如果在没有缓存的情况下,当用户端有大量请求同时发起,每次请求都会与数据库直接交互。这时候压力来到数据库,达到数据库io性能瓶颈时,其他服务可能因数据库无法读取写入数据时,造成服务不可用,从而引发一系列连锁反应,这将是开发人员的终极梦魇。
引入缓存
为解决上述问题,我们只需在用户端和数据库中间添加缓存。用户端的请求先通过缓存读取数据,如果数据不存在,则查询数据库将数据保存到缓存中,然后返回给用户端。这种方案既避免了请求对数据库造成的直接负载压力,又可以提高读写性能,缓存中间件一般都是在内存中存储数据,有很高的QPS且加快了接口响应时间。结构图如下
缓存的双刃剑
在分布式系统引入缓存后,我们很容易地发现系统接口响应时间明显提升了一个等级,然后对数据库的io占用也降低了许多,对后端线程的负载减少了。但过了一段时间我们发现系统出现一些问题。数据不一致性问题,缓存击穿、缓存穿透、缓存雪崩等问题,也是在面试中经常被问到的问题,实锤是必知必会的面试题。下面我们将围绕这些问题提出可行性解决方案。
1.数据不一致性问题
无论业务项目是分布式还是单体系统,引入缓存后无疑增加系统的复杂度,在数据更新,既要更新数据库,又要更新缓存。那么这里有个问题了,到底先更新数据库还是先更新缓存?
这时候就有小伙伴说了先更新数据库后更新缓存,得确保最新的数据保存到数据库中。经过一通分析操作成功上线,没过两天业务方客户就开始怼着项目经理国粹了。项目经理开始找苦大仇深的研发小白谈心,你咋个写的功能之类之类的话,你XXX干的啥子活等等。这时苦逼的研发小白开始认真上网查资料。研发小白开启认真分析心路OS: 如果先更新数据库数据再更新缓存,有两个请求同时访问同一条数据时很可能会出现访问到的数据和存储的数据是不一致的,即缓存和数据库中的数据在同一个时刻是不一致的问题。如下图所示:
此时研发小白想到了一种思路,更新数据库的同时直接删除缓存,不就解决了从缓存中拿取旧数据的问题嘛。在读取缓存发现缓存中没有该数据存在,再次查询数据库拿取最新的数据放入缓存中,然后返回给用户端。这种思路称之为 Cache Aside 策略, 其中分为写操作和读操作,
写操作:更新数据库数据,删除缓存数据。
读操作:查询数据是否存在于缓存中。如果命中,直接从缓存获取数据,返回直接返回;如果不能命中,则从数据库查询数据,存入缓存中,返回结果。
废话不多说,我们直接上图验证数据在并发情况下有没有问题。
我们发现在实际应用场景中只要是在读多写少的场景,缓存写入速率是远高于数据库写入(ps:不要问我为什么,谁叫缓存中间件通常使用的是内存呢),在一般场景下上图浏览器A的行为会早于浏览器B删除缓存动作发生,再下次别的请求到缓存中的时候,发现缓存中没有数据了,又开始进行循环动作,重新到数据库拿取新的数据更新到缓存中。
理想是完美的,现实是骨感的。不出意外的还是出现意外,随着上线后用户请求的并发增加,当有多次请求更新操作时,这时候缓存和数据库又出现不一致现象,原因是更新数据库和更新缓存是两个独立的操作,有没有对其做并发控制,无法对写入顺序保持一致导致数据不一致现象。如下图所示:
在并发较高的场景中,浏览器1请求先进入系统,先把缓存删除了。由于网络波动的原因,造成延迟写入新数据到数据库中。同时刻浏览器2请求也进入系统,发现缓存没命中就查询数据库,这时候数据库还没来得及写入新数据,即返回原有旧数据到缓存,浏览器2得到旧值。等到网络延迟结束,原有浏览器1的请求将新数据已经写入数据库。这样就导致缓存与数据库数据不一致问题。那么这种问题能有解决方案吗?不妨我们看看网上常用的方案,这也是面试环节广大程序员们被老生常谈的问题。
解决方案
1.缓存双删
如上图所示就是常被提起的缓存双删。具体步骤为:先删除缓存,再写数据库,根据业务要求休眠具体时间,再次删除缓存。其主要目的是确保读取数据的请求结束的时候,写入数据请求可以删除读取数据请求造成的缓存保持旧数据的问题。但是这个方案有个缺陷,当删除缓存失败了呢,那还是会出现数据不一致的情况。那么既然一次删除不了,我们是不是可以用重试机制来给这个方案打个补丁呢?废话不多说说干就干直接上方案。但是系统处于高并发的场景下,如果直接用同步重试将会降低系统性能,因此只能用异步重试。异步的方式有很多,比如:重试框架、消息队列、定时任务更新等。
2.异步重试
1.重试任务给重试机制(比如:retry-go)处理,处理数据在可能因为服务器重启导致部分数据丢失;
2.由定时任务定时扫描数据库重试表数据,执行删除缓存操作,设定执行最大重试数,直到操作成功;否则进入下次重试定时任务中;
3.重试数据写入mq中,自己消费队列消息,得到需要删除的数据,消费失败,由消息队列重试删除操作,直到操作成功;
下面还是用图说明这些方案是否能满足我们业务要求的既要保证缓存的快速响应,又要数据最终一致性的目的。
异步重试机制
通过上面的示意图,我们只需要在删除缓存的环节添加重试机制既可以。这里我就以retry-go 库为例,通过简单代码实现该功能。但是这个需要在业务上做幂等性等措施,如果redis做了集群化部署,以及主从同步模式下一旦出现网络故障导致重试,是可能会导致在主从节点数据不一致问题。要想解决这些问题可以使用主节点持久化、哨兵模式、复制积压缓冲区(replica backlog)等方式解决
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"github.com/avast/retry-go"
"time"
)
func main() {
// 创建Redis客户端
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
DB: 0, // 选择数据库
})
// 设置重试参数
retryOptions := []retry.Option{
retry.Attempts(5), // 最大重试次数:5次
retry.Delay(time.Second), // 重试间隔单位: second
retry.LastErrorOnly(true), // 只返回最后一次错误
retry.OnRetry(func(n uint, err error) {
fmt.Printf("删除Key失败,重试次数:%d\n", n)
}),
}
// 调用删除Key函数,带有重试机制
err := retry.Do(func() error {
return deleteKey(client, "specialfood")
}, retryOptions...)
if err != nil {
fmt.Println("删除Key失败:", err)
} else {
fmt.Println("删除成功")
}
}
// deleteKey 函数删除Redis指定Key
func deleteKey(client *redis.Client, key string) error {
return client.Del(context.Background(), key).Err()
}
定时任务
定时任务主要是针对于数据更新实时性要求没有那么高的业务场景。对于实时性要求特别高的业务系统是不合适的,因此我们不考虑这个方案。
消息队列
消息队列既可以异步又可以业务解耦,还可以削峰填谷(请求峰值的时候,由于mq消费者消费队列能力是有限的,未处理的请求在队列中等待处理,维持分布式高并发系统的稳定性。)
这种方案优点在于将删除缓存的任务全部交给消息队列处理,只需要保证mq正常进入队列消费消息就可以保证删除缓存正常执行。同时支持重试机制以及顺序执行、部分消息中间件还可以进行事务、死信队列等多种场景应用,其实时性也能到达系统要求,但是其引入增加了系统复杂度,也要避免消息队列给业务系统重复消费、消息堆积、消息丢失等问题。在解决这些问题的情况下,使用消息队列处理删除缓存是一种比较好的方案。
binlog监听(业务侵入最小,推荐)
通过前面的方案比较,不难发现我们都需要在业务代码中添加删除缓存的逻辑。那有没有一个不需要在业务逻辑中处理删除缓存的方案呢?答案是有的。我们都知道每次请求更新数据后会直接给到数据库,去更新数据库数据。因此只需要监听binlog就可以知道数据库好久更新了数据,我们就可以去执行删除缓存操作,就能保证缓存和数据库两者的数据一致性。