返回架构笔记
Arch Day 11

Arch Day 11: 领域建模实战(金融级)

金融级领域建模不是"用DDD的类图画Account和Transaction"那么简单——它需要处理金额精度(一分钱都不能差)、复式记账(借贷必须平衡)、并发控制(双重支付防护)、幂等性(网络重试不重复扣款) 这四个核心挑战,每一个都能让没有金融背景的开发者踩坑3个月。

2026-04-10
第一阶段 - 架构基础
领域建模复式记账Money模式并发控制幂等性金融系统

日期: 2026-04-10 (Day 11) 阶段: 第一阶段 - 架构基础 标签: #领域建模 #复式记账 #Money模式 #并发控制 #幂等性 #金融系统


核心概念

一句话定义

金融级领域建模不是"用DDD的类图画Account和Transaction"那么简单——它需要处理金额精度(一分钱都不能差)、复式记账(借贷必须平衡)、并发控制(双重支付防护)、幂等性(网络重试不重复扣款) 这四个核心挑战,每一个都能让没有金融背景的开发者踩坑3个月。

为什么资深架构师仍需关注

10年金融软件经验的核心沉淀之一就是对"钱"的敬畏。这不是抽象的领域建模练习——是关系到真金白银的系统设计:

  1. 精度错误 = 真实损失:浮点数运算0.1 + 0.2 ≠ 0.3的问题在金融系统中意味着对账失败、审计不通过
  2. 并发错误 = 双重支付:两个请求同时扣款,余额应该扣两次但只扣了一次
  3. 幂等性缺失 = 重复扣款:网络超时重试导致用户被扣两次钱
  4. 记账错误 = 监管风险:借贷不平衡意味着账目异常,可能面临审计处罚

常见误区与反模式

误区真相后果
"用float/double存金额"浮点数有精度丢失0.1 + 0.2 = 0.30000000000000004
"用BigDecimal就安全了"BigDecimal也有陷阱(除法精度/比较)new BigDecimal("0.10") != new BigDecimal("0.1")
"余额用一个字段就够了"应该通过记账分录计算余额(可审计)余额和分录对不上时无法排查
"乐观锁总是比悲观锁好"高并发热点账户下乐观锁重试风暴系统性能崩溃
"重试就是幂等"没有幂等键的重试=重复执行用户被多次扣款
"单币种设计后加多币种容易"多币种从Day 1就要设计进去后加多币种≈重写

知识点详解

知识点1: Money模式——金额的正确表示

为什么不能用float/double:

// JavaScript中的浮点数灾难
console.log(0.1 + 0.2);            // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);    // false
console.log(1.005 * 100);          // 100.49999999999999 (四舍五入会出错)

// 在金融系统中,这意味着:
// 用户转账 $0.10 + $0.20 后,余额显示 $0.30000000000000004
// 或者更糟——对账时发现少了/多了一分钱

Money模式的正确实现:

/**
 * 核心原则: 用最小单位的整数表示金额
 * - USD: 用分(cents)表示, $1.23 = 123 cents
 * - BTC: 用聪(satoshi)表示, 1 BTC = 100,000,000 satoshi
 * - ETH: 用wei表示, 1 ETH = 10^18 wei
 */

// Currency定义(值对象)
class Currency {
  constructor(
    readonly code: string,        // "USD", "CNY", "ETH"
    readonly decimalPlaces: number, // USD=2, BTC=8, ETH=18
    readonly symbol: string        // "$", "¥", "Ξ"
  ) {}

  static readonly USD = new Currency("USD", 2, "$");
  static readonly CNY = new Currency("CNY", 2, "¥");
  static readonly ETH = new Currency("ETH", 18, "Ξ");
  static readonly BTC = new Currency("BTC", 8, "₿");
  static readonly USDC = new Currency("USDC", 6, "$");

  equals(other: Currency): boolean {
    return this.code === other.code;
  }
}

// Money值对象(不可变)
class Money {
  // 内部用BigInt存储最小单位的值
  // BigInt支持任意精度整数,不会有浮点数问题
  private constructor(
    readonly amountInMinorUnits: bigint,
    readonly currency: Currency
  ) {}

