正确使用内存数据库(Redis)
目录
用语 | 解释 |
---|---|
Redis OSS | Redis Open Source Software,泛指社区版 Redis。 |
Redis Labs | Redis 公司的旧名。一个提供 Redis 实验性软件或模块的组织(例如 RedisMod)。 |
Redis Stack | 由官方提供的(社区版)Redis 套件。旨在构建实时的数据平台;目前生产级别的推荐方案。内置了 Redis OSS、稳定的 Redis Module、Redis Insight(UI)等。 |
分布式锁 | 泛指分布式锁管理器(简称:DLM)。代表某种操作的权限 |
墨菲定律(Murphy’s Law) | 任何可能出错的事情都会出错。 |
死锁 | 一种并发程序设计缺陷。具体可描述为:因条件不满足,而无法获取或释放锁。进而导致其他并发个体处于持续等待的状态。 |
缓存层 | 负责存取缓存的逻辑边界。它可以是代码片段、库/框架、AOP、代理等 |
SoR | 记录系统(System of record)。泛指一等数据源(,应用的状态会以该数据源为准)。 |
分布式协调服务 | 在分布式环境下,帮助多个节点协调工作的一类中间件或服务。常见需要协调的工作有主节点选举、主备切换、分布式锁、配置管理、策略性任务调度 等。 |
消息队列 | 一种异步通信方式。支持请求排序、请求缓冲、发布-订阅等需求实现。 |
查询模式 | 特定于上下文的查询需求。 |
技术思维陷阱 | 开发者在设计、开发或维护软件时,因对软件工程认知不足或对某种技术持有偏见,而导致思维被固化。进而作出错误和低效的决策。 |
1. 可靠性
Redis 提供了两种持久化方案:RDB (Redis Database)、AOF (Append Only File)。前者属于条件间隔全量快照;后者属于增量指令备份。
RDB 存在快照空窗期。这意味着间隙期间一旦发生宕机就会存在数据丢失风险。 AOF 理论上可以(通过设置 appendfsync always)做到每条指令都确保落盘。然而,在极端情况下(如突然断电、进程崩溃或错误关闭)AOF文件会存在损害的风险;一旦文件损坏就会导致数据丢失(。Redis 7 提供了multi part AOF特性,可以将 AOF 切分为多个文件,从而降低数据丢失所涉及的数据面)。
注意,以上问题即便采用混合持久化方案(RDB + AOF) 和复制架构也无法解决。墨非定律已经道明一切,切勿存在侥幸的心理。
建议
- 当需求无法接受数据丢失时,就不应该使用 Redis 来作为解决方案
- 必要时,考虑使用应用程序日志来作为补偿依据,以防数据丢失后彻底无法恢复
2. 应用场景
2.1 特性需求(第一原则:可接受数据丢失)
2.1.1 常见用例参考
- 服务注册和发现:set、zset
- 位图(如:签到):bitmap
- 时间线(如:供稿服务、News Feed System):zset
- 随机抽取:set
- 差集/交集/并集计算:set
- 二级索引:任何类型
- 布隆过滤器:RedisBloom
- 时序数据存储:RedisTimeSeries
- 空间检索:Geospatial
- 文档存储:RedisJSON
- 全文检索:RediSearch
- 建议词典/Suggest(如:检索建议、@建议):RediSearch
- 排名:zset
- Top-K(如:热搜、热词、热卖商品):RedisBloom
- 基数统计(如:UV统计):HyperLogLog
- 分布式会话:任何类型
- 数据去重:set
- 计数(如:PV统计):string
- 流量控制:lua + string
- 消息队列:stream
2.1.2 Redis DLM(分布式锁)
当需求可以接受不可靠(即容许锁语义被破坏的情况发生)时,Redis 方案就能够足以胜任。 而容许不可靠的前提是需求本身属于最终一致性或仅仅只是利用 DLM 来降低并发性。 譬如处理并发缓存更新问题时,即便有多条进程同时操作也无关紧要。但在无法接受不可靠时,则不建议使用 Redis 来作为解决方案。根本原因在于其持久化机制并不可靠。 一旦锁状态丢失就会破坏锁语义(即同步机制被破坏),进而导致不一致。这对于大部分系统来说都是无法容忍的。即便官方提议使用RedLock来解决不可靠性。 RedLock 算法的核心逻辑是通过实施分布式仲裁(Quorum)来提供可靠性和一致性。但鉴于其实施成本(至少3个独立的Redis节点;官方推荐5个)以及没有解决所有问题(如网络分区、时间回拨),所以建议还是选择更加可靠的方案(如Etcd(Raft CP协议)、Zookeeper(ZAB CP协议))。但如果要坚持使用 Redis,则可以参考 RedLock implementations。
不论使用何种技术作为解决方案也无法忽视死锁问题。在 DLM 上下文中,死锁通常是由于持锁进程因为某些原因而未能正确释放锁而导致的。常见解决方案是引入超时释放机制(如TTL、临时会话)。然而,超时释放又引发出另一个需要思考的问题。即超时时间(timeout
值)应该如何设置?针对该问题通常有两种解决思路,就是额外增加时间续约机制或 Fencing令牌。续约可以避免timeout
过小问题,而Fencing令牌则可以让持有过时锁的客户端自己回滚(。譬如在极端情况下,可解决锁因 GC 而续约失效的情况)。
如何基于 Redis 来实现 DLM,并非本文讨论范畴。这里仅提供大体的实现思路。 首先 Redis 并没有像关系型数据库那样提供ACID事务。它无法为多条指令提供原子性保证。针对该问题可使用Lua脚本来实现类似的需求。因为 Redis 会将Lua脚本视为一个单一指令。因此,只需相应地进行防御性编程,就能够一定地满足对原子性的需求。思路如下:
获取锁:基于Lua脚本,使用
setnx lockName threadMsg
来竞争锁;成功后则为lockName
设置初始timeout
值。此外,锁被占用期间其他客户端依然会持续地尝试获取锁;为了降低其失败(或请求)频率,可以通过指数退避算法来解决。
死锁问题:通过引入时间续约机制来解决。可在成功获取到锁后,创建一条负责续约的精灵线程;该线程可以持续地为
lockName
增加或重制timeout
值。直到客户端主动释放锁(即移除lockName
)为止
锁重入(是一个性能优化)问题:锁重入指的是持锁客户端应该能够再次获取(它所持有的)锁。注意,这并非是一个必须解决的问题;具体需要根据实际的使用情况而定(即是否有重入的需求)。然而,即便是没有重入机制,锁服务(即在 Redis 上执行的Lua脚本)也应该理所当然地支持持锁客户端再次获取锁(譬如发现是持锁客户端时,就直接响应获取成功)。所以通常情况下,在 DLM 上下文中谈论锁重入并非真的需要解决重入问题,而是避免持锁客户端执行无谓的外部请求而损耗性能(,因为它本身已经获取到锁)。换言之,实际上真正需要的是一个避免持锁客户端再次获取锁的机制。该问题可以通过本地线程变量来解决;只需要在获取锁成功后,在本地线程上构建一个计数器即可;每一次获取都递增1,反之递减1;当递减到0时,就向锁服务移除持有标识(即
lockName
)。
2.2 缓存需求(第一原则:可接受数据最终一致)
2.2.1 正确理解缓存
缓存的价值在于可以加速或避免查询。这里的查询泛指为对SoR进行直接访问和执行繁重的计算。
使用缓存的第一原则是能够接受数据最终一致。其背后的根本原因在于缓存层和SoR(通常)没有提供能够确保数据一致性的协议。 换言之,只要数据源之间没有一致性协议,那么就必然存在数据一致性问题。网上(和面试中)经常有人试图“解决”这一问题,但显然他们会徒劳无功。因为即便可以采用某种极端手段来使其达到强一致,但这已然失去了缓存的意识。即当同步成本大于其查询价值时,这种决策就已经说明是错误的。
为了提高缓存的使用效率,最应该做的是合理地选择缓存目标。 其实通常情况下,一个系统中的大部分查询都不要强一致。我们可以大体将查询划分为两种类型,状态相关查询和非状态相关查询。非状态相关查询(如展示内容)并不影响系统状态,所以无需强求强一致;大部分情况下都可以使用缓存来优化查询。状态相关查询就正好相反,应该保证强一致,避免使用缓存。
对于计算结果相关的查询,需要谨慎对待。只有计算结果属于非状态相关查询时,才应用使用缓存。 据经验之谈,好些开发者会因为图方便而将计算结果放入 Redis。这是不可取的,因为它并非可靠存储。如果有这方面的需求,建议使用分布式协调服务来解决。譬如Zookeeper(ZAB CP协议)、Etcd(Raft CP协议)、Consul(Raft CP协议) 这些。
💡 在分布式系统中,服务之间应该避免透过共享状态来进行通信。因为这样会隐藏通信的复杂性。其状态维护工作会随着系统迭代而变得隐晦(譬如难以得知到底有谁在维护和使用它)。这种不透明性最终会导致系统变得难以维护。解决方法是使用API来替代共享状态。
2.2.2 常见用例参考
- 静态内容(如:静态页面或片段)
- 仅需满足最终一致的数据(如:外部系统查询、配置信息)
- 用于展示的记录或文档、计算结果(如:互动统计)
- 社交媒体功能(如:追随者列表、关注者列表、@提及用户列表、互动用户列表)
- 体积较小的临时文件(如:二维码图片)。Redis Key 最大(长度)可为 512 MB
2.2.3 设计模式
⚠️ 设计模式只是指导;不是非此即彼。它们完全可以结合使用。
缓存端模式(Cache Aside)
- 特点
- 延时加载
- 缓存层不负责与 SoR 直接交互。该工作需要由客户端来负责
- 应用逻辑
- 读操作
- 客户端请求缓存层
- 若缓存层无法命中,则客户端需要主动访问 SoR,然后再将响应结果更新至缓存层
- 写操作
- 方案1:写 SoR 后,再删除缓存层。能更有效低地缓存有用数,能一定程度节省缓存空间
- 方案2:写 SoR 后,再更新缓存层。低并发场景下时效性更好。属于空间换时间策略
- 读操作
提前刷新模式(Refresh Ahead)
- 特点:客户端无需检索 SoR
- 应用逻辑:将数据预先地、定期地加载到缓存层
本地缓存模式(Local Cache。也叫进程缓存)
- 特点:将数据直接缓存在客户端内存
- 应用逻辑:可用编程语言内置的标准库,或第三方缓存库来实现
缓存层即SoR模式(Cache as SoR)
- 特点:SoR 对于客户端而言是透明的,客户端仅需和缓存层交互
- 元模式:
- 直读(Read Through)
- 和缓存端模式一样,但由缓存层来实现
- 直写(Write Through)
- 客户端更新缓存层后,再由缓存层将状态同步写入 SoR
- 后写(Write Back)
- 和直写基本一致。但缓存层通过异步写入 SoR
- 直读(Read Through)
2.2.4 增强一致性
虽然缓存层和SoR基本上无法实现强一致,但却有方法使其实现真正的最终一致。 可以通过CDC(Change data capture)技术来监控 SoR 上的数据变更事件,然后透过消息队列将事件投递给缓存更新程序来实现这一目的。较为常用的CDC实现有Debezium、Flink CDC、Apache SeaTunnel、Logstash、Alibaba Canal 等,可根据实际情况来进行选择。
2.2.5 应对极端情况
当系统需要应对大流量时,缓存层将会面临一系列考验。 从技术层面来说,缓存层除了能够加速查询之外,另一大价值在于可以阻挡(部份)流量直接进入SoR。因为大部分(尤其是业务型)系统的 SoR 依然在使用关系型数据库,但关系型数据库并不擅长应对大流量和大数据。此时,缓存层就起到了防御的作用。 然而在某些极端情况下,缓存层可能会失效。一旦缓存层失效就可能会导致系统性能骤降,甚至崩溃。譬如在系统流量高峰期见,缓存层因过期策略(TTL) 而失效,导致流量直击 SoR。而一旦 SoR 负载到底极限,就很可能会拖垮整个系统(或服务)。这一现象被称为缓存击穿,指的是缓存层突然失效就像是被大流量打穿了一样(。此外,缓存击穿还有一种极端称作缓存雪崩,指短时间内缓存层发生大面积失效)。问题的解决思路如下:
- 在网关增加流量控制,以防过载(对生产环境进行压测然,再计算出流控精度)
- 在程序中增加一层本地缓存,避免过度依赖 Redis
- 采集并分析缓存(key)使用率,实现动态 Redis TTL 调整
- 使用分布式锁来控制缓存更新,避免并发访问 SoR
- 通过客户端缓存(浏览器、App) 或内容分发网络(CDN)来降低系统流量
不设置 Redis TTL,避免缓存过期(不推荐:长期占用内存会影响性能,而且还会加剧数据不一致)- 实施高可用架构可以有效地避免缓存雪崩。注意,在大流量分布式环境下可能会引发热点(Hot Key) 问题。譬如当某个拥有大量追随者的明星在社交媒体上发布动态时,那么这条动态就会迎来一大批高频流量。这种热点现象会导致流量汇聚,严重起来甚至会瘫痪节点。较为可行的解决方案是将热点数据广播到所有节点,从而实现负载均衡(可参考Hash tags进行实现)。至于何谓热点数据,则取决于具体系统。例如在社交媒体中,拥有大量追随者的明星(帐号)就可以被定义为热点。他们所发的动态就应该被广播到所有的缓存节点上
除了缓存击穿外,还有一种称为缓存穿透的问题尤其需求注意。穿透指的是请求有意或无意地绕过了缓存层,导致流量直接落在 SoR 上。在缓存上下文中,穿透比击穿更容易发生。 常见有两种情况,一是开发者没有意识到需要增加缓存。这种情况通常比较好解决。只需在发现 SoR 负载较高、查询较慢、优化过 SoR,但没什么效果 后,及时引入缓存层即可。 二是系统部署在公网上遭到恶意攻击(。譬如使用根本不存在的标识发起大批量查询。因为根本没有数据,自然就无法命中缓存)。该问题的解决思路如下:
- 在网关采集流量特征,进行针对性限流
- 使用Bloom filter或Cuckoo filter来阻挡恶意查询
- 引入断路器(Circuit breaker),在必要时触发降级操作
当查询(SoR)返回空时,就在缓存层中设置临时占位(不推荐:毫无意义。攻击者不会愚蠢到使用重复标识来进行攻击)
3. 数据建模
如果对 NoSQL 建模没什么认识,可以先参考另一篇文章:认识NoSQL建模。
在 NoSQL 上下文中进行数据建模需要关注两点,系统实际的查询模式和NoSQL特性。 前者很容易理解,针对具体查询需求来进行建模。 但后者在 Redis 中较为多样化,因为它提供了多种数据结构。这意味着它存在多种建模方案吗?希望不要掉进技术思维陷阱。 技术的价值仅在于服务需求。这应该被视为软件开发的第一原则。 优秀的设计应该根据实际需求来进行技术选型。即便它提供一万种数据结构,但在第一原则面前都只是特定场景下的工具,而不应该反作用于需求。如果不明白这一点,那么设计和开发出来的系统通常会是难以使用和维护的(。但凡从事过几年软件开发,就职过几家公司。就知道这并非危言耸听)。
只要不是在“错误”行径上使用 Redis(如前文提到的共享状态),就仍然可以选择面向聚合建模来作为解决方案。只需确保聚合位于单个key上即可。但某些时候查询可能并不需要返回完整的聚合,特别是聚合比较大(Big Key)时。虽然部份数据结构支持返回聚合的部份内容(譬如 map、json),但未必满足所有需求。此时可以通过逻辑聚合来解决该问题。
简单说,就是使用名称空间(namespace)来组织(逻辑)聚合。而在结构层面聚合会被拆分成不同的组成部份。
但遗憾的是,Redis 对于名称空间的支持比较有限。唯一的实现只有逻辑数据库,而且通常还不建议使用。因结构上并没有实现隔离,查询会相互影响。
解决办法是透过对key进行规范化命名来模拟命名空间。
例如可以将key名格式限定为:namespaceId:aggregationType:instanceId:instanceAttributeName
。
其中namespaceId
可以是租户标识。instanceAttributeName
代表聚合的组成部份,用于拆分聚合;且不同的组成部份可以根据实际需求来使用不同的数据结构。注意,该格式只是一个例子。你应该根据实际系统的需求来规范化key名格式。