幂等性与 Upsert:防止重复操作产生副作用的技术
一、问题的起点:为什么同一个操作会执行多次
在分布式系统里,"一个操作只执行一次"其实很难保证。同一条请求/消息被处理多次是常态,典型来源:
- 消息队列的重复投递:MQ 为保证"至少投递一次(at-least-once)",网络抖动或消费端 ACK 超时会导致同一条消息被重复消费。
- 接口超时重试:客户端调用超时后发起重试,但其实上一次服务端已经处理成功了。
- 用户重复提交:按钮连点、页面刷新重发表单。
- 分布式事务补偿:失败重试、定时任务补偿扫描,可能重复触发。
反面示例:非幂等操作的灾难
假设"支付成功后给用户账户加余额"这样写:
java
// 非幂等:每消费一次消息就累加一次
@RabbitListener(queues = "pay.success.queue")
public void onPaySuccess(PayMessage msg) {
accountMapper.addBalance(msg.getUserId(), msg.getAmount()); // UPDATE account SET balance = balance + amount
}
如果这条消息因为网络原因被重复投递 3 次,用户余额就被加了 3 次。这类"重复执行导致结果错误"的操作叫非幂等操作。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、核心概念
2.1 幂等性(Idempotency)
一个操作,执行一次和执行多次,产生的结果(对系统状态的影响)完全相同,就称它是幂等的。
一句话:重复做,不会造成额外的副作用。
数学上 f(f(x)) = f(x)。工程上就是:同一个请求发 1 次和发 100 次,系统最终状态一致。
2.2 天然幂等 vs 非天然幂等
| 操作 | 是否天然幂等 | 说明 |
|---|---|---|
查询 SELECT |
✅ 是 | 查多少次都不改状态 |
UPDATE x SET status = 'PAID' |
✅ 是 | 赋绝对值,重复执行结果一样 |
删除 DELETE WHERE id = 1 |
✅ 是 | 删一次和删多次,结果都是"没了" |
UPDATE x SET balance = balance + 10 |
❌ 否 | 累加,每执行一次就多加一次 |
INSERT 一条新记录 |
❌ 否 | 重复执行产生多条重复数据 |
关键规律 :赋绝对值 的操作天然幂等;相对增量 (+1、-1、append)和无条件插入不是幂等的,需要额外手段改造。
2.3 Upsert
Upsert = Update + Insert:存在则更新,不存在则插入。它是实现幂等写入的最常用手段之一------因为它把"插入"从"无条件新增"变成了"按唯一键合并",天然避免重复插入。
三、实现幂等的典型手段
3.1 手段一:Upsert(唯一键 + 合并写入)
核心是给数据定义一个业务唯一键,重复写入时按这个键合并,而不是新增。
MySQL:INSERT ... ON DUPLICATE KEY UPDATE
sql
-- 前提:user_id 是主键或唯一索引
INSERT INTO user_order_rank
(user_id, user_name, order_count, total_amount, update_time)
VALUES
(#{userId}, #{userName}, #{orderCount}, #{totalAmount}, NOW())
ON DUPLICATE KEY UPDATE -- 唯一键冲突时,改为更新
user_name = VALUES(user_name),
order_count = VALUES(order_count),
total_amount = VALUES(total_amount), -- 注意:赋绝对值,不是 +=
update_time = NOW();
重复消费同一条消息,第一次 INSERT,之后都走 UPDATE 覆盖成相同的值,最终结果不变。这就是"幂等 upsert"。
⚠️ 易错点:如果写成
order_count = order_count + VALUES(order_count)(累加),就又变成非幂等了。幂等 upsert 必须赋绝对值。
MySQL:INSERT IGNORE(只要不重复插入,重复的直接忽略)
sql
-- 唯一键冲突时直接忽略,不报错也不更新,适合"只需插入一次"的场景
INSERT IGNORE INTO idempotent_record (biz_id, create_time)
VALUES (#{bizId}, NOW());
PostgreSQL / 标准 SQL:INSERT ... ON CONFLICT
sql
INSERT INTO user_order_rank (user_id, total_amount, update_time)
VALUES (#{userId}, #{totalAmount}, NOW())
ON CONFLICT (user_id) -- 指定冲突的唯一键
DO UPDATE SET
total_amount = EXCLUDED.total_amount,
update_time = NOW();
JPA / Hibernate:saveOrUpdate 语义
java
// 先查后定,存在则改、不存在则建(注意并发下仍需唯一约束兜底)
UserRank rank = repository.findByUserId(userId)
.orElseGet(UserRank::new);
rank.setUserId(userId);
rank.setTotalAmount(totalAmount); // 赋绝对值
repository.save(rank);
3.2 手段二:唯一约束 + 去重表(防重复插入)
对于"必须插入新记录"的场景(如创建订单),用数据库唯一约束兜底:
sql
-- 给业务唯一键建唯一索引,重复插入会被数据库直接拒绝
ALTER TABLE orders ADD UNIQUE INDEX uk_request_id (request_id);
public void createOrder(OrderRequest req) {
try {
orderMapper.insert(buildOrder(req)); // request_id 唯一
} catch (DuplicateKeyException e) {
// 唯一键冲突 = 这个请求已处理过,直接当成功返回,不重复创建
log.warn("订单已存在,幂等拦截, requestId={}", req.getRequestId());
}
}
唯一约束是最后一道防线:即使应用层判断漏了,数据库也不会让重复数据落库。
3.3 手段三:幂等令牌(Token / 唯一请求号)
适合"用户重复提交""接口重试"场景。核心:每个请求带一个全局唯一 ID,服务端记录"处理过的 ID",重复的直接拒绝。
java
@Service
public class IdempotentService {
@Resource
private StringRedisTemplate redis;
/**
* 基于 Redis 的幂等校验:同一个 requestId 只放行一次。
* setIfAbsent = SETNX,原子操作,天然防并发。
*/
public boolean tryAcquire(String requestId) {
Boolean first = redis.opsForValue()
.setIfAbsent("idem:" + requestId, "1", Duration.ofHours(1));
return Boolean.TRUE.equals(first); // true=首次,放行;false=重复,拦截
}
}
@RabbitListener(queues = "pay.success.queue")
public void onPaySuccess(PayMessage msg) {
if (!idempotentService.tryAcquire(msg.getMessageId())) {
log.warn("消息已处理,幂等跳过, msgId={}", msg.getMessageId());
return; // 重复消息,直接丢弃
}
accountMapper.addBalance(msg.getUserId(), msg.getAmount()); // 保护后的累加操作
}
这里即使
addBalance本身不幂等,外层令牌保证了它只被执行一次,整体就幂等了。
3.4 手段四:状态机 / 乐观锁(防重复流转)
对"状态流转"类操作,用条件更新保证只流转一次:
sql
-- 只有当前是"未支付"才能改成"已支付";重复执行第二次影响 0 行
UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_id = #{orderId}
AND status = 'UNPAID'; -- 关键:带前置状态条件
int affected = orderMapper.payOrder(orderId);
if (affected == 0) {
// 影响 0 行 = 已经是 PAID 了,重复操作,直接当成功
log.warn("订单已支付,幂等拦截, orderId={}", orderId);
return;
}
乐观锁(版本号) 是同类思想,防并发覆盖:
sql
UPDATE account
SET balance = #{newBalance}, version = version + 1
WHERE id = #{id} AND version = #{oldVersion}; -- 版本不符则更新失败
四、各手段对比与选型
| 手段 | 适用场景 | 实现成本 | 特点 |
|---|---|---|---|
| Upsert | 结果表同步、预计算更新 | 低 | 简单,依赖唯一键,赋绝对值 |
| 唯一约束 + 捕获冲突 | 创建类操作(订单、注册) | 低 | 数据库兜底,最可靠 |
| 幂等令牌(Redis/DB) | 接口重试、消息去重、表单防重 | 中 | 通用,需额外存储记录处理痕迹 |
| 状态机条件更新 | 状态流转(支付、审批) | 低 | 利用业务前置状态,优雅 |
| 乐观锁(版本号) | 并发更新同一行 | 中 | 防并发覆盖,需重试机制 |
选型口诀:
- 更新结果 → Upsert(赋绝对值)
- 插入新数据 → 唯一约束兜底
- 状态变更 → 条件更新 / 状态机
- 通用去重 → 幂等令牌
- 并发争抢 → 乐观锁
五、真实业务场景举例
场景 1:MQ 消息重复消费(最经典)
支付回调、订单变更等消息,MQ 保证"至少一次"投递,必然可能重复。做法:消息带全局 messageId,消费前用 Redis 幂等令牌去重;或业务操作本身设计成 Upsert / 条件更新。
场景 2:支付回调被第三方多次通知
支付平台(微信/支付宝)会对同一笔支付多次回调。做法:用订单状态机 WHERE status = 'UNPAID' 条件更新,重复回调第二次影响 0 行,天然幂等。
场景 3:预计算结果表同步
后台异步重算排行榜/统计表,同一用户的变更消息可能重复。做法:结果表用 user_id 做唯一键,ON DUPLICATE KEY UPDATE 覆盖成绝对值,重复消费结果一致。
场景 4:接口防重复提交
用户连点"提交订单"。做法:进页面时后端发一个一次性 token,提交时携带,后端校验 token(用一次即失效),重复提交因 token 已失效被拒绝。
场景 5:定时任务补偿
每天跑批补偿失败数据,可能和实时流程重叠处理同一条。做法:补偿操作设计为 Upsert 或带状态条件,重复处理不产生重复结果(如月结快照先 existsByMonth 判断再生成)。
六、代价与注意事项
- 额外存储/校验开销:幂等令牌要存"已处理记录",占存储、增一次查询。可设置 TTL 过期清理。
- 令牌的时效与清理:Redis 令牌设多久过期要权衡------太短可能漏判重复,太长占内存。一般覆盖业务重试窗口即可。
- 并发竞态 :"先查后插"在高并发下仍可能两个线程都查到"不存在"然后都插入。必须用数据库唯一约束或**原子操作(SETNX)**兜底,不能只靠应用层判断。
- Upsert 必须赋绝对值:这是最常见的坑,累加写法会破坏幂等。
- 幂等 ≠ 并发安全:幂等解决"重复执行",乐观锁/分布式锁解决"并发冲突",两者常需配合。
七、相关概念关系图
重复执行来源
(MQ重投 / 超时重试 / 重复提交 / 补偿)
│
▼
幂等性 (Idempotency)
"执行多次 = 执行一次"
│ 实现手段
┌───────────┼───────────┬───────────┐
▼ ▼ ▼ ▼
Upsert 唯一约束兜底 幂等令牌 状态机/乐观锁
(赋绝对值) (DB拒绝重复) (记录已处理) (条件更新)
│ │ │ │
└───────────┴─────┬─────┴───────────┘
▼
兜底:数据库唯一约束 + 原子操作
(防止"先查后写"的并发竞态)
八、什么时候必须做幂等
必须做:
- 一切 MQ 消费端(天然可能重复投递)。
- 涉及金额、库存、积分等"累加/扣减"的写操作。
- 对外暴露的、可能被客户端重试的接口(尤其支付、下单)。
- 定时补偿、重试逻辑涉及的操作。
可以不做:
- 纯查询操作(天然幂等)。
- 赋绝对值的简单更新(天然幂等,但仍要注意并发)。
九、一句话总结
幂等性的本质是让"重复执行不产生额外副作用",核心规律是把"相对增量/无条件插入"改造成"赋绝对值/按唯一键合并/带前置条件" 。常用五种手段------Upsert、唯一约束、幂等令牌、状态机条件更新、乐观锁------按"更新/插入/状态流转/通用去重/并发争抢"场景选用,并始终以数据库唯一约束或原子操作 作为防并发竞态的最后一道防线。其中 Upsert(ON DUPLICATE KEY UPDATE 赋绝对值)是实现幂等写入最简单常用的方式。