  // 工厂方法
  static of(amount: number | string, currency: Currency): Money {
    // 从人类可读的金额创建
    // $1.23 → 123 cents
    const multiplier = BigInt(10 ** currency.decimalPlaces);
    // 用字符串避免浮点数问题
    const parts = amount.toString().split('.');
    const wholePart = BigInt(parts[0]) * multiplier;
    const fracPart = parts[1]
      ? BigInt(parts[1].padEnd(currency.decimalPlaces, '0')
                       .slice(0, currency.decimalPlaces))
      : 0n;
    const sign = amount.toString().startsWith('-') ? -1n : 1n;
    return new Money(sign * (BigInt(Math.abs(Number(parts[0]))) * multiplier + fracPart), currency);
  }

  static fromMinorUnits(amount: bigint, currency: Currency): Money {
    return new Money(amount, currency);
  }

  static zero(currency: Currency): Money {
    return new Money(0n, currency);
  }

  // 算术操作(全部返回新的Money实例)
  add(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money(this.amountInMinorUnits + other.amountInMinorUnits, this.currency);
  }

  subtract(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money(this.amountInMinorUnits - other.amountInMinorUnits, this.currency);
  }

  multiply(factor: number): Money {
    // 乘法后四舍五入到最小单位
    const result = Number(this.amountInMinorUnits) * factor;
    return new Money(BigInt(Math.round(result)), this.currency);
  }

  // 分配(解决"分钱问题": $10分成3份不能是$3.33+$3.33+$3.33=$9.99)
  allocate(ratios: number[]): Money[] {
    const total = ratios.reduce((a, b) => a + b, 0);
    let remainder = this.amountInMinorUnits;
    const results: Money[] = [];

    for (let i = 0; i < ratios.length; i++) {
      const share = (this.amountInMinorUnits * BigInt(ratios[i])) / BigInt(total);
      results.push(new Money(share, this.currency));
      remainder -= share;
    }

    // 余数分给第一份(确保总额一致)
    results[0] = new Money(
      results[0].amountInMinorUnits + remainder,
      this.currency
    );

    return results;
  }

  // 比较
  isPositive(): boolean { return this.amountInMinorUnits > 0n; }
  isNegative(): boolean { return this.amountInMinorUnits < 0n; }
  isZero(): boolean { return this.amountInMinorUnits === 0n; }
  isGreaterThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amountInMinorUnits > other.amountInMinorUnits;
  }
  isLessThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.amountInMinorUnits < other.amountInMinorUnits;
  }

  // 展示
  toDisplayString(): string {
    const divisor = BigInt(10 ** this.currency.decimalPlaces);
    const whole = this.amountInMinorUnits / divisor;
    const frac = this.amountInMinorUnits % divisor;
    const fracStr = frac.toString().padStart(this.currency.decimalPlaces, '0');
    return `${this.currency.symbol}${whole}.${fracStr}`;
  }

  // 安全检查
  private assertSameCurrency(other: Money): void {
    if (!this.currency.equals(other.currency)) {
      throw new CurrencyMismatchError(
        `Cannot operate on ${this.currency.code} and ${other.currency.code}`
      );
    }
  }
}

// 使用示例
const price = Money.of("19.99", Currency.USD);   // $19.99
const tax = price.multiply(0.08);                  // $1.60 (四舍五入)
const total = price.add(tax);                      // $21.59

// 分钱问题
const shares = Money.of("10.00", Currency.USD).allocate([1, 1, 1]);
// → [$3.34, $3.33, $3.33] (总计$10.00,不会少一分钱)

知识点2: 复式记账领域模型

复式记账的核心原则:

每笔交易至少涉及两个账户:
- 一个账户借方(Debit)增加
- 一个账户贷方(Credit)增加
- 借方总额 = 贷方总额 (永远平衡)

例: Alice转$100给Bob
- Alice的账户: Credit $100 (钱出去)
- Bob的账户: Debit $100 (钱进来)

完整领域模型:

// ========== 值对象 ==========

// 账户ID
class AccountId {
  constructor(readonly value: string) {
    if (!value || value.length === 0) {
      throw new Error("AccountId cannot be empty");
    }
  }
  equals(other: AccountId): boolean {
    return this.value === other.value;
  }
}

