支付订单号设计与幂等表实现:从生成规则到唯一索引兜底
订单号看起来是最简单的一个字段,实际是支付系统的地基。选错生成方案,问题往往不在上线时暴露,而是在半年后集中出现:订单号撞了、换通道后长度超限被拒、自增 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 原创,文中涉及的产品能力、费率与准入要求以相关服务商官网文档及合同为准。