基于注解实现分布式锁防重复提交

主要解决的是防止同一优惠卷重复提交的问题。

问题产生原因:

  • 网络延迟:用户的网络连接可能有延迟,或者用户无意中刷新了页面,导致按钮被点击多次,系统接收到多个相同的请求。
  • 按钮未禁用:前端页面中的按钮在用户点击后没有及时禁用,导致用户可以多次点击,从而发起多个创建请求。
  • 系统处理延迟:系统在处理请求时可能出现延迟,用户误以为请求没有成功,从而重复提交相同的请求。

为什么不能使用本地锁?

  • 范围有限:本地锁仅在应用程序的单个实例中有效。如果你的应用程序在多台服务器上运行(即分布式环境),每个实例的本地锁相互独立,无法在集群中共享锁状态。
  • 竞争条件:不同实例上的本地锁无法相互感知,这意味着多个实例可能同时认为自己获得了锁,从而导致并发冲突。

分布式锁 KEY 设计:

  • 分布式锁前缀。
  • 请求路径。
  • 当前访问用户。
  • 参数 MD5。
 String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(requestParam));
private final RedissonClient redissonClient;

@Override
public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
    // 获取分布式锁标识
    String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(requestParam));
    RLock lock = redissonClient.getLock(lockKey);

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

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

/**
 * @return 获取当前线程上下文 ServletPath
 */
private String getServletPath() {
    ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    return sra.getRequest().getServletPath();
}

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

/**
 * @return joinPoint md5
 */
private String calcArgsMD5(CouponTemplateSaveReqDTO requestParam) {
    return DigestUtil.md5Hex(JSON.toJSONBytes(requestParam));
}

以上就是分布式锁的基本逻辑,由于分布式锁在本项目中使用较多,我们考虑将其封装为组件。

组件封装于 framework-idempotent 包下: 自定义注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoDuplicateSubmit {

    /**
     * 触发幂等失败逻辑时,返回的错误提示信息
     */
    String message() default "您操作太快,请稍后再试";
}

AOP 切面:

@Aspect
@RequiredArgsConstructor
public final class NoDuplicateSubmitAspect {

    private final RedissonClient redissonClient;

    /**
     * 增强方法标记 {@link NoDuplicateSubmit} 注解逻辑
     */
    @Around("@annotation(com.nageoffer.onecoupon.framework.idempotent.NoDuplicateSubmit)")
    public Object noDuplicateSubmit(ProceedingJoinPoint joinPoint) throws Throwable {
        NoDuplicateSubmit noDuplicateSubmit = getNoDuplicateSubmitAnnotation(joinPoint);
        // 获取分布式锁标识
        String lockKey = String.format("no-duplicate-submit:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(joinPoint));
        RLock lock = redissonClient.getLock(lockKey);
        // 尝试获取锁,获取锁失败就意味着已经重复提交,直接抛出异常
        if (!lock.tryLock()) {
            throw new ClientException(noDuplicateSubmit.message());
        }
        Object result;
        try {
            // 执行标记了防重复提交注解的方法原逻辑
            result = joinPoint.proceed();
        } finally {
            lock.unlock();
        }
        return result;
    }

    /**
     * @return 返回自定义防重复提交注解
     */
    public static NoDuplicateSubmit getNoDuplicateSubmitAnnotation(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
        return targetMethod.getAnnotation(NoDuplicateSubmit.class);
    }

    /**
     * @return 获取当前线程上下文 ServletPath
     */
    private String getServletPath() {
        ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return sra.getRequest().getServletPath();
    }

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

    /**
     * @return joinPoint md5
     */
    private String calcArgsMD5(ProceedingJoinPoint joinPoint) {
        return DigestUtil.md5Hex(JSON.toJSONBytes(joinPoint.getArgs()));
    }
}

切面中,封装的就是之前准备和获取分布式锁的逻辑。

使用配置类将组件交给 Spring 管理:

public class IdempotentConfiguration {

    /**
     * 防止用户重复提交表单信息切面控制器
     */
    @Bean
    public NoDuplicateSubmitAspect noDuplicateSubmitAspect(RedissonClient redissonClient) {
        return new NoDuplicateSubmitAspect(redissonClient);
    }
}

配置自动发现:

com.nageoffer.onecoupon.framework.config.IdempotentConfiguration

引入注解:

@NoDuplicateSubmit
@Operation(summary = "商家创建优惠券模板")
@PostMapping("/api/merchant-admin/coupon-template/create")
public Result<Void> createCouponTemplate(@RequestBody CouponTemplateSaveReqDTO requestParam) {
    couponTemplateService.createCouponTemplate(requestParam);
    return Results.success();
}

此作者没有提供个人介绍
最后更新于 2024-09-03