// 交易ID(用于幂等性)
class TransactionId {
  constructor(readonly value: string) {}

  static generate(): TransactionId {
    return new TransactionId(crypto.randomUUID());
  }
  equals(other: TransactionId): boolean {
    return this.value === other.value;
  }
}

// 记账方向
enum EntryType {
  DEBIT = "DEBIT",
  CREDIT = "CREDIT"
}

// ========== 实体 & 聚合根 ==========

// 记账分录(实体,属于Journal聚合根)
class JournalEntry {
  constructor(
    readonly accountId: AccountId,
    readonly entryType: EntryType,
    readonly amount: Money,       // 始终为正数
    readonly description: string
  ) {
    if (!amount.isPositive()) {
      throw new Error("Entry amount must be positive");
    }
  }
}

// 记账凭证(聚合根)
class Journal {
  readonly id: string;
  readonly transactionId: TransactionId;
  private _entries: JournalEntry[];
  readonly createdAt: Date;
  private _status: JournalStatus;

  constructor(
    transactionId: TransactionId,
    entries: JournalEntry[]
  ) {
    this.id = crypto.randomUUID();
    this.transactionId = transactionId;
    this._entries = [...entries];
    this.createdAt = new Date();
    this._status = JournalStatus.PENDING;

    // 核心不变量: 借贷必须平衡
    this.assertBalanced();
    // 核心不变量: 至少两条分录
    this.assertMinimumEntries();
  }

  get entries(): readonly JournalEntry[] {
    return this._entries;
  }

  get status(): JournalStatus {
    return this._status;
  }

  post(): void {
    if (this._status !== JournalStatus.PENDING) {
      throw new Error(`Cannot post journal in status ${this._status}`);
    }
    this._status = JournalStatus.POSTED;
    // 发出领域事件
    DomainEvents.raise(new JournalPosted(this.id, this.transactionId, this._entries));
  }

  void_(): void {
    if (this._status !== JournalStatus.POSTED) {
      throw new Error(`Cannot void journal in status ${this._status}`);
    }
    this._status = JournalStatus.VOIDED;
    DomainEvents.raise(new JournalVoided(this.id, this.transactionId));
  }

  // 不变量检查
  private assertBalanced(): void {
    const debitTotal = this._entries
      .filter(e => e.entryType === EntryType.DEBIT)
      .reduce((sum, e) => sum.add(e.amount), Money.zero(this._entries[0].amount.currency));

    const creditTotal = this._entries
      .filter(e => e.entryType === EntryType.CREDIT)
      .reduce((sum, e) => sum.add(e.amount), Money.zero(this._entries[0].amount.currency));

    if (!debitTotal.equals(creditTotal)) {
      throw new UnbalancedJournalError(
        `Debit total ${debitTotal.toDisplayString()} != Credit total ${creditTotal.toDisplayString()}`
      );
    }
  }

  private assertMinimumEntries(): void {
    if (this._entries.length < 2) {
      throw new Error("Journal must have at least 2 entries");
    }
  }
}

enum JournalStatus {
  PENDING = "PENDING",
  POSTED = "POSTED",
  VOIDED = "VOIDED"
}

// 账户(聚合根)
class Account {
  readonly id: AccountId;
  private _name: string;
  private _currency: Currency;
  private _status: AccountStatus;
  private _version: number;  // 用于乐观锁

  // 注意: 余额不存储在Account中!
  // 余额通过所有已过账分录的借贷汇总计算
  // 这确保了余额始终与分录一致(可审计)

  constructor(
    id: AccountId,
    name: string,
    currency: Currency
  ) {
    this.id = id;
    this._name = name;
    this._currency = currency;
    this._status = AccountStatus.ACTIVE;
    this._version = 0;
  }

  get version(): number { return this._version; }
  get status(): AccountStatus { return this._status; }
  get currency(): Currency { return this._currency; }

  freeze(): void {
    if (this._status === AccountStatus.CLOSED) {
      throw new Error("Cannot freeze a closed account");
    }
    this._status = AccountStatus.FROZEN;
    DomainEvents.raise(new AccountFrozen(this.id));
  }

