概述

Tair是淘宝团队开源的高可用分布式KV存储引擎,采用服务端自动负载均衡方式,使客户端逻辑简单。Tair,即TaoBao Pair缩写,Pair表示一对、一双等意思,即Key-Value数据对。

Tair分为持久化和非持久化两种方式。非持久化Tair可看成是一个分布式缓存;持久化Tair将数据存放于磁盘中。支持以下4种存储引擎:

  • 非持久化:mdb
  • 持久化:fdb、kdb和ldb

这4种存储引擎分别基于四种开源的KV数据库:Memcached、Firebird、Kyoto Cabinet和LevelDB。Firebird是关系型数据库,另外三个是NoSQL数据库。

架构

Tair架构图

Tair简介_存储引擎


包括Client、ConfigServer和DataServer三个不同的应用。ConfigServer通过和DataServer的心跳(即HeartBeat)维护集群中可用的节点,并根据可用的节点,构建数据的在集群中的分布信息。Client在初始化时,从ConfigServer处获取数据的分布信息,根据分布信息和相应的DataServer交互完成用户的请求。DataServer负责数据的存储,并按照ConfigServer的指示完成数据的复制和迁移工作。

ConfigServer

ConfigServer维护集群内DataServer信息,可用DataServer的信息,以及用户配置的桶数量、副本数、机房信息等,构建数据分布的对照表,以达到负载均衡和高可用的目标。ConfigServer和client相互配合根据对照表决定数据的具体分布。如果DataServer宕机或扩容,ConfigServer负责协调数据迁移、管理进度,将数据迁移到负载较小的节点上。

Tair客户端和ConfigServer的交互主要是为了获取数据分布的对照表,客户端从ConfigServer拿到对照表后,会在本地缓存对照表,在需要存储/获取数据时根据对照表查找数据在哪个DataServer上。由此也可以看出,数据访问请求不需要和ConfigServer交互,所以ConfigServer本身的性能高低并不会形成集群的瓶颈。

ConfigServer维护的对照表有版本概念,由于集群变动或管理触发,构建新的对照表后,对照表的版本号递增,并通过DataServer的心跳,将新表同步给数据节点。

客户端和DataServer交互时,DataServer每次都把自己缓存的对照表版本号放入response结构中,返回给客户端,客户端将DataServer的对照表版本号和自己缓存的对照表版本号比较,如果不相同,会主动和ConfigServer通信,请求新的对照表。

ConfigServer使客户端使用时,不需要配置数据节点列表,也不需要处理节点的状态变化,这使得Tair对最终用户来说使用和配置都很简单。

DataServer

DataServer负责数据的物理存储,并根据ConfigServer构建的对照表完成数据的复制和迁移工作。DataServer具备抽象的存储引擎层,可以很方便地添加新存储引擎。DataServer还有一个插件容器,可以动态地加载/卸载插件。

DataServer逻辑架构图

Tair简介_缓存_02

自动复制和迁移

为了增强数据的安全性,Tair支持配置数据的备份数。比如你可以配置备份数为3,则每个数据都会写在不同的3台机器上。得益于抽象的存储引擎层,无论是作为cache的mdb,还是持久化的fdb,都支持可配的备份数。

当数据写入一个DataServer主节点后,主节点会根据对照表自动将数据写入到其他备份节点,整个过程对用户是透明的。

当有新DataServer加入或有DataServer不可用时,ConfigServer会根据当前可用的DataServer列表,重新构建对照表。DataServer获取到新的对照表时,会自动将在新表中不由自己负责的数据迁移到对照表中相应的DataServer。迁移完成后,客户端可以从ConfigServer同步到新的对照表,完成扩容或者容灾过程。整个过程对用户是透明的,服务不中断。

插件容器

Tair内置一个插件容器,支持热插拔插件。插件由ConfigServer配置,ConfigServer会将插件配置同步给各个数据节点,数据节点会负责加载/卸载相应的插件。插件分为request和response两类,可以分别在request和response时执行相应的操作,如在put前检查用户的quota信息等。插件容器也让Tair在功能方便具有更好的灵活性。

DataServer最主要组成模块有tair_serverrequest_processortair_managerstorage_manager和各种存储的具体实现实现。

源码

目录结构良好:

Tair简介_数据_03

ConfigServer

