第 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 + 异地备份可以满足大部分要求。