1. 前言
Redis作为一款缓存中间件,如今不可谓不火,火了好长一段时间,缘于其优秀的性能和丰富的功能,如今只要是有缓存、性能优化类的需求,都会第一时间想到用Redis。但要知道,世上没有什么东西是完美的,水能载舟亦能覆舟,火能取暖也能燎原,Redis具备高性能的同时,也伴随着数据一致性问题,这也是各位有经验的开发者或架构师们津津乐道的话题。
公司每次组织代码评审,我看到其它业务代码中应用Redis的时候,经常看到有使用Redis用来做缓存,提升数据查询性能,当然,大家关心的缓存数据和数据库数据的一致性的问题,他们当然也有考虑到,但每次都没能做到十全十美,这与Redis的特性是有关的。我刚开始自己写代码以及做代码评审的时候,总是忍不住要细抠每一个细节和异常点,既想要拥有Redis的高性能,又要求我的和别人的代码保证一致性,本文想要讨论的正是这所谓的“一致性”问题,到底要怎么做才能十全十美呢?什么场景下要如何考虑一致性方案?一致性真的那么重要吗?
2. 简单聊一下,Redis为什么会有一致性问题
了解CAP理论的同学,应该知道,Redis是AP模型,也就是舍弃了C(一致性),选择了A(可用性)。
Redis Server收到数据后,会先将数据保存到内存,并基于异步刷盘机制,对数据进行持久化。
2.1 持久化机制-RDB
RDB是Redis对内存的快照,是一个二进制文件,Redis的conf文件中支持该快照同步机制触发条件的配置,像这样:
save 900 1 # 900秒之内对数据库进行了至少1次修改
save 300 10 # 300秒之内对数据库进行了至少10次修改
save 60 10000 # 60秒之内对数据库进行了至少10000次修改
只要满足以上三个条件中的任意一个,BGSAVE(后台刷盘)命令就会被执行。
由此可见,在RDB模式下,内存数据与持久化文件之间存在不一致的时间段,此时间段内如果Redis重启,数据将丢失。
2.2 持久化机制-AOF
AOF是对客户端向Redis Server发起写操作命令的原始报文的记录,以日志的形式来记录每个写操作(增量保存),将Redis执行过的所有写指令记录下来(读操作不记录), 只许追加文件但不可以改写文件,redis启动之初会读取该文件重新构建数据。
它也支持持久化策略配置如下:
appendfsync always # 始终同步,每次Redis的写入都会立刻记入日志;性能较差但数据完整性比较好
appendfsync everysec # 每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能丢失。
appendfsync no # redis不主动进行同步,把同步时机交给操作系统。
三者任选其一,通常选用everysec策略,达到一个一致性和性能平衡的效果。
2.3 主从同步的问题
Redis不管是单机、哨兵、集群,每个节点都必不可少地需要配置从节点,来提升其可靠性,即:主节点异常宕机后,由备节点接替业务,因此,备节点需要时刻与主节点保持数据一致性。但前文说了,Redis是AP模型,必须满足高可靠,如果客户端在写数据时,主节点还要将其数据同步给所有的从节点后才视为写数据成功并返回客户端,那就违背了【A】的宗旨,因此,Redis的主从同步也是异步的,其缺点跟本节点持久化一样,都可能存在本节点挂掉重启导致内存数据丢失的问题。
3. 跳出程序员思维,从上帝视角看Redis的应用
世上没有什么是全能的,生活不可能完美,技术也没有银弹。
Redis现如今为我们的业务提供了诸多选择,诸多帮助,我们在使用它的时候,应当做到“扬长避短”,而不是要求它“左右兼顾”。
我重新回顾了自己写的所有关于Redis的代码,也回顾了作为评审专家参与代码评审时提出的相关意见,进行了一次反思,我们到底为什么用Redis?
3.1 Redis生而为“快”
要知道,Redis诞生之初,就是为了解决性能问题的。国外那哥们儿一开始就是在使用关系型数据库MySQL来查询统计信息时,觉得太慢了,才想到搞一个Redis来单纯地解决“慢”这个问题。只不过呢,Redis发展到今天,api越来越丰富,数据类型越来越多,功能越来越完善,我们作为高级开发人员或者架构师在进行技术选型时,有了更多的选择,用它做MQ、分布式锁、分布式计数器、限流等等。但一定要结合业务场景和技术架构,充分评估其优缺点,并从业务和技术层面制定异常补偿措施,否则,我们的系统将好比一搜巨轮,风和日丽下,它扬帆远航,暴风雨来临,将立刻土崩瓦解。
3.2 传统的Redis与数据库一致性方案
所谓传统的一致性方案,其实也就是面试时常常被面试官问到的那几个点:
- Redis中的数据是怎么跟数据库的数据保持一致性的?
- 是先保存数据库在同步Redis,还是先保存Redis再同步数据库?
- 同步的时候Redis挂掉怎么办?
- 如果在同步Redis数据的时候存在并发甚至高并发操作,如何保证数据一致性?
- ......
这些其实都有解决方案,但都不可能100%完美。
我们使用Redis做缓存,Redis与数据库是独立的两个中间件,两步操作涉及到至少两次分别与不同进程进行通信,因此,这操作是不具备原子性的。
public void doBusiness(Param param) {
// 保存数据库
xxxMapper.save(param);
// 保存Redis
redisClient.save(param);
}
上述伪代码中,两次操作中,如果有任何一步出现异常,或者执行到一半服务挂掉重启,直接就数据不一致了。但我们又不可能去强行保证这两次操作的原子性,因为首先,这是一次基于两个不同类型数据库的事务的操作,事务的ACID特性中最基本的A(原子性)在这里是根本无法保证的。
所以不得不放弃“保障原子性”的强一致方案,转而奔向“弱一致性”或“最终一致性”方案。
其实也好办,在写数据时,直接不去写Redis了,改为删除Redis中的数据,Redis的同步放到查询去做,当查询发现Redis里没有数据时,直接去查询数据库即可
public void doBusiness(Param param) {
// 保存数据库
xxxMapper.save(param);
// 删除Redis
redisClient.delete(param);
}
但原子性的问题仍然存在,即:当数据库写成功,Redis删除失败或者还没删除的时候服务就挂了重启,数据又不一致了,怎么办呢?
其实只需要将删除Redis的操作放到前面即可,这样一来,即使Redis删除成功,数据库写失败,虽然业务异常,但数据一致性是保证的,下次查询的时候发现Redis里没数据,直接再去数据库里查一次就行了
public void doBusiness(Param param) {
// 删除Redis
redisClient.delete(param);
// 保存数据库
xxxMapper.save(param);
}
流程变成了下面这样:
这样看起来似乎完美了,每个环节都有了保障,即便是读操作最后查询数据库后写Redis这一步如果失败了,下一次发起读操作还是会查询数据库,相当于进行多次重试,嗯,没问题,感觉是这样。但是这只是单线操作下的视角,如果是并发操作甚至高并发操作呢?
这里不讨论写操作的高并发,如果是读操作时存在高并发操作,会有什么问题?
3.2.1 数据不一致的问题
为什么高并发下又会存在数据不一致的问题?不是都解决了吗?
看似解决,实际上,上面的方案是经不起仔细推敲的,画个图一看便知:
图中Thread1为读操作,Thread2为写操作,按照T0到T2的时间线,并发情况下,两者刚好交错运行,则有可能在这次写操作完成后,后续很长一段时间内会存在数据不一致的情况。
有的同学面试题背的很6,就说了,延时双删行不行?
在一定程度上是可以的,所谓延时双删,就是负责写操作的线程,在最后操作完数据库后,延时一段时间后再次删除该key在Redis中的缓存值,目的就是防止在其写操作期间,其它并发读的线程将脏数据同步到Redis中,这样一来,即便是同步了脏数据,延迟一段时间后也会被删除,后续读线程将再次将数据库的数据同步到Redis。
public void doBusiness(Param param) {
// 删除Redis
redisClient.delete(param);
// 保存数据库
xxxMapper.save(param);
// 延迟一段时间后再删一次Redis(假如5s后再次删除)
scheduler.schedule(()->{
// 删除Redis
redisClient.delete(param);
}, 5000)
}
伪代码如上所示,流程图变成了这样:
但这样也无法100%解决问题,试想一下,假如:
- 负责读操作的线程在同步脏数据到Redis时,出现网络故障或者程序卡顿,花了比“延迟双删”更久的时间
- 负责读操作的线程很快将脏数据同步到了Redis,写线程在第二次删除时成功,但紧接着Redis主节点挂掉,备节点还未同步“被删除的数据”(备节点还保留着脏数据),升级为主节点
- ......
发生任意一种情况,仍然会导致数据一致性问题。
有的更有经验的同学又说了,我不搞延迟双删了,写操作的线程只复制操作数据库,别的啥也不干,然后通过canal或者debezium直接将数据库binlog发到MQ,再由另一个服务专门负责同步数据到Redis。
这样一来,操作Redis的动作与数据写操作线程进行了解耦,不必担心数据操作失败等问题,只需要选用合适的MQ中间件,加固“数据同步服务”的业务逻辑即可,比如:幂等性保证、异常补偿机制等。
然后接下来,就只需要解决另外一个问题,就是Redis主备同步及时性的问题即可,因为即便我们的方案做的再完善,一旦Redis主备数据不一致时,主节点挂掉,仍然会存在数据不一致的问题。但这对于基于AP模型的Redis来说,几乎很难做到,即便要做,也要损失很多的性能,代价实在太大。
于是,可以考虑,在数据同步服务中,增加一项异常补偿措施,即:每隔一段时间,清理掉Redis中的某些key,能够让其它查询线程从数据库中读取到最新的数据存入其中,保证它的最终一致性。这是我们的妥协,也是我个人认为的一个比较不错的解决方案。
3.2.2 并发查库的性能问题
这里还有一个问题,是针对读操作线程的,试想一下,在高并发场景中,如果成千上万个线程打进服务,都去查Redis,如果Redis中有数据还好,如果没有数据,那么他们都会去查询数据库,Redis就像是一个大型超市,一次性闯进成千上万个大妈没问题,但是数据库就像一个小便利店,这样下去数据库肯定会一瞬间垮掉。
所以我们不得不在查询线程里也做一些文章,目的是保护我们的服务。
有经验的同学就说了,加分布式锁就行了,既然都用了Redis,顺理成章地,查询的时候如果发现Redis没有数据,直接加个锁,如果有10000个线程,拦住9999个线程,让他们都等着,只放一个线程去查库,等它把数据放到Redis后,再把9999个线程放开,他们就不会去查库了。
public Object getData(String key) {
// 查询Redis
Object data = redisClient.get(key);
if(null == data) {
// 只有一个线程抢占到锁资源,其余线程在此被阻塞
redisClient.lock(key);
// 再次查询,double check lock
data = redisClient.get(key);
if(null == data) {
// 查库
Object data = xxxMapper.select(key);
// 存入Redis
redisClient.save(key, data);
}
}
return data;
}
这的确是一个解决方案,但是要知道,在分布式微服务集群下,针对高并发施加分布式锁,是一项开销很大的操作,大量的线程对Redis中间件的一个key进行争抢,同样消耗资源,大量的线程被阻塞,对服务端来说也是一项不小的开销。
所以,能不能不阻塞呢?甚至,能不能不加锁呢?结合实际业务场景,考虑这样的解决方案:
- 还是加锁,不阻塞,因为我觉得每次一定要把数据查出来才行,所以我使用tryLock,发现锁被抢占后直接返回,不再继续等待,大不了页面上提示一下数据不存在,让用户过会儿重试一下
- 不加锁了,也不阻塞了,我搞一个线程或者服务,实时监控Redis中某些key的变化,一旦变化了,就及时维护进去
- ......
这里就不写代码不画图了,因为图画的有点多,也不难理解,大家都是架构师级别的大佬,肯定一看就懂
3.3 不要本末倒置,你的初衷是什么?
回到本文最开始说的,Redis与数据库的一致性真的重要吗?
我们选择Redis是为了解决什么问题?Redis是因为什么而诞生的?
其实就一个字:快。
上面分析的这些解决方案能解决一致性问题吗?是可以的,至少一定程度上是可以的,但是它真的过于复杂了,我们的业务真的有必要做到这样吗?真的有架构师或者程序员会将这些方案应用到自己的系统里吗?我作为一个架构师,我觉得未必,至少,我没有完全应用过,因为我知道,我们的业务没那么重要,如果真的那么重要,我根本就不会选择Redis。
所以,不要本末倒置,不要强行装B,上面这些方案,我个人觉得,只在面试的时候有点用,其它时候屁用没有。
在系统架构设计,开发的时候,一定要结合业务实际情况、技术选型、人员技术水平、可维护性等多方面因素考虑,而不是一味地钻牛角尖。程序源于生活,有些时候,放弃某些东西,反而会让你的生活更加海阔天空。
就像Redis的应用一样,有的场景下,我的业务就很简单,我也不怕它数据不一致,不一致就不一致了,我大不了页面加一个按钮,发现数据有问题,我点一下,手动刷一下,它就一致了,我的数据维护和查询逻辑就很简单,我就是要像文章最开始那样,直接先写库,再写Redis,又怎么了?这样我写起来舒服,维护起来也简单,所有的保障都在页面那个按钮上,这样降低了设计和开发成本,业务上也没有问题,岂不快哉!
4. 架构师之道
说到底,还是初衷。架构师是干什么的,不是为了体现你懂的比别人多多少,你的方案考虑的多全面,你写的代码有多漂亮。而是你能基于当前业务、技术架构、人员配置下,能用最优的方式解决实际的业务问题。
千万不要过度设计,更不要让你的兄弟们为了你的过度设计做更多没必要的工作,不要增加系统架构的复杂程度,不要增加代码的复杂度。用最简单的方式解决业务问题即可。