【橙子老哥】C实操高并发分布式缓存解决方案

程序员有二十年 2024-10-19 10:50:49
hello,大家好,今天到了是橙子老哥的分享时间,希望大家一起学习,一起进步。

欢迎加入.net意社区,第一时间了解我们的动态,文章第一时间分享至社区

社区官方地址:https://ccnetcore.com

官方微信公众号:搜索 意.Net

添加橙子老哥微信加入官方微信群:chengzilaoge520

此篇我们放松一下,不看源码了,而是看看高并发分布式缓存的使用

相信很多人已经对分布式缓存这种面试八股文很熟悉了,说个七七八八不成问题,网上也有很多教程,但是多偏向于理论,没有实操,今天橙子老哥使用c#,带大家把整个流程落地一遍

希望下次遇到这个问题,能回想到橙子老哥的这篇文章,就是这篇文章的意义了

1、CAP原则

来了,提到分布式,第一时间想到的就会想到CAP,这里也不会过多赘述它,我们简单过一遍

CAP原则,全称Consistency(一致性)、Availability(可用性)、Partition Tolerance(分区容错性)原则,是分布式系统设计中一个经典的理论。它指出在分布式系统中,任何系统都无法同时满足以下三个要求,设计者必须在三者间做出折衷:

1. 一致性(Consistency): 数据一致性意味着在分布式系统中,任何时刻所有节点都能看到相同的数据视图。当一个数据更新成功后,其他节点访问时应能看到这个更新后的值,即保证了全局的数据一致性。

2. 可用性(Availability): 可用性意味着在正常情况下,任何时候请求都能够得到响应(不保证响应的是最新的数据),且响应时间在合理范围内。简单来说,系统始终保持可读和可写的状态。

3. 分区容错性(Partition Tolerance): 分区容错性是指在分布式系统中,网络分区或通信失败可能发生,即节点间可能由于网络原因无法通信,但即便在这种情况下,系统也要能够继续运作。

简单说,就是在任何时候最多只能三选二,做不到十全十美,而分布式系统架构,就已经要先满足分区容错性,剩下的一致性和可用性

我们不能同时满足,因为分布式系统存在多个节点,我们无法确保节点之间的信息交换的百分之百成功,或者无延迟等极端情况

• 当要数据绝对的一致性,那得上锁,可用性就差了

• 当可用性绝对的高,请求立马返回数据,并发高了,数据可能出现一系列问题,一致性就差了

这个就是cap原则,我们虽然不能同时满足绝对的一致性和可用性,但是我们可以根据业务进行妥协

• 例如银行系统的转账,一致性要高,所以转账等操作多花费一点时间再出结果,降低一点可用性也没关系

• 例如一些系统,要求能立马返回结果,不能有延迟,但是允许返回稍为旧一点的数据,但是最后结果一定要保证结果的一致性,这个也是我们比较主流常见的一种设计

就介绍到这里,有了理论,我们来看看如何在分布式高并发的场景下,保证缓存的一致性问题(最终一致性)

2、实战准备

我们先编写2个db,一个mysql的数据库,一个redis的缓存,都有一个更新数据、查询数据的操作,同时缓存还多一个删除缓存

MysqlDb{ public MysqlDb(string data) { Data= data; } public string Data{get;set;} public string GetData() { returnData; } public void SetData(string data) { Data= data; }} RedisDb { public string? Data{get;set;} public string? GetData() { return Data; } public void SetData(string? data) { Data= data; } public void DelData() { Data=; } }

上面的方法可以理解为官方提供的驱动包,接下来,我们撸两个查数据和更新数据的方法,下面的方法可以理解是我们自己写的查询和更新操作

//查询数据,当缓存没有数据的时候,就去找mysql的数据,并赋值给缓存string Get(){ var redisData = redisDb.GetData(); if(redisData ==) { var data = mysqlDb.GetData(); //4特殊 Thread.Sleep(2000); redisDb.SetData(data); return data; } else { return redisData;

}

}

