1. 名词解释
缓存预热:
在系统上线前后或流量激增前(如大促活动),主动地将提前预测出的热点数据加载到缓存中,而不是等待用户请求来触发缓存写入。避免初期洪峰压垮数据库。
缓存穿透:
用户查询一个根本不存在于数据库中和缓存中的数据。由于缓存中不存在,每次请求都会穿透到数据库去查询。如果有人恶意攻击,用大量不存在的 Key 进行请求,就会给数据库造成巨大压力。
解决:
-
做好接口校验,对于明显不合法的ID(如负数、非数字字符)直接拦截返回。或使用布隆过滤器。
-
缓存空对象。即使从数据库没查到,也将空结果进行缓存。这样,短期内再请求同一个不存在的 key,会直接返回空,而不会访问数据库。
缓存雪崩:
在某一时刻,大量的缓存数据同时过期。此时请求全部涌向数据库,导致数据库瞬时压力过大而崩溃。这有可能是因为一部分 key 被设置了相同的过期时间,也有可能是 Redis 服务宕机。
解决:
-
设置随机过期时间。
-
使用 Redis 哨兵或集群,避免单点问题,构建高可用 Redis 集群。
缓存击穿:
某个热点 Key 在缓存中过期的瞬间,同时有海量请求对这个 Key 进行访问。这个 Key 的失效,像一颗子弹在缓存上击穿了一个洞,所有请求都从这个洞穿透到数据库,导致数据库瞬间压力激增。
解决:
-
对于极少数超级热点数据,可以设置为永不过期。
-
使用分布式锁。当请求发现缓存失效,不能直接查数据库,必须先去获取一个分布式锁,获取到锁的进程去查库,重建缓存。
2. 缓存淘汰策略
-
先进先出,淘汰缓存中存在时间最久的数据。
-
淘汰最长时间没用过的。
-
淘汰最不常用的。
-
随机淘汰一个。
Redis 内置的缓存淘汰策略基本沿用上面的场景策略,只不过针对带有过期时间的 key 做了区分,我们可以选择只淘汰有过期时间的 key。默认策略是不自动淘汰数据,当内存不足时,新写入操作会报错。
3. 旁路缓存模式
在旁路缓存模式中,应用程序的读写逻辑如下:
读:先读缓存,缓存命中则返回数据;缓存未命中则从数据库读取,写入缓存,然后返回数据。
写:更新数据库然后将缓存中对应的数据项删除,注意这里是删除而不是更新。
3.1 为什么是删除缓存
- 避免并发写问题:
在更新缓存策略下,如果两个写操作并发执行,由于网络延迟等原因,它们更新缓存的操作顺序可能与更新数据库的顺序不一致,从而导致缓存中是旧数据。
比如 A 更新数据库,然后 B 更新数据库,然后 B 更新缓存,最后 A 更新缓存。此时,数据库中的数据是 A 更新后 B 又更新后的数据,而缓存中的数据只是 A 更新后的数据,因为 B 的更新被覆盖了。
如果是删除缓存,则最终一定能保证缓存是被清空的。下一个读请求会因为缓存未命中而从数据库读取最新的值并重新填充缓存,这就保证了最终一致性。
- 降低写操作负载:
如果采用更新缓存策略,则每次写操作都会更新缓存。更新缓存需要将完整的数据对象序列化并写入缓存,而删除缓存只需要一个简单的 DEL 命令。
- 避免写入冷门数据:
需要更新的数据并不一定是热点数据,若该数据被更新后很长一段时间都不会再读取,那么该次缓存更新就是浪费的。而删除缓存是惰性的,只有在真正需要时(即下次读取时)才将数据加载到缓存中,这样缓存中保留的都是热点数据。
但更新缓存策略在特定场景下也有它的优势。如果在某场景下,已知该数据将被持续高并发地读取,希望尽可能避免任何一次请求穿透到数据库。使用更新缓存可以确保写之后缓存总是可用的。但此时需要通过分布式锁等手段来解决并发写问题,这会增加系统复杂度。
3.2 另外的一致性问题
如果 A 查询缓存未命中,开始查数据库。随后 B 更新数据库中的数据,并删缓存。A 查到的是 B 更新前的数据,将旧数据写入缓存,造成数据不一致。其实这个问题严格来说也不能叫读写并发问题,因为它涉及到写缓存了。这个问题概括起来就是说,一个线程想用从 DB 查出来的数据写缓存,在这个时间窗口内另一个线程已经把这个数据在 DB 改了。
这里的关键问题其实是,A 线程感知不到 B 线程是否修改过缓存,因为删除空值相当于没删。
这个问题最直接的解决方法就是,从我查 DB 到写缓存的这个时间,排斥一切写操作。也就是使用分布式锁,这里可以使用读写锁,以 key 为粒度加锁。
如果不想加锁,还有下面两种方案:
-
延迟双删:写操作中,第一次删除缓存后,等一小段时间,再删除一次。
-
为缓存设置较短的过期时间:即使发生上述情况,旧数据也会在一定时间后自动失效,实现最终一致。过期时间设置较短,确实数据一致性会变高,但缓存命中率也会变低,因此还是要根据实际业务做出权衡,设置合理的过期时间。
4. Redis 实现简易分布式锁
只要涉及到多个线程或进程访问同一公共资源,都会涉及到使用锁做同步控制。在分布式系统下,需要使用分布式锁。分布式锁就是使用一个或一组服务器专门用于记录加锁状态。思路就是用一个键值对来标识锁的状态。
例如,对于一个商品下单的接口,通常是先校验库存,若库存为 0,返回下单失败;若库存大于 0,将库存减去下单数量,再返回下单成功。很明显这些操作必须打包成原子操作。
此时,收到请求的服务器必须先向锁服务器申请锁,即在 Redis 上设置一个键值对:
SET key value NX PX(毫秒)|EX(秒) timeout
key -> 资源 ID
value -> 服务器 ID,用于保证只有锁的持有者才能释放它。在代码中,删除 key 之前,加上对该 ID 的校验,只有它的确是当初加锁的服务器才能删除。
NX -> 当且仅当 key 不存在时才能设置成功,这是实现分布式锁的核心字段
PX|EX -> 为了防止客户端崩溃后锁永远无法释放,必须为锁设置一个过期时间。
只有设置该键值对成功,才能访问共享数据,访问之后,在通过服务器 ID 校验后,使用 DEL 指令删除该 key。
"检查锁所有权" 和 "删除锁" 之间的时间窗口内,锁的所有权有极小概率发生变化,如下图。为了确保万无一失,还是要将这两步打包成原子操作(用 lua 脚本执行)。此时,SET 只是一行命令天然保证原子性,删除时也能通过 lua 脚本保证原子性。