ConfigServer源代码目录下主要有下面几个cpp文件:

  • tair_cfg_svr.cppserver_conf_thread.cpp:ConfigServer的主文件,tair_cfg_svr被执行后,会检查参数和配置,然后启动几个主要线程:
  • task_queue_thread:处理请求的具体线程;
  • packet_transport:发送和接收命令数据包的线程,其中引用tbnet公共包处理epoll;
  • server_conf_thread:ConfigServer的主要业务逻辑实现线程。包括ConfigServer之间的心跳保持,根据心跳维持DataServer存活列表,维护对照表,数据迁移过程管理等逻辑;
  • heartbeat_transport:发送和接收心跳数据包的线程,其中引用tbnet公共包处理epoll。
  • conf_server_table_manager.cpp:管理对照表的辅助类,提供对照表持久化、取得一些元信息等功能,还提供打印对照表的功能,方便调试。
  • table_builder.cpp:包括3个文件,实际的对照表构建过程是由server_conf_thread::table_builder_thread::build_table触发,其中:
  • table_builder:基类,定义构造对照表的主体逻辑,其中有几个虚函数:rebuild_tableset_available_serveris_this_node_OKcaculate_capableget_tokens_per_node用于不同的构造实现扩展不同的逻辑;
  • table_builder1:构建负载均衡策略对照表的实现类,继承table_builder类,对几个虚函数进行基于负载均衡优先的逻辑实现;
  • table_builder2:构建多数据中心策略对照表的实现类,继承table_builder类,对几个虚函数进行基于位置和负载均衡双因子的逻辑实现。
  • group_info.cppgroup_info负责处理group.conf和持久化文件$TAIR_DATA_DIR/data/group_1_server_table,通过读取配置和持久化的信息,构建DataServer位置信息,供ConfigServer主逻辑使用。
  • server_info:记录DataServer存活信息的主要数据结构,server_info会被持久化到$TAIR_DATA_DIR/data/server_info.0中。server_info由下面几个部分组成:
  • serverid:DataServer在集群里的唯一标识,由ip和port构成;
  • last_time:记录最后一次接收到该DataServer心跳时间;
  • status:表示该DataServer的状态,有三种状态:ALIVE、DOWN、FORCE_DOWN。
  • server_info_file_mapper.cpp、server_info_allocator.cpp:实现server_info持久化逻辑。持久化的文件存放在$TAIR_DATA_DIR/data目录下,server_info_allocator维护server_info持久化化文件集合和其中包含的server_info数量。如果当前文件没有空间来存储新server_info,则新建一个序列化文件。
  • stat_info_detail.cpp:存储统计信息,主要的数据结构vector<u64> data_holder,包含GETCOUNT,PUTCOUNT,EVICTCOUNT,REMOVECOUNT,HITCOUNT,DATASIZE,USESIZE,ITEMCOUNT。

DataServer

通过重载的process函数,处理put、get、range等请求。request_processor.cpp定义每种请求的最高层执行流程,每个请求的大体流程都相似,request_processor处理流程:

Tair简介_缓存_04


如上图,Tair接收到请求后,会循环处理每一个key,如果key在迁移,会发送数据迁移的响应给客户端,客户端重新获取数据分布后,到新的DataServer操作相应的数据。处理过程中会调用性能监控工具,统计相应的性能数据。

数据结构

mdb的存储数据结构:

struct mdb_item {
	uint64_t h_next;
	uint64_t prev;
	uint64_t next;
	uint32_t exptime;
	uint32_t key_len;
	uint32_t data_len;
	uint16_t version;
	uint32_t update_time;
	uint64_t item_id;
	char data[0];
};

mdb的存储数据结构:

struct LdbItemMetaBase {
	uint8_t  meta_version_;
	uint8_t  flag_;
	uint16_t version_;
	uint32_t cdate_; // create time
	uint32_t mdate_; // modify time
	uint32_t edate_; // expired time
};

kdb的存储数据结构:

struct kdb_item_meta {
	uint8_t  flag;
	uint8_t  reserved;
	uint16_t version;
	uint32_t cdate;
	uint32_t mdate;
	uint32_t edate;
};

fdb的存储数据结构:

typedef struct fdb_item_meta {
	uint16_t magic;
	uint16_t checksum;
	uint16_t keysize; // key size max: 64KB
	uint16_t version;
	uint32_t prefixsize;
	uint32_t valsize: 24;
	uint8_t  flag; // for extends
	uint32_t cdate;
	uint32_t mdate;
	uint32_t edate;
};

高可用

Tair的高可用,主要通过对照表和数据迁移两大功能进行支撑。

对照表

