跑转卡码平台的人都有过这种经历:单量一上来,数据库先垮——订单表几百万行,一个 SELECT 卡三秒;卡密表被并发 UPDATE 锁死;月底对账发现流水对不上,翻半天日志才知道是回调重复写入。这些问题九成出在设计阶段:表结构没想清楚,后面所有代码都在给烂设计填坑

这篇文章以一套生产级转卡码系统为例,把订单表、卡密表、支付流水表、分润流水表四张核心表的 Schema 设计、状态机字段、索引与分表归档方案完整拆开,附可直接落地的 MySQL DDL 与 Python 访问代码。看完你就能照着搭出一套不超卖、不重复发货、对得上账的数据库底座。

一、先看全局:四张核心表与它们的关系

转卡码系统的业务闭环是:买家下单 → 支付 → 锁卡发货 → 代理分润。落到数据库就是四张表:

  • orders 订单表:记录谁在什么时间买了什么、付了多少钱、当前状态;
  • cards 卡密表:记录卡密本体、状态与归属订单;
  • payment_flows 支付流水表:记录每一笔支付请求与回调,是资金对账的依据;
  • commission_flows 分润流水表:记录每一笔佣金入账,可对账可追溯。

它们的关系一句话:订单是主线,卡密从属于订单,支付流水与分润流水都是围绕订单的账务记录

orders 1 ──── 0..1 cards            # 一个订单最多锁定一张卡密
orders 1 ──── 1..n payment_flows    # 一个订单可能有多笔流水(回调重试/部分退款)
orders 1 ──── 0..n commission_flows # 一笔订单给多个代理分润

二、订单表:状态机是灵魂,金额只用分

订单表是所有表的锚点,设计时把握三个原则:

1. 状态用字符串枚举,不用魔法数字。订单状态机:pending(待支付)→ paid(已支付)→ delivered(已发货)→ completed(已完成),以及逆向的 closed(超时关闭)、refunding(退款中)、refunded(已退款)。状态迁移必须单向可控,代码里用状态机表约束。

2. 金额一律用分(整数)存储。浮点数算钱是灾难:0.1 + 0.2 在二进制里是无限小数,四舍五入会差出"分"。用 BIGINT 存分,展示层再转元。

3. 订单号全局唯一且有序。推荐"时间戳+渠道+随机数"拼接,如 20260821103045001,既是友好的有序字符串,又方便日志排查与分表路由。

