2 分布式存储引擎

分布式存储引擎层负责处理分布式系统中的各种问题,例如数据分布、负载均衡、容错、一致性协议等。与其它NOSQL系统类似,分布式存储引擎层支持根据主键更新、插入、删除、随机读取以及范围查找等操作,数据库功能层构建在分布式存储引擎层之上。

分布式存储引擎层包含三个模块:RootServer、UpdateServer和ChunkServer。

  • RootServer用于整体控制,实现Tablet分布、副本复制、负载均衡、机器管理以及Schema管理。

  • UpdateServer用于存储增量数据,数据结构为一颗内存B+树,并通过主备实时同步实现高可用,另外,UpdateServer的网络框架也经过专门的优化。

  • ChunkServer用于存储基准数据,基准数据按照主键有序划分为一个一个Tablet,每个Tablet在ChunkServer上存储了一个或者多个SSTable,另外,定期合并的主要逻辑也由ChunkServer实现。

OceanBase实现时也采用了很多技巧,例如Group Commit、双缓冲区预读、合并限速、内存管理等。本章将介绍分布式存储引擎的实现以及涉及到的实现技巧。

2.1 RootServer实现机制

RootServer是OceanBase集群对外的窗口,客户端通过RootServer获取集群中其它模块的信息。RootServer实现的功能包括:

  • 管理集群中的所有ChunkServer,处理ChunkServer上下线。
  • 管理集群中的UpdateServer,实现UpdateServer选主。
  • 管理集群中Tablet数据分布,发起Tablet复制、迁移以及合并等操作。
  • 与ChunkServer保持心跳,接受ChunkServer汇报,处理Tablet分裂与合并。
  • 接受UpdateServer汇报的大版本冻结消息,通知ChunkServer执行定期合并。
  • 实现主备RootServer,数据强同步,支持主RootServer宕机自动切换。

2.1.1 数据结构

RootServer的中心数据结构为一张存储了Tablet数据分布的有序表格,称为RootTable。每个Tablet存储的信息包括:Tablet主键范围、Tablet各个副本所在ChunkServer的编号、Tablet各个副本的数据行数、占用的磁盘空间、CRC校验值以及基准数据版本。

RootTable是一个读多写少的数据结构,除了ChunkServer汇报、RootServer发起Tablet复制、迁移以及合并等操作需要修改RootTable外,其它操作都只需要从RootTable中读取某个Tablet所在的ChunkServer。因此,OceanBase设计时考虑以写时复制的方式实现该结构.另外,考虑到RootTable修改特别少,实现时没有采用支持写时复制的B+树或者Skip List,而是采用相对更加简单的有序数组,以减少工作量。

往RootTable增加Tablet信息的操作步骤如下:

  1. 拷贝当前服务的RootTable为新的RootTable。
  2. 将Tablet信息追加到新的RootTable,并对新的RootTable重新排序。
  3. 原子地修改指针使得当前服务的RootTable指向新的RootTable。

ChunkServer一次汇报一批Tablet(默认一批包含1024个),如果每个Tablet修改都需要拷贝整个RootTable并重新排序,性能上显然无法接受。RootServer实现时做了一些优化:拷贝当前服务的RootTable为新的RootTable后,将ChunkServer汇报的一批Tablet一次性追加到新的RootTable中并重新排序,最后再原子地切换当前服务的RootTable为新的RootTable。采用批处理优化后,RootTable的性能基本满足需求,OceanBase单个集群支持的Tablet个数最大达到几百万个。当然,这种实现方式并不优雅,后续我们会将RootTable改造为B+树或者Skip List。

ChunkServer汇报的Tablet信息可能和RootTable中记录的不同,比如发生了Tablet分裂。此时,RootServer需要根据汇报的Tablet信息更新RootTable。

图2-1所示,假设原来的RootTable包含四个Tablet:r1(min, 10],r2(10, 100],r3(100, 1000],r4(1000, max]。ChunkServer汇报的Tablet列表为:t1(10, 50],t2(50, 100],t3(100, 1000],表示r2发生了Tablet分裂,那么,RootServer会将RootTable修改为:r1(min, 10],r2(10, 50],r3(50, 100],r4(100, 1000],r5(1000, max]。

图2-1 RootTable修改

2.1

RootServer中还有一个管理所有ChunkServer信息的数组,称为ChunkServerManager。数组中的每个元素代表一台ChunkServer,存储的信息包括:机器状态(已下线、正在服务、正在汇报、汇报完成等)、启动后注册时间、上次心跳时间、磁盘相关信息、负载均衡相关信息。OceanBase刚上线时依据每台ChunkServer磁盘占用信息执行负载均衡,目的是为了尽可能确保每台ChunkServer占用差不多的磁盘空间。上线运行一段时间后发现这种方式效果并不好。目前的方式为按照每个表格的Tablet个数执行负载均衡,目的是尽可能保证对于每个表格,每台ChunkServer上的Tablet个数大致相同。

2.1.2 Tablet复制与负载均衡

RootServer中有两种操作都可能触发Tablet迁移:Tablet复制(rereplication)和负载均衡(rebalance)。当某些ChunkServer下线超过一段时间后,为了防止数据丢失,需要拷贝副本数小于阀值的Tablet。另外,系统也需要定期执行负载均衡,将Tablet从负载较高的机器迁移到负载较低的机器。

每台ChunkServer记录了Tablet迁移相关信息,包括:ChunkServer上Tablet的个数、所有Tablet的大小总和、正在迁入的Tablet个数、正在迁出的Tablet个数和Tablet迁移任务列表。RootServer包含一个专门的线程定期执行Tablet复制与负载均衡任务。

  • Tablet复制
    扫描RootTable中的Tablet,如果某个Tablet的副本数小于阀值,则选取一台包含该Tablet副本的ChunkServer为迁移源,另外一台符合要求的ChunkServer为迁移目的地,生成Tablet迁移任务。迁移目的地需要符合一些条件有:不包含待迁移Tablet、服务的Tablet个数小于平均个数减去可容忍个数(默认值为10)、正在进行的迁移任务不超过阀值等。

  • 负载均衡
    扫描RootTable中的Tablet,如果某台ChunkServer包含的某个表格的Tablet个数超过平均个数以及可容忍个数(默认值为10)之和,则以这台ChunkServer为迁移源,并选择一台符合要求的ChunkServer为迁移目的地,生成Tablet迁移任务。

Tablet复制和负载均衡生成的Tablet迁移并不会立即执行,而是会加入到迁移源的迁移任务列表中。RootServer中的一个后台线程会扫描所有的ChunkServer,然后执行每台ChunkServer的迁移任务列表中保存的迁移任务。Tablet迁移时限制了每台ChunkServer同时进行的最大迁入和迁出任务数,从而防止一台新的ChunkServer刚上线时,迁入大量Tablet而负载过高。

