SpringBoot + 分布式 ID + 幂等令牌:跨服务调用防重复提交的终极方案

一、跨服务调用的重复提交噩梦

上周,一位朋友找我吐槽:他们公司的订单系统又出问题了。

用户在APP上下单,点击"提交"按钮后,因为网络延迟,页面没有立即响应,用户以为没提交成功,就又点击了一次。结果系统创建了两个相同的订单,用户收到了两条订单确认短信,商家也收到了两条订单通知。

更糟糕的是,这个问题不是第一次出现了。之前在支付、退款、发货等环节都发生过类似的重复操作问题。

"我们的系统是微服务架构,订单服务、支付服务、库存服务都是独立部署的,"朋友无奈地说,"跨服务调用的时候,很难保证操作不被重复执行。"

这样的场景,作为后端开发的你,是不是也遇到过?

二、为什么重复提交这么难解决?

在微服务架构下,重复提交问题变得更加复杂:

1. 网络不稳定

网络延迟、抖动、丢包等问题,可能导致客户端以为请求失败,从而重复发送请求。

2. 服务端处理延迟

服务端处理请求的时间过长,客户端超时后可能会重新发送请求。

3. 重试机制

为了提高系统的可靠性,很多系统都实现了自动重试机制,这也可能导致重复请求。

4. 跨服务调用

在微服务架构下,一个业务流程可能涉及多个服务的调用,任何一个环节的失败都可能导致整个流程的重试。

5. 数据一致性

重复提交可能导致数据不一致,比如重复创建订单、重复扣减库存、重复支付等。

三、传统方案的局限性

为了解决重复提交问题,我们通常会使用以下方案:

1. 前端防重复

  • 禁用按钮:点击后禁用提交按钮
  • 加载动画:显示加载动画,提示用户请求正在处理
  • 防抖节流:限制用户在一定时间内只能提交一次

这种方案只能防止用户的误操作,无法防止恶意请求或网络问题导致的重复提交。

2. 后端防重复

  • 数据库唯一索引:通过数据库唯一索引来防止重复数据
  • Redis 分布式锁:使用 Redis 分布式锁来保证操作的原子性
  • 请求参数签名:对请求参数进行签名,防止请求被篡改

这些方案都有一定的局限性:

  • 数据库唯一索引:需要在数据库层面做文章,对性能有影响
  • Redis 分布式锁:实现复杂,需要考虑锁的过期时间、释放机制等
  • 请求参数签名:只能防止请求被篡改,不能防止重复请求

四、终极方案:分布式 ID + 幂等令牌

今天,我要和大家分享一个在实战中验证过的解决方案:SpringBoot + 分布式 ID + 幂等令牌

这套方案的核心思想是:

  1. 分布式 ID:为每个操作生成唯一的标识符
  2. 幂等令牌:在操作执行前生成令牌,执行后验证令牌
  3. Redis 存储:使用 Redis 存储令牌和操作结果

五、方案详解

1. 分布式 ID 生成

分布式 ID 是整个方案的基础,它为每个操作生成唯一的标识符。我们可以使用雪花算法来生成分布式 ID。

(1)雪花算法实现

@Component
public class SnowflakeIdGenerator {
    
    // 起始时间戳
    private static final long START_TIMESTAMP = 1609459200000L; // 2021-01-01 00:00:00
    
    // 机器ID位数
    private static final long MACHINE_BIT = 5L;
    
    // 数据中心ID位数
    private static final long DATACENTER_BIT = 5L;
    
    // 序列号位数
    private static final long SEQUENCE_BIT = 12L;
    
    // 机器ID最大值
    private static final long MAX_MACHINE_NUM = ~(-1L << MACHINE_BIT);
    
    // 数据中心ID最大值
    private static final long MAX_DATACENTER_NUM = ~(-1L << DATACENTER_BIT);
    
