幂等性的6类核心解决方案

一、核心概念:什么是幂等性

接口幂等性指同一操作的一次或多次请求,结果完全一致,无副作用

  • 典型反例:支付时网络异常导致用户重复点击,引发多次扣款;
  • 核心目标:避免重复请求造成数据错乱(如多扣款、多创建订单)。

二、幂等性判定:哪些操作天然幂等/非幂等

1. 天然幂等操作(无需额外处理)
sql 复制代码
-- 1. 查询:多次执行结果一致,不改变数据状态
SELECT * FROM user WHERE id = 1;

-- 2. 基于条件更新(固定值):多次执行状态不变
UPDATE goods SET stock = 10 WHERE id = 2;

-- 3. 删除:多次执行结果相同(第一次删除后,后续无数据可删)
DELETE FROM order WHERE order_no = 'O20250101001';

-- 4. 唯一主键插入:重复插入会触发主键冲突,仅成功一次
INSERT INTO user (user_id, name) VALUES (1001, '张三'); -- user_id为主键
2. 非幂等操作(必须做幂等处理)
sql 复制代码
-- 1. 增量更新:每次执行结果变化
UPDATE goods SET stock = stock - 1 WHERE id = 2;

-- 2. 非唯一键插入:重复执行会新增多条数据
INSERT INTO log (content) VALUES ('操作日志'); -- 无唯一约束

三、需要保证幂等性的场景

  1. 用户交互:多次点击按钮、页面回退重新提交;
  2. 系统调用:微服务间通信(如Feign)因网络异常触发重试;
  3. 其他:定时任务重复执行、消息队列重复消费。

四、幂等性解决方案(含示例)

1. Token机制(通用方案)

核心逻辑 :请求前获取唯一Token,请求时携带Token,服务端验证Token有效性。
步骤+示例

  1. 前端获取Token:调用服务端 /get-token 接口,服务端生成Token存入Redis(过期时间5分钟);

  2. 前端发起业务请求:请求头携带 X-Idempotent-Token: xxx

  3. 服务端验证Token(原子操作,用Lua脚本避免并发问题):

    lua 复制代码
    -- Redis Lua脚本:判断Token存在则删除,返回1;不存在返回0
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1]) -- 第一次请求,删除Token继续业务
    else
        return 0 -- 重复请求,直接返回
    end
  4. 服务端处理:返回1则执行业务(如创建订单),返回0则直接返回"重复请求"。

注意:优先"先删除Token再执行业务",若业务失败,前端需重新获取Token重试。

2. 锁机制(更新/并发场景)
(1)数据库悲观锁(写多读少场景)

核心逻辑 :通过 for update 锁定数据,防止并发修改。
示例

java 复制代码
// 订单支付扣减库存(事务内执行)
@Transactional
public boolean payOrder(String orderNo) {
    // 锁定订单记录,避免并发支付(id需为主键/唯一索引)
    Order order = orderMapper.selectByIdForUpdate(orderNo);
    if (order == null || order.getStatus() == 1) {
        return false; // 订单不存在或已支付
    }
    // 扣减库存、更新订单状态等业务逻辑
    order.setStatus(1);
    orderMapper.updateById(order);
    return true;
}

SQL示例

sql 复制代码
SELECT * FROM `order` WHERE order_no = 'O20250101001' FOR UPDATE;
(2)数据库乐观锁(读多写少场景)

核心逻辑 :基于版本号(version)控制,仅当版本号匹配时才更新。
示例

  1. 数据库表添加 version 字段(默认值1);

  2. 实体类添加版本号属性:

    java 复制代码
    public class Goods {
        private Long id;
        private Integer stock;
        private Integer version; // 乐观锁版本号
        // getter/setter
    }
  3. 更新SQL(MyBatis-Plus自动生成):

    sql 复制代码
    -- 仅当version=1时更新,更新后version自动+1
    UPDATE goods SET stock = stock - 1, version = version + 1 
    WHERE id = 2 AND version = 1;
  4. 业务处理:若更新行数为0,说明是重复请求,直接返回失败。

(3)业务层分布式锁(跨服务/多机器场景)

核心逻辑 :用Redis/ZooKeeper实现分布式锁,锁定业务唯一标识(如订单号)。
示例(Redis分布式锁)

