第 34 章

金融级数据库方案

金融系统数据库设计

金融系统对数据库的要求极为苛刻:资金绝对准确、操作完全幂等、每笔变更可审计、任何时刻可对账。一个设计错误可能导致数百万的资金损失。本章从支付公司的实战经验出发,系统讲解金融级 MySQL 设计规范。

金融数据库核心原则

原则 1:金额必须用 DECIMAL,绝对禁止 FLOAT/DOUBLE

FLOAT(10,2) 存储 1.1 实际是 1.0999999...,累计计算后误差不可接受。DECIMAL(19,4) 精确存储十进制数,精度损失为零。

原则 2:所有金额操作必须在数据库事务中完成

转账 = 扣款 + 入账 + 写流水,必须在同一个 InnoDB 事务中原子完成。任何拆开操作的设计都是错误的。

原则 3:每笔资金变动必须有对应的流水记录

账户余额可以从流水表重新计算得出(账户余额 = 所有入账流水之和 - 所有出账流水之和)。流水记录是金融系统的"源头真理"。

原则 4:禁止物理删除和直接 UPDATE 余额字段

UPDATE account SET balance = 1000 WHERE id = 1 — 这种操作无法审计。正确方式是通过余额变动事务,让余额是流水聚合的结果。

账户与余额设计

