CometBFT: Mempool
概要
mempool (メモリプール, メムプール) は未確定のトランザクション (unconfirmed transaction) を他のノードと共有し、提案ブロックの生成が行われるまで一時的に保存することを目的とした CometBFT の機構である。
mempool は、乱暴な言い方をすれば CometBFT コンセンサスの前に置かれたキューやバッファといった役割である。キューと同様に容量制限を超過するトランザクションの受信は拒否される。
なお mempool という呼称は CometBFT 固有ではなく、ブロックチェーンの同様の機構として比較的よく使われている名前である。
Table of Contents
mempool の機能は mempool
以下のパッケージで実装されている。下位のパッケージに v0
と v1
が存在し、v0
は FIFO の動作、v1
は優先キューの動作だが v1
は今後のバージョンで削除されることが決定しているため v0.37 では deprecated 扱いである。どちらの実装を使うかは設定で変更することができる (デフォルトは v0
)。この記事では v0
の実装を説明の対象とする。
mempool
パッケージに含まれている主要なクラスを Fig 2 に示す。特に Mempool
と Reactor
の 2 つが重要な役割を持っている。Mempool
は有効な未確定トランザクションを保存しておくことを目的とした mempool パッケージの中心的なクラスである。その実装となる CListMampool
は受信した有効な未確定トランザクションを順序付けられたリストとしてメモリ上に保存している。Reactor
実装は p2p 機構から得たメッセージからトランザクションを取り出して mempool に渡したり、mempool に淹れられたトランザクションを隣接するノードにブロードキャストする役割を持つ。CListMempool
も Reactor
も NewNode()
でノードを構築するときに構築される。
CListMempool クラス
CListMempool
内での未確定のトランザクションは順序付けらたリスト txs *CList
に保存されている。txsMap Map
は送信者に対するトランザクションを \(O(1)\) で検索するためのインデックス的なコレクションであり txs
と同じトランザクション (mempoolTx
) が格納されている。
未確定トランザクションの受信と保存
CometBFT ノードは Packet
メッセージを使って未確定のトランザクションを送信している (1 つのメッセージに複数の未確定トランザクションを含んでいる)。Reactor
は受信したメッセージに含まれている tendermint.mempool.Txs の各トランザクションに対して CListMempool
の CheckTx()
を実行することで、アプリケーションによって検査済みのトランザクションが CListMempool
に保管される。
Fig 3 の一連の処理シーケンスには CheckTxAsync
という呼び出しが含まれているが local, socket, grpc のいずれの実装もブロッキングを伴う同期処理で実装されており、コールバックを別のスレッドで実行して非同期のような動きに見せているだけである (なぜこのような実装のままなのかは質問中; 設計上は Promise/Future パターンに似た ReqRes
がエラーを返す方法を考慮していないため非同期処理に使用できないように見える)。
ここで未確定トランザクションの検査は ABCI を経由してアプリケーション実装まで到達することに注意。Fig 3 に示すように、現在の CometBFT 設計はこの処理をトランザクションごとに同期処理で逐次的に行っている。
CheckTx と前処理/後処理
CheckTx はノードが受信した未確定トランザクションをアプリケーション実装が受け入れ可能かを判断するフェーズである。これは無効なトランザクションをブロックに含める前に排除してブロックの容量や処理時間を削減することを目的としている。またトランザクション単体に閉じた検査のみならず、過去に Ban したアカウントからのリクエストを拒否するといったアプリケーション状態との比較も可能である (アプリケーション状態が変化したとき、つまりブロックがコミットされたときに mempool 内のすべてのトランザクションは再チェックされる)。ただし、CheckTx で渡されるトランザクションはまだ受理や順序が確定していないことからそれらの順序に依存してはならない。
CListMempool
の preCheck
と postCheck
はそれぞれアプリケーションの CheckTx
に対する前処理と後処理である。アスペクト指向的なアプローチで処理を挿入できる構造で実装されているが、内部的な柔軟性が目的であってアプリケーションから処理を注入できる構造ではない。具体的には NewNode()
の内部でそれぞれ以下のような処理が静的に設定される。
preCheck
: ブロックの最大サイズに基づく 1 トランザクションの最大サイズを検査する。ここでのトランザクションの最大サイズは Validator 数などに基づいて動的に算出される。ブロックの最大サイズはコンセンサスパラメータによって決定するが、一般にブロックサイズは
Header
やCommit
と比較して十分に大きいため、ブロックサイズに迫るような大きなトランザクションを扱わないならアプリケーション実装はこの制約を気にすることはないだろう。実際、デフォルトの最大ブロックサイズは 21MB であることから、Validator 数 10 に対するトランザクションの最大サイズも 20.998MB 程度までを許容できる (下記の設定に基づく最大サイズも参照)。preCheck
より先に設定ファイルに基づくトランザクションの最大サイズの検査 (デフォルト 1 MB) も行われていることに注意。一般に大きなトランザクションは preCheck よりもそちらのサイズ制約で検出されることになるだろう。またメッセージ受信時にPacketMsg
に対する最大サイズチェック (デフォルト 100MB) も行われており、数 GB の巨大なトランザクションを送信してサービスを妨害するような攻撃はできない。postCheck
:CheckTx
でアプリケーションの算出したトランザクション実行に必要なガス量GasWanted
が最大ガス量を超えていないかを検査する。最大ガス量はコンセンサスパラメータで決定する (デフォルト値は -1 でトランザクションあたりのガス量制限なしを意味する)。
CheckTx はブロックがコミットされたときにも mempool に保存されているすべてのトランザクションに対して再実行される。詳細は後述。
受信済みトランザクションの判定
ゴシッピングによる未確定トランザクションの伝搬は既に受信したトランザクションを繰り返し受信する機会が頻発する。mempool はまだブロックに取り込まれていないトランザクションを一時的に保存しておく領域であるため、1) 既に mempool に存在するトランザクションや、2) 既にブロックに取り込まれているトランザクションを mempool に保存しないようにする必要がある。つまり mempool は過去に "観測" したトランザクション (seen transaction) を記憶し、同じトランザクションを受信したときに無視する機構が必要になる。
CListMempool
では cache TxCache
フィールドに観測したトランザクションの TxKey
(SHA-256 ハッシュ) が保存され mempool に保存する必要のないトランザクションを検知できるようになっている。標準の LRUTxCache
クラスは Least Recently Used ポリシーで観測済みトランザクションを記憶している。
LRUTxCache
は観測済みの TxKey
をメモリー上に保持していることに注意。メモリーは揮発性であり、容量はストレージほど大きくなく、したがって長期間運用したブロックチェーンのすべての TxKey
を保持することはできない。LRU のポリシーが示すように、固定個数を超えた TxKey
は最近観測されていないものからキャッシュアウトされる。これは、トランザクションを大幅に遅延して再受信したり、再起動直後に再受信したり、大量のトランザクションが短時間に観測される負荷の高い状況では観測済みであるはずのトランザクションを mempool に保存してしまい、過去に確定済みのトランザクションが再びブロックに出現して検証に失敗する可能性があることを意味している。cache_size 値を増やしてキャッシュ領域を大きくすることもできるが、この問題の根本的な解決は CheckTx 時にアプリケーション側で既に実行済みのトランザクションかを判断するしかない (TxCache
による判断は効率化のための予防的な機構と考えたほうが良い)。
ブロードキャスト
CometBFT のノードはピア (隣接ノード) ごとに Go ルーチンを起動して broadcastTxRoutine()
のループを開始する。この処理は、mempool に送信可能なトランザクションが保存されるたびにそれをピアに送信する処理を繰り返し、ピアか mempool が終了すると終了する。Fig 4 はこの検査済み未確定トランザクションの送信処理を示したシーケンス図である。
TxsWaitChan()
は mempool に保存されているトランザクション数が 1→0 に変化すると新しく構築され、0→1 になると close 状態となるチャネルである。つまり有効なトランザクションが存在しないときの受信 <- はブロッキングとなり、存在するときの受信は常に即時で戻る (これはチャネルというよりシグナルとしての利用である)。したがってここでは送信すべきトランザクションを指していない next == nil
状態のときは mempool に有効なトランザクションが到着するまで待機し、到着後にその先頭を next
で指すという動作になる。
次に、送信すべき有効なトランザクションをピアに送信している。ここで、プロトコル設計上は Txs
を使って複数のトランザクションを送信できるが、現在の実装では #5796 のために一度に 1 トランザクションしか送信できないようになっている。
最後のステップでは次に送信すべきトランザクションに next
を移動させる。NextWaitChan()
は、次のトランザクションが非 nil から nil と変化すると新しく構築され、nil から非 nil になると close 状態となる。つまり次のトランザクションが存在しないときの受信 <- をブロッキングし、存在するときの受信は常に即時で戻る。したがってブロードキャストループの最後は mempool に次のトランザクションが到着するまで待機して次のトランザクションに移動するという動作になる。
この一連の処理をループで実行すると Mempool
に保存されているすべてのトランザクションをピアに送信し、また Mempool
に新しいトランザクションが追加されるたびに同様に送信するという動作になる。すべてのピアに対してこの動作を行うため結果的にトランザクションのブロードキャストとなり、そして各ノードがこの動作を行うことで CheckTx による検査の完了した未確定トランザクションが CometBFT ネットワークでゴシッピングされる、という動作が完成する。
設定ファイルの broadcast
を false
にすることでこのブロードキャストを行わないようにすることができる。
トランザクションが受信されてから CheckTx を経てブロードキャストされ他のノードに複製されるまでに障害等でノードが停止すると、受信で成功応答をしたにもかかわらずトランザクションがネットワークから喪失することに注意。より信頼性を高めるには、クライアントは複数の異なる CometBFT ノードに同じトランザクションを送信する必要がある。
提案ブロックの生成
mempool に保存された検査済みの未確定トランザクションは、そのノードが Proposer となったときに提案ブロックを生成するために mempool から収集される。トランザクションの取り出しを行う ReapMaxBytesMaxGas()
は未確定トランザクションの FIFO リストから指定された最大バイト長, 最大ガス量を超えない範囲で先行するトランザクションを取り出して返す。
各トランザクションの実行に必要なガス量 gasWanted は CheckTx を行ったときにアプリケーションによって設定されている。
ブロックのコミット
Validator の合意によってブロックがコミットされるとそのブロックに含まれているトランザクションは "確定済み" (confirmed) となる。確定済みトランザクションはもはやブロック生成には使われないので mempool から除外する必要がある。また確定済みだが未観測のトランザクションを後になって mempool が保存しないように "観測済み" として記憶する必要がある。
mempool の Update()
はそれらの処理を行うために呼び出される。まず DeliverTx
の結果が成功だったトランザクションを "観測済み" とし、そうでないトランザクションは将来の再実行で成功する可能性のために "未観測" とする。ただし CheckTx でアプリケーションに無効と判断されたトランザクションが将来有効になることがない設計上の確証があれば設定で無効なトランザクションを "観測済み" のままにすることもできる。
再チェック
ブロックを実行したことによってアプリケーションの状態が変わり、以前までは有効と判断された未確定トランザクションがある時点から無効の判定に変わる可能性がある。mempool の再チェック (recheck) 機構は、ブロックが実行されるたびに mempool が保存しているすべてのトランザクションに対して再度 CheckTx を実行し、無効なトランザクションを mempool から除外する処理を行う。
しかし、ABCI の API 設計はバッチ化が行われていないため大量のトランザクションに CheckTx を実行するには (特に gRPC 経由で) オーバーヘッドが大きい。もしアプリケーションに有効と判断されたトランザクションが将来無効になることがない、あるいはそのようなトランザクションがブロックに含まれるオーバーヘッドが無視できる程度という設計上の確証があれば設定で再チェックを無効化することでパフォーマンスの改善を期待できるだろう。
チェックの結果が冪等ではなく、外部のアプリケーション状態に依存してトランザクションに閉じていないという設計はあまり良くないという見方もある。これはステートレス (設計のシンプルさ) vs ステートフル (全体の効率) という考え方の違いであるためどちらが正しいということはないが、再チェックをオフにする前提で、可能な限りブロック上に無効なトランザクションが発生しない設計を考慮すべきである。
なお、再チェックでは preCheck, postCheck の処理は行われない。