すべての記事

台帳シリーズ 第 1 部:複式簿記モデル

·
2026年6月30日
·
9 分で読めます
·
••• 回閲覧
·
••• いいね

英語の原文から翻訳した記事です。 英語で読む

台帳シリーズ 第 1 部:複式簿記モデル

ほとんどの金融システムは同じように始まります。ウォレットを作っていて、ユーザーが $100 を持っていて、そのうち $30 を友人に送る。そこで片方の行には balance = balance - 30 を、もう片方の行には balance = balance + 30 を書きます。残高は、ギフトカードの合計額のように、ウォレットに持たせて毎回更新するただの数値になります。それで動くので、そのまま本番に出します。

やがて会社が成長し、デビットカードやクレジットカード、暗号資産のレール、法定通貨での払い出し、手数料、返金、いくつかの決済プロバイダー、ウォレットプロバイダーまで扱うようになります。金融システムは、別々のチームが管理するサービスやテーブルに散らばっていきます。そしてある四半期、財務チームから $40,000 の所在がわからない、どこにも見当たらないと告げられるのです。

そのお金は、厳密に言えば消えたわけではありません。銀行口座にはちゃんとあって、1 セント残らずそろっています。消えたのは帰属、つまり出どころの記録、それが誰のものか という記録です。DB の balance カラムは数値を持っていても、その背後にある経緯までは持っていません。だから数値と現実が食い違ったとき、どうやってその数値になったのかを再現する手段がないのです。重要な金融システムに携わったことのあるエンジニアなら、誰でもこの手の悪夢を何かしら経験しています。聞いてみてください!

解決策は、単にもっとよく考えた balance カラムを作ることではありません。銀行や伝統的な金融システムが何年も使ってきたモデルであり、本格的な台帳が今でもその上で動いているもの、それが複式簿記です。

単一の数値ではなぜうまくいかないのか

お金を可変の数値、つまり単式で保存するやり方は、主に 3 つの理由で破綻します。しかも、それはたいてい深夜 2:45 に表面化します。

まず、何が起きたのかを追跡して理解することができません。明確な履歴がないからです。balance = 70 は、その数値を生んだお金の動きについて何も語りません。ただの数値です。それが間違っていたとき、さかのぼって監査する手がかりが何もありません。

次に、もう片方の側がありません。ウォレットから $30 を引いても、その $30 がどこへ行ったのかは記録されません。お金の出どころと行き先は、たまたま実行されたアプリケーションコードのどこかの行にしか存在せず、システムの中核となるデータには残りません。

さらに、起こりうる競合状態に対して何の助けにもなりません。2 つのリクエストが同時に balance = 100 を読み取り、それぞれが 30 を引いて、それぞれが 70 を書き込みます。お金は 2 回動いたのに、カラムは正しいように見えたままです。

3 つの基本原則

複式簿記の台帳は、その可変の数値を、お金の動きを追記していくだけの記録に置き換えます。この記録は 3 つのオブジェクトで構成されます。

  1. アカウント:これは単に、独立した価値のプールです。ユーザーのウォレット、会社の現金、徴収した手数料、そのそれぞれがアカウントです。

  2. エントリ:これは、1 つのアカウントに対する 1 つの借方または貸方の記録です。エントリは必ず一度だけ書き込み、その後は決して変更も削除もしてはいけません。それぞれが、1 つのアカウントに価値が入ってくる、または出ていくことを表す永続的な記録であり、「イミュータブル」 です。

  3. トランザクション:これは、ひとまとまりで動くエントリのグループで、すべて成功するか、何も起きないかのどちらかです。システム全体をつなぎとめているのが、このオブジェクトです。

