接口幂等解决方案——单机版(防止数据重复提交)

郎家岭伯爵 2023年03月22日 421次浏览

前言

在业务开发中,接口的幂等性是一个十分重要的设计。

接口幂等是指对于同一个接口的多次调用,其结果应该和单次调用的结果一致。 也就是说,无论调用接口的次数是一次还是多次,最终的结果都应该是一样的。

本文我们主要介绍单机服务的接口幂等解决方案,解决的问题为防止数据重复提交

实现

模拟用户场景

例如我们有一个如下的业务场景:通过调用 /add 接口,从而在系统中新增用户(我们使用 log.info() 来简化新增用户的业务代码)。

package com.langjialing.helloworld.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author 郎家岭伯爵
 * @time 2023/3/22 9:30
 */
@RestController
@Slf4j
public class UserController {

    @GetMapping("/add")
    public String addUser(@RequestParam String userId){

        // 此处省略添加用户的业务代码
        log.info("成功添加用户:{}", userId);

        return userId;
    }
}

固定大小的数组

我们使用一个固定大小的数组(为了避免数组大小无限增加,此处我们限制数组大小,从而保证服务的性能),把提交的数据存储在数组中。在后续的请求到达时,先判断是否存储在此数组中,如果存在则为重复提交,如果不存在则正常放行。

package com.langjialing.helloworld.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;

/**
 * @author 郎家岭伯爵
 * @time 2023/3/22 9:30
 */
@RestController
@Slf4j
public class UserController {

    /**
     * 请求 ID 存储集合
     */
    private static final String[] REQ_CACHE = new String[100];

    /**
     * 请求计数器(指示 ID 存储的位置)
     */
    private static Integer reqCacheCounter = 0;

    @GetMapping("/add")
    public String addUser(@RequestParam String userId){
        // 非空判断(忽略)...
        synchronized (this.getClass()) {
            // 重复请求判断
            if (Arrays.asList(REQ_CACHE).contains(userId)) {
                // 重复请求
                log.info("请勿重复提交:{}", userId);
                return "执行失败";
            }
            // 记录请求 ID
            // 重置计数器
            if (reqCacheCounter >= REQ_CACHE.length) reqCacheCounter = 0;
            // 将 ID 保存到缓存
            REQ_CACHE[reqCacheCounter] = userId;
            // 下标往后移一位
            reqCacheCounter++;
        }
        // 业务代码...
        log.info("成功添加用户:{}", userId);
        return "执行成功!";
    }
}

固定大小的数组-优化版

在上一小节的方法里,我们在业务代码部分加了 synchronized 关键字来避免线程安全问题,但这样会导致当接口的并发数量较大时,锁导致接口相应速度比较慢。

因此我们这一小节使用 双重检测锁(DCL,Double-Checked Locking) 机制来优化这一方案。

双重检测锁实现:

package com.langjialing.helloworld.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;

/**
 * @author 郎家岭伯爵
 * @time 2023/3/22 9:30
 */
@RestController
@Slf4j
public class UserController {

    /**
     * 请求 ID 存储集合
     */
    private static final String[] REQ_CACHE = new String[100];

    /**
     * 请求计数器(指示 ID 存储的位置)
     */
    private static Integer reqCacheCounter = 0;

    @GetMapping("/add")
    public String addUser(@RequestParam String userId){

        // 非空判断(忽略)...
        // 重复请求判断
        if (Arrays.asList(REQ_CACHE).contains(userId)) {
            // 重复请求
            log.info("请勿重复提交:{}", userId);
            return "执行失败";
        }

        // 非空判断(忽略)...
        synchronized (this.getClass()) {
            // // 双重检查锁(DCL,Double-Checked Locking)提高程序的执行效率
            if (Arrays.asList(REQ_CACHE).contains(userId)) {
                // 重复请求
                log.info("请勿重复提交:{}", userId);
                return "执行失败";
            }
            // 记录请求 ID
            // 重置计数器
            if (reqCacheCounter >= REQ_CACHE.length) reqCacheCounter = 0;
            // 将 ID 保存到缓存
            REQ_CACHE[reqCacheCounter] = userId;
            // 下标往后移一位
            reqCacheCounter++;
        }
        // 业务代码...
        log.info("成功添加用户:{}", userId);
        return "执行成功!";
    }
}

两种方案本质上是一样的,第二种的设计思路更加巧妙,避免重复资源因等待锁资源而增加接口响应时间。

LRUMap

上面的方案基本已经实现了重复数据的拦截,但显然不够简洁和优雅,比如下标计数器的声明和业务处理等,但值得庆幸的是 Apache 为我们提供了一个 commons-collections 的框架,里面有一个非常好用的数据结构 LRUMap 可以保存指定数量的固定的数据,并且它会按照 LRU 算法,帮我们清除最不常用的数据。

LRULeast Recently Used 的缩写,即最近最少使用,是一种常用的数据淘汰算法,选择最近最久未使用的数据予以淘汰。

首先引入依赖:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-collections4</artifactId>
    <version>4.4</version>
</dependency>

代码实现:

package com.langjialing.helloworld.controller;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.map.LRUMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;

/**
 * @author 郎家岭伯爵
 * @time 2023/3/22 9:30
 */
@RestController
@Slf4j
public class UserController {

    /**
     * 最大容量 100 个,根据 LRU 算法淘汰数据的 Map 集合
     */
    private LRUMap<String, Integer> reqCache = new LRUMap<>(100);

    @GetMapping("/add")
    public String addUser(@RequestParam String userId){

        // 非空判断(忽略)...
        synchronized (this.getClass()) {
            // 重复请求判断
            if (reqCache.containsKey(userId)) {
                // 重复请求
                log.info("请勿重复提交:{}", userId);
                return "执行失败";
            }
            // 存储请求 ID
            reqCache.put(userId, 1);
        }
        // 业务代码...
        log.info("成功添加用户:{}", userId);
        return "执行成功!";
    }
}

此方案也可以使用 双重检测锁(DCL,Double-Checked Locking) 进行优化,避免重复数据的提交在 Synchronized 里判断。

总结

本文主要介绍了单机环境下避免数据重复提交的业务场景的接口幂等解决方案。