业务幂等性技术——1

1、幂等性的介绍

现如今很多系统都会基于分布式或微服务思想完成对系统的架构设计。那么在这一个系统中,就会存在若干个微服务,而且服务间也会产生相互通信调用。那么既然产生了服务调用,就必然会存在服务调用延迟或失败的问题。当出现这种问题,服务端会进行重试等操作或客户端有可能会进行多次点击提交。如果这样请求多次的话,那最终处理的数据结果就一定要保证统一,如支付场景。此时就需要通过保证业务幂等性方案来完成。
总的来说:业务幂等性是指无论对某一个业务操作执行多少次,其产生的业务结果和影响都是一致的。

1.1 简介

幂等本身是一个数学概念。即f(n) = 1^n,无论n为多少,f(n)的值永远为1。在编程开发中,对于幂等的定义为:无论对某一个资源操作了多少次,其影响都应是相同的。 换句话说就是:在接口重复调用的情况下,对系统产生的影响是一样的,但是返回值允许不同,如查询。幂等性不仅仅只是一次或多次操作对资源没有产生影响,还包括第一次操作产生影响后,以后多次操作不会再产生影响。并且幂等关注的是是否对资源产生影响,而不关注结果。

1.2 SQL的幂等性介绍

  • select * from table where id=1。此SQL无论执行多少次,虽然结果有可能出现不同,都不会对数据产生改变,具备幂等性。
  • insert into table(id,name) values(1,‘leixin’)。此SQL如果id或name有唯一性约束,多次操作只允许插入一条记录,则具备幂等性。如果不是,则不具备幂等性,多次操作会产生多条数据。
  • update table set score=100 where id =1。此SQL无论执行多少次,对数据产生的影响都是相同的。具备幂等性。
  • update table set score=50+score where id = 1。此SQL涉及到了计算,每次操作对数据都会产生影响。不具备幂等性。
  • delete from table where id = 1。此SQL多次操作,产生的结果相同,具备幂等性。

幂等性设计主要从两个维度进行考虑:空间、时间。

  • 空间:定义了幂等的范围,如生成订单的话,不允许出现重复下单。
  • 时间:定义幂等的有效期。有些业务需要永久性保证幂等,如下单、支付等。而部分业务只要保证一段时间幂等即可。

同时对于幂等的使用一般都会伴随着出现锁的概念,用于解决并发安全问题。

1.3 问题的抛出

在业务开发与分布式系统设计中,幂等性是一个非常重要的概念,有非常多的场景需要考虑幂等性的问题,尤其对于现在的分布式系统,经常性的考虑重试、重发等操作,一旦产生这些操作,则必须要考虑幂等性问题。以交易系统、支付系统等尤其明显,如:

  • 当用户购物进行下单操作,用户操作多次,但订单系统对于本次操作只能产生一个订单。
  • 当用户对订单进行付款,支付系统不管出现什么问题,应该只对用户扣一次款。
  • 当支付成功对库存扣减时,库存系统对订单中商品的库存数量也只能扣减一次。
  • 当对商品进行发货时,也需保证物流系统有且只能发一次货。

在电商系统中还有非常多的场景需要保证幂等性。但是一旦考虑幂等后,服务逻辑务必会变的更加复杂。因此是否要考虑幂等,需要根据具体业务场景具体分析。而且在实现幂等时,还会把并行执行的功能改为串行化,降低了执行效率。

此处以下单减库存为例,当用户生成订单成功后,会对订单中商品进行扣减库存。 订单服务会调用库存服务进行库存扣减。库存服务会完成具体扣减实现。

现在对于功能调用的设计,有可能出现调用超时,因为出现如网络抖动,虽然库存服务执行成功了,但结果并没有在超时时间内返回,则订单服务也会进行重试。那就会出现问题,stock对于之前的执行已经成功了,只是结果没有按时返回。而订单服务又重新发起请求对商品进行库存扣减。 此时出现库存扣减两次的问题。 对于这种问题,就需要通过幂等性进行结果。

在这里插入图片描述

1.4 HTTP协议语义幂等性

HTTP协议有两种方式:RESTFUL、SOA。现在对于WEBAPI,更多的会使用RESTFUL风格定义。为了更好的完成接口语义定义,HTTP对于常用的四种请求方式也定义了幂等性的语义。

  • GET:用于获取资源,多次操作不会对数据产生影响,具有幂等性。注意不是结果。
  • POST:用于新增资源,对同一个URI进行两次POST操作会在服务端创建两个资源,不具有幂等性。
  • PUT:用于修改资源,对同一个URI进行多次PUT操作,产生的影响和第一次相同,具备幂等性。
  • DELETE:用于删除资源,对同一个URI进行多次DELETE操作,产生的影响和第一次相同,具备幂等性。