エントリを直接書き込むことはありません。書き込むのはトランザクションで、トランザクションがエントリを抱えています。書き込み自体は 1 つの DB トランザクションの中で実行されるので、すべて成功するか何も起きないかのどちらかになります。tx, _ := db.Begin(); tx.Exec(...); tx.Commit() のようにです。例えば、Alfred から Tobi への $30 の送金は、2 つのエントリを持つ 1 つのトランザクションです。1 つは Alfred から引くエントリ、もう 1 つは Tobi に渡すエントリで、両方がコミットされるか、どちらもコミットされないかのどちらかです。Alfred からは引き落とされたのに Tobi には一度も入金されていない、という状態が有効になることは決してあってはいけません。このアトミックなグループ化こそが保証なのです。

借方と貸方

さて、ここからが次のパートで、かなり多くの人が混乱するところです。借方(debit)は引き算ではなく、貸方(credit)は足し算ではありません。これらは単に、お金の動きが取りうる 2 つの方向にすぎません。ある方向がアカウントを 増やす ↑ のか 減らす ↓ のかは、その動きが行き着くアカウントの種類で決まります。わかりにくければ、「3 つの基本原則」のセクションをもう一度シンプルに読み返してみてください

すべてのアカウントは、先ほど話したように、いくつかある種類のどれか 1 つに属します。そして、その種類によって通常残高、つまり残高を増やす側が決まります。なので、扱うのは 2 つだけです。1 つ目は借方ノーマルのアカウント、つまり資産と費用です。借方で増え、貸方で減ります。2 つ目は貸方ノーマルのアカウント、つまり負債、純資産、収益です。貸方で増え、借方で減ります。

ウォレットの例に当てはめると、その違いがすぐにはっきりします。ユーザーがあなたのところに置いている $100 はあなたのお金ではなく、あなたがユーザーに対して負っているお金です。だから帳簿上は負債であり、貸方ノーマルです。ユーザーの代わりに実際に保有しているドルは資産であり、こちらは借方ノーマルです。

これで入金の意味がわかります。例えばユーザーが 100 USDC を入金すると、2 つのことが同時に成り立ちます。あなたが保有する額が 100 USDC 増える、つまり資産が増えるので借方です。そして、あなたがユーザーに負う額も 100 USDC 増える、つまり負債が増えるので貸方です。起きた動きは 1 つ、エントリは 2 つ、そしてそれらは釣り合います。

100usdc の入金に対する 2 つのエントリ

借方と貸方は、張り合う正反対のものではありません。まったく同じ $100 を、同時に、1 つのトランザクションとして記録した 2 つの見え方なのです。

物理学のたとえを使うと、すべてを理解するためのもう 1 つの見方があります。それは有名なニュートンの運動の第 3 法則 「すべての作用には、大きさが等しく向きが反対の反作用がある」 です

どのトランザクションでも、借方と貸方は等しくならなければなりません。毎回必ず、例外はゼロです。Σ 借方 − Σ 貸方 = 0

力は決して単独では現れず、必ず大きさが等しく向きが反対の片割れと一緒に現れます。この台帳のシナリオにおける価値も、まったく同じように働きます。あるアカウントに価値が入るときは、必ず別のアカウントに大きさが等しく向きが反対のエントリがあり、その 2 つが帳簿の釣り合いを保ちます。

カストディ側で借方に記入する 100 USDC と、ユーザー側で貸方に記入する 100 USDC は、大きさは同じでも同じものではありません。一方はあなたが新たに保有する資産、もう一方はあなたが新たに負う負債です。つまり、ここでは何も移動していません。両側が同時に増えているのです。新しい資産と、それと等しい新しい負債が一緒に生まれる。だからこそ帳簿は釣り合ったままなのです。

厳格なルール

すべてのトランザクションにおいて、借方の合計は貸方の合計と等しくなければなりません。釣り合わないトランザクションは、どこにも存在することが許されません。

このルールによって、「お金を絶対に失わない」が単なる願いから、あなたが扱っているシステムの重要な性質に変わります。価値は、別のアカウントに届くことなく 1 つのアカウントから出ていくことはできません。そうしないとトランザクションが釣り合わないからです。カストディが、ユーザーに対して負っている額とずれていくこともないはずです。すべての動きが両側を同時に記録するからです。何かがおかしいとき、台帳は誤った残高を黙って返すのではなく、書き込みそのものをはっきりと拒否します。

