SQL幂等性设计是指针对相同的写入请求,无论执行多少次,最终对数据库产生的影响都和执行一次时完全一致,这是保障数据一致性的重要设计原则。在实际业务开发中,网络重试、用户重复提交、消息队列重复消费等情况都可能导致写入请求被重复执行,因此需要针对性设计写入幂等操作。

幂等性的核心判断标准
判断一个SQL写入操作是否满足幂等性,核心看两个条件:第一,相同参数的请求多次执行后,数据库的最终状态一致;第二,多次执行不会产生额外的副作用,比如重复插入记录、重复更新数值等。比如执行UPDATE user SET score = 100 WHERE id = 1是幂等操作,因为无论执行多少次,id为1的用户分数最终都是100;而执行UPDATE user SET score = score + 1 WHERE id = 1就不是幂等操作,每次执行分数都会增加1。
常见SQL写入幂等操作实践方法
1. 利用数据库唯一约束实现幂等
这是最常用的幂等实现方式,适用于插入场景。我们可以在表中创建唯一索引,当有重复数据写入时,数据库会直接抛出唯一约束冲突的错误,应用程序捕获该错误后就可以认为请求已经处理过,直接返回成功即可。
比如用户签到记录表,需要保证同一个用户同一天只能有一条签到记录,我们可以给user_id和sign_date两个字段创建联合唯一索引:
-- 创建签到表
CREATE TABLE user_sign (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
sign_date DATE NOT NULL COMMENT '签到日期',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 联合唯一索引,保证同一个用户同一天只能有一条签到记录
UNIQUE KEY uk_user_date (user_id, sign_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 写入签到记录的SQL,重复执行只会成功一次
INSERT IGNORE INTO user_sign (user_id, sign_date) VALUES (1001, '2024-05-20');
这里使用INSERT IGNORE语法,当插入重复数据时,数据库不会报错,只会返回0行受影响,应用程序可以根据返回结果判断是否已经签到过。
2. 基于前置状态校验实现幂等
对于更新类操作,尤其是状态流转类的更新,可以通过校验当前状态是否满足更新条件来实现幂等。比如订单状态从待支付流转为已支付,我们可以先查询当前订单状态,只有状态是待支付时才执行更新操作。
为了避免查询和更新的间隙出现并发问题,建议使用带条件判断的更新语句,把状态校验放到SQL语句中:
-- 订单状态流转:只有当前状态是1(待支付)时,才更新为2(已支付) UPDATE order_info SET status = 2, pay_time = NOW() WHERE order_id = 'ORDER_20240520001' AND status = 1;
执行该语句后,如果返回受影响行数为1,说明更新成功;如果返回0,说明订单已经是其他状态,不需要重复更新,直接返回成功即可。这种方式不需要额外的锁机制,性能较好,适合状态流转明确的场景。
3. 使用分布式锁实现幂等
对于没有唯一约束、也没有明确状态可以校验的复杂写入场景,比如累加类操作、多表关联写入场景,可以使用分布式锁来保证同一个请求不会被重复执行。核心思路是给每个写入请求生成一个唯一的请求ID,在执行写入前先尝试获取该ID对应的分布式锁,获取成功才执行写入,执行完成后释放锁。
以下是基于Redis实现分布式锁的Java代码示例:
public boolean idempotentWrite(String requestId, String userId, int amount) {
String lockKey = "lock:write:" + requestId;
try {
// 尝试获取分布式锁,过期时间30秒,避免死锁
Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lockSuccess)) {
// 获取锁成功,执行写入操作
String sql = "UPDATE user_account SET balance = balance + ? WHERE user_id = ?";
jdbcTemplate.update(sql, amount, userId);
return true;
} else {
// 获取锁失败,说明请求已经执行过,直接返回成功
return true;
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
注意这里的请求ID需要由调用方生成,保证同一个重复请求的ID完全一致,比如可以用业务唯一标识(如订单ID)加时间戳哈希生成,避免不同请求的锁冲突。
4. 基于幂等表记录实现幂等
我们可以单独维护一张幂等记录表,记录已经处理过的请求ID,每次写入前先查询该请求ID是否已经处理过,如果已经处理过则直接返回成功,否则执行写入并记录请求ID。
幂等表的结构如下:
CREATE TABLE idempotent_record (
id INT PRIMARY KEY AUTO_INCREMENT,
request_id VARCHAR(64) NOT NULL COMMENT '请求唯一ID',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_request_id (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
写入流程如下:
-- 第一步:插入幂等记录,重复插入会因为唯一约束失败
INSERT IGNORE INTO idempotent_record (request_id, business_type) VALUES ('REQ_20240520001', 'user_recharge');
-- 第二步:判断插入是否成功,受影响行数为1则执行后续写入操作
UPDATE user_account SET balance = balance + 100 WHERE user_id = 1001;
这种方式适合需要跨多个表写入、逻辑比较复杂的场景,把幂等判断和业务逻辑解耦,便于维护。
不同场景的方案选型建议
可以根据业务场景的特点选择合适的幂等方案:
- 单表插入场景,优先选择唯一约束方案,实现简单,性能最好,不需要额外组件支持。
- 状态流转类更新场景,优先选择前置状态校验方案,把校验逻辑放到SQL中,避免并发问题。
- 复杂多表写入、没有唯一标识的场景,选择幂等表或者分布式锁方案,分布式锁适合对性能要求高、请求量大的场景,幂等表适合需要持久化幂等记录、方便排查问题的场景。
需要注意的是,所有幂等方案都需要考虑异常情况的处理,比如写入过程中出现异常,需要保证幂等记录或者锁的状态能够正确回滚,避免出现数据不一致的情况。同时,请求ID的生成需要保证全局唯一,避免不同请求的ID冲突导致幂等判断错误。