综上所述,这些仅仅只是HTTP协议建议在基于RESTFUL风格定义WEB API时的语义,并非强制性。同时对于幂等性的实现,肯定是通过前端或服务端完成。

1.5 幂等性的种类

  • 接口幂等性
  • 服务幂等性
  • 消息幂等性

2、接口幂等性的实现

对于幂等的考虑,主要解决两点前后端交互与服务间交互。这两点有时都要考虑幂等性的实现。从前端的思路解决的话,主要有三种:前端防重、PRG模式、Token机制。

2.1 前端防重

通过前端防重保证幂等是最简单的实现方式,前端相关属性和JS代码即可完成设置。可靠性并不好,有经验的人员可以通过工具跳过页面仍能重复提交。主要适用于表单重复提交或按钮重复点击。

2.2 PRG模式

PRG模式即POST-REDIRECT-GET。当用户进行表单提交时,会重定向到另外一个提交成功页面,而不是停留在原先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进/后退导致表单重复提交。是一种比较常见的前端防重策略。

2.3 token机制

2.3.1 介绍

通过token机制来保证幂等是一种非常常见的解决方案,同时也适合绝大部分场景。该方案需要前后端进行一定程度的交互来完成。
在这里插入图片描述

  1. 服务端提供获取token接口,供客户端进行使用。服务端生成token后,如果当前为分布式架构,将token存放于redis中,如果是单体架构,可以保存在jvm缓存中。
  2. 当客户端获取到token后,会携带着token发起请求。
  3. 服务端接收到客户端请求后,首先会判断该token在redis中是否存在。如果存在,则完成进行业务处理,业务处理完成后,再删除token。如果不存在,代表当前请求是重复请求,直接向客户端返回对应标识。

但是现在有一个问题,当前是先执行业务再删除token。在高并发下,很有可能出现第一次访问时token存在,完成具体业务操作。但在还没有删除token时,客户端又携带token发起请求,此时,因为token还存在,第二次请求也会验证通过,执行具体业务操作。

对于这个问题的解决方案的思想就是并行变串行。会造成一定性能损耗与吞吐量降低。

第一种方案:对于业务代码执行和删除token整体加线程锁。当后续线程再来访问时,则阻塞排队。

第二种方案:借助redis单线程和incr是原子性的特点。当第一次获取token时,以token作为key,对其进行自增。然后将token进行返回,当客户端携带token访问执行业务代码时,对于判断token是否存在不用删除,而是对其继续incr。如果incr后的返回值为2。则是一个合法请求允许执行,如果是其他值,则代表是非法请求,直接返回。
在这里插入图片描述
那如果先删除token再执行业务呢?其实也会存在问题,假设具体业务代码执行超时或失败,没有向客户端返回明确结果,那客户端就很有可能会进行重试,但此时之前的token已经被删除了,则会被认为是重复请求,不再进行业务处理。

在这里插入图片描述
这种方案无需进行额外处理,一个token只能代表一次请求。一旦业务执行出现异常,则让客户端重新获取令牌,重新发起一次访问即可。推荐使用先删除token方案。

但是无论先删token还是后删token,都会有一个相同的问题。每次业务请求都回产生一个额外的请求去获取token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然redis性能好,但是这也是一种资源的浪费。

2.3.2 实现

2.3.2.1 基于自定义业务流程实现

