跑转卡码平台的人都有过这种经历:单量一上来,数据库先垮——订单表几百万行,一个 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 帮你审查索引与约束,几分钟就能得到一份优化建议。