分布式场景下的操作幂等性:注解 + AOP 实践指南

311 阅读6分钟

一、背景

  • 在电商系统中,用户在完成购物后会点击“提交订单”按钮来生成一个新的订单。由于系统运行在分布式架构上,可能会出现以下常见的重复提交问题:
  • 网络延迟:用户网络环境较差,页面响应速度慢,可能导致重复点击“提交订单”按钮,系统接收到多个重复的订单请求。
  • 按钮未禁用:前端页面的提交按钮在用户点击后未及时禁用,用户可以多次点击,发起多次请求。
  • 系统处理延迟:后端系统处理订单请求时间较长,用户误以为提交失败,重复提交相同订单数据。

重复提交会导致:

  • 多个重复订单生成,影响库存扣减、支付流程等业务操作。
  • 用户体验差,可能引发多次支付扣款的问题。

为了解决以上问题,我们引入了分布式锁,确保在分布式环境下,用户的订单请求具有幂等性,防止重复提交。

二、通过分布式锁防重复提交

  • 常见两种本地锁:synchronizedReentrantLock
  • synchronized 是 Java 中的一种内置锁机制,用于在代码块或方法上实现线程同步。
public synchronized void synchronizedMethod() {
    // 线程安全的代码
}
  • ReentrantLock 是 java.util.concurrent.locks 包下的锁实现,它提供了更多的控制和灵活性。
private final ReentrantLock lock = new ReentrantLock();

public void method() {
    lock.lock(); // 加锁
    try {
        // 线程安全的代码
    } finally {
        lock.unlock(); // 解锁,确保在最终块中释放锁
    }
}

三、本地锁出现的问题

范围有限:本地锁仅在应用程序的单个实例中有效。如果你的应用程序在多台服务器上运行(即分布式环境),每个实例的本地锁相互独立,无法在集群中共享锁状态。

竞争条件:不同实例上的本地锁无法相互感知,这意味着多个实例可能同时认为自己获得了锁,从而导致并发冲突。

而且,上面锁定的话,单个实例里的逻辑就变成串行了。如果想让不同的用户、不同的参数并行执行,还需要额外代码控制。

四、什么是分布式锁?

  • 分布式锁是一种用于在分布式系统协调多个节点对共享资源的访问的机制。
  • 它确保在多个节点并发访问时,只有一个节点可以在某个时刻拥有特定资源的访问权,从而避免数据不一致、竞争条件或资源冲突的问题。

五、分布式锁 Key 的组成部分

  • (1)分布式锁前缀。
  • (2)请求路径。
  • (3)当前访问用户。
  • (4)参数 MD5。
private final RedissonClient redissonClient;