//更新数据void Update(string data){//待做}//开始撸业务了//给mysql一个默认100的数据var mysqlDb =new MysqlDb("100");//redis缓存为空var redisDb =new RedisDb();//用例循环次数int number =10;int copyNumber = number;//结果不一致错误的出现的次数int differentNumber =0;while(copyNumber >0){//每次循环初始化 mysqlDb.SetData("100"); redisDb.SetData(); Console.WriteLine("----------进行新的一轮----------"); Task[] tasks =new Task[2]; Console.WriteLine($"之前-mysql:{mysqlDb.GetData()},redis-{redisDb.GetData()}"); //第一个线程执行查询或者更新操作 tasks[0]=new Task(()=> { //Update_1("66"); //Console.WriteLine($"1-当前线程更新操作:66"); Console.WriteLine($"1-当前线程读取操作:{Get()}"); }); //第二个线程执行查询或者更新操作 tasks[1]=new Task(()=> { // Update_1("77"); Update_4("77"); Console.WriteLine($"2-当前线程更新操作:77"); }); // 启动所有任务 for(int i =0; i <2; i++) { tasks[i].Start(); } await Task.WhenAll(tasks); //所有执行完成,比较数据库和缓存的数据出现不一致的情况 Console.WriteLine($"之后-mysql:{mysqlDb.GetData()},redis-{redisDb.GetData()}"); Console.WriteLine("完成"); //这里要注意一点,如果结果缓存是空的,其实也是一致性的,当下次查询到来,就会保证最终一致性 if(mysqlDb.GetData()!= redisDb.GetData()&& redisDb.GetData()!=) { //出现不一样的情况,自增1 Interlocked.Increment(ref differentNumber); } copyNumber--;}//打印统计结果Console.WriteLine();Console.WriteLine($"数据库与缓存出现差异比例:{differentNumber}/{number}");

好了,到这里,就已经准备好了,接下来我们开始进入分析

3、缓存更新策略分析

上面,查询的数据的方法,相信大家都会写,我们空下了更新的方法,还没有去写,因为不同的缓存更新策略,在高并发的场景下,是完全不一样的!

我把我们常见的缓存更新策略的方式,并用编号列出来

1. 先更新缓存,再更新数据库

2. 先更新数据库,再更新缓存

3. 删除缓存,更新数据库

4. 更新数据库,删除缓存

以上的4种方式,如果不是在高并发的场景中,结果都是一样的,但是当并发高的时候,又是另一回事了

高并发编程 与 低并发编程 很多时候完全要考虑的东西不一样

以上4种情况,还有个前提,就是任何时候,任何操作不会执行失败,保证分区容错性,如果更新数据库或者更新缓存操作会存在失败的情况,那需要用到别的手段,这个我们放到最后去讲

4、方案1-先更新缓存,再更新数据库

线程1 - 线程2都执行更新操作

