土法炼钢兴趣小组的算法知识备份

【金融科技工程】复式记账工程化:科目、分录、余额、对账

文章导航

分类入口
architecturefintech
标签入口
#double-entry#ledger#accounting#journal#general-ledger#tigerbeetle#beancount

目录

复式记账工程化:科目、分录、余额、对账

一个工程师第一次接触账务系统,通常有两个瞬间会被震撼。第一个瞬间是他发现”用户余额”不是一个字段,而是一个由几十张表、几百亿行分录推导出来的衍生量;第二个瞬间是他意识到,这套在 1494 年由帕乔利(Luca Pacioli)写进《算术、几何、比与比例概要》的方法,在今天的 Stripe、Square、TigerBeetle、蚂蚁集团的分布式账务里,几乎没有结构性变化。

复式记账(Double-Entry Bookkeeping)不是会计师的仪式,而是金融工程里最接近”不变量”的那一层建模。这篇文章的目标是:把会计语言整体翻译成工程语言,给出可直接落地的数据模型、约束和查询接口,让后续文章里的账务数据库(第 04 篇)、幂等与对账(第 05、23 篇)、清算结算(第 11 篇)都有一个共同的底座。

读者画像:做支付、交易、账务、计费、钱包、资管系统的后端工程师;读过一点会计但总觉得”借贷方向”混乱的开发者;准备设计或重写一套 ledger 的架构师。默认你已经读过本系列第 02 篇《钱的建模》,对 Money = {amount, currency, scale} 的三元组不再陌生。

一、为什么一定要复式:单式记账的工程病

1.1 单式账本的幸福与崩溃

最初版本的”钱包”系统,几乎所有公司都写过这样的表:

CREATE TABLE user_wallet (
    user_id     BIGINT PRIMARY KEY,
    balance     DECIMAL(20, 4) NOT NULL DEFAULT 0,
    updated_at  TIMESTAMP NOT NULL
);

充值就 UPDATE user_wallet SET balance = balance + 100 WHERE user_id = ?;消费就减。这是单式记账(Single-Entry Bookkeeping)的工程形态:只记录主体本身的余额变化

单式账本在三种场合会立刻崩溃:

  1. 钱去哪了:用户从 100 元变成 90 元,这 10 元的去向无法从本表追溯——是消费?手续费?扣款?
  2. 回放不了:如果 UPDATE 丢失或错误,没有分录可以重算余额,只能依赖备份。
  3. 对不上账:当支付、商品、营销、清算分属不同团队时,每个团队的”减了 10 元”和”收到 10 元”无法交叉验证。

一旦你有了两个以上的账户(用户余额、平台手续费、第三方支付通道备付金),单式记账必然走向复式,因为任何一笔钱的移动都至少涉及两个账户

1.2 复式的三个工程属性

复式记账给工程师最有价值的不是”借贷”这个古老的词汇,而是它强制的三个不变量:

这三条合起来,正好对应分布式系统里我们熟悉的三个概念:约束(invariant)事件溯源(event sourcing)确定性回放(deterministic replay)。复式记账可以说是会计版本的 event sourcing。

1.3 一个最小的对照例

用户充值 100 元:

模式 记录
单式 wallet[user_A].balance += 100
复式 Dr. 备付金 100;Cr. 用户可用余额(user_A 户)100

表面看复式多了一条记录,实际上它同时回答了四个问题:钱来自哪里(备付金)、去了哪里(用户负债)、总账是否平衡(+100 和 -100 互抵)、如何回滚(插入一条反向分录)。这就是”工程上的复利”。

二、术语一次讲清:从会计到工程的映射

2.1 核心名词

把会计里最常被混用的词汇做一次工程语义的定格:

一个关键口径:工程上”Journal Entry”(分录头)= 一次交易;“Journal Line”(分录行)= 借贷明细。下文建模按这个区分。

2.2 账户类型与余额方向

会计五大类账户及其”常态方向”(Normal Balance):

类型 中文 常态方向 工程直觉
Asset 资产 借(Dr.) 我方拥有的钱和债权
Liability 负债 贷(Cr.) 我方欠别人的钱
Equity 所有者权益 贷(Cr.) 股东投入 + 留存收益
Revenue / Income 收入 贷(Cr.) 赚到的钱
Expense 费用 借(Dr.) 花出去的钱

规则:借记(Debit)使资产 / 费用增加,使负债 / 权益 / 收入减少;贷记(Credit)相反。 很多工程师觉得”借贷”反直觉,其实换个视角就很容易记:

2.3 “我方角度”的确立

复式记账没有绝对方向,它总是以某个记账主体为原点。工程上必须在系统层面先回答:

举例:在微信支付服务商接入中,用户支付 100 元到商户 A,经过服务商平台。对服务商来说:

