我见过太多人做支付系统,数据库表结构直接照搬电商那套——一张订单表、一张商品表、一张用户表,然后就开始写接口。结果做到通道切换、对账、退款这些环节,发现表结构根本撑不住,又得推倒重来。

支付系统的数据库,和电商系统的数据库,是两码事。电商订单表关心的是"谁买了什么、送到哪",支付订单表关心的是"这笔钱从哪来、往哪去、什么状态、能不能追回来"。这篇文章我不讲概念,直接给你三张核心表(订单、商户、通道)的完整字段清单,再加上两张最容易漏掉的辅助表(流水、对账),附上建表 SQL。你照着改,能少走很多弯路。

先把一个根本问题想清楚:订单表和业务表要分开

这是做支付数据库的第一个分水岭,也是大多数人第一步就踩的坑。

很多人觉得,我的商城已经有订单表了,支付就是给订单表加几个字段——加个 pay_status、加个 transaction_id 不就完了?

这么做的后果是:你的业务订单和支付订单耦合在一起。支付那边通道回调一改状态,你的业务订单也跟着动;你想要支持"一个业务订单拆成多笔支付""一笔支付对应多个业务订单"(比如合并付款),根本做不到。更麻烦的是,业务系统要迭代,支付系统也要迭代,两个系统的改动互相牵制,最后谁都不敢动。

正确的做法是:业务订单表归业务系统,支付系统单独建一张支付订单表,两者通过 out_trade_no(商户订单号)关联。业务系统下单时生成一个订单号,调用支付接口时把这个订单号传过去,支付系统用这个号作为自己订单表的主键。两边各自维护自己的状态,互不干扰。

这个设计还有一个隐藏的好处:将来你换支付平台、换通道,业务系统一行代码都不用改,只改支付系统内部的通道配置就行。关于通道切换的坑,我在这篇支付通道稳定性测试方法里详细复盘过一次并发超时的事故,你可以对照着看。

订单表:支付系统的心脏

支付订单表是整套系统里读写最频繁、也最容易出问题的一张表。字段设计要同时兼顾三个目标:能快速定位一笔订单、能完整记录一笔钱的流转、能支撑后续的对账和退款。

下面是完整的字段清单。我按"必须要有"和"很多人漏掉"的维度,把每个字段为什么这么设计都标出来了。

字段清单(支付订单表 pay_order)

字段名类型说明为什么这么设计
idBIGINT UNSIGNED自增主键内部用,不对外暴露,避免被猜到订单量
order_noVARCHAR(32)支付订单号,平台内部唯一独立于业务订单号,平台自己生成,UNIQUE 索引
out_trade_noVARCHAR(64)商户订单号,商户系统生成关联业务订单的桥梁,加普通索引,同一商户下唯一
merchant_idBIGINT商户 ID关联商户表,所有查询都要带上这个条件做过滤
channel_idBIGINT通道 ID记录这笔单走了哪条通道,对账和排查全靠它
pay_typeVARCHAR(16)支付方式:alipay/wxpay/qqpay和通道是两回事,一个通道可能支持多种支付方式
amountBIGINT支付金额,单位:分金额一律用整数分存储,绝不用 float/double,精度问题就出在这
feeBIGINT通道手续费,单位:分下单时按通道费率算好存下来,对账时直接比对
real_amountBIGINT实际到账金额(扣手续费后)amount 减 fee,结算时用,避免结算时再算一遍出错
statusTINYINT订单状态状态机单独设计,下面详细讲,绝不用字符串存状态
notify_urlVARCHAR(255)异步通知地址下单时商户传的,回调时要用,必须原样存
return_urlVARCHAR(255)同步跳转地址同上,用户付完跳回商户页面用
client_ipVARCHAR(45)下单用户 IP风控要用,IPv6 最长 45 位,别用 15 位
expire_timeDATETIME订单过期时间超时未支付的订单要自动关闭,定时任务扫这个字段
paid_timeDATETIME支付成功时间回调确认后写入,对账、退款、统计都要用
create_timeDATETIME创建时间默认 CURRENT_TIMESTAMP
update_timeDATETIME更新时间每次状态变更都要更新,排查问题时看时间线

这里有三个字段,是我见过最多人漏掉的,单独拿出来说:

第一是 fee 和 real_amount。很多人觉得手续费结算时再算就行,不用存。但通道费率是会变的,你下单时是 0.38%,三个月后通道调价到 0.45%,结算时你按哪个算?手续费必须在订单落库那一刻就锁定,存进订单表,这样无论后面费率怎么变,这笔单的成本是不动的。

第二是 client_ip。很多人做支付只关心钱,不关心是谁在付。但风控是支付系统的命根子,同一个 IP 短时间下了几十笔单,大概率是异常。你没有这个字段,风控就是瞎的。

