云原生内存数据库 Tair 是阿里云自研数据库,兼容 Redis 的同时提供更多数据结构和企业级能力,包括全球多活、任意时间点恢复和透明加密等。
作者 | 刘欢(浅奕)
(资料图)
来源 | 阿里开发者公众号
2022 年 6 月 8 日,Redis Inc.的官方博客发布了一篇名为《13 年后,Redis是否需要一个新架构?》 [1] 的文章,这篇文章由Redis的联合创始人兼CTO Yiftach Shoolman、首席架构师Yossi Gottlieb以及性能工程师Filipe Oliveira联合署名,被业界认为是Redis官方针对Dragonfly [2] “碰瓷式”营销的回应。
Dragonfly 是一款兼容 Redis/Memcached 协议的内存存储系统,从 Dragonfly 官方发布的基准测试结果来说,声称自己比 Redis 快了25倍,单个存储节点可以处理数百万的请求。官方给的性能基准测试结果如下:
(图片来自:https://github.com/dragonflydb/dragonfly的项目介绍)
截止 2022 年 8 月 10 日,Dragonfly 在 github 上已经有了 9.3 K 的 star 和 83 个 fork 的分支。虽然以前就有诸如 KeyDB [3] 或者 Skytable [4] 这样的 Redis 挑战者,但是从来没有一款产品得到 Redis 官方的正式回应。此次官方的正式撰文回应,在外界看来至少也说明 Dragonfly 相较于之前的挑战者有了更大的影响力。
Redis 官方博客这篇文章对 Dragonfly 和 Redis 重新做了性能基准测试,以 40 分片 Redis 集群的方式重新压测对比 Dragonfly 单节点的性能(没说明 Dragonfly 单节点使用了多少线程),得出了 Redis 在同等资源占用下,吞吐量比 Dragonfly 要高出 18% 至 40%的结论:
文章最后给出了 Redis 当下架构设计的几个原则 / 目标:
在每个 VM 上运行多个 Redis 实例 将每个 Redis 进程限制在合理的(内存)大小 水平扩展至关重要尤其是第三点文章用了很大篇幅来说明:比如单线程多分片的架构设计更加弹性、会有更小的爆炸半径、纵向扩展导致实例容量翻倍、更贴近 NUMA 架构等等,其他涉及 Redis Inc 商业版本因 AWS 的硬件限制以及自身数据中心限制带来的设计影响在这里不陈述了。
传统 Redis 单线程模式和层出不穷的多线程挑战者之间擦出的火花愈演愈烈,作为耕耘了这个领域近 12 年的 Tair 怎么看待这个架构之争呢?Tair 自研引擎使用的架构也是多线程,这么看来 Tair 认为多线程才是唯一正确的设计吗?当然没这么简单,Tair 做多线程的根因不仅仅是因为单节点的性能问题,更不是单纯为了单节点的跑分去设计实现多线程引擎架构的。去年发布的这篇文章中提到了我们的大客户业务场景下遇到的诸多问题,这里再次引用下文中的部分场景以便于后续的讨论:
1. 某用户打满了 Redis 的服务,主线程 CPU 使用率 100% 后业务开始卡顿。为了解决问题,用户紧急在控制台进行扩容。然而 Redis 主线程过于繁忙,新的主备关系很难建立,建立后数据同步进度特别慢。用户特别着急,但我们除了让用户降低流量外束手无策(限流对业务伤害太大,当下只是卡顿,限流会大面积不可用)。最后怎么解决的?说来惭愧,等用户流量降低后才升配成功的。
2. 某用户的访问存在间歇的峰值,主线程负载时不时飙高,导致管控采集服务监控的请求也时不时超时。最终造成了监控数据的曲线出现断点,引发了用户投诉。为什么超时?因为监控链路和数据链路都是主线程处理的。当前只是在社区版上把探活端口和逻辑移动到了另外的线程,监控和管控仍在主线程。(正在改造,但是还是遇到了很多困难。因为 Redis 单线程,所以数据结构都是线程不安全的。所以在别的线程获取信息要么加锁,要么主线程提前生成好。提前生成看起来挺好是吧?但是主线程执行一个慢查询的时候,也就不会执行生成的定时任务去生成这个信息了,拿到的也是旧的)。
3. 读写分离场景下,Redis 链式挂载了多个同步只读节点,从 Master 开始直到最后的只读节点,数据更新同步的一致性逐渐降低(链式逐个同步存在时差)。当 Master 遇到大流量打满 CPU 时,容易造成主从断开(堆积的同步请求太多或者心跳失败),然后触发链式同步断开后逐个节点全量同步。然而 CPU 是打满的,同步断开后更难建立了,最终难以恢复。为什么是链式同步不是星型同步?链式只挂载一个只读节点都会出问题还敢星型啊?我们能做什么?检测到这个情况后 Proxy 隔离数据不同步的从节点。但是!这是读写分离实例啊,流量大到了主从都断开了,隔离了更多的只读节点这是要让系统雪崩吗?
4. Redis 作为缓存使用时,为了缓存一致性,数据都是有过期时间兜底的。如果一个节点长期以高 CPU 运行时,过期检查和清理的定时任务就得不到足够多的优先级去执行,累积的过期数据过多后达到内存上限就会淘汰。业务会发现明明存在很多过期数据,但是淘汰的却不一定是过期的数据。
5. Redis 的内存统计是进程级别的,没有分区域(数据区,元数据区、Client buf 等等),当遇到了一些网络延迟大或者客户端/服务端处理缓慢的情况时,会造成较多的 Client buf 占用。当内存统计达到设置的阈值后就开始淘汰用户数据了。那为什么不把统计分开?统计各个单独区域有这么难吗?技术上不难,但是这是个繁琐的工作,但凡漏掉一处就会引发大问题。Redis 怎么统计全局的?
Redis 内存分配和释放在 malloc 和 free 上包了一层,有自己的 zmalloc/zfree,在申请和释放的时候做的。那 Redis 后台还是有几个 BIO 线程啊,怎么保证统计准确?难道用原子变量?对,就是原子变量,每次内存申请和释放都是原子加减一次。如果再区分每个区域,都包装一个 xxx_malloc/xxx_free,先不说繁琐性,原子操作的翻倍会带来性能的进一步降低(原子统计有些按照 Cache Line 对齐的小技巧减少 False Sharing,但是高频原子操作带来的性能损失还是尽量要避免的)。
6. 某直播用户,采用 Pub/Sub 机制进行弹幕的广播,需要 1 对 N 的广播能力。但是 Redis 的单线程负担不起(1 对多等于请求放大),最终业务采用了一主几十从的架构去做链式的广播消息推送,这个架构特别复杂且运维难度很高。如果 Redis 能支持1对几十甚至几百的广播能力就不用这么多从节点了。
针对上述的这些 Redis 使用过程中的“病痛”,Tair 开出了“多线程”的处方来试图根治。我们来逐一对照解释如何在 Tair 自研的引擎里根治这些问题:
1. Redis 的单线程模式使得用户的请求处理和主备同步的数据发送都是同一个线程,当用户的请求占据了太多 CPU 算力的时候,新的主备全量同步自然受到了很大影响。这本质上还是个 CPU 算力的问题。Tair 的解法是把同步的逻辑拆到单独的线程去做,每个从节点采用独立的线程做主从同步相关的工作。在此基础上,Tair 把 Redis 的 AOF 数据彻底的 Binlog 化,做到了 Binlog Based Replication 的同步,实现了同步和持久化的链路统一,解决了 Redis 基于内存的同步带来的大流量下主从重连容易触发全量以及存量数据同步过程中内存 buffer 写满导致主从再次断开等问题。
秉承着“既要又要还要”的精神,Tair 在网络条件良好的情况下,会自动从磁盘同步切换到内存同步以获得更好的同步时延。另外 Tair 围绕 Binlog 也实现了跨域同步、CDC(Change Data Capture)等外部的 Binlog 消费和通知服务。这里多说几句,现代存储服务的一个核心设计就是 Binlog,很多特性都是围绕着 Binlog 展开的,有足够多附加信息的 Binlog 才能在多副本一致性以及 CDC 等场景下提供相应的支持。
2. 还是单线程的问题,其实这是存储服务控制流和数据流没有分开的问题。存储服务要接入管控机制,必然要预留特权账号给管控组件使用。比如对用户账号施加的白名单、限流、敏感命令拦截等逻辑必须对管控账号提供另外的一组限制规则,但是在开源产品里,这类设计往往比较欠缺。Tair 在设计上直接把控制流和数据流做了隔离和拆分,无论是从代码逻辑上,还是资源预留上彻底分为了控制平面和数据平面。控制平面有单独的网络和请求处理线程,单独的账号权限控制,可以绑定到另外的网卡以及端口上。这个设计无论是在可用性、安全性还是可维护性上都有更好的保障。
3. 得益于 1 里面对同步机制的重新设计和拆分,从节点可以很轻松的以星型的方式和主节点进行挂载,数据一致性得到了很好的保障。特别的,以为主节点本身是多线程的设计,可以直接扩容主节点的线程数来实现,而不是增加更多的只读从节点。单节点扩展处理线程比扩展更多的只读节点无论在数据一致性上还是运维的复杂度上都是更优的选择。
4. Tair 的过期数据扫描也有独立的线程来执行,这个线程以每秒千次的(可调)频率调用扫描接口进行过期数据的检查和删除,基本上可以准实时的清理掉过期数据。独立的过期线程乍一听好像并不复杂,但是能这样做的前提是先得有并发安全的存储引擎,才能把引擎的一些任务完全的拆分出去。这里存在实现路径上的依赖关系,如果存储引擎做不到线程安全,独立的扫描线程就无从谈起。当然具体到 Redis 的语义上,多线程引擎的过期逻辑会更复杂一些。比如多个事务并发的时候,每个事务执行过程中是不能有过期的(导致「不可重复读」等其他意外问题),所以社区的事务(含 Lua 脚本)执行过程中是不会有过期检查的。但是这个问题在多线程事务并发的时候就麻烦一些,需要维护全局事务的最早开始时间,过期检查需要用这个时间进行,而不是当前时间。
5. 这个问题本质上是统计的问题,内存引擎本身要做好自己的 Footprint 控制,需要详细的统计清楚自己的数据区域用量,元数据区域用量,各类 buffer 机制的用量。只有这样才能在内存紧张的时候,合理的进行过载保护和回收内存。Tair 在内存统计上做到了严格的统计和区分,可以实时的获取各部分的内存使用统计,尤其是针对存储引擎部分的每种数据类型都维护了详细的内存统计。
6. PubSub(发布订阅)在上文中有详细的介绍,解释了在 Tair 中如何利用多线程的方式做加速来优化单节点的 PubSub 机制的,这里不再赘述。同样的,在集群模式下的广播式 PubSub 也做了相应的优化策略。这个策略的专利还在流程中,这里不便多说。
说了这么多,看起来 Tair 都是在用多线程的手段来解决这些问题的。那么在 Tair 看来多线程才是正确的设计吗?是,也不是。我们认为这个争论其实没有意义。工程里充满着妥协与折衷,没有银弹。外部的讨论一般都是集中于单一的场景(比如普通的读/写请求跑分),只从纯粹的技术角度来看待架构的发展和技术的权衡。而 Tair 作为一个贴身业务,诞生于双十一的实际需求、和业务互相扶持着一路走来的存储产品,希望从业务视角给这场“模型之争”带来更多的思考。毕竟技术最终还是要落地业务的,不能片面的追求架构美而忽视真实的世界。我们需要根据用户实际的存储需求不断的打破自己的边界去拓展更多的使用场景。
再者,单线程节点通过集群扩展和单节点通过线程扩展不是一个完全对立的问题。Tair 的单节点形态既可以是单线程(命令执行单线程,后台线程和功能性线程保留),也可以是多线程模式(我们全都要)。不同的模型可以适应不同的场景,现实世界不是非黑即白的,往往比有技术洁癖的码农心里所想象的世界要复杂很多。我们依旧要面临业务架构积重难返的客户,面临对 Redis 不甚熟悉但是要完成业务需求的客户,关键时候我们都得能拿出方案帮助客户去过渡或者应对突发情况。Tair 永远会朝着用户需要的方向比社区多走一步,我们认为这是作为云服务和用户自建相比的核心价值之一。
一路看来,所有兼容 Redis 的多线程服务都在强调自己的某种程度的“性能更优”,但这是付出了更多 CPU 算力的条件下的。如果界定在相同的 CPU 投入中,在普通接口单 key 读写(get/set)场景下,单分片多线程(Standalone)的总体性价比几乎无法超过单线程多分片(Cluster )模式。为什么?因为在 Redis 的集群模式下,采用的是 hash 数据分片算法。所有单 key 的普通读写从客户端就天然的分成了没有数据依赖的请求,各自独立的发给了某个存储节点,单个存储又都是单线程无锁的。而在单机多线程模式下,多个并发的线程总是要处理各种数据依赖和隔离,所以理论上在同等 CPU 资源下,双方都做到理论最优的话多线程很难超越。
社区 Redis 一直是单线程的设计,所以天然满足最高的事务隔离级别 Serializable(序列化),多线程的服务只有完全做到了这个隔离级别才能作为 Redis 的平替,但是在公开的文档里很少看到有这样的讨论。如果只是单纯的实现一点数据结构,再兼容下 Redis 语义的话就对比 Redis 的话想超越不是很难。这就好比学会了 socket 之后随手写个网络库简单测试大概率比 libevent/libuv 之类的库跑得快的原因(因为缺了很多检查和各种高级特性,代码执行路径上就短了很多)。在这一点上Tair 自身做到了和 Redis 完全一致的语义,对外的所有协议/接口直接移植了 Redis 社区的 TCL 测试来保证行为一致。
现实世界是复杂的,Redis 能取代 Memcached 的本质原因当然不仅仅只是 Redis 的 KV 性能更好,而是 Redis 更多的数据结构提供了更多业务视角的便利。再加上 PubSub(发布订阅)、Lua、持久化、集群等等一系列机制更是丰富了 Redis 的使用场景,使得 Redis 逐渐脱离了 Memcached 仅用于 cache 加速的简单用途而承担了更多的业务职责。
Tair 的多线程也不仅仅是对 Redis 数据结构操作的简单性能提升,而是针对阿里云 Redis 服务这些年服务的客户所遇到的各类边界场景的优化,也是 Tair 在这个领域多年积累的经验的整合与输出。在传统 Redis 的使用场景中,有 KV、List、Hash、Set、Sorted Set、Stream 等数据结构,Tair 也扩展了 TairString(带 version 可以 CAS 的 String)、TairHash(支持 field 带过期的 Hash 结构)、TairZSet(多维排序集合)、TairDoc(Json 格式数据)、TairBloom(Bloom 过滤器)、TairTS(时序数据)、TairGIS(R-Tree 地理位置)、TairRoaring(Roaring Bitmap)、TairCPC(Compressed Probability Counting)以及 TairSearch(搜索)等等高级数据结构:《Tair 扩展数据结构的命令》 [5] 。
Redis 自身的数据结构除了大多数 O(1) 和 O(lgN) 时间复杂度的算法外,也有 O(N) 乃至 O(M *N) 等时间复杂度的性能杀手。比如大家熟知的keys *调用(用于匹配所有满足规则的 key 并返回)就是个不折不扣的性能刺客。Redis 作为一个 Hash 引擎,如何实现模糊匹配呢?答案是暴力匹配。这个接口的实现是在整个存储结构上进行的 for in all kv 的逐个比较,有 O(N) 的时间复杂度(N 是存储的 Key 总数)。生产环境里稍大点的 Redis 实例的 key 数量往往都是数百万甚至千万过亿的,那这个接口的执行效率可想而知。
类似这样的接口还有不少,比如 smembers 、 hkeys 、 hvals 、 hgetall 等完整读取一个 key 的接口。如果不慎误用,问题往往不会出现在测试阶段,甚至业务上线的早期也不会有任何问题。因为这些接口的执行代价是和数据大小息息相关的,一开始数据量没那么大的时候往往风平浪静,但是在随着数据的逐渐增多,最终会在某个时间点拉大延迟,拖慢整个服务继而雪崩。
精通 Redis 的用户在开发中会严格的控制自己每种复杂数据结构的大小,而且会避免使用这些可能会有问题的接口,但是现实中又没法保证所有的用户都精通 Redis 的接口。而且随着业务系统的演进,某些数据结构的设计也会变更。当一个 Hash 结构的 filed 数量扩展了数倍之后,谁又会想到业务代码的某处可能埋藏着一个早期的 hgetall (返回整个 Hash 的内容)的调用呢。也许在业务早期的设计里,这个 Hash 结构是有预期最大值的, hgetall 不会有问题,但是后来随着业务发展这个 Hash 被扩展了,这个正常的逻辑在某一天就成了系统雪崩的导火索。
退一步讲,即使此类慢查询不会严重到出现业务故障,但是偶发的抖动也会对服务造成负面的影响。访问毛刺一般由偶发的慢请求产生,而请求的排队使得毛刺逐渐蔓延。如果放任这样的偶发慢查询蔓延开的话,等到量变最终引起质变的时候,也会影响到在线服务的使用体验。
单纯在内存中实现一个线程安全的 HashMap 不算什么困难的事情。不过根据 Redis 的接口语义,这个 HashMap 还得实现两个特性:「渐进式 rehash」以及「并发修改过程中的无状态扫描」。前者是因为单个 Hash 存储的数据会到千万甚至亿条,触发引擎 rehash 的时候不能一蹴而就,必须是随着其他的数据操作渐进式进行的。后者是因为 Redis 支持使用 scan 接口来遍历数据,并发修改中这个遍历过程可以重复但是不能遗漏。
Dragonfly 在官方文档里也坦言是参考了《Dash: Scalable Hashing on Persistent Memory》 [6] 这篇论文的思路。当然这个核心的存储不见得一定是 Hash 结构,社区也提过 RadixTree 的想法。这个领域的讨论也很多,比如 B+ Tree 的一些变体CSB+ Tree、PB+ Tree、Bw Tree 以及吸收了 B+ Tree 和 Radix Tree 优点的 MassTree 等等。Tair 的存储引擎是在不断变化和调整的,以后可以在另一篇文章中更系统的聊聊这个话题。并发引擎的设计难点不仅仅是数据结构层面,除了高性能的实现之外,服务的抖动控制、可观测性以及数据流和控制流的隔离与联动也是至关重要的。
有了支持并发的存储引擎,上层还需要对处理事务的并发控制。这里并发控制机制所带来的开销与用户的请求没有直接关系,是用于保证事务一致性和隔离性的额外开销。之前有提到过 Redis 的事务隔离级别是 Serializable(序列化),那么想做到完全的兼容就必须保持一致。内存数据库和传统基于磁盘的数据库在体系结构上有很大的区别。内存事务不会涉及到 IO 操作,性能瓶颈就从磁盘转移到了 CPU 上。比较成熟的并发协议有:轻量锁、时间戳、多版本、串行等方式。大多数的 Redis 兼容服务还是采用了轻量锁的方案,这样比较容易做到兼容,Dragonfly 的实现是参考了《VLL: a lock manager redesign for main memory database systems》 [7] 里的 VLL 方案。不过从支持的接口 [8] 列表看,Dragonfly 尚未完全兼容 Redis 的接口。
完全的 Redis 接口兼容性对 Tair 来说是至关重要的,这里简单介绍下 Tair 采用的轻量锁方案。一般情况下,处理 KV 引擎的事务并发只要保证在 key 级别上串行即可,轻量锁方案都是实现一个 Hash Lock Table,然后对一个事务中所有涉及到 key 进行加锁即可。这个 Hash Lock Table 类似这样:
Hash Lock Table 的实现本质上是个悲观锁机制,事务涉及的 key 操作都必须在执行前去检查 Hash Lock Table 来判断是锁授权还是锁等待。如果事务涉及到多个 key 的话加锁的顺序也很重要,否则会出现 AB 和 BA 的死锁问题。工程上常规的做法是要么对 key 本身进行排序,要么是对最终加锁的内存地址进行排序,以保证相同的加锁顺序。
点击查看原文,获取更多福利!
https://developer.aliyun.com/article/1081534?utm_content=g_1000368602
版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。
世界最新:Tair 对 Redis 引擎架构之争的看法
全球热消息:森林草原防灭火形势严峻 云南已发布防火令
全球即时:公交自燃
环球观焦点:画饼充饥的读法
【焦点热闻】第一上海:新世界发展在香港+内地双引擎布局下经营及财务具有韧性未来将充分提升股东回报
全球今热点:A股午评: 指数窄幅震荡,钠电池 储能板块较活跃
【新视野】鲁信有邻花园_卢信宥
全球观点:ttt培训师资格证_ttt培训是什么意思
当前资讯!事关襄阳!正式签订!
全球快资讯:澧县:建成高标准农田近80万亩
环球即时看!为啥人到中年,酒局上不愿喝啤酒,反而转喝白酒?酒友:身不由己
世界快讯:秦岭路街道:开展“禁种铲毒,共护净土”禁毒宣传进社区活动
时讯:复锐医疗科技盘中再涨超5% 公司将进一步扩大全球直销布局
环球信息:把好安全第一关!湖南工业职院开展“开学第一课讲安全”系列活动
当前快看:020期姜山双色球预测奖号:四码蓝球参考
【独家】保险该不该买?本人帮家人买了一份保险,年交费四千三百多,需交十年,靠谱吗?
热点评!2020中国金球奖前三名揭晓其中武磊韦世豪吴曦成为最后的3大候选者
环球热门:御风而行:水皮财智启示录
即时:怎么改变电脑字体样式win10_怎么改变电脑字体样式
环球速读:湫山乡_关于湫山乡介绍
环球时讯:恋爱脑又醒一个?她竟用三年才跟“法制咖”男友分手!
环球播报:网友嗑不到,但他俩爱很深?
世界速讯:南昌第六医院进口2价宫颈癌疫苗专场预约
新动态:池州新型冠状病毒肺炎疫情:2月22日池州疫情最新消息今天数据统计情况通报
视讯!百年人寿关爱自闭症儿童公益活动再出发
当前简讯:湖南将打造“15分钟就医圈”!最新方案印发 还有城市社区15分钟健身圈
【全球独家】带追索权的应收票据贴现会计分录_贴现会计分录
世界资讯:湛江市鸿振机械设备有限公司_关于湛江市鸿振机械设备有限公司介绍
全球消息!北方矿业拟实施资本重组 继续停牌
环球观点:趣智校园怎么退款_趣智校园怎么退款
当前快播:沃尔沃汽车在华召回部分进口XC90 V90CC汽车
全球今热点:港股异动|香港航天科技(01725)午后跌近9% 内资近期大幅买入 或为避免被剔除港股通
焦点精选!中国首条直通中越边境口岸高铁建设站前工程总体完成90%
今日快讯:能源化工:国内需求复苏前景乐观,油价上涨
每日快播:基金优选:服务基民视角下的加减法
全球时讯:光电共封装CPO板块2月20日涨3.07%,锐捷网络领涨,主力资金净流入2.06亿元
相关新闻