服务商并没有”拥有”这 100 元(它只是过路资金),所以记为资产+负债同时增加,结算后两边同步减少。这就是资金流与所有权的分离在账簿上的体现。

三、映射真实业务:十种常见交易的分录

下面以”某跨境电商平台”为例,给出十种高频业务的标准分录。约定:

为简洁起见,金额都用整数元表示,实际工程中应使用最小单位(见第 02 篇)。

3.1 用户充值 100 元(微信通道)

Dr. 备付金-微信                100
    Cr. 应付-用户[u_123]           100

3.2 下单扣款:用户花 100 元买商户 A 的商品,平台抽 1 元手续费

站在平台主体:

Dr. 应付-用户[u_123]           100
    Cr. 应付-商户[m_A]              99
    Cr. 手续费收入                   1

注意:“手续费收入”是平台自己的收入。它既不是商户的负债也不是用户的资产,它进入了权益类的利润循环。

3.3 全额退款

使用”反向冲销”生成一条镜像分录(下文 §8 详述):

Dr. 应付-商户[m_A]              99
Dr. 手续费收入                   1
    Cr. 应付-用户[u_123]          100

3.4 部分退款(30 元,手续费不退)

Dr. 应付-商户[m_A]              30
    Cr. 应付-用户[u_123]           30

是否退手续费是产品策略。一旦产品决定”手续费不退”,手续费收入账户就不需要回冲。

3.5 通道成本(微信收取 0.6% 通道费)

假设通道费在 T+1 从备付金账户扣除:

Dr. 通道成本                   0.6
    Cr. 备付金-微信                 0.6

“通道成本”是费用类,借方增加;备付金是资产类,贷方减少。平台利润 = 手续费收入 − 通道成本 − ……

3.6 分润:商户 A 与达人 D 按 70/30 分

在订单成交时记:

Dr. 应付-用户[u_123]           100
    Cr. 应付-商户[m_A]             69.3
    Cr. 应付-达人[d_007]           29.7
    Cr. 手续费收入                   1

二次清分(Resettlement)也常见:先把 99 元全部记给 m_A,次日再做一笔内部转账 Dr. 应付-商户[m_A] / Cr. 应付-达人[d_007]。两种做法的差别在于分录的原子性对账难度:一次记完最干净;二次清分便于争议处理,但对账系统要能识别这对反向分录。

3.7 营销补贴:平台补贴 10 元

用户支付 90 元,平台承担 10 元,商户到账 99 元:

Dr. 应付-用户[u_123]            90
Dr. 营销补贴                    10
    Cr. 应付-商户[m_A]              99
    Cr. 手续费收入                   1

营销补贴是费用类,借方增加。这条分录揭示了一个常见的 bug:如果把补贴记成”用户余额赠送”,会让 GAAP 利润虚高,因为看起来平台没花钱,实际上它用自己的费用顶替了用户的付款。

3.8 红包(发放时形成负债)

双十一给 10 万用户每人发 5 元红包,共 50 万元。红包核销之前它是或有负债,会计保守做法是先形成负债:

Dr. 红包发放费用         500,000
    Cr. 红包负债池                500,000

用户使用红包抵扣 5 元购买 20 元商品:

Dr. 应付-用户[u_456]            15
Dr. 红包负债池                   5
    Cr. 应付-商户[m_B]              19.8
    Cr. 手续费收入                   0.2

红包过期未用:

Dr. 红包负债池         (sum_expired)
    Cr. 红包发放费用      (sum_expired)  -- 冲回原费用

过期释放要不要冲回费用、冲回多少,受会计准则(IFRS 15、ASC 606)约束。工程上需要在系统里留下”过期策略”开关。

3.9 二次清分:平台集中结算给商户

每日 T+1,把所有 应付-商户[*] 按照绑定银行卡批量打款:

Dr. 应付-商户[m_A]           (sum_A)
Dr. 应付-商户[m_B]           (sum_B)
    ...
    Cr. 备付金-银行           (Σ sum_i)

这是一条”多借一贷”的聚合分录,通常每日一笔。对账系统(第 23 篇)会用它与银行回单对齐。

3.10 跨境收款(美元入账,记 CNY 账簿)

收到美元 100,锁汇 6.9:

Dr. 备付金-美金             690  (CNY 等值)
    Cr. 应付-商户[m_cn]          687
    Cr. 汇兑损益                   ?
    Cr. 手续费收入                  3

更严格的做法是开双币种账簿,见 §9。


这十个例子覆盖了 80% 的互联网支付业务。关键心法:任何涉及第三方(支付通道、商户、代理、税务)的资金流,必须把”我代收 / 我代付”显式建模为负债,而不是偷懒地”扣谁就扣谁”。

