豪鬼メモ

一瞬千撃

DBサービスを作ろう その11 非同期レプリケーション完成!

ついに、データベースサーバTkrzw-RPCに非同期レプリケーション機能がついた。マスタ・スレーブ構成だけでなく、デュアルマスタ構成もサポートして、オンラインシステムのバックエンドとして利用できる高可用性を備えたシステムになった。Tkrzw-RPC-0.9.0から利用できる。
f:id:fridaynight:20210925210207p:plain

高可用性と非同期レプリケーション

高可用性とは、サーバに障害が起きてもダウンタイムなしで運用を続けるという概念だ。データレプリケーションとは、同じデータを複数のマシンに保存する機能であり、高可用性を実現するための共通の方法である。Tkrzw-RPCは非同期レプリケーションを実装する。時間的には非同期に、データベースの内容を同期させる機能とも言える。クライアントの更新クエリを直接受け取るサーバをマスタと呼び、その更新を間接的に受け取るサーバをスレーブと呼ぶ。

非同期レプリケーションでは、マスタが各更新クエリを処理する際に、スレーブに更新が伝達されたことを待つことなく、処理を完了させる。したがって、レプリケーションのためにレイテンシが増加しないという利点がある一方、データが確実に多重化されたことが保証されない状態で結果を返すという欠点がある。マスタは各更新クエリの内容を「更新ログ」としてローカルに保存する。スレーブはその更新ログの更新を監視して読み出すことで、更新内容を取得する。
f:id:fridaynight:20210925210221p:plain

コスパ良く高可用性を実現するには、非同期レプリケーションの方が優れている。同期レプリケーションは本体とレプリカの両方が生きていないと実行できないので、高可用性のためには少なくともレプリカが2台必要となり、3台が最小構成になる。非同期レプリケーションの場合はレプリカが死んでいる時間も運用できるので、2台が最小構成になる。どちらが良いかは予算とレイテンシと信頼性の要求によって決まるが、とりあえずTkrzw-RPCは非同期レプリケーションを実装している。ネットワーク層のオーバーヘッドが大きいとDBMの高速性を活かせなくなるからだ。ハードウェア障害が発生した瞬間に行われていた更新は消える可能性があり、その意味では絶対的な信頼性は確保できないので決済システムなどには向かない。しかし、それ以外の、万が一消えてもゴメンで済むようなコンテンツの管理には向いている。例えば、SNS上の投稿やイイネとかが万が一消えたとしてもゴメンで済むだろう。

多少の遅延を伴うが、マスタとスレーブは同じ内容のデータベースを持つことになる。よって、クライアントはどちらのデータベースに検索クエリを投げても同じ結果を得ることができる。負荷を分散させるために、2つ以上のスレーブを設置して、ロードバランシングを行うのも有用だ。ただし、データの一貫性を維持するためには、更新は必ずマスタに行うべきだ。

マスタが死んだ場合、スレーブの中の一つが新たなマスタに昇進する。その他のスレーブがいれば、それらは新しいマスタに追従するように再設定される。スレーブの欠員は新たなスレーブを立てて補填する。スレーブを立てるには、バックアップとして作成したデータベースファイルを用いる。バックアップを作成した時点から最新までの更新が自動的にマスタから取得され、新たなスレーブのデータベースは同期される。通常、スレーブも更新ログを記録するように設定され、いつでもマスタに昇格できるように備える。

レプリケーションクライアント

マスタサーバを実際に立てて、それをクライアントユーティリティを使って監視してみよう。マスタを立てるには、サーバIDを指定して、さらに更新ログファイルの接頭辞も指定する。以下のようなコマンドになる。

$ tkrzw_server --address "localhost:1978" \
  --threads 8 --log_level debug \
  --server_id 1 --ulog_prefix casket-1-ulog \
  "casket-1.tkh"

別のターミナルを開いて、いくつか更新クエリをマスターに投げる。これにより、マスタのデータベースは「one」と「two」というキーの二つのレコードを持つようになる。この二つの更新は更新ログにも記述される。

$ tkrzw_dbm_remote_util set --address localhost:1978 one first
$ tkrzw_dbm_remote_util set --address localhost:1978 two second

更新ログの内容を調べてみよう。以下のコマンドは、現在の更新ログの内容を表示した上で、さらなる更新を待機して停止状態になる。この端末は停止状態のまま放置しておこう。

