亚马逊AWS官方博客

溯本追源 – Amazon MemoryDB SIGMOD 2024 论文解读

SIGMOD,VLDB,ICDE 是数据库领域的三大顶级会议。顶会上成功发表的数据库相关论文,意味着在学术界对文章内容的认可。Amazon MemoryDB 团队在 SIGMOD 2024 上发表了一篇名为 “Amazon MemoryDB: A Fast and Durable Memory-First Cloud Database”, 本篇博客针对这篇论文,对 Amazon MemoryDB 进行深度解读,剖析它的推出背景、架构特性,以及性能评估。

MemoryDB 推出背景

针对金融、广告、IoT 等行业对数据访问高并发、实时响应的诉求,能提供百万级 QPS 和微秒级延迟的 key-value 数据库成为契合的选择。根据 DB-Rank 排名,在 key-value 领域很有名的数据库是 Redis1,它能够提供 400 微秒的 P99 延迟,也提供了丰富的数据结构比如 hash table,sorted sets,streams,hyperloglog 等,来供应用程序进行复杂的操作(200 多种),也支持 Lua 脚本的执行。针对数据量和数据访问量的变化,Redis 提供了主从复制以及集群的扩展,在写节点和读节点间进行异步复制,在写节点故障时,提供 failover 到读节点的逻辑。此外,Redis 提供了在节点级别写本地事务日志(AOF)的方式来进行数据的持久化操作。然而,Redis 的复制方案并不能保证写节点故障切换到读节点时进行数据无损恢复,也不能实现一致性读。因此, Redis 比较理想适合的场景是缓存。

亚马逊提供了 Amazon ElastiCache for Redis 作为 Redis 的托管服务。然而,很多客户在实现低延迟微服务应用时,直接使用 Redis 来作为主存储。为了克服 Redis 可能丢失数据的风险,客户需要构建复杂的数据管道来获取数据、持久化存储、再加载到 Redis 中。如果 Redis 发生了数据丢失,需要运行单独的任务来将数据重新加载到 Redis 中。比如,电商购物网站的元信息微服务对商品元信息的查找吞吐量可能高达每秒百万级 QPS。因为低延迟的考虑,最佳的方案是把元信息全部存储到 Redis 中,然而,由于 Redis 缺乏持久型的保证,客户只能实现一个数据管道把这些数据持久化存储到另一个单独的数据库中,比如 Amazon DynamoDB,再通过 streaming 的方式把数据同步到 Redis 中。当检测到 Redis 中数据丢失的时候,需要重新启动一个把数据同步到 Redis 的任务。这些客户希望寻求一套简单的方案,通过简单的架构达到良好的持久性,提升性能,并降低维护两套存储系统的成本。基于客户的诉求,亚马逊云科技推出了 Amazon MemoryDB 产品。

Amazon MemoryDB 是一款云原生的数据库服务,能够提供良好的持久型、强一致性和高可用性。在写节点发生故障时,它能选择读节点作为 failover 对象,新的写节点拥有全部的数据,实现“数据不丢”。它能提供 99.99% 的可用性,微秒级别读取延迟和个位数毫秒写入延迟。

MemoryDB 架构特性

MemoryDB 架构

Amazon MemoryDB 在架构上进行了解耦,使用 Redis 作为内存级的计算和存储引擎,同时使用跨多个可用区的事务日志系统来持久化对 Redis 的操作日志。数据写入写节点时,写节点会把事务日志同步写入事务日志系统,等事务日志落盘后,写节点返回写入成功给应用程序。同时,读节点从事务日志系统异步复制更新。

沿用 Redis 的计算引擎,可以实现与 RedisAPI 的完全兼容,使用 Redis 的应用程序可以无缝迁移到 MemoryDB。基于多个可用区的事务日志系统是亚马逊内部的一套成熟稳定的系统,能够提供低延迟、跨多个可用区的强一致性提交以及 11 个 9 的持久性保障。通过架构的解耦,MemoryDB 也实现了持久性和可用性的解耦。即便 MemoryDB 用户只为每个分片设置一个写节点,或者一写一读,也能达到跨 3 个可用区的持久性。

持久性和一致性