CREATE TABLE orders (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_no      VARCHAR(32)  NOT NULL COMMENT '业务订单号,全局唯一',
  product_id    INT UNSIGNED NOT NULL COMMENT '商品ID',
  product_name  VARCHAR(64)  NOT NULL COMMENT '商品名快照,防改名影响历史订单',
  amount        BIGINT       NOT NULL COMMENT '实付金额,单位:分',
  status        VARCHAR(16)  NOT NULL DEFAULT 'pending'
                COMMENT 'pending/paid/delivered/completed/closed/refunding/refunded',
  channel       VARCHAR(16)  NOT NULL COMMENT '支付渠道:alipay/wechat',
  buyer_uid     INT UNSIGNED NOT NULL COMMENT '买家ID',
  agent_uid     INT UNSIGNED NULL COMMENT '上级代理ID,用于分润',
  card_id       BIGINT UNSIGNED NULL COMMENT '锁定的卡密ID',
  version       INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  created_at    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  paid_at       DATETIME     NULL,
  closed_at     DATETIME     NULL,
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_status_created (status, created_at),
  KEY idx_buyer (buyer_uid, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

几个容易被忽略的点:product_name 做快照,商品改名不影响历史订单展示;version 配合乐观锁,防止并发改单互相覆盖;idx_status_created 支撑"超时订单扫描"这类高频查询。

三、卡密表:用条件 UPDATE 完成原子领取

卡密表是转卡码系统的"库存"。核心需求只有一条:同一张卡密只能被领取一次。这不能靠应用层判断,必须靠数据库的唯一约束 + 条件更新:

CREATE TABLE cards (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  card_no    VARCHAR(64)  NOT NULL COMMENT '卡密(加密存储时此处为密文)',
  product_id INT UNSIGNED NOT NULL,
  status     VARCHAR(16)  NOT NULL DEFAULT 'pending'
             COMMENT 'pending/locked/used/expired',
  order_id   BIGINT UNSIGNED NULL COMMENT '归属订单',
  batch_no   VARCHAR(32)  NOT NULL COMMENT '导入批次号,方便追溯',
  locked_at  DATETIME     NULL,
  used_at    DATETIME     NULL,
  UNIQUE KEY uk_card_no (card_no),
  KEY idx_product_status (product_id, status),
  KEY idx_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='卡密表';

发货时的原子领取,靠一条条件 UPDATE 完成:

# 只更新 status='pending' 的行;并发下 InnoDB 行锁保证只有一笔成功
UPDATE cards
SET status='locked', order_id=%s, locked_at=NOW()
WHERE id = (
    SELECT id FROM cards
    WHERE product_id=%s AND status='pending'
    ORDER BY id LIMIT 1
  )
  AND status='pending';

# 影响行数为 0 说明库存不足或已被并发领取,走补货/提示分支

会不会超卖?不会:uk_card_no 唯一约束保证卡密不重复,条件更新保证领取原子。两个请求同时执行时,数据库行锁让第二个请求更新 0 行,代码判断 rowcount == 0 即可。安全提示:卡密本体建议 AES-256-GCM 加密后入库、展示时再解密,防止拖库导致卡密资产被盗,具体方案之前文章写过,这里不展开。

四、支付流水表:每一笔回调都留痕

支付流水表是"资金账本"。支付宝/微信的回调可能重复推送,平台也可能主动查单,同一笔支付会产生多条记录。设计要点:交易号唯一 + 回调原文落库 + 幂等键去重

CREATE TABLE payment_flows (
  id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  flow_no     VARCHAR(40)  NOT NULL COMMENT '支付平台交易号(如支付宝 trade_no)',
  order_no    VARCHAR(32)  NOT NULL COMMENT '关联订单号',
  channel     VARCHAR(16)  NOT NULL COMMENT 'alipay/wechat',
  amount      BIGINT       NOT NULL COMMENT '支付金额(分)',
  status      VARCHAR(16)  NOT NULL COMMENT 'notify/reverse/refund',
  raw_body    TEXT         NOT NULL COMMENT '回调原始报文,对账排障用',
  notify_time DATETIME     NOT NULL,
  created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_flow (flow_no, status),   # 同交易号同类型只记一次
  KEY idx_order (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';

回调处理先插流水再改订单,用 INSERT ... ON DUPLICATE KEY UPDATE 天然幂等:

INSERT INTO payment_flows
  (flow_no, order_no, channel, amount, status, raw_body, notify_time)
VALUES (%s, %s, %s, %s, 'notify', %s, NOW())
ON DUPLICATE KEY UPDATE notify_time = NOW();
# 重复回调只更新时间,不产生新行,杜绝流水虚增

有了这张表,日终对账就变成"支付平台账单 vs 流水表"的比对,缺哪笔、多哪笔一目了然,不用再靠猜。

五、分润流水表:佣金也要有据可查

开了代理分销的平台,每一笔佣金都要像支付流水一样可追溯。分润流水表通过 order_no 与订单关联,入账必须幂等:

CREATE TABLE commission_flows (
  id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_no   VARCHAR(32)  NOT NULL,
  agent_uid  INT UNSIGNED NOT NULL COMMENT '获得佣金的代理',
  amount     BIGINT       NOT NULL COMMENT '佣金(分)',
  rate       DECIMAL(5,4) NOT NULL COMMENT '分润比例,如 0.1000',
  status     VARCHAR(16)  NOT NULL DEFAULT 'pending'
             COMMENT 'pending/frozen/settled/refunded',
  created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_order_agent (order_no, agent_uid), # 同一订单同一代理只分一次
  KEY idx_agent (agent_uid, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分润流水表';

uk_order_agent 是分润幂等的最后一道保险:并发计算分润时,重复插入直接报唯一键冲突,业务层捕获后跳过即可,绝不会给同一个代理分两次。

六、索引与性能:先想清楚查询,再建索引

索引不是越多越好——每个索引都拖慢写入。转卡码系统里高频查询就三类,按需建:

  • 按订单号查(回调、查单、对账):uk_order_no 唯一索引,必须;
  • 按状态+时间扫(超时关单、待发货补偿):(status, created_at) 联合索引;
  • 按买家查(订单列表页):(buyer_uid, created_at) 联合索引。

建完用 EXPLAIN 验证是否走索引:

EXPLAIN SELECT id FROM orders
WHERE status='pending' AND created_at < '2026-08-21 00:00:00'
ORDER BY created_at LIMIT 100;
# type 应为 range 或 index,rows 应远小于全表行数;出现 ALL 说明索引没建对

七、分表与归档:单表过千万行之前动手

日单量 1 万笔的平台,一年订单 365 万行,三年就过千万。这个量级单表还能撑,但查询会明显变慢。两种成熟方案:

1. 按月分表(推荐起步)。订单和流水都带时间属性,按 created_at 的月份路由:

# 表名规则:orders_202608 / payment_flows_202608
def table_for(ts, base):
    return "%s_%s" % (base, ts.strftime("%Y%m"))

# 先算出目标表再拼 SQL;月份来自受控参数,白名单校验防注入
tbl = table_for(order_time, "orders")
db.query(f"SELECT * FROM {tbl} WHERE order_no=%s", order_no)

2. 冷热归档。把 6 个月前的已完结订单搬到归档库,业务表只留热数据。归档用定时任务小批量搬,避免长事务锁表:

INSERT INTO orders_archive SELECT * FROM orders
WHERE status IN ('completed','refunded')
  AND created_at < DATE_SUB(NOW(), INTERVAL 6 MONTH)
LIMIT 5000;  # 循环执行,搬完校验两边行数一致再删原表

注意:分表后跨月查询要靠 UNION 或汇总表,所以下单时间字段必须保留——它是所有路由的基础。

八、写在最后

数据库设计没有银弹,但转卡码系统只要守住四条底线就不会出大乱子:金额用分、状态机化、领取靠条件更新、流水全留痕。表结构稳了,上层的并发、幂等、对账才有地方落地。

如果你不想从零设计这些表结构,我们商城的转卡码系统 V3 内置了完整的订单、卡密、支付流水、分润流水四表设计与按月分表方案,部署即可商用;写 DDL 时配合Codex Desktop 让 AI 帮你审查索引与约束,几分钟就能得到一份优化建议。

浏览源码商城 →