@Override
public Object noDuplicateSubmit(ProceedingJoinPoint joinPoint) throws Throwable {
    NoDuplicateSubmit noDuplicateSubmit = getNoDuplicateSubmitAnnotation(joinPoint);
    // 获取分布式锁标识
    String lockKey = String.format("order:submit:path:%s:userId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(joinPoint));
    RLock lock = redissonClient.getLock(lockKey);

    // 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常
    if (!lock.tryLock()) {
        throw new ClientException("请勿短时间内重复提交订单");
    }

    try {
        // 执行常规业务代码
        // ......
    } finally {
        lock.unlock();
    }
}

/**
 * @return 获取当前请求路径
 */
private String getServletPath() {
    ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    return sra.getRequest().getServletPath();
}

/**
 * @return 当前操作用户 ID
 */
private String getCurrentUserId() {
    // 用户属于非核心功能,这里先通过模拟的形式代替。后续如果需要后管展示,会重构该代码
    return "2025011411361122";
}

/**
 * @return joinPoint md5
 */
private String calcArgsMD5(ProceedingJoinPoint requestParam) {
    return DigestUtil.md5Hex(JSON.toJSONBytes(joinPoint.getArgs()));
}
  • 上述代码可以解决我们的订单防重复提交问题。虽然业务问题解决了,但是遇到了和上一节操作日志记录一样的问题。

当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁琐。

对于代码的可读性和可维护性来说是一个灾难,因为不止这一处使用,存在大量的冗余代码。

自定义注解实现分布式锁

自定义 Java 注解,取名 @NoDuplicateSubmit 防止重复提交。

/**
  *
  * @Description 幂等注解,防止用户重复提交表单信息
  * @Author huanglibin
  * @Date 2024/9/26 19:47
  **/
@Target(ElementType.METHOD) // 意味着只能在方法上使用。
@Retention(RetentionPolicy.RUNTIME)  // 在运行时有效
public @interface NoDuplicateSubmit {

    /**
     * 触发幂等失败逻辑时,返回的错误提示信息
     */
    String message() default "您操作太快,请稍后再试";
}
  • 自定义注解上还有两个注解详细说明
  • @Target(ElementType.METHOD) 意味着只能在方法上使用。
  • @Retention(RetentionPolicy.RUNTIME) 意味着可以通过反射获取注解内的信息。

自定义 SpringAOP 切面

  • 通过 SpringAOP 环绕通知对方法增强

image.png

  • common模块下面添加aop依赖
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
  • 新增切面类 NoDuplicateSubmitAspect
@Aspect
@RequiredArgsConstructor
public class NoDuplicateSubmitAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(com.example.orderservice.annotation.NoDuplicateSubmit)")
    public Object noDuplicateSubmit(ProceedingJoinPoint joinPoint) throws Throwable {
        NoDuplicateSubmit noDuplicateSubmit = getNoDuplicateSubmitAnnotation(joinPoint);
        // 构造分布式锁的 Key:路径 + 用户 ID + 参数哈希值
        String lockKey = String.format("order:submit:path:%s:userId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(joinPoint));
        
        RLock lock = redissonClient.getLock(lockKey);
       // 尝试获取锁,10秒超时自动释放
        if (!lock.tryLock(10, TimeUnit.SECONDS)) { 
        // 尝试获取锁,10秒超时自动释放 
            throw new ClientException(noDuplicateSubmit.message()); 
        }
        
        Object result;
        try {
            // 执行标记了防重复提交注解的方法原逻辑
            result = joinPoint.proceed();  
        } finally {
            lock.unlock(); // 释放锁
        }
        return result;
    }

    /**
     * 返回自定义防重复提交注解
     * @Author huanglibin
     * @Date 2024/9/26 20:00
     **/
    public static NoDuplicateSubmit getNoDuplicateSubmitAnnotation(ProceedingJoinPoint joinPoint) throws NoSuchMethodException{
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        /*Method targetMethod = joinPoint.getTarget().getClass() // joinPoint.getTarget().getClass(): 获取目标类的 Class 对象。
                .getDeclaredMethod(methodSignature.getName(), // methodSignature.getName(): 获取方法的名称。
                        methodSignature.getMethod()
                                .getParameterTypes()); // methodSignature.getMethod().getParameterTypes(): 获取方法的参数类型。*/
        Method targetMethod = methodSignature.getMethod();
        return targetMethod.getAnnotation(NoDuplicateSubmit.class);
    }

    /**
     * 获取当前线程上下文 ServletPath
     * @Author huanglibin
     * @Date 2024/9/26 20:01
     **/
    private String getServletPath(){
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return requestAttributes.getRequest().getServletPath();
    }

    /**
     * @return 当前操作用户 ID
     */
    private String getCurrentUserId() {
        return "10001"; // 实际业务中可以从 Token 或 Session 获取用户 ID
    }

    /**
     * md5加密
     * @Author huanglibin
     * @Date 2024/9/26 20:02
     **/
    private String calcArgsMD5(ProceedingJoinPoint joinPoint) throws Throwable {
        return DigestUtil.md5Hex(JSON.toJSONBytes(joinPoint.getArgs()));
    }
}
  • 创建幂等自动装配类 IdempotentConfiguration
/**
  *
  * @Description 幂等组件相关配置类
  * @Author huanglibin
  * @Date 2024/9/26 20:05
  **/
@Configuration
public class IdempotentConfiguration {
    /**
     * 防止用户重复提交表单信息切面控制器
     */
    @Bean
    public NoDuplicateSubmitAspect noDuplicateSubmitAspect(RedissonClient redissonClient) {
        return new NoDuplicateSubmitAspect(redissonClient);
    }
}
  • 配置自动发现

在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 追加幂等自动装配全限定路径。

com.example.springbootopenaicommon.exception.GlobalExceptionHandler
com.example.springbootopenaicommon.config.IdempotentConfiguration
  • 控制层引入注解
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    /**
     * 订单提交接口,防止重复提交
     */
    @NoDuplicateSubmit(message = "订单正在处理中,请勿重复提交")
    @Operation(summary = "用户提交订单")
    @PostMapping("/submit")
    public ResponseEntity<Void> submitOrder(@RequestBody OrderSubmitRequest request) {
        orderService.submitOrder(request);
        return ResponseEntity.ok().build();
    }
}

  • 服务层处理订单提交逻辑
@Service
@RequiredArgsConstructor
public class OrderService {

    public void submitOrder(OrderSubmitRequest request) {
        // 核心业务逻辑:生成订单
        // ......
        System.out.println("订单已成功提交:" + request);
    }
}

  • 创建并发单元测试类 OrderSubmitDuplicateSubmitTests
@Slf4j
@SpringBootTest
public class OrderSubmitDuplicateSubmitTests {

    @Autowired
    private OrderController orderController;

    @Test
    public void testDuplicateSubmit() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        String orderJSONStr = """
                {
                  "userId": "10001",
                  "productId": "20001",
                  "quantity": 1,
                  "price": 99.99
                }
                """;

        MockHttpServletRequest request = new MockHttpServletRequest();
        ServletRequestAttributes attributes = new ServletRequestAttributes(request);

        for (int i = 0; i < 10; i++) {
            executorService.execute(() -> {
                RequestContextHolder.setRequestAttributes(attributes);
                try {
                    orderController.submitOrder(JSON.parseObject(orderJSONStr, OrderSubmitRequest.class));
                } catch (Exception e) {
                    log.error("订单提交失败", e);
                } finally {
                    RequestContextHolder.resetRequestAttributes();
                }
            });
        }

        executorService.shutdown();
        while (!executorService.isTerminated()) {
            Thread.sleep(1000);
        }
    }
}