概述
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架构图
包括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支持配置数据的备份数。比如你可以配置备份数为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_server
、request_processor
、tair_manager
、storage_manager
和各种存储的具体实现实现。
源码
目录结构良好:
ConfigServer
ConfigServer源代码目录下主要有下面几个cpp文件:
tair_cfg_svr.cpp
、server_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_table
、set_available_server
、is_this_node_OK
、caculate_capable
、get_tokens_per_node
用于不同的构造实现扩展不同的逻辑; -
table_builder1
:构建负载均衡策略对照表的实现类,继承table_builder
类,对几个虚函数进行基于负载均衡优先的逻辑实现; -
table_builder2
:构建多数据中心策略对照表的实现类,继承table_builder
类,对几个虚函数进行基于位置和负载均衡双因子的逻辑实现。
-
group_info.cpp
:group_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接收到请求后,会循环处理每一个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存储的桶数量超过,且存储个桶的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,存储个副本的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结构图
四个主要的数据结构为:
-
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;
}
参考
- 深入分布式缓存:从原理到实践