点评yugabytedb 对RocksDB改进

546 阅读9分钟

点评yugabytedb 对RocksDB改进

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

yugabytedb是一款开源的全球分布式的事务数据库,与tidb一样,底层基于RocksDB,主打强一致全球部署分布式事务。为了满足yugabytedb对其可扩展性与性能的追求,其对RocksDB做了许多改造,本文结合作者经验逐一点评,相信yugabytedb的痛点与应对策略是值得借鉴的。

系统架构

yugabytedb逻辑上使用两层架构,查询层与存储层,但都位于一组yb-tserver进程中,还有一组master管理元数据,其架构如下图。

1669439997326-ac6e5601-86ad-4898-8a6a-28e5cd78124a.png 在查询层,是分布式的并且高度弹性的SQL 层,无状态跨越多个节点运行. 它与PostgreSQL的SQL方言和连接协议兼容. 代码也看到是魔改PostgresSQL。 distributed-postgresql-google-spanner-query-layer-01.png

存储层是使用DocDB作为底层,一个受Google Spanner启发的高性能分布式文档存储。它提供了强大的一致性语义,包括单行线性化能力和多行ACID事务。

DocDB封装了RocksDB,提供了支持强大的语义,这也是我对其感兴趣的原因。

  • • 透明的表分区
  • • 分区数据的复制
  • • 跨分区事务(也称为分布式事务)
  • • 持久化基于文档的行

yugabytedb的最小存储单元是tablet,一个表先被hash sharding到(0x0000 到 0xFFFF)的空间,此hash空间将再次被划分为多个范围空间,每个范围由一个tablet RaftGroup负责,这是比较亮点的设计。

1669459905862-fccec064-5f94-46a4-b1ae-b43a91a2aa54.png

yugabytedb的技术栈和我们有点像,如果你也在构建基于Raft协议,存储引擎使用RocksDB封装,高扩展性和容错性类似的存储服务,应该去读一下yugabytedb的源码。

编译yugabytedb

我们希望可以完整编译,目标是改代码,加日志,以及他们的单元测试可以快速跑起来,我折腾了一下午,终于编译通过。

  • • 环境:Ubuntu 22.04 LTS
  • • wget、curl、g++、python3、pip、autoconf、libtool(我的docker这些都没)
git clone https://github.com/yugabyte/yugabyte-db.git ` 
cd yugabyte-db
./yb_build.sh --debug --skip-java-build --export-compile-commands

重要技巧

  • • 添加--export-compile-commands,cmake 成功即可生成compile_commands.json ,甚至不需要编译通过
  • • ./yb_build.sh --targets=write_batch_test 可以编译单元测试目标
  • • 所有单元测试目标文件在CMakeLists.txt都可以找到,形如ADD_YB_TEST() ,ADD_YB_TEST(db/write_batch_test)
  • • 使用python bin/yugabyted start --daemon=false启动
python bin/yugabyted start --daemon=false
/home/yugabyte/bin/yb-master --stop_on_parent_termination --undefok=stop_on_parent_termination --fs_data_dirs=/root/var/data --webserver_interface=172.17.0.87 --metrics_snapshotter_tserver_metrics_whitelist=handler_latency_yb_tserver_TabletServerService_Read_count,handler_latency_yb_tserver_TabletServerService_Write_count,handler_latency_yb_tserver_TabletServerService_Read_sum,handler_latency_yb_tserver_TabletServerService_Write_sum,disk_usage,cpu_usage,node_up --yb_num_shards_per_tserver=1 --ysql_num_shards_per_tserver=1 --placement_cloud=cloud1 --placement_region=datacenter1 --placement_zone=rack1 --rpc_bind_addresses=172.17.0.87:7100 --server_broadcast_addresses=172.17.0.87:7100 --replication_factor=1 --use_initial_sys_catalog_snapshot --server_dump_info_path=/root/var/data/master-info --master_enable_metrics_snapshotter=true --webserver_port=7000 --default_memory_limit_to_ram_ratio=0.35 --instance_uuid_override=25f6cadb81c04329a1b35b9327a4706c --master_addresses=172.17.0.87:7100 --cluster_uuid=3a269d2f-21ea-4f57-8df3-b93ed13d9dab
/home/yugabyte/bin/yb-tserver --stop_on_parent_termination --undefok=stop_on_parent_termination --fs_data_dirs=/root/var/data --webserver_interface=172.17.0.87 --metrics_snapshotter_tserver_metrics_whitelist=handler_latency_yb_tserver_TabletServerService_Read_count,handler_latency_yb_tserver_TabletServerService_Write_count,handler_latency_yb_tserver_TabletServerService_Read_sum,handler_latency_yb_tserver_TabletServerService_Write_sum,disk_usage,cpu_usage,node_up --yb_num_shards_per_tserver=1 --ysql_num_shards_per_tserver=1 --placement_cloud=cloud1 --placement_region=datacenter1 --placement_zone=rack1 --rpc_bind_addresses=172.17.0.87:9100 --server_broadcast_addresses=172.17.0.87:9100 --cql_proxy_bind_address=172.17.0.87:9042 --server_dump_info_path=/root/var/data/tserver-info --start_pgsql_proxy --pgsql_proxy_bind_address=172.17.0.87:5433 --tserver_enable_metrics_snapshotter=true --metrics_snapshotter_interval_ms=11000 --webserver_port=9000 --default_memory_limit_to_ram_ratio=0.6 --instance_uuid_override=381aa8228e424739a35386470e742c5b --start_redis_proxy=false --tserver_master_addrs=172.17.0.87:7100
00:00:00 /home/yugabyte/bin/yugabyted-ui -database_host=172.17.0.87
/home/yugabyte/postgres/bin/postgres -D /root/var/data/pg_data -p 5433 -h 172.17.0.87 -k /tmp/.yb.11581713487073106743 -c unix_socket_permissions=0700 -c logging_collector=on -c log_directory=/root/var/data/yb-data/tserver/logs -c yb_pg_metrics.node_name=2fb59df2a680:9000 -c yb_pg_metrics.port=13000 -c config_file=/root/var/data/pg_data/ysql_pg.conf -c hba_file=/root/var/data/pg_data/ysql_hba.conf
postgres: logger
postgresYSQL webserver
postgres: checkpointer
postgres: stats collector