$ tkrzw_dbm_remote_util replicate --address localhost:1978
1632472502596  1  0  SET  one  first
1632472506668  1  0  SET  two  second

別の端末から、マスタにさらなる更新クエリを送り込む。その分の更新ログが追記されるわけだが、それは即座に読み出されて、上述の端末に表示される。それを確認したら、上述の端末はCtrl-Cを入力して閉じて良い。

$ tkrzw_dbm_remote_util set --address localhost:1978 three third
$ tkrzw_dbm_remote_util set --address localhost:1978 four fourth

更新ログを取得して、ローカルにあるデータベースに適用することもできる。データベースのパスを指定すると、そのデータベースに更新ログが適用されることになる。そのデータベースが存在しなければ、新たに作成される。また、"--ts_file" オプションでタイムスタンプファイルも作成する。どの時点までの更新を取得したかを記録するものだ。さらに、"--wait 0" と "--items 0" を指定することで、現時点までの更新ログを全て取得した時点で処理を終了させる。

$ tkrzw_dbm_remote_util replicate --address localhost:1978 \
  --ts_file casket-local.ts --wait 0 --items 0 casket-local.tkh
Opening DBM: casket-local.tkh
Using the minimum timestamp: 0
Connecting to the server: localhost:1978
Doing replication: localhost:1978
Done: timestamp=1632474357697

作成されたデータベースに、今までの更新が全て入っているかを確認しよう。

$ tkrzw_dbm_util list casket-local.tkh
three  third
two    second
four   fourth
one    first

この方法で、更新ログを備えるサーバがあれば、それを止めることなく、バックアップデータベースがいつでも作れる。基本的には、マスタに問い合わせてバックアップを作る。マスタが複数のデータベースを運用している場合、ローカルでもそれに対応する数のデータベースを指定する。タイムスタンプファイルのおかげで、同一コマンドを実行すればバックアップをいつでも最新状態に追従させられる。

マスタ-スレーブ構成

レプリケーションの最も単純な構成は、一つのマスタと一つのスレーブからなる。上述の例で使ったマスタのサービスをそのまま再利用しよう(よって、落としている場合は再起動されたい)。スレーブサーバを起動させるには、その前にバックアップファイルとタイムスタンプファイルを持ってくるのが普通だ。これも上述の例で使ったものを再利用する。そして、以下のコマンドでスレーブサーバを起動する。マスタサーバが既に1978番ポートを使っているので、スレーブには1979番を割り当てる。サーバIDもユニークなものを割り当てる。スレーブがいつでもマスタに昇進できるように、更新ログも記録させる。

$ cp casket-local.tkh casket-2.tkh
$ cp casket-local.ts casket-2.ts
$ tkrzw_server --address "localhost:1979" \
  --threads 8 --log_level debug \
  --server_id 2 --ulog_prefix casket-2-ulog \
  --repl_master "localhost:1978" --repl_ts_file casket-2.ts --repl_ts_skew -10000 \
  "casket-2.tkh"

タイムスタンプファイルに記録された時点から現在までの間にマスタ上で行われた更新ログは自動的に取得されて、スレーブ上のデータベースにも適用される。もちろん、それ以降の更新も即時に取得されて適用される。かくしてスレーブとマスタのデータベースは同期し続ける。これを確かめるために、マスタにいくつか更新をかけてから、それがスレーブに来ているか見てみよう。

$ tkrzw_dbm_remote_util set --address localhost:1978 five fifth
$ tkrzw_dbm_remote_util set --address localhost:1978 six sixth

$ tkrzw_dbm_remote_util list --address localhost:1979
five   fifth
three  third
two    second
four   fourth
one    first
six    sixth

さて、マスタが死んだ状況を再現しよう。マスタの端末でCtrl-Cを入力するなどしてマスタを停止させる。その上で、新しいスレーブを加える。今回は、新しいスレーブに新しいマスタのアドレスは教えないで起動して、後で指定することにする。

$ cp casket-local.tkh casket-3.tkh
$ cp casket-local.ts casket-3.ts
$ tkrzw_server --address "localhost:1980" \
  --threads 8 --log_level debug \
  --server_id 3 --ulog_prefix casket-3-ulog \
  --repl_ts_file casket-3.ts \
  "casket-3.tkh"

現時点ではレプリケーションは開始していない。よって、新たに更新した「five」と「six」のレコードはスレーブに存在しない。

$ tkrzw_dbm_remote_util list --address localhost:1980
three  third
two    second
four   fourth
one    first

