OceanBaseデータベースはマルチテナントデータベースシステムであり、テナント間でのリソース競合を防ぎ、業務の安定した運用を保証するために、テナント間のリソースを分離しています。
OceanBaseデータベースでは、ユニットをテナントへのリソース割り当ての基本単位としています。1つのユニットは、Dockerコンテナに例えることができます。1つのノード上に複数のユニットを作成でき、ノード上でユニットを1つ作成するごとに、そのノードのCPU、メモリなどの物理リソースの一部を占有します。ノードのリソース割り当て状況は内部テーブルに記録され、DBAが確認できます。
1つのテナントは複数のノード上に複数のユニットを配置できますが、あるノード上においては1つのテナントにつき1つのユニットしか存在できません。テナントの複数のユニットは相互に独立しており、OceanBaseデータベースでは現在、複数のユニットのリソース使用量を集計してグローバルなリソース制御を行う仕組みはありません。具体的には、あるテナントがあるノード上でリソースを満たせない場合でも、別のノードで他のテナントのリソースを奪うことはありません。
いわゆるリソース分離とは、ノードがローカルの複数のユニット間でのリソース割り当てを制御する行為であり、それはノード固有の動作です。類似の技術としてDockerや仮想マシンがありますが、OceanBaseデータベースはDockerや仮想マシン技術に依存せず、データベース内部でリソース分離を実現しています。
OceanBaseデータベースのテナント隔離の利点
Dockerや仮想マシンと比較して、OceanBaseデータベースのテナント隔離はより軽量であり、優先順位などの高度な機能の実装も容易です。 OceanBaseデータベースの要件から見ると、Dockerや仮想マシンには主に以下の問題があります:
Dockerまたは仮想マシンの実行環境のオーバヘッドが大きすぎるため、OceanBaseデータベースは軽量級のテナントをサポートする必要があります。
Dockerまたは仮想マシンのスペック変更および移行にかかるオーバヘッドが大きく、OceanBaseデータベースではテナントのスペック変更と移行をできるだけ迅速に行いたいと考えています。
Dockerまたは仮想マシンでは、テナント間のリソース共有(例:オブジェクトプールの共有)が不便です。
Dockerまたは仮想マシンのリソース隔離はカスタマイズが難しく、例えばテナント内での優先順位サポートが困難です。
さらに、Dockerや仮想マシンの実装では統一されたビューを提供することが不便です。
一般ユーザーの視点から見た分離効果
一般ユーザーの視点から見た分離効果は以下のとおりです:
メモリは完全に分離されています。
具体的には以下が含まれます:
SQL実行プロセスにおける各演算子が使用するメモリは分離されており、あるテナントのメモリ枯渇が別のテナントに影響を与えることはありません。
SQL実行プロセスの各演算子の詳細については、ユーザーインターフェースとクエリ言語の章を参照してください。
Block CacheとMemTableは分離されており、あるテナントのメモリ枯渇が別のテナントの書き込みや読み取りに影響を与えることはありません。
Block Cacheの詳細については、マルチレベルキャッシュを参照してください。
MemTableの詳細については、MemTableを参照してください。
CPUはユーザー空間スケジューリングによって分離されます。
一つのテナントが使用できるCPUリソースはUnit仕様によって決定されます。
より良い分離効果を得るため、V4.0.0バージョンからは、cgroupを設定してCPU分離を行うことがサポートされています。
異なるテナントのSQLモジュールは互いに影響しません。
具体的には以下が含まれます:
SQLのPlan Cacheはテナントごとに分離されており、あるテナントのPlan Cacheのフラッシュが別のテナントに影響を与えることはありません。
SQLのAuditテーブルは分離されており、あるテナントのQPSが高すぎても、別のテナントのAudit情報がフラッシュされることはありません。
異なるテナントのトランザクションモジュールは互いに影響しません。
具体的には以下が含まれます:
あるテナントの行ロックの一時停止は、他のテナントに影響しません。
あるテナントのトランザクションの一時停止は、他のテナントに影響しません。
あるテナントのリリースに問題が発生しても、他のテナントに影響しません。
異なるテナントのCLOGモジュールは互いに影響しません。
ログストリームのディレクトリはテナント単位であり、最終的にディスク上での実装は以下のように表現されます:
tenant_id ├── unit_id_0 │ ├── log │ └── meta ├── unit_id_1 │ ├── log │ └── meta └── unit_id_2 ├── log └── meta
リソースの分類
OceanBaseデータベースは設計上、V4.0.0バージョンからテナント間のCPU、メモリ、IOPSの分離をサポートしています。1つのUnitでは、CPU、メモリ、IOPS、log_disk_sizeの4種類のリソースを指定できます。
obclient> CREATE RESOURCE UNIT name
MAX_CPU = 1, [MIN_CPU = 1,]
MEMORY_SIZE = '5G',
[MAX_IOPS = 1024, MIN_IOPS = 1024, IOPS_WEIGHT=0,]
[LOG_DISK_SIZE = '2G'];
ノードの利用可能なCPU
ノード起動時に物理マシンまたはコンテナのオンラインCPU数を検出しますが、ノードの検出が不正確な場合(例:コンテナ環境など)、cpu_count構成パラメーターで指定することもできます。
ノードはバックグラウンドスレッド用に2つのCPUを予約するため、テナントに実際に割り当て可能なCPUの総数は2つ減ります。
ノードの利用可能なメモリ
ノード起動時に物理マシンまたはコンテナのメモリを検出します。ノードは他のプロセス用にメモリの一部を予約する必要があるため、observerプロセスの利用可能なメモリは物理メモリ * memory_limit_percentageに等しくなります。memory_limit構成パラメーターを使用して、observerプロセスが使用可能なメモリサイズを直接設定することもできます。
observerプロセスが使用可能なメモリから、内部共有モジュールが使用するメモリ分をさらに差し引く必要があります。このメモリサイズはsystem_memory構成パラメーターで指定され、残りのメモリがテナントが使用可能な総メモリとなります。
メモリ設定の詳細については、メモリ管理の章を参照してください。
各ノードの利用可能なリソースの確認
oceanbase.GV$OB_SERVERSビューを使用して、各ノードの利用可能なリソースを確認できます。例:
obclient> SELECT * FROM oceanbase.GV$OB_SERVERS\G
*************************** 1. row ***************************
SVR_IP: 172.xx.xxx.xxx
SVR_PORT: 2882
ZONE: zone1
SQL_PORT: 2881
CPU_CAPACITY: 64
CPU_CAPACITY_MAX: 64
CPU_ASSIGNED: 8
CPU_ASSIGNED_MAX: 8
MEM_CAPACITY: 75161927680
MEM_ASSIGNED: 18253611008
LOG_DISK_CAPACITY: 179583320064
LOG_DISK_ASSIGNED: 56103010304
LOG_DISK_IN_USE: 1409286144
DATA_DISK_CAPACITY: 179593805824
DATA_DISK_ASSIGNED: NULL
DATA_DISK_IN_USE: 207618048
DATA_DISK_HEALTH_STATUS: NORMAL
MEMORY_LIMIT: 107374182400
DATA_DISK_ALLOCATED: 179593805824
DATA_DISK_ABNORMAL_TIME: NULL
SSL_CERT_EXPIRED_TIME: NULL
SS_DATA_DISK_OPERATION_SUGGESTED: NULL
SS_DATA_DISK_SIZE_SUGGESTED: NULL
1 row in set
リソース分離
メモリの分離
ノードのメモリリソースは実際にはオーバーコミットをサポートしておらず、メモリのオーバーコミットを導入するとテナントの動作が不安定になるため、V4.0.0以降のOceanBaseデータベースではメモリのオーバーコミットはサポートされなくなりました。MIN_MEMORY と MAX_MEMORY の構成パラメータは廃止され、代わりに MEMORY_SIZE パラメータが採用されています。
CPUの分離
OceanBaseデータベースV3.1.x以前では、主にスレッド数を制御することでCPU使用率を制御していました。OceanBaseデータベースV3.1.x以降では、cgroupを設定することでCPU使用率を制御できます。
スレッドの分類
ノードは多様な機能を持つスレッドを多数起動します。詳細な分類については、observerスレッドを参照してください。このセクションでは、最も大まかな基準に従って以下の2つのカテゴリに分けます:
一つはSQL処理やトランザクションコミットを行うスレッドで、これらは総称してテナントワーカースレッドと呼ばれます。
残りはネットワークI/O、ディスクI/O、Compaction、タイマー処理を行うスレッドで、これらは総称してバックグラウンドスレッドと呼ばれます。
現在のバージョンでは、テナントワーカースレッドとほとんどのバックグラウンドスレッドはテナントごとに分かれていますが、ネットワークスレッドは共有されています。
スレッド数に基づくテナントワーカースレッドのCPU分離
UnitのCPU分離は、そのUnitのアクティブなテナントワーカースレッド数によって実現されます。
SQL実行中にI/O待ちやロック待ちなどが発生する可能性があるため、一つのスレッドが物理CPUをフル活用することはできません。そのため、デフォルト設定では、OBServerノードは各CPUに対して4つのスレッドを起動します。この4という倍数は cpu_quota_concurrency の設定で変更できます。これは、Unitの MAX_CPU が10の場合、同時に実行可能なアクティブスレッド数は40であることを意味します。
cgroupに基づくテナントワーカースレッドのCPU分離
cgroupを有効にすると、最大の変化は異なるテナントのワーカースレッドが異なるcgroupディレクトリに配置されることで、テナント間のCPU分離効果が向上することです。最終的な分離効果は以下の通りです:
一つのOBServer上で一つのテナントの負荷が高く、他のテナントが比較的空いている場合、その負荷の高いテナントのCPUも
MAX_CPUの制限を受けます。上記のシナリオを続けると、複数の空いているテナントの負荷が上昇し、物理CPUが不足した場合、cgroupはウェイトに応じてタイムスライスを割り当てます。
バックグラウンドスレッドのCPU分離
V4.0.0以降では、バックグラウンドスレッドがテナントごとに分割され、各テナント内で対応するスレッドが管理されます。
より良い分離効果を得るため、バックグラウンドスレッドも異なるcgroupディレクトリに配置され、テナント間の分離およびテナント内でのワーカースレッドとの分離が実現されます。
大規模クエリの処理
私たちは、短いクエリを迅速に返す方がユーザーにとってより意味があると考えています。つまり、大規模クエリの優先順位は低くなります。大規模クエリと短いクエリが同時にCPUを競合する場合、システムは大規模クエリのCPU使用を制限します。
スレッドが実行するSQLクエリの実行時間が長すぎる場合、そのクエリは大規模クエリと判定されます。一度大規模クエリと判定されると、そのクエリを実行するスレッドはPthread Conditionで待機し、他のテナントのワーカースレッドにCPUを譲ります。
具体的な実装では、OBServerノードはコードに多くのチェックポイントを挿入しています。テナントワーカースレッドは実行中にチェックポイントを定期的に確認し、停止すべきと判断された場合はPthread Conditionで待機し、適切なタイミングで再開されます。
大規模クエリと小規模クエリが同時に存在する場合、大規模クエリはテナントワーカースレッドの最大30%を占有します。この30%という割合は構成パラメータ large_query_worker_percentage で設定できます。
説明が必要な点が2つあります:
クエリが存在しない場合、大規模クエリはテナントのワーカースレッドを100%使用できます。大規模クエリと小規模クエリが同時に存在する場合にのみ、30%の比率が適用されます。
テナントのワーカースレッドが大規模クエリの実行によりスレッド停止状態になった場合、補償としてシステムは新しいテナントワーカースレッドを作成する可能性があります。ただし、テナントのワーカースレッドの総数は
MAX_CPUの10倍を超えることはできません。この10倍の倍数は構成パラメータworkers_per_cpu_quotaで設定できます。
大規模クエリの事前特定
ノードが大規模クエリスレッドをスレッド停止状態にするたびに、新しいテナントワーカースレッドが起動します。しかし、大量の大規模クエリが押し寄せた場合、OBServerノードが新規作成するスレッドも依然として大規模クエリの処理に使用され、すぐにテナントワーカースレッドの上限に達してしまいます。その結果、この一連の大規模クエリが終わるまで、短いクエリを処理する機会が得られなくなります。
このシナリオを最適化するため、OBServerノードはSQLの実行開始前に、それが大規模クエリかどうかを事前に判断します。この事前判断の本質は、SQLの実行時間を推定することです。事前判断は主に以下の仮定に基づいています:もし2つのSQLの実行計画が同じであれば、それらの実行時間も似ていると推測できます。そうすることで、計画の直近の実行時間を用いて、SQLが大規模クエリかどうかを判断できます。
特定のSQLが大規模クエリと事前判断された場合、そのクエリは特別な大規模クエリキューに入れられ、そのスレッドは解放され、システムは後続のリクエストの処理を続けます。
モニタリング項目とログ
ログで dump tenant info キーワードを検索すると、テナントの仕様、スレッド、キュー、リクエスト統計などの情報が確認できます。このログは各テナントごとに30秒ごとに出力されます。
ログ例:
grep 'dump tenant info.tenant={id:1002' log/observer.log | sed 's/,/,\n/g'
[2022-07-20 14:55:40.774143] INFO [SERVER.OMT] run1 (ob_multi_tenant.cpp:1993) [80700][MultiTenant][T0][Y0-0000000000000000-0-0] [lt=621]
dump tenant info(tenant={id:1002, //テナントID
tenant_meta:{unit:{tenant_id:1002, //tenant_metaはテナントメタデータで、Unit構成情報、SuperBlock、およびcreate_statusが含まれます
unit_id:1001, //Unit ID
has_memstore:true, //MemTableの有無
unit_status:"NORMAL", //Unitの状態: UNIT_NORMAL/UNIT_MIGRATE_IN/UNIT_MIGRATE_OUT/UNIT_MARK_DELETING/UNIT_WAIT_GC_IN_OBSERVER/UNIT_DELETING_IN_OBSERVER/UNIT_ERROR_STAT
config:{unit_config_id:1003, //Unitの設定ID
name:"2c2g", //Unit設定名
resource:{min_cpu:2, //Unit設定の最小CPU数
max_cpu:2, //Unit設定の最大CPU数
memory_size:"1.5GB", //Unit設定のメモリサイズ
log_disk_size:"5.4GB", //Unit設定のログディスクサイズ
min_iops:20000, //Unit設定の最小ディスクIOPS
max_iops:20000, //Unit設定の最大ディスクIOPS
iops_weight:2}}, //Unit設定のIOPS重み
mode:1, //CompatMode 0:MYSQL 1:ORACLE
create_timestamp:1658298418435426, //Unitの作成時間
is_removed:false}, //このUnitが削除フラグを付けられているかどうか
super_block:{tenant_id:1002, //ObTenantSuperBlock情報
replay_start_point:ObLogCursor{file_id=1, //テナントslogのログ再生ポイント
log_id=440,
offset=343322},
ls_meta_entry:[140](ver=0, //テナントls metaのslogチェックポイントエントリポイント
mode=0,
seq=139),
tablet_meta_entry:[141](ver=0, // テナントtablet metaのslogチェックポイントのエントリポイント
mode=0,
seq=140)},
create_status:1}, // テナントの作成状態: CREATING = 0,CREATE_COMMIT=1,CREATE_ABORT=2,DELETING=3,DELETE_COMMIT=4
unit_min_cpu:"2.000000000000000000e+00", //保証される最小CPUコア数
unit_max_cpu:"2.000000000000000000e+00", //制限上限の最大CPUコア数
slice:"0.000000000000000000e+00",
slice_remain:"0.000000000000000000e+00",
token_cnt:8, //スケジューラーが割り当てたトークン数。1つのトークンは1つのワーカースレッドに変換されます
sug_token_cnt:8,
ass_token_cnt:8, //テナントが現在確認しているトークン数(token_cntに基づいて確認されます。通常は両者は等しいです)
lq_tokens:2, //Large Queryトークンの個数。token_cntに大規模リクエストの割合を掛けて設定します
used_lq_tokens:0, //現在LQ Tokenを保持しているWorker数
stopped:false, //テナントunitが削除中かどうか
idle_us:6076629, //1ラウンド(10秒)におけるワーカースレッドのアイドル総時間。いわゆるアイドルは、実際には待機キューでの時間のみが集計されます
recv_hp_rpc_cnt:1, //テナントが累計で受信した異なるレベルのRPCリクエスト数。hp(High)、np(Normal)、lp(Low)
recv_np_rpc_cnt:5,
recv_lp_rpc_cnt:0,
recv_mysql_cnt:24, //テナントが累計で受信したMySQLリクエスト数
recv_task_cnt:1, //テナントが累計で受信した内部タスク数
recv_large_req_cnt:0, //テナントが累計で予測した大規模リクエスト数。増加するだけで、リセットされません。実際には再試行時に増加します。
tt_large_quries:0, //テナントが累計で処理した大規模リクエスト数。増加するだけで、リセットされません。実際にはチェックポイントチェック時に増加します。
pop_normal_cnt:1024555,
actives:8, //アクティブなワーカースレッド数。通常はworkersと等しく、その差には以下が含まれます:テナントワーカースレッドキャッシュ + ワーカースレッドを持つ大規模リクエストキャッシュ
workers:8, //テナントが保持しているワーカースレッド数。実際にはworkers_このリストのサイズです。
nesting workers:7, //テナントが保持しているネストリクエスト専用スレッド数。合計7つのスレッドが7つのネストレベルに対応します。
lq waiting workers:0, //大規模クエリと判断され、スケジューリングを待機しているワーカースレッド
req_queue:total_size=0 queue[0]=0 queue[1]=0 queue[2]=0 queue[3]=0 queue[4]=0 queue[5]=0 , //異なる優先順位のワークキュー。数字が小さいほど優先順位が高い
large queued:0, //現在予測された大規模リクエストの個数
multi_level_queue:total_size=0 queue[0]=0 queue[1]=0 queue[2]=0 queue[3]=0 queue[4]=0 queue[5]=0 queue[6]=0 queue[7]=0 , //ネストリクエストを格納するワークキュー。1~7が7つのネストレベルに対応します(queue[0]は一時的に使用せず、queue[5]もInnerySQLリクエストを格納します)。
recv_level_rpc_cnt:cnt[0]=0 cnt[1]=0 cnt[2]=0 cnt[3]=0 cnt[4]=0 cnt[5]=0 cnt[6]=0 cnt[7]=0 ,
group_map:group_id = 1, //group_mapの後には、各group_idに対応するgroupのスレッドとキュー状況が続きます
queue_size = 0, //groupキューのキュー状況
recv_req_cnt = 13526, //groupがキューにプッシュした累計リクエスト数
pop_req_cnt = 13526, //groupスレッドがポップした累計リクエスト数
token_cnt = 2, //groupに割り当てられたトークン数。1つのトークンは1つのワーカースレッドに変換されます
min_token_cnt = 2,
max_token_cnt = 2,
ass_token_cnt = 2 , //groupが現在確認しているトークン数(token_cntに基づいて確認されます。通常は両者は等しいです)
rpc_stat_info: pcode=0x150a:cnt=1489 pcode=0x150b:cnt=1091}) //テナントが一定期間内に受信した最も多いRPC pcode。統計期間は10秒で、最も多い上位5つを出力します