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 的项目介绍)
(当然 Dragonfly 以多线程对比 Redis 单线程,这个评测的公平性有待于商榷)
截止 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%的结论:
(图片来自:https://redis.com/blog/redis-architecture-13-years-later/)
(当然这个测试也没说 Redis 集群下功能是受限的,多 key 操作命令有额外的限制)
文章最后给出了 Redis 当下架构设计的几个原则/目标:
在每个 VM 上运行多个 Redis 实例
将每个 Redis 进程限制在合理的(内存)大小
水平扩展至关重要
尤其是第三点文章用了很大篇幅来说明:比如单线程多分片的架构设计更加弹性、会有更小的爆炸半径、纵向扩展导致实例容量翻倍、更贴近 NUMA 架构等等,其他涉及 Redis Inc 商业版本因 AWS 的硬件限制以及自身数据中心限制带来的设计影响在这里不陈述了。
传统 Redis 单线程模式和层出不穷的多线程挑战者之间擦出的火花愈演愈烈,作为耕耘了这个领域近 12 年的 Tair 怎么看待这个架构之争呢?Tair 自研引擎使用的架构也是多线程,这么看来 Tair 认为多线程才是唯一正确的设计吗?当然没这么简单,Tair 做多线程的根因不仅仅是因为单节点的性能问题,更不是单纯为了单节点的跑分去设计实现多线程引擎架构的。去年发布的这篇文章中提到了我们的大客户业务场景下遇到的诸多问题,这里再次引用下文中的部分场景以便于后续的讨论:
某用户打满了 Redis 的服务,主线程 CPU 使用率 100% 后业务开始卡顿。为了解决问题,用户紧急在控制台进行扩容。然而 Redis 主线程过于繁忙,新的主备关系很难建立,建立后数据同步进度特别慢。用户特别着急,但我们除了让用户降低流量外束手无策(限流对业务伤害太大,当下只是卡顿,限流会大面积不可用)。最后怎么解决的?说来惭愧,等用户流量降低后才升配成功的。
某用户的访问存在间歇的峰值,主线程负载时不时飙高,导致管控采集服务监控的请求也时不时超时。最终造成了监控数据的曲线出现断点,引发了用户投诉。为什么超时?因为监控链路和数据链路都是主线程处理的。当前只是在社区版上把探活端口和逻辑移动到了另外的线程,监控和管控仍在主线程。(正在改造,但是还是遇到了很多困难。因为 Redis 单线程,所以数据结构都是线程不安全的。所以在别的线程获取信息要么加锁,要么主线程提前生成好。提前生成看起来挺好是吧?但是主线程执行一个慢查询的时候,也就不会执行生成的定时任务去生成这个信息了,拿到的也是旧的)。
读写分离场景下,Redis 链式挂载了多个同步只读节点,从 Master 开始直到最后的只读节点,数据更新同步的一致性逐渐降低(链式逐个同步存在时差)。当 Master 遇到大流量打满 CPU 时,容易造成主从断开(堆积的同步请求太多或者心跳失败),然后触发链式同步断开后逐个节点全量同步。然而 CPU 是打满的,同步断开后更难建立了,最终难以恢复。为什么是链式同步不是星型同步?链式只挂载一个只读节点都会出问题还敢星型啊?我们能做什么?检测到这个情况后 Proxy 隔离数据不同步的从节点。但是!这是读写分离实例啊,流量大到了主从都断开了,隔离了更多的只读节点这是要让系统雪崩吗?
Redis 作为缓存使用时,为了缓存一致性,数据都是有过期时间兜底的。如果一个节点长期以高 CPU 运行时,过期检查和清理的定时任务就得不到足够多的优先级去执行,累积的过期数据过多后达到内存上限就会淘汰。业务会发现明明存在很多过期数据,但是淘汰的却不一定是过期的数据。
Redis 的内存统计是进程级别的,没有分区域(数据区,元数据区、Client buf 等等),当遇到了一些网络延迟大或者客户端/服务端处理缓慢的情况时,会造成较多的 Client buf 占用。当内存统计达到设置的阈值后就开始淘汰用户数据了。那为什么不把统计分开?统计各个单独区域有这么难吗?技术上不难,但是这是个繁琐的工作,但凡漏掉一处就会引发大问题。Redis 怎么统计全局的?Redis 内存分配和释放在 malloc 和 free 上包了一层,有自己的 zmalloc/zfree,在申请和释放的时候做的。那 Redis 后台还是有几个 BIO 线程啊,怎么保证统计准确?难道用原子变量?对,就是原子变量,每次内存申请和释放都是原子加减一次。如果再区分每个区域,都包装一个 xxx_malloc/xxx_free,先不说繁琐性,原子操作的翻倍会带来性能的进一步降低(原子统计有些按照 Cache Line 对齐的小技巧减少 False Sharing,但是高频原子操作带来的性能损失还是尽量要避免的)。
-
某直播用户,采用 Pub/Sub 机制进行弹幕的广播,需要 1 对 N 的广播能力。但是 Redis 的单线程负担不起(1 对多等于请求放大),最终业务采用了一主几十从的架构去做链式的广播消息推送,这个架构特别复杂且运维难度很高。如果 Redis 能支持1对几十甚至几百的广播能力就不用这么多从节点了。
针对上述的这些 Redis 使用过程中的“病痛”,Tair 开出了“多线程”的处方来试图根治。我们来逐一对照解释如何在 Tair 自研的引擎里根治这些问题:
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 等场景下提供相应的支持。
还是单线程的问题,其实这是存储服务控制流和数据流没有分开的问题。存储服务要接入管控机制,必然要预留特权账号给管控组件使用。比如对用户账号施加的白名单、限流、敏感命令拦截等逻辑必须对管控账号提供另外的一组限制规则,但是在开源产品里,这类设计往往比较欠缺。Tair 在设计上直接把控制流和数据流做了隔离和拆分,无论是从代码逻辑上,还是资源预留上彻底分为了控制平面和数据平面。控制平面有单独的网络和请求处理线程,单独的账号权限控制,可以绑定到另外的网卡以及端口上。这个设计无论是在可用性、安全性还是可维护性上都有更好的保障。
得益于 1 里面对同步机制的重新设计和拆分,从节点可以很轻松的以星型的方式和主节点进行挂载,数据一致性得到了很好的保障。特别的,以为主节点本身是多线程的设计,可以直接扩容主节点的线程数来实现,而不是增加更多的只读从节点。单节点扩展处理线程比扩展更多的只读节点无论在数据一致性上还是运维的复杂度上都是更优的选择。
Tair 的过期数据扫描也有独立的线程来执行,这个线程以每秒千次的(可调)频率调用扫描接口进行过期数据的检查和删除,基本上可以准实时的清理掉过期数据。独立的过期线程乍一听好像并不复杂,但是能这样做的前提是先得有并发安全的存储引擎,才能把引擎的一些任务完全的拆分出去。这里存在实现路径上的依赖关系,如果存储引擎做不到线程安全,独立的扫描线程就无从谈起。当然具体到 Redis 的语义上,多线程引擎的过期逻辑会更复杂一些。比如多个事务并发的时候,每个事务执行过程中是不能有过期的(导致「不可重复读」等其他意外问题),所以社区的事务(含 Lua 脚本)执行过程中是不会有过期检查的。但是这个问题在多线程事务并发的时候就麻烦一些,需要维护全局事务的最早开始时间,过期检查需要用这个时间进行,而不是当前时间。
这个问题本质上是统计的问题,内存引擎本身要做好自己的 Footprint 控制,需要详细的统计清楚自己的数据区域用量,元数据区域用量,各类 buffer 机制的用量。只有这样才能在内存紧张的时候,合理的进行过载保护和回收内存。Tair 在内存统计上做到了严格的统计和区分,可以实时的获取各部分的内存使用统计,尤其是针对存储引擎部分的每种数据类型都维护了详细的内存统计。
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 本身进行排序,要么是对最终加锁的内存地址进行排序,以保证相同的加锁顺序。
在 Tair 完成了 Redis 兼容引擎的事务并发支持后,针对慢查询请求的隔离需求便提上了议程。首先是识别,数学上的时间复杂度无法衡量真实的执行代码,还需要每个数据操作指令自定义复杂度公式,结合引擎查询计算复杂度。假设我们定义 C 表示参数的数量(argc)或范围(range),K 表示单个 DB 下的 KeyCount,M 表示该种数据结构的内部成员数量(比如 list 的 node 数量,hash 的 field 数量等等),一些命令的代价计算公式如下 :
当引擎能估算识别请求的执行代价后,那么将这些必然会触发慢查询的请求隔离出去就可以了。Tair 在原本的 IO/Worker 线程之外,抽象了一组慢查询协程来处理这类慢查询的请求,整体的模型如下:
请求被分为 Fast Path,Slow Path 以及可能造成严重阻塞的 Slowest Path 三类。Fast Path 直接在 IO 线程上执行并返回,Slow Path 在单独的 Worker 线程上计算(最后回到 IO 线程上回包),Slowest Path 是引擎的代码估算系统得出的慢速请求,会被投递给专门的 Coroutines Threads 处理。这里重点介绍下 Coroutines Threads,本质上这是一组协程和线程以 M:N 模型进行协作的抽象,每个慢速请求会创建一个新的协程加入到优先队列里被调度执行。使用协程切换的目的是希望慢查询本身也是有优先级的,次慢的查询理应比更慢的查询尽早返回,而不是简单的 FCFS(First Come First Serve)策略。
协程的选择有无栈协程(Stackless)和有栈协程(Stackful)两种,有栈和无栈最大的区别就在于编程模型和资源占用上。使用有栈协程可以在任意的函数位置进行协程切换,用户态一次纯寄存器的上下文切换仅在 10 ns 上下,远低于操作系统上下文切换的开销(几 us 到上百 us )。为了不对原始代码进行过多的修改,Tair 选择了有栈协程进行任务的封装与切换,同时为了降低极端场景下的内存开销,也提供了共享栈协程(Copying the stack)的选项以支持时间换空间。
Tair 是一个多引擎的服务,有内存和磁盘等不同的介质,所以这三类线程是根据实际的需求创建的。比如内存模式就只分配了 IO 和 Coroutines 线程,涉及到磁盘形态的存储才会使用 Worker 线程池。因为内存形态下请求处理的很快,此时 IO 线程直接处理快速的查询要比和 Worker 线程池的任务交互带来的 2 次上下文切换开销更划算。
接下来我们聊聊 Lua 机制。Redis 原生的事务(watch-multi-exec)机制只是一种带有 key watch 机制的批量执行手段,实际上和传统数据事务所强调的 ACID 不搭边。Redis 为了弥补自身的一些缺陷,引入了 Lua 作为存储过程的替代实现。Redis 允许用户提交一段 Lua 脚本在服务端执行,并且提供了build-in 的 Lua 库来执行 Redis 命令。得益于一直以来的单线程实现,Lua 脚本在 Redis 服务端是顺序执行的,也就保证了原子性。有了 Lua 这个灵活的机制,Redis 的边界被进一步被拓宽,诸如分布式锁这样的组合特性很快便流行开来。但实际上 Lua 的执行是有额外的开销的,词法分析、语法分析、JIT 以及跨越语言边界的 2 次额外数据类型转换。
但是 Lua 带来的这些成本问题并没有阻碍 Lua 的大量使用,因为这个特性极大的简化了很多业务逻辑。在很多场景下,大量的数据从存储服务读取到业务服务器,然后处理后又被写回。这个处理过程中很多时间和带宽被浪费在了数据的来回搬运上,以及业务服务为了原子性而采用的分布式锁甚至更重的机制上。如果一段针对数据的操作能简单的推送到服务端原子的执行,这就可以大幅度的简化业务逻辑,并且在整体性能上可能会更好。然而,一旦这个逻辑涉及到多个 key 时,在多个存储节点组成的集群模式下,这个操作就难以进行了。所以 Redis 集群模式下限定一个 Lua 脚本操作的 key 不得跨 slot,否则涉及到多个存储节点的 Lua 是无法解决原子性的问题的,而分布式事务显然太重了,不符合 Redis 的设计理念。但是 key 的文本表示以及 hash 运算后的 slot 并没有符合直觉的对应关系,这就使得 Lua 脚本在集群模式下的优势严重缩水。尽管有 hashtag 这样的机制可以把相关的 key 存储在相同的分片上,但是这样更容易造成集群节点间的水位不一致,也无法线性扩展。
特别地,在游戏客户的眼里,Lua 是一个可以帮助他们大幅度简化业务逻辑的机制。小到登录 Session 的多地域防重复登录,即时排行榜的维护,大到反作弊机制的完整解决方案,都有这广泛的应用场景。而且在短、平、快的游戏迭代中,谁能早一天发布,谁就能多一点市场机会。因为维护一个全游戏玩家的一致性视图是很困难的,所以分区分服是常见的业务策略。这样一个区/服的数据量进一步缩小和独立,如果能在一个数据节点上存储所有的数据,并且完整的支持 Lua 机制的话对游戏开发来说是很幸福的事情。
针对这个场景,Tair 的多线程进一步的支持了 Lua 的执行加速,每个命令执行线程拥有自己的 Local Lua VM 对用户的脚本进行并行处理,同时其他的普通请求正常进行。这时候多线程的优势就体现出来了。原本在单线程节点组成的集群里需要用分布式事务才能解决的问题此时用单机事务就可解决了。尽管单节点无法像集群那样水平扩展到数百个节点,但是单机近百 core 的机器已经覆盖了绝大多数的用户场景了。尽管一个 64 线程的单节点在绝对性能上不如一个 64 分片的集群实例,但是这个单节点能做到集群实例做不到的事情。
不过正因为 Lua 的灵活性,操作的数据 key 有可能是在运行时动态计算得出的,那么事务开始前的 Hash Lock Table 机制就不够了,因为无法执行前预知这个 Lua 要操作哪些 key 要加哪些锁。所幸一般性的 Redis Lua 使用规范里都要求把操作的会变化的参数抽象到脚本参数里,以避免 Lua 脚本不同占据了服务端太多存储。所以 Tair 提供了一个「Lua 严格模式」的选项,开启后要求 Lua 脚本所操作的 key 必须在参数中给出,否则拒绝执行。在这个选项开启后在事务锁阶段就使用行锁(key 锁)而不是表锁(db 锁),能获得最好的加速效果。
Tair 多线程引擎版本首发于 2019 年 4 月份,当时以「Redis 企业版-性能增强型」的名称对外发布,完全兼容社区 Redis 5.x 版本,当时的引擎线程模型和社区版的区别如下:
Tair for Redis 5.x 版本当时率先于 Redis 社区实现了多 IO 单 Worker 的形态。该版本将网络 IO 操作从原本的引擎线程中完全剥离,在不同的访问场景下有着相对于社区 150%~300% 的性能提升。
Redis 社区 6.x 于本文所介绍的 Tair for Redis 6.x 的线程模型对比如下:
Tair for Redis 6.x 版本完全的实现了多线程的并发引擎操作,突破了 5.x 版本 IO 线程存在上限的问题,在访问的 key 均匀的情况下,可以水平扩展线程数量提升单分片性能(接口完全兼容社区托管版 6.0 版本)。在用户业务强依赖单节点访问方式且短期无法适配集群版的情况下,继续帮助用户抵御业务增长压力或者为改造集群版提供缓冲时间。值得一提的是,这里的线程模型图只是 Tair 新版本的一种部署形态,在 Tair 适配不同的存储介质时,会选择最合适的线程模型以最大化的利用硬件资源,在不妥协延迟的前提下尽可能的提升吞吐能力。Tair for Redis 6.x 版本目前已经在全网发布,首发版本支持内存形态并完全兼容社区 6.x 版本的所有接口和用户特性。
云原生内存数据库 Tair 是阿里云自研数据库,兼容 Redis 的同时提供更多数据结构和企业级能力,包括全球多活、任意时间点恢复和透明加密等。支持多种存储介质和不同场景性价比需求:内存型支持超高吞吐,性能为Redis 三倍;持久内存型成本降低 30%,支持数据实时持久化;图引擎(原图数据库GDB)支持数据融合与知识图谱应用:
https://www.aliyun.com/product/apsaradb/kvstore/tair
[1]https://redis.com/blog/redis-architecture-13-years-later/
[2]https://dragonflydb.io/
[3]https://docs.keydb.dev/
[4]https://skytable.io/
[5]https://help.aliyun.com/document_detail/145832.htm
[6]https://arxiv.org/pdf/2003.07302.pdf
[7]https://www.cs.umd.edu/~abadi/papers/vldbj-vll.pdf
[8]https://github.com/dragonflydb/dragonfly/blob/main/docs/api_status.md