跳到主要内容

正确使用内存数据库(Redis)

用语
解释
Redis OSSRedis Open Source Software,泛指社区版 Redis。
Redis LabsRedis 公司的旧名。一个提供 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 直接交互。该工作需要由客户端来负责
  • 应用逻辑
    • 读操作
      1. 客户端请求缓存层
      2. 若缓存层无法命中,则客户端需要主动访问 SoR,然后再将响应结果更新至缓存层
    • 写操作
      • 方案1:写 SoR 后,再删除缓存层。能更有效低地缓存有用数,能一定程度节省缓存空间
      • 方案2:写 SoR 后,再更新缓存层。低并发场景下时效性更好。属于空间换时间策略

提前刷新模式(Refresh Ahead)

  • 特点:客户端无需检索 SoR
  • 应用逻辑:将数据预先地定期地加载到缓存层

本地缓存模式(Local Cache。也叫进程缓存

  • 特点:将数据直接缓存在客户端内存
  • 应用逻辑:可用编程语言内置的标准库,或第三方缓存库来实现

缓存层即SoR模式(Cache as SoR)

  • 特点:SoR 对于客户端而言是透明的,客户端仅需和缓存层交互
  • 元模式:
    • 直读(Read Through)
      • 缓存端模式一样,但由缓存层来实现
    • 直写(Write Through)
      • 客户端更新缓存层后,再由缓存层将状态同步写入 SoR
    • 后写(Write Back)
      • 直写基本一致。但缓存层通过异步写入 SoR

2.2.4 增强一致性

虽然缓存层SoR基本上无法实现强一致,但却有方法使其实现真正的最终一致。 可以通过CDC(Change data capture)技术来监控 SoR 上的数据变更事件,然后透过消息队列将事件投递给缓存更新程序来实现这一目的。较为常用的CDC实现有DebeziumFlink CDCApache SeaTunnelLogstashAlibaba 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 filterCuckoo 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名格式。

参考

Dik Tam
作者
Dik Tam
只是喜欢写代码。