How to avoid double payments and ensure idempotency in a distributed web application with Spring Boot

By | June 24, 2020

Idempotency

Idempotency means that no matter how many times you execute the request, the result is the same. Speaking of idempotency, you have to say repeated submission. You click the submit button continuously. In theory, this is the same piece of data. The database should only store one item but store multiple items, which violates idempotency. Therefore, we need to do some processing to ensure that the database can only store one piece of data after continuously clicking the submit button.

Custom annotation and aspect-oriented programming (AOP) implementation

We obtain the user's IP and access interface to determine whether he repeatedly submits. If the IP accesses this interface multiple times in a period, we think that it is a repeated submission. We will process the repeated submission request directly and do not allow access to the target interface.

  • Custom annotation in Spring Boot
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {

    /**
     * 1s threshold
     * @return
     */
    long timeout() default 1;
}
  • AOP implementation

We use the IP and interface address as the key, randomly generate the UUID as the value, and store it in Redis. Every time a request comes in, query Redis database based on the key. If it exists, it means repeated submission and throws an exception. If it does not exist, it submits and stores the key in Redis as usual.

@Aspect
@Component
public class NoRepeatSubmitAop {

    @Autowired
    private RedisService redisUtils;

    /**
     *     Pointcut definition
     */
    @Pointcut("@annotation(NoRepeatSubmit)")
    public void noRepeat() {}

    /**
     *     inform
     * @param point
     * @throws Throwable
     */
    @Before("noRepeat()")
    public void before(JoinPoint point) throws Exception{
        // get request
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        Assert.notNull(request, "request can not null");

        // Use token / JSessionId
        String token = IpUtils.getIpAddr(request);
        String path = request.getServletPath();
        String key = getKey(token, path);
        String clientId = getClientId();
        List<Object> lGet = redisUtils.lGet(key, 0, -1);
        // get annotations
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        NoRepeatSubmit annotation = method.getAnnotation(NoRepeatSubmit.class);
        long timeout = annotation.timeout();
        boolean isSuccess = false;
        if (lGet.size()==0 || lGet == null) {
            isSuccess = redisUtils.lSet(key, clientId, timeout);
        }
        if (!isSuccess) {
            redisUtils.lSet(key, clientId, timeout);
            throw new Exception("Repeated submissions not allowed");
        }

    }

    private String getKey(String token, String path) {
        return token + path;
    }

    private String getClientId() {
        return UUID.randomUUID().toString();
    }
}

Provide interface for testing

Add our custom annotation @NoRepeatSubmit to the interface.

@RequestMapping("/test")
@NoRepeatSubmit
public String tt(HttpServletRequest request) {

    return "1";
}

We request the interface twice in a row in the browser. It is found that the first interface responds with regular content: 1. The second interface responds with abnormal information that cannot be submitted repeatedly. Click on the interface after 1s, and found that the regular content is returned.