第三是 expire_time。没有过期时间,订单就一直挂着"待支付"状态,数据库里堆一堆僵尸订单。定时任务扫 status=待支付 and expire_time < now,批量改成"已关闭"。

订单状态机:别用字符串存状态

订单状态是支付系统最容易出 bug 的地方。状态之间是有合法流转路径的,不是想怎么改就怎么改。

我推荐的支付订单状态机是这样的:

待支付(1) ──支付成功──> 已支付(2) ──退款中──> 部分退款(3)/全额退款(4)
   │
   ├──超时──> 已关闭(5)
   └──支付失败──> 支付失败(6)

两个原则:

第一,状态用 TINYINT 数字,不用字符串。字符串看起来直观,但索引效率低、容易拼错、扩展也麻烦。数字状态配一个常量类或枚举,代码里可读性照样有。

第二,状态流转要有校验,不能直接 UPDATE。比如"已支付"不能直接跳到"待支付","已关闭"不能再变成"已支付"。你在更新状态的地方加一层校验,非法流转直接拒绝。这个校验做在应用层还是数据库层都行,关键是必须有。订单状态乱跳,对账一定对不平。

关于订单状态异常带来的连环问题,我之前写过一篇易支付订单状态异常的复盘,一个配置疏忽导致订单卡在中间态,商户那边全乱了,你可以看看真实场景长什么样。

商户表:别把商户和账户混在一张表里

商户表是第二张核心表,也是被简化得最厉害的一张。很多人觉得商户表不就存个名字、密钥、费率吗,一张表搞定。真到了结算、多账户、多通道签约的时候,才发现信息堆在一起拆不开。

我的建议是:商户主体和商户账户拆成两张表。商户主体存"这家商户是谁"(资质、法人、联系方式),商户账户存"这家商户在平台上的钱和配置"(密钥、费率、结算账户、余额)。

商户主体表(merchant)

字段名类型说明
idBIGINT UNSIGNED自增主键
merchant_noVARCHAR(32)商户号,对外展示和对接用,唯一
nameVARCHAR(128)商户名称(公司全称)
short_nameVARCHAR(64)商户简称,展示给用户看
legal_personVARCHAR(64)法人姓名
id_card_noVARCHAR(32)法人身份证号(加密存储)
license_noVARCHAR(64)营业执照号
contact_nameVARCHAR(64)业务联系人
contact_phoneVARCHAR(20)联系电话
statusTINYINT状态:待审核/正常/冻结/注销
audit_timeDATETIME审核通过时间
create_timeDATETIME创建时间

商户账户表(merchant_account)

字段名类型说明为什么单独拆出来
idBIGINT UNSIGNED自增主键
merchant_idBIGINT关联商户主体一个主体可以有多个账户
api_keyVARCHAR(64)接口密钥(加密存储)密钥和主体分开,换密钥不影响主体信息
secret_keyVARCHAR(64)签名密钥(加密存储)同上,加密存,不能明文
rateDECIMAL(6,4)商户费率,如 0.0038费率是账户级配置,不是主体级
balanceBIGINT账户余额,单位:分余额变动必须走流水,不能直接改
frozen_amountBIGINT冻结金额,单位:分风控冻结用,和可用余额分开
settle_typeTINYINT结算方式:T+0/T+1/手动影响结算逻辑
settle_accountVARCHAR(255)结算账户信息(银行卡等,加密)结算时打款用
statusTINYINT账户状态:正常/冻结账户冻结不等于主体冻结,粒度更细

拆成两张表最大的好处是灵活性。一个商户主体,可能同时签约了微信、支付宝两条线,费率不一样、结算账户不一样,你用一个账户表就存不下,用 account_type 字段硬塞,又变成了一张大杂烩表。

另外提醒一句:密钥和身份证、银行卡这些敏感字段,必须加密存储,不能明文落库。数据库一旦泄露,明文密钥等于把商户的钱直接交给别人。加密用 AES,密钥放配置文件或专门的密钥管理服务里,别和数据库放一起。

通道表:聚合支付最核心、也最容易被低估的一张表

什么是聚合支付?本质就是一套系统对接了多家支付通道,给商户提供一个统一接口。所以通道表是聚合支付系统的命脉——通道管理不好,聚合就是一句空话。

通道表要解决三个问题:第一,每条通道的对接参数怎么存;第二,通道现在能不能用(状态);第三,同一笔支付走哪条通道(路由)。

字段清单(通道表 channel)