我们先来看下 Redis 架构上可以考虑的几种持久化实现。

  1. 拓扑上增添额外读节点。Redis 可以在写节点发生故障时,通过 Quorum 的协议从读节点中选举出新的写节点,并完成自动切换。因为读节点中也有一份数据拷贝,所以可以在切换后提供服务。然而,由于 Redis 协议中写和读的复制是异步复制,在写节点返回成功给应用程序之前,并不会检查读节点是否已经拿到本次更新的数据,所以故障切换极有可能带来数据的丢失,数据的丢失量取决于读节点和写节点之间的复制延迟。
  1. 通过快照实现内存数据的持久化。Redis 快照能在用户发起请求时,将内存中的全部数据串行化存储到磁盘上。用户可以周期性地或者在进行重大数据修改之前,打一个快照。如果更改发生问题,可以通过快照恢复来启动一个新的 Redis 集群。然而,快照只能保证快照时刻的数据的持久性,在快照打完之后的数据修改还是不能持久化。
  1. 通过事务日志 AOF 实现持久化。在 AOF 中,用户可以配置 fsync() 的频率决定多久将操作日志持久化一次。如果用户希望每条数据都不丢,可以配置每条记录都进行一次刷盘操作。这样可以实现单节点数据的本地持久化保障。然而,对于一个多节点的 Redis 集群而言,不同节点之间的 AOF 并没有同步机制。当写节点故障时,会触发 leader 选举,选择一个读节点并 promote 成新的主节点,此时 Redis 并没有办法保证新的主节点拿到原来写节点所有的更新,因此仍然可能带来数据丢失。极端情况下,Redis 可能会选择一个没有数据的读节点作为新的主节点,这样整个分片的其他节点都会从新的没有数据的主节点中同步数据,最终造成整个分片的数据丢失。用户也许可以考虑从故障主节点的 AOF 中恢复数据,但 AOF 数据本身是否实现多可用区的存储实现,以及从 AOF 恢复数据的实现复杂度和恢复时间也是一个问题。
  1. 让客户端发送 WAIT 命令来确保数据同步复制。Redis 支持客户端发送 WAIT 命令,语法为 WAIT numreplicas timeout,该命令可以阻塞当前客户端,直到所有先前的写入命令成功传输并且至少由指定数量的从节点复制完成,才能进行后续操作。如果执行超过 timeout 超时时间(以毫秒为单位),即使尚未完成指定数量的从结点复制,该命令也会返回。然而 WAIT 命令只对当前的连接有效,只能确保该连接之前的写入同步到了读节点,并不能影响其他连接,其他连接仍然可能读到该连接之前写入到写节点但尚未同步到读节点的数据。而且, 主节点发生故障需要进行读节点选择时,也没有相关机制来查找包含所有由 WAIT 操作同步过的数据的读节点进行 failover。

我们可以看到,在基于 Redis 架构的基础上,很难有一种方式保证写节点故障时数据不丢。下面我们来看一下 MemoryDB 的实现。

Amazon MemoryDB 的持久化存储基于跨多可用区的事务日志来实现。用户向写节点写入的每一条记录,更改内存实际数据的同时,也会将日志记录同步到事务日志系统中。MemoryDB 在同步日志的过程中复用了 Redis 的复制模型,采用了 write-behind logging 的机制,即:先更新内存中数据,再发送日志信息。这样的机制能够使 MemoryDB 优雅地处理非确定性的指令(non-deterministic command)。比如 SPOP 命令从 Set 数据集中随机删除并返回一些数据,采用 write-behind logging 的机制,MemoryDB 能够将具体删除的数据记录下来,而不是 SPOP 命令本身,实现数据的确定性同步。

在事务日志响应完成数据落盘之前,MemoryDB 已经修改了内存中的数据,MemoryDB 必须保证这些更改的数据不能被任何客户端看见,因为一旦直接对用户可见,底层还没有完成落盘,发生故障切换的话,新的写节点并没有这部分数据,会带来数据不一致。为判断数据的可见性,关系数据库系统经常采用的机制是 MVCC (Multi-Version Concurrent Control),为单条数据添加更改该数据的事务 ID 信息等,当一个事务发送读请求时,会对系统事务的状态(是否已经提交)和要访问数据的信息综合判断该条数据对当前事务是否可见。然而,Redis 的数据结构没有对 MVCC 的实现。MemoryDB 采用的是 Tracker 的机制,将被更改的数据 key ID 存放在一个单独的结构 Tracker 中,当收到底层事务日志系统的 ACK 确认落盘响应后,才会对数据更改进行确认,返回给客户端。将 Key ID 单独存放,也释放了 Redis 的主线程进行后续其他命令的处理。而 Redis 主线程处理操作时,会判断该操作涉及到的数据是否在等待确认的 Tracker 中,如果在 tracker 中,也会 delay 对客户端的响应,直到收到底层事务日志系统对对应数据的持久化确认。注意 MemoryDB 的读节点并不需要类似的判断,因为读节点读的每一条数据都已经在事务日志层完成了跨 3 个可用区的持久化。