对raft协议的优化

yugabytedb在存储层以及master都使用raft作为一致性协议复制副本,看代码是出自Apache Kudu(其实yugabytedb底层应该是kudu改出来的)。在raft协议上的改造如下表列出,src/yb/consensus/consensus.txt有提到一些实现中的对leader选举的优化目标。比较常规,除了对事务、HLC,我们对这些特性都做了实现和优化。

Feature/Enhancement NamePurpose
Leader leases提高线性一致性下的读性能,不必每次读确认leader身份
Group commits批量提交,提高写性能
Leader balancing提高节点利用率,可以提高读写性能
Affinitized Leaders将leader放置在地理上更靠近业务的区域来提高读写性能
Configurable missed heartbeats在高延迟的混合云部署环境,可配置心跳丢失多少次才认为leader故障
Integrating Hybrid Logical ClocksHLC相关的优化,Enables cross-shard transactions as a building block for a software-defined atomic clock for a cluster.
MVCC Fencing事务相关Guarantee safety of writes in leader failure scenarios.
Non-Voting Observer Nodeslearner节点

对RocksDB的优化

yugabytedb底层使用RocksDB,其版本是久远的4.4.x(当前已经是7.x),而后对其做了较大的改动,我对这些改动比较感兴趣,考虑跟踪主线代码,看yugabytedb团队会遇到哪些(和我们相似的)痛点,他们又是怎样解决掉的。

1669211902842-61f3a914-8ac2-4d1d-90cb-929ae28d6831.png

Raft group1:1对应RocksDB实例

yugabytedb使用的是DocDB->raft group->RocksDB 实例1:1:1。有意思的是目前TiKV底层使用了2个RocksDB实例,默认RocksDB 实例存储KV 数据,Raft RocksDB 实例存储Raft Wal。

这和我多年前的设计方案一致,为此踩了许多坑,RocksDB虽然允许单个进程内运行多个实例,但实践中这需要小心仔细的设置和工程调优,最终稳定下来,收益非常大。

  • • 添加、删除、调度均衡raft group时非常方便,换句话说我们可以以压缩方式从leader中直接下载压缩的sst文件,速度极快,而像TiDB这种共享RocksDB实例的架构,需要对SST文件逻辑扫描(seek)再拆分,而这将耗费大量的CPU和时间。

  • • 删除表就像删除相关的 RocksDB 实例一样简单。

  • • 允许每个表存储策略

    • • 磁盘压缩——开/关?哪种压缩算法?等等
    • • 内存增量编码方案——例如,前缀压缩或差异编码
  • • 允许每表布隆过滤器策略。对于具有复合主键(比如(<userid>, <message-id>))的表,将键的哪些部分添加到布隆过滤器取决于要优化的访问模式。例如查询通常只提供复合键的一部分(比如只是<user-id>),将整个复合键添加到 bloomfilter 是无用的,而且纯粹是开销。这里是定制了DocDbAwareFilterPolicy,同样是插件模式集成。我们在则大量使用prefix-bloomfilter,减少seek耗时占比(读放大)。

