具体案例
业务场景
假设在业务场景中,我们有20GB的短视频属性信息(包括短视频ID、短视频基本信息,例如短视频作者、 创建时间等)要持久化保存,并且线上负载以读为主,需要能快速查询到这些短视频信息。
现在,针对这个需求,我们想使⽤Redis来解决,请你来设计⼀个解决⽅案。我来提⼏个问题,你可以思考下。
⾸先,你会⽤Redis的什么数据类型来保存数据?如果我们只⽤单个实例来运⾏的话,你会采⽤什么样的持 久化⽅案来保证数据的可靠性?
另外,如果不使⽤单实例运⾏,我们有两个备选⽅案:⼀个是⽤两台32GB内存的云主机来运⾏主从两个 Redis实例;另⼀个是⽤10台8GB的云主机来运⾏Redis Cluster,每两台云主机分别运⾏⼀个Redis实例主库 和从库,分别保存4GB数据,你会⽤哪种⽅案呢?请聊⼀聊你的想法
解决方案
Redis的Hash类型属于典型的集合类型,可以保存key-value形式的数据。⽽且,当Hash类型中保存较多数据时,它的底层是由哈希表实现的。哈希表的存取复杂度是O(1),所以可以实现快速访问。
在这道题中,短视频属性信息属于典型key-value形式,所以,我们可以使⽤Hash类型保存短视频信息。
具体来说,就是将⼀个短视频ID作为Hash集合的key,将短视频的其他属性信息作为Hash集合内部的键值对,例如“作 者”:“实际姓名”,“创建时间”:“实际时间”。
这样既满⾜了保存数据的需求,也可以利⽤Hash快速查询的特点,快速查到相应的信息。Redis的AOF⽇志会记录客⼾端发送给实例的每⼀次写操作命令,在Redis实例恢复时,可以通过重新运⾏ AOF⽂件中的命令,实现恢复数据的⽬的。
在这道题的业务场景中,负载以读为主,因此,写命令不会太 多,AOF⽇志⽂件的体量不会太⼤,即使实例故障了,也可以快速完成恢复。所以,当使⽤单实例运⾏时, 我们可以使⽤AOF⽇志来做持久化⽅案。 关于使⽤多实例的运⾏⽅案:两种⽅案各有优势,我们来分析⼀下。
⽅案⼀ :单实例运行
优势:可以节省云主机数量和成本。虽然主从节点进⾏第⼀次全量同步时,RDB⽂件较⼤,耗时会⻓些,但是因为写请求少,所以复制缓冲区的压⼒不⼤。
不⾜:如果⽹络环境不好,需要频繁地进⾏全量同步的话,这种⽅案的优势就⼩了,每次全量同步时的RDB ⽣成和传输压⼒都很⼤。
⽅案⼆: 多实例运行
优势:每个实例只⽤保存4GB数据,和从库同步时的压⼒较⼩。⽽且,这种⽅案的可扩展性更好,如果有新增数据,可以更好地应对。
不⾜:需要较多的云主机,运维和资源成本较⾼。
缓存异常
解决缓存和数据库的数据不⼀致问题
存在问题:在更新数据库和删除缓存值的过程中,⽆论这两个操作的执⾏顺序谁先谁后,只要有⼀个操作失败了,就会导致客⼾端读取到旧值。
| 不同情况 | 潜在问题 |
|---|---|
| 先删除缓存值,后更新数据库值 | 数据库更新失败,导致请求再次访问缓存时,发现缓存缺失,再读取数据库时,从数据库中读到旧值! |
| 先更新数据库值,后删除缓存值 | 缓存删除失败,导致请求再次访问缓存时,发现缓存命中,并从缓存中读取到旧值。 |
方案一:重试机制
具体来说,可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使⽤Kafka消息队 列)。当应⽤没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进⾏删除或更新。 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证 数据库和缓存的数据⼀致了。否则的话,我们还需要再次进⾏重试。如果重试超过的⼀定次数,还是没有成功,我们就需要向业务层发送报错信息了。
下图显⽰了先更新数据库,再删除缓存值时,如果缓存删除失败,再次重试后删除成功的情况 
实际上,即使这两个操作第⼀ 次执⾏时都没有失败,当有⼤量并发请求时,应⽤还是有可能读到不⼀致的数据。 同样,我们按照不同的删除和更新顺序,分成两种情况来看。在这两种情况下,我们的解决⽅法也有所不同。
情况⼀:先删除缓存,再更新数据库。
假设线程A删除缓存值后,还没有来得及更新数据库(⽐如说有⽹络延迟),线程B就开始读取数据了,那么这个时候,线程B会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
1. 线程B读取到了旧值;
2. 线程B是在缓存缺失的情况下读取的数据库,所以,它还会把旧值写⼊缓存,这可能会导致其他线程从缓存中读到旧值。
等到线程B从数据库读取完数据、更新了缓存后,线程A才开始更新数据库,此时,缓存中的数据是旧值, ⽽数据库中的是最新值,两者就不⼀致了。结合下图理解: 
解决方案:延迟双删
在线程A更新完数据库值以后,我们可以让它先sleep⼀⼩段时间,再进⾏⼀次缓存删除操作。
之所以要加上sleep的这段时间,就是为了让线程B能够先从数据库读取数据,再把缺失的数据写⼊缓存, 然后,线程A再进⾏删除。所以,线程A sleep的时间,就需要⼤于线程B读取数据再写⼊缓存的时间。这个 时间怎么确定呢?建议你在业务程序运⾏的时候,统计下线程读数据和写缓存的操作时间,以此为基础来进⾏估算。
这样⼀来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个⽅案会在第⼀ 次删除缓存值后,延迟⼀段时间再次进⾏删除,所以我们也把它叫做“延迟双删”。
redis.delKey(X)db.update(X)Thread.sleep(N)redis.delKey(X)
情况⼆:先更新数据库值,再删除缓存值。
如果线程A删除了数据库中的值,但还没来得及删除缓存值,线程B就开始读取数据了,那么此时,线程B查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。⽽且,线程A⼀般也会很快删除缓存值,这样⼀来,其他线程再次读取时,就会发⽣缓存缺失,进⽽从数据库中读取最新值。所以,这种情况对业务的影响较⼩。 结合下图理解:
总结
- 删除缓存值或更新数据库失败⽽导致数据不⼀致,你可以使⽤重试机制确保删除或更新操作成功。
- 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对⽅案是延迟双删。
- 对于读写缓存来说,如果我们采⽤同步写回策略,那么可以保证缓存和数据库中的数据⼀致。
- 只读缓存的情况⽐较复杂,我总结了⼀张表,以便于你更加清晰地了解数据不⼀致的问题原因、现象和应对⽅案。

最后,我还想再多说⼏句。在⼤多数业务场景下,我们会把Redis作为只读缓存使⽤。针对只读缓存来说, 我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存。
我的建议是,优先使⽤先更新数据库再删除缓存的⽅法,原因主要有两个:
1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失⽽访问数据库,给数据库带来压⼒; 删除缓存值或更新数据库失败⽽导致数据不⼀致,你可以使⽤重试机制确保删除或更新操作成功。 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对⽅案是延迟双删。
2. 如果业务应⽤中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。 不过,当使⽤先更新数据库再删除缓存时,也有个地⽅需要注意,如果业务层要求必须读取⼀致的数据,那么,我们就需要在更新数据库时,先在Redis缓存客⼾端暂存并发读请求,等数据库更新完、缓存值删除 后,再读取数据,从⽽保证数据⼀致性。