字段名类型说明为什么这么设计
idBIGINT UNSIGNED自增主键
channel_noVARCHAR(32)通道编号,唯一日志和对账里都用这个号定位
nameVARCHAR(64)通道名称
providerVARCHAR(32)通道提供方:wechat/alipay/第三方标识底层是哪家
pay_typesVARCHAR(128)支持的支付方式,逗号分隔如 alipay,wxpay,路由时判断
configTEXT通道配置,JSON 格式存 appid、商户号、密钥等,灵活扩展
rateDECIMAL(6,4)通道成本费率商户费率减去通道费率就是你的利润
weightINT路由权重权重越高分到的单越多,做流量分配
statusTINYINT通道状态:启用/停用/维护中停用的通道路由要跳过
daily_limitBIGINT单日交易限额,单位分超限自动切换其他通道
daily_amountBIGINT今日已交易金额,单位分每日清零,和 daily_limit 比对
last_error_timeDATETIME最近一次失败时间连续失败自动熔断降权
error_countINT连续失败次数达到阈值就临时停用,防止烂通道拖垮全局
create_timeDATETIME创建时间

这里最关键的一列是 config。通道的对接参数(appid、商户号、密钥、网关地址)每家都不一样,你不可能为每一家通道都建一张表,那样表会爆炸。用 TEXT 存 JSON,把变的部分都塞进 config,表结构保持稳定,加新通道只加一行数据、不改表结构。

然后是 weight、error_count、daily_limit 这三个字段,它们共同构成了通道的健康度和路由。一条通道连续失败几次,error_count 累加,达到阈值就自动降权甚至停用;每天交易额到 daily_limit 了,路由就跳过它。这套机制不做,一条通道挂了,你的所有单子都堵在它上面,用户付不出去,你却浑然不知。

通道和支付方式之间,是典型的多对多关系:一条通道可能同时支持支付宝和微信,一种支付方式又可能有多条通道可选。这个关系可以单独建一张 channel_pay_type 关联表,也可以像我上面那样用 pay_types 字段逗号分隔。数据量小的时候用逗号分隔够用,数据量大、要频繁按支付方式查通道,就拆关联表。

两张大多数人漏掉的表:流水表和对账表

订单、商户、通道三张表是基础,但只有这三张表,你的支付系统是"能跑但不敢对账"的状态。真正让系统可靠的,是下面这两张。

资金流水表(fund_flow)

账户余额的每一次变动,都必须有一条流水记录。这个原则叫"账实相符"——余额表上的数字,永远等于流水表的汇总,任何一笔对不上,就能定位到具体哪条流水有问题。

核心字段:

字段名类型说明
idBIGINT UNSIGNED自增主键
flow_noVARCHAR(32)流水号,全局唯一
merchant_idBIGINT商户 ID
order_noVARCHAR(32)关联订单号
typeTINYINT流水类型:入账/出账/退款/结算/冻结/解冻
amountBIGINT变动金额,单位分,正负表示方向
balance_afterBIGINT变动后余额,单位分
create_timeDATETIME发生时间

注意 balance_after 这个字段。很多人只记变动金额,不记变动后的余额。但你要对账、要审计,光有变动金额不够,还必须知道每一笔流水发生之后,余额变成了多少。这个字段让流水表可以按时间顺序"重放"出任意时刻的余额,是财务对账和争议处理的关键证据。

对账表(reconciliation)

对账表的用途是:把平台的订单记录,和通道方的账单文件(通常是通道提供的对账单)做比对,找出不一致的单子。

核心字段:

字段名类型说明
idBIGINT UNSIGNED自增主键
channel_idBIGINT通道 ID
bill_dateDATE对账日期
order_noVARCHAR(32)订单号
platform_amountBIGINT平台记录金额
channel_amountBIGINT通道账单金额
diff_typeTINYINT差异类型:一致/平台多/通道多/金额不符
handle_statusTINYINT处理状态:待处理/已处理
create_timeDATETIME对账时间

对账的核心逻辑就一句话:平台有通道没有、通道有平台没有、两边都有但金额对不上,这三种情况都要记下来。每天跑一次对账任务,把差异单写进这张表,人工或自动处理。没有这张表,你的账就是"看起来对,但没人知道到底对不对"。

三个能直接抄的建表语句

下面给你三张核心表的完整建表 SQL,用的 MySQL 8.0 语法,字段名和上面清单一一对应。你可以直接拿去改。