在这里插入图片描述

  1. 修改token_service_order工程中OrderController,新增生成令牌方法genToken
	@GetMapping("/genToken")
    public String genToken(){
        String token = String.valueOf(idWorker.nextId());
        redisTemplate.opsForValue().set(token,0,30, TimeUnit.MINUTES);
        return token;
    }
  1. 修改token_service_api工程,新增OrderFeign接口。
	@GetMapping("/genToken")
    public String genToken();
  1. 修改token_web_order工程中WebOrderController,新增获取token方法
	/**
     * 服务端生成token
     */
    @GetMapping("/genToken")
    public String genToken() {
        return orderFeign.genToken();
    }
  1. 修改token_common,新增feign拦截器
	@Component
	public class FeignInterceptor implements RequestInterceptor {
	    @Override
	    public void apply(RequestTemplate requestTemplate) {
	        // 传递令牌
	        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
	        if (requestAttributes != null) {
	            HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
	            if (request != null) {
	                Enumeration<String> headerNames = request.getHeaderNames();
	                while (headerNames.hasMoreElements()) {
	                    String headerName = headerNames.nextElement();
	                    if ("token".equals(headerName)) {
	                        String headerValue = request.getHeader(headerName);
	                        // 传递token
	                        requestTemplate.header(headerName, headerValue);
	                    }
	                }
	            }
	        }
	    }
	}
  1. 修改token_web_order启动类
	@Bean
	public FeignInterceptor feignInterceptor(){
	return new FeignInterceptor();
	}
  1. 修改token_service_order中OrderController,新增添加订单方法
	/**
     * 生成订单
     */
    @PostMapping("/genOrder")
    public String genOrder(@RequestBody Order order, HttpServletRequest request) {
        // 获取令牌
        String token = request.getHeader("token");
        // 校验令牌
        try {
            if (redisTemplate.delete(token)) {
                // 令牌删除成功,代表不是重复请求,执行具体业务
                order.setId(String.valueOf(idWorker.nextId(
                )));
                order.setCreateTime(new Date());
                order.setUpdateTime(new Date());
                int result = orderService.addOrder(order);
                if (result == 1) {
                    System.out.println("success");
                    return "success";
                } else {
                    System.out.println("fail");
                    return "fail";
                }
            } else {
                // 删除令牌失败,重复请求
                System.out.println("repeat request");
                return "repeat request";
            }
        } catch (Exception e) {
            throw new RuntimeException("系统异常, 请重试");
        }
    }
  1. 修改token_service_order_api中OrderFeign。
	@PostMapping("/genOrder")
    public String genOrder(@RequestBody Order order, HttpServletRequest request)
  1. 修改token_web_order中WebOrderController,新增添加订单方法
	/**
     * 新增订单
     */
    @PostMapping("/addOrder")
    public String addOrder(@RequestBody Order order){
        return orderFeign.genOrder(order);
    }
  1. 测试
	{   
	    "id":"1",
	    "totalNum":1,
	    "payMoney":1,
	    "payType":"1",
	    "payTime":"2024-12-11",
	    "receiverContact":"leixin",
	    "receiverMobile":"15666566666",
	    "receiverAddress":"beijing"
	}

在这里插入图片描述
在这里插入图片描述

2.3.2.2 基于自定义注解实现

直接把token实现嵌入到方法中会造成大量重复代码的出现。因此可以通过自定义注解将上述代码进行改造。在需要保证幂等的方法上,添加自定义注解即可。

  1. 在token_common中新建自定义注解Idemptent
	@Target({ElementType.METHOD})
	@Retention(RetentionPolicy.RUNTIME)
	public @interface Idemptent {
	}
  1. 在token_common中新建拦截器
/**
 * @author xinlei
 * @date 2024/12/12
 */
public class IdemptentInterceptor implements HandlerInterceptor {
    @Override
    public boolean
    preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();
        Idemptent annotation = method.getAnnotation(Idemptent.class);
        if (annotation != null){
            //进行幂等性校验
            checkToken(request);
        }
        return true;
    }

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 幂等性校验
     *
     * @param request 请求
     */
    private void checkToken(HttpServletRequest request) {
        String token = request.getHeader("token");
        if (StringUtils.isEmpty(token)){
            throw new RuntimeException("非法参数");
        }
        boolean delResult = Boolean.TRUE.equals(redisTemplate.delete(token));
        if (!delResult){
            //删除失败
            throw new RuntimeException("重复请求");
        }
    }

    @Override
    public void
    postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }
    @Override
    public void
    afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
}
  1. 修改token_service_order启动类,让其继承WebMvcConfigurerAdapter
	@Bean
    public IdemptentInterceptor idemptentInterceptor() {
        return new IdemptentInterceptor();
    }
    @Override
    public void
    addInterceptors(InterceptorRegistry registry) {
        //幂等拦截器
        registry.addInterceptor(idemptentInterceptor());
        super.addInterceptors(registry);
    }
  1. 更新token_service_order与token_service_order_api,新增添加订单方法,并且方法添加自定义幂等注解
	@Idemptent
    @PostMapping("/genOrder2")
    public String genOrder2(@RequestBody Order order){
        order.setId(String.valueOf(idWorker.nextId()));
        order.setCreateTime(new Date());
        order.setUpdateTime(new Date());
        int result = orderService.addOrder(order);
        if (result == 1){
            System.out.println("success");
            return "success";
        }else {
            System.out.println("fail");
            return "fail";
        }
    }
	@PostMapping("/genOrder2")
    public String genOrder2(@RequestBody Order order);
  1. 在token_web_order的WebOrderController中添加方法
	@PostMapping("/addOrder2")
    public String addOrder2(@RequestBody Order order){
        return orderFeign.genOrder2(order);
    }
  1. 测试

获取令牌后,在jemeter中模拟高并发访问设置50个并发访问
在这里插入图片描述
新增一个http request。并设置相关信息
在这里插入图片描述
添加HTTP Header Manager
在这里插入图片描述
测试执行,可以发现,只有一个请求是成功的,其他全部被判定为重复请求。而且数据库只有一条数据。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Retrograde-lx

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值