• 软件幂等性(Software Idempotence)


    幂等性是软件系统设计中十分重要的概念。幂等性要求在设计接口时,对同一个系统,使用同样的条件,一次请求和重复多次请求对系统资源的影响是一致的。对业务系统来说,在操作执行失败时,有时会选择“重试”,如果接口不具备“幂等性”,则会带来逻辑错误问题。
    为保证接口幂等性,对单实例应用,可以使用同步锁避免多线程并发问题,从而保证幂等性。对多实例应用来说,可以使用分布式锁来避免多进程并发问题,从而保证幂等性。使用分布式锁可以解决接口幂等性问题,但是增加了业务系统的复杂度。一种更优雅的方式是使用数据库的唯一性索引,即在数据库中对业务字段添加唯一性约束。使用业务字段+唯一性约束的方式可以解决接口幂等性问题,但是对业务表来说,该唯一性约束并不是业务关注点,所以一种更好的方式是使用去重表。使用唯一性索引和去重表的前提业务本身存在唯一性字段,但如果业务本身不存在这样的字段,则需根据业务来统一生成全局唯一ID。相比以上方案,全局唯一ID进一步增大了系统的复杂度。此外,对于更新场景,可考虑多版本控制或状态机控制来保证接口的幂等性。

    什么是幂等性

    幂等性是数学上的概念,即满足f(x)=f(f(x)),则称f(x)具备幂等性。在软件开发领域,幂等性则表示对同一个系统,使用同样的条件,一次请求和重复多次请求对系统资源的影响是一致的。下文描述的幂等性,如无特殊说明,均指软件中操作的幂等性。
    维基百科中对幂等性定义是:幂等性是一个数学和计算机科学领域的概念,公式表示为f(f(x)) = f(x)。简单来说,幂等性就是一个操作一次执行与多次执行产生的结果是一致的。原文定义如下:

    idempotence (idempotent, idempotence) is a mathematical and computer science concept commonly found in abstract algebra, that is, f ( f ( x )) = f ( x ).  
    The characteristic of an idempotent operation in programming is that the effect of any number of executions is the same as that of one execution. Idempotent functions, or idempotent methods, refer to functions that can be executed repeatedly with the same parameters and obtain the same results. These functions will not affect the state of the system, and there is no need to worry that repeated execution will cause changes to the system. For example, the "setTrue()" function is an idempotent function. No matter how many times it is executed, the result is the same. The idempotence guarantee of more complicated operations is realized by using a unique transaction number (serial number).
    

    为什么要保证幂等性

    有些操作天生具备幂等性,如查询操作,有些操作则需要软件开发人员保证,如新增操作。接口能否保证幂等性,对系统的影响可能是非常大的,特别实在分布式场景下。如新增行操作由于网络的复杂性、用户误操作、网络抖动、消息重复、服务超时导致业务自动重试等情况,都可能会使线上数据产生了不一致。特别是该操作涉及资金转移时,则更需保证幂等性,否则会带来极大的生产事故。关于保证幂等性的事例还有很多,这里不再一一列举,有兴趣的同学可以自行搜集并学习。

    如何实现接口幂等性

    实现接口幂等性,要根据不同的业务场景,选择合适的方案,没有银弹。且随着需求的变化,实现接口幂等性的方案有时也许同步更换。这里将以业务服务执行数据库insert操作为例,介绍下保证insert操作幂等性的常用方法。这里假设需要执行新增订单操作,且包含id、userId、orderCode、orderName、createTime等字段,对应伪代码如下:

    public void createOrder(String userId, String orderCode, String orderName, String createTime) {
        // 1.首先根据userId, orderCode判断订单是否已存在
        boolean alreadyExists = selectOrder(userId, orderCode);
        if (alreadyExists) {
            return;
        }
        // 2.如果该订单不存在,则新增一条订单记录
        insertOrder(userId, orderCode, orderName, createTime);
    }
    

    使用同步锁

    为保证新增订单操作的幂等性,第一个需要考虑的问题就是多线程并发问题。假设有两个线程同时执行上述操作,且均写库成功,则会导致订单重复创建。为避免多线程并发问题,一个简单的实现方案是使用同步锁。示例代码如下:

    synchronized public void createOrder(String userId, String orderCode, String orderName, String createTime) {
        boolean alreadyExists = selectOrder(userId, orderCode);
        if (alreadyExists) {
            return;
        }
        insertOrder(userId, orderCode, orderName, createTime);
    }
    

    使用分布式锁

    使用同步锁可以解决单一进程内多线程并发问题,但是无法解决多进程问题。云原生时代,进程是一等公民。云原生时代通常使用多实例方式(横向扩容)来解决大规模并发问题。针对多实例场景,一种有效的方式是使用分布式锁,如以Redis的setnx实现的分布式锁。示例代码如下:

    public void createOrder(String userId, String orderCode, String orderName) throws BusinessException {
        // 1.首先尝试获取分布式锁
        if (getLocker(key, timeout)) {
            // 2. 如果获取锁,则新增一条订单记录
            insertOrder(userId, orderCode, orderName, System.currentTimeMillis());
        } else {
            throw new BusinessException("");
        }
    }
    

    这里需要注意的是,尽管分布式锁可以保证同一时间仅有一个请求获取锁,但是锁可以支持排队。也就是说,如果第一个请求执行完,等待队列中的第二个请求一样会执行。所以,在新增订单前,还需判断根据userId, orderCode判断订单是否已存在。对应代码如下:

    public void createOrder(String userId, String orderCode, String orderName) throws BusinessException {
        // 1.首先尝试获取分布式锁
        if (getLocker(key, timeout)) {
            // 2. 如果获取锁,则新增一条订单记录
            createOrder(userId, orderCode, orderName, System.currentTimeMillis());
        } else {
            throw new BusinessException("");
        }
    }
    

    一种更加高效的做法是,在获取分布式锁时,指定等待时间。对于该场景,如果获取锁失败,则直接返回(不进入等待队列)。这样可以减少一次数据库查询时间。对应代码如下:

    public void createOrder(String userId, String orderCode, String orderName) throws BusinessException {
        // 1.首先尝试获取分布式锁,如果获取不到则直接返回,不等待锁
        int waitTime = 0;
        if (getLocker(key, waitTime, timeout)) {
            // 2. 如果获取锁,则新增一条订单记录
            createOrder(userId, orderCode, orderName, System.currentTimeMillis());
        } else {
            throw new BusinessException("repeat order");
        }
    }
    

    业务字段+唯一性约束

    使用分布式锁可以解决接口幂等性问题,但是增加了业务系统的复杂度。对业务系统来说,分布式锁是一种侵入式修改,尽管仅做了少量修改。而且,引入分布式锁,还导致业务系统对缓存的强依赖。如果缓存失效,业务系统将无法正常工作。一种更优雅的方式是数据库的唯一性索引,即在数据库中对业务字段添加唯一性约束。对业务系统来说,仅需处理数据唯一性异常即可。对应代码如下:

    public void createOrder(String userId, String orderCode, String orderName, long createTime) throws BusinessException {
        try {
            insertOrder(userId, orderCode, orderName, createTime);
        } catch (RuntimeException e) {
            if (e.getMessage().contains("Duplicate entry")) {
                throw new BusinessException("repeat order")} else {
                //其他类型的异常要往外抛出
                throw e;
            }
        }
    }
    

    去重表+唯一性约束

    使用业务字段+唯一性约束的方式可以解决接口幂等性问题,但是对业务表来说,该唯一性约束并不是业务关注点,所以一种更好的方式是独立出一张表token_table,也将其称为排重表或者令牌表。该表仅有一个字段(unique_key_str)组成,并将该字段设置为唯一索引。示例代码如下:

    public void createOrder(String userId, String orderCode, String orderName, long createTime) throws BusinessException {
        int token = insertToken(userId + "_" + orderCode);
        if (token > 0) {
            insertOrder(userId, orderCode, orderName, System.currentTimeMillis());
        } else {
            throw new BusinessException("repeat order");
        }
    }
    

    这里使用了数据库处理重复提交的能力。以MySQL为例,支持以insert ignore、replace和on duplicate key update等三种方式来防止重复数据插入。使用这种方式,无需处理异常且业务表减少不必要的唯一性约束。

    全局唯一ID

    针对幂等性,一种通用的方案是使用全局唯一ID。具体实现方案是:业务系统根据操作和内容生成一个全局ID,在执行插入操作前,先查询全局唯一ID是否存在,如果存在,则表示数据已经插入。如果不存在,则把全局ID存储到存储系统中。
    使用全局ID解决幂等性问题,可以作为一个基础业务服务存在。如果在每个业务服务中都实现这样一个功能,会存在工作量重复。此外,打造一个高可靠的幂等服务还需要考虑很多问题,比如全局ID的超时机制、全局ID与业务数据的事务问题,等等。

    多版本控制

    上面讨论插入的场景,对于更新的场景,则可以使用多版本控制机制。如需要更新订单名称,则可以在更新的接口中增加一个版本号,来做幂等。示例SQL如下:

    update order set orderName=#{newOrderName},version=#{version} where id=#{id} and version<${version}
    

    状态机控制

    对于更新的场景,如果有状态流转,则可以根据状态字段的大小来做幂等,比如订单的创建为0,付款成功为100。付款失败为99。示例SQL如下:

    update order set status=#{status} where id=#{id} and status<#{status}
    

    参考

    https://www.cnblogs.com/sea520/p/10117729.html 接口幂等性
    https://www.cnblogs.com/javalyy/p/8882144.html 深入理解幂等性
    https://www.jianshu.com/p/475589f5cd7b 幂等性浅谈
    https://programmerall.com/article/2178246093/ How to achieve interface idempotence
    https://blog.katastros.com/a?ID=01200-3f207ce7-a4a8-43d4-85ca-7f565a3cae38 Summary about interface idempotence
    https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html 分布式系统互斥性与幂等性问题的分析与解决
    https://blog.csdn.net/jbboy/article/details/46828917 MySql避免重复插入记录方法

  • 相关阅读:
    洛谷 B2033 A*B问题 C++代码
    基于STM32温湿度传感器采集报警系统设计
    Linux vi/vim
    通过WARN(1,“xxx“) 来确定code的flow和打印callstack
    【JavaSE】注释\标识符\关键字\字面常量\数据类型与变量
    企业订货系统常见问题与解决方案|网站定制搭建|小程序APP开发
    ffmpeg转码视频
    BUUCTF web(九)
    iVX低代码平台系列详解 -- 概述篇(三)
    开户许可证识别 易语言代码
  • 原文地址:https://blog.csdn.net/wangxufa/article/details/127045514