唯一键比应用层判重更可靠,因其在存储引擎层原子校验,可硬性拦截重复写入;而select+insert存在竞态窗口。

为什么唯一键比应用层判重更可靠
支付通知重复是常见问题,上游(如微信/支付宝)可能因网络超时重发,而应用层用 SELECT + INSERT 判重存在竞态窗口:两个并发请求同时查不到记录,接着都插入成功。MySQL 唯一键约束在存储引擎层原子校验,只要索引字段组合能唯一标识一笔通知,就能硬性拦截重复写入。
- 唯一键必须覆盖「来源渠道 + 通知唯一标识」,例如微信的
transaction_id和out_trade_no组合、支付宝的notify_id或trade_no - 不要用时间戳、用户ID、金额等可能重复或不稳定的字段参与唯一约束
- 建议给该唯一索引起明确名,如
uk_channel_notify_id,便于后续排查
建表时如何设计唯一索引字段
支付通知表核心字段至少包含:id(自增主键)、channel(varchar,如 'wxpay'/'alipay')、notify_id(渠道返回的通知唯一标识)、content(原始通知报文)、status(如 'pending'/'success'/'failed')、created_at。
CREATE TABLE `payment_notify` ( `id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, `channel` VARCHAR(16) NOT NULL, `notify_id` VARCHAR(64) NOT NULL, `content` TEXT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_channel_notify_id` (`channel`, `notify_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-
notify_id长度按渠道文档设足(微信 transaction_id 最长32位,但部分回调字段如req_info解密后含 ID,建议留 64) -
channel加入联合唯一键,避免不同渠道偶然撞 ID(虽然概率低,但约束应严谨) - 不要对
content建索引,它不参与去重逻辑,且可能超长
插入时怎么处理唯一键冲突
收到通知后直接 INSERT IGNORE 或 INSERT ... ON DUPLICATE KEY UPDATE,不要先 SELECT。
- 用
INSERT IGNORE:冲突时静默忽略,返回影响行数为 0,适合只需幂等写入的场景 - 用
INSERT ... ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at = NOW():冲突时更新状态和时间,适合需跟踪通知处理进展的情况 - 避免用
REPLACE INTO:它本质是 DELETE + INSERT,在有外键或触发器时行为不可控,且自增 ID 会跳变
代码中需检查执行结果:
- MySQL 返回
errno 1062表示唯一键冲突(可通过异常捕获或mysql_affected_rows()判断) - 影响行数为 0 ≠ 失败,而是“已存在”,业务应继续走幂等处理流程(如查状态、重推消息)
唯一键失效的几个典型坑
- 字段值带空格或大小写:微信通知里的
transaction_id 全小写,但若数据库列定义为 utf8mb4_bin 校对规则,'ABC' 和 'abc' 视为不同;建议统一用 utf8mb4_unicode_ci 或显式 LOWER() 存储
- JSON 字段里抽 ID 不一致:有些渠道把真正唯一 ID 放在加密字段里,解密后提取时逻辑出错,导致存入的
notify_id 每次都不一样
- 表字符集不一致:建表时没指定
CHARSET,继承了库默认,而线上库是 latin1,导致中文或特殊字符截断,破坏唯一性
- 未清理测试数据:压测时大量用固定
notify_id 反复请求,把唯一键占满,正式流量进来就全冲突
transaction_id 全小写,但若数据库列定义为 utf8mb4_bin 校对规则,'ABC' 和 'abc' 视为不同;建议统一用 utf8mb4_unicode_ci 或显式 LOWER() 存储 notify_id 每次都不一样 CHARSET,继承了库默认,而线上库是 latin1,导致中文或特殊字符截断,破坏唯一性 notify_id 反复请求,把唯一键占满,正式流量进来就全冲突 唯一键不是银弹——它只保「写入层」不重复,但通知解析失败、状态更新遗漏、下游服务重复消费等问题仍需配合消息队列去重、本地缓存、或分布式锁来兜底。真正难的从来不是加个 UNIQUE KEY,而是确认你存进去的那个 notify_id 确实来自渠道且未被篡改。