分布式系统的一个核心问题:数据在集群中的分布策略,好的策略应该能将数据均衡地分布到所有节点上,且能适应集群节点的增减变化。

对照表将数据分为若干个桶,并根据机器数量、机器位置进行负载均衡和副本放置,确保数据分布均匀,并且在多机房有数据副本。在集群发生变化时,会重新计算对照表,并进行数据迁移。

Tair基于一致性Hash算法存储数据,根据配置建立固定数量的bucket,将这些bucket尽量均衡分配到DataServer节点上,并建立副本。

ConfigServer启动后,会等待4秒,然后根据有连接和心跳的状态,检查DataServer是否在线,然后决定是否重建对照表,DataServer需要在ConfigServer启动之前启动。

对照表的初始化

过程如下:在tair_cfg_svr程序启动后,会调用tair_config_server::start(),这个方法会调用my_server_conf_thread.start()my_server_conf_thread有个属性table_builder_threadbuilder_thread,这个类在构造方法里,会调用自己的start方法,把自己启动为一个线程。这个线程会每秒钟检查一次是否需要重新构造对照表。如果需要重新构造,就调用组对象的rebuild方法重新构建对照表。

ConfigServer会定期调用server_conf_thread::check_server_status()方法检查是否需要重建对照表或有节点变动。第一次启动时,由于没有原有的对照表,所以check_server_status调用group_info::is_need_rebuild()时,会固定返回true。因此第一次启动时会根据在线的服务器列表重构对照表。

重建对照表有三种策略选择,可通过group.conf中的_build_strategy=num的配置项进行配置:

  • num=1:表示所有机器不分机房;
  • num=2:表示按照机房分组;
  • num=3:表示让ConfigServer自动决定使用哪种模式。

在设置为自动选择模式时,根据_pos_mask设置的值,检测DataServer所在机房,如果有机器分布在不同机房,且不同机房的服务器数量差不大于_build_diff_ratio配置项指定的差异率,则使用策略类型2,否则使用策略类型1;如果没有分布在不同机房的机器,则使用策略类型1。

机房之间的差异算法:假设有两个机房A和B,配置差异比率_build_diff_ratio=0.5。假设机房A有8台DataServer,机房B有4台DataServer,差异比率=(8-4)/8=0.5。此时满足条件。如果后续对机房A进行扩容,增加一台DataServer,扩容后的差异比率=(9-4)/9=0.556,即对DataServer多的机房扩容会扩大差异比率。如果_build_diff_ratio配置值是0.5,那扩容后ConfigServer会拒绝再继续build新表。如果正在做数据迁移,则调用p_table_builder->build_quick_table(),否则调用p_table_builder->rebuild_table()重建对照表。

负载均衡策略

由于允许数据存放多备份,某个桶最多会存储copyCount次(本例用Y表示),也就是说集群中存在Y个bucket的内容是完全一样的,这些一样的数据桶,我们将其中的一个叫作主桶,下面的推演实例为方便和代码对照,主桶都存放在line0中。

采用负载均衡策略(_build_strategy=1)建出的对照表将使bucket会均衡分布到集群中的DataServer上。假设共有X个桶,Y个副本,N个节点,那么在负载均衡优先的策略下,每个节点存放桶最少的个数为:(XY)/N。如果(XY)%N等于0,每个节点就会存放相同数量的桶。如果(XY)%N不等于0,那么将有(XY)%N个节点将负载(X*Y)/N+1个桶。也就是说,如果使用这种策略,任意两个节点存放的桶数量至多相差1。

在使用负载均衡策略构建对照表的时候,会按照约束级别调用table_builder1::is_this_node_OK函数,决定一个DataServer是否适合存储某个桶。约束级别有四种:

  • CONSIDER_ALL = 0;
  • CONSIDER_POS = 1;
  • CONSIDER_BASE = 2;
  • CONSIDER_FORCE = 3。

函数的四个约束:

  • c1:如果DataServer存储主桶的个数,超过计算出来的主桶容量,就会返回TOOMANY_MASTER;
  • c2:如果DataServer存储的桶的总个数,超过计算出来的容量,就会返回TOOMANY_BUCKET;
  • c3:如果DataServer存储的桶数量超过Tair简介_存储引擎_05,且存储Tair简介_存储引擎_06个桶的DataServer数量超过计算出的最大个数+1,会返回TOOMANY_BUCKET;
  • c4:存储相同数据的任何两个桶不能在同一个DataServer上,如果违反此条,会返回SAME_NODE。

主桶和副本桶检查约束的规则如表

正在迁移的是主副桶?