例如:某OceanBase集群中包含4台ChunkServer:ChunkServer1(包含Tablet A1,A2,A3),ChunkServer2(包含Tablet A3,A4),ChunkServer3(包含Tablet A2),ChunkServer4(包含Tablet A4)。 假设Tablet副本数配置为2,最多能够容忍的不均衡Tablet的个数为0。

  1. RootServer后台线程首先执行Tablet复制,发现Tablet A1只有一个副本,于是,将ChunkServer1作为迁移源,并选择一台ChunkServer作为迁移目的地(假设为ChunkServer3),生成迁移任务<ChunkServer1,ChunkServer3,A1>。
  2. 执行负载均衡,发现ChunkServer1包含3个Tablet, 超过平均值(平均值为2),而ChunkServer4包含的Tablet个数小于平均值。
  3. 将ChunkServer1作为迁移源, ChunkServer4作为迁移目的地,选择某个Tablet(假设为A2),生成迁移任务<ChunkServer1,ChunkServer4,A2>。
  4. 如果迁移成功,A2将包含3个副本,可以通知ChunkServer1删除上面的A2副本。
    此时,Tablet分布情况为:ChunkServer1(包含Tablet A1,A3),ChunkServer2(包含Tablet A3,A4),ChunkServer3(包含Tablet A1,A2),ChunkServer4(包含Tablet A2,A4)。 每个Tablet包含2个副本,且平均分布在4台ChunkServer上。

2.1.3 Tablet分裂与合并

Tablet分裂由ChunkServer在定期合并过程中执行。由于每个Tablet包含多个副本,且分布在多台ChunkServer上,如何确保多个副本之间的分裂点保持一致成为问题的关键。OceanBase采用了一种比较直接的做法:每台ChunkServer使用相同的分裂规则。由于每个Tablet的不同副本之间的基准数据完全一致,且定期合并过程中冻结的增量数据也完全相同,只要分裂规则一致,分裂后的Tablet主键范围也保证相同。

OceanBase曾经有一个线上版本的分裂规则如下:只要定期合并过程中产生的数据量超过256MB,就生成一个新的Tablet。假设定期合并产生的数据量为257MB,那么最后将分裂为两个Tablet,其中,前一个Tablet(记为r1)的数据量为256MB,后一个Tablet(记为r2)的数据量为1MB。接着,r1接受新的修改,数据量很快又超过256MB,于是,又分裂为两个Tablet。系统运行一段时间后,充斥着大量数据量很少的Tablet。

为了解决分裂产生小Tablet的问题,需要确保分裂以后的每个Tablet数据量大致相同。OceanBase对每个Tablet记录了两个元数据:数据行数row_count以及Tablet大小(occupy_size)。根据这两个值,可以计算出每行数据的平均大小,即:occupy_size/row_count。 根据数据行平均大小,可以计算出分裂后的Tablet行数,从而得到分裂点。

Tablet合并过程如下:

  1. 合并准备:RootServer选择若干个主键范围连续的小Tablet。
  2. Tablet迁移:将待合并的若干个小Tablet迁移到相同的ChunkServer机器。
  3. Tablet合并:往ChunkServer机器发送Tablet合并命令,生成合并后的Tablet范围。

例如: 某OceanBase集群中有3台ChunkServer:ChunkServer1(包含Tablet A1,A3),ChunkServer2(包含Tablet A2,A3),ChunkServer3(包含Tablet A1,A2),其中,A1和A2分别为10MB,A3为256MB。

  1. RootServer扫描RootTable后发现A1和A2满足Tablet合并条件,则发起Tablet迁移。
  2. 假设将A1迁移到ChunkServer2,使得A1和A2在相同的ChunkServer上。
  3. 分别向ChunkServer2和ChunkServer3发起Tablet合并命令。
    Tablet合并完成以后,Tablet分布情况为:ChunkServer1(包含Tablet A3),ChunkServer2(包含Tablet A4(A1, A2),A3),ChunkServer3(包含Tablet A4(A1, A2)),其中,A4是Tablet A1和A2合并后的结果。

说明:每个Tablet包含多个副本,只要某一个副本合并成功,OceanBase就认为Tablet合并成功,其它合并失败的Tablet将通过垃圾回收机制删除掉。

2.1.4 UpdateServer选主

为了确保一致性,RootServer需要确保每个集群中只有一台UpdateServer提供写服务,这个UpdateServer称为主UpdateServer。

RootServer通过租约(Lease)机制实现UpdateServer选主。主UpdateServer必须持有RootServer的租约才能提供写服务,租约的有效期一般为3~5秒。正常情况下,RootServer会定期给主UpdateServer发送命令,延长租约的有效期。如果主UpdateServer出现异常,RootServer等待主UpdateServer的租约过期后才能选择其它的UpdateServer为主UpdateServer继续提供写服务。

RootServer可能需要频繁升级,升级过程中UpdateServer的租约将很快过期,系统也会因此停服务。为了解决这个问题,RootServer设计了优雅退出的机制,即RootServer退出之前给UpdateServer发送一个有效期超长的租约(比如半小时),承诺这段时间不进行主UpdateServer选举,用于RootServer升级。

2.1.5 RootServer主备

每个集群一般部署一主一备两台RootServer,主备之间数据强同步,即所有的操作都需要首先同步到备机,接着修改主机,最后才能返回操作成功。

RootServer主备之间需要同步的数据包括:RootTable中记录的Tablet分布信息、ChunkServerManager中记录的ChunkServer机器变化信息以及UpdateServer机器信息。Tablet复制、负载均衡、合并、分裂以及ChunkServer和UpdateServer上下线等操作都会引起RootServer内部数据变化,这些变化都将以操作日志的形式同步到备RootServer。备RootServer实时回放这些操作日志,从而与主RootServer保持同步。

OceanBase中的其它模块,比如ChunkServer和UpdateServer,以及客户端通过VIP(Virtual IP)访问RootServer,正常情况下,VIP总是指向主RootServer。当主RootServer出现故障时,部署在主备RootServer上的Linux HA(heartbeat)软件能够检测到,并将VIP漂移到备RootServer。

Linux HA软件的核心包含两个部分:心跳检测部分和资源接管部分。心跳检测部分通过网络链接或者串口线进行,主备RootServer上的heartbeat软件相互发送报文来告诉对方自己当前的状态。如果在指定的时间内未收到对方发送的报文,那么就认为对方失败。这时需启动资源接管模块来接管运行在对方主机上的资源,这里的资源就是VIP。备RootServer后台线程能够检测到VIP漂移到自身,于是自动切换为主机提供服务。

2.2 UpdateServer实现机制

UpdateServer用于存储增量数据,它是一个单机存储系统,由如下几个部分组成:

  • 内存存储引擎:在内存中存储修改增量,支持冻结以及转储操作。
  • 任务处理模型:包括网络框架、任务队列、工作线程等,针对小数据包做了专门的优化。
  • 主备同步模块:将更新事务以操作日志的形式同步到备UpdateServer。

