为了提升系统吞吐量,咱们常常在业务架构中引入缓存层。mysql
缓存一般使用 Redis / Memcached 等高性能内存缓存来实现, 本文以 Redis 为例讨论缓存应用中面临的一些问题。git
当执行写操做后,须要保证从缓存读取到的数据与数据库中持久化的数据是一致的,所以须要对缓存进行更新。github
由于涉及到数据库和缓存两步操做,难以保证更新的原子性。sql
在设计更新策略时,咱们须要考虑多个方面的问题:shell
通常来讲操做失败出现的几率较小,且一般会在日志中留下较为详细的信息比较容易修复数据。数据库
而并发异常形成的数据不一致则很是难以检测,且多在流量高峰时发生可能形成较多数据不一致,须要更加剧视。缓存
并发异常一般因为后开始的线程却先完成操做致使,咱们能够把这种现象称为“抢跑”。安全
更新缓存有两种方式:服务器
更新缓存和更新数据库有两种顺序:数据结构
两两组合共有四种更新策略,如今咱们逐一进行分析。
四种策略都存在问题,通常来讲先更新数据库再删除缓存是四种策略中一致性最好的策略,但仍需具体场景具体分析选择。
若数据库更新成功,删除缓存操做失败,则此后读到的都是缓存中过时的数据,形成不一致问题。
缓存操做失败在会在日志中留下错误信息,在系统恢复正常后比较容易检测和修复数据。
若线程A试图读取某个数据而缓存未命中,在线程A读取数据库后写入缓存前,线程B完成了更新操做。此时,缓存中还是旧数据,致使与数据库不一致。
对于 list、hash 或计数器等缓存来讲,更新缓存实现难度较大(且难以保证一致性)而重建缓存的难度较低,此时采用后删除缓存的策略较好。
由于缓存删除后读操做会直接访问数据库,可能对数据库形成很大压力。这一问题在热点数据上很是明显。好比热门文章的阅读数或者某个大V的粉丝数,它们的读写都很是频繁。
当缓存被清除后,线程A会读取数据库试图重建缓存,在重建完成前线程B也试图读取该数据。此时线程B缓存未命中而去读取数据库,从而给数据库带来没必要要的压力。
对于热点数据,若即时性和一致性要求较低时建议采用延迟更新的策略,若一致性要求略高则采用加(分布式)锁的方式。
同删除缓存策略同样,若数据库更新成功缓存更新失败则会形成数据不一致问题。
缓存更新失败的问题较为少见且比较容易处理,但后更新缓存的模式存在难以解决的并发问题。
若线程A试图写入数据a, 随后线程B试图将该数据更新为b。若线程B后完成了数据库的写入, 但却抢在线程A以前完成了缓存更新。此时数据库中值为b(线程B后提交事务), 而缓存中值为a(线程A后写入缓存), 为不一致状态。
若数据库写入延时较大,此种方案可能出现风险。 考虑这样的情景:
若线程A试图更新数据, 线程B在线程A删除缓存后、提交数据库事务前尝试读取该数据。则由于数据库未更新,线程B从数据库中读出旧数据写入缓存中, 致使缓存中一直是旧数据。
若缓存更新成功数据库更新失败, 则此后读到的都是未持久化的数据。由于缓存中的数据是易失的,这种状态很是危险。
由于数据库由于键约束致使写入失败的可能性较高,因此这种策略风险较大。
双写更新的逻辑复杂,一致性问题较多。如今咱们能够采用订阅数据库更新的方式来更新缓存。
阿里巴巴开源了mysql数据库binlog的增量订阅和消费组件 - canal。
咱们能够采用API服务器只写入数据库,而另外一个线程订阅数据库 binlog 增量进行缓存更新,则能够轻松地保证缓存更新顺序与数据事务提交顺序一致。
为了不无效数据占用缓存,咱们一般不会在缓存中存储空对象,但这种策略会形成缓存穿透问题。
若要查询的数据不存在,那么固然不可能从缓存中查到这个数据,按照缓存未命中即访问数据库的逻辑,全部对不存在数据的查询都会到达数据库,这种现象称做缓存穿透。
为了减小无心义的数据库访问,咱们能够缓存表示数据不存在的占位符。
一般来讲访问已被删除的对象形成缓存穿透的几率较高, 所以删除数据时应在缓存中放置表示已被删除占位符。
另外一种常见的缓存穿透场景是访问集合式缓存,好比访问没有评论的文章的评论页,或者未发表过文章的用户主页。这种场景可使用占位符避免缓存穿透, 也能够先检查缓存中的评论计数器或文章计数器防止缓存穿透。
Redis 提供了 List、Hash、Set 和 SortedSet 等数据结构,咱们能够将其称为集合式缓存。
集合式缓存一般更新的逻辑较为复杂(或者难以保证一致性)而重建逻辑较为简单,同时重建缓存时也可能带来更大的数据库压力。
计数器式缓存一样具备更新逻辑复杂、重建简单但重建缓存时数据库压力大的特色,所以做者也将其纳入集合式缓存。计数器的复杂度在计数的对象状态机复杂时尤其明显,如计数某个用户公开文章和所有文章数。
以文章的评论列表为例,当 Redis 缓存中评论列表为空时,可能有两种缘由:
除了上一节提到的防止缓存击穿外,更新缓存的逻辑也须要分别处理两种状况。若缓存未命中而直接插入新评论,则可能致使评论列表中只有这一条新评论而没有更早评论的状况。
做者建议集合式缓存中元素应为不可变的对象或对象ID。仍以评论列表为例,若在 List 或 SortedSet 中直接存储序列化后的评论对象,则只有知道对象的所有字段才能定位该评论。
在修改评论后,咱们难以得到原评论的内容定位或修改的难度较高。若某条评论存在于多个集合式缓存中,则须要多处修改。
此外,完整的评论对象字节数远大于ID, 在须要多处存储时使用ID能够节省大量内存。
在上文中提到过,当线程A缓存未命中时会尝试从数据库读取数据以重建缓存。若在线程A重建缓存完成前,线程B尝试读取该数据一样会发生缓存未命中,致使重复读取数据库,形成数据库资源浪费。
若重建过程涉及较多操做 Redis 没法保证其原子性时,咱们一样也须要使用加锁的方式保证重建操做的原子性避免并发异常。
重建问题与单例模式中多线程同时调用 getInstance() 方法致使对象被重复建立的问题相似,咱们一样能够采用 Check-Lock-Check 模式解决。
即当线程缓存未命中后阻塞试图加(分布式)锁,成功得到锁后再次检查缓存是否已被建立。若缓存仍未被重建则进入读数据库重建流程。
一样的,使用 Watch 命令监视要重建的 KEY 并使用 Multi 命令开始事务重建该缓存。Redis 事务也能够达到避免重复创建的目的,可是没法避免重复读取数据库,且在集群条件下 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)
若是说上文经过加锁的方式避免并发问题能够认为是悲观锁的思路,对于写入竞争不激烈的场景可使用 RENAMENX 命令来实现乐观锁。
当须要重建缓存时,咱们须要建立一个临时的键并在其上完成重建操做, 由于临时键只有一个线程访问,无需担忧原子性和各类并发问题。
重建完成后使用 RENAMENX 或 RENAME 命令原子性地将其重命名为正式的键提供给全部线程访问。
咱们能够将脏数据放入 SET 或 HASH 中以进行离线更新。如上文提到的热门文章的访问数,咱们可使用 HINCRBY 命令将文章ID及其访问数增量放入 HASH 表中, 使用 HSCAN 命令单线程的遍历,将增量持久化到数据库或线上缓存。
须要注意的问题是: 在 HSCAN 命令扫描 HASH 表的过程当中, 该 HASH 表内容发生变化可能致使并发问题。特别是当 HSCAN 命令执行过程当中新增 field 可能致使重复访问。
所以咱们须要将线上脏数据 Hash 重命名到临时键中,在不会发生改变的临时键中单线程的进行遍历。
HSCAN 和 SSCAN 命令遍历的过程较长,遍历线程可能会被中断。若担忧数据丢失,则能够按必定规则生成临时键, 这样能够方便检查有哪些临时键还没有被消费完毕。
在集群环境中,可能仅支持相同 Slot 下的 RENAME 和 RENAMENX 命令。所以, 咱们可使用 HashKey 机制保证临时键和原键在同一个Slot中。
若原键为 "original" 咱们则能够生成临时键为 "{original}-1", 花括号表示仅由花括号内部的子串进行哈希来决定 Slot, "{original}-1" 必定会与 "original" 处于相同 Slot 中。
使用临时键的目的是为了单线程的进行操做避免并发问题,所以务必检查临时键是否已被其它线程占用。
临时键有两种生成策略:
为了不临时键冲突,咱们能够在使用前先尝试设置一个占位符。如,在使用 "{original}-1" 前先执行 "SETNX {original}-1-lock" 若设置成功则能够安全地使用 "{original}-1"。这种作法其实是加了一个简单的分布式锁。
在检测临时键存在后就使用是不安全的,在线程A检测存在后实际使用前,其它线程检测不到临时键存在可能误认为该键可用。
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 缓存使用经验,所以一些基本常识放在最后以尽可能避免浪费读者的时间。