スレーブに動的にマスタを教えて、レプリケーションを開始しよう。このケースでは、さっきまでスレーブだったポート1979のサーバを新しいマスタにする。自分はポート1980だ。"--ts_skew -10000" が意味するのは、タイムスタンプファイルに記録してあるタイムスタンプよりも10秒(10000ミリ秒)前の時点からレプリケーションを開始せよということだ。新しいマスタの時刻がちょっとずれていたとしてもレプリケーションのギャップができないようにするためだ。更新ログの冪等性により、同じ更新を複数回適用するのは問題ない。

$ tkrzw_dbm_remote_util changemaster --address localhost:1980 \
  --ts_skew -10000 localhost:1979

スレーブのデータベースの内容がきちんと同期されているかどうかを確かめよう。

$ tkrzw_dbm_remote_util list --address localhost:1980
five   fifth
three  third
two    second
four   fourth
one    first
six    sixth

一貫性を確保するためには更新クエリはマスタにのみ発行することが許されるが、参照クエリはマスタに発行してもスレーブに発行してもよい。参照クエリがやたら多い場合、スレーブを2つ以上立ててロードバランシングするのも実践的な方法だ。というか、この方法で負荷分散ができるのもレプリケーションの大きな利点と言えよう。

レプリケーションのタイムスタンプは常にマスタから与えられる必要がある。タイムスタンプとは、レプリケーションを再開するためのブレークポイントともみなせるものだ。タイムスタンプファイルの内容はマスタから与えられたものであり、ローカルの時計とは独立している。よって、スレーブの時計がたとえずれていたとしても、レプリケーションは正常に動作する。しかし、マスタが死んでスレーブが新たなマスタになった場合、タイムスタンプは新たなマスタの時系列で管理されねばならない。その移行で生じうるギャップを埋めるために、"--ts_skew" や "--repl_ts_skew" というオプションがある。

デュアルマスタ構成

マスタ-スレーブ構成は高可用性運用の基礎をなすが、短いながらも更新系のダウンタイムが生じうるというリスクを抱えている。マスタが死んでから新たなマスタが選出されてクライアントに通知されるまでの間、クライアントは更新クエリを投げることができないのだ。そのワークアラウンドの一つは、マスタに接続できない場合には「筆頭」のスレーブをマスタとみなしてそこに更新クエリを投げるというものだ。しかし、この方法は不整合を起こすリスクがある。とあるクライアントからの通信が途絶していたとしても、マスタは死んでいるとは限らないからだ。スレーブを更新してしまった場合で、そのスレーブが実際にはマスタにならなかった場合、その更新は失われてしまう。

デュアルマスタ構成はこの問題を解決する。マスタが筆頭スレーブをレプリケーションすれば、間違って筆頭スレーブを更新してしまった場合にも、その更新はマスタに伝搬する。デュアルマスタ構成の文脈では、主に更新を受け取るマスタを「アクティブマスタ」と呼び、筆頭スレーブを「スタンバイマスタ」と呼ぶ。同じレコードがアクティブマスタとスタンバイマスタで同時に更新されると不整合が起きうるために、基本的には更新クエリはアクティブマスタのみに投げるものとする。アクティブマスタが応答しない場合にのみ、スタンバイマスタを使う。こうすることで、不整合がごく稀に起きるリスクを許容しつつ、更新系も含めてダウンタイムをほぼゼロにできる。

実際にデュアルマスタ構成を動かしてみよう。アクティブマスタであるところのサーバ1はポート1978で動かし、スタンバイマスタであるところのサーバ2はポート1979で動かす。それらは互いをレプリケーションする。

$ tkrzw_server --address "localhost:1978" \
  --threads 8 --log_level debug \
  --server_id 1 --ulog_prefix casket-1-ulog \
  --repl_master "localhost:1979" --repl_ts_file casket-1.ts --repl_ts_skew -10000 \
  "casket-1.tkh"
$ tkrzw_server --address "localhost:1979" \
  --threads 8 --log_level debug \
  --server_id 2 --ulog_prefix casket-2-ulog \
  --repl_master "localhost:1978" --repl_ts_file casket-2.ts --repl_ts_skew -10000 \
  "casket-2.tkh"

片方に投げた更新はもう片方にも伝搬することを確かめよう。アクティブマスタにもスタンバイマスタにも更新をかける。