  unfreeze(): void {
    if (this._status !== AccountStatus.FROZEN) {
      throw new Error("Can only unfreeze a frozen account");
    }
    this._status = AccountStatus.ACTIVE;
  }

  close(): void {
    // 关户前需要检查余额为0(这需要查询服务配合)
    this._status = AccountStatus.CLOSED;
    DomainEvents.raise(new AccountClosed(this.id));
  }

  ensureActive(): void {
    if (this._status !== AccountStatus.ACTIVE) {
      throw new AccountNotActiveError(this.id, this._status);
    }
  }
}

enum AccountStatus {
  ACTIVE = "ACTIVE",
  FROZEN = "FROZEN",
  CLOSED = "CLOSED"
}

知识点3: 转账服务——领域服务+应用服务

// 领域服务: 封装涉及多个聚合根的业务逻辑
class TransferDomainService {

  createTransferJournal(
    fromAccountId: AccountId,
    toAccountId: AccountId,
    amount: Money,
    transactionId: TransactionId,
    description: string
  ): Journal {
    const entries = [
      new JournalEntry(fromAccountId, EntryType.CREDIT, amount, `Transfer out: ${description}`),
      new JournalEntry(toAccountId, EntryType.DEBIT, amount, `Transfer in: ${description}`)
    ];

    return new Journal(transactionId, entries);
  }
}

// 应用服务: 编排领域对象 + 处理基础设施关注点(事务/幂等/并发)
class TransferApplicationService {
  constructor(
    private accountRepo: AccountRepository,
    private journalRepo: JournalRepository,
    private balanceQuery: BalanceQueryService,
    private transferService: TransferDomainService,
    private idempotencyStore: IdempotencyStore
  ) {}

  async executeTransfer(command: TransferCommand): Promise<TransferResult> {
    // Step 1: 幂等性检查
    const existing = await this.idempotencyStore.find(command.idempotencyKey);
    if (existing) {
      return existing.result; // 重复请求直接返回之前的结果
    }

    // Step 2: 加载并验证账户
    const fromAccount = await this.accountRepo.findById(command.fromAccountId);
    const toAccount = await this.accountRepo.findById(command.toAccountId);

    fromAccount.ensureActive();
    toAccount.ensureActive();

    // Step 3: 检查余额(读取服务)
    const balance = await this.balanceQuery.getBalance(command.fromAccountId);
    if (balance.isLessThan(command.amount)) {
      throw new InsufficientBalanceError(command.fromAccountId, balance, command.amount);
    }

    // Step 4: 创建记账凭证(领域逻辑)
    const transactionId = TransactionId.generate();
    const journal = this.transferService.createTransferJournal(
      command.fromAccountId,
      command.toAccountId,
      command.amount,
      transactionId,
      command.description
    );

    // Step 5: 过账(在事务中)
    try {
      await this.journalRepo.save(journal);
      journal.post();
      await this.journalRepo.update(journal);

      // Step 6: 记录幂等性
      const result = new TransferResult(transactionId, 'SUCCESS');
      await this.idempotencyStore.save(command.idempotencyKey, result);

      return result;
    } catch (error) {
      if (error instanceof OptimisticLockError) {
        // 并发冲突,让调用方重试
        throw new ConcurrentModificationError("Account was modified concurrently, please retry");
      }
      throw error;
    }
  }
}

// 命令对象
class TransferCommand {
  constructor(
    readonly fromAccountId: AccountId,
    readonly toAccountId: AccountId,
    readonly amount: Money,
    readonly description: string,
    readonly idempotencyKey: string  // 调用方提供,确保幂等
  ) {}
}

知识点4: 并发控制——乐观锁 vs 悲观锁

在金融系统中,并发控制是生死攸关的问题:

场景: Alice的账户余额$100
请求A: Alice给Bob转$80
请求B: Alice给Charlie转$50

如果没有并发控制:
T1: 请求A读取余额=$100, 请求B读取余额=$100
T2: 请求A: $100 >= $80 ✓, 扣款 → 余额=$20
T3: 请求B: $100 >= $50 ✓, 扣款 → 余额=$50
结果: Alice只有$100,却转出了$130! (双重支付)

乐观锁(Optimistic Locking):

