春眠不觉晓,接口幂等知多少~

1. 写在前面的话

在实际开发中,不知道大家有没有遇到过 “ 测试人员 ” 疯狂点击提交按钮,结果产生脏数据的问题?最终导致一条数据会产生多条记录,这篇博客主要核心是为了解决这一类事情(BUG)提供一个简单的方案。
--------- 甚至我前端使用 disabled 或者 loading 都没有完全能阻止这类BUG,只能说有效减少部分,所以我就在想如何搞一个简单办法,最好是能通用的方案呢?

2. 了解一个概念:幂等

任意多次执行所产生的影响均与一次执行的影响相同。

简单理解就是:只做一次(可以做多次,但是效果都是第一次的效果)

3. 实际场景有哪些?

  • 订单接口,不能多次创建订单
  • 支付接口,重复支付同一笔订单只能扣一次钱
  • 支付宝回调接口,可能会多次回调,必须处理重复回调
  • 普通表单提交接口,因为网络超时等原因多次点击提交,只能成功一次等等

4. 解决方案汇总

  • 唯一索引 – 防止新增脏数据(如果数据精准性要求高,建议使用)
  • token机制 – 防止页面重复提交
  • 悲观锁 – 获取数据的时候加锁(锁表或锁行)
  • 乐观锁 – 基于版本号version实现, 在更新数据那一刻校验数据
  • 分布式锁 – redis(jedis、redisson)或zookeeper实现
  • 状态机 – 状态变更, 更新数据时判断状态

5. 实现简单幂等方案

为什么叫做简单幂等?因为这里的实现方案并不是完全幂等的实现,只是并发幂等实现,如果需要完全幂等,还需要在具体service操作一些校验, 这里其实方案有点类似 “token机制” 和 “分布式锁”。

主要用到哪些知识点?

  1. 注解的使用
  2. 切面(AOP)增强(前置和后置)
  3. redis的使用(基于RedisTemplate)

6. 问题源码

@RequestMapping("/simple-idempotent")
public String simpleIdempotent(String params) throws InterruptedException {
    // 先从数据库里边查询是否存在该记录
    Object object = this.getOne(params);
    if(object == null) {
        log.info("{}", "请求成功, 正在操作业务");
        // 请求成功, 自增
        atomicInteger.incrementAndGet();
        // 模拟处理业务耗时3秒
        Thread.sleep(3000);
        // 保存入库
        this.save(params);
        return "handle success";
    } else {
        log.info("{}", "数据已经存在, 不执行任何操作");
        return "数据已经存在, 不执行任何操作";
    }
}

问题描述:
这里存在的问题主要是当有多个线程同时进入 Object object = this.getOne(params); 这行代码的时候, 都是返回null, 因此都会一直往下执行, 最终导致新增多个记录

7. 解决思路

知道问题出现的原因,那就有解决的思路了,这里主要解决,如果同一个的参数同时(只要这个参数还没处理完成之前进入的)进入这个 simpleIdempotent 方法,那就抛出异常,或者等待,从而实现 Object object = this.getOne(params); 的 “准确性”。

8. 源码实现

  • SimpleIdempotent (注解类)
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @description 简单幂等
 * @date 2020-04-18 21:14:11
 * @author houyu for.houyu@foxmail.com
 */
@Retention(RetentionPolicy.RUNTIME)
@Target( {ElementType.METHOD})
@Documented
public @interface SimpleIdempotent {

}
  • SimpleIdempotentAspect (切面类)
import cn.shaines.fastboot.common.core.extra.BusinessException;
import cn.shaines.fastboot.common.core.extra.Constant;
import cn.shaines.fastboot.common.utils.CoreUtil;
import cn.shaines.fastboot.common.utils.JSONUtil;
import cn.shaines.fastboot.common.utils.SpringContextUtil;
import cn.shaines.fastboot.common.utils.validator.AssertUtils;
import java.nio.charset.Charset;
import java.time.Duration;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

/**
 * @description 简单幂等切面
 * @date 2020-04-18 21:14:11
 * @author houyu for.houyu@foxmail.com
 */
@Slf4j
@Aspect
@Component
public class SimpleIdempotentAspect {

    private static final String keyPrefix = Constant.appName + ":idempotent:simple:";
    @Autowired
    private RedisTemplate redisTemplate;

    @Pointcut("@annotation(cn.shaines.fastboot.common.core.idempotent.SimpleIdempotent)")
    public void requiresIdempotent() {}

