幂等性与 Upsert:防止重复操作产生副作用的技术

幂等性与 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 判断再生成)。

六、代价与注意事项

  1. 额外存储/校验开销:幂等令牌要存"已处理记录",占存储、增一次查询。可设置 TTL 过期清理。
  2. 令牌的时效与清理:Redis 令牌设多久过期要权衡------太短可能漏判重复,太长占内存。一般覆盖业务重试窗口即可。
  3. 并发竞态 :"先查后插"在高并发下仍可能两个线程都查到"不存在"然后都插入。必须用数据库唯一约束或**原子操作(SETNX)**兜底,不能只靠应用层判断。
  4. Upsert 必须赋绝对值:这是最常见的坑,累加写法会破坏幂等。
  5. 幂等 ≠ 并发安全:幂等解决"重复执行",乐观锁/分布式锁解决"并发冲突",两者常需配合。

七、相关概念关系图

复制代码
          重复执行来源
   (MQ重投 / 超时重试 / 重复提交 / 补偿)
                │
                ▼
          幂等性 (Idempotency)
          "执行多次 = 执行一次"
                │ 实现手段
    ┌───────────┼───────────┬───────────┐
    ▼           ▼           ▼           ▼
 Upsert    唯一约束兜底   幂等令牌    状态机/乐观锁
(赋绝对值)  (DB拒绝重复)  (记录已处理) (条件更新)
    │           │           │           │
    └───────────┴─────┬─────┴───────────┘
                      ▼
            兜底:数据库唯一约束 + 原子操作
            (防止"先查后写"的并发竞态)

八、什么时候必须做幂等

必须做:

  • 一切 MQ 消费端(天然可能重复投递)。
  • 涉及金额、库存、积分等"累加/扣减"的写操作。
  • 对外暴露的、可能被客户端重试的接口(尤其支付、下单)。
  • 定时补偿、重试逻辑涉及的操作。

可以不做:

  • 纯查询操作(天然幂等)。
  • 赋绝对值的简单更新(天然幂等,但仍要注意并发)。

九、一句话总结

幂等性的本质是让"重复执行不产生额外副作用",核心规律是把"相对增量/无条件插入"改造成"赋绝对值/按唯一键合并/带前置条件" 。常用五种手段------Upsert、唯一约束、幂等令牌、状态机条件更新、乐观锁------按"更新/插入/状态流转/通用去重/并发争抢"场景选用,并始终以数据库唯一约束或原子操作 作为防并发竞态的最后一道防线。其中 Upsert(ON DUPLICATE KEY UPDATE 赋绝对值)是实现幂等写入最简单常用的方式。

相关推荐
牛奔1 小时前
mise 管理多版本 Go 项目
开发语言·前端·chrome·后端·golang
波力海苔夹心脆6751 小时前
C# 视觉检测实战:PLC 按钮触发 VisionPro 检测,OK 亮灯 / NG 灭灯(西门子 PLC,含界面显示与图片存档)
开发语言·经验分享·c#·自动化·视觉检测·.net
2601_962885721 小时前
如何用 Python 计算 BBI 多空指标?
开发语言·python
老王爱玩车2 小时前
动态内存管理
c语言·开发语言·学习·面试
I'm Jie2 小时前
【开源】7 天,我用 Rust 复刻了 FinalShell!Rhost v1.0.0 发布
开发语言·rust·开源
qq_543447822 小时前
DNS解析怎么排查?故障处理与运维实用手册
运维·服务器·网络·php
kimnoic2 小时前
Python常用命令提示符使用方法详解
开发语言·python
Crazy________2 小时前
05Linux内存管理核心原理与运维实战
java·开发语言
Nebula_g2 小时前
JavaSE拓展:工具类Executors
java·开发语言·后端·spring·基础·javase