在电商下单、支付回调等业务场景中,重复请求会导致同一笔订单被多次创建、同一笔支付被重复入账,因此业务幂等性是系统设计中必须考虑的问题。SQL层面的锁机制可以从数据库底层保障操作的唯一性,其中唯一防重表结合排他锁插入的方案实现简单、可靠性高,是常用的幂等实现方式。

核心原理
唯一防重表结合排他锁插入的方案核心逻辑分为三步:首先创建一张专门存储业务唯一标识的防重表,给唯一标识字段添加唯一约束;当请求到来时,先尝试获取对应唯一标识的排他锁;如果获取锁成功,再执行插入操作,若插入失败说明已经存在记录,直接返回已有结果即可。
这种方式利用了数据库唯一约束的天然防重能力,同时结合排他锁避免并发场景下多个请求同时通过唯一约束校验,从根源上杜绝重复数据写入。
具体实现步骤
1. 创建唯一防重表
防重表只需要存储业务的唯一标识和相关的时间戳即可,不需要存储完整的业务数据,结构越简单性能越好。以下是MySQL环境下的建表语句:
-- 创建支付回调防重表,request_id为支付回调的唯一请求标识 CREATE TABLE `pay_callback_idempotent` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `request_id` varchar(64) NOT NULL COMMENT '回调唯一请求ID', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_request_id` (`request_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调幂等防重表';
2. 排他锁插入实现幂等逻辑
在业务代码中,需要先开启事务,然后通过SELECT ... FOR UPDATE语句对唯一标识加排他锁,再执行插入操作。完整的SQL逻辑如下:
-- 开启事务
START TRANSACTION;
-- 对request_id加排他锁,若记录不存在则锁的是间隙锁,防止其他事务插入相同request_id
SELECT `request_id` FROM `pay_callback_idempotent` WHERE `request_id` = 'req_123456' FOR UPDATE;
-- 尝试插入防重记录,若唯一约束冲突会直接报错
INSERT INTO `pay_callback_idempotent` (`request_id`) VALUES ('req_123456');
-- 若插入成功,执行后续业务逻辑,比如更新订单状态、记录支付流水
-- 后续业务SQL省略
-- 提交事务
COMMIT;
如果并发场景下两个请求同时携带相同的request_id进入,第一个请求执行SELECT ... FOR UPDATE后会持有该记录(或对应间隙)的排他锁,第二个请求执行相同查询时会阻塞等待,直到第一个请求提交事务释放锁。此时第二个请求再执行插入时,会因为唯一约束冲突抛出错误,业务层捕获该错误后直接返回第一次请求的处理结果即可,无需重复执行业务逻辑。
注意事项
- 防重表的存储引擎必须使用InnoDB,因为MyISAM不支持行级锁和事务,无法实现该方案。
SELECT ... FOR UPDATE必须走索引,否则会升级为表级锁,严重影响数据库性能,本方案中request_id有唯一索引,符合该要求。- 事务要尽量小,插入防重记录和后续业务操作完成后尽快提交,避免长事务占用锁资源。
- 如果业务需要防重记录有有效期,可以定期清理过期的防重记录,避免表数据无限膨胀。
适用场景
该方案适合所有需要防重的写操作场景,尤其是回调类、消息消费类这类重复请求概率高的场景,不需要引入额外的中间件,仅通过数据库原生能力即可实现,成本较低。如果是超高并发场景,可以结合Redis防重做前置校验,减少数据库锁的竞争压力。