ALL

POS

BASE

FORCE

主桶

c1、c4

c1、c4

c1

副本桶

c2、c4

c2、c4

c3、c4

c4

主桶没有宕机的情况下,检查当前DataServer上的主桶数量是否过多,如果数量过多,则轮询每个节点,查看如果主桶迁移到该DataServer,是否引起数量过多,或者和自己的副本在同一个节点;主数据桶宕机的情况下,副本桶进行提升,如果即将存放主桶的DataServer存放的主桶数量过多,则轮询每个节点,查看如果主桶迁移到该DataServer,是否引起数量过多,或者和自己的副本在同一个节点。

多机架支持

Tair的设计考虑多机架支持,在机架/机房灾难时,确保异地有数据备份。假设搭建Tair集群后,配置数据副本数为3,搭建5个DataServer分布在两个机架上。Tair在建立对照表时,会确保每一份数据至少在两个机架的DataServer上至少有一个副本,如果数据在某一个机架上有两份副本,Tair会尽量使这两份副本分布在不同的DataServer上。

多机架情况下,调用is_this_node_OK判断某个副本是否适合存放在某个DataServer上时,Tair会考虑机架信息和所在机架各个DataServer之间的负载均衡。主要考虑点如下:

  • DataServer存储主副本数量不超过master_server_capable中计算的上限;
  • DataServer存储副本总数量不超过server_capable中计算的上限;
  • 某个机架上总共存储N个副本,共有C个DataServer,存储Tair简介_缓存_07个副本的DataServer个数不超过上限;
  • 主副本与其备份副本不能存储在同一个DataServer上;
  • 主副本与其备份副本不能存储在同一个机架上。

数据迁移

Tair每次重新构造对照表之后,会将新的对照表发送给DataServer,DataServer拿到新的对照表后,通过计算,如果发现需要迁移的数据列表不为空,则通过migrate_manager::set_migrate_server_list方法,把迁移列表写入migrate_manager的迁移列表里。迁移完成后,DataServer向ConfigServer发送迁移完成的消息。

migrate_manager是DataServer启动后就启动的一个线程,不断扫描自己的迁移表,发现迁移表不为空的时候,就进行数据迁移工作。具体迁移逻辑在migrate_manager::do_migrate_one_bucket方法里,主要逻辑是,开始迁移数据时,设置current_migrating_bucket为当前正在迁移的桶id,之后DataServer写入这个桶时,都会写入redolog。然后migrate_manager开始迁移内存中桶的数据(或ldb文件中的数据)。数据迁移完成后,迁移redolog。redolog迁移完成后,将这个桶标记为迁移完成,并把迁移完成信息发送给ConfigServer。

存储引擎

storage_manager,Tair的抽象存储引擎层,只要满足存储引擎需要的接口,便可以很方便地替换Tair底层的存储引擎。如有需要,可对bdb、tc甚至MySQL进行包装作为存储引擎,而同时使用Tair的分布式、同步等特性。

Tair默认包含四个存储引擎:

  • mdb:高效的缓存存储引擎,它有着和Memcached类似的内存管理方式。mdb支持使用share memory,使得在重启Tair数据节点的进程时不会导致数据丢失,使应用升级更平滑,不会导致命中率的较大波动。
  • fdb:简单高效的持久化存储引擎,使用树的方式根据数据key的Hash值索引数据,加快查找速度。索引文件和数据文件分离,尽量保持索引文件在内存中,以便减小IO开销。使用空闲空间池管理被删除的空间。
  • ldb:LevelDB是Google开源的快速轻量级的单机KV存储引擎。基本特性:
  • 提供KV支持,key和value是任意的字节数组;
  • 数据按key内部排序;
  • 支持批量修改(原子操作)。
  • kdb:Kyoto Cabinet是一个数据库管理的lib,是Tokyo Cabinet的改进版本。数据库是一个简单的包含记录的数据文件,每个记录是一个键值对,key和value都是变长的字节序列。key和value可以是二进制、文本字符串。数据库中的key必须唯一。数据库既没有表的概念,也不存在数据类型。所有的记录被组织为Hash表或B+树。Kyoto Cabinet的运行速度非常快,例如保存一百万记录到Hash数据库中只需要0.9秒,保存到B+ tree数据库只需要1.1秒。且数据库本身还非常小。Hash数据库的每个记录头只有16字节,B+ tree数据库是4字节。Kyoto Cabinet的伸缩性非常好,数据库大小可以增长到8EB。

mdb