// 乐观锁: 读取时记录版本号,写入时检查版本号是否变化
class AccountRepository {
  async save(account: Account): Promise<void> {
    const result = await db.query(
      `UPDATE accounts
       SET status = $1, version = version + 1
       WHERE id = $2 AND version = $3`,
      [account.status, account.id.value, account.version]
    );

    if (result.rowCount === 0) {
      throw new OptimisticLockError(
        `Account ${account.id.value} was modified by another transaction`
      );
    }
  }
}

// 优点: 无锁等待,吞吐量高
// 缺点: 高竞争时重试频繁

// 适用场景:
// - 大多数账户(非热点)
// - 读多写少
// - 可以接受偶尔重试

悲观锁(Pessimistic Locking):

// 悲观锁: 读取时就锁定记录
class AccountRepository {
  async findByIdForUpdate(id: AccountId): Promise<Account> {
    const row = await db.query(
      `SELECT * FROM accounts WHERE id = $1 FOR UPDATE`,
      [id.value]
    );
    // FOR UPDATE会锁定这行,直到事务结束
    return this.mapToAccount(row);
  }
}

// 优点: 保证不会有并发冲突
// 缺点: 锁等待可能导致超时/死锁

// 适用场景:
// - 热点账户(如平台手续费账户,并发极高)
// - 必须一次成功,不能接受重试
// - 事务时间短(减少锁持有时间)

金融系统的并发策略选择:

场景推荐策略理由
普通用户账户乐观锁并发概率低,重试成本低
热点账户(平台归集户)悲观锁 + 异步排队并发极高,乐观锁重试风暴
批量转账悲观锁 + 按ID排序避免死锁(A锁B等B锁A)
余额查询(非扣款)无锁(读快照)读操作不需要锁

高级模式: 热点账户的异步排队

// 对于超高并发的热点账户(如交易所的热钱包)
// 直接锁会导致排队超时
// 解决方案: 内存排队 + 批量处理

class HotAccountProcessor {
  private queue: TransferCommand[] = [];
  private processing = false;

  async enqueue(command: TransferCommand): Promise<void> {
    this.queue.push(command);
    if (!this.processing) {
      this.processBatch();
    }
  }

  private async processBatch(): Promise<void> {
    this.processing = true;
    while (this.queue.length > 0) {
      // 每次取一批(如100笔)
      const batch = this.queue.splice(0, 100);
      // 在单个事务中处理整批
      await this.executeBatchInTransaction(batch);
    }
    this.processing = false;
  }
}

知识点5: 幂等性设计

为什么金融系统必须幂等:

用户点击"转账" → 请求发送 → 网络超时(但服务端已处理) → 客户端自动重试
如果没有幂等性: 用户被扣款两次!

更隐蔽的场景:
消息队列消费者处理完消息 → 发送ACK前进程崩溃 → 消息重新投递 → 重复处理

幂等性实现方案:

// 方案1: 幂等键(Idempotency Key)
// 调用方生成唯一key,服务端检查key是否已处理

interface IdempotencyStore {
  find(key: string): Promise<IdempotencyRecord | null>;
  save(key: string, result: any, ttl: Duration): Promise<void>;
}

class IdempotencyMiddleware {
  async handle(key: string, handler: () => Promise<any>): Promise<any> {
    // 1. 检查是否已处理
    const existing = await this.store.find(key);
    if (existing) {
      if (existing.status === 'COMPLETED') {
        return existing.result; // 已完成,直接返回
      }
      if (existing.status === 'PROCESSING') {
        throw new ConflictError("Request is being processed"); // 正在处理,拒绝重复
      }
    }

    // 2. 标记为处理中
    await this.store.save(key, null, 'PROCESSING');

    try {
      // 3. 执行业务逻辑
      const result = await handler();
      // 4. 标记为已完成
      await this.store.save(key, result, 'COMPLETED');
      return result;
    } catch (error) {
      // 5. 标记为失败(允许重试)
      await this.store.save(key, null, 'FAILED');
      throw error;
    }
  }
}

// 方案2: 状态机驱动的幂等
// 通过状态转换保证每个操作只执行一次