    // 序列号最大值
    private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BIT);
    
    // 机器ID左移位数
    private static final long MACHINE_LEFT = SEQUENCE_BIT;
    
    // 数据中心ID左移位数
    private static final long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
    
    // 时间戳左移位数
    private static final long TIMESTAMP_LEFT = SEQUENCE_BIT + MACHINE_BIT + DATACENTER_BIT;
    
    // 数据中心ID
    private final long datacenterId;
    
    // 机器ID
    private final long machineId;
    
    // 序列号
    private long sequence = 0L;
    
    // 上次时间戳
    private long lastTimestamp = -1L;
    
    public SnowflakeIdGenerator() {
        this.datacenterId = getDatacenterId(MAX_DATACENTER_NUM);
        this.machineId = getMachineId(MAX_MACHINE_NUM, datacenterId);
    }
    
    /**
     * 获取数据中心ID
     */
    private long getDatacenterId(long maxDatacenterNum) {
        // 这里可以根据实际情况获取数据中心ID
        return 1L;
    }
    
    /**
     * 获取机器ID
     */
    private long getMachineId(long maxMachineNum, long datacenterId) {
        // 这里可以根据实际情况获取机器ID
        return 1L;
    }
    
    /**
     * 生成ID
     */
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        
        // 如果当前时间戳小于上次时间戳,说明系统时钟回拨
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock moved backwards. Refusing to generate id.");
        }
        
        // 如果当前时间戳等于上次时间戳,说明在同一毫秒内
        if (timestamp == lastTimestamp) {
            // 序列号自增
            sequence = (sequence + 1) & MAX_SEQUENCE;
            // 如果序列号超过最大值,说明需要等待下一毫秒
            if (sequence == 0L) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            // 如果当前时间戳大于上次时间戳,说明进入了下一毫秒
            sequence = 0L;
        }
        
        // 更新上次时间戳
        lastTimestamp = timestamp;
        
        // 生成ID
        return (
            // 时间戳部分
            (timestamp - START_TIMESTAMP) << TIMESTAMP_LEFT
            // 数据中心ID部分
            | datacenterId << DATACENTER_LEFT
            // 机器ID部分
            | machineId << MACHINE_LEFT
            // 序列号部分
            | sequence
        );
    }
    
    /**
     * 等待到下一毫秒
     */
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

2. 幂等令牌实现

幂等令牌是防止重复提交的关键,它在操作执行前生成,执行后验证。

(1)幂等令牌服务

@Service
public class IdempotentTokenService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private SnowflakeIdGenerator idGenerator;
    
    /**
     * 生成幂等令牌
     */
    public String generateToken() {
        // 生成唯一令牌
        String token = String.valueOf(idGenerator.nextId());
        
        // 存储令牌到Redis,设置过期时间为5分钟
        redisTemplate.opsForValue()
            .set(getTokenKey(token), "1", 5, TimeUnit.MINUTES);
        
        return token;
    }
    
    /**
     * 验证幂等令牌
     */
    public boolean validateToken(String token) {
        if (token == null || token.isEmpty()) {
            return false;
        }
        
        // 从Redis中删除令牌
        Long result = redisTemplate.delete(getTokenKey(token));
        
        // 如果删除成功,说明令牌有效
        return result != null && result > 0;
    }
    
    /**
     * 获取令牌在Redis中的键
     */
    private String getTokenKey(String token) {
        return "idempotent:token:" + token;
    }
}

(2)幂等令牌注解

为了方便使用,我们可以创建一个注解来标记需要防重复提交的方法:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    
    /**
     * 令牌参数名
     */
    String tokenParamName() default "token";
    
    /**
     * 重复提交时的提示信息
     */
    String message() default "操作正在处理中,请不要重复提交";
}

(3)幂等令牌拦截器

使用AOP来拦截标记了@Idempotent注解的方法,验证幂等令牌:

@Aspect
@Component
@Slf4j
public class IdempotentInterceptor {
    
    @Autowired
    private IdempotentTokenService tokenService;
    
    /**
     * 拦截标记了@Idempotent注解的方法
     */
    @Around("@annotation(com.example.demo.annotation.Idempotent)")
    public Object intercept(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取方法签名
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        
        // 获取@Idempotent注解
        Idempotent idempotent = method.getAnnotation(Idempotent.class);
        
        // 获取请求参数
        Object[] args = joinPoint.getArgs();
        
        // 获取令牌
        String token = getToken(args, idempotent.tokenParamName());
        
        // 验证令牌
        if (!tokenService.validateToken(token)) {
            throw new RuntimeException(idempotent.message());
        }
        
        // 执行方法
        return joinPoint.proceed();
    }
    