CREATE TABLE account (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    account_no      VARCHAR(32)     NOT NULL COMMENT '账户号(对外展示)',
    user_id         BIGINT UNSIGNED NOT NULL,
    account_type    TINYINT         NOT NULL COMMENT '1=主账户 2=冻结账户 3=红包账户',
    currency        CHAR(3)         NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217',
    balance         DECIMAL(19,4)   NOT NULL DEFAULT 0.0000 COMMENT '可用余额',
    frozen_amount   DECIMAL(19,4)   NOT NULL DEFAULT 0.0000 COMMENT '冻结金额',
    total_income    DECIMAL(19,4)   NOT NULL DEFAULT 0.0000 COMMENT '累计收入(校验用)',
    total_expense   DECIMAL(19,4)   NOT NULL DEFAULT 0.0000 COMMENT '累计支出(校验用)',
    status          TINYINT         NOT NULL DEFAULT 1 COMMENT '1=正常 2=冻结 3=注销',
    version         BIGINT          NOT NULL DEFAULT 0 COMMENT '乐观锁',
    created_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_account_no (account_no),
    INDEX idx_user_type (user_id, account_type),
    CONSTRAINT chk_balance CHECK (balance >= 0),            -- 余额非负约束
    CONSTRAINT chk_frozen  CHECK (frozen_amount >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='账户主表';

-- 查询账户余额时加行锁(转账操作前)
SELECT balance, version FROM account
WHERE account_no = ? FOR UPDATE;

转账模型

CREATE TABLE transfer_order (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    transfer_no     VARCHAR(32)     NOT NULL COMMENT '转账单号(幂等键)',
    from_account    VARCHAR(32)     NOT NULL,
    to_account      VARCHAR(32)     NOT NULL,
    amount          DECIMAL(19,4)   NOT NULL,
    currency        CHAR(3)         NOT NULL DEFAULT 'CNY',
    transfer_type   TINYINT         NOT NULL COMMENT '1=普通转账 2=退款 3=手续费 4=充值 5=提现',
    status          TINYINT         NOT NULL DEFAULT 0 COMMENT '0=处理中 1=成功 2=失败',
    biz_no          VARCHAR(64)     COMMENT '关联业务单号',
    remark          VARCHAR(200),
    created_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    finished_at     DATETIME,
    PRIMARY KEY (id),
    UNIQUE KEY uk_transfer_no (transfer_no),
    INDEX idx_from_account (from_account, created_at),
    INDEX idx_to_account   (to_account, created_at),
    INDEX idx_biz_no       (biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 转账存储过程(原子执行)
DELIMITER $$
CREATE PROCEDURE sp_transfer(
    IN p_transfer_no  VARCHAR(32),
    IN p_from_account VARCHAR(32),
    IN p_to_account   VARCHAR(32),
    IN p_amount       DECIMAL(19,4)
)
BEGIN
    DECLARE v_from_balance DECIMAL(19,4);
    DECLARE v_from_version BIGINT;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Transfer failed';
    END;

    START TRANSACTION;

    -- 1. 幂等检查
    IF EXISTS (SELECT 1 FROM transfer_order WHERE transfer_no = p_transfer_no) THEN
        COMMIT;
        LEAVE sp_transfer;
    END IF;

    -- 2. 锁定付款方账户(按 account_no 字母序加锁,避免死锁)
    SELECT balance, version INTO v_from_balance, v_from_version
    FROM account WHERE account_no = p_from_account FOR UPDATE;

    -- 3. 余额检查
    IF v_from_balance < p_amount THEN
        SIGNAL SQLSTATE '45001' SET MESSAGE_TEXT = 'Insufficient balance';
    END IF;

    -- 4. 锁定收款方账户
    SELECT balance FROM account WHERE account_no = p_to_account FOR UPDATE;

    -- 5. 扣款
    UPDATE account
    SET balance       = balance - p_amount,
        total_expense = total_expense + p_amount,
        version       = version + 1
    WHERE account_no = p_from_account AND version = v_from_version;

    -- 6. 入账
    UPDATE account
    SET balance      = balance + p_amount,
        total_income = total_income + p_amount,
        version      = version + 1
    WHERE account_no = p_to_account;

    -- 7. 写转账单
    INSERT INTO transfer_order (transfer_no, from_account, to_account,
                                 amount, status, finished_at)
    VALUES (p_transfer_no, p_from_account, p_to_account,
            p_amount, 1, NOW());

    COMMIT;
END$$
DELIMITER ;

流水账本设计

CREATE TABLE account_ledger (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    account_no      VARCHAR(32)     NOT NULL COMMENT '账户号',
    direction       TINYINT         NOT NULL COMMENT '1=收入(+) 2=支出(-)',
    amount          DECIMAL(19,4)   NOT NULL COMMENT '变动金额(绝对值)',
    balance_before  DECIMAL(19,4)   NOT NULL COMMENT '变动前余额',
    balance_after   DECIMAL(19,4)   NOT NULL COMMENT '变动后余额',
    biz_type        TINYINT         NOT NULL COMMENT '1=转账 2=充值 3=提现 4=退款 5=手续费',
    biz_no          VARCHAR(64)     NOT NULL COMMENT '关联业务单号',
    remark          VARCHAR(200),
    created_at      DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3), -- 毫秒精度
    PRIMARY KEY (id),
    INDEX idx_account_time (account_no, created_at),
    INDEX idx_biz_no       (biz_no),
    INDEX idx_date         (DATE(created_at))  -- 函数索引,支持按日期聚合
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
    PARTITION p202401 VALUES LESS THAN (202402),
    PARTITION p202402 VALUES LESS THAN (202403),
    -- ... 每月一个分区
    PARTITION pmax    VALUES LESS THAN MAXVALUE
) COMMENT='资金流水(不可修改,不可删除)';

-- 流水对账查询
SELECT
    DATE(created_at)                     AS date,
    COUNT(*)                             AS tx_count,
    SUM(CASE WHEN direction=1 THEN amount ELSE 0 END) AS total_in,
    SUM(CASE WHEN direction=2 THEN amount ELSE 0 END) AS total_out,
    SUM(CASE WHEN direction=1 THEN amount ELSE -amount END) AS net_change
FROM account_ledger
WHERE account_no = ?
  AND created_at BETWEEN ? AND ?
GROUP BY DATE(created_at)
ORDER BY date;

幂等设计

金融接口必须幂等:网络超时后客户端重试不能导致重复扣款。

-- 幂等表:记录已处理的请求
CREATE TABLE idempotency_record (
    idempotency_key VARCHAR(64) NOT NULL COMMENT '客户端传入的幂等键',
    biz_type        VARCHAR(32) NOT NULL COMMENT '业务类型',
    result          JSON        COMMENT '处理结果',
    status          TINYINT     NOT NULL DEFAULT 1 COMMENT '1=成功 2=失败',
    created_at      DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expire_at       DATETIME    NOT NULL COMMENT '幂等记录过期时间',
    PRIMARY KEY (idempotency_key, biz_type),
    INDEX idx_expire (expire_at)  -- 定期清理过期记录
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 处理逻辑(伪代码)
BEGIN;
  -- 先查幂等表(加锁防并发)
  SELECT result FROM idempotency_record
  WHERE idempotency_key = ? AND biz_type = ? FOR UPDATE;

  -- 若已存在,直接返回历史结果(不重复处理)
  IF FOUND THEN RETURN historical_result; END IF;

  -- 执行业务逻辑...
  CALL sp_transfer(...);

  -- 写入幂等记录
  INSERT INTO idempotency_record (...) VALUES (...);
COMMIT;

审计与合规

-- 审计日志(只写不改,存储所有数据库变更操作)
CREATE TABLE audit_log (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    operator    VARCHAR(64)     NOT NULL COMMENT '操作人(系统账号或员工账号)',
    action      VARCHAR(50)     NOT NULL COMMENT 'DEBIT/CREDIT/FREEZE/UNFREEZE',
    table_name  VARCHAR(64)     NOT NULL,
    record_id   VARCHAR(64)     NOT NULL COMMENT '操作记录 ID',
    before_data JSON            COMMENT '操作前数据快照',
    after_data  JSON            COMMENT '操作后数据快照',
    ip          VARCHAR(45),
    request_id  VARCHAR(64)     COMMENT '链路追踪 ID',
    created_at  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id),
    INDEX idx_operator_time (operator, created_at),
    INDEX idx_record        (table_name, record_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 触发器自动记录账户变更审计(或应用层记录)
DELIMITER $$
CREATE TRIGGER trg_account_audit
AFTER UPDATE ON account
FOR EACH ROW
BEGIN
    INSERT INTO audit_log (operator, action, table_name, record_id, before_data, after_data)
    VALUES (
        USER(),
        CASE WHEN NEW.balance > OLD.balance THEN 'CREDIT' ELSE 'DEBIT' END,
        'account',
        OLD.account_no,
        JSON_OBJECT('balance', OLD.balance, 'frozen', OLD.frozen_amount),
        JSON_OBJECT('balance', NEW.balance, 'frozen', NEW.frozen_amount)
    );
END$$
DELIMITER ;

对账设计

-- 每日对账快照
CREATE TABLE daily_balance_snapshot (
    snapshot_date DATE           NOT NULL,
    account_no    VARCHAR(32)    NOT NULL,
    balance       DECIMAL(19,4)  NOT NULL,
    frozen_amount DECIMAL(19,4)  NOT NULL,
    created_at    DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (snapshot_date, account_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 对账 SQL: 验证当日快照 = 前日快照 + 当日流水净变动
SELECT
    s.account_no,
    s.balance                                        AS snapshot_balance,
    prev.balance + IFNULL(l.net_change, 0)           AS calculated_balance,
    s.balance - (prev.balance + IFNULL(l.net_change, 0)) AS diff
FROM daily_balance_snapshot s
JOIN daily_balance_snapshot prev
     ON prev.account_no = s.account_no
    AND prev.snapshot_date = DATE_SUB(s.snapshot_date, INTERVAL 1 DAY)
LEFT JOIN (
    SELECT account_no,
           SUM(CASE WHEN direction=1 THEN amount ELSE -amount END) AS net_change
    FROM account_ledger
    WHERE DATE(created_at) = ?
    GROUP BY account_no
) l ON l.account_no = s.account_no
WHERE s.snapshot_date = ?
HAVING ABS(diff) > 0.0001;  -- 差异超过 0.0001 元则告警

高可用与灾备策略

策略 配置 目的
同步复制 semi-sync replication(至少一个从库确认) 主库宕机零数据丢失
双写确认 sync_binlog=1 + innodb_flush_log_at_trx_commit=1 事务级持久化
多活架构 两地三中心(同城双活+异地灾备) 机房级故障不中断
备份策略 每日全量 XtraBackup + 连续 Binlog 备份 任意时间点恢复
RTO/RPO RTO < 30s,RPO = 0(同步复制) 金融级 SLA

禁止关闭双1配置:部分 DBA 为提升写性能会设置 innodb_flush_log_at_trx_commit=2(每秒刷盘),这在金融系统是严禁的。MySQL 宕机或服务器断电时,最多丢失 1 秒数据。金融场景必须保持 sync_binlog=1 + innodb_flush_log_at_trx_commit=1(双1配置),哪怕写性能损失 30%。

银行级要求:国内金融监管(人民银行、银保监会)要求金融机构数据库满足:生产数据实时备份(RPO=0)、灾难恢复时间(RTO≤2h)、数据保存7年。MySQL 结合 semi-sync + 异地备份可以满足大部分要求。

本章评分
4.5  / 5  (3 评分)

💬 留言讨论