为了提高系统吞吐量,我们经常在业务架构中引入缓存层。
缓存通常使用 Redis / Memcached 等高性能内存缓存来实现, 本文以 Redis 为例讨论缓存应用中面临的一些问题。
- 缓存穿透
- 集合式缓存
- 热点数据缓存
- 使用锁保证高一致性
- 乐观策略
- Rename
- 临时键的生成
- SortedSet
- 延时队列
- 滑动窗口
- 一些常识
缓存穿透
为了避免无效数据占用缓存,我们通常不会在缓存中存储空对象,但这种策略会造成缓存穿透问题。
若要查询的数据不存在,那么当然不可能从缓存中查到这个数据,按照缓存失效即访问数据库的逻辑,所有对不存在数据的查询都会到达数据库,这种现象称作缓存穿透。
为了减少无意义的数据库访问,我们可以缓存表示数据不存在的占位符。
与访问一个从未存在过的数据相比访问已删除数据的概率较高, 因此删除数据时应在缓存中放置表示已被删除的占位符。
集合式缓存
Redis 提供了 List、Hash、Set 和 SortedSet 等数据结构,我们将其称为集合式缓存。
集合式缓存通常更新的逻辑较为复杂(或者难以保证一致性)而重建逻辑较为简单,但重建缓存时也可能带来很大的数据库压力。
计数器式缓存同样具有更新逻辑复杂、重建简单但重建缓存时数据库压力大的特点,因此作者也将其归入集合式缓存。计数器的复杂度在对象状态机复杂时尤为明显,如计数某个用户公开文章数和全部文章数。
以文章的评论列表为例,当 Redis 缓存中评论列表为空时,可能有两种原因:
- 缓存失效
- 确实没有评论
若当发布评论后试图更新缓存时发现缓存中没有评论列表,我们需要考虑是缓存失效还是原来确实没有评论。不要直接使用 LPUSH 或 ZADD 指令插入评论。
集合式缓存中元素应为不可变的对象或对象ID。仍以评论列表为例,若在 List 或 SortedSet 中直接存储序列化后的评论对象,则只有知道对象的全部字段才能定位该评论。在修改评论后,我们难以获得原评论的内容定位或修改的难度较高。若某条评论存在于多个集合式缓存中,则需要多处修改。
此外,完整的评论对象字节数远大于ID, 在需要多处存储时使用ID可以节省大量内存。
热点数据缓存
在实际业务中我们常常需要处理热点数据缓存失效问题。热点数据的并发读取量很大,一旦发生缓存失效可能会有大量线程访问数据库,可能造成响应变慢甚至数据库宕机等严重后果。
一些场景下可能出现频繁写入的热点数据,使用更新缓存的策略通常不会产生问题。若我们选择了删除过期缓存的策略进行更新,因为热点数据更新非常迅速导致频繁地删除缓存,进一步产生大量缓存失效错误。若采用了先删除缓存后更新数据库的策略,大量读请求非常可能将过时数据写入缓存中造成并发错误。
若热点数据为 Set 或 SortedSet 等集合式缓存,我们可能无法使用一条原子性指令完成整个重建操作,因此需要考虑保证重建过程的线程安全性。
根据对热点数据一致性要求的不同,我们有两套策略。
使用锁保证高一致性
对于高一致性要求的场景我们可以使用分布式锁服务。读请求应获得读锁后才能访问数据,写请求应获得写锁后才能更新数据。
当发生缓存失效的情况时,分布式锁服务会保证有且只有一个读线程获得写锁并完成缓存重建工作,其它读线程因无法获得锁而被堵塞,直到缓存重建完成。这种方法避免了大量线程重复执行缓存重建工作造成数据库压力,但是无法避免响应变慢。
在单例模式中多线程同时调用 getInstance() 方法可能会导致对象重复创建,使用锁进行缓存重建存在着类似的问题。线程A发现缓存失效于是获取写锁进行重建工作,线程B在重建完成前访问缓存仍然出现缓存失效,于是线程B尝试获取写锁。由于写锁被线程A持有,线程B会被阻塞直到重建完成才能得到写锁。因为缓存已被重建,若线程B继续重建缓存则会导致无意义的开销。
使用单例模式中我们熟悉的 Check-Lock-Check 策略即可解决这个问题:
代码语言:javascript复制try {
读取缓存
加读锁
} finally {
释放读锁
}
if (缓存失效) {
try {
加写锁
读取缓存
if (缓存失效) {
重建缓存
}
} finally {
释放写锁
}
}
因为有写锁保护我们无需担心重建缓存时的线程安全问题。
乐观策略
当热点数据的缓存失效时,我们可以先使用 placeholder 占位然后进行缓存重建工作。其它线程读取到缓存中的 placeholder 会返回空结果而不会访问数据库,同时也避免了大量线程阻塞可能造成的不良后果。
placeholder 不能保证只有一个线程访问数据库。当线程A写入 placeholder 时,线程B可能已经发生了缓存失效进入了重建流程。
若我们无法保证重建过程的原子性,则可以在临时键上完成重建操作,然后使用 Rename 命令原子性替换掉正式键开放给所有线程。
Rename
虽然 Redis 命令都是原子性的但我们常常会遇到单个命令无法完成的操作,除了使用分布式锁来保证复杂过程的线程安全外,一些场景下我们可以使用 rename 命令来降低开销。
典型的一个场景是上文提到的,无法保证缓存重建或更新操作的原子性时可以在当前线程私有的临时键上完成操作,然后使用 Rename 命令原子性替换掉正式键开放给所有线程。
另一个常见的场景是将脏数据放入 Set 或 Hash 中,使用 SSCAN 或 HSCAN 命令进行异步更新。SSCAN 命令只保证在遍历开始到结束整个过程中一直存在于数据集中的键至少会被返回一次,若遍历的同时添加新数据则可能造成重复或遗漏的情况。
我们可以将脏数据集 rename 到异步线程私有的临时键上,异步线程在遍历私有脏数据集的同时,其它线程仍然可以向线上脏数据集添加数据。
临时键的生成
在集群环境中,可能仅支持相同 Slot 下的 RENAME 和 RENAMENX 命令。因此, 我们可以使用 HashKey 机制保证临时键和原键在同一个Slot中。
若原键为 "original" 我们则可以生成临时键为 "{original}-1", 花括号表示仅由花括号内部的子串进行哈希来决定 Slot, "{original}-1" 一定会与 "original" 处于相同 Slot 中。
使用临时键的目的是为了单线程的进行操作避免并发问题,因此务必检查临时键是否已被其它线程占用。
临时键有两种生成策略:
- 原键加随机值: 如 "{original}-kGi3X1", 这种方法的优点是随机键冲突的概率较小,但是难以扫描库中有哪些临时键
- 原键加计数器: 如 "{original}-1"、"{original}-2", 这种方法的优点是容易扫描库中的临时键,但是冲突的概率较高。
在检测临时键不存在后就使用是不安全的,在线程A检测到临时键可用到实际使用临时键之间,其它线程检测同一个临时键时也会认为它可用。
为了避免临时键冲突,我们可以在使用前先尝试设置一个占位符。如,在使用 "{original}-1" 前先执行 "SETNX {original}-1-lock" 若设置成功则可以安全地使用 "{original}-1"。这种做法实际上是加了一个简单的分布式锁。
在更新或重建缓存时应使用加随机值的方法以尽量减少冲突。在遍历脏数据时应使用加计数器的方法,我们可以根据计数器来搜索未被释放的临时键,从而继续被中断的遍历过程。
SortedSet
SortedSet 作为 Redis 中唯一的可排序和可范围查找的数据结构可以进行一些比较灵活的应用。
延时队列
在对一致性没有较高要求的场景可以使用 SortedSet 充当延时队列,将消息的内容作为 member, 预定执行时间的UNIX时间戳作为 score。
调用 ZRANGEBYSCORE 方法轮询预定执行时间早于当前时间的消息并发送给 Msg Consumer 处理。
代码语言:javascript复制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用于一致性要求较高的业务场景。