    /**
     * 从请求参数中获取令牌
     */
    private String getToken(Object[] args, String tokenParamName) {
        for (Object arg : args) {
            if (arg instanceof HttpServletRequest) {
                HttpServletRequest request = (HttpServletRequest) arg;
                return request.getParameter(tokenParamName);
            } else if (arg instanceof Map) {
                Map<?, ?> map = (Map<?, ?>) arg;
                return (String) map.get(tokenParamName);
            } else {
                // 尝试从对象的属性中获取令牌
                try {
                    Field field = arg.getClass().getDeclaredField(tokenParamName);
                    field.setAccessible(true);
                    return (String) field.get(arg);
                } catch (Exception e) {
                    // 忽略异常
                }
            }
        }
        return null;
    }
}

3. 分布式 ID + 幂等令牌的组合使用

在跨服务调用中,我们可以结合使用分布式 ID 和幂等令牌来防止重复提交。

(1)订单服务示例

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private IdempotentTokenService tokenService;
    
    /**
     * 获取幂等令牌
     */
    @GetMapping("/token")
    public String getToken() {
        return tokenService.generateToken();
    }
    
    /**
     * 创建订单
     */
    @PostMapping
    @Idempotent
    public String createOrder(@RequestBody OrderRequest request, @RequestParam String token) {
        try {
            // 创建订单
            String orderId = orderService.createOrder(request);
            return "订单创建成功,订单ID:" + orderId;
        } catch (Exception e) {
            log.error("创建订单失败", e);
            return "创建订单失败:" + e.getMessage();
        }
    }
}

@Service
public class OrderService {
    
    @Autowired
    private RestTemplate restTemplate;
    
    @Autowired
    private SnowflakeIdGenerator idGenerator;
    
    /**
     * 创建订单
     */
    @Transactional
    public String createOrder(OrderRequest request) {
        // 生成唯一订单ID
        String orderId = String.valueOf(idGenerator.nextId());
        
        // 创建订单
        Order order = new Order();
        order.setOrderId(orderId);
        order.setUserId(request.getUserId());
        order.setAmount(request.getAmount());
        order.setItems(request.getItems());
        order.setStatus(OrderStatus.PENDING);
        order.setCreateTime(LocalDateTime.now());
        
        // 保存订单
        orderRepository.save(order);
        
        // 扣减库存
        deductInventory(request.getItems());
        
        return orderId;
    }
    
    /**
     * 扣减库存
     */
    private void deductInventory(List<OrderItem> items) {
        // 调用库存服务扣减库存
        // 这里需要传递订单ID作为唯一标识符,防止重复扣减库存
        InventoryRequest inventoryRequest = new InventoryRequest();
        inventoryRequest.setOrderId(orderId);
        inventoryRequest.setItems(items);
        
        restTemplate.postForObject(
            "http://inventory-service/api/inventory/deduct",
            inventoryRequest,
            String.class
        );
    }
}

(2)库存服务示例

@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
    
    @Autowired
    private InventoryService inventoryService;
    
    /**
     * 扣减库存
     */
    @PostMapping("/deduct")
    @Idempotent(tokenParamName = "orderId")
    public String deductInventory(@RequestBody InventoryRequest request) {
        try {
            // 扣减库存
            inventoryService.deductInventory(request);
            return "库存扣减成功";
        } catch (Exception e) {
            log.error("库存扣减失败", e);
            return "库存扣减失败:" + e.getMessage();
        }
    }
}

@Service
public class InventoryService {
    
    @Autowired
    private InventoryRepository inventoryRepository;
    
    /**
     * 扣减库存
     */
    @Transactional
    public void deductInventory(InventoryRequest request) {
        // 获取订单ID作为唯一标识符
        String orderId = request.getOrderId();
        
        // 验证订单ID是否已经处理过
        if (isOrderProcessed(orderId)) {
            throw new RuntimeException("订单已经处理过");
        }
        
        // 扣减库存
        for (OrderItem item : request.getItems()) {
            Inventory inventory = inventoryRepository.findByProductId(item.getProductId());
            if (inventory == null) {
                throw new RuntimeException("商品不存在");
            }
            
            if (inventory.getStock() < item.getQuantity()) {
                throw new RuntimeException("商品库存不足");
            }
            
            inventory.setStock(inventory.getStock() - item.getQuantity());
            inventoryRepository.save(inventory);
        }
        
        // 标记订单已处理
        markOrderAsProcessed(orderId);
    }
    
