T16 · データベースとチェーンのデータ
チェーンのデータを、どうデータベースへ保存するか?
- 練習する能力
- BuilderOnchain Literacy
- 実践
- プロトコルの送金イベント用のテーブルを設計し、重複登録しても壊れない書き込み処理を作る。
- AI Lab
- AI にスキーマを設計させ、チェーン再編成で不整合なデータが生じないか自分で検証する。
現実の問題
3 フィールドの表を作りました。
create table balances (
address text primary key,
token text,
amount numeric
);索引器は送金イベントを読むたびに update します。初日は数値が正しく見え、満足しました。
3 日目、運営担当から、一つのアドレスの残高に一件足りないと言われます。
表のアドレス、トークン、数値を眺めて、気付きます。
その数値がどう生まれたかを調べる手段がありません。
数百回の増減の累積です。どの取引が欠けたか、どのブロックか、入金漏れか出金の二重計上か、今日の誤りか 3 日前からの誤りかも分かりません。
できるのは全削除して最初から再実行することだけです。一致しても原因が不明なので、再発するかも分かりません。
3 日後、また不一致になりました。
バグではなく、データモデルの問題です。 設計初日から、チェーンの最も価値ある性質を捨てていました。
思考実験
会計の記録には 2 通りあります。
第一は、残高だけ記録する方法です。 紙に現金 8500 と書き、300 支払ったら消して 8200、1000 受け取ったら 9200 とします。
現在を見るのは最速でも、問題の調査は不可能です。 一か月後に 200 足りないと分かっても、紙の数値は何も教えません。
第二は、明細を記録し、そこから残高を計算する方法です。 日付、相手、金額、証票番号を一件ごとに残します。残高は維持する数値ではなく、明細から導く結論です。
照会は少し遅くても、すべての数値を一枚の証票までたどれます。不一致なら個別照合でき、一区間だけ再計算でき、無効な証票の影響だけを取り消せます。
T1 を振り返ると、チェーン自体が、残高だけの帳簿とは逆です。取引という明細を記録し、状態を導きます。誰でも同じ履歴を独立検証できるのは、明細を残すからです。
そこで、問題は明確です。
チェーンが大きな費用で記録した明細を、自分の DB で一つの数値に圧縮しています。
追跡可能性だけでなく、チェーンの中心的な性質も失います。
また、残高だけでは扱えないことがあります。チェーンの証票は取り消されることがあります。 先端の再編成で、昨日記録した取引がなかったことになります。数値だけでは、どの一件を戻すかも分かりません。
あなたが決める
プロトコルの送金イベントを、どう表へ保存しますか?
結果を観察する
4 つの問いで比較します。
| 取引へ追跡 | 任意高さから再生 | 再編成の巻き戻し | 照会費用 | |
|---|---|---|---|---|
| 最終状態のみ | 不可 | 不可 | 不可 | 最小 |
| 明細のみ | 可 | 可 | 可 | 高い |
| 明細と実体化残高 | 可 | 可 | 可 | 低い |
| 生 JSON | 可 | 難しい | 難しい | 非常に高い |
最初の 3 問は、行の出所が残っているかを問います。
表設計のレビューへ、次のルールを置きます。
DB の各行は、元の取引、所属するブロック高、そのブロックが今もチェーンにあるか、という 3 問に答えられる必要があります。
第三が最も漏れやすく致命的です。最初の二つは tx_hash と block_number で答えられますが、第三にはブロックハッシュも必要です。なければ、どの分岐のデータか分かりません。
モデルを作る
3 種類の表の責任を分けます。
- Fact:事実表、追記のみ
- Derived:派生表、再構築可能
- Checkpoint:進捗表、カーソル記録
事実表:主キーはチェーンの座標
-- 一行はチェーンの一ログ。追記のみで、update しない。
create table transfer_events (
chain_id integer not null,
block_number bigint not null,
log_index integer not null,
block_hash bytea not null, -- 再編成検出に使用
block_time timestamptz not null, -- now() ではなくブロックヘッダー由来
tx_hash bytea not null,
tx_index integer not null,
contract bytea not null,
from_addr bytea not null,
to_addr bytea not null,
amount numeric(78,0) not null, -- 256 ビット整数を格納可能
primary key (chain_id, block_number, log_index)
);
create index on transfer_events (chain_id, contract, from_addr, block_number);
create index on transfer_events (chain_id, contract, to_addr, block_number);DDL の 6 判断が、各種類の事故に対応します。
主キーは自動採番 ID ではなく、チェーン座標です。 チェーン、ブロック、その中のログ番号で位置が一意に決まり、重複挿入を防ぎます。これが冪等な取り込みの基礎です。自動採番なら再実行で倍になります。
chain_id を主キーへ入れます。 複数チェーンでは高さが重なり、この列がないと互いを上書きし、奇妙な症状になります。
amount は numeric(78,0) にします。 256 ビット符号なし整数は bigint に収まらず、float は精度を失います。数量の浮動小数点保存は、この分野で許されない誤りです。十進 78 桁なら範囲を覆えます。
block_hash は必須です。 どの分岐のデータかを判断する根拠です。なければ再編成後に永久に汚れ、気付きません。
block_time は now() でなくヘッダーから取得します。 保存時刻を業務時刻にすると、過去埋め戻しの時間統計がずれます。一年前のデータも今日の時刻になります。
アドレスとハッシュの形式を統一します。 bytea は省容量で誤りが少なく、テキストなら全体で小文字へ統一します。そうしないと同アドレスが二行になり、join でもデータが欠けます。
進捗表とブロック表
-- チェーンごとに一行で索引位置を記録。
create table indexer_checkpoints (
chain_id integer primary key,
last_block_number bigint not null,
last_block_hash bytea not null,
updated_at timestamptz not null default now()
);
-- 最近のブロックのハッシュ連鎖を残し、再編成をさかのぼる。
create table blocks (
chain_id integer not null,
block_number bigint not null,
block_hash bytea not null,
parent_hash bytea not null, -- 連続性の確認に使用
block_time timestamptz not null,
primary key (chain_id, block_number)
);blocks は省略できません。「1000 まで索引した」だけでは、「当時見た 1000 の内容」が分からず、再編成を検出できません。T17 で活用します。
冪等な取り込み:一文
insert into transfer_events
(chain_id, block_number, log_index, block_hash, block_time,
tx_hash, tx_index, contract, from_addr, to_addr, amount)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (chain_id, block_number, log_index) do nothing;同区間を 100 回処理しても行数は変わりません。処理済みかをコードで判断するのでなく、主キーで冪等にします。
派生表:最も間違えやすい場所
create table token_balances (
chain_id integer not null,
contract bytea not null,
holder bytea not null,
balance numeric(78,0) not null,
updated_at_block bigint not null,
primary key (chain_id, contract, holder)
);どう更新するかが重要です。
自然な「イベント挿入後、受取人へ加算」という方法は、再実行で二重加算します。挿入は do nothing で止まっても、加算は続きます。
二つを同じ一文へ結び付け、初めて挿入した場合だけ残高を動かします。
with inserted as (
insert into transfer_events
(chain_id, block_number, log_index, block_hash, block_time,
tx_hash, tx_index, contract, from_addr, to_addr, amount)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
on conflict (chain_id, block_number, log_index) do nothing
returning chain_id, block_number, contract, from_addr, to_addr, amount
),
deltas as (
select chain_id, contract, to_addr as holder, amount as delta, block_number from inserted
union all
select chain_id, contract, from_addr as holder, -amount as delta, block_number from inserted
)
insert into token_balances as b (chain_id, contract, holder, balance, updated_at_block)
select chain_id, contract, holder, delta, block_number from deltas
on conflict (chain_id, contract, holder) do update
set balance = b.balance + excluded.balance,
updated_at_block = greatest(b.updated_at_block, excluded.updated_at_block);3 回読む価値のある SQL です。鍵は returning です。競合時に do nothing は行を返さず、deltas が空になるので、残高は一行も動きません。
次を覚えてください。
初めて見たログかの判断と、派生表を更新するかの判断を、同じトランザクションの一文に結び付けます。
分ければ再実行、再試行、同時実行で誤ります。
再編成での削除
事実表は高さで削除できます。
begin;
delete from transfer_events where chain_id = $1 and block_number > $2;
delete from blocks where chain_id = $1 and block_number > $2;
update indexer_checkpoints
set last_block_number = $2,
last_block_hash = (select block_hash from blocks where chain_id = $1 and block_number = $2)
where chain_id = $1;
commit;しかし、派生表はそのまま削除できません。累積した残高は、イベントを消しても自動で戻りません。
方法は二つです。
- 再計算:影響する holder をゼロにし、事実表から集約します。遅くても正しく、実装は簡単です。
- 出所を記録:派生変更ごとに元ログを記録し、高さで逆適用します。速い代わりに、表とロジックが倍になります。
初版では必ず前者を選びます。 正確性が優先で、再編成は通常先端の数ブロックなので、対象アドレスも限られます。
Redis に置くもの
Redis のデータを失っても、すべて Postgres から再構築できる必要があります。
置けるのは照会キャッシュ、重複除去窓、制限カウンター、ランキング、最新高です。 置けないのは、チェーンで起きたことの事実です。
Redis の信頼性の問題ではなく、事実は一つであるべきだからです。二つ持てば、最も必要な時に不一致へ気付きます。
何と呼ぶか
チェーンのログを事実、派生表を投影とする方法です。事実は追記のみで、投影は随時再構築できます。
イベントソーシングと同じ考えですが、Crypto ではイベント設計も不要です。チェーンが既に定義し、改ざんできない形で提供しています。
自動採番ではなく、データ自身の属性を主キーに使います。
ここではチェーン ID、ブロック高、ログ番号です。DB が冪等性を担うため、重複除去コードは不要です。
主キーが競合したら更新、または何もしない挿入方法です。
事実表では競合時に何もしません。派生表では更新します。逆にすると重複保存か二重計上を起こします。
処理したブロックを記録するカーソルです。高さとハッシュの両方が必要で、高さだけでは再編成を検出できません。
データと同じトランザクションでコミットします。二種類の間違いが別の結果を生むため、T17 で繰り返し扱います。
残高、保有、ランキング、統計など、事実から導いて照会を速める表です。
基準は、全削除後に事実表から完全再構築できるかです。できなければ事実が混ざり、将来不一致を起こします。
何個の後続ブロックを待ってから、業務上不可逆と扱うかです。
業務パラメータです。表示なら 0、資金決済なら数十の場合があります。コードへ散らさず、設定へ置きます。
実践
テストネットの公開読み取り専用端点を使い、秘密鍵も取引送信も不要です。
合格基準は一つです。同じブロック区間を何度実行しても、DB の内容が完全一致します。
3 表を作ります。
transfer_events、blocks、indexer_checkpoints をそのまま作ります。
任意の行を取引へ追跡できるか、高さが分かるか、ブロックが現チェーンにあるか判断できるか、確認します。
500 ブロックのログを取得します。
活発なトークンを選び、eth_getLogs の範囲指定で Transfer を取得します(T1 の Lab)。
ヘッダーも取得して blocks へ保存し、parent_hash を忘れません。
3 回処理し、行数が変わらないか確認します。
同区間をさらに 2 回実行し、各回で次を行います。
select count(*) from transfer_events;3 回で一致する必要があります。増えれば主キーか on conflict の誤りです。基礎なので、通過するまで先へ進みません。
CTE で派生残高を追加します。
token_balances を作り、with inserted as (...) の文で書き込みます。
500 ブロックを再実行し、残高の一桁も変わらないことを確認します。
倍なら挿入と更新を分けています。戻って統合します。Lab の中心です。
再編成を模擬します。
高さ N まで処理済みとし、手で実行します。
delete from transfer_events where chain_id = $1 and block_number > $2; -- $2 = N - 5
delete from blocks where chain_id = $1 and block_number > $2;影響アドレスの token_balances をゼロにして事実から再計算し、チェックポイントを N-5 へ戻します。
N-5 から N へ再実行し、削除前の残高表と完全一致を確認します。
派生の再構築可能性を検証する工程です。できなければ、残高表に事実が混ざっています。
照合します。
20 holder を無作為に選び、RPC の balanceOf で現在高の実残高を読み、DB と比較します。
不一致はあり得ます。明細があるので原因を調べられるのが重要です。手数料付き送金、Transfer で捉えられない mint/burn、ブロック漏れなどがあります。
T1 の実例と照合してください。
T17 に備え、チェックサム照会を書きます。
select md5(string_agg(t.tx_hash::text || t.amount::text, '|'
order by t.chain_id, t.block_number, t.log_index))
from transfer_events t
where t.chain_id = $1 and t.block_number <= $2;保存してください。T17 では、任意高さから再実行しても、チェックサムが変わらないことを確認します。
AI Lab
2 段階で、2 番目が本当の試験です。
第 1 段階:
ERC-20 の Transfer 用に PostgreSQL の表を設計してください。
アドレス別履歴と残高、複数チェーン、億件の規模を扱います。
完全な DDL と書き込み文を示してください。
第 2 段階:
深さ 5 ブロックの再編成が起き、保存済みの末尾 5 個が別の 5 個へ変わりました。
送金イベント 3 件が消え、2 件が追加されました。設計に沿って説明してください。
1. どう再編成を検出するか?
2. 何行を、どの文で消すか?
3. 残高表はどうなるか、まだ正しいか?
4. 再索引後、残高表は正しい値に一致するか?第 1 段階は答えられても、多くは自動採番 ID を主キーにします。教材の大半がそうだからです。再実行で倍になる、頻出かつ致命的な誤りです。
第 2 段階で真の問題が出ます。ハッシュでなく高さで再編成を比較するため検出できないことと、イベントだけ削除し残高を放置するため永久に汚れることが典型です。
答えがよければ、「累積維持の残高を再計算して、正しい値への完全一致をどう保証するか」と追加で聞きます。正しく話せることと、十分に考えたことを区別できます。
すべて実 DB で自分が実行します。 スキーマの誤りはレビューではもっともらしく見え、実データで初めて現れます。
AI の回答後、自分で必ず検証すること
- 主キーがチェーン ID、高さ、ログ番号になっている。自動採番では冪等にできず、修正が必要です
- chain_id が主キーにある。なければ第二チェーンと高さが衝突します
- 数量型に 256 ビット整数が収まる。bigint はあふれ、浮動小数点は精度を失います
- block_hash と parent_hash がある。なければ再編成を検出できません
- 再実行の upsert が残高を二重加算しないか、自分で確認した
- ブロック時刻が now() でない。そうすると過去データの時刻が誤ります
- アドレスの大文字小文字を統一し、呼び出し側へ任せていない
- チェックポイントとデータを同じトランザクションで更新する
実例
冒頭の balances 表では、問題があると創世ブロックから再実行するしかありません。
真の代償は数時間でなく、原因が永久に分からないことです。根因を直せず再発を待ち、毎週定期的に全削除するという奇妙な運用へ進みます。
教訓:追跡可能性は飾りでなく、調査の唯一の入口です。
bigint で数量を保存しても、日常送金の多くは収まり、長く正常に見えます。
18 桁精度のトークンで大口送金があり、bigint の上限を超えると、エラーか黙った切り捨てが起きます。帳簿は永久に誤り、誤った場所も見えません。
教訓:256 ビット整数が入る十進型を使います。最適化でなく正確性です。
RPC の小文字と、別 API のチェックサム表記を同じ列へ入れました。
同アドレスが二行になり、残高が分かれ、join と履歴で半分しか見えません。
しかもエラーを出さず、数値を静かに減らします。バイナリで統一するか小文字を強制し、DB 層でも制約を設けます。
1000 まで処理したことだけを記録し、1000 の内容が再編成で変わっても 1001 から続けました。
995〜1000 のデータが現チェーンから外れたことに気付かず、正しいデータに混ざったままで、照会でも印が付きません。
数か月後に不一致へ気付いても、どの行が不正かを教える情報が DB にありません。
block_hash は安価な保険です。T17 で完全な対応機構にします。
変数を一つ変える
同じアドレスでも構造が変わり、一表で強引に受けると解析失敗かフィールドのずれが起きます。
事実表にイベント版か ABI 版を加え、高さで旧 ABI と新 ABI を分けます。
アドレスは安定した型識別子ではありません。 永久に同じイベントとは想定しません。T25 で体系的な扱いを学びます。
主キーの chain_id は、良い習慣から、ないと壊れる必須要件になります。高さが大量に重なり、上書きします。
確認深度、数十倍違う速度、数量精度、ブロック時刻の信頼性なども異なります。
違いは設定表へ集めます。if 分岐へ散らさず、一チェーンの値を固定しません。それは複数チェーン化の大きな負債です。
block_number の範囲で分割します。照会だけでなく、ブロック区間の削除がパーティション削除になり、巻き戻しと保管が速くなります。
アドレス履歴の索引も巨大になり、専用照会表や列指向保存が必要になります。事実表は、すべてを再生できる生の記録という役割へ戻ります。
それが本来の位置です。
token_balances は現在値だけなので答えられません。
事実から対象高まで再生するなら遅くても省容量で正しく、定期スナップショットなら速くても容量と間隔設計が必要です。
多くは日次スナップショットと、近いものからの短区間再生を組み合わせます。成立の前提は、やはり完全な事実表です。
持ち帰る問い
何を解決しますか?自分のシステムでもチェーンのデータを検証可能に保つことです。保存時に検証可能性を失いやすく、失えば誰も照合できないブラックボックスになります。
誰がリスクを負いますか?ユーザーは誤残高や記録を見て、運営は数か月気付かないかもしれません。静かに現実から分かれるため、定期照合で初めて早期発見できます。
AI が間違えると誰が損失を負いますか?通常業務なら正しい自動採番主キーが、ここでは致命的です。責任は使用者です。verify の全項目を実 DB で試します。
章末チェック
同じログに自動採番の制約がなく、再実行で新 ID の重複行を作り、倍になるからです。
チェーン ID、高さ、ログ番号なら、DB が重複を拒否します。on conflict do nothing と合わせ、追加の除去コードなしで冪等にできます。
再生可能な索引器は、表設計の最初の行から始まります。
全削除して、他の表から完全に再構築できるかで判断します。
残高、保有、統計、ランキングは派生で、再計算できます。
ログは事実の写しで、削除後はチェーンから再取得します。
再構築できず、直接チェーン由来でもない独自数値なら、将来の不一致の場所です。T15 の結びでも説明しました。
ログを初めて見たかという判断を共有するためです。
二文なら、挿入は do nothing で正しく止まっても、残高は誤って加算し、二重になります。
with inserted as (... returning ...) で結ぶと、競合時に returning が空になり更新も飛ばします。一文で二つの正確性を確保します。
高さはブロックを一意に決めず、再編成後は同じ高さに別内容があるためです。
block_hash があれば分岐を識別し、分岐点を見つけ、影響部分だけ削除できます。
なければ、未検出か全再実行の二択です。T17 の機構の支点になります。
唯一の正解はありません。6 点を確認します。
- 主キーがチェーン座標で、
chain_idを含むか? - 256 ビット整数が収まり、浮動小数点を使っていないか?
block_hashとparent_hashがあるか?- 時刻はヘッダー由来か、
now()か? - 派生表を全削除して再構築できるか?
- チェックポイントにハッシュがあり、データと同一トランザクションか?
第 5、第 6 項が T17 への条件です。6 点を答えられれば、索引器の成功へ半分進んでいます。
要点
イベントを事実、ブロック高をバージョンとして扱い、各行を元の取引まで追跡できるようにする。