UpdateServer是OceanBase性能瓶颈点,核心是高效,实现时对锁(例如,无锁数据结构)、索引结构、内存占用、任务处理模型以及主备同步都需要做专门的优化。

2.2.1 内存存储引擎

UpdateServer存储引擎如图2-2所示。

图2-2 UpdateServer存储引擎

2.2

UpdateServer存储引擎与Bigtable存储引擎相似,不同点在于:

  1. UpdateServer只存储了增量更新数据,基准数据以SSTable的形式存储在ChunkServer上,而Bigtable存储引擎同时包含某个Tablet的基准数据和增量数据。
  2. UpdateServer内部所有表格共用MemTable以及SSTable,而Bigtable中每个Tablet的MemTable和SSTable分开存放。
  3. UpdateServer的SSTable存储在SSD盘中,而Bigtable的SSTable存储在GFS中。

UpdateServer存储引擎包含几个部分:操作日志、内存表(底层为一颗高性能的B+树)以及SSTable。更新操作首先记录到操作日志中,接着更新内存中活跃的MemTable(Active MemTable),活跃的MemTable到达一定大小后将被冻结,成为Frozen MemTable,同时创建新的Active MemTable。Frozen MemTable将以SSTable文件的形式转储到SSD盘中。

  • 操作日志
    OceanBase中有一个专门的提交线程负责确定多个写事务的顺序(即事务id)。将这些写事务的操作追加到日志缓冲区,并将日志缓冲区的内容写入日志文件。为了防止写操作日志污染操作系统的缓存,写操作日志文件采用Direct IO的方式实现。
  • MemTable
    MemTable底层是一颗高性能内存B+树。MemTable封装了B+树,对外提供统一的读写接口。

B+树中的每个元素对应MemTable中的一行操作,key为行主键,value为行操作链表的指针。每行的操作按照时间顺序构成一个行操作链表,如图2-3所示。

图2-3 MemTable内存结构

2.3

MemTable内存结构包含两部分:索引结构以及行操作链表,索引结构为B+树,支持插入、删除、更新、随机读取以及范围查询操作。行操作链表保存的是对某一行各个列(每个行和列确定一个单元,称为Cell)的操作,例如,对主键为1的商品有3个操作,分别是:将商品购买人数修改为100,删除该商品,将商品名称修改为“女鞋”。那么,该商品的行操作链中将保存三个Cell,分别为: <update,购买人数,100>、<delete,*> 以及<update,商品名,“女鞋”>。 也就是说,MemTable中存储的是对该商品的所有操作,而不是最终结果。另外,MemTable删除一行也只是往行操作链表的末尾加入一个逻辑删除标记,即<delete,*>,而不是实际删除索引结构或者行操作链表中的行内容。

MemTable实现时做了很多优化,包括:

  • Hash索引
    针对主要操作为随机读取的应用,MemTable不仅支持B+树索引,还支持Hash索引,UpdateServer内部会保证两个索引之间的一致性。

  • 内存优化
    行操作链表中每个cell操作都需要存储操作列的编号(column_id)、操作类型(更新操作还是删除操作)、操作值以及指向下一个cell操作的指针,如果不做优化,内存膨胀会很大。为了减少内存占用,MemTable实现时会对整数值进行变长编码,并将多个cell操作编码后序列到同一块缓冲区中,共用一个指向下一个cell操作缓冲区的指针。

    // 开启一个事务
    // @param =in] trans_type 事务类型,可能为读事务或者写事务    
    // @param =out] td 返回的事务描述符
    int start_transaction(const TETransType trans_type,   MemTableTransDescriptor& td); 
    
    // 提交或者回滚一个事务 // @param =in] td 事务描述符 // @param =in] rollback 是否回滚,默认为false int end_transaction(const MemTableTransDescriptor td, bool rollback = false);
    // 执行随机读取操作,返回一个迭代器 // @param =in] td 事务描述符 // @param =in] table_id 表格编号 // @param =in] row_key 待查询的主键 // @param =out] iter 返回的迭代器 int get(const MemTableTransDescriptor td, const uint64_t table_id, const ObRowkey& row_key, MemTableIterator& iter);
    // 执行范围查询操作,返回一个迭代器 // @param =in] td 事务描述符 // @param =in] range 查询范围,包括起始行、结束行,开区间或者闭区间 // @param =out] iter 返回的迭代器 int scan(const MemTableTransDescriptor td, const ObRange& range, MemTableIterator& iter);
    // 开始执行一次更新操作 // @param =in] td 事务描述符 int start_mutation(const MemTableTransDescriptor td);
    // 提交或者回滚一次更新操作 // @param =in] td 事务描述符 // @param =in] rollback 是否回滚 int end_mutation(const MemTableTransDescriptor td, bool rollback);
    // 执行更新操作 // @param =in] td 事务描述符 // @param =in] mutator 更新操作,包含一个或者多个对多个表格的cell操作 int set(const MemTableTransDescriptor td, ObUpsMutator& mutator);

    对于读事务,操作步骤如下:

    1. 调用start_transaction开始一个读事务,获得事务描述符。
    2. 执行随机读取或者扫描操作,返回一个迭代器。
    3. 调用end_transaction提交或者回滚一个事务。
    class MemTableIterator
       {
     public:
     // 迭代器移动到下一个cell
     int next_cell();
     // 获取当前cell的内容
     // @param [out] cell_info 当前cell的内容,包括表名(table_id),行主键(row_key),列编号(column_id)以及列值(column_value)
     int get_cell(ObCellInfo** cell_info);
     // 获取当前cell的内容
     // @param [out] cell_info 当前cell的内容
     // @param is_row_changed 是否迭代到下一行
     int get_cell(ObCellInfo** cell_info, bool * is_row_changed);
     };

    读事务返回一个迭代器MemTableIterator,通过它可以不断地获取下一个读到的cell。上面的例子中,读取编号为1的商品可以得到一个迭代器,从这个迭代器中可以读出行操作链中保存的3个Cell,依次为:<update,购买人数,100>,<delete,*>,<update,商品名,“女鞋”>。

    写事务总是批量执行,步骤如下:

    1. 调用start_transaction开始一批写事务,获得事务描述符。
    2. 调用start_mutation开始一次写操作。
    3. 执行写操作,将数据写入到MemTable中。
    4. 调用end_mutation提交或者回滚一次写操作;如果还有写事务,转到“步骤2”。
    5. 调用end_transaction提交写事务。
  • SSTable

    当活跃的MemTable超过一定大小或者管理员主动发起冻结命令时,活跃的MemTable将被冻结,生成冻结的MemTable,并同时以SSTable的形式转储到SSD盘中。

    SSTable的详细格式请参见本手册“2.3 ChunkServer实现机制”章节。与ChunkServer中的SSTable不同的是,UpdateServer中所有的表格共用一个SSTable,且SSTable为稀疏格式。也就是说,每一行数据的每一列可能存在,也可能不存在更新操作。

    另外,OceanBase设计时也尽量避免读取UpdateServer中的SSTable。只要内存足够,冻结的MemTable会保留在内存中。系统会尽快将冻结的数据通过定期合并转移到ChunkServer中去,以后不再需要访问UpdateServer中的SSTable数据。