    @Before("requiresIdempotent()")
    public void doBefore(JoinPoint point) {
        // MethodSignature methodSignature = (MethodSignature) point.getSignature();
        // Method method = methodSignature.getMethod();
        // SimpleIdempotent annotation = method.getAnnotation(SimpleIdempotent.class);
        Object[] args = point.getArgs();
        AssertUtils.check(args == null || args.length == 0, "参数为空");
        String jsonString = JSONUtil.toJSONString(args);
        String hash = CoreUtil.md5(jsonString.getBytes(Charset.defaultCharset()));
        this.check(hash);
    }

    private void check(String hash) {
        String key = keyPrefix + hash;
        // 设置初始值, 也可以不设置, 但是建议设置
        // (这里设置10秒钟, 也就是说这个请求10秒钟之内如果没有完成, 相同参数的请求也会进来)
        Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(key, 0, Duration.ofSeconds(10));
        if(ifAbsent == null || !ifAbsent) {
            throw new BusinessException("重复提交");
        }
        // 自增1
        Long increment = redisTemplate.opsForValue().increment(key);
        if(increment == null || increment.intValue() != 1) {
            // 基于redis线程安全的自增, 保证准确性
            throw new BusinessException("重复提交");
        }
        HttpServletRequest request = SpringContextUtil.getHttpServletRequest();
        if(request != null) {
            // 基于请求存储一下这个key, 请求完之后删除这个key, 那么相同的参数其他就会可以进来了
            request.setAttribute(keyPrefix, key);
        }
    }

    @After("requiresIdempotent()")
    public void doAfter(JoinPoint point) {
        HttpServletRequest request = SpringContextUtil.getHttpServletRequest();
        if(request != null) {
            String key = (String) request.getAttribute(keyPrefix);
            if(StringUtils.isBlank(key)) {
                return;
            }
            // 删除key
            redisTemplate.delete(key);
        }
    }
}

9. 简单使用: 添加@SimpleIdempotent

@SimpleIdempotent // 添加简单幂等注解
@RequestMapping("/simple-idempotent")
public String simpleIdempotent(String params) throws InterruptedException {
    // 先从数据库里边查询是否存在该记录
    Object object = this.getOne(params);
    if(object == null) {
        log.info("{}", "请求成功, 正在操作业务");
        // 请求成功, 自增
        atomicInteger.incrementAndGet();
        // 模拟处理业务耗时3秒(3秒内 相同的params都会返回重复提交提示)
        Thread.sleep(3000);
        // 保存入库
        this.save(params);
        return "handle success";
    } else {
        log.info("{}", "数据已经存在, 不执行任何操作");
        return "数据已经存在, 不执行任何操作";
    }
}

10. 测试

这里测试可以使用压测工具 jmeter 或者 postman 什么的一类工具都可以, 我这里图方便就直接使用代码测试了~~

public static void main(String[] args) {
    CountDownLatch runLatch = new CountDownLatch(1);
    String url = "http://localhost:10002/test/simple-idempotent?params=xxx";
    for(int i = 0; i < 20; i++) {
        new Thread(() -> {
            try {
                runLatch.await();
            } catch(InterruptedException e) {
                e.printStackTrace();
            }
            // HttpClient 是我的一个工具类, 不必纠结
            Response<String> response = HttpClient.buildHttpClient().buildRequest(url).execute(BodyHandlers.ofString());
            System.out.println("==>>>" + response.getBody());
        }).start();
    }
    try {
        // 这里简单等待一下创建线程, 当然也可以使用 CountDownLatch 来进行精准等待
        Thread.sleep(2000);
    } catch(InterruptedException e) {
        e.printStackTrace();
    }
    // 开始执行请求
    runLatch.countDown();
}

测试结果

==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>{"code":40000,"msg":"重复提交","data":null,"extra":{}}
==>>>handle success

可以看出来,20个并发请求, 19个被拦截掉,只有一个进入controller, 因为进入controller需要3秒执行业务,因此最后才打印出来

11. 写在最后的话

  • 这里只是控制并发时的幂等,我叫它简单幂等(相同参数进入接口,只有一个在处理,其他的被拦截掉),因此并不是完全的控制了幂等,如果需要完全幂等,建议1. 数据库建立唯一约束 2. Service判断记录是否存在
  • 这里实现的是使用接口的所有参数进行hash,因此范围可能有些广,可以结合自己自身业务灵活调整
  • 往后再也不怕测试拼手速啦 [奸笑], 只需要在接口添加一个注解 @SimpleIdempotent 就完事啦,甚至前端都可以不用加 loading disabled (为了友好体验的话还是加上吧)
  • 深知并发水深,不敢说这里实现的代码不会出现其他并发问题, 如果有大佬发现问题,欢迎找我啪啪打脸…
  • emial: for.houyu@foxmail.com
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值