Tair默认使用MDB存储数据,MDB是一个内存K/V存储引擎,有着类似Memcached的内存管理模式。

mdb结构图

Tair简介_缓存_08


四个主要的数据结构为:

  • mem_pool:用于管理内存;
  • mem_cache:用于管理slab;
  • cache_hash_map:用于存储Hash表;
  • mdb_area_stat:用于维护area状态。

mem_pool主要用于内存管理,Tair通过将内存分为若干个page管理内存。每个page的大小是1MB,page的个数由Tair根据slab_mem_size配置设置,单位MB。上图中的例子设置为2048,即2GB。Tair代码中定义最大page数量MAX_PAGES_NO = 65536限制,单个DataServer节点最多可使用64GB内存。mem_pool里存储当前已经占用的page、未分配的page。

mem_cache中主要存放slab_manager列表。slab_manager主要用于管理各个item,每个slab_manager中管理相同大小的数据块,存储在Tair中的数据,最终存储在这些块里,也就是item。

Tair中限制最大slab个数为100(TAIR_SLAB_LARGEST),每个slab的数据块大小按照mdb_param::factor(值为1.1)递增。最小slab中数据块大小cache_info->base_size=ALIGN(sizeof(mdb_item)+16),为64字节,可存储16字节数据,slab最大可存储881920约800kB字节每个item的数据。slab_manager中会分配page,然后将数据写入page中。

cache_hash_map,主要存储一个HashTable,按照数据key进行Hash,Hash冲突时,产生一个链表。

mdb_area_stat:维护area的相关数据,主要记录area的数据量限制和属于某个area的所有数据的链表。在写入数据时,会检查area数据量限制,如果数据量达到上限,则会循环50次;检查是否有过期数据,如果找到,则逐出。如果50次都没有找到过期数据,则将最后一个数据逐出。

被逐出的数据,有两种可能:

  • 如果配置evict_data_path选项,被逐出的数据会记入文件;
  • 没有配置,数据直接被逐出。

area中记录的所有数据链表,用于执行clear操作。

API

Tair为客户端提供丰富的API支持,主要分为:

  • KV操作API:普通KV操作,和Redis操作很相似;
  • Prefix操作API:类似Redis Hash数据结构。

KV

几个示例(没必要完全列举):

  • getHidden(short ns, byte[] key, TairOption opt):用于获取被标记为隐藏的key;
  • put(short ns, byte[] key, byte[] value, TairOption opt):设置KV;
  • hideByProxy(short ns, byte[] key, TairOption opt):隐藏某个key。

解读:ns表示namespace或area,K和V都是byte数组,TairOption表示参数设置(如version、expire)。

Prefix

几个示例:

  • prefixPut(short ns, byte[] pkey, byte[] skey, byte[] value,TairOption opt):设置KV;
  • prefixGetHidden(short ns, byte[] pkey, byte[] skey,TairOption opt):取得隐藏的KV;

原理:Tair在接收到含有prefix的请求后,会按照prefix计算Hash,因此同一个namespace下的同一个prefix,会Hash到同一个HashTable位置,形成一个链表。后续通过prefix操作时,都是操作这个链表。

Range

几个示例:

  • getRange(short ns, byte[] pkey, byte[] begin, byte[] end, int offset, int maxCount, boolean reverse, TairOption opt):按照前缀匹配取得prefix的子KV;
  • getRangeKey(short ns, byte[] pkey, byte[] begin, byte[] end, int offset, int maxCount, boolean reverse, TairOption opt):按照前缀匹配取得prefix的子key;
  • getRangeValue:参数同上,按照前缀匹配取得prefix的子key对应的value。

mdb、fdb、kdb引擎不支持range操作,需要更换为ldb引擎。

Version

Tair中的每个数据都包含版本号,版本号在每次更新后都会递增。这个特性可防止数据的并发更新导致的问题。

Tair使用不同的存储引擎时,存储的数据结构里,都会有一个版本号。参考上面的数据结构部分。

在执行put操作时,会首先把原来存储的数据拿出来,对比version如果version不匹配,返回错误;如果version匹配,则更新数据,并增加版本号。如果不希望使用version匹配,可以传入0:

else if(version_care && version ! = 0
        && it->version ! = static_cast<uint32_t> (version)) {
	TBSYS_LOG(WARN, "it->version(%hu) ! = version(%hu)", it->version, key.get_version());
	return TAIR_RETURN_VERSION_ERROR;
}

参考

  • 深入分布式缓存:从原理到实践