四、数据模型:三张核心表

4.1 建表 SQL

-- 1. 账户主数据
CREATE TABLE accounts (
    account_id        BIGINT PRIMARY KEY,
    code              VARCHAR(64)  NOT NULL UNIQUE,   -- 账户编码,如 2201.01.u_123
    name              VARCHAR(128) NOT NULL,
    account_type      VARCHAR(16)  NOT NULL,          -- ASSET/LIABILITY/EQUITY/REVENUE/EXPENSE
    normal_side       CHAR(1)      NOT NULL,          -- D / C
    currency          CHAR(3)      NOT NULL,          -- 记账币种(单币种账户)
    scale             SMALLINT     NOT NULL DEFAULT 2,-- 最小单位幂
    parent_id         BIGINT       NULL,              -- 层级结构
    status            VARCHAR(16)  NOT NULL DEFAULT 'ACTIVE',
    created_at        TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES accounts(account_id),
    CONSTRAINT ck_type   CHECK (account_type IN
        ('ASSET','LIABILITY','EQUITY','REVENUE','EXPENSE')),
    CONSTRAINT ck_side   CHECK (normal_side IN ('D','C'))
);

-- 2. 分录头(一次业务 = 一个 entry_id)
CREATE TABLE journal_entries (
    entry_id        UUID PRIMARY KEY,
    biz_type        VARCHAR(32)  NOT NULL,     -- PAYMENT / REFUND / SETTLE / FEE / ADJUST
    biz_ref         VARCHAR(128) NOT NULL,     -- 业务单号(订单号、退款号)
    idempotency_key VARCHAR(128) NOT NULL,     -- 幂等键,通常 = biz_type + biz_ref
    narration       VARCHAR(256) NULL,         -- 摘要
    effective_time  TIMESTAMP    NOT NULL,     -- 业务发生时间
    booking_time    TIMESTAMP    NOT NULL      -- 记账时间
        DEFAULT CURRENT_TIMESTAMP,
    status          VARCHAR(16)  NOT NULL DEFAULT 'POSTED',  -- DRAFT/POSTED/REVERSED
    reversed_of     UUID         NULL,         -- 若本条是冲销分录,指向被冲销
    created_by      VARCHAR(64)  NOT NULL,     -- 服务 / 操作人
    CONSTRAINT uk_idem UNIQUE (idempotency_key),
    CONSTRAINT fk_rev  FOREIGN KEY (reversed_of) REFERENCES journal_entries(entry_id)
);

-- 3. 分录行
CREATE TABLE journal_lines (
    line_id         BIGSERIAL PRIMARY KEY,
    entry_id        UUID         NOT NULL,
    account_id      BIGINT       NOT NULL,
    direction       CHAR(1)      NOT NULL,           -- D / C
    amount          NUMERIC(24,0) NOT NULL,          -- 最小单位;恒为正
    amount_signed   NUMERIC(24,0) GENERATED ALWAYS AS (
        CASE direction WHEN 'D' THEN amount ELSE -amount END
    ) STORED,
    currency        CHAR(3)      NOT NULL,
    -- 辅助核算维度
    user_id         BIGINT       NULL,
    merchant_id     BIGINT       NULL,
    order_id        VARCHAR(64)  NULL,
    channel         VARCHAR(32)  NULL,
    CONSTRAINT fk_entry   FOREIGN KEY (entry_id)  REFERENCES journal_entries(entry_id),
    CONSTRAINT fk_account FOREIGN KEY (account_id) REFERENCES accounts(account_id),
    CONSTRAINT ck_dir     CHECK (direction IN ('D','C')),
    CONSTRAINT ck_amt     CHECK (amount > 0)
);

CREATE INDEX idx_lines_acc_time ON journal_lines (account_id, entry_id);
CREATE INDEX idx_lines_order    ON journal_lines (order_id);
CREATE INDEX idx_lines_user     ON journal_lines (user_id);

4.2 借贷平衡的强约束