class TransferStateMachine {
  // 只允许合法的状态转换
  private static readonly TRANSITIONS: Record<string, string[]> = {
    'CREATED': ['VALIDATED'],
    'VALIDATED': ['DEBITED'],
    'DEBITED': ['CREDITED'],
    'CREDITED': ['COMPLETED'],
    // 任何状态都可以进入FAILED
    'CREATED': ['VALIDATED', 'FAILED'],
    'VALIDATED': ['DEBITED', 'FAILED'],
    'DEBITED': ['CREDITED', 'FAILED', 'DEBIT_REVERSED'],
  };

  transition(from: string, to: string): void {
    const allowed = TransferStateMachine.TRANSITIONS[from];
    if (!allowed?.includes(to)) {
      throw new IllegalStateTransitionError(from, to);
    }
  }
}

// 每一步都是幂等的:
// 如果已经DEBITED,再次调用debit()会直接跳过(因为状态已经是DEBITED)

知识点6: 多币种转账完整领域模型

// 多币种转账涉及汇率转换
class ForeignExchangeService {
  constructor(private rateProvider: ExchangeRateProvider) {}

  convert(amount: Money, targetCurrency: Currency): MoneyConversion {
    const rate = this.rateProvider.getRate(amount.currency, targetCurrency);
    const convertedAmount = amount.multiply(rate.value);
    return new MoneyConversion(amount, convertedAmount, rate);
  }
}

class ExchangeRate {
  constructor(
    readonly fromCurrency: Currency,
    readonly toCurrency: Currency,
    readonly value: number,
    readonly timestamp: Date,
    readonly source: string  // "Reuters", "Chainlink", etc.
  ) {}
}

class MoneyConversion {
  constructor(
    readonly sourceAmount: Money,
    readonly targetAmount: Money,
    readonly rate: ExchangeRate
  ) {}
}

// 跨币种转账的记账凭证
// Alice的USD账户转$100给Bob的CNY账户,汇率7.2
class CrossCurrencyTransferService {
  constructor(
    private fxService: ForeignExchangeService,
    private platformAccountIds: PlatformAccountIds
  ) {}

  createCrossCurrencyJournal(
    fromAccountId: AccountId,
    toAccountId: AccountId,
    sourceAmount: Money,
    targetCurrency: Currency,
    transactionId: TransactionId
  ): Journal {
    const conversion = this.fxService.convert(sourceAmount, targetCurrency);

    // 四条分录(两对借贷):
    // 1. Alice USD账户 Credit $100 (钱出去)
    // 2. 平台USD归集户 Debit $100 (钱进来)
    // 3. 平台CNY归集户 Credit ¥720 (钱出去)
    // 4. Bob CNY账户 Debit ¥720 (钱进来)
    const entries = [
      new JournalEntry(fromAccountId, EntryType.CREDIT, sourceAmount,
        `Cross-currency transfer out`),
      new JournalEntry(this.platformAccountIds.usdPool, EntryType.DEBIT, sourceAmount,
        `FX: received USD`),
      new JournalEntry(this.platformAccountIds.cnyPool, EntryType.CREDIT, conversion.targetAmount,
        `FX: sent CNY`),
      new JournalEntry(toAccountId, EntryType.DEBIT, conversion.targetAmount,
        `Cross-currency transfer in`),
    ];

    // 注意: 这里有两对借贷(USD对和CNY对),每对内部平衡
    // 实现上可以用multi-currency journal,或者拆成两个journal
    return new MultiCurrencyJournal(transactionId, entries, conversion.rate);
  }
}

对比分析

金额存储方案对比

方案精度性能适用场景风险
float/double差(≈15位有效数字)绝不用于金融精度丢失
BigDecimal(Java)优秀后端计算除法需要指定精度
bigint(最小单位)完美存储和传输需要正确的单位转换
string完美(不做计算)API传输需要解析
定点数(Solidity)优秀(自定义)智能合约溢出风险

并发控制策略对比

策略吞吐量一致性实现复杂度适用场景
乐观锁高(无锁等待)强(重试保证)低竞争场景
悲观锁中(锁等待)高竞争热点
内存排队极高(单线程)超高并发热点
Saga(补偿)最终一致跨服务分布式