MemoryDB 针对不同的读请求可以达到不同的一致性。如果您希望强一致读,即写后立即读,您可以将读请求直接发送到写节点;如果您并没有强一致读的要求,可以将读请求发送到读节点。在 MemoryDB 单个读节点的读取操作能够实现顺序一致性读(Sequential Consistency),跨 MemoryDB 多个读节点的读取操作可以实现最终一致性读

可用性 – Leader 选举

同样,让我们先来看一下 Redis 的 Leader 选举机制。

Redis 集群之间通过名叫“cluster bus”的Gossip协议来实现 leader 选举。集群中每个分片的主节点通过 cluster bus 持续彼此发送心跳信息。当大部分主节点没有收到一个特定主节点的心跳信息时,会认为这个主节点发生了故障,并从该主节点的从节点中选取一个节点作为所在分片的新的主节点。投票节点会根据自己掌握的读节点的信息,对读节点的 candidate 进行排序,选择拥有最新数据的读节点作为新的主。然而, Redis 并不能保证选举中的读节点拥有失败主节点所提交过的所有数据,因为 Redis 集群在执行写或读操作时,并不使用一致性协议,主从之间的复制是异步的,所以新的写节点不一定有失败写节点的全部数据。此外,如果原来的写节点和其他节点之间的网络发生了隔离,它会在某个时间前继续提供写入服务,而其他健康的节点有可能已经选举出了一个新的写节点提供服务,这样会产生“脑裂”的现象。所以,本质上讲,Redis 集群的选举机制并不能满足基于 Quorum 协议的复制系统的安全性保证,存在两个缺陷:1)不能保证任意时刻整个系统只有单一 Leader 写节点; 2)不能保证一致性的 failover,即新的主节点并不能保证拿到原来主节点的全部数据。

MemoryDB 的 Leader 选举机制是基于底层的事务日志系统实现的,能够保证一致性 failover 和单一写节点,而且不需要集群级别的 quorum 协议来判断主节点的存活,降低了对其他节点的依赖。具体实现如下:

  1. 事务日志服务的 Append 接口。MemoryDB 向日志系统发送存储请求时,必须调用 Append 接口。该接口要求请求指明前序日志的 ID 号。参加竞选,请求自己为 Leader 也是一条特殊的 Append 请求。
  2. 一致性的故障恢复。在写节点发生故障需要选举读节点的情况下,读节点会像平常同步一样向日志系统发送读取请求,当它已经读到最新的数据时,日志系统会回复一条 Control 信息。收到该信息后,读节点会发起竞选,即向日志系统发送一条 Append 操作,并指明前序日志的 ID,也就是最新数据对应的 ID 号。即便有多个读节点竞争成为新主,也只有一个读节点能竞选成功。因为当日志系统收到最早的竞选请求后,会将请求本身作为一条日志存放,系统里最新的日志 ID 号已经发生更新,然而其他读节点的竞选请求的前序 ID 仍然为数据更新的记录,所以写请求失败,即竞选失败。当一个读节点竞选成功成为新主节点之后,它会复用 Redis 集群的 cluster bus 模式通知集群中其他节点关于角色的变化,其它节点可以对应地通知 Redis 客户端。我们可以看到,在整个竞选过程中,每个节点只是和底层事务日志系统交互,避免了对其他节点的依赖,增强了系统的健壮性。
  3. Leader 租约的实现。Leader 租约能够确保在给定时间段内,集群中各个节点都知道谁是写节点,不用再进行系统上的同步,可以各司其职,提高读写效率。MemoryDB 在分片 Shard 级别采用了租约的机制。主节点会阶段性地发送租约续期请求给日志系统。当读节点从日志系统中读到该条租约续期请求后,会开启一个预先设定好的计时器进行倒计时(也称 backoff),这个设定好的时间要严格大于一个租期的时间。在倒计时没有归零之前,读节点不会发起竞选请求。如果主节点因为某些故障,在租约到期后没有发送续约 renew 通知,读节点会在 backoff 结束后,发起竞选请求。

