SpringBoot + 分布式 ID + 幂等令牌:跨服务调用防重复提交的终极方案
一、跨服务调用的重复提交噩梦
上周,一位朋友找我吐槽:他们公司的订单系统又出问题了。
用户在APP上下单,点击"提交"按钮后,因为网络延迟,页面没有立即响应,用户以为没提交成功,就又点击了一次。结果系统创建了两个相同的订单,用户收到了两条订单确认短信,商家也收到了两条订单通知。
更糟糕的是,这个问题不是第一次出现了。之前在支付、退款、发货等环节都发生过类似的重复操作问题。
"我们的系统是微服务架构,订单服务、支付服务、库存服务都是独立部署的,"朋友无奈地说,"跨服务调用的时候,很难保证操作不被重复执行。"
这样的场景,作为后端开发的你,是不是也遇到过?
二、为什么重复提交这么难解决?
在微服务架构下,重复提交问题变得更加复杂:
1. 网络不稳定
网络延迟、抖动、丢包等问题,可能导致客户端以为请求失败,从而重复发送请求。
2. 服务端处理延迟
服务端处理请求的时间过长,客户端超时后可能会重新发送请求。
3. 重试机制
为了提高系统的可靠性,很多系统都实现了自动重试机制,这也可能导致重复请求。
4. 跨服务调用
在微服务架构下,一个业务流程可能涉及多个服务的调用,任何一个环节的失败都可能导致整个流程的重试。
5. 数据一致性
重复提交可能导致数据不一致,比如重复创建订单、重复扣减库存、重复支付等。
三、传统方案的局限性
为了解决重复提交问题,我们通常会使用以下方案:
1. 前端防重复
- 禁用按钮:点击后禁用提交按钮
- 加载动画:显示加载动画,提示用户请求正在处理
- 防抖节流:限制用户在一定时间内只能提交一次
这种方案只能防止用户的误操作,无法防止恶意请求或网络问题导致的重复提交。
2. 后端防重复
- 数据库唯一索引:通过数据库唯一索引来防止重复数据
- Redis 分布式锁:使用 Redis 分布式锁来保证操作的原子性
- 请求参数签名:对请求参数进行签名,防止请求被篡改
这些方案都有一定的局限性:
- 数据库唯一索引:需要在数据库层面做文章,对性能有影响
- Redis 分布式锁:实现复杂,需要考虑锁的过期时间、释放机制等
- 请求参数签名:只能防止请求被篡改,不能防止重复请求
四、终极方案:分布式 ID + 幂等令牌
今天,我要和大家分享一个在实战中验证过的解决方案:SpringBoot + 分布式 ID + 幂等令牌。
这套方案的核心思想是:
- 分布式 ID:为每个操作生成唯一的标识符
- 幂等令牌:在操作执行前生成令牌,执行后验证令牌
- 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. 前端使用指南
- 获取令牌:在提交操作前,先调用后端的获取令牌接口
- 传递令牌:将令牌作为请求参数或请求头传递给后端
- 处理失败:如果后端返回"操作正在处理中"等错误信息,提示用户不要重复操作
- 禁用按钮:点击提交按钮后,禁用按钮,防止用户重复点击
2. 后端使用指南
- 标记方法:在需要防重复提交的方法上添加@Idempotent注解
- 传递唯一标识:在跨服务调用时,传递唯一标识符(如订单ID)
- 设置合理的过期时间:根据业务场景设置令牌和操作结果的过期时间
- 处理异常:妥善处理令牌验证失败、操作重复执行等异常情况
- 记录日志:记录关键操作的日志,便于排查问题
3. 性能优化
- 使用Redis Pipeline:批量操作Redis,减少网络开销
- 使用Lua脚本:将令牌验证和删除操作封装为Lua脚本,保证原子性
- 缓存预热:提前生成一批令牌,减少生成令牌的时间
- 异步处理:对于耗时较长的操作,使用异步处理,提高响应速度
七、方案优势
- 唯一性:使用分布式 ID 保证操作的唯一性
- 安全性:使用幂等令牌防止重复提交
- 可靠性:即使在网络不稳定的情况下,也能保证操作不被重复执行
- 可扩展性:易于集成到现有的SpringBoot项目中
- 性能高:使用Redis存储令牌,性能优异
- 适用范围广:适用于各种需要防止重复提交的场景
八、适用场景
- 订单创建:防止重复创建订单
- 支付操作:防止重复支付
- 退款操作:防止重复退款
- 库存扣减:防止重复扣减库存
- 发货操作:防止重复发货
- 优惠券领取:防止重复领取优惠券
- 积分兑换:防止重复兑换积分
- 任何需要保证操作唯一性的场景
九、写在最后
跨服务调用中的重复提交问题,是微服务架构下的一个常见挑战。通过结合使用分布式 ID 和幂等令牌,我们可以有效地防止重复提交,保障操作的唯一性。
当然,这套方案也不是银弹,它需要根据具体的业务场景进行调整和优化。比如,对于不同的业务操作,我们可能需要设置不同的过期时间;对于高并发场景,我们可能需要优化Redis的性能。
希望这篇文章能给你带来一些启发,帮助你在实际项目中更好地解决重复提交问题。
如果你在使用这套方案的过程中有其他经验或困惑,欢迎在评论区留言交流!
服务端技术精选,专注分享后端开发实战经验,让技术落地更简单。
如果你觉得这篇文章有用,欢迎点赞、在看、分享三连!
标题:SpringBoot + 分布式 ID + 幂等令牌:跨服务调用防重复提交的终极方案
作者:jiangyi
地址:http://jiangyi.space/articles/2026/02/26/1771992546360.html
公众号:服务端技术精选
评论