2.2.2 任务处理模型

任务模型包括网络框架、任务队列和工作线程。UpdateServer最初的任务模型基于淘宝网实现的Tbnet框架(已开源,请参见http://code.taobao.org/p/tb-common-utils/src/trunk/tbnet/)。Tbnet封装得很好,使用比较方便,每秒收包个数最多可以达到接近10万,不过仍然无法完全发挥UpdateServer收发小数据包以及内存服务的特点。OceanBase后来采用优化过的任务模型Libeasy,小数据包处理能力得到进一步提升。

  • Tbnet
    图2-4所示,Tbnet队列模型本质上是一个“生产者-消费者”队列模型,有两个线程:网络读写线程和超时检查线程。其中,网络读写线程执行事件循环,当服务器端有可读事件时,调用回调函数读取请求数据包,生成请求任务,并加入到任务队列中。工作线程从任务队列中获取任务,处理完成后触发可写事件,网络读写线程会将处理结果发送给客户端。超时检查线程用于将超时的请求移除。
    图2-4 Tbnet队列模型
    2.4
    Tbnet模型的问题在于多个工作线程从任务队列获取任务需要加锁互斥,这个过程将产生大量的上下文切换,测试发现,当UpdateServer每秒处理包的数量超过8万个时,UpdateServer每秒的上下文切换次数接近30万次,在测试环境中已经达到极限(测试环境配置:Linux内核2.6.18,CPU为2 * Intel Nehalem E5520,共8核16线程)。
  • Libeasy
    为了解决收发小数据包带来的上下文切换问题,OceanBase目前采用Libeasy任务模型。Libeasy采用多个线程收发包,增强了网络收发能力,每个线程收到网络包后立即处理,减少了上下文切换。
    图2-5所示,UpdateServer有多个网络读写线程。每个线程通过Linux epool监测一个套接字集合上的网络读写事件。每个套接字只能同时分配给一个线程。当网络读写线程收到网络包后,立即调用任务处理函数,如果任务处理时间很短,可以很快完成并回复客户端,不需要加锁,避免了上下文切换。UpdateServer中大部分任务为短任务,比如随机读取内存表,另外还有少量任务需要等待共享资源上的锁,可以将这些任务加入到长任务队列中,交给专门的长任务处理线程处理。
    图2-5 Libeasy任务模型
    2.5
    由于每个网络读写线程处理一部分预先分配的套接字,这就可能出现某些套接字上请求特别多而导致负载不均衡的情况。例如,有两个网络读写线程thread1和thread2,其中thread1处理套接字fd1、fd2,thread2处理套接字fd3、fd4,fd1和fd2上每秒1000次请求,fd3和fd4上每秒10次请求,两个线程之间的负载很不均衡。为了处理这种情况,Libeasy内部会自动在网络读写线程之间执行负载均衡操作,将套接字从负载较高的线程迁移到负载较低的线程。

2.2.3 主备同步模块

在本手册的“1.4.1 一致性选择”章节已经介绍了UpdateServer的一致性选择。OceanBase选择了强一致性,主UpdateServer往备UpdateServer同步操作日志,如果同步成功,则主UpdateServer读写操作生效,并将更新成功消息返回给客户端;否则,主UpdateServer会把备UpdateServer从同步列表中剔除。另外,剔除备UpdateServer之前需要通知RootServer,从而防止RootServer将不一致的备UpdateServer选为主UpdateServer。

图2-6所示,主UpdateServer往备机推送操作日志,备UpdateServer的接收线程接收日志,并写入到一块全局日志缓冲区中。主UpdateServer接着更新本地内存并将日志刷到磁盘文件中,最后回复客户端写入操作成功。这种方式实现了强一致性,如果主UpdateServer出现故障,备UpdateServer包含所有的更新操作,因而能够完全无缝地切换为主UpdateServer继续提供服务。另外,主备同步过程中要求主机刷磁盘文件,备机只需要写内存缓冲区,强同步带来的额外延时也几乎可以忽略。

图2-6 UpdateServer主备同步原理

2.6

正常情况下,备UpdateServer的日志回放线程会从全局日志缓冲区中读取操作日志,在内存中回放并同时将操作日志刷到备机的日志文件中。如果发生异常,比如备UpdateServer刚启动或者主备之间网络刚恢复,全局日志缓冲区中没有日志或者日志不连续,此时,备UpdateServer需要主动请求主UpdateServer拉取操作日志。主UpdateServer首先查找日志缓冲区,如果缓冲区中没有数据,还需要读取磁盘日志文件,并将操作日志回复备UpdateServer。

2.3 ChunkServer实现机制

ChunkServer用于存储基线数据,它由如下基本部分组成:

  • SSTable,根据主键有序存储每个Tablet的基线数据。
  • 基于LRU(Least Recently Used)实现块缓存(Block cache)以及行缓存(Row cache)。
  • 实现Direct IO,磁盘IO与CPU计算并行化。
  • 通过定期合并获取UpdateServer的冻结数据,从而分散到整个集群。
  • 主动实现Tablet分裂,配合RootServer实现Tablet迁移、删除、合并。

每台ChunkServer服务着几千到几万个Tablet的基线数据,每个Tablet由若干个SSTable组成(一般为1个)。

2.3.1 SSTable

图2-7所示,SSTable中的数据按主键排序后存放在连续的数据块(Block)中,Block之间也有序。接着,存放数据块索引(Block Index),由每个Block最后一行的主键(End Key)组成,用于数据查询中的Block定位。接着,存放Bloom Filter和表格的Schema信息。最后,存放固定大小的Trailer以及Trailer的偏移位置。

图2-7 SSTable包含多个Table/Column Group

2.7

查找SSTable时,首先从Tablet的索引信息中读取SSTable Trailer的偏移位置,接着获取Trailer信息。根据Trailer中记录的信息,可以获取Block Index的大小和偏移,从而将整个Block Index加载到内存中。根据Block Index记录的每个Block的End Key,可以通过二分查找定位到查找的Block。最后将Block加载到内存中,通过二分查找Block中记录的行索引(Row Index)查找到具体某一行。本质上看,SSTable是一个两级索引结构:块索引和行索引。而整个ChunkServer是一个三级索引结构:Tablet索引、块索引和行索引。

SSTable分为两种格式:稀疏格式和稠密格式。对于稀疏格式,某些列可能存在,也可能不存在,因此每一行只存储包含实际值的列,每一列存储的内容为:<Column ID,Column Value>;而稠密格式中每一行都需要存储所有列,每一列只需要存储Column Value,不需要存储Column ID,这是因为Column ID可以从表格Schema中获取。

例如:假设有一张表格包含10列,列ID为1~10,表格中有一行的数据内容为:column_id=2 column_id =3 column_id =5 column_id =7 column_id =8 20 30 50 70 80
那么,如果采用稀疏格式存储,内容为:<2,20>,<3,30>,<5,50>,<7,70>,<8,80>;如果采用稠密格式存储,内容为:null,20,30,null,50,null,70,80,null,null。

ChunkServer中的SSTable为稠密格式,而UpdateServer中的SSTable为稀疏格式,且存储了多张表格的数据。另外,SSTable支持列组(Column Group),将同一个Column Group下的多个列的内容存储在一块。

当一个SSTable中包含多个Table/Column Group时,数据按照[table_id,column group id,row_key]的形式有序存储。另外,SSTable支持压缩功能,压缩以Block为单位。每个Block写入磁盘之前调用压缩算法执行压缩,读取时需要解压缩。用户可以自定义SSTable的压缩算法,目前支持的算法包括LZO和Snappy。

SSTable的操作接口分为写入和读取两个部分:写入类为ObSSTableWriter,读取类为ObSSTableGetter(随机读取)和ObSSTableScanner(范围查询)。

class ObSSTableWriter
   {
 public:
 // 创建SSTable
 // @param [in] schema 表格schema信息
 // @param [in] path SSTable在磁盘中的路径名
 // @param [in] compressor_name 压缩算法名
 // @param [in] store_type SSTable格式,稀疏格式或者稠密格式
 // @param [in] block_size 块大小,默认64KB
 int create_sstable(const ObSSTableSchema& schema, const ObString& path, const ObString& compressor_name, const int store_type, const int64_t block_size);
// 往SSTable中追加一行数据
   // @param [in] row 一行SSTable数据
   // @param [out] space_usage 追加完这一行后SSTable大致占用的磁盘空间
   int append_row(const ObSSTableRow& row, int64_t& space_usage);
// 关闭SSTable,将往磁盘中写入Block Index,Bloom Filter,Schema,Trailer等信息。
   // @param [out] trailer_offset 返回SSTable的Trailer偏移量
   int close_sstable(int64_t& trailer_offset); };

定期合并过程将产生新的SSTable的过程如下:

  1. 调用create_sstable函数创建一个新的SSTable。
  2. 不断调用append_row函数往SSTable中追加一行一行数据。
  3. 调用close_sstable完成SSTable写入。

与“2.2.1 内存存储引擎”节中的MemTableIterator一样,ObSSTableGetter和ObSSTableScanner实现了迭代器接口,通过它可以不断地获取SSTable的下一个cell。

class ObIterator
   {
 public:
 // 迭代器移动到下一个cell
 int next_cell();
// 获取当前cell的内容
   // @param [out] cell_info 当前cell的内容,包括表名(table_id),行主键(row_key),列编号(column_id)以及列值(column_value)
   int get_cell(ObCellInfo** cell_info);
// 获取当前cell的内容
   // @param [out] cell_info 当前cell的内容
   // @param is_row_changed 是否迭代到下一行
   int get_cell(ObCellInfo** cell_info, bool * is_row_changed); };

OceanBase读取的数据可能来源于MemTable,也可能来源于SSTable,或者是合并多个MemTable和多个SSTable生成的结果。无论底层数据来源如何变化,上层的读取接口总是ObIterator。

2.3.2 缓存实现

ChunkServer中包含三种缓存:块缓存(Block Cache),行缓存(Row Cache)和块索引缓存(Block Index Cache)。不同缓存的底层采用相同的实现方式。

经典的LRU缓存实现包含两个部分:Hash表和LRU链表。其中,Hash表用于查找缓存中的元素,LRU链表用于淘汰。每次访问LRU缓存时,需要将被访问的元素移动到LRU链表的头部,从而避免被很快淘汰,这个过程需要锁住LRU链表。

图2-8所示,块缓存和行缓存底层都是一个Key-Value Cache,实现如下:

  • OceanBase一次分配1MB的连续内存块(称为memblock),每个memblock包含若干缓存项(item)。添加item时,只需要简单地将item追加到memblock的尾部;另外,缓存淘汰以memblock为单位,而不是以item为单位。

  • OceanBase没有维护LRU链表,而是对每个memblock都维护了访问次数和最近访问时间。淘汰memblock时,对所有的memblock按照访问次数和最近访问时间排序,淘汰访问次数少且长时间没有访问的memblock。这种实现方式通过牺牲LRU算法的精确性,来规避访问LRU链表的全局锁。

  • 每个memblock维护了引用计数,读取缓存项时所在memblock的引用计数加1,淘汰memblock时引用计数减1,引用计数为0时memblock可以回收重用。通过引用计数,实现读取memblock中的缓存项不加锁。

图2-8 Key-Value Cache实现

2.8

2.3.3 IO实现

OceanBase没有使用操作系统本身的page cache机制,而是自己实现缓存。相应地,IO也采用Direct IO实现,并且支持磁盘IO与CPU计算并行化。

ChunkServer采用Linux的Libaio实现异步IO,并通过双缓冲区机制实现磁盘预读与CPU处 理并行化,步骤如下:

  1. 分配current以及ahead两个缓冲区,current为当前缓冲区,ahead为预读缓冲区。
  2. 使用current缓冲区读取数据,current缓冲区通过Libaio发起异步读取请求,接着等待异步读取完成。
  3. 异步读取完成后,将current缓冲区返回上层执行CPU计算,同时,原来的ahead变为新的current,发送异步读取请求将数据读取到新的current缓冲区。CPU计算完成后,原来的current缓冲区变为空闲,成为新的ahead,准备作为下一次预读的缓冲区。
  4. 重复“步骤3”,直到所有数据全部读完。

例如:假设需要读取的数据范围为(1, 150]。分三次读取:(1, 50], (50, 100], (100, 150],current和ahead缓冲区分别记为A和B。

  1. 发送异步请求将(1, 50]读取到缓冲区A,等待读取完成。
  2. 对缓冲区A执行CPU计算,发送异步请求,将(50, 100]读取到缓冲区B。
  3. 如果CPU计算先于磁盘读取完成,那么,缓冲区A变为空闲,等到(50, 100]读取完成后将缓冲区B返回上层执行CPU计算,同时,发送异步请求,将(100, 150]读取到缓冲区。
  4. 如果磁盘读取先于CPU计算完成,那么,首先等待缓冲区A上的CPU计算完成,接着,将缓冲区B返回上层执行CPU计算,同时,发送异步请求,将(100,150]读取到缓冲区A。
  5. 等待(100, 150]读取完成后,将缓冲区A返回给上层执行CPU计算。

2.3.4 定期合并

定期合并也称为每日合并,主要将UpdateServer中的增量更新分发到ChunkServer中,其主要流程如下:

  1. UpdateServer冻结当前的活跃内存表(Active MemTable),生成冻结内存表,并开启新的活跃内存表。后续的更新操作都写入新的活跃内存表。

  2. UpdateServer通知RootServer数据版本发生了变化。之后,RootServer通过心跳消息通知ChunkServer。

  3. 每台ChunkServer启动定期合并操作,从UpdateServer获取每个Tablet对应的增量更新数据。

定期合并过程中ChunkServer需要将本地SSTable中的基准数据与冻结内存表的增量更新数据执行一次多路归并,融合后生成新的基准数据并存放到新的SSTable中。定期合并对系统服务能力影响很大,往往安排在每天服务低峰期执行(例如凌晨1点开始)。

图2-9所示,活跃内存表冻结后生成冻结内存表,后续的写操作进入新的活跃内存表。定期合并过程中ChunkServer需要读取UpdateServer中冻结内存表的数据、融合后生成新的Tablet,即:

新Tablet = 旧Tablet + 冻结内存表

图2-9 定期合并

1.4

虽然定期合并过程中各个ChunkServer的各个Tablet合并时间和完成时间可能都不相同,但并不影响读取服务。如果Tablet没有合并完成,那么使用旧Tablet,并且读取UpdateServer中的冻结内存表以及新的活跃内存表;否则,使用新Tablet,只读取新的活跃内存表,即:

查询结果 = 旧Tablet + 冻结内存表 + 新的活跃内存表 = 新Tablet + 新的活跃内存表

UpdateServer进行数据冻结可以分为大版本冻结和小版本冻结:假设UpdateServer中的数据版本为“111”,其允许的小版本数为“3”。在UpdateServer中会先产生一个小版本数据“111-1”,当小版本的数据达到一定大小时会进行冻结(数据大小与UpdateServer服务器内存相关),我们称为“小版本冻结”。同时将产生一个新的小版本“111-2”,以此类推。当冻结的小版本数达到UpdateServer允许的最大小版本数时,即所有小版本数据均冻结,我们称为“大版本冻结”。

当UpdateServer执行了大版本冻结时,ChunkServer将执行定期合并。ChunkServer唤醒若干个定期合并线程(比如10个),每个线程执行如下流程:

  1. 加锁获取下一个需要定期合并的Tablet。

  2. 根据Tablet的主键范围读取UpdateServer中的更新操作。

  3. 将每行数据的基线数据和增量数据合并后,产生新的基线数据,并写入到新的SSTable中。

  4. 更改Tablet索引信息,指向新的SSTable。

等到ChunkServer上所有的Tablet定期合并都执行完成后,ChunkServer会向RootServer汇报,RootServer会更新RootTable中记录的Tablet版本信息。另外,定期合并过程中ChunkServer的压力比较大,需要控制合并速度,否则可能影响正常的读取服务。

2.4 消除更新瓶颈

从表面上看,UpdateServer单点像是OceanBase架构的软肋,然而经过OceanBase团队持续不断地的性能优化以及旁路导入功能的开发,单点的架构在实践过程中经受住了线上考验。每年淘宝网“双十一”光棍节,OceanBase系统都承载着核心的数据库业务,系统访问量出现5到10倍的增长,而OceanBase只需简单地增加机器即可。

本节介绍OceanBase的优化工作,包括读写性能优化以及旁路导入功能。

2.4.1 读写优化回顾

OceanBase中的UpdateServer相当于一个内存数据库,能够支持每秒数百万次单行读写操作,这样的性能对于目前关系数据库的应用场景都是足够的。为了达到这样的性能指标,我们已经完成或正在进行的工作如下:

  • 网络框架优化
    在本手册的“2.2.2 任务处理模型”章节中提到,如果不经过优化,UpdateServer单机每秒最多能够接收的数据包个数只有10万个左右,而经过优化后的libeasy框架对于千兆网卡每秒最多收包个数超过50万,对于万兆网卡则超过100万。另外,UpdateServer内部还会在软件层面实现多块网卡的负载均衡,从而更好地发挥多网卡的优势。通过网络框架优化,使得单机支持百万次操作成为可能。
  • 高性能内存数据结构
    UpdateServer的底层是一颗高性能内存B+树。为了最大程度地发挥多核的优势,B+树实现时大部分情况下都做到了无锁(lock*free)。测试数据表明,即使在普通的16核机器上,OceanBase的B+树每秒支持的单行修改操作都超过150万次。
  • 写操作日志优化
    在软件层面,写操作日志涉及到的工作主要有以下三点:
    • Group commit。将多个写操作聚合在一起,一次性刷入磁盘中。
    • 降低日志缓冲区的锁冲突。多个线程同时往日志缓冲区中追加数据,实现时需要尽可能地减少追加过程的锁冲突。追加过程包含两个阶段:第一个阶段是占位,第二个阶段是拷贝数据。相比较而言,拷贝数据比较耗时。实现的关键在于只对占位操作互斥,而允许多线程并发拷贝数据。例如,有两个线程,线程1和线程2,他们分别需要往缓冲区追加大小为100字节和大小为300字节的数据。假设缓冲区初始为空,那么,线程1可以首先占住位置0~100,线程2接着占住100~300。最后,线程1和线程2同时将数据拷贝到刚才占住的位置。
    • 日志文件并发写入。UpdateServer中每个日志缓冲区的大小一般为64MB,如果写入太快,那么,很快会产生多个日志缓冲区需要刷入磁盘,可以并发地将这些日志缓冲区刷入不同的磁盘。

    在硬件层面,UpdateServer机器需要配置较好的RAID卡。这些RAID卡自带缓存,而且容量比较大(例如1GB),从而进一步提升写磁盘性能。

  • 内存容量优化
    随着数据不断写入,UpdateServer的内存容量将成为瓶颈。因此,有两种解决思路。第一种思路是精心设计UpdateServer的内存数据结构,尽可能地节省内存使用;另外一种思路就是将UpdateServer内存中的数据很快地分发出去。
    OceanBase实现了这两种思路。首先,UpdateServer会将内存中的数据编码为精心设计的格式。例如,100以内的64位整数在内存中只需要占用两个字节。这种编码格式不仅能够有效地减少内存占用,而且往往使得CPU缓存能够容纳更多的数据。因此,也不会造成太多额外的CPU消耗。另外,当UpdateServer的内存使用量到达一定大小时,OceanBase会触发数据合并操作,将UpdateServer的数据分发到集群中的ChunkServer中,从而避免UpdateServer的内存容量成为瓶颈。

2.4.2 数据旁路导入

虽然OceanBase内部实现了大量优化技术,但是UpdateServer单点写入对于某些OLAP应用仍然可能成为问题。这些应用往往需要定期(例如每天、每月)导入大批数据,对导入性能要求很高。为此,OceanBase专门开发了旁路导入功能,本节介绍直接将数据导入到ChunkServer中的方法。

OceanBase的数据按照全局有序排列,因此,ChunkServer旁路导入的过程如下:

  1. 使用工具(例如:Hadoop MapReduce)将所有的数据排序,并且划分为一个一个有序的范围,每个范围对应一个SSTable文件。
  2. 将SSTable文件并行拷贝到集群内所有的ChunkServer中。
  3. 通过RootServer要求每个ChunkServer并行加载这些SSTable文件。每个SSTable文件对应ChunkServer的一个子表。
  4. ChunkServer加载完本地的SSTable文件后向RootServer汇报,RootServer接着将汇报的子表信息更新到RootTable中。

例如:有4台ChunkServer分别为A、B、C和D。所有的数据排好序后划分为6个范围:r1(0~100],r2(100~200],r3(200~300],r4(300~400],r5(400~500],r6(500~600],对应的SSTable文件分别记为sst1,sst2,…,sst6。

  1. 假设每个子表存储两个副本,那么,拷贝完SSTable文件后,可能的分布情况为:
    A:sst1,sst3,sst4
    B:sst2,sst3,sst5
    C:sst1,sst4,sst6
    D:sst2,sst5,sst6
  2. 接着,每个ChunkServer分别加载本地的SSTable文件,完成后向RootServer汇报。RootServer最终会将这些信息记录到RootTable中,如下:
    r1(0~100]:A、C
    r2(100~200]:B、D
    r3(200~300]:A、B
    r4(300~400]:A、C
    r5(400~500]:B、D
    r6(500~600]:C、D
  3. 如果导入的过程中ChunkServer发生故障,例如拷贝sst1到机器C失败,那么,旁路导入模块会自动选择另外一台机器拷贝数据。

当然,实现旁路导入功能时还需要考虑很多问题。例如:如何支持将数据导入到多个数据中心的主备OceanBase集群等。

2.5 实现技巧

OceanBase开发过程中使用了一些小技巧,这些技巧说起来相当朴实,却实实在在地解决了当时面临的问题。本节通过几个例子介绍开发过程中用到的实现技巧。

2.5.1 内存管理

内存管理是C++高性能服务器的核心问题。一些通用的内存管理库,比如Google TCMalloc在内存申请/释放速度、小内存管理、锁开销等方面都已经做得相当卓越了,然而,我们并没有采用。这是因为,通用内存管理库在性能上毕竟不如专用的内存池,更为严重的是,它鼓励了开发人员忽视内存管理的陋习,比如在服务器程序中滥用C++标准模板库(STL)。

在分布式存储系统开发初期,内存相关的Bug相当常见,比如内存越界,服务器出现Core Dump,这些Bug都非常难以调试。因此,这个时期内存管理的首要问题并不是高效,而是可控性,并防止内存碎片。

OceanBase系统有一个全局的定长内存池,这个内存池维护了由64KB大小的定长内存块组成的空闲链表。

  • 如果申请的内存不超过64KB,则尝试从空闲链表中获取一个64KB的内存块返回给申请者。如果空闲链表为空,则先从操作系统中申请一批大小为64KB的内存块加入空闲链表。释放时将64KB的内存块加入到空闲链表中以便下次重用。

  • 如果申请的内存超过64KB,则直接调用Glibc的malloc函数,向操作系统申请用户所需大小的内存块。释放时直接调用Glibc的free函数,将内存块归还操作系统。

OceanBase的全局内存池实现简单,但内存使用率比较低,即使申请几个字节的内存,也需要占用大小为64KB的内存块。因此,全局内存池不适合管理小块内存,每个需要申请内存的模块,比如UpdateServer中的MemTable,ChunkServer中的缓存等,都只能从全局内存池中申请大块内存,每个模块内部再实现专用的内存池。每个线程处理读写请求时需要使用临时内存,为了提高效率,每个线程会缓存若干个大小分别为64KB和2MB的内存块,每个线程总是首先尝试从线程局部缓存中申请内存,如果申请不到,则再从全局内存池中申请。

全局内存池的意义如下:

  • 全局内存池可以统计每个模块的内存使用情况。如果出现内存泄露,可以很快定位到发生问题的模块。

  • 全局内存池可用于辅助调试。例如,可以将全局内存池中申请到的内存块按字节填充为某个非法的值(比如0xFE),当出现内存越界等问题时,服务器程序会很快在出现问题的位置Core Dump,而不是带着错误运行一段时间后才Core Dump,从而方便问题定位。

总而言之,OceanBase的内存管理没有采用高深的技术,也没有做到通用或者最优,但是很好地满足了系统初期的两个最主要的需求:可控性以及没有内存碎片。

2.5.2 成组提交

为了提高写性能,UpdateServer会将多个写操作的日志组成一批,一次性写到日志文件中,这种技术称为成组提交(Group Commit)。

考虑如下模型:生产者不断地将写任务加入到任务队列中,有一个批处理线程从任务队列中每次取一批写任务进行批量处理。由于写操作的时间消耗主要在于写日志文件,批处理1个写任务与批处理10个写任务花费的时间相差不大。因此,批处理线程总是尽量提高一次处理的任务数。假设一批任务最多包含1024个,常见的Group Commit做法为:批处理线程尝试从任务队列中取出1024个任务,如果队列中任务不够,那么等待一段时间(比如5ms),直到取到1024个任务或者超时为止。

这种做法的问题在于延时,当系统比较空闲时,批处理线程经常需要额外等待一段时间。然而仔细观察可以发现,这里其实是不需要等待的。如果批处理线程前一次处理的任务数较少,下一次任务队列中自然会积攒较多的任务,相应地,批处理线程也能处理得更快。

例如:假设生产者每隔1ms会往任务队列中加入一个新的任务,批处理线程处理1个任务和10个任务的时间都是5ms。

  • 方式1(等待 5ms):5ms的时候开始处理第一批共5个任务,10ms的时候处理完成。接着等待5ms,直到15ms的时候开始处理第二批任务共10个任务,25ms的时候处理完成。依次类推。
  • 方式2(不等待):1ms的时候开始处理第一批共1个任务,6ms的时候处理完成。接着开始处理第二批共5个任务,11ms的时候处理完成。依次类推。

“方式1”每隔10ms处理10个任务,“方式2”每隔5ms处理5个任务。无论采用哪种方式,批处理线程的处理能力为1ms一个任务,与生产者产生任务的速率相同。“方式1”和“方式2”处理的并发数相同,而“方式2”的任务响应时间更短。

2.5.3 双缓冲区

双缓冲区广泛用于“生产者/消费者”模型。ChunkServer中使用了双缓冲区异步预读的技术,生产者为磁盘,消费者为CPU,磁盘中生产的原始数据需要给CPU计算消费掉。

所谓“双缓冲区”,顾名思义就是两个缓冲区(假设为A和B)。这两个缓冲区,总是一个用于生产者,一个用于消费者。当两个缓冲区都操作完,再进行一次切换,先前被生产者写入的被消费者读取,先前消费者读取的转为生产者写入。为了做到不冲突,给每个缓冲区分配一把互斥锁(假设为La和Lb)。生产者或者消费者如果要操作某个缓冲区,必须先拥有对应的互斥锁。

双缓冲区包括如下几种状态:

  • 双缓冲区都在使用的状态(并发读写)
    大多数情况下,生产者和消费者都处于并发读写状态。不妨设生产者写入A,消费者读取B。在这种状态下,生产者拥有锁La;同样地,消费者拥有锁Lb。由于俩缓冲区都是处于独占状态,因此每次读写缓冲区中的元素都不需要再进行加锁、解锁操作。这是节约开销的主要来源。

  • 单个缓冲区空闲状态
    由于两个并发实体的速度会有差异,必然会出现一个缓冲区已经操作完,而另一个尚未操作完。不妨假设生产者快于消费者。在这种情况下,当生产者把A写满的时候,生产者要先释放La(表示它已经不再操作A),然后尝试获取Lb。由于B还没有被读空,Lb还被消费者持有,所以生产者进入等待(wait)状态。

  • 缓冲区的切换
    过了若干时间,消费者终于把B读完。这时候,消费者也要先释放Lb,然后尝试获取La。由于La刚才已经被生产者释放,所以消费者能立即拥有La并开始读取A的数据。而由于Lb被消费者释放,所以刚才等待的生产者会苏醒过来(wakeup)并拥有Lb,然后生产者继续往B写入数据。

2.5.4 定期合并限速

定期合并期间系统的压力较大,需要控制定期合并的速度,避免影响正常服务。定期合并限速的措施包括:

  • ChunkServer:ChunkServer定期合并过程中,每合并完成若干行(默认2000行)数据,就查看本机的负载(查看Linux系统的Load值)。如果负载过高,一部分定期合并线程转入休眠状态;如果负载过低,唤醒更多的定期合并线程。另外,RootServer将UpdateServer冻结的大版本通知所有的ChunkServer,每台ChunkServer会随机等待一段时间再开始执行定期合并,防止所有的ChunkServer同时将大量的请求发给UpdateServer。

  • UpdateServer:定期合并过程中ChunkServer需要从UpdateServer读取大量的数据,为了防止定期合并任务占满带宽而阻塞用户的正常请求,UpdateServer将任务区分为高优先级(用户正常请求)和低优先级(定期合并任务),并单独统计每种任务的输出带宽。如果低优先级任务的输出带宽超过上限,降低低优先级任务的处理速度;反之,适当提高低优先级任务的处理速度。

如果OceanBase部署了两个集群,还能够支持主备集群在不同时间段进行“错峰合并”:一个集群执行定期合并时,把全部或大部分读写流量切到另一个集群。该集群合并完成后,把全部或大部分流量切回,以便另一个集群接着进行定期合并。两个集群都合并完成后,恢复正常的流量分配。

2.5.5 缓存预热

UpdateServer中的Frozen MemTable将会以SSTable的形式转储到SSD盘中。如果内存不够需要丢弃Frozen MemTable,则大量请求只能读取SSD盘,UpdateServer性能将大幅下降。因此,希望能够在丢弃Frozen MemTable之前将SSTable的缓存预热。

UpdateServer的缓存预热机制实现如下:在丢弃Frozen MemTable之前的一段时间(比如10分钟),每隔一段时间(比如30秒),将一定比率(比如5%)的请求发给SSTable,而不是Frozen MemTable。这样,SSTable上的读请求将从5%到10%,再到15%,依次类推,直到100%,很自然地实现了缓存预热。

另外,ChunkServer定期合并后需要使用生成的新的SSTable提供服务,这里也需要缓存预热。OceanBase最初的版本实现了主动缓存预热:扫描原来的缓存,根据每个缓存项的key读取新的SSTable并将结果加入到新的缓存中。例如,原来缓存数据项的主键分别为100、200、500,那么只需要从新的SSTable中读取主键为100、200、500的数据并加入新的缓存。扫描完成后,原来的缓存可以丢弃。

线上运行一段时间后发现,定期合并基本上都安排在凌晨业务低峰期,合并完成后OceanBase集群收到的用户请求总是由少到多(早上7点之前请求很少,9点以后请求逐步增多),能够很自然地实现被动缓存预热。由于ChunkServer在主动缓存预热期间需要占用两倍的内存,因此,目前的线上版本放弃了这种方式,转而采用被动缓存预热。

 

 

转载于:https://my.oschina.net/u/1454851/blog/201870

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
HustStore- 高性能分布式存储服务huststore 是一个高性能的分布式存储服务,不但提供了 10w QPS 级别的 kv 存储的功能,还提供了 hash、set 等一系列数据结构的支持,并且支持 二进制 的 kv 存储,可以完全取代 Redis 的功能。此外,huststore 还结合特有的 HA 模块实现了分布式消息队列的功能,包括消息的流式推送,以及消息的 发布-订阅 等功能,可以完全取代 RabbitMQ 的功能。特性huststore 分为 hustdb 以及 HA 模块两大部分。hustdb (存储引擎)的底层设计采用了自主开发的 fastdb,通过一套独特的 md5 db 将QPS 提升至 10w 级别的水准(含网络层的开销)。HA 以 nginx 模块的方式开发。nginx 是工业级的 http server 标准,得益于此,huststore 具备以下特性:高吞吐量hustdb 的网络层采用了开源的 libevhtp 来实现,结合自主研发的高性能 fastdb 存储引擎,性能测试 QPS 在 10w 以上。高并发参考 nginx 的并发能力。高可用性huststore 整体架构支持 Replication (master-master),支持 load balance 。HA 的可用性由nginx 的 master-worker 架构所保证。当某一个 worker 意外挂掉时, master 会自动再启动一个 worker 进程,而且多个 worker 之间是相互独立的,从而保证了 HA 的高可用性。huststore 的高可用性由其整体架构特点保证。由于 hustdb 的存储节点采用了 master-master 的结构,当某一个存储节点挂掉时,HA 会自动将请求打到另外一台 master,同时 HA 会按照自动进行负载均衡,将数据分布存储在多个 hustdb节点上,因此存储引擎不存在单点限制。同时 HA 集群本身也是分布式的设计,而且每个 HA 节点都是独立的,当某一台 HA 挂掉时, LVS 会自动将请求打到其他可用的 HA 节点,从而解决了 HA 得单点限制。通用性的接口huststore 使用 http 作为通用协议,因此客户端的实现不限制于语言。支持二进制的 key-value架构设计运维架构存储引擎设计依赖leveldblibcurllibevhtpzlog 标签:360  分布式存储

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值