Java分布式幂等案例如何落地

wen java案例 27

本文目录导读:

Java分布式幂等案例如何落地

  1. 核心思想:唯一标识 + 去重表/状态机
  2. 方案一:基于数据库唯一索引(最常用、最可靠)
  3. 方案二:基于 Redis 分布式锁 + Token(高并发、高性能)
  4. 方案三:基于状态机(防重复更新)
  5. 方案四:基于消息队列的幂等(去重表)
  6. 方案五:全局唯一ID + 幂等中间件(框架级别)
  7. 总结与选型建议

这是一个非常经典的分布式系统设计问题,所谓的幂等性,通俗来说就是同一个请求无论被调用多少次,对系统产生的影响(即最终状态)都是一次性的

在分布式环境中,由于网络超时、重试、消息重复消费等原因,幂等性是防止数据重复、资金损失、库存超卖的核心保障。

以下是 Java 分布式幂等案例落地的五个核心方案,从简单到复杂,包含具体实现代码和场景分析。

核心思想:唯一标识 + 去重表/状态机

所有幂等方案的核心都是:在第一次请求时生成或使用一个全局唯一的Key,并在后续请求中检查这个Key是否已经被处理过。


基于数据库唯一索引(最常用、最可靠)

适用场景: 核心交易、订单创建、资金流水等对数据一致性要求极高的场景。

原理: 利用数据库表的 UNIQUE KEY 约束,第一次插入成功,第二次插入会抛出 DuplicateKeyException(或类似异常),此时捕获异常并认为是重复请求。

案例:防止重复下单

建表(MySQL)