-- 支付订单表
CREATE TABLE pay_order (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_no VARCHAR(32) NOT NULL COMMENT '支付订单号',
  out_trade_no VARCHAR(64) NOT NULL COMMENT '商户订单号',
  merchant_id BIGINT NOT NULL COMMENT '商户ID',
  channel_id BIGINT NOT NULL DEFAULT 0 COMMENT '通道ID',
  pay_type VARCHAR(16) NOT NULL COMMENT '支付方式',
  amount BIGINT NOT NULL COMMENT '金额(分)',
  fee BIGINT NOT NULL DEFAULT 0 COMMENT '手续费(分)',
  real_amount BIGINT NOT NULL DEFAULT 0 COMMENT '到账金额(分)',
  status TINYINT NOT NULL DEFAULT 1 COMMENT '状态',
  notify_url VARCHAR(255) NOT NULL DEFAULT '' COMMENT '异步通知地址',
  return_url VARCHAR(255) NOT NULL DEFAULT '' COMMENT '同步跳转地址',
  client_ip VARCHAR(45) NOT NULL DEFAULT '' COMMENT '下单IP',
  expire_time DATETIME NOT NULL COMMENT '过期时间',
  paid_time DATETIME DEFAULT NULL COMMENT '支付成功时间',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_merchant_out (merchant_id, out_trade_no),
  KEY idx_status_expire (status, expire_time),
  KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';

-- 商户账户表(主体表略,账户表更关键)
CREATE TABLE merchant_account (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  merchant_id BIGINT NOT NULL COMMENT '商户主体ID',
  api_key VARCHAR(64) NOT NULL COMMENT '接口密钥(加密)',
  secret_key VARCHAR(64) NOT NULL COMMENT '签名密钥(加密)',
  rate DECIMAL(6,4) NOT NULL DEFAULT 0.0038 COMMENT '商户费率',
  balance BIGINT NOT NULL DEFAULT 0 COMMENT '余额(分)',
  frozen_amount BIGINT NOT NULL DEFAULT 0 COMMENT '冻结金额(分)',
  settle_type TINYINT NOT NULL DEFAULT 1 COMMENT '结算方式',
  settle_account VARCHAR(255) NOT NULL DEFAULT '' COMMENT '结算账户(加密)',
  status TINYINT NOT NULL DEFAULT 1 COMMENT '账户状态',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_merchant (merchant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商户账户表';

-- 通道表
CREATE TABLE channel (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  channel_no VARCHAR(32) NOT NULL COMMENT '通道编号',
  name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '通道名称',
  provider VARCHAR(32) NOT NULL DEFAULT '' COMMENT '提供方',
  pay_types VARCHAR(128) NOT NULL DEFAULT '' COMMENT '支持的支付方式',
  config TEXT COMMENT '通道配置(JSON)',
  rate DECIMAL(6,4) NOT NULL DEFAULT 0.0038 COMMENT '成本费率',
  weight INT NOT NULL DEFAULT 100 COMMENT '路由权重',
  status TINYINT NOT NULL DEFAULT 1 COMMENT '状态',
  daily_limit BIGINT NOT NULL DEFAULT 0 COMMENT '单日限额(分)',
  daily_amount BIGINT NOT NULL DEFAULT 0 COMMENT '今日已交易(分)',
  last_error_time DATETIME DEFAULT NULL COMMENT '最近失败时间',
  error_count INT NOT NULL DEFAULT 0 COMMENT '连续失败次数',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_channel_no (channel_no),
  KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付通道表';

最后说几个容易忽略的细节

表结构设计完,还有几个细节,做的时候不注意,上线后会慢慢变成坑。

第一,金额全部用 BIGINT 存分,展示时除以 100。这是支付系统的铁律。有人图省事用 DECIMAL(10,2),小数运算在 Java、PHP 里一样有精度问题,除非你全程用 BigDecimal 或字符串运算,否则迟早出那 0.03 元的账。用整数分,从根上杜绝这个问题。

第二,索引要跟着查询走,别乱建。支付订单表最常见的查询是"按商户号 + 商户订单号查单"和"按状态 + 过期时间扫超时单",所以上面的 SQL 里我建了 idx_merchant_out 和 idx_status_expire 两个联合索引。索引不是越多越好,建错了反而拖慢写入。

第三,订单表数据量大了一定要分表。支付订单是高频写入的表,单表几千万行之后,查询和写入都会明显变慢。常见的做法是按月分表,或者按商户 ID 取模分库分表。这个可以晚点做,但表结构一开始就要预留好,别让 order_no 和业务逻辑强耦合,否则将来拆表时哭都来不及。

第四,敏感字段加密、密钥和数据库分离、余额变动必走流水。这三条是支付系统的底线,任何一条做不到,系统都不算合格。支付系统的数据库,第一原则从来不是"能用",而是"出事能查、能追、能赔"。

把这套表结构搭起来,你再去对账、做退款、切通道,会发现之前那些"怎么都对不上""状态乱跳""通道挂了不知道"的问题,大半都是表结构没设计好埋下的。表结构是地基,地基打歪了,上面盖什么都晃。