何谓幂等性

官方的说法叫做:一次或者N次请求同一个资源地址的返回结果应该是一致的 通俗的说幂等就是说要么对资源的就没有副作用比如查询操作,每次返回的结果一致(忽略数据的局部字段的不一致),要么是对请求的资源有副作用比如更新操作,但是需要做到只能更新一次,在一次更新需要提示已经更新成功,直接返回。由于网络的超时,或者远程服务宕机不包含在幂等性概念讨论考虑之内。幂等性在需要一致性场景强的业务中是默认的存在,在普通场景中也是减少业务数据库脏数据的利器。说白了这个幂等性是一个Web服务对外提供服务的承诺。

如何保证幂等

一般使用如下方式实现Web服务的幂等,数据库唯一索引,数据库乐观锁,分布式锁,令牌等

数据库唯一索引

数据库唯一索引这个实现最为简单,针对更新不频繁的业务表可以在其插入字段上增加唯一索引,然后在业务代码中增加捕获DuplicateKeyException,然后返回重复插入提示。如果说更新频繁的业务表则不可以使用唯一索引来保证数据不重复插入。因为对于有唯一索引的加锁MySQL会加两次,一次Xlock 一次是INSERT_INTENTION lock,如果此时争抢频繁导致了锁等待,那么很容易导致死锁。然后让业务无法使用。所以最简单的方式是启用一张新表来做这个幂等表(类似哨兵的概念)当这个表没有数据可以插入,如果已存在,则禁止后续业务操作。

try{ 
            remoteService.insertSelective(remoteEntity); 
            return new Result(SUCCESS, "成功"); 
        }catch (DuplicateKeyException e){ 
            return new Result(EXIST_RESOURCE, "你已经支付了,请不要重复点击"); 
        }catch (Exception e){
            return new Result(SYSTEM_ERROR); 
        }
数据库乐观锁

数据库乐观锁实现也比较简单,不过针对项目设计初期需要考虑这个数据库version字段的是设计,每一次的update。delete操作的时候需要对version进行叠加。并且每一次需要对version进行条件校验。

分布式锁

这个实现方式依托与分布式锁,我在这里他提供一种借力Redis的实现方式供大家拍砖。我的实现是参照简书上的一个兄弟的设计,我参照canal的其中回调设计进行的一部分改良。使用自定义的注解用于拦截请求,在切面中处理。力求把redis的key打散,使用SPEL表达式来进行动态生成KEY。

自定义的注解

/**
 * @author: by Mood
 * @date: 2019-03-05 11:11:11
 * @Description: 幂等注解
 * @version: 1.0
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 幂等的key 使用SPEL表达式来创建#request.id+'-'+#request.code
     * @return
     */
    String express();
}

幂等注解切面

/**
 * @author: by Mood
 * @date: 2019-04-19 10:46:06
 * @Description: 幂等注解切面
 * @version: 1.0
 */
@Aspect
@Component
public class IdempotentAspect {
    private static final Logger LOGGER = LoggerFactory.getLogger(IdempotentAspect.class) ;
    @Autowired
    private Jedis jedis;
    @Around("@annotation(idempotent)" )
    public void invokeMethod(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        Object resp = null;
        Class clazz=joinPoint.getTarget().getClass();
        String caller = getCaller(joinPoint);
        try{
            LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
            String [] paraNameArr=u.getParameterNames(clazz.getMethod(joinPoint.getSignature().getName(), AngelRequestParam.class));
            String requestKey =parseKey(idempotent.express(),paraNameArr,joinPoint.getArgs());
            LOGGER.info("Idempotent KEY:{} ",requestKey);
            resp = checkIdempotent(requestKey)? joinPoint.proceed():"已发生调用,禁止重复调用";
        }catch (Exception e){
            LOGGER.error("Idempotent create Error Info:{} ",e);
        }finally {
            directResponse(resp, caller);
        }
    }
    /**
     * 调用redis读取数据请求幂等key,如果存在,则禁止去调用业务方法
     * @param requestKey
     * @return
     */
    private Boolean checkIdempotent(String requestKey) {
        if (!RedisUtil.getInstance(jedis).getMonitorRequestKey(requestKey)){
            return RedisUtil.getInstance(jedis).getManagerRequestKey(requestKey);
        }
        return false;
    }