CREATE TABLE `t_order` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `order_no` varchar(64) NOT NULL COMMENT '业务幂等键,如订单号',
  `user_id` bigint NOT NULL,
  `amount` decimal(10,2) NOT NULL,
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付',
  `version` int NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`)  -- 关键:唯一索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Java Service 实现

@Service
@Transactional // 必须开启事务,保证插入和业务操作原子性
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;
    public Order createOrder(String orderNo, Long userId, BigDecimal amount) {
        // 1. 尝试插入幂等记录
        Order order = new Order();
        order.setOrderNo(orderNo);
        order.setUserId(userId);
        order.setAmount(amount);
        order.setStatus(0);
        try {
            // 关键步骤:如果order_no重复,这里会抛出 DuplicateKeyException
            orderMapper.insert(order);
        } catch (DuplicateKeyException e) { // 或 DataIntegrityViolationException
            // 2. 捕获唯一键冲突异常,表示重复请求
            log.warn("重复的订单请求,orderNo: {}", orderNo);
            // 查询已存在的订单并返回(幂等返回)
            return orderMapper.selectByOrderNo(orderNo);
        }
        // 3. 第一次插入成功,继续执行业务逻辑(如扣库存、发消息)
        // ...
        return order;
    }
}

优点: 强一致性,实现简单,业务与数据库交互天然支持。 缺点: 数据库写压力大;异常处理需要区分是业务异常还是幂等冲突(容易混)。


基于 Redis 分布式锁 + Token(高并发、高性能)

适用场景: 高并发、对响应速度要求高、可以容忍短暂的最终一致性(如秒杀抢购、优惠券发放)。

原理: 客户端在发起请求前,先向服务端请求一个 Token(唯一标识),服务端将 Token 存入 Redis 并设置过期时间,请求时携带 Token,服务端使用 Redis 的 setnx 命令(或 Lua 脚本)进行原子性检查并删除(或更新状态)。

案例:防止表单重复提交(前端+后端联动)

获取 Token 接口

@RestController
public class TokenController {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @GetMapping("/token")
    public String getToken(@RequestParam String userId) {
        String token = UUID.randomUUID().toString();
        String redisKey = "idempotent:token:" + userId;
        // 存入Redis,有效期30分钟
        redisTemplate.opsForValue().set(redisKey, token, 30, TimeUnit.MINUTES);
        return token;
    }
}

业务接口(核心逻辑)

@Service
public class PaymentService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    /**
     * 使用Lua脚本保证原子性:检查Token是否存在 -> 删除Token -> 执行业务
     */
    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";
    public boolean pay(String userId, String token, BigDecimal amount) {
        String redisKey = "idempotent:token:" + userId;
        // 1. 执行Lua脚本:如果token匹配且未被使用,则删除并返回1
        Long result = redisTemplate.execute(
            (RedisCallback<Long>) connection -> {
                byte[] keyBytes = redisKey.getBytes(StandardCharsets.UTF_8);
                byte[] tokenBytes = token.getBytes(StandardCharsets.UTF_8);
                return (Long) connection.eval(
                    LUA_SCRIPT.getBytes(),
                    ReturnType.INTEGER,
                    1,
                    keyBytes,
                    tokenBytes
                );
            }
        );
        // 2. 如果返回0,说明Token不存在、不匹配或已被消费(重复请求)
        if (result == null || result == 0) {
            log.warn("重复支付请求或Token无效,userId: {}, token: {}", userId, token);
            return false;
        }
        // 3. 执行业务逻辑(扣款、更新订单状态)
        // ...
        return true;
    }
}

优点: 性能极高(Redis内存操作),支持高并发。 缺点: 依赖 Redis 高可用;需要解决 Redis 主从切换时的 Token 丢失(短暂不幂等)。


基于状态机(防重复更新)

适用场景: 订单状态流转(待支付->已支付->已发货),系统内部重试或消息队列重复消费时,防止状态回退或重复处理。

原理: 在更新 SQL 中增加 where status = 上一个状态 的条件,利用数据库行锁或乐观锁实现。

案例:防止发货逻辑重复执行

数据库表设计

CREATE TABLE `t_order` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `order_no` varchar(64) NOT NULL,
  `status` tinyint NOT NULL COMMENT '0待支付 1已支付 2已发货 3已完成',
  `version` int NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB;

发货 Service

@Service
public class OrderDeliveryService {
    @Autowired
    private OrderMapper orderMapper;
    // 使用乐观锁版本号机制
    public boolean deliverOrder(String orderNo, int currentVersion) {
        // 关键SQL: update t_order set status = 2, version = version+1 
        //          where order_no = ? and status = 1 and version = currentVersion
        int affectedRows = orderMapper.updateStatusByOrderNoAndVersion(orderNo, 
                                                    StatusEnum.PAID.getCode(), // 旧状态
                                                    StatusEnum.DELIVERED.getCode(), // 新状态
                                                    currentVersion);
        // 影响行数为0,说明状态不对或已被修改(重复请求)
        if (affectedRows == 0) {
            log.warn("发货失败,订单状态异常或版本冲突: {}", orderNo);
            return false;
        }
        // 执行后续业务(发送物流通知等)
        return true;
    }
}

优点: 天然防止状态回退,适合有严格状态流转的业务。 缺点: 需要业务方理解状态机,代码与状态耦合。


基于消息队列的幂等(去重表)

适用场景: 消息队列(RocketMQ / Kafka)消费端的重复消息处理。

原理: 消费端维护一张去重表(可放 Redis 或 DB),消息体里带唯一ID(如 orderNo + eventType),消费前先查询去重表是否存在,若存在则跳过。

案例:支付回调消息消费

去重表设计(Redis 实现)

@Service
public class PaymentCallbackConsumer {
    @Autowired
    private StringRedisTemplate redisTemplate;
    // 消费消息
    public void consumePaymentSuccess(PaymentMessage msg) {
        String dedupKey = "dedup:payment:" + msg.getOrderNo() + ":" + msg.getEventType();
        // 1. 先查 Redis 是否存在(原子操作)
        Boolean absent = redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", 1, TimeUnit.DAYS);
        if (Boolean.FALSE.equals(absent)) {
            log.info("重复的支付回调消息,已跳过: {}", dedupKey);
            return;
        }
        // 2. 执行实际的业务逻辑(更新订单状态等)
        // 注意:如果业务执行失败,需要删除该Key,以便重试(或使用定时任务清理)
        try {
            doBusiness(msg);
        } catch (Exception e) {
            // 业务失败,删除去重key,允许重试
            redisTemplate.delete(dedupKey);
            throw e;
        }
    }
    private void doBusiness(PaymentMessage msg) {
        // 更新订单状态等
    }
}

优点: 轻量、性能高、易扩展。 缺点: Redis 主从切换时可能出现短暂重复(业务能接受即可)。


全局唯一ID + 幂等中间件(框架级别)

适用场景: 企业级、多个微服务共享的通用幂等组件。

原理: 封装一个公共组件,基于 注解 + AOP 实现,开发者只需在方法上加 @Idempotent,组件负责生成/校验幂等Key。

自定义注解实现

定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    // 幂等Key的表达式,支持SpEL
    String key();
    // 超时时间
    long expire() default 60;
}

AOP 切面(核心)

@Aspect
@Component
public class IdempotentAspect {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
        // 1. 通过SpEL解析幂等Key
        String key = parseKey(idempotent.key(), pjp);
        String dedupKey = "idempotent:" + key;
        // 2. 使用 setnx 尝试占位
        Boolean success = redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", idempotent.expire(), TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(success)) {
            // 如果已经被处理,直接返回上一次的结果(或抛异常提示)
            String resultStr = (String) redisTemplate.opsForValue().get(dedupKey + ":result");
            if (resultStr != null) {
                return JSON.parse(resultStr);
            }
            throw new RuntimeException("重复请求");
        }
        // 3. 执行业务
        Object result = pjp.proceed();
        // 4. 缓存结果(可选,用于后续直接返回)
        redisTemplate.opsForValue().set(dedupKey + ":result", JSON.toJSONString(result), idempotent.expire(), TimeUnit.SECONDS);
        return result;
    }
}

使用方式:

@Service
public class OrderService {
    @Idempotent(key = "#orderNo", expire = 60)
    public Order createOrder(String orderNo, Long userId) {
        // ...
    }
}

总结与选型建议

方案 可靠性 性能 实现复杂度 推荐场景
数据库唯一索引 ★★★★★ ★★★ ★★ 核心交易、资金对账
Redis + Token ★★★★ ★★★★★ ★★★ 高并发秒杀、表单防重复
状态机/乐观锁 ★★★★★ ★★★★ ★★★ 订单流转、物流更新
消息去重表 ★★★★ ★★★★ ★★ 消息队列消费端
注解+AOP组件 ★★★★ ★★★ ★★★★★ 企业级通用组件

落地建议:

  1. 核心交易:优先使用 数据库唯一索引 + 事务,这是底线。
  2. 高并发读多写少:使用 Redis 分布式锁,注意锁的原子性(Lua 脚本)和过期时间。
  3. 系统内部重试:使用 状态机(如 update ... where status=1),简单有效。
  4. 消息队列场景:使用 去重表,建议存储在 Redis(高性能)或专用去重库(强一致)。
  5. 不要依赖时间戳:时间戳在分布式环境下难以统一,务必使用唯一业务标识(如订单号)作为幂等Key。

幂等不一定是绝对的事后检查,也可以是事前的预防,设计业务表时,将关键业务字段(如订单号+用户ID)作为唯一索引,是成本最低、效果最好的方式。

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