搜尋
首頁JavaJava面試題面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

大家好,我是田哥

#昨天,在給一位朋友做 #模擬面試的時候,關於介面冪等如何實作?從他的回答語氣中能看出,就是在背八股文

所以,為了讓大家能輕鬆體會介面的冪等實現,田哥今天安排了這篇文章。

本文一共有九個主要內容:

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

一、冪等性概念

冪等性, 通俗的說就是一個介面, 多次發起同一個請求, 必須保證操作只能執行一次例如:

  • 訂單介面, 不能多次建立訂單
  • 支付介面, 重複支付同一筆訂單只能扣一次錢
  • #支付寶回呼介面, 可能會多次回呼, 必須處理重複回呼
  • #普通表單提交介面, 因為網路逾時等原因多次點擊提交, 只能成功一次等等等

二、常見解決方案

  1. #唯一索引-- 防止新增髒資料
  2. token機制-- 防止頁面重複提交
  3. 悲觀鎖定-- 取得資料的時候加鎖(鎖定表或鎖定行)
  4. 樂觀鎖定-- 基於版本號version實作, 在更新資料那一刻校驗資料
  5. 分散式鎖定-- redis(jedis、redisson)或zookeeper實作
  6. 狀態機-- 狀態變更, 更新資料時判斷狀態

############################################################################## #######三、本文實作###### #########本文採用第2種方式實作, 即透過###Redis token###機制實現介面冪等性校驗。 ###

四、實作想法

#為需要保證冪等性的每一次請求建立一個唯一識別token, 先取得token, 並將此token存入redis, 請求介面時, 將此token放到header或作為請求參數請求介面,後端介面判斷redis中是否存在此token:

#
  • 如果存在, 正常處理業務邏輯, 並從redis中刪除此token, 那麼, 如果是重複請求, 由於token已被刪除,則不能通過校驗, 返回請勿重複操作提示
  • #如果不存在, 說明參數不合法或者是重複請求, 返回提示即可

五、專案簡介

  • Spring Boot
  • #Redis
  • #@ApiIdempotent註解攔截器對請求進行攔截
  • #@ControllerAdvice全域例外處理
  • #壓縮工具: Jmeter

說明:

本文重點介紹冪等性核心實作, 關於Spring Boot如何整合redis 、ServerResponse 、ResponseCode 等細枝末節不在本文討論範圍之內.

六、程式碼實作

#1、maven依賴

<!-- Redis-Jedis -->
<dependency>
   <groupId>redis.clients</groupId>
   <artifactId>jedis</artifactId>
   <version>2.9.0</version>
</dependency>

<!--lombok 本文用到@Slf4j注解, 也可不引用, 自定义log即可-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.16.10</version>
</dependency>

2、JedisUtil

@Component
@Slf4j
public class JedisUtil {

    @Autowired
    private JedisPool jedisPool;

    private Jedis getJedis() {
        return jedisPool.getResource();
    }

