接口幂等问题

项目代码里类似有这样的业务逻辑

... ...
Service service1 =serviceDao.getService(serviceId);
if(service1.getStatus == 200){
    throw new BusinessException("status exception!");
}

Service service2 =new Service();
service2.setUserId(500);
service2.setStatus(200);
serviceDao.addService(Service2);
... ...

插入service2前先校验之前最新插入的记录service1,如果service1的状态不对,则抛出异常,否则向mysql里插入service2.

很普通的业务逻辑,表面上看起来也没有问题,但随随便便一个并发的重复请求,比如在4毫秒内给这个接口发了三次请求,就能直接击穿了service的状态校验,向mysql里连续插三条记录,很明显违背了业务逻辑,产生了脏数据。这就是接口设计的幂等问题。

有人可能觉得这是DB事务的问题,其实不是,这个并不是幻读,虽然看起来很像。读的定义是事务A读取与搜索条件相匹配的若干行。事务B以插入或删除行等方式来修改事务A的结果集,然后再提交。在我测试环境上,从sql发过去到mysql执行查询再加上网络IO时间大概要10-50ms不等,上面我举的例子是在4ms内发送三个请求进来,阻塞在serviceDao.getService(serviceId);这一步,显然事务BC还没有执行serviceDao.addService(Service2),因此算不上幻读,就算那三个请求发送的间隔时间长一些,真的产生了幻读,本质上也是由于这个接口是非幂等的原因导致的,不要把锅强行甩到mysql的事务隔离等级上。

所谓幂等,即一次请求与多次请求产生的副作用是相同的。比如SELECT、DELETE就是天然的幂等逻辑,因为你无论怎么SELECT,都不会影响db里的数据状态,同理,无论你怎么DELETE,虽说对DB有一些影响,不过仍然不影响其数据状态,顶多是删除不成功。DELETE可以算是一种伪幂等,而INSERT、UPDATE就不同了,INSERT自不必说,至于UPDATE,大家可能觉得反正都是一样的请求,就算update一百次,最终的数据也是一致的,从结果上看确实如此,但从过程上看,会产生类似CAS的重试问题,也就是说,虽然你修改成功了,但我并不知道你修改了多少次,解决方法可以借鉴CAS的版本号,打印日志流水即可。

另外,接口的幂等性和并发没什么太大关系,如果你的接口不是幂等的,并发请求会使这个问题更加严重。就算你的系统和接口没什么并发请求,用户不多,也可能会产生这样的问题,导致DB里的脏数据,而且并不好排查。至于解决方案,大概有这样几种:

1.把锅甩到mysql头上,把事务隔离等级改成可串行化。(根本不行,严重影响mysql读写性能,而且只有这一个接口有幂等问题,改事务隔离等级相当于把所有接口全变成串行了)

2.业务代码加锁。(看起来似乎可以,但是严重降低了这个接口的并发,并不可取)

3.维护一个全局缓存,每次请求都带着uuid,去缓存里做比对,有说明执行过了,没有说明可以执行。(很经典的做法,但增加了复杂度,需要维护缓存淘汰、过期时间、缓存的时机等等)

4.用redis维护分布式锁,同时结合uuid,根据setnx的结果判断是否执行。

5.DB里维护一个去重表,使用生成的唯一主插入,每次进入业务逻辑前先插入去重表,成功即可继续,发生主键冲突即失败(本质上和uuid缓存差不多)

6.重新设计业务逻辑,将非幂等的业务改为幂等(有的没法改),或容忍暂时的数据不一致,通过读写时的其他办法(重新统计等等)实现最终的数据一致。(不适用于rpc调用失败重试的情况,仅适用于对数据一致性要求不高的场景)

一个基于redis分布式锁的简单实现:

@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 默认仅打开并发重复请求校验
     */
    boolean openConcurrentRepeatValid() default true;

    /**
     * 并发重复调用情况下接口锁等待时间
     */
    long timeout() default 1000;

    /**
     * 是否打开重复请求校验
     */
    boolean openRepeatValid() default false;

    /**
     * 接口调用成功后禁止重复请求调用的限制时间
     */
    long expireTime() default 30000;
}
@Aspect
@Component
@Slf4j
public class IdempotentAspect {