doc_boundary_values_extractor

在compact/create SSTable时,抽取该sst文件中的聚簇列的max/min value,并将其存储在SSTable meta block中,这就是option.doc_boundary_values_extractor的用途,在业务层可定制实现,方便抽取业务编码数据。

doc_boundary_values_extractor使得 YugabyteDB 能够优化范围谓词查询,例如通过最小化需要查找的 SSTable 文件的数量。

SELECT metric_val 
  FROM metrics
 WHERE device=AND ts < ? AND ts > ?

YugabyteDB 在ReadOptions中添加了std::shared_ptr<ReadFileFilter> file_filter;,接下来read操作可以跳过不必要的SSTable,显著提高了seekread操作,这个改造感觉会比较有用的,幸运的是,在较新版本的官方RocksDB中,已经有类似实现,有索引需求的产品可以用起来。

RocksDB中TableProperties已经被大量使用来存储该SSTable文件相关的数据,例如max/min 编码key,用于定时CompactRange该SSTable文件,存储HLC时间戳,存储RaftApplyIndex等。

  • • RocksDB靠compaction清理TTL相关的数据,在TableProperties中存储最晚过期的TTL,读的时候使用table_filter可以跳过不必要的文件,这在存储时序数据时较为有用。
  • • 在实现ZSet逻辑时,TableProperties存储Max/Min Score,GetRange时可能有优化作用
  • • 近期我们在实现的二级索引功能,用TableProperties可以优化between语义
//RocksDB ReadOptions已经有类似实现
struct ReadOptions {
  std::function<bool(const TableProperties&)> table_filter;
};
//tidb已经用起来
impl TableFilter for TsFilter {
    fn table_filter(&self, props: &TableProperties) -> bool {
        if self.hint_max_ts.is_none() && self.hint_min_ts.is_none() {
            return true;
        }
        let user_props = props.user_collected_properties();
        ......

使用全局BlockCache

在节点内的多RocksDB实例中,YugabyteDB全局共享BlockCache,这有效提高了内存使用率,因为大规模数据集的LRU更有效地筛选出热的数据block。

其实找到一个指标,自动安全地扩容BlockCache,是我们比较需要的,毕竟不像多租户存储或者YugabyteDB这样,启动即最大化BlockCache。

服务器全局 memtable限制

RocksDB允许为每个表独立配置memtable flush的阈值,但是在多实例RocksDB节点中,会有许多问题。

memtable flush 阈值问题

若memtable flush阈值设置的较大,YugabyteDB不断创建新表时会导致memtable越来越多,内存浪费,同时作者提到,过大的阈值将导致服务器重启恢复时间变大,这是因为YugabyteDB使用raft协议,并监控raft 落地applyIndex的值,用来做raft wal的GC,如果memtable flush 阈值过大,进一步导致raft wal没有及时GC而残留过多,节点重启时,raft wal重放时间显著变大,更严重的是,重放期间系统计算资源(内存/CPU)显著增加,这可能导致系统抖动。 image-20221129204945001.png 但flush阈值过小将导致过多的L0 sst 文件,过早触发level0_slowdown_writes_trigger,更大的写放大,严重时导致write stall。此外若你通过设置rocksdb::option::max_background_job控制压缩线程,小的flush阈值会导致大量flush->compaction,很容易用尽共享线程池,此时节点很有可能卡死。

image-20221129205726071.png YugabyteDB加强了rocksdb的memtable设计,当所有memtable使用的内存达到此限制时,刷新记录最旧的 memtable(使用混合时间戳确定)。

struct DBOptions {
    //添加了MemoryMonitor跟踪内存使用。
    std::shared_ptr<MemoryMonitor> memory_monitor;
    ....
};
void TabletMemoryManager::FlushTabletIfLimitExceeded() {
    int iteration = 0;
    while (memory_monitor_->Exceeded()){
        //这里flush memtable
    }

Separate Queues for Large & Small Compactions

RocksDB 支持单个压缩队列并配置压缩线程池。但这会导致一些大型压缩(压缩过程读取/写入的数据量)被安排在所有这些线程上并最终导致较小的压缩饥饿的场景。这会导致过多的存储文件、写入停顿和高读取延迟。

YugabyteDB增强了RocksDB,根据输入数据文件的总大小启用多个队列,以便根据大小确定压缩的优先级。队列由可配置数量的线程提供服务,其中这些线程的特定子集被保留用于小型压缩,以便 SSTable 文件的数量不会增长过快。

这个特性感觉是有用的,据说RocksDB 7.x对manual compact时的读写延迟有所优化,需要进一步测试。总的来说,YugabyteDB对Raft协议框架的结合、对TTL的定制,索引的优化,值得持续地阅读源码。