借贷平衡(Σ amount_signed = 0 per entry_id、per currency必须在数据库层保证,不能依赖应用层。常见三种实现:

  1. 延迟约束 + 事务内校验:PostgreSQL 可以用 DEFERRABLE INITIALLY DEFERRED 的触发器,在事务提交时检查一笔 entry 的所有行和为零:
CREATE OR REPLACE FUNCTION check_entry_balance() RETURNS TRIGGER AS $$
DECLARE
    bad_currency CHAR(3);
BEGIN
    SELECT currency INTO bad_currency
    FROM journal_lines
    WHERE entry_id = NEW.entry_id
    GROUP BY currency
    HAVING SUM(amount_signed) <> 0
    LIMIT 1;

    IF bad_currency IS NOT NULL THEN
        RAISE EXCEPTION
            'Entry % unbalanced in currency %', NEW.entry_id, bad_currency;
    END IF;
    RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE CONSTRAINT TRIGGER trg_entry_balance
AFTER INSERT OR UPDATE ON journal_lines
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION check_entry_balance();
  1. 写入侧专用 API:所有写入走 PostEntry(entry, lines[]) 接口,库层面不暴露单独插入 journal_lines 的能力,API 内部校验后一次性写入。
  2. 数据库内置支持:TigerBeetle 把 “transfer must balance” 作为原语,不依赖触发器。

4.3 幂等键:重复投递的最后一道闸

idempotency_key唯一索引是整个账务系统的护城河。支付网关、MQ、上游对账文件都可能重复投递同一笔业务。约定:

4.4 时序:effective_time vs booking_time

两个时间必须分开存:

它们可能差几秒也可能差几天(例如通道事后补单)。对账按 effective_time 切日,性能分析、延迟监控按两者差值。跨月补录更要小心——要不要影响”上月利润”往往是财务和审计的拉锯战。工程做法:

五、余额:派生量,不是字段

5.1 公式

对账户 a、币种 c、截止时间 t

balance(a, c, t) = Σ amount_signed over journal_lines
                   WHERE account_id = a
                     AND currency   = c
                     AND entry_id IN (POSTED entries with effective_time <= t)

借方常态(资产/费用)账户,正数表示增加;对贷方常态(负债/权益/收入)账户,习惯上余额显示为 -Σ amount_signed(翻个符号,让 UI 上是正数)。

5.2 全量扫描的死亡螺旋

如果每次查询余额都扫全表,活跃账户很快会让 DB 崩掉。举个例子:平台的”手续费收入”账户每天可能有千万级分录行,10 天就 1 亿行,查一次余额几十秒。

5.3 三种工程策略

策略 A:余额快照表

维护 account_balances 表,随写入同步更新:

CREATE TABLE account_balances (
    account_id     BIGINT NOT NULL,
    currency       CHAR(3) NOT NULL,
    balance        NUMERIC(24,0) NOT NULL DEFAULT 0,
    last_entry_id  UUID NULL,
    updated_at     TIMESTAMP NOT NULL,
    PRIMARY KEY (account_id, currency)
);

写入分录时,同一事务内 UPDATE account_balances SET balance = balance + :signed_amount。优点:O(1) 查询;缺点:热点账户的行锁成为瓶颈(§6)。

策略 B:快照 + 增量

每天落一个余额快照 daily_balances(account_id, currency, date, balance);查询时用最近快照 + 当日增量:

SELECT b.balance + COALESCE(
    (SELECT SUM(amount_signed) FROM journal_lines l
     JOIN journal_entries e USING(entry_id)
     WHERE l.account_id = :a AND l.currency = :c
       AND e.effective_time >= b.date
       AND e.effective_time <= :t
       AND e.status = 'POSTED'), 0)
FROM daily_balances b
WHERE b.account_id = :a AND b.currency = :c
  AND b.date = (SELECT MAX(date) FROM daily_balances
                WHERE account_id = :a AND currency = :c AND date <= :t);

这是 beancount、Ledger CLI 这类”文本账本”工具的思路:数据本身是分录,余额通过流式汇总得到,加上日切快照避免重算。

策略 C:物化视图 / 流式

用 Flink / Materialize / ClickHouse 物化视图把余额维护成一个持续更新的物化表。适合分析型查询(不做强一致性担保)。

生产上最常见的组合是:实时路径用 A(带热点优化)+ 分析路径用 B 或 C

5.4 余额一致性校验

无论哪种策略,都必须周期性跑一致性校验:

-- 试算平衡(Trial Balance)
SELECT currency, SUM(amount_signed) AS delta
FROM journal_lines l
JOIN journal_entries e USING (entry_id)
WHERE e.status = 'POSTED'
GROUP BY currency;
-- 期望每种币种结果为 0

以及”快照表 vs 分录和”一致性:

SELECT l.account_id, l.currency,
       SUM(l.amount_signed) AS from_lines,
       MAX(b.balance)       AS from_snapshot
FROM journal_lines l
JOIN journal_entries e USING (entry_id)
LEFT JOIN account_balances b ON b.account_id = l.account_id AND b.currency = l.currency
WHERE e.status = 'POSTED'
GROUP BY l.account_id, l.currency
HAVING SUM(l.amount_signed) <> MAX(b.balance);

实际工程中会安排”日终批跑”和”实时抽查”。差异超过 0 分立即告警。

六、热点账户:工程级问题

6.1 热点的来源

以下账户天然是热点:

热点账户的每一行都是全系统的争用焦点,余额快照表的行锁、数据库的 page latch、Binlog 的写放大全都集中在这里。

6.2 分桶(Sharded Counter)

把”手续费收入”在逻辑上保持为一个账户,物理上拆 N 个子账户

手续费收入 (1001)
  └── 手续费收入-shard-0 (1001.00)
  └── 手续费收入-shard-1 (1001.01)
  ...
  └── 手续费收入-shard-63 (1001.63)

写入时按 hash(entry_id) % 64 选子账户。查询总余额 SUM 所有子账户。Google Cloud Datastore 的 Sharded Counters、Stripe 内部的 ledger 都用过类似方案。

6.3 Shadow Account + 延迟合并

另一种思路:写入时用”影子账户”,定时合并到主账户。极低延迟要求下,影子账户可以是纯内存/Redis 的计数器,合并通过幂等的”结转分录”落库。风险在于合并延迟内总账不平,对账系统必须能识别”未合并”状态。

6.4 TigerBeetle 的做法

TigerBeetle 是一个为金融设计的专用数据库,把复式记账写成内核级原语。它的关键设计:

在一台普通机器上可以做到百万级 TPS 的 transfer,代价是”没有灵活 SQL”。它把热点账户的问题消除在数据结构层,而不是用 shard 缓解。

七、“账簿 vs 余额”:派生关系的工程纪律

把§二到§六串起来,一条纪律:账簿(Ledger)是事实,余额(Balance)是视图;所有写入动作只增加分录,从不直接改余额

这条纪律保证:

工程上典型破坏这条纪律的反例:

  1. 应用代码里直接 UPDATE user_wallet SET balance = ? WHERE user_id = ?
  2. 把”用户提现冻结金额”写成用户表上的字段
  3. 客服误操作工具里提供”直接调整余额”按钮

替代做法:

八、冲销 vs 更正

8.1 冲销(Reversal)

最安全的回滚方式:生成一条与原分录借贷完全相反的新分录,并在 reversed_of 指向原 entry_id。原分录保持不动。

原: Dr. 应付-用户 100 / Cr. 应付-商户 99 / Cr. 手续费 1
冲: Dr. 应付-商户 99 Dr. 手续费 1 / Cr. 应付-用户 100

冲销的 effective_time 选择两种:

审计视角更倾向第二种:历史不能被改写

8.2 更正(Correction)

对”只错了一部分”的分录,不一定全量冲销,而是补一笔差额分录:

更正的优点是分录少、故事清楚;缺点是如果错得离谱(金额方向都错了),还是冲销+重记更安全。

8.3 规则

给系统设几条硬规则:

  1. 已进入对账、清算的分录禁止直接改字段,只能冲销/更正
  2. 冲销分录必须带原分录引用,审计可以反查
  3. 更正分录必须带业务理由(narration 或独立字段),人工审批
  4. 系统应能查询任一原分录的”生命线”(原 → 更正 → 冲销 → 再记)

九、多币种账簿

9.1 单币种账户还是多币种账户

两种建模:

单币种账户的好处:试算平衡天然分币种成立;查询余额不用再分组;符合中国企业会计准则的”一币一户”。多币种账户更紧凑,但每次计算余额都要 GROUP BY currency,余额快照表主键也要扩成 (account_id, currency)

9.2 FX 折算与 Revaluation

以 CNY 为记账本位币、同时保留美元明细的做法(“双轨账”):

Dr. 备付金-美金 (USD)       100     (原币)
Dr. 备付金-美金 (CNY)       690     (本位币)
    Cr. 应付-商户 (USD)          100
    Cr. 应付-商户 (CNY)          687
    Cr. 汇兑损益 (CNY)             0
    Cr. 手续费收入 (CNY)           3

实际工程中一般写两条 entry:一条按原币种记,一条按本位币记,两者通过 biz_ref 关联,余额分别查。

重估(Revaluation):月末对美元资产按月末汇率重估,差额计入”汇兑损益”。这是一条自动生成的系统分录:

Dr. 备付金-美金 (CNY)     Δ               -- 若本位币升值,为负
    Cr. 汇兑损益 (CNY)                     Δ

IFRS / CAS 都允许这种按期重估。工程上要注意:不要重估负债与资产时选不同汇率,否则表不平。

9.3 三币种账

更复杂的场景是”交易币种 / 结算币种 / 本位币”分离。例如中国平台接入日本商户,交易日元、结算美元、账簿人民币。这时候单条业务需要三条分录或一条分录三个币种字段。常见做法是分录行加 txn_ccy / settle_ccy / reporting_ccy / fx_rate_*,每条业务的原币留一份、折算两次。这增加了建模复杂度,但对汇率对账是必要的。

十、对账接口预留

账本自身不是对账系统(第 23 篇),但账本必须提供对账需要的一切查询能力。最小接口集合:

-- 1. 按业务单号反查分录
SELECT e.*, l.*
FROM journal_entries e
JOIN journal_lines l USING (entry_id)
WHERE e.biz_ref = :biz_ref;

-- 2. 按日期切片提取全部分录行(给对账系统跑全量)
SELECT l.*, e.effective_time, e.biz_type, e.biz_ref
FROM journal_lines l
JOIN journal_entries e USING (entry_id)
WHERE e.effective_time >= :day
  AND e.effective_time <  :day + INTERVAL '1 day'
  AND e.status = 'POSTED'
ORDER BY e.effective_time;

-- 3. 按账户 + 时间窗取流水(对外展示 / 下游钱包)
SELECT ...
WHERE l.account_id = :a
  AND e.effective_time BETWEEN :t1 AND :t2;

-- 4. 按辅助核算维度聚合(商户日报、用户月度消费)
SELECT merchant_id, SUM(amount_signed)
FROM journal_lines l
JOIN journal_entries e USING (entry_id)
WHERE l.account_id = :gmv_account
  AND e.effective_time BETWEEN :t1 AND :t2
GROUP BY merchant_id;

工程上约定 4 条接口为”一等公民”,独立压测、独立缓存;其余复杂分析走数仓离线表(T+1 同步 journal_* 到 Hive/Iceberg)。

十一、一张图:100 元订单的分录

下图用 Mermaid 画出 §3.2 场景的资金流与分录:

flowchart LR
    U["用户 u_123<br/>应付-用户 (负债)"]
    M["商户 m_A<br/>应付-商户 (负债)"]
    F["手续费收入<br/>(收入)"]
    B["备付金-微信<br/>(资产)"]

    U  -- "Dr. 100 (扣用户余额)" --> E(("Entry<br/>PAY_ORDER_100"))
    E  -- "Cr. 99  (挂应付商户)" --> M
    E  -- "Cr. 1   (计入手续费)" --> F

    classDef asset fill:#0b3d1e,stroke:#3fb950,color:#adbac7;
    classDef liab  fill:#3b1f0b,stroke:#f0883e,color:#adbac7;
    classDef rev   fill:#1a2a55,stroke:#388bfd,color:#adbac7;
    class B asset;
    class U,M liab;
    class F rev;

同时给出 SVG 版本(深色主题),便于静态站点渲染:

Entry: PAY_ORDER_100 (effective_time=…)

应付-用户 u_123 (负债) Dr. 100

Journal Entry Σ amount_signed = 0

应付-商户 m_A (负债) Cr. 99

手续费收入 (收入) Cr. 1

备付金 (资产) 不变(仅权属转移)

红线=Debit 借方 绿线=Credit 贷方 Entry 内借贷合计为 0

十二、开源与工业实现参考

12.1 文本账本:Ledger CLI、hledger、beancount

工程上这类工具不适合直接跑生产支付,但它们的”账本是文本、余额是派生”这条设计哲学值得内化。如果你要设计内部账务系统,先用 beancount 把一个真实业务跑一遍,再写 SQL 表。

12.2 桌面 / SaaS:GnuCash、QuickBooks、Xero

12.3 金融级专用:TigerBeetle、Square LedgerDB、Stripe Ledger

12.4 选型速览

场景 推荐
个人 / 团队记账 / 财务模型原型 beancount、Ledger CLI
小企业 ERP 内置账本 GnuCash schema 参考
中等规模互联网业务(< 十万 TPS) PostgreSQL / TiDB 自建三表模型
高并发金融业务(撮合、清结算) TigerBeetle 或自研内存 + 持久化
需要强审计 / 合规证据链 自建 + 不可变 WORM 存储 + 分录哈希链

十二点五、一次完整的写入:从 API 到落库

为把§四到§十的零散规则串起来,下面写一段伪代码,演示”下单支付 100 元”的完整服务端路径。语言用 Go 风格伪代码,重点在流程而非语法。

// PostEntryReq 是账务写入的唯一入口
type PostEntryReq struct {
    BizType        string      // PAYMENT / REFUND / ...
    BizRef         string      // 订单号
    IdempotencyKey string      // biz_type:biz_ref:version
    EffectiveTime  time.Time
    Narration      string
    Lines          []Line      // 至少 2 条
}

type Line struct {
    AccountCode string
    Direction   byte    // 'D' | 'C'
    Amount      int64   // 最小单位
    Currency    string
    // 辅助核算
    UserID      *int64
    MerchantID  *int64
    OrderID     *string
    Channel     *string
}

func PostEntry(ctx context.Context, req PostEntryReq) (entryID string, err error) {
    // 1. 参数校验
    if len(req.Lines) < 2 {
        return "", ErrTooFewLines
    }
    if err := validateBalance(req.Lines); err != nil {
        return "", err // Σ amount_signed per currency != 0
    }

    // 2. 幂等预检:先查再写,遇冲突返回已有 entry
    if existing, ok := lookupByIdem(ctx, req.IdempotencyKey); ok {
        return existing.EntryID, nil
    }

    // 3. 解析账户 code -> account_id、校验类型与币种
    accIDs, err := resolveAccounts(ctx, req.Lines)
    if err != nil {
        return "", err
    }

    // 4. 同事务内:插 entries、插 lines、更新 balances
    err = tx(ctx, func(t Tx) error {
        entryID = uuid.NewString()
        if err := t.InsertEntry(entryID, req); err != nil {
            // 唯一键冲突 => 并发写,读回已存在的
            if isDuplicate(err) {
                entryID, _ = t.LookupIdem(req.IdempotencyKey)
                return nil
            }
            return err
        }
        if err := t.InsertLines(entryID, req.Lines, accIDs); err != nil {
            return err
        }
        return t.ApplyBalances(entryID, req.Lines, accIDs)
    })
    if err != nil {
        return "", err
    }

    // 5. 发出事件(事务性 outbox 在步骤 4 内落库,这里仅唤醒投递)
    kickOutbox(entryID)
    return entryID, nil
}

几个关键点:

  1. 幂等先行:在真正写库前先查 idempotency_key,避免大量重复请求打穿事务。
  2. 借贷平衡在应用层 + 数据库层双重校验:应用层拒绝明显错误,数据库约束兜底(防止绕过 API 写入)。
  3. ApplyBalances 与分录写入在同一事务:避免”分录成功、余额更新失败”的半状态。若用策略 B(快照+增量),这一步可以省略,换成异步物化;但要接受查询延迟。
  4. outbox 在事务内:下游订阅者(对账系统、风控、行情、BI)拿到的”分录事件”与账本强一致。

十二点六、审计与可验证性:哈希链与 WORM

金融账本的合规要点不仅是”能查回历史”,还要能证明历史未被篡改。工程手段主要有:

12.6.1 分录哈希链

给每条 entry 计算 hash_i = SHA-256(hash_{i-1} || canonical(entry_i)),形成一条 Merkle 风格的哈希链:

ALTER TABLE journal_entries ADD COLUMN prev_hash BYTEA;
ALTER TABLE journal_entries ADD COLUMN entry_hash BYTEA;

这样任何历史篡改都会让哈希链断裂,从断点倒推能定位被改行。工业上 Amazon QLDB、AWS Ledger Database 都以类似思路实现不可变性。

12.6.2 WORM 存储归档

“一次写、永不改”(Write-Once-Read-Many)的存储(如 S3 Object Lock Compliance 模式、阿里云 OSS 合规保留、专用 WORM 磁盘阵列)用于归档:

12.6.3 审批流:哪些分录要双人复核

不是所有分录都需要人工复核(否则日均千万笔没法做)。一般的红线:

审批流产出的证据(审批人、时间、理由)与 entry 一并落库,形成合规证据链。

十二点七、一段可运行的 Python 迷你账本

为了让”纪律”变得可感知,下面给出一段不到 100 行的 Python 迷你账本,展示借贷平衡与余额派生。读者可以抄到本地,把第三节的业务案例全部跑一遍。

from dataclasses import dataclass, field
from collections import defaultdict
from typing import List, Dict
from decimal import Decimal
from datetime import datetime
import uuid

@dataclass
class Line:
    account: str
    direction: str           # 'D' or 'C'
    amount: Decimal
    currency: str = 'CNY'
    dims: Dict[str, str] = field(default_factory=dict)

    @property
    def signed(self) -> Decimal:
        return self.amount if self.direction == 'D' else -self.amount

@dataclass
class Entry:
    biz_type: str
    biz_ref: str
    lines: List[Line]
    narration: str = ''
    effective_time: datetime = field(default_factory=datetime.utcnow)
    entry_id: str = field(default_factory=lambda: str(uuid.uuid4()))

    def validate(self):
        sums = defaultdict(Decimal)
        for l in self.lines:
            sums[l.currency] += l.signed
        bad = {c: v for c, v in sums.items() if v != 0}
        if bad:
            raise ValueError(f'Unbalanced: {bad}')

class Ledger:
    def __init__(self):
        self.entries: List[Entry] = []
        self.idem: Dict[str, str] = {}

    def post(self, entry: Entry, idem_key: str) -> str:
        if idem_key in self.idem:
            return self.idem[idem_key]
        entry.validate()
        self.entries.append(entry)
        self.idem[idem_key] = entry.entry_id
        return entry.entry_id

    def balance(self, account: str, currency: str = 'CNY',
                as_of: datetime = None) -> Decimal:
        total = Decimal(0)
        for e in self.entries:
            if as_of and e.effective_time > as_of:
                continue
            for l in e.lines:
                if l.account == account and l.currency == currency:
                    total += l.signed
        return total

    def trial_balance(self) -> Dict[str, Decimal]:
        out = defaultdict(Decimal)
        for e in self.entries:
            for l in e.lines:
                out[l.currency] += l.signed
        return dict(out)

# ---- demo ----
L = Ledger()

# 3.1 充值
L.post(Entry('PAYMENT', 'topup_1', [
    Line('备付金-微信',    'D', Decimal('100')),
    Line('应付-用户/u_123','C', Decimal('100')),
], narration='充值 100'), 'PAYMENT:topup_1')

# 3.2 下单
L.post(Entry('PAYMENT', 'order_1', [
    Line('应付-用户/u_123','D', Decimal('100')),
    Line('应付-商户/m_A',  'C', Decimal('99')),
    Line('手续费收入',      'C', Decimal('1')),
], narration='下单 100,抽 1'), 'PAYMENT:order_1')

assert L.balance('应付-用户/u_123') == 0
assert L.balance('应付-商户/m_A')   == -Decimal('99')   # 负债贷方为正,展示翻号
assert L.balance('手续费收入')       == -Decimal('1')
assert L.trial_balance() == {'CNY': Decimal('0')}

这段代码浓缩了第 02 到 09 节的所有纪律:

真实生产系统的复杂度主要来自规模(分库、热点)、多币种、审计与合规、并发写入时的锁策略;但核心的对象模型就是这几十行

十三、工程坑点

  1. 只记一方:写代码时想着”用户扣 100”,忘了同时挂”应付商户/手续费”。最稳的办法是不暴露单腿 API,写入必须整体 entry。
  2. 正负号混乱amount 用有符号 vs 无符号+方向字段,团队要统一。推荐 amount > 0 + direction,派生一个 amount_signed 便于 SUM。
  3. 币种缺失:早期只支持 CNY 时省略币种字段,后来做跨境瞬间崩塌。从第 0 行代码就带 currency
  4. 时间错用:用 created_at 做对账切日,导致跨零点的订单记到错误的日期。对账用 effective_time
  5. 热点账户直接打库:手续费账户不分桶,大促每秒上千笔更新同一行,死锁风暴。
  6. 更正改历史:为图省事 UPDATE journal_lines SET amount = ?,审计和下游物化视图全乱。
  7. 没做试算平衡巡检:系统上线不跑 SUM(amount_signed) = 0 的日终校验,差异积累数月无人发现。
  8. 余额快照 vs 分录差异容忍 0.01:看似小,实际是建模漏洞(四舍五入错账户),必须为 0。
  9. 跨库事务写分录:把 journal_entries 和业务表跨服务分库,没上分布式事务。结果业务成功、分录没写。要么同库同事务、要么用事务性发件箱(transactional outbox)+ 对账补偿。
  10. 冲销没幂等:上游重试触发两次冲销,账反超回去。冲销 entry 也要有 idempotency_key = 'REV:' + original_entry_id

十四、落地清单

十五、写给后续章节的钩子

本篇确定了账本层的数据模型与约束。接下来:

复式记账作为会计学的千年底座,其工程化的要点不在”高深”,而在”纪律”。账本是事实,余额是视图;写入只新增,历史不改写;借贷必平衡,币种必明确。守住这几条,后面所有复杂度都还有救。


参考资料


上一篇《钱的建模:金额精度、币种、会计单位、多语言金额》

下一篇《账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户

账务(Ledger)数据库是金融系统最硬的那块骨头。本文从 RPO/RTO 目标出发,对比 PostgreSQL、MySQL、OceanBase、TiDB、CockroachDB、Oracle、TigerBeetle 等主流选型,讲分片维度、热点账户拆解、索引设计、冷热归档、MVCC 并发控制与审计合规,辅以蚂蚁、Stripe、PayPal、Square 的真实演进路径。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】幂等、事务与一致性:SAGA、TCC、对账补偿

支付与账务系统里,"这笔操作能不能重放一遍"几乎是每一次故障复盘都会问到的问题。本文从网络重试的本质谈起,讲清楚幂等(idempotency)的三层设计、Idempotency-Key 的工程细节、订单状态机的落库方式,并横向对比 2PC、TCC、SAGA、可靠消息四种分布式事务方案,配合 Outbox Pattern、CDC、补偿策略与真实事故案例,给出一份可以直接落地的检查清单。


By .