void Update_1(string data){ mysqlDb.SetData(data); Thread.Sleep(new Random().Next(1,50)); redisDb.SetData(data);}tasks[0]=new Task(()=>{ Update_1("66"); Console.WriteLine($"1-当前线程更新操作:66"); // Console.WriteLine($"1-当前线程读取操作:{Get()}");}); tasks[1]=new Task(()=>{ Update_1("77"); // Update_4("77"); Console.WriteLine($"2-当前线程更新操作:77"); });

我们先将数据库的数据更新,再更新缓存的数据库,中间的随机等待代表执行的网络延迟、执行时间情况

执行结果: 数据库与缓存出现差异比例:8/10 是的,你没看错,如果采用这种方案,只是简单的2个线程并发,10次就出现了8次问题

问题出现执行顺序:

1. 线程 A 更新数据库(X = 1)

2. 线程 B 更新数据库(X = 2)

3. 线程 B 更新缓存(X = 2)

4. 线程 A 更新缓存(X = 1)

最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。

我们回想一下为什么会导致这个问题?答:问题在中间的存在时间差,A先去数据库更新,但是A又是最后缓存更新,导致数据库更新被B覆盖,缓存更新,又被A覆盖

5、方案2-先更新数据库,再更新缓存

线程1 - 线程2都执行更新操作

void Update_2(string data){ redisDb.SetData(data); Thread.Sleep(new Random().Next(1,50)); mysqlDb.SetData(data);}tasks[0]=new Task(()=>{ Update_2("66"); Console.WriteLine($"1-当前线程更新操作:66"); // Console.WriteLine($"1-当前线程读取操作:{Get()}");});tasks[1]=new Task(()=>{ Update_2("77"); // Update_4("77"); Console.WriteLine($"2-当前线程更新操作:77");});

执行结果: 数据库与缓存出现差异比例:6/10

没有什么区别,这个情况和上面一种是一样的,只是顺序反过来了

我们回想一下为什么会导致这个问题?答:问题在中间的存在时间差,A先去缓存更新,但是A又是最后数据库更新,导致缓存更新被B覆盖,数据库更新,又被A覆盖

看来同时去更新缓存和数据库,区别都不大,而且在高并发场景下,出一堆问题,淘汰,接下来,我们看看不更新缓存,而是直接删除缓存

6、方案3-先删除缓存,再更新数据库

线程1 执行查询操作 - 线程2执行更新操作

void Update_3(string data){ redisDb.DelData(); Thread.Sleep(new Random().Next(1,50)); mysqlDb.SetData(data);}tasks[0]=new Task(()=>{ // Update_2("66"); // Console.WriteLine($"1-当前线程更新操作:66"); Console.WriteLine($"1-当前线程读取操作:{Get()}");

});

tasks[1]=new Task(()=>

{ Update_3("77"); Console.WriteLine($"2-当前线程更新操作:77"); });

执行结果: 数据库与缓存出现差异比例:9/10 即使换成了删除缓存的操作,好像这个一致性问题,在高并发情况下并没有解决

问题出现执行顺序:

1. 线程 A 要更新 X = 2(原值 X = 1)

2. 线程 A 先删除缓存

3. 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)

4. 线程 A 将新值写入数据库(X = 2)

5. 线程 B 将旧值写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。

我们回想一下为什么会导致这个问题?答:问题在中间的存在时间差,A先去删除缓存,B就去读取发现是空的把数据库老的值准备进行更新,A去更新数据库之后,B又把数据库老的值更新了

现在还有最后一个方案,如果这个也不行,说明我们更新操作还得换

7、方案4-先更新数据库,再删除缓存

线程1 执行查询操作 - 线程2执行更新操作

void Update_4(string data){ mysqlDb.SetData(data); Thread.Sleep(new Random().Next(1,50)); redisDb.DelData();}tasks[0]=new Task(()=>{ // Update_2("66"); // Console.WriteLine($"1-当前线程更新操作:66"); Console.WriteLine($"1-当前线程读取操作:{Get()}");});tasks[1]=new Task(()=>{ Update_4("77"); Console.WriteLine($"2-当前线程更新操作:77");});

执行结果: 数据库与缓存出现差异比例:0/10 什么?竟然没有出现问题?难道这个就是最终解决方案嘛?

其实不然,我们在两个地方等待,这些情况都可能发生

//在数据库更新加个等待void Update_4(string data){ Thread.Sleep(300); mysqlDb.SetData(data); Thread.Sleep(newRandom().Next(1,50)); redisDb.DelData();}//同时在查询方法加个等待string Get(){ var redisData = redisDb.GetData(); if(redisData ==) { var data = mysqlDb.GetData(); Thread.Sleep(2000); redisDb.SetData(data); return data; } else { return redisData; }}

执行结果: 数据库与缓存出现差异比例:10/10 这次相反,全部错误!只是在一些地方加入了网络延迟结果又完全不一样了!

我们来分析一下 问题出现执行顺序:

1. 缓存中 X 不存在(数据库 X = 1)

2. 线程 A 读取数据库,得到旧值(X = 1)

3. 线程 B 更新数据库(X = 2)

4. 线程 B 删除缓存

5. 线程 A 将旧值写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。

我们回想一下为什么会导致这个问题?答:先更新数据库,后删除缓存,也有可能会存在问题,A去读取缓存,发现没有把数据库的值读取到了,准备去写给缓存,B把数据库更新,同时还删了缓存,A更改数据库,问题很难复现,因为在B更新数据的之前,A要去读取完数据库,同时还没有写到缓存的时候,(这步难)B要去执行删除缓存,A在覆盖缓存

综上所述,先更新数据库,再删除缓存是更推荐的方式,在高并发场景,基本能保证一致性,只有在上述这种情况才会出现,其实概率「很低」,这是因为它必须满足 3 个条件:

• 缓存刚好已失效

• 读请求 + 写请求并发

• 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)

仔细想一下,条件 3 发生的概率其实是非常低的。

8、最终方案

前面小结,我们实操了各各方案,最后看缓存的一致性,选出了先更新数据库,再删除缓存是更推荐的方式,但是我们也提到了,这种方式在一些特殊情况,还是出问题!

另外,一开始我们也说过,这些方案的前提是所有操作都可用,网络正常、数据库正常、缓存正常、程序正常,实际情况,来个火灾,来个爆炸,来个断网啥情况都有,这些也都是不能保证

解决这种意外情况,并要保持最终一致性,方案只有:重试!

重试也分异步重试和堵塞重试,这里当然推荐异步,同时重试的信息都要进行保存,只有当真正执行握手成功后才能删除,这里,我们就要引入新的组件-消息队列

当我们使用消息队列去重试处理了这种意外情况,那我们现在再看下方案4,无论如何做,好像都会存在情况有问题

那有没有真正一种方案,解决上述问题?

有!就是那个大名鼎鼎的 - 延迟删除

方案4中,导致意外情况出现,是因为,先删除了缓存,随后被读的缓存给覆盖,只要我们确保了删除缓存是在最后一步,等下次情况下来,发现是空的缓存,就会去查询缓存,保证数据的一致性

那如何保证,更新操作,最后一步是删除缓存呢?

延迟删除给了我们答案,当我们执行最后一步删除,往消息队列丢一个延迟的消息,例如5s,5s后让消费者去消费,再次把缓存删掉,这样就确保了这个删除是最后一步

当然,如果是方案3,也可以玩出花,由于是先删除缓存,再更新数据库,我们再最后一步,和上面一样再来个删除,确保最后一步一定是删除缓存,也能保证缓存的一致性,由于这里被删了两次,也叫做- 延迟双删

妥了吗?其实远远没有,当我们引入了更多的组件,也会带来更多的问题,CAP原则的限制,我们都是需要做出取舍的,比如最终方案,延迟删除我们要延迟多久?延迟的时间内,是不是都是不一致的情况?引入了消息队列,是不是也要考虑与各各组件通讯问题?数据库更新成功,缓存炸了,是不是还要考虑数据库与缓存的事务问题?等等

我们难道真的无法做到百分之百的一致吗?写一个不到100人的博客站点,你整这一套?我的朋友,换个想法,你能做到百分之99.9999,1年不出问题,最后百分之0.0001人工介入下,这是不是也算百分之百~ 我们应该以实际业务场景为主,而不是一味的追求最极致的可用。

最后的最后 - 意.Net 小程序即将上线 啦!各位敬请期待!--爱你们的橙子老哥

.Net意社区,高频发布原创有深度的.Net相关知识内容

与你一起学习,一起进步

0 阅读:0
程序员有二十年

程序员有二十年

感谢大家的关注