    /**
     * 设值
     *
     * @param key
     * @param value
     * @return
     */
    public String set(String key, String value) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.set(key, value);
        } catch (Exception e) {
            log.error("set key:{} value:{} error", key, value, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 设值
     *
     * @param key
     * @param value
     * @param expireTime 过期时间, 单位: s
     * @return
     */
    public String set(String key, String value, int expireTime) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.setex(key, expireTime, value);
        } catch (Exception e) {
            log.error("set key:{} value:{} expireTime:{} error", key, value, expireTime, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 取值
     *
     * @param key
     * @return
     */
    public String get(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.get(key);
        } catch (Exception e) {
            log.error("get key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 删除key
     *
     * @param key
     * @return
     */
    public Long del(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.del(key.getBytes());
        } catch (Exception e) {
            log.error("del key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 判断key是否存在
     *
     * @param key
     * @return
     */
    public Boolean exists(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.exists(key.getBytes());
        } catch (Exception e) {
            log.error("exists key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 设值key过期时间
     *
     * @param key
     * @param expireTime 过期时间, 单位: s
     * @return
     */
    public Long expire(String key, int expireTime) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.expire(key.getBytes(), expireTime);
        } catch (Exception e) {
            log.error("expire key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 获取剩余时间
     *
     * @param key
     * @return
     */
    public Long ttl(String key) {
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.ttl(key);
        } catch (Exception e) {
            log.error("ttl key:{} error", key, e);
            return null;
        } finally {
            close(jedis);
        }
    }

    private void close(Jedis jedis) {
        if (null != jedis) {
            jedis.close();
        }
    }

}

3、自訂註解#@ApiIdempotent

/**
 * 在需要保证 接口幂等性 的Controller的方法上使用此注解
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}

4、ApiIdempotentInterceptor 攔截器

/**
 * 接口幂等性拦截器
 */
public class ApiIdempotentInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();

        ApiIdempotent methodAnnotation = method.getAnnotation(ApiIdempotent.class);
        if (methodAnnotation != null) {
            check(request);// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
        }

        return true;
    }

    private void check(HttpServletRequest request) {
        tokenService.checkToken(request);
    }

    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
    }
}

5、TokenServiceImpl

@Service
public class TokenServiceImpl implements TokenService {

    private static final String TOKEN_NAME = "token";

    @Autowired
    private JedisUtil jedisUtil;

    @Override
    public ServerResponse createToken() {
        String str = RandomUtil.UUID32();
        StrBuilder token = new StrBuilder();
        token.append(Constant.Redis.TOKEN_PREFIX).append(str);

        jedisUtil.set(token.toString(), token.toString(), Constant.Redis.EXPIRE_TIME_MINUTE);

        return ServerResponse.success(token.toString());
    }

    @Override
    public void checkToken(HttpServletRequest request) {
        String token = request.getHeader(TOKEN_NAME);
        if (StringUtils.isBlank(token)) {// header中不存在token
            token = request.getParameter(TOKEN_NAME);
            if (StringUtils.isBlank(token)) {// parameter中也不存在token
                throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());
            }
        }

        if (!jedisUtil.exists(token)) {
            throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
        }

        Long del = jedisUtil.del(token);
        if (del <= 0) {
            throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());
        }
    }

}

6、TestApplication

@SpringBootApplication
@MapperScan("com.wangzaiplus.test.mapper")
public class TestApplication  extends WebMvcConfigurerAdapter {

    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }

    /**
     * 跨域
     * @return
     */
    @Bean
    public CorsFilter corsFilter() {
        final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
        final CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(urlBasedCorsConfigurationSource);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 接口幂等性拦截器
        registry.addInterceptor(apiIdempotentInterceptor());
        super.addInterceptors(registry);
    }

    @Bean
    public ApiIdempotentInterceptor apiIdempotentInterceptor() {
        return new ApiIdempotentInterceptor();
    }

}

好了,以上便是程式碼的實作部分,下面我們就來驗證一下。

七、測試驗證

#取得token的控制器TokenController

@RestController
@RequestMapping("/token")
public class TokenController {

    @Autowired
    private TokenService tokenService;

    @GetMapping
    public ServerResponse token() {
        return tokenService.createToken();
    }

}

TestController, 注意@ApiIdempotent注解, 在需要幂等性校验的方法上声明此注解即可, 不需要校验的无影响:

@RestController
@RequestMapping("/test")
@Slf4j
public class TestController {

    @Autowired
    private TestService testService;

    @ApiIdempotent
    @PostMapping("testIdempotence")
    public ServerResponse testIdempotence() {
        return testService.testIdempotence();
    }

}

获取token

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

查看Redis

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

测试接口安全性: 利用Jmeter测试工具模拟50个并发请求, 将上一步获取到的token作为参数

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?
面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

header或参数均不传token, 或者token值为空, 或者token值乱填, 均无法通过校验, 如token值为abcd

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

八、注意點(非常重要)

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

上圖中, 不能單純的直接刪除token而不校驗是否刪除成功, 會出現並發安全性問題, 因為, 有可能多個線程同時走到第46行, 此時token還未被刪除,所以繼續往下執行, 如果不校驗jedisUtil.del(token)的刪除結果而直接放行, 那麼還是會出現重複提交問題, 即使實際上只有一次真正的刪除操作, 下面重現一下。

稍微修改一下程式碼:

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

再一次要求

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

#再看看控制台

面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?

雖然只有一個真正刪除掉token, 但由於沒有對刪除結果進行校驗, 所以還是有並發問題, 因此, 必須校驗

九、總結

其實思路很簡單, 就是每次請求保證唯一性, 從而保證冪等性, 透過攔截器註解, 就不用每次請求都寫重複程式碼, 其實也可以利用Spring AOP實作。

好了,今天就分享到這裡。  

#

以上是面試官:支付介面, 重複支付同一筆訂單只能扣一次錢, 怎麼做?的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述
本文轉載於:Java后端技术全栈。如有侵權,請聯絡admin@php.cn刪除

熱AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover

AI Clothes Remover

用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool

Undress AI Tool

免費脫衣圖片

Clothoff.io

Clothoff.io

AI脫衣器

Video Face Swap

Video Face Swap

使用我們完全免費的人工智慧換臉工具,輕鬆在任何影片中換臉!

熱工具

Dreamweaver CS6

Dreamweaver CS6

視覺化網頁開發工具

SublimeText3 英文版

SublimeText3 英文版

推薦:為Win版本,支援程式碼提示!

mPDF

mPDF

mPDF是一個PHP庫,可以從UTF-8編碼的HTML產生PDF檔案。原作者Ian Back編寫mPDF以從他的網站上「即時」輸出PDF文件,並處理不同的語言。與原始腳本如HTML2FPDF相比,它的速度較慢,並且在使用Unicode字體時產生的檔案較大,但支援CSS樣式等,並進行了大量增強。支援幾乎所有語言,包括RTL(阿拉伯語和希伯來語)和CJK(中日韓)。支援嵌套的區塊級元素(如P、DIV),

EditPlus 中文破解版

EditPlus 中文破解版

體積小,語法高亮,不支援程式碼提示功能

SAP NetWeaver Server Adapter for Eclipse

SAP NetWeaver Server Adapter for Eclipse

將Eclipse與SAP NetWeaver應用伺服器整合。