    /**
     * 幂等表达式 在当前版本boot无法自动解析。手动调用SpelExpressionParser去解析与一下。
     * @param express
     * @param paraNameArr
     * @param args
     * @return
     */
    private String parseKey(String express, String [] paraNameArr,Object[] args) {
        StandardEvaluationContext context = new StandardEvaluationContext();
        for(int i=0;i<paraNameArr.length;i++){
            context.setVariable(paraNameArr[i], args[i]);
        }
        return new SpelExpressionParser().parseExpression(express).getValue(context,String.class);
    }

    /**
     * 返回结果
     * @param resp
     * @param caller
     * @return
     */
    private static void directResponse(Object resp, String caller) {
        try {
            HttpServletResponse httpServletResponse = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse();
            httpServletResponse.setCharacterEncoding("UTF-8");
            httpServletResponse.setContentType("application/json; charset=UTF-8");
            if (resp != null) {
                httpServletResponse.getWriter().write(resp.toString());
            } else {
                httpServletResponse.getWriter().write("resp is null" + caller);
            }
        } catch (Exception e) {
            LOGGER.error("IdempotentAspect.directResponse error", e);
        }
    }
    /**
     * 获取接口调用者
     * @param pjp
     * @return
     */
    public static String getCaller(ProceedingJoinPoint pjp) {
        // 获取简单类名
        String className = pjp.getSignature().getDeclaringTypeName();
        String simpleClassName = className.substring(className.lastIndexOf(".") + 1);
        // 获取方法名
        String methodName = pjp.getSignature().getName();
        return simpleClassName + "#"+ methodName;
    }

}

Redis工具类

/**
 * @author: by Mood
 * @date: 2019-04-19 10:46:06
 * @Description: 使用redis监控幂等KEY工具类
 * @version: 1.0
 */
@Setter
public class RedisUtil {
	//管理KEY
    private static volatile Map<String, String> managerRequestKey; 
    //监控KEY
    private static volatile Map<String, Boolean> monitorRequestKey; 
    public static int expire=5;
    private static RedisUtil INSTANCE;
    private RedisUtil(Jedis jedis){
        monitorRequestKey= MigrateMap.makeComputingMap(new Function<String, Boolean>() {
            public Boolean apply(String requestKey) {
                return false;
            }
        });
        managerRequestKey= MigrateMap.makeComputingMap(new Function<String, String>() {
            public String apply(String requestKey) {
                String result=jedis.set(requestKey, requestKey, "nx", "ex", expire);
                if(result == null || StringUtils.isEmpty(result.toString())){
                    monitorRequestKey.put("requestKey",false);
                }else if("OK".equals(result)){
                    monitorRequestKey.put("requestKey",true);
                }
                return result;
            }
        });
    }
    @SuppressWarnings("deprecation")
    public static RedisUtil getInstance(Jedis jedis){
        if (INSTANCE!=null){
            synchronized (RedisUtil.class){
                if(INSTANCE==null){
                    INSTANCE=new RedisUtil(jedis);
                }
            }
        }
        return INSTANCE;
    }

    /**
     * 获取KEY,如果不存在 则设置进行,原子性。
     * @param requestKey
     */
    public boolean getManagerRequestKey(String requestKey){
        managerRequestKey.get(requestKey);
        return monitorRequestKey.get(requestKey);
    }

    /**
     * 删除Key
     * @param jedis
     * @param requestKey
     */
    public void deleteRequestKey(Jedis jedis,String requestKey){
        jedis.decr(requestKey);
    }
    /**
     * 获取监控key
     * @param requestKey
     */
    public boolean getMonitorRequestKey(String requestKey){
       return monitorRequestKey.get(requestKey);
    }
}

Guava的预编译Map(当调用map.get(key),执行Function的apply方法。)

package com.google.common.collect;

import com.google.common.base.Function;

import java.util.concurrent.ConcurrentMap;

public class MigrateMap {

    @SuppressWarnings("deprecation")
    public static <K, V> ConcurrentMap<K, V> makeComputingMap(MapMaker maker,
                                                              Function<? super K, ? extends V> computingFunction) {
        return maker.makeComputingMap(computingFunction);
    }

    @SuppressWarnings("deprecation")
    public static <K, V> ConcurrentMap<K, V> makeComputingMap(Function<? super K, ? extends V> computingFunction) {
        return new MapMaker().makeComputingMap(computingFunction);
    }
}
令牌token
幂等使用不足