总体而言,MemoryDB 的 Leader 选举机制有几个优势:1)在 Leader 选举期间提高了系统的可用性。Leader 选举只需要依赖于底层事务日志系统,不需要依赖于集群中的大多数节点来进行选举。而底层事务系统已经在亚马逊的多个服务内部使用,是一套成熟稳定的系统。2)提高了数据的一致性。新的主节点在申请竞选时已经有了系统中写入的全部数据,实现了“数据不丢”。 3)避免了“脑裂”,任何时刻系统只有单一的主节点,即便网络发生了分割,写入时要求必须有前序日志的 ID 号使得原来主节点无法实现数据的成功写入。

可用性-故障恢复

亚马逊在设计一款产品时,常常考虑故障处理的情况,因为故障是不可避免的(Design for Failure)。

MemoryDB 在监测故障时结合了两种方式的监测:外部监测和内部监测。MemoryDB 有一套专门的监测系统,持续对所有 MemoryDB 的节点进行心跳监测,同时,MemoryDB 集群内部的各个节点会通过 gossip 协议互相发送心跳信息,来实现集群内的监测。在决定发生 Failover 时,MemoryDB 的管控节点会结合外部和内部的信息来进行判断,并及时恢复失效节点。视故障类型不同,会采取不同的策略,有可能重启当前节点的 Redis 进程,或者重新启动一个新的节点。启动的新节点通常会以读节点的方式存在。

在数据库故障恢复时,经常考虑的两个要素是 RPO 和 RTO,即数据可能丢失的时间范围和数据库影响服务的时间范围。MemoryDB 的数据是完全持久化存储的,新节点能够从事务日志中拿到全部的数据,所以 RPO 为 0。衡量 RTO 的一个指标是 MTTR(Mean-time-to-recovery),越快拉起新的节点,对上层应用的影响越小。MemoryDB 在恢复数据时采用的是快照和事务日志结合的方式。

MemoryDB 的事务日志中包含有对数据的所有更改记录,但是如果在新节点恢复数据时从头恢复效率较低,因为对一个 key 的多次更改需要依次完成,其实这些操作可能是不必要的,只需要记录这个 key 的最新状态即可。为此,MemoryDB 采用了定期快照,并把快照存储到 S3 的方式。快照实际是对快照时刻之前所有的数据操作进行了整合,只保存此时刻的数据形态。这样,进行数据恢复时,只需选择离当前时间点最近的快照,根据快照恢复成新的节点,再把快照之后的事务日志依次应用到新节点上即可,避免了从事务日志的开始逐步应用日志的过程,降低了 MTTR。

接下来我们看下 MemoryDB 打快照的实现。

Redis 在打快照的时候,采用的是 fork 数据库进程的方式,子进程会捕获特定时点的数据集,并把这些数据持久化到快照文件中,与此同时 Redis 主进程继续处理读写请求。Redis 主进程此刻仍然能够处理读写请求,如果有更新操作,会通过操作系统提供的 copy-on-write 的机制,拷贝数据页并在拷贝的数据页上进行修改,这个过程会增加对系统内存的占用,也耗费了额外的 CPU。因此,使用 Redis 以及 ElastiCache 时,我们会建议用户预留打快照占用的空间,ElastiCache 对应的指标是 ReservedMemoryPercent,默认值为 25%。 另外一个常见的建议是在读节点上打快照,来分担写节点的负担,但这样操作也会带来读节点额外的资源消耗,影响读节点的负载。如果读节点打快照时,写节点恰好故障需要进行故障切换,也会对整个集群带来性能的影响。

MemoryDB 在打快照的实现上做了改进,采用的是 off-box 的机制,用户无需预留内存,MemoryDB 会基于底层的事务日志单独启动用户集群之外的集群来完成快照任务。快照过程类似于 MemoryDB 读节点的数据恢复机制,会基于之前最近的快照恢复出一个集群,并应用上次快照到这次快照之前到事务日志,然后再对这个集群进行 Redis 的快照操作,并上传到 S3 中。这样的快照方式,避免了对用户集群节点的资源占用,使得用户的 MemoryDB 集群完全对外提供服务。

由于快照对数据进行了压缩存储,所以从快照恢复的效率往往更高,但本身打快照也会消耗一定的资源,因此 MemoryDB 在决定何时打快照时也做了相应的权衡。总体而言,它会考虑两个因素:应用对 MemoryDB 的写入吞吐量和用户数据集的大小。写入吞吐越大,新产生事务日志的速度越快,当前数据距离上一次的快照越远,事务日志应用起来越慢;用户数据集越大,快照恢复本身的时间越久。MemoryDB 会及时监测这些信息,触发快照操作。