$ tkrzw_dbm_remote_util set --address "localhost:1978" japan tokyo
$ tkrzw_dbm_remote_util get --address "localhost:1979" japan
tokyo
$ tkrzw_dbm_remote_util set --address "localhost:1979" korea seoul
$ tkrzw_dbm_remote_util get --address "localhost:1978" korea
seoul

アクティブマスタが死んだ場合を想定しよう。アクティブマスタの端末でCtrl-Cを入力して停止させる。サーバ2は新たなアクティブマスタとみなされる。それから、サーバ3をポート1980で立てて、新たなスタンバイマスタとする。今回は存在しないデータベースファイルを指定しているので、空のデータベースが作られる。

$ tkrzw_server --address "localhost:1980" \
  --threads 8 --log_level debug \
  --server_id 3 --ulog_prefix casket-3-ulog \
  --repl_master "localhost:1979" --repl_ts_file casket-3.ts --repl_ts_skew -10000 \
  "casket-3.tkh"

新たなデータベースが自動的に同期されていることを確認しよう。

$ tkrzw_dbm_remote_util list --address "localhost:1980"
japan   tokyo
korea   seoul

新たなアクティブマスタのマスタを新たなスタンバイマスタに切り替える。

$ tkrzw_dbm_remote_util changemaster --address localhost:1979 \
  --ts_skew -10000 localhost:1980

相互のレプリケーションを確認する。

$ tkrzw_dbm_remote_util set --address "localhost:1979" china beijing
$ tkrzw_dbm_remote_util get --address "localhost:1980" china
beijing
$ tkrzw_dbm_remote_util set --address "localhost:1980" france paris
$ tkrzw_dbm_remote_util get --address "localhost:1979" france
paris

更新ログの削除

運用を続けると、更新ログは各々のサーバに蓄積していく。デフォルトでは、ファイルサイズが1GBを超えると新たなファイルが作られ、ファイル名にはIDが接尾辞としてつけられる。IDが大きいほど新しいということだ。バックアップデータベースを作成した後には、古い更新ログは必要なくなるので、消しても構わない。各更新ファイルのタイムスタンプは以下のコマンドで確認できる。

$ tkrzw_ulog_util listfiles casket-ulog
./casket-ulog.0000000000    0    1632561080615    5.8 days    1073741824
./casket-ulog.0000000001    1    1632652635815    4.6 days    1073743335
./casket-ulog.0000000002    2    1632744191015    3.5 days    1073748723
./casket-ulog.0000000003    3    1632835746215    2.4 days    1073745432
./casket-ulog.0000000004    4    1632927301415    1.3 days    1073742564

運用中のサーバの更新ログファイルでも、最新のもの以外は消しても動作に支障はない。最新のもの以外で3日以上古い更新ログファイルを消すならば、以下のコマンドを実行する。"-3D"のマイナス記号を決して忘れないこと。

$ tkrzw_ulog_util removeoldfiles --timestamp "-3D" --exclude_latest casket-ulog

以上のことを知っていれば、高可用性を備えたデータベースサービスが運用できるはずだ。10年以上前にKyoto Tycoonで実装した機能なのだが、gRPCとC++17を使ったモダンな実装になって帰ってきた。低水準を旨とするTkrzwなので、サーバの仕様もいたって低水準だ。低水準なツールを組み合わせて高水準なことをするのがトンプソン先生のUNIX哲学であり、その具現化の一つであるDBMの継承者であるところのTkrzwとそのサーバもまた低水準であり続ける。

この例では同じマシンで何個もサーバを立てたが、実際の運用環境では複数のマシンを使うだろう。さらに、単一の論理的なデータベースを複数のマシンにシャーディング(水平分散)して、そのそれぞれのシャードがデュアルマスタ構成を取ることもできる。もしサーバの数が10を超えるようならば、監視と設定に関して何らかの自動化が必要となるだろう。それをどうするかはTkrzw-RPCのスコープの外である。サービス設計に応じて適当にやってもらうスタンスだ。

まとめ

データベースライブラリTkrzwのRPC機能を担うTkrzw-RPCにて、非同期レプリケーションを実装した。非同期であることで、低いレイテンシで高い可用性を実現できる。デュアルマスタ構成を取れば、更新系のダウンタイムも実質ゼロにできる。各種ユーティリティにより、バックアップの作成や更新ログの削除などの運用も自由に設計できる。次回はおそらく最終回で、レプリケーションの設計や実装の詳細について語る。