Java案例如何实现接口幂等?

wen python案例 4

本文目录导读:

Java案例如何实现接口幂等?

  1. 核心思路
  2. 方案一:数据库层面(利用唯一索引 / 唯一约束)
  3. 方案二:使用 Redis 分布式锁 + Token 机制
  4. 方案三:乐观锁(版本号机制)
  5. 方案四:状态机前置校验
  6. 生产环境中的完整实现建议(最佳实践)
  7. 总结:如何选择?

这是一个非常经典且重要的后端问题。接口幂等是指:无论调用者调用同一个接口多少次(在相同条件下),最终产生的效果都是一样的,且不会因为重复调用而产生副作用(如重复扣款、重复下单)。

下面按照从简单到复杂,再到实际生产环境落地的逻辑,梳理 Java 中实现接口幂等的常见方案。


核心思路

幂等的核心是 “防重”:系统需要能识别出这是“同一个请求”还是“不同的请求”,识别手段一般是全局唯一标识 + 状态锁

数据库层面(利用唯一索引 / 唯一约束)

这是最常用、最可靠的方案之一,尤其适合关键业务(如订单创建、资金交易)。

  • 场景:创建支付单、创建订单。
  • 实现
    1. 客户端在发起请求时,生成一个全局唯一的 幂等键(如:UUID、订单号、业务流水号)。
    2. 在数据库表中为这个幂等键字段建立 唯一索引
    3. 业务处理时,直接 INSERT 数据。
      • 如果插入成功 -> 新请求,继续处理。
      • 如果插入报错“Duplicate entry”(重复键) -> 重复请求,直接返回上一次成功的结果。

Java 示例(伪代码):

@Service
public class PaymentService {
    @Autowired
    private PaymentMapper paymentMapper;
    public Result createPayment(String idempotentId, PaymentRequest request) {
        try {
            // 尝试插入,数据库中 idempotent_id 字段有 UNIQUE KEY
            paymentMapper.insert(new Payment(idempotentId, request));
        } catch (DuplicateKeyException e) {
            // 重复请求,查询已有结果并返回
            Payment existing = paymentMapper.selectByIdempotentId(idempotentId);
            return Result.success("请求已处理", existing);
        }
        // 正常处理后续业务逻辑
        return Result.success("处理成功");
    }
}

使用 Redis 分布式锁 + Token 机制

适合请求到达时,需要先进行“防重校验”再执行业务的场景。

  • 流程
    1. 客户端先向服务端请求一个 Token(存到 Redis,设置 TTL)。
    2. 客户端带着这个 Token 去执行业务请求。
    3. 服务端收到请求后,尝试从 Redis 中 删除 这个 Token(必须使用 Lua 脚本保证原子性)。
    4. 如果删除成功 -> 第一次请求,放行。
    5. 如果删除失败(Token 不存在或已被删除) -> 重复请求,拒绝。

Java 示例(使用 RedisTemplate + Lua):

@Service
public class OrderService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    private static final String LUA_SCRIPT = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "    return redis.call('del', KEYS[1]) " +
        "else " +
        "    return 0 " +
        "end";
    // 1. 生成 Token 接口(客户端调用)
    public String generateToken() {
        String token = UUID.randomUUID().toString();
        redisTemplate.opsForValue().set("token:" + token, "1", 30, TimeUnit.MINUTES);
        return token;
    }
    // 2. 执行业务接口(验证 Token)
    public Result createOrder(String token, OrderRequest request) {
        // 使用 Lua 脚本原子性删除
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
            Collections.singletonList("token:" + token),
            "1"
        );
        if (result == null || result == 0) {
            return Result.error("重复请求或 Token 无效");
        }
        // 这里是真正的业务逻辑
        return doCreateOrder(request);
    }
}

乐观锁(版本号机制)

适用于 更新 操作,特别是存在并发修改的场景(如库存更新、状态流转)。

  • 核心:给数据表增加一个 version 字段。
  • 原理:每次更新时,where 条件带上当前的 version,并设置 set version = version + 1
  • 如果影响行数 = 0 -> 说明数据已被别人修改,本次请求视为重复或冲突,返回失败。

Java 示例:

@Service
public class InventoryService {
    @Autowired
    private InventoryMapper inventoryMapper;
    public Result deductStock(Long productId, int quantity, int currentVersion) {
        // UPDATE inventory SET stock = stock - ?, version = version + 1 
        // WHERE product_id = ? AND version = ?
        int affectedRows = inventoryMapper.deductStock(productId, quantity, currentVersion);
        if (affectedRows == 0) {
            // version 不匹配,说明是重复请求或数据已变更
            return Result.error("更新失败,请重试");
        }
        return Result.success("扣减成功");
    }
}

状态机前置校验

最适合 状态流转 的业务(如:订单状态 待支付 -> 已支付 -> 已发货 -> 已完成)。

  • 原理:每个请求都包含期望的“当前状态”,数据库更新时,where 条件检查当前状态是否一致。
  • 示例
    UPDATE orders SET status = '已支付' WHERE order_id = ? AND status = '待支付';

    如果影响行数为 0,说明订单状态不是“待支付”,即为重复请求/非法请求。


生产环境中的完整实现建议(最佳实践)

在实际微服务项目中,建议将幂等逻辑“横切”出去,通过 注解 + AOP过滤器 实现,避免侵入每个业务的代码。

自定义注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    // 幂等键的来源:从请求参数哪个字段取值
    String key();
    // 过期时间
    int expireSeconds() default 60;
}

AOP 切面实现

@Aspect
@Component
public class IdempotentAspect {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 1. 提取幂等键(从参数或 Header 中)
        String idempotentKey = extractKey(joinPoint, idempotent.key());
        String redisKey = "idempotent:" + idempotentKey;
        // 2. 检查 Redis 中是否存在该 key
        // 使用 SET NX EX 命令,第一次成功,后续失败
        // 或者使用 Lua 脚本保证原子性
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, "1", idempotent.expireSeconds(), TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(success)) {
            // 重复请求,直接返回“请求正在处理”或上次结果
            return Result.error("请勿重复提交");
        }
        try {
            // 3. 执行业务逻辑
            return joinPoint.proceed();
        } finally {
            // 注意:这里不建议业务完成后立刻删除 redis key,
            // 因为幂等需要在 TTL 时间内一直生效,防止请求重试。
            // 如果业务有失败回滚需求,可以在这里处理。
        }
    }
}

使用方式

@RestController
public class OrderController {
    @PostMapping("/order")
    @Idempotent(key = "#request.orderId", expireSeconds = 30)
    public Result createOrder(@RequestBody OrderRequest request) {
        // 业务逻辑,不用担心重复提交
        return orderService.create(request);
    }
}

如何选择?

业务场景 推荐方案 理由
创建资源(下单、注册) 数据库唯一索引 + 业务幂等键 强一致,天然幂等
防止重复提交(表单、按钮) Redis Token 机制 + 注解 AOP 无侵入,性能好,防前端连点
更新资源(库存、余额) 乐观锁(version) 适合高并发,避免行锁竞争
状态流转(订单状态、审核流) 状态机前置校验(where 条件) 简单高效,符合业务逻辑

还有一个容易被忽略的点: 如果业务处理时间很长,或者需要调用远程服务,建议在业务处理完成之前不要删除 Redis 中的幂等标记,等 TTL 到期自动失效即可,这样即使客户端重试,也能保证在 TTL 时间内只执行一次。

抱歉,评论功能暂时关闭!