架构设计实操

实操: 完整的"多币种转账"系统设计

系统架构:

用户请求 → API Gateway → Transfer Service
                              │
                    ┌─────────┼─────────┐
                    │         │         │
              Account Service │   FX Rate Service
              (账户状态检查)    │   (汇率查询)
                              │
                     Ledger Service
                    (记账+并发控制)
                              │
                    ┌─────────┼─────────┐
                    │         │         │
               Idempotency  Event    Balance
               Store        Store    Cache

ADR:

# ADR-003: 转账系统的并发控制策略

## 状态: 已接受

## 上下文
系统需要处理转账请求,同一账户可能同时收到多笔转账。
预估: 普通账户并发≤5 QPS,热点账户(平台归集户)并发~1000 QPS。

## 决策
- 普通账户: 乐观锁(版本号)
- 热点账户: 悲观锁 + 内存排队(按账户分片)
- 识别方式: 在Account实体上标记is_hot_account

## 理由
- 普通账户并发低,乐观锁足够
- 热点账户用乐观锁会导致重试风暴(1000 QPS时90%+的请求会重试)
- 内存排队确保热点账户的请求顺序处理,吞吐量反而更高

## 后果
- 需要维护热点账户列表
- 内存排队需要处理进程崩溃时的恢复
- 监控乐观锁重试率,当重试率>10%时考虑升级为热点账户

AI增强实践

AI在金融领域建模中的应用

1. 代码审查——寻找精度问题

Prompt: "以下是我们的金融系统中涉及金额计算的代码:
[粘贴代码]
请审查:
1. 是否有浮点数精度问题?
2. 除法运算是否指定了舍入策略?
3. 金额比较是否正确(不应该用==比较浮点数)?
4. 多币种场景是否处理了汇率精度?"

2. 并发场景分析

Prompt: "我的转账流程如下:
1. 查询余额
2. 检查余额充足
3. 创建记账凭证
4. 更新余额
请分析可能的并发问题和竞态条件,并建议解决方案。"

3. 领域模型生成

可以让AI根据业务描述生成领域模型的初始草案,但必须由有金融经验的人审查。AI常见的错误:

  • 用float/double表示金额
  • 忽略并发控制
  • 不理解复式记账的约束
  • 幂等性处理不完整

AI vs 人工边界:

任务AI能力人工不可替代
生成领域模型代码框架优秀-
检查精度和类型安全优秀-
设计并发控制策略良好(需要验证)对实际并发场景的判断
设计记账分录规则中等会计准则和监管要求
幂等性方案设计良好边界case的识别需要经验

与Web3/DeFi的关联

传统金融概念Web3对应关键差异
复式记账ERC-20 transfer事件链上自动保证借贷平衡(合约逻辑)
Money值对象uint256 + decimalsSolidity没有浮点数,天然用整数
乐观锁Nonce机制以太坊用Nonce防止交易重放
幂等性交易哈希唯一性同一笔交易不能被打包两次
汇率服务Chainlink/TWAP预言机链上获取价格需要预言机
热点账户合约地址(如Uniswap Pool)所有交易都经过同一合约地址
Saga补偿闪电贷(Flash Loan)同一区块内原子性执行

深度洞察:DeFi的记账模型

传统金融:
  用户余额存储在数据库中
  通过记账分录更新余额
  余额 = SUM(借方) - SUM(贷方)

DeFi:
  用户余额存储在合约状态中(mapping(address => uint256))
  通过transfer函数更新余额
  余额 = balanceOf(address) (直接读取,不需要汇总)

关键差异:
  传统金融: 余额是"派生值"(从分录计算)→ 可审计
  DeFi: 余额是"当前值"(直接存储)→ 需要Event Log做审计

  传统金融: 并发通过数据库锁控制
  DeFi: 并发通过区块排序控制(矿工/验证者排序交易)

  传统金融: 幂等通过幂等键控制
  DeFi: 幂等通过交易哈希+Nonce天然保证