    @Autowired
    private JedisPool jedisPool;

    @Pointcut("(execution(* com.hc.*.*.controller.*.*(..))) && " +
            "@annotation(org.springframework.web.bind.annotation.RequestMapping) && " +
            "@annotation(com.hc.common.idempotent.Idempotent)")
    public void pointCut() {
    }

    @Around("pointCut()")
    public Object doFilter(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        Idempotent annotation = method.getAnnotation(Idempotent.class);
        Class<?> returnType = method.getReturnType();
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        //创建request唯一ID
        String requestId = createRequestID(request);
        if (requestId == null) {
            return joinPoint.proceed();
        }
        //根据加锁结果判断请求是否重复
        if (isRepeatRequest(annotation, requestId)) {
            return returnType.newInstance();
        }
        log.info("cost time:{}", System.currentTimeMillis() - start);
        Object proceed = joinPoint.proceed();
        //根据调用结果决定是否释放锁
        resultHandler(requestId, proceed);
        return proceed;
    }

    private boolean isRepeatRequest(Idempotent idempotent, String requestId) {
        long timeout = idempotent.timeout();
        long expireTime = idempotent.expireTime();
        try (Jedis jedis = jedisPool.getResource()) {
            if (idempotent.openRepeatValid()) {
                return !"OK".equals(jedis.set(requestId, requestId, "NX", "PX", expireTime));
            }
            if (idempotent.openConcurrentRepeatValid()) {
                return !"OK".equals(jedis.set(requestId, requestId, "NX", "PX", timeout));
            }
            return false;
        } catch (Exception e) {
            return false;
        }
    }


    private String createRequestID(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        String requestMethod = request.getMethod();
        String param = getRequestParam(request);
        String ipAddress = Tools.getIpAddress(request);
        log.info("filter idempotent request,uri:{},method:{},param:{},ip:{}", requestURI, requestMethod, param, ipAddress);
        String bytes;
        try {
            bytes = DigestUtils.md5DigestAsHex((requestMethod + requestURI + ipAddress + param).getBytes("UTF-8"));
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
            return null;
        }
        return bytes;
    }

    private String getRequestParam(HttpServletRequest request) {
        String contentType = request.getContentType();
        String param;
        if ("application/json".equals(contentType) ||
                "text/xml".equals(contentType)) {
            param = parseFromBody(request);
        } else if ("application/x-www-form-urlencoded".equals(contentType) ||
                "multipart/form-data".equals(contentType)) {
            param = parseParamMap(request);
        } else {
            //TODO pathVariable处理
            param = parseParamMap(request);
        }
        return param;
    }

    private String parseParamMap(HttpServletRequest request) {
        Map<String, String[]> parameterMap = request.getParameterMap();
        return convertMap2String(parameterMap);
    }

    private String convertMap2String(Map<String, String[]> parameterMap) {
        StringBuilder stringBuilder = new StringBuilder();
        for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
            stringBuilder.append(entry.getKey());
            for (String param : entry.getValue()) {
                stringBuilder.append(param);
            }
        }
        return stringBuilder.toString();
    }

    private void resultHandler(String requestId, Object result) {
        try (Jedis jedis = jedisPool.getResource()) {
            UniteResponseEntry responseEntry = new UniteResponseEntry();
            BeanUtils.copyProperties(result, responseEntry);
            if (responseEntry.getCode() != 1) {
                //请求失败则可以继续
                jedis.del(requestId);
            }
        } catch (Exception e) {
            log.warn("Verify interface idempotent failure");
        }
    }


    private String parseFromBody(HttpServletRequest req) {
        try (BufferedReader bufferReaderBody = new BufferedReader(req.getReader())) {
            return bufferReaderBody.readLine();
        } catch (IOException e) {
            return "";
        }
    }

}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值