扩展性

MemoryDB 的扩展是由 MemoryDB 的集群管控层(即 Control Plane)决定的,我们先来看下 Control Plane 的实现。

MemoryDB 的 Control Plane 是一个多租户的系统,可以管理多个用户创建的 MemoryDB 集群。Control Plane 接受用户创建集群的命令,会启动 EC2 节点和底层的跨可用区的事务日志系统,并按照用户指定的拓扑结构创建集群。Control Plane 会把创建的 EC2 节点放在用户指定的 VPC 中,并分配一个 DNS Endpoint 供用户登陆 MemoryDB 集群,同时会对 MemoryDB 集群配置加密、用户指定的参数组、权限等选项。Control Plane 自身部署在 MemoryDB 管控平台的 VPC 中,其监控服务会每隔 5 秒钟读取集群中每个节点的状态来监测集群状态,及时恢复故障,并提供报警机制。另外,Control Plane 还能够根据客户请求给 MemoryDB 集群打补丁和扩展等操作。

MemoryDB 集群的扩展分为三个层级:增减读副本;更改实例类型;增减分片。

  1. 增减读副本。用户需要去掉一个读副本时, MemoryDB 会在每个分片中选取对应的读副本,终止 Redis 进程,并释放 EC2 资源。增加读副本时,会为每个分片增添一台 EC2 节点,新增的节点会拿到 S3 中最新的快照信息,在此基础上再重放快照以后的事务日志。在追踪到最新的事务日志后,新节点会加入到集群中,并通过 Redis 的 cluster bus 把自己状态通知给集群中其他节点。
  1. 更改实例类型。MemoryDB 采用 N+1 Rolling Upgrade 的方式来更改实例类型。首先会通过增加读副本的方式创建新的实例,当新的读节点加入到集群中后,Control Plane 会从集群中删除一个原有类型的节点,删除时会优先删除读节点。这样 MemoryDB 集群中的读节点会依次完成替换。当所有读节点都替换完毕后,Control Plane 会删除写节点,写节点的删除会触发 Leader 的重新选举,进而完成 failover。如果用户指定的新实例类型比较小,不足以放下 MemoryDB 的数据,产生内存溢出的情况,Control Plane 会回滚更换实例类型操作。
  1. 增减分片。增减分片需要在不同分片之间迁移 Slot,以及增加或者减少旧分片。增减分片涉及到创建或删除 EC2 节点和每个分片对应的事务日志系统。Slot 的迁移包括两个步骤:传输数据和转移 Slot 所有权。
    • 传输数据。传输数据和 Redis 增加新的读副本很像,只是这里只需要传输特定 Slot 的数据。MemoryDB 会将 Slot 的数据从原来归属的源写节点传送给目标写节点。源写节点向目标写节点传输数据的过程中,仍然可以对外提供读写服务。所以数据传输分为两个部分:已经持久化的数据和传输过程中持续向源写节点写入的新数据。目标写节点会将所有的数据写入到它所在分片的事务日志中,这样该分片的读节点能够从事务日志中得到新的数据。
    • 转移 Slot 所有权。当目标写节点的数据和源写节点几近追齐后,源写节点会阻塞所有写入请求,等待现有的所有写操作在内存中执行成功并在自己和目标写节点的事务日志中完成落盘,然后会触发 Slot 所有权转移工作。这时,源写节点会和目标节点进行一个数据完整性的校验,确定目标写节点收到了 Slot 中的全部数据。在这个过程中,一旦发生失败,比如内存溢出、网络故障、校验失败等,Control Plane 会直接把数据迁移操作撤销,恢复源写节点的写入能力,删除目标写节点中的对应数据。Redis 中对于 Slot 所有权的迁移是通过 cluster-bus 来实现的,中间可能会存在几种错误模式,导致数据的损坏或丢失。MemoryDB 在实现时,为了减少和 Redis 的差异化分叉,复用了 cluster bus 来进行 Slot 所有权信息的沟通,但与此同时把 Slot 的所有权持久化存储在事务日志中了。Slot 的原 Owner 和新 Owner 之间的切换是通过两阶段提交(2 Phase Commit)的方式实现的。在所有权转移之后,新的 Owner 会开始接受写请求,原 Owner 会向仍然发送过来写请求的应用端回复 redirect 重定向信息以便应用端连接到新 Owner,同时源 Owner 会启动后台任务开始删除已经转移出的数据。通常情况下,Slot 所有权转移过程中带来的写的不可用时间只局限在几个网络回路和事务日志的更新延迟。最好在业务低峰期进行重分片操作,并在客户端增加重试机制,来最小化这个影响。此外,由于事务日志采取 2PC 方式记录所有权的变化,如果在所有权迁移期间源或目标写节点发生了故障,也可以顺利完成故障恢复。在故障恢复后,可以继续所有权转移的操作。