实际上,凭感觉设置过期时间是不靠谱的,如果设置太短,锁有可能提前失效,如果设置太长,其他服务器不能及时获取到锁。我们必须能比较精确地控制这个过期时间,也就是监视服务器的任务执行情况。
因此我们可以先为锁设置一个较短的超时时间,随后启动一个后台线程,称为看门狗(Watch Dog),定期去检测服务器的任务执行情况。若检测到其未完成任务,则重置超时时间(续约)。
对于更极端的情况,可能需要集群部署分布式锁。如果当前 Redis 锁服务器是主从结构,从节点对主节点可能存在一定的数据延迟,如果锁信息还未同步给从节点,主节点就宕机了,锁信息就会丢失。因此如果想保证锁的更高可用性,还是要使用多个主节点。加锁时,向每个主节点都要 SET key,判定锁是否加成功依然要遵守半数以上原则,这样就不会因为个别主节点的加锁失败而导致整体锁服务不可用。释放锁的时候,也要把所有主节点都进行解锁,判定解锁是否成功也要遵守半数以上原则。
上面的模式称为 Redlock 算法,其思想就是避免单点问题,不能只写一个主节点,要写多个,加锁成功的结论一定是 "少数服从多数" 的,而不是只听一个节点的。
上面只是基于 Redis 实现分布式锁的一些最基本的原理,实际上我们也不会在业务中自己实现分布式锁,使用现成的工具如 Java 的 Redission 是更好的选择。