支付订单号设计与幂等表实现:从生成规则到唯一索引兜底

订单号看起来是最简单的一个字段,实际是支付系统的地基。选错生成方案,问题往往不在上线时暴露,而是在半年后集中出现:订单号撞了、换通道后长度超限被拒、自增 ID 泄露了真实交易量、并发下同一笔支付被处理两次。

本文给的是可直接落地的实现,不是概念科普。国内通道的接入细节见 微信支付 APIv3 接入实操,回调侧见 多通道支付回调框架实现。

一、先看通道的硬约束

订单号不是你想怎么设计就怎么设计,通道侧有明确限制:

通道 长度约束 字符集 唯一性要求
微信支付 6–32 个字符 数字、大小写字母及下划线等有限符号 同一商户号下唯一
支付宝 64 个字符以内 数字与字母 同一应用下唯一
银联 按报文规范约定 字母数字 商户内唯一

结论很明确:取交集,用 32 位以内的纯字母数字。这样任何通道都能用同一套订单号,未来增加通道时不需要改造订单体系。具体长度与字符集以各通道官方文档当前版本为准。

另一个必须区分的是内部订单号与通道流水号:内部订单号由你生成、全局唯一、永不改变;通道流水号(支付宝的 trade_no、微信的 transaction_id)是通道返回的属性,只做记录。把两者混用,换通道时订单体系会崩。

二、五种生成策略对比

策略 优点 致命问题
数据库自增 ID 绝对唯一、简单 泄露业务量、跨库分表难、迁移困难
时间戳 + 随机数 实现简单 冲突概率不可控,高并发下会撞号
雪花算法(Snowflake) 趋势递增、无中心依赖 依赖机器时钟,时钟回拨会产生重复
UUID / ULID 无中心、易生成 36 位超长,含连字符不符合通道字符集
时间戳 + 分片位 + 序列 可控、无中心、长度固定 需要正确的分片与序列管理

推荐最后一种,原因很实际:它是唯一同时满足"长度可控、无中心依赖、趋势递增、字符集干净"的方案。

一个可用的结构是:业务前缀(2) + 年月日时分秒(14) + 分片号(2) + 序列号(6) + 校验位(2),共 26 位。业务前缀让订单可读,时间戳保证趋势递增便于索引,分片号支持多实例部署,序列号解决同秒并发,校验位用于快速识别录入错误。

序列号用 Redis 的 INCR 实现,按"分片号 + 秒"设 key 并加过期时间,同秒内自增,跨秒自动重置。这样既保证了唯一性,又不依赖数据库写入。

三、幂等表怎么建

订单号唯一只是第一步,真正防重复的是幂等表。核心思路:用数据库唯一索引做最终裁决,而不是用"先查后写"。

CREATE TABLE `payment_idempotency` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `idem_key` varchar(128) NOT NULL COMMENT '幂等键:业务类型:订单号:事件',
  `biz_type` varchar(32) NOT NULL COMMENT 'recharge/order/refund',
  `order_no` varchar(32) NOT NULL,
  `channel` varchar(16) NOT NULL,
  `channel_txn_id` varchar(64) DEFAULT NULL,
  `amount` bigint NOT NULL COMMENT '最小货币单位整数',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败',
  `result` json DEFAULT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_idem_key` (`idem_key`),
  KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

关键在于 uk_idem_key 这条唯一索引。处理逻辑变成:

try {
    DB::table('payment_idempotency')->insert([
        'idem_key'   => "{$bizType}:{$orderNo}:{$event}",
        'biz_type'   => $bizType,
        'order_no'   => $orderNo,
        'channel'    => $channel,
        'amount'     => $amount,
        'status'     => 0,
        'created_at' => now(),
        'updated_at' => now(),
    ]);
} catch (QueryException $e) {
    if (isDuplicateKey($e)) {
        // 已存在,说明这条事件正在或已被处理,直接返回成功
        return true;
    }
    throw $e;
}
// 首次插入成功,继续执行业务

这段代码的价值在于:把并发冲突交给数据库的唯一索引解决,而不是靠应用层的查询。两个线程同时到达时,只有一个能插入成功,另一个走重复分支。这比 if (!exists()) { insert(); } 在高并发下可靠得多。

amount 存 bigint 而不是 decimal,是为了避免浮点误差——所有金额统一用最小货币单位(分)的整数存储。

四、状态机与状态流转

支付状态必须只允许有限流转,禁止跳跃与回退:

final class PaymentState
{
    public const PENDING  = 'pending';
    public const PAID     = 'paid';
    public const CLOSED   = 'closed';
    public const REFUNDED = 'refunded';

    private const TRANSITIONS = [
        self::PENDING  => [self::PAID, self::CLOSED],
        self::PAID     => [self::REFUNDED],
        self::CLOSED   => [],
        self::REFUNDED => [],
    ];

    public static function canTransit(string $from, string $to): bool
    {
        return in_array($to, self::TRANSITIONS[$from] ?? [], true);
    }
}

配合带条件的更新语句才能真正确保正确性:

UPDATE orders
SET status = 'paid', paid_at = NOW()
WHERE order_no = ? AND status = 'pending';

如果受影响行数为 0,说明状态已被其他请求改变,此时应当查单确认而不是重试更新。这个 WHERE ... AND status = ? 的条件就是乐观锁,比 SELECT ... FOR UPDATE 更轻,也更不容易出现死锁。

五、六种会真实发生的重复支付

第一种:回调重复投递。 通道重试通知,你的接口没做幂等,同一笔订单发了两次货。上面的幂等表可以兜住。

第二种:回调与主动查单并发。 用户支付成功后你发起查单,同时回调到达,两条路径都判定成功并各自入账。解决办法是把两条路径都收敛到同一个幂等键上。

第三种:关单与支付竞态。 你发起关单的同时用户完成支付,关单失败但钱已到账。正确处理是关单失败后立即查单,以通道状态为准。

第四种:用户重复点击。 前端没做防抖或按钮禁用,用户连点两次创建了两个订单、付了两次。这是产品问题,但技术上可以用"同一用户 + 同一商品 + 时间窗口内"的唯一约束兜住。

第五种:多通道并行。 同一订单允许用户选支付宝或微信,两个页面同时打开,用户都付了。需要在订单维度加互斥,付款中状态不允许再次发起。

第六种:时钟回拨导致撞号。 用了雪花算法但没处理时钟回拨,序列重复。如果坚持用时间戳方案,必须加序列位而不是纯时间戳。

六、上线检查清单

订单号长度不超过 32 位、纯字母数字、含业务前缀与时间戳;内部订单号与通道流水号分开存储;幂等表有唯一索引且幂等键包含业务类型与事件;金额以整数最小单位存储;状态流转有白名单校验且更新语句带状态条件;回调、查单、关单三条路径共用同一套幂等逻辑;同一订单的多通道支付有互斥;对账能按订单号反查全部相关流水。

对账侧的字段归一化见 对账自动化脚本实现,对账流程本身见 对账与账单下载操作手册,通用链路见 支付交易流程基础。更多支付方案可参见 支付 分类。

来源:16IDC 原创,文中涉及的产品能力、费率与准入要求以相关服务商官网文档及合同为准。