MemoryDB 性能评估

论文从两方面对比了 MemoryDB 和 Redis 进行性能评估:1)正常读写 2)打快照时对系统的影响。 MemoryDB 和 Redis 都选择 Redis 版本 7.0.7。采用的负载为 Redis-benchmark,测试了三种不同模式:只读、只写和读写混合,操作为 GET/SET。

正常读写评估

实例:
MemoryDB/Redis 机型从 r7g.large 到 r7g.16xlarge;10 台 EC2 节点负责发送负载,EC2 和 MemoryDB/Redis 写节点在同一可用区。

Redis版本:
7.0.7

负载:
只读负载,GET 请求,只写负载,SET 请求,读写混合负载,GET:SET=80:20。负载运行前数据量:1 百万个 key,确保 GET 命中率为 100%。每台 EC2 的 Redis-benchmark 进程为 100 并发连接,操作 value 大小为 100 字节。

特殊配置:
多 IO 线程配置。Redis 的 IO 线程数目配置与 MemoryDB 齐平。Redis IO 线程可把 IO 工作放给单独的 IO 线程来做,MemoryDB 更近一步,在 IO 线程上实现了多路复用,不同客户端的请求可以复用 IO 线程。

TLS 关闭。因为 Redis IO 多线程不支持 SSL,所以将 SSL 关闭。

吞吐量对比结果:

只读负载,机型低于 2xlarge 时,MemoryDB 和 Redis 持平,达到每秒 200K Op/s;机型从 2xlarge 开始,MemoryDB 优于 Redis,吞吐量达到 500K Op/s,Redis 吞吐量为 300K Op/s。总体而言,MemoryDB 在只读负载表现上优于 Redis,原因在于 MemoryDB 的多路复用 IO 实现,可以将多个客户端的请求连接合并到一起发送给 Redis 主线程,减少了 IO 线程和主线程之间的沟通成本,提高了效率,增加了吞吐量。

只写负载,Redis 优于 MemoryDB。Redis 最大吞吐能够达到 300K Op/s,MemoryDB 最大吞吐达到 185K Op/s。原因在于 MemoryDB 会将每个写操作都落到跨多个可用区的事务日志中,延迟相对较高。因为实验中客户端数目较少,而且请求是顺序的阻塞式请求,MemoryDB 比 Redis 的吞吐量要低。经 MemoryDB 团队测试,对于较高并发、pipeline 操作或者是较大的工作负载,MemoryDB 的每个分片能支撑 100MB/s 的吞吐量。

延迟对比结果:

延迟对比的测试机型为 r7g.16xlarge,主要观测在不同吞吐量下的响应延迟。

只读负载,MemoryDB 和 Redis 相当,平均延迟亚毫秒级别,P99 延迟在 2 毫秒以下。

只写负载,Redis 的平均延迟为亚毫秒,P99 在 3 毫秒;MemoryDB 平均延迟 3 毫秒,P99 在 6 毫秒左右。

混合负载,MemoryDB 和 Redis 的平均延迟都是亚毫秒,P99 延迟 Redis 到达 2 毫秒,MemoryDB 在 4 毫秒左右。

总体来看,MemoryDB 对只读负载和读写混合负载能够达到亚毫秒的平均延迟,对于只写负载和读写负载的尾部延迟,能在为每个写操作实现跨可用区的持久性的同时,达到个位数毫秒的响应延迟。

打快照时读写评估

前面我们介绍过 Redis 打快照的流程。Redis 利用 BGSave(Background Save)来进行快照操作。BGSave 会从 Redis 主进程中 fork 一个子进程,子进程会遍历整个 keyspace 把数据持久化到磁盘。在子进程串行化过程中,如果 Redis 主进程需要修改数据,为了确保子进程只对进程启动时刻的数据进行备份,Redis 会采用 copy-on-write 机制生成内存数据页的拷贝,在拷贝上进行数据修改。如果打快照时写的吞吐很大,COW 会带来额外的内存消耗,最坏情况下,可能带来 Swap 空间的占用,延迟增加,吞吐量降级。而 MemoryDB 的实现避免了这一影响。论文对打快照时对 Redis 和 MemoryDB 的影响做了测试。