今日思考

  1. 你见过最严重的金融系统Bug是什么? 是精度问题、并发问题、还是记账逻辑问题?如果用今天的模型重新设计,能避免吗?

  2. DeFi对传统金融系统设计有什么启发? 区块链的Nonce机制天然解决了幂等性问题,交易排序天然解决了并发问题。传统金融系统能从中借鉴什么?

  3. 在Web3时代,"链上记账"和"链下记账"如何共存? 很多Web3产品(如中心化交易所)在链上和链下都有账本,如何保证两套账本的一致性?


面试题准备

Q1: 如何设计支持多币种的记账系统?

30秒版本: 核心是三个设计:一是Money值对象(用最小单位的整数表示金额,避免浮点精度问题);二是复式记账(每笔交易借贷必须平衡,跨币种通过汇率换算和平台归集户实现平衡);三是汇率快照(记录交易时点的汇率,而非实时汇率,确保可审计)。

2分钟版本:

多币种记账系统有四个核心设计点:

第一,金额表示。绝对不用浮点数。用最小单位的整数——USD用分,ETH用wei。封装成Money值对象,包含金额和币种,所有算术操作都在Money内部进行并检查币种一致性。

第二,复式记账。每笔交易生成一个Journal(记账凭证),包含至少两条Entry(借贷分录)。同币种交易:借方总额=贷方总额。跨币种交易:通过平台归集户(clearing account)分成两对借贷——源币种一对、目标币种一对。

第三,汇率管理。每笔跨币种交易必须记录使用的汇率(快照)。查询历史交易时用快照汇率而非当前汇率。汇率来源可以是外汇市场、Chainlink预言机等。

第四,余额计算。余额不应该作为独立字段存储,而应该从已过账的分录中汇总计算。这确保了余额始终与分录一致。出于性能考虑可以缓存余额,但缓存必须在每次分录变化时同步更新。

追问准备:

追问: "余额实时计算性能怎么保证?" 回答: 实际中我们会用"余额快照+增量计算"。每天/每小时做一次全量快照,之后只需要从快照点计算增量分录。查询时:当前余额 = 最近快照余额 + 快照后的分录汇总。

Q2: 金融系统并发如何处理?

30秒版本: 分三层处理:普通账户用乐观锁(版本号),热点账户用悲观锁加内存排队,跨服务场景用Saga补偿。核心原则是——余额检查和扣款必须在同一个原子操作中完成,否则会出现双重支付。

2分钟版本:

金融系统的并发核心挑战是防止双重支付(double spending)。解决方案分三层:

第一层,数据库层。对于单服务内的并发,用乐观锁(版本号)或悲观锁(SELECT FOR UPDATE)。我的选择标准是并发冲突的概率——低于10%用乐观锁,高于10%用悲观锁。

第二层,应用层。对于热点账户(如平台归集户),数据库锁效率太低。用内存排队,按账户ID分片,每个分片内顺序处理。这样热点账户的吞吐量可以达到万级QPS。

第三层,分布式层。跨服务的转账用Saga模式。先扣源账户(Debit),成功后加目标账户(Credit)。如果Credit失败,执行补偿(Reverse Debit)。关键是每一步都必须幂等。

最重要的一点:余额检查和扣款不能分成两步。"先查余额→确认够→再扣款"之间如果有另一个请求也通过了余额检查,就会双重支付。必须在数据库层做原子操作:UPDATE accounts SET balance = balance - amount WHERE id = ? AND balance >= amount


学习资源

  1. 《Patterns of Enterprise Application Architecture》 - Martin Fowler - Money模式的原始出处
  2. 《Domain-Driven Design》 - Eric Evans - Chapter 6: Value Objects
  3. Martin Fowler - "Money Pattern" - https://martinfowler.com/eaaCatalog/money.html
  4. Joda-Money (Java) / dinero.js (JavaScript) - Money模式的开源实现
  5. 《Designing Data-Intensive Applications》 - Martin Kleppmann - 并发控制和分布式事务
  6. Stripe Engineering Blog - 支付系统幂等性设计的最佳实践

明日预告

Day 12: Event Storming高级 —— 从会议室到代码的桥梁。明天我们将深入Event Storming的三个层级(Big Picture → Process Level → Design Level),学习如何引导业务专家参与,并将Event Storming的产出直接映射为代码。实操:对"贷款申请→审批→放款→还款→逾期"做完整三层Event Storming。