java 复制代码
public boolean processOrder(String orderNo) {
    String lockKey = "lock:order:" + orderNo;
    // 获取锁(过期时间30秒,避免死锁)
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
    if (Boolean.FALSE.equals(locked)) {
        return false; // 已被其他服务锁定,重复请求
    }
    try {
        // 校验订单是否已处理
        Order order = orderMapper.selectById(orderNo);
        if (order.getStatus() == 1) {
            return false;
        }
        // 执行业务逻辑
        updateOrderStatus(orderNo);
        return true;
    } finally {
        // 释放锁
        redisTemplate.delete(lockKey);
    }
}
3. 唯一约束(插入场景)
(1)数据库唯一约束

核心逻辑 :给业务唯一标识(如订单号、请求ID)添加唯一索引,避免重复插入。
示例

  • 订单表 order_no 字段添加唯一索引;
  • 插入订单时,若重复提交,数据库会抛出唯一约束异常,服务端捕获后返回"重复创建"。
(2)Redis Set防重

核心逻辑 :将业务唯一标识(如请求参数MD5)存入Redis Set,存在则为重复请求。
示例

java 复制代码
public boolean checkRepeat(String requestParam) {
    // 计算请求参数的MD5(作为唯一标识)
    String md5 = DigestUtils.md5DigestAsHex(requestParam.getBytes());
    String key = "repeat:check:" + md5;
    // 若已存在,返回true(重复);不存在则存入Set(过期时间10分钟)
    return !redisTemplate.opsForSet().add(key, md5, 10, TimeUnit.MINUTES);
}

// 业务调用
public Result submitForm(String formData) {
    if (checkRepeat(formData)) {
        return Result.error("重复提交");
    }
    // 执行业务逻辑
    return Result.success();
}
(3)防重表

核心逻辑 :创建专门的防重表,用业务唯一标识作为唯一索引,与业务操作同事务。
示例

  1. 防重表设计:

    sql 复制代码
    CREATE TABLE `idempotent_table` (
        `id` bigint AUTO_INCREMENT PRIMARY KEY,
        `unique_key` varchar(64) NOT NULL COMMENT '业务唯一标识(如订单号)',
        `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
        UNIQUE KEY `uk_unique_key` (`unique_key`)
    );
  2. 业务代码(同事务):

    java 复制代码
    @Transactional
    public boolean createOrder(OrderDTO orderDTO) {
        // 1. 插入防重表(唯一索引冲突则事务回滚)
        IdempotentTable table = new IdempotentTable();
        table.setUniqueKey(orderDTO.getOrderNo());
        try {
            idempotentMapper.insert(table);
        } catch (DuplicateKeyException e) {
            return false; // 重复请求
        }
        // 2. 执行业务逻辑(创建订单)
        Order order = new Order();
        order.setOrderNo(orderDTO.getOrderNo());
        orderMapper.insert(order);
        return true;
    }
4. 全局请求唯一ID

核心逻辑 :给每个请求分配唯一ID(如通过Nginx、网关生成),服务端记录已处理的ID。
示例

  1. Nginx配置生成唯一请求ID:

    nginx 复制代码
    proxy_set_header X-Request-Id $request_id; # 转发请求时添加唯一ID
  2. 服务端拦截器处理:

    java 复制代码
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response) {
        String requestId = request.getHeader("X-Request-Id");
        if (redisTemplate.hasKey("request:processed:" + requestId)) {
            response.getWriter().write("重复请求");
            return false;
        }
        // 标记为已处理(过期时间5分钟)
        redisTemplate.opsForValue().set("request:processed:" + requestId, "1", 5, TimeUnit.MINUTES);
        return true;
    }

五、方案选型建议

场景 推荐方案 核心优势
前后端交互(如表单提交) Token机制 通用性强,无侵入性
读多写少的更新场景 数据库乐观锁 性能高,无锁等待
写多读少的并发场景 数据库悲观锁/分布式锁 安全性高,避免并发冲突
插入场景(如创建订单) 数据库唯一约束/防重表 数据库层面保障,可靠性高
跨服务/网关层防重 全局请求唯一ID 无业务侵入,适合统一拦截
相关推荐
菠萝地亚狂想曲4 天前
使用C语言操作LUA栈
c语言·junit·lua
曲莫终6 天前
junit自定义ArgumentsSource以自定义ParameterizedTest参数加载方式
junit
Jomurphys7 天前
测试 - 单元测试(JUnit)
android·junit·单元测试
一过菜只因8 天前
使用Junit测试
服务器·数据库·junit
张较瘦_8 天前
Springboot3 | JUnit 5 使用详解
spring boot·junit
IMPYLH9 天前
Lua 的 warn 函数
java·开发语言·笔记·junit·lua
yeshihouhou10 天前
redis实现分布式锁
redis·分布式·junit
小雨下雨的雨11 天前
第5篇:Redis事务与Lua脚本
redis·junit·lua