Redis 缓存应用实战

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: Redis 缓存应用实战为了提高系统吞吐量,我们经常在业务架构中引入缓存层。缓存通常使用 Redis / Memcached 等高性能内存缓存来实现, 本文以 Redis 为例讨论缓存应用中面临的一些问题。

Redis 缓存应用实战
为了提高系统吞吐量,我们经常在业务架构中引入缓存层。

缓存通常使用 Redis / Memcached 等高性能内存缓存来实现, 本文以 Redis 为例讨论缓存应用中面临的一些问题。

缓存更新一致性
先更新数据库,再删除缓存
先更新数据库,再更新缓存
先删除缓存,再更新数据库
先更新缓存,再更新数据库
异步更新
缓存穿透
集合式缓存
重建缓存
Check-Lock-Check
事务
Rename
乐观锁
离线数据处理
临时键的生成
SortedSet
延时队列
滑动窗口
一些常识
缓存更新一致性
当执行写操作后,需要保证从缓存读取到的数据与数据库中持久化的数据是一致的,因此需要对缓存进行更新。

因为涉及到数据库和缓存两步操作,难以保证更新的原子性。

在设计更新策略时,我们需要考虑多个方面的问题:

对系统吞吐量的影响:比如更新缓存会比删除缓存减少数据库查询压力
并发安全性:并发读写时某些异常操作顺序可能造成数据不一致(缓存中长期存储旧数据)
更新失败的影响:若执行过程中某个操作失败,如何对业务影响降到最小
检测和修复故障的难度: 并发问题导致缓存中存储旧数据比操作失败导致的数据更难检测
一般来说操作失败出现的概率较小,且通常会在日志中留下较为详细的信息比较容易修复数据。

而并发异常造成的数据不一致则非常难以检测,且多在流量高峰时发生可能造成较多数据不一致,需要更加重视。

并发异常通常由于后开始的线程却先完成操作导致,我们可以把这种现象称为“抢跑”。

更新缓存有两种方式:

删除失效缓存: 读取时会因为未命中缓存而从数据库中读取新的数据并更新到缓存中
更新缓存: 直接将新的数据写入缓存覆盖过期数据
更新缓存和更新数据库有两种顺序:

先数据库后缓存
先缓存后数据库
两两组合共有四种更新策略,现在我们逐一进行分析。

四种策略都存在问题,一般来说先更新数据库再删除缓存是四种策略中一致性最好的策略,但仍需具体场景具体分析选择。

先更新数据库,再删除缓存
若数据库更新成功,删除缓存操作失败,则此后读到的都是缓存中过期的数据,造成不一致问题。

缓存操作失败在会在日志中留下错误信息,在系统恢复正常后比较容易检测和修复数据。

若线程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 无法保证其原子性时,我们同样也需要使用加锁的方式保证重建操作的原子性避免并发异常。

Check-Lock-Check
重建问题与单例模式中多线程同时调用 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)
Rename
乐观锁
如果说上文通过加锁的方式避免并发问题可以认为是悲观锁的思路,对于写入竞争不激烈的场景可以使用 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}-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用于一致性要求较高的业务场景。
原文地址https://www.cnblogs.com/Finley/p/10674101.html

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
22天前
|
存储 消息中间件 NoSQL
Redis数据类型详解:选择合适的数据结构优化你的应用
Redis数据类型详解:选择合适的数据结构优化你的应用
|
27天前
|
存储 XML 缓存
【深入浅出Spring原理及实战】「缓存Cache开发系列」带你深入分析Spring所提供的缓存Cache功能的开发实战指南(一)
【深入浅出Spring原理及实战】「缓存Cache开发系列」带你深入分析Spring所提供的缓存Cache功能的开发实战指南
65 0
|
17小时前
|
存储 缓存 NoSQL
Redis入门到通关之Redis缓存数据实战
Redis入门到通关之Redis缓存数据实战
|
2天前
|
存储 缓存 运维
软件体系结构 - 缓存技术(5)Redis Cluster
【4月更文挑战第20天】软件体系结构 - 缓存技术(5)Redis Cluster
136 10
|
10天前
|
缓存 NoSQL Java
使用Redis进行Java缓存策略设计
【4月更文挑战第16天】在高并发Java应用中,Redis作为缓存中间件提升性能。本文探讨如何使用Redis设计缓存策略。Redis是开源内存数据结构存储系统,支持多种数据结构。Java中常用Redis客户端有Jedis和Lettuce。缓存设计遵循一致性、失效、雪崩、穿透和预热原则。常见缓存模式包括Cache-Aside、Read-Through、Write-Through和Write-Behind。示例展示了使用Jedis实现Cache-Aside模式。优化策略包括分布式锁、缓存预热、随机过期时间、限流和降级,以应对缓存挑战。
|
17天前
|
存储 缓存 NoSQL
使用redis进行缓存加速
使用redis进行缓存加速
27 0
|
18天前
|
存储 缓存 NoSQL
Java手撸一个缓存类似Redis
`LocalExpiringCache`是Java实现的一个本地缓存类,使用ConcurrentHashMap存储键值对,并通过ScheduledExecutorService定时清理过期的缓存项。类中包含`put`、`get`、`remove`等方法操作缓存,并有`clearCache`方法来清除过期的缓存条目。初始化时,会注册一个定时任务,每500毫秒检查并清理一次过期缓存。单例模式确保了类的唯一实例。
15 0
|
27天前
|
缓存 应用服务中间件 数据库
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(多级缓存设计分析)
【分布式技术专题】「缓存解决方案」一文带领你好好认识一下企业级别的缓存技术解决方案的运作原理和开发实战(多级缓存设计分析)
32 1
|
存储 NoSQL Java
当Java遇到Redis:Jedis实战入门
Redis是一个开源,高级的键值存储和一个适用的解决方案,用于构建高性能,可扩展的Web应用程序。本文将概要介绍Redis的特性和语法,并以实例代码的形式介绍如何通过Jedis在java语言环境下控制Redis,帮助各位读者快速入门。
1550 0

热门文章

最新文章