实例:
2vCPU,16GB 内存实例,最大内存 max_memory 配置为 12GB。

Redis 版本:
7.0.7

负载:
读写混合负载,100 个客户端发送 GET 请求,20 个客户端发送 SET 请求。负载运行前数据量:2 千万个 key,每个 key/value 大小为 500 字节。采用大负载的目的是为了能够快速加压内存。

测试方法:
快照运行过程中,记录平均吞吐量、平均延迟和 P100 延迟。之所以记录 P100 延迟,是因为快照期间请求相对较少,样本数目比较少,拿到 P100 延迟有助于看整体的影响情况。

Redis 快照对前端读写的影响:

BGSave 刚开始时,吞吐量并没有影响。在 10 秒左右的一瞬间,P100 延迟有一个高达 67 毫秒的抖峰,这是因为 fork 进程的系统调用会克隆整个内存 page table。基于内部测量,内存克隆过程每克隆 1GB 的数据,大概消耗 12 毫秒时间。

当系统内存耗尽时,Redis 实例会开始使用 Swap 交换区进行内存页的替换,此时延迟不断增加,吞吐急剧下降。原因是 CPU 被阻塞在等待内存页刷到磁盘过程,才能进行 COW 操作。当 SWAP 空间超过 8%的内存空间时,P100 延迟增加到了 1 秒,吞吐降低到了几近 0,在客户端看来 Redis 几近不能使用。

为了应对 BGSave 对内存的额外消耗,占用 SWAP 空间,影响 Redis 的可用性,Redis 用户通常需要预留一部分内存供快照使用,通常情况下一个 Redis 节点只有一半左右的空间能够用来支持前端读写操作。或者用户需要对自己的应用负载特别了解,选择在零写入或者低写入的时候进行快照操作。

MemoryDB 快照对前端读写的影响:

MemoryDB 打快照是基于后台事务日志,启动单独的后台集群进行操作的,对前端实例没有任何影响,选择实例类型对 MemoryDB 快照没有影响,用户甚至可以选择 1.37GB 内存,2vCPU 的配置。

上图显示后台快照过程中,MemoryDB 对吞吐量和延迟都没有发生变化。平均延迟稳定在个位数毫秒,P100 延迟在 10-20 毫秒之间。注意这里的 P100 延迟相对于上一小节测试正常读写时较高是因为运行的混合负载,而且 item 大小 500 字节是上一小节测试的 5 倍,如果读取的 key 恰巧需要等待之前写入到事务日志层的成功返回,会一定程度上影响延迟。

由于 Snapshot 过程采用的是离线集群(独立于用户的集群)进行快照,用户在使用 MemoryDB 时并不需要预留快照内存空间,也不需要考虑选择合适的时间节点进行快照操作。MemoryDB 能够供用户使用的有效内存比例更高。

MemoryDB 的一致性验证

升级时的一致性保证

MemoryDB 的计算层和内存存储层与 Redis 保持一致,所以可以做到快速支持新的 Redis 小版本,用户可以根据自身需要选择新的版本。

MemoryDB 在对集群进行版本升级时,同样采用了 N+1 rolling upgrade 滚动升级的方式。它会通过增加一个额外节点的方式先将读副本逐一升级,最后再升级写节点,这样可以最大程度上仍然保持集群支撑读的吞吐量。对于多个节点的升级,为了维持整个集群的可用性,MemoryDB 并没有把升级所有节点的操作做成一个事务级操作,因此,在升级过程中可能集群的不同节点会处于不同的版本状态。如果在特殊情况下,写节点的版本已经更新,而某个读节点还在使用较旧的版本,同时新旧版本的事务日志记录格式发生了变化,就有可能发生写节点写入了一条新的格式记录,而读节点无法识别甚至误解的问题。此时,如果写节点发生了故障,该读节点成为了新的写,会带来数据不一致的问题。

MemoryDB 定制了升级保护机制,在写入事务日志记录的同时,也加入写节点的版本信息。如果读节点发现读到的事务日志记录对应版本与自身版本不同,会停止读取。为了保证集群的可用性,Control Plane 会启动故障恢复,替换掉老版本的读节点。

