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的缓存,都有一个更新数据、查询数据的操作,同时缓存还多一个删除缓存
class MysqlDb
{
public MysqlDb(string data)
{
Data= data;
}
public string Data{get;set;}
public string GetData()
{
returnData;
}
public void SetData(string data)
{
Data= data;
}
}
class RedisDb
{
public string? Data{get;set;}
public string? GetData()
{
return Data;
}
public void SetData(string? data)
{
Data= data;
}
public void DelData()
{
Data=null;
}
}
上面的方法可以理解为官方提供的驱动包,接下来,我们撸两个查数据和更新数据的方法,下面的方法可以理解是我们自己写的查询和更新操作
//查询数据,当缓存没有数据的时候,就去找mysql的数据,并赋值给缓存
string Get()
{
var redisData = redisDb.GetData();
if(redisData ==null)
{
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(null);
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()!=null)
{
//出现不一样的情况,自增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 ==null)
{
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相关知识内容
与你一起学习,一起进步