実際のところ、台帳の中核はこの 1 つのチェックです。ここでは金額を最小単位(セントや wei)の非負整数として保存し、方向は符号ではなく side フィールドで表します:

type Entry = { accountId: string; side: "DR" | "CR"; amountMinor: bigint; assetId: string };
 
function assertBalanced(entries: Entry[]) {
  if (entries.length === 0) throw new Error("empty txn");
 
  const net = new Map<string, bigint>();
 
  for (const e of entries) {
    if (e.amountMinor <= 0n) throw new Error(`bad amount: ${e.accountId} ${e.amountMinor}`);
    const delta = e.side === "DR" ? e.amountMinor : -e.amountMinor;
    net.set(e.assetId, (net.get(e.assetId) ?? 0n) + delta);
  }
 
  for (const [asset, balance] of net) {
    if (balance !== 0n) throw new Error(`unbalanced: ${asset} off by ${balance}`);
  }
}

このコードには、見た目以上に重要な点が 2 つあります。釣り合いは 資産ごと にチェックされるので、ドルとビットコインの両方に触れるトランザクションは、それぞれ独立して釣り合っていなければなりません。ある通貨で別の通貨の帳尻をこっそり合わせることは決してできないのです。そして、金額は浮動小数点ではなく bigint です。浮動小数点とお金の組み合わせは、また別の機会に語るべき悪夢だからです。

もう 1 つ注目してほしいのは、そのスニペットのどこでも balance カラムに触れていないことです。コードはエントリが釣り合っているかをチェックするだけで、残高をどこにも書き込みません。そしてそれこそが正しい出発点です。残高は、直接設定する事実ではありません。balance = 500 と書くことはしないのです。エントリを debits - credits で合計し、アカウントの通常残高の側に従って読み取ります。エントリこそが真実です。残高は、それに対して投げかける問いにすぎません。

さて、ここにも間違えやすいポイントがあります。エントリによって定義されるからといって、読み取りのたびに足し直すという意味ではありません。そうすると、毎回アカウントの全履歴を合計することになり、それは O(n) で、しかも n は増え続ける一方です。すべてのトランザクションが触れるアカウント、例えばトレジャリーなどが、いちばん遅い読み取りになってしまいます。だから足し直しはしません。残高を保存し、各エントリが書き込まれた瞬間に更新して、ログと同期させておきます。信頼できる情報源はあくまでログです。保存された数値は、実際にはログと一致していなければならないキャッシュにすぎません。意味としては導出され、実際には保存される。どちらか一方ではなく、その両方です。

同じ考え方を 1 枚の図で表すと、1 つのトランザクションが釣り合いのとれた 2 つのエントリに分かれ、それぞれが 2 つのアカウントに届く様子になります:

2 つのエントリと 2 つのアカウント

なぜこれが土台なのか

単式簿記のシステムは、数値を信用するよう求めてきます。複式簿記のシステムは、数値が単独で成り立つことを許しません。残高は自分で言い張るものではなく、エントリによって正しいことが強制されるものです。これは、ユニークインデックスが重複を単に「やってはいけないこと」ではなく「起こりえないこと」にするのと同じです。釣り合いのルールによって、お金を失うことは、何か月も何年も経ってから、聴聞会でライセンスを問われながら見つける食い違いではなく、そもそもコミットされない書き込みになります。ほぼすべての金融システムがこの方法で作られているのは、そのためです。

この第 1 部は、まだ骨組みにすぎません。本物の台帳は、まだ決済が完了していない資金、ブロックの承認を待っている暗号資産の入金、失敗して送金を始めたユーザーに差し戻す必要があるかもしれない出金などを扱わなければなりません。第 2 部では、このモデルを実際のお金の動きに当てはめ、1 つの残高が 3 つになる様子を見ていきます。

•••
この記事をシェア:
シリーズ
台帳シリーズ

最新情報を受け取る

最新の記事をメールでお届けします。