正确性验证

MemoryDB 兼顾了内存级性能和持久性,简化了用户在 Redis 和另一个有持久化能力的数据源间同步数据的负担,与此同时,它增加了很多正确性验证,来保证提供给用户完整无误的数据。

MemoryDB 会对快照进行验证,判断快照数据是否与事务日志系统中数据对齐。MemoryDB 会对整个事务日志持续进行 checksum 校验和计算,并阶段性地将 checksum 插入到事务日志中。MemoryDB 的快照会存储三个信息:1)该快照的 checksum,即它捕获的事务日志的前缀; 2)最后一条日志在事务日志系统中的位置信息; 3)快照对应全部数据的 checksum。MemoryDB 会把快照恢复到一个单独集群的方式来进行快照校验。首先,它使用第 3 个信息,即对应全部数据的 checksum 来校验快照中的数据;然后,它会根据第 2 个信息,即最后一条日志的位置 ID 信息来找到接下来要从哪个事务日志开始重放数据。接着,它会开始重放事务日志,会以第 1 个信息,即快照的 checksum 为基础,在重放过程中不停计算事务日志中的 checksum,并与事务日志中持久化的 checksum 进行对比。如果发现实时计算出的 checksum 和事务日志系统中存放的 checksum 不一致,校验失败。只有在校验成功以后,MemoryDB 才会将该快照设为对用户可见。

此外,MemoryDB 还进行了一致性验证。它采用了分解的方式来分别验证各个模块。对实现持久性依赖的模块,比如 S3,事务日志,MemoryDB 引擎自身等,MemoryDB 研发团队采用了多种工具来进行形式验证(formal verification),比如使用 TLA+和轻量级的形式方法来验证 S3,使用 TLA+来验证事务日志的复制协议,使用 P 来验证 MemoryDB 新功能开发等。对于 Redis 接口本身,MemoryDB 采用了 porcupine 来进行验证。具体验证的方法可以参考论文的参考文献。

总结

这篇 SIGMOD2024 论文对 MemoryDB 进行了详细介绍。

MemoryDB 的核心架构就是将持久层从内存的执行引擎剥离出来,并使用亚马逊内部的事务日志服务来实现。MemoryDB 在实现过程中采用了多种技术来提供了不同特性,比如:利用跨多个可用区的事务日志实现了 11 个 9 持久性的保证,实现“数据不丢“;利用事务日志的 Leader 选举机制,保证了同一时刻集群(分片)中只有一个写节点,新的写节点能拿到原来写节点写入的全部数据,实现了强一致;利用快照和事务日志结合的方式,减少故障恢复时间,降低 RTO;利用 Redis cluster-bus 传递 slot ownership 的管控消息,同时利用 MemoryDB 事务日志记录 slot ownership,实现良好的扩展性;复用 Redis 的 API 以及复制流系统实现写节点到事务日志和事务日志到读节点的复制,能够及时跟进 Redis 新版本的功能,实现了灵活性。此外,MemoryDB 能够达到亚毫秒的读取性能,个位数毫秒的写入性能,避免快照时对于额外内存资源的消耗。

总之,MemoryDB 对于既希望数据库有内存级性能,又希望主节点故障时数据不丢,同时希望减少运维两套数据存储系统的用户来说,是一个很好的选择。

注解1. 2024 年 3 月 20 日,Redis Labs 宣布从 Redis 7.4 开始,将 BSD 源码使用协议修改为 RSAv2 和 SSPLv1 协议。该变化意味着 Redis 在 OSI(开放源代码促进会)定义下不再是严格的开源产品。Redis 的多位核心成员对此变动并不认可,所以单独成立了 Valkey 开源项目,继续推动开源社区的发展。Valkey 采用 BSD 源码使用协议,已于 2024 年 4 月 16 日推出 7.2.5 版本,Valkey7.2.5 版本从 Redis7.2.4 fork 而来,用户可以无缝完成切换。目前 Valkey 项目热度持续上升,聚集了来自包括亚马逊云科技在内的多家公司的贡献者。

本篇作者

马丽丽

亚马逊云科技数据库解决方案架构师,十余年数据库行业经验,先后涉猎 NoSQL 数据库 Hadoop/Hive、企业级数据库 DB2、分布式数仓 Greenplum/Apache HAWQ 以及亚马逊云原生数据库的开发和研究。