    /**
     * 验证订单是否已经处理过
     */
    private boolean isOrderProcessed(String orderId) {
        // 从Redis中查询订单是否已经处理过
        return redisTemplate.hasKey(getOrderProcessedKey(orderId));
    }
    
    /**
     * 标记订单已处理
     */
    private void markOrderAsProcessed(String orderId) {
        // 存储订单已处理标记到Redis,设置过期时间为24小时
        redisTemplate.opsForValue()
            .set(getOrderProcessedKey(orderId), "1", 24, TimeUnit.HOURS);
    }
    
    /**
     * 获取订单已处理标记的键
     */
    private String getOrderProcessedKey(String orderId) {
        return "order:processed:" + orderId;
    }
}

六、最佳实践

1. 前端使用指南

  1. 获取令牌:在提交操作前,先调用后端的获取令牌接口
  2. 传递令牌:将令牌作为请求参数或请求头传递给后端
  3. 处理失败:如果后端返回"操作正在处理中"等错误信息,提示用户不要重复操作
  4. 禁用按钮:点击提交按钮后,禁用按钮,防止用户重复点击

2. 后端使用指南

  1. 标记方法:在需要防重复提交的方法上添加@Idempotent注解
  2. 传递唯一标识:在跨服务调用时,传递唯一标识符(如订单ID)
  3. 设置合理的过期时间:根据业务场景设置令牌和操作结果的过期时间
  4. 处理异常:妥善处理令牌验证失败、操作重复执行等异常情况
  5. 记录日志:记录关键操作的日志,便于排查问题

3. 性能优化

  1. 使用Redis Pipeline:批量操作Redis,减少网络开销
  2. 使用Lua脚本:将令牌验证和删除操作封装为Lua脚本,保证原子性
  3. 缓存预热:提前生成一批令牌,减少生成令牌的时间
  4. 异步处理:对于耗时较长的操作,使用异步处理,提高响应速度

七、方案优势

  1. 唯一性:使用分布式 ID 保证操作的唯一性
  2. 安全性:使用幂等令牌防止重复提交
  3. 可靠性:即使在网络不稳定的情况下,也能保证操作不被重复执行
  4. 可扩展性:易于集成到现有的SpringBoot项目中
  5. 性能高:使用Redis存储令牌,性能优异
  6. 适用范围广:适用于各种需要防止重复提交的场景

八、适用场景

  1. 订单创建:防止重复创建订单
  2. 支付操作:防止重复支付
  3. 退款操作:防止重复退款
  4. 库存扣减:防止重复扣减库存
  5. 发货操作:防止重复发货
  6. 优惠券领取:防止重复领取优惠券
  7. 积分兑换:防止重复兑换积分
  8. 任何需要保证操作唯一性的场景

九、写在最后

跨服务调用中的重复提交问题,是微服务架构下的一个常见挑战。通过结合使用分布式 ID 和幂等令牌,我们可以有效地防止重复提交,保障操作的唯一性。

当然,这套方案也不是银弹,它需要根据具体的业务场景进行调整和优化。比如,对于不同的业务操作,我们可能需要设置不同的过期时间;对于高并发场景,我们可能需要优化Redis的性能。

希望这篇文章能给你带来一些启发,帮助你在实际项目中更好地解决重复提交问题。

如果你在使用这套方案的过程中有其他经验或困惑,欢迎在评论区留言交流!


服务端技术精选,专注分享后端开发实战经验,让技术落地更简单。

如果你觉得这篇文章有用,欢迎点赞、在看、分享三连!


标题:SpringBoot + 分布式 ID + 幂等令牌:跨服务调用防重复提交的终极方案
作者:jiangyi
地址:http://jiangyi.space/articles/2026/02/26/1771992546360.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消