在高吞吐、大连接数、热点请求、异常流量、复杂计算逻辑、弹性伸缩这些真实场景下保持稳定的低延时,是 Tair 能够在低延时场景被选择的关键因素。
低延时
存储介质
索引数据,与 真实数据 采用不同的存储介质
除了存储介质的延时,通常我们还需要关心的是介质的成本。成本一方面是从硬件上,Tair 是率先采用 SCM 的云产品,相对于 DRAM,SCM 的密度更高能支持持久化,且成本更低。上面提到的三部分数据结构中, Tuple 和 String Pool 是主要占用数据的空间,存放在空间更大的 SCM 上,Index 需要频繁访问且占用空间更低,存放在空间较小延时更低的 DRAM 上。
索引数据,全内存,延时<100ns
真实数据,云存储,延时 <=300ns
另外一方面是从数据结构上去降低成本,这里的技术手段包括,设计更友好的数据结构和碎片整理的机制、进行透明的数据压缩。Tair 中会以 Page 为单位来管理 Tuple,随着数据的删除,每个 Page 会有一些空闲的 Tuple,存储引擎会按照空闲率来对 Page 分组,当整体的空闲率高于一定阈值(默认是 10%)时,就会试图根据空闲率进行页的合并
Tair 目前在使用的索引主要有 HashTable、SkipList、RBTree、RTree、Number Tree、Inverted index 等,分别应用于不同的场景。索引和需要服务的模型是相关联的,比如如果服务的主要模型是 Key-Value,那么主索引使用 HashTable 来达到 O(1)的时间复杂度,ZSet 涉及到数据排序和排名的获取,所以 Zset 使用了一个可以在查找时同时获取 Rank 的 Skiplist 作为索引。排序场景使用 SkipList 作为索引是内存数据库中比较常见的方案,相较于 BTree 来说,由于没有 Structure Modification,更易于实现并发和无锁,当然,也会增加一些 Footprint。在 Table 存储中,使用 RBTree 作为排序索引,在数据量达到 10k 的场景下,RBTree 能够提供更稳定的访问延时和更低的内存占用。
在数据库系统中,索引能力的增强还可以让执行器对外暴露更强的算子,比如 Tair 中的 RBTree 提供了快速计算两个值之间 Count 的能力,对外提供了 IndexCountOperator,这样类似于 Select count(*) from person where age >= 8 and age <= 25 的查询就可以直接使用 IndexCountOperator 来获取结果,无需朴素地调用 IndexScanOperator -> AggregateOperator对索引进行扫描才得出结果。
高并发
并发是低延时场景一个关键挑战。
解法通常分为两种,
一种是在存储引擎内部支持更细粒度的锁或者无锁的并发请求;还有一种是在存储引擎外部来进行线程模型的优化,
保证某一部分数据(一般来说是一个分区)只被一个线程处理,这样就能够在单线程引擎之上构建出高吞吐的能力。【使用这种方式需要满足一些假设:对每个 Partition 的访问是均衡的;跨 Partition 的访问比较少】
为了提升单机的处理能力,Tair 引入了 RCU 无锁引擎,实现内存 KV 引擎的无锁化访问,成倍提升了内存引擎的性能
超大连接数
连接数的限制是一个比较容易被忽略的约束。但在一个真实的系统中,连接数过多会给系统带来巨大的压力。比如说 Redis,即使在 6.0 支持了多 io 之后,能够支持的连接数也是有限的。而目前直接访问 Tair 的应用动辄有 100k 规模的容器数目,所以支持超多连接数是一个必选项。
其中涉及到的技术主要是几方面:
a. 提高多线程 io 的能力,目前成熟的网络框架基本都有这个能力;
b. 把 io 线程和 worker 线程解耦,这样可以独立增强 worker 的处理能力,避免对 io 产生阻塞,当然这个策略取决于 worker 的工作负载,对于单次处理延时稳定较小的场景,支持无锁并发后,整个链路使用 io 线程处理避免线程切换是更优的方案;
c. 轻量化连接,把关联到连接上的业务逻辑和 io 功能剥离开,可以更加灵活地做针对性的优化,一些系统中连接对资源的消耗较大,一个连接需要消耗 ~10M 的内存资源,这样连接数就比较难以扩展了。
水平扩展
HA-Group 同一个进程处理好几个分段,对于[0~1023]分段,该进程是leader, 而对于[1024~2047]分段,该进程是follower。从而实现整个集群负载相对比较均衡。
稳定性
热点
热点访问是商品维度、卖家维度的数据常常会遇到的一个挑战,热点方案也是 Tair 能够服务于低延时场景的关键能力。
前面讲了水平扩展之后,用户的某个请求就会根据一定的规则(Hash、Range、List 等)路由到某一个分区上,如果存在热点访问,就会造成这一个分区的访问拥塞。
处理热点有很多方案,比如二级散列,这种方案对于热点的读写可以做进一步拆分的场景是有用的,比如现在我们有一个卖家订单表,然后卖家 id 是分区列,则我们可以再以订单 id 做一次二级散列,解决一个大卖家导致的热点问题;目前淘宝大规模使用的 Tair 的 KV 引擎不满足使用二级散列的前提,一般来说商品的信息映射到 Tair 内就是某一个 Value,更新和读取都是原子的。
所以 Tair 目前使用的方案是在一层进行散列,借助于和客户端的交互,将热点数据分散到集群当中的其它节点,共同来处理这个热点请求,当然这种方案需要应用接受热点在一定时间内的延迟更新。另外这种方案需要客户端和服务端协同,需要应用升级到对应的客户端才能使用。
所以最新的 Tair 热点策略在兼容社区 Redis 的服务时使用了不同的方案,应用能够直接使用任一流行的开源客户端进行访问,因此需要在服务端提供独立的热点处理能力。目前的 Tair 热点能力是由 Proxy 来提供的,相对于 Tair 之前的方案,这种方案拥有更强大的弹性和更好的通用性。
流控
服务于多租户的数据库系统,解决资源隔离的问题通常需要对进行容量或者访问量的配额管理来保证 QoS。
即使服务于单租户的系统,也需要在用户有突发异常流量时,保证系统的稳定性,识别出异常流量进行限制,保证正常流量不受影响,比如 Tair 中对于 慢 SQL 识别和阻断。
再退一步,即使面对无法识别的异常流量,如果判断请求流量已经超过了服务的极限,按照正常的行为进行响应会对服务端造成风险,需要进行 Fast Fail,并保证服务端的可用性,达到可用性防御的目的,比如 Tair 在判断有客户端的 Output Buffer 超过一定内存阈值之后,就会强制 Kill 掉客户端连接;
在判断目前排队的请求个数或者回包占用的内存超过一定阈值之后,就会构造一个流控的回包并回复给客户端。
流控一般包含以下几部分内容:请求资源消耗的统计,这部分是为流控策略和行为提供数据支撑;流控的触发,一般是给资源消耗设定一个阈值,如果超过阈值就触发;流控的行为,这部分各个系统根据服务的场景会有较大的不同;最后的流控的恢复,也是就是资源消耗到达什么情况下解除流控。
流控一般包含以下几部分内容:请求资源消耗的统计,这部分是为流控策略和行为提供数据支撑;流控的触发,一般是给资源消耗设定一个阈值,如果超过阈值就触发;流控的行为,这部分各个系统根据服务的场景会有较大的不同;最后的流控的恢复,也是就是资源消耗到达什么情况下解除流控。
执行流程优化
经典的 NoSQL 系统,提供的 API 都是和服务端的处理流程非常耦合的,比如说 Redis 提供了很多 API,光是 List 就有 20 多个接口。在服务端其实很多接口的执行过程中的步骤是比较类似的,比如说有一些 GenericXXX 的函数定义。我们再看看一般的 RDBMS 中的处理 SQL 的流程,一般是 解析(从 SQL 文本到 AST),然后是优化器编译 (把 AST 编译成算子,TableScan、Filter、Aggregate),然后是执行器来执行。
类比到 Redis 中,用户传进来的就是 AST,且服务端已经预定了执行计划,直接执行就行了。如果我想使用 SQL,不想学习这么多 API,同时由于我的访问场景是比较固定的,比如进行模板化之后,只有十多种 SQL 语句,且访问的数据比较均衡,某一条特定的语句所有的参数用一条特定的索引就足够了,有没有办法在执行过程中省去解析、编译的开销来提高运行的效率?
有很多同学可能已经想到了存储过程。是的,存储过程很多场景是在扩充表达能力,比如多条语句组成的存储过程,需要进行比较复杂的逻辑判断,单条语句存储过程本质上是在灵活性和性能上进行折衷。Tair 所有线上运行的 SQL 都是预先创建存储过程的,这样进行访问就类似于调用 Redis 的一个 API 了,这是在复杂计算逻辑的场景下保证低延时的一种方案。