面试官:支付接口, 重复支付同一笔订单只能扣一次钱, 怎么做?

2023年 8月 28日 51.7k 0

大家好,我是田哥

昨天,在给一位朋友做模拟面试的时候,关于接口幂等如何实现?从他的回答语气中能看出,就是在背八股文。

所以,为了让大家能轻松体会接口的幂等实现,田哥今天安排了这篇文章。

本文一共有九个主要内容:

面试官:支付接口, 重复支付同一笔订单只能扣一次钱, 怎么做?

一、幂等性概念

幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次 比如:

  • 订单接口, 不能多次创建订单
  • 支付接口, 重复支付同一笔订单只能扣一次钱
  • 支付宝回调接口, 可能会多次回调, 必须处理重复回调
  • 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次 等等

二、常见解决方案

  • 唯一索引 -- 防止新增脏数据
  • token机制 -- 防止页面重复提交
  • 悲观锁 -- 获取数据的时候加锁(锁表或锁行)
  • 乐观锁 -- 基于版本号version实现, 在更新数据那一刻校验数据
  • 分布式锁 -- redis(jedis、redisson)或zookeeper实现
  • 状态机 -- 状态变更, 更新数据时判断状态
  • 三、本文实现

    本文采用第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.clients
    jedis
    2.9.0

    org.projectlombok
    lombok
    1.16.10

    登录后复制

    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

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论