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

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

Java后端技术全栈
Java后端技术全栈轉載
2023-08-22 15:57:311371瀏覽

大家好,我是田哥

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

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

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

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

一、冪等性概念

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

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

二、常見解決方案

  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刪除