为了提高系统吞吐量,我们经常在业务架构中引入缓存层。
缓存通常使用 Redis / Memcached 等高性能内存缓存来实现, 本文以 Redis 为例讨论缓存应用中面临的一些问题。
缓存更新一致性
当执行写操作后,需要保证从缓存读取到的数据与数据库中持久化的数据是一致的,因此需要对缓存进行更新。
因为涉及到数据库和缓存两步操作,难以保证更新的原子性。
在设计更新策略时,我们需要考虑多个方面的问题:
- 对系统吞吐量的影响:比如更新缓存会比删除缓存减少数据库查询请求
- 并发安全性:并发读写时某些异常操作顺序可能造成数据不一致(缓存中长期存储旧数据)
- 更新失败的影响:若执行过程中某个操作失败,如何对业务影响降到最小
- 检测和修复故障的难度: 如先淘汰缓存再更新数据库的方式并发读写导致的故障难以检测和修复
一般来说操作失败出现的概率较小,且通常会在日志中留下较为详细的信息比较容易修复数据。
而并发异常造成的数据不一致则非常难以检测,且多在流量高峰时发生可能造成较多数据不一致,需要更加重视。
并发异常通常由于后开始的线程却先完成操作导致,我们可以把这种现象称为“抢跑”。
更新缓存有两种方式:
- 删除失效缓存: 读取时会因为未命中缓存而从数据库中读取新的数据并更新到缓存中
- 更新缓存: 直接将新的数据写入缓存覆盖过期数据
更新缓存和更新数据库有两种顺序:
- 先更新数据库后更新缓存
- 先更新缓存后更新数据库
两两组合共有四种更新策略,现在我们逐一进行分析。
四种策略都存在问题,一般来说先更新数据库再删除缓存是四种策略中一致性最好的策略,但仍需具体场景具体分析选择。
先更新数据库,再删除缓存
若数据库更新成功,删除缓存操作失败,则此后读到的都是缓存中过期的数据,造成不一致问题。
缓存操作失败在会在日志中留下错误信息,在系统恢复正常后比较容易检测和修复数据。
若线程A试图读取某个数据而缓存未命中,在线程A读取数据库后写入缓存前,线程B完成了更新操作。此时,缓存中仍是旧数据,导致与数据库不一致。
对于 list、hash 或计数器等缓存来说,更新缓存实现难度较大(且难以保证一致性)而重建缓存的难度较低,此时采用后删除缓存的策略较好。
因为缓存删除后读操作会直接访问数据库,可能对数据库造成很大压力。这一问题在热点数据上非常明显。比如热门文章的阅读数或者某个大V的粉丝数,它们的读写都非常频繁。
当缓存被清除后,线程A会读取数据库试图重建缓存,在重建完成前线程B也试图读取该数据。此时线程B缓存未命中而去读取数据库,从而给数据库带来不必要的压力。
对于热点数据,若即时性和一致性要求较低时建议采用延迟更新的策略,若一致性要求略高则采用加(分布式)锁的方式。
先更新数据库,再更新缓存
同删除缓存策略一样,若数据库更新成功缓存更新失败则会造成数据不一致问题。
缓存更新失败的问题较为少见且比较容易处理,但后更新缓存的模式存在难以解决的并发问题。
若线程A试图写入数据a, 随后线程B试图将该数据更新为b。若线程B后完成了数据库的写入, 但却抢在线程A之前完成了缓存更新。此时数据库中值为b(线程B后提交事务), 而缓存中值为a(线程A后写入缓存), 为不一致状态。
先删除缓存,再更新数据库
若数据库写入延时较大,此种方案可能出现风险。 考虑这样的情景:
若线程A试图更新数据, 线程B在线程A删除缓存后、提交数据库事务前尝试读取该数据。则因为数据库未更新,线程从数据库中读出旧数据写入缓存中, 导致缓存中一直是旧数据。
先更新缓存,再更新数据库
若缓存更新成功数据库更新失败, 则此后读到的都是未持久化的数据。因为缓存中的数据是易失的,这种状态非常危险。
因为数据库因为键约束导致写入失败的可能性较高,所以这种策略风险较大。
异步更新
双写更新的逻辑复杂,一致性问题较多。现在我们可以采用订阅数据库更新的方式来更新缓存。
阿里巴巴开源了mysql数据库binlog的增量订阅和消费组件 - [canal]((https://github.com/alibaba/canal)。
我们可以采用API服务器只写入数据库,而另一个线程订阅数据库 binlog 增量进行缓存更新,则可以轻松地保证缓存更新顺序与数据事务提交顺序一致。
缓存穿透
为了避免无效数据占用缓存,我们通常不会在缓存中存储空对象,但这种策略会造成缓存穿透问题。
若要查询的数据不存在,那么当然不可能从缓存中查到这个数据,按照缓存未命中即访问数据库的逻辑,所有对不存在数据的查询都会到达数据库,这种现象称作缓存穿透。
为了减少无意义的数据库访问,我们可以缓存表示数据不存在的占位符。
通常来说访问已被删除的对象造成缓存穿透的概率较高, 因此删除数据时应在缓存中放置表示已被删除占位符。
另一种常见的缓存穿透场景是访问集合式缓存,比如访问没有评论的文章的评论页,或者未发表过文章的用户主页。这种场景可以使用占位符避免缓存穿透, 也可以先检查缓存中的评论计数器或文章计数器防止缓存穿透。
集合式缓存
Redis 提供了 List、Hash、Set 和 SortedSet 等数据结构,我们可以将其称为集合式缓存。
集合式缓存通常更新的逻辑较为复杂(或者难以保证一致性)而重建逻辑较为简单,同时重建缓存时也可能带来更大的数据库压力。
计数器式缓存同样具有更新逻辑复杂但重建简单同时重建缓存时数据库压力大的特点,因此作者也将其归入集合式缓存。计数器的复杂度在计数的对象状态机复杂时尤为明显,如计数某个用户公开文章和全部文章数。
以文章的评论列表为例,当 Redis 缓存中评论列表为空时,可能有两种原因:
- 缓存未命中
- 评论列表确实为空
除了上一节提到的防止缓存击穿外,更新缓存的逻辑也需要分别处理两种情况。若缓存未命中而直接插入新评论,则可能导致评论列表中只有这一条新评论而没有更早评论的情况。
作者建议集合式缓存中元素应为不可变的对象或对象ID。仍以评论列表为例,若在 List 或 SortedSet 中直接存储序列化后的评论对象,则只有知道对象的全部字段才能定位该评论。
在修改评论后,我们难以获得原评论的内容定位或修改的难度较高。若某条评论存在于多个集合式缓存中,则需要多处修改。
此外,完整的评论对象字节数远大于ID, 在需要多处存储时使用ID可以节省大量内存。
重建缓存
在上文中提到过,当线程A缓存未命中时会尝试从数据库读取数据以重建缓存。若在线程A重建缓存完成前,线程B尝试读取该数据同样会发生缓存未命中,导致重复读取数据库造成数据库资源浪费。
若重建过程涉及较多操作 Redis 无法保证其原子性时,我们同样也需要使用加锁的方式保证重建操作的原子性避免并发异常。
Check-Lock-Check
重建问题与单例模式中多线程同时调用 getInstance() 方法导致对象被重复创建的问题类似,我们同样可以采用 Check-Lock-Check 模式解决。
即当线程缓存未命中后进行阻塞试图加(分布式)锁,成功获得锁后再次检查缓存是否已被创建。若缓存仍未被重建则进入读数据库重建流程。
事务
同样的,使用 Watch 命令监视要重建的 KEY, 并使用 Multi 命令开始事务重建该缓存也可以达到避免重复建立的目的。但是无法避免重复读取数据库, 且在集群条件下 Redis 事务可能受到较多限制。
使用 Redis 事务进行重建的示例:
127.0.0.1:6379> WATCH a
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set a 1
QUEUED
127.0.0.1:6379> EXEC
1) OK
开启两个客户端模拟竞争的情况:
client-1> WATCH b
OK
client-1> MULTI
OK
client-1> set b 2
QUEUED
client-2> set b 1
OK
client-1> EXEC
(nil)
Rename
乐观锁
如果说上文通过加锁的方式避免并发问题可以认为是悲观锁的思路,对于写入竞争不激烈的场景可以使用 RENAMENX 命令来实现乐观锁。
当需要重建缓存时,我们需要创建一个临时的key并在其上完成重建操作, 因为临时键只有一个线程访问,无需担心原子性和各种并发问题。
重建完成后使用 RENAMENX 或 RENAME 命令原子性地将其重命名为正式的键提供给所有线程访问。
离线数据处理
我们可以将脏数据放入 SET 或 HASH 中以进行离线更新。如上文提到的热门文章的访问数,我们可以使用 HINCRBY 命令将文章ID及其访问数增量放入 HASH 表中, 使用 HSCAN 命令单线程的遍历,将增量持久化到数据库或线上缓存。
需要注意的问题是在 HSCAN 命令扫描 HASH 表的过程中,该 HASH 表内容发生变化可能导致并发问题。特别是当 HSCAN 命令执行过程中新增 field 可能导致重复访问。
因此我们需要将线上脏数据 Hash 重命名到临时键中,在不会发生改变的临时键中单线程的进行遍历。
HSCAN 和 SSCAN 命令遍历的过程较长,遍历线程可能会被中断。若担心数据丢失,则可以按一定规则而非随机生成临时键, 这样可以方便检查有哪些临时 Key 尚未被消费完毕。
临时键的生成
在集群环境中,可能仅支持相同 Slot 下的 RENAME 和 RENAMENX 命令。因此, 我们可以使用 HashKey 机制保证临时键和原键在同一个Slot中。
若原键为 "original" 我们则可以生成临时键为 "{original}-1", 花括号表示仅由花括号内部的子串进行哈希来决定 Slot, "{original}-1" 一定会与 "original" 处于相同 Slot 中。
使用临时键的目的是为了单线程的进行操作避免并发问题,因此务必检查临时key是否已被其它线程占用。
临时键有两种生成策略:
- 原键加随机值: 如 "{original}-kGi3X1", 这种方法的优点是随机键冲突的概率较小但是难以扫描库中有哪些随机键
- 原键加计数器: 如 "{original}-1"、"{original}-2", 这种方法的有点是容易扫描库中有哪些随机键可以用于离线数据处理,但是冲突的概率较高
SortedSet
SortedSet 作为 Redis 中唯一的可排序和可范围查找的数据结构可以进行一些比较灵活的应用。
延时队列
在对一致性没有较高要求的场景可以使用 SortedSet 充当延时队列,将消息的内容作为 member, 预定执行时间的UNIX时间戳作为 score。
调用 ZRANGEBYSCORE 方*询预定执行时间早于当前时间的消息并发送给 Msg Consumer 处理。
127.0.0.1:6379> ZADD DelayQueue 155472822 msg
(integer) 1
127.0.0.1:6379> ZRANGEBYSCORE DelayQueue 0 1554728933 WITHSCORES
1) "msg"
2) "1554728822"
必要时可以选用富类型 Java 客户端 Redisson 提供的 RDelayedQueue, 它实现了更完善的延时队列。
由于 Redis 持久化机制等原因,任何基于 Redis 的队列都不可能提供高一致性的服务。
请勿在高一致性要求的业务场景下使用 Redis 做消息队列。
滑动窗口
在如热搜或限流之类的业务场景中我们需要快速查询过去一小时内被搜索最多的关键词。
与延时队列类似,将关键词作为 SortedSet 的 member, 发生的UNIX时间戳作为 score。
使用 ZRANGEBYSCORE 命令查询某个时间段内发生的事件, ZREMRANGEBYSCORE 命令移除过旧的数据。
一些常识
阅读本文的读者应有一定的 Redis 缓存使用经验,因此一些基本常识放在最后以尽量避免浪费读者的时间。
- IO操作的耗时通常远高于CPU计算,尽量使用 MGET 等批量命令或 Pipeline 机制来减少 IO 时间,切勿循环进行 Redis 读写等IO操作
- Redis 使用IO复用模型内核单线程模式,保证命令执行原子性和串行性。(至写作时 Redis 4.0 版本仍是如此,此后很可能引入多线程内核)
- Redis 的RDB和AOF都采用异步持久化的模式,无法保证Redis崩溃后完全不丢失数据。 因此请勿将Redis用于一致性要求较高的业务场景。