分布式系统中接口的幂等性

业务场景

公司有个借贷的项目,具体业务相似于阿里的蚂蚁借呗,用户在平台上借款,而后规定一个到期时间,在该时间内用户需将借款还清并收取必定的手续费,若是规定时间逾期未还上,则会产生滞纳金。前端

用户发起借款所以会产生一笔借款订单,用户可经过支付宝或在系统中绑定银行卡到期自动扣款等方式进行还款。还款流程都走支付系统,所以用户还款是否逾期以及逾期天数、逾期费等都经过系统来计算。java

可是在作订单系统的时候,遇到这样一个业务场景,因为业务缘由容许用户经过线下支付宝还款,即咱们提供一个公司官方的支付宝二维码,用户扫码还款,而后财务不按期的去拉取该支付宝帐户下的还款清单并生成规范化的Excel表格录入到支付系统。程序员

支付系统将这些支付信息生成对应的支付订单并落库,同时针对每笔还款记录生产一个消息信息到消息系统,消息的消费者就是订单系统。订单系统接受到消息后去结算当前用户的金额清算:先还本金,本金还清再还滞纳金,都还清则该笔订单结清并提高可借贷额度,……,整个流程大体以下:web

从上面的流程描述能够知道,至关于原来线上的支付如今转移到线下进行,这会产生一个问题:支付结算的不及时。例如用户的订单在今天19-05-27到期,可是用户在19-05-26还清,财务在19-05-27甚至更晚的时候从支付宝拉取清单录入支付系统。这样就形成了实际上用户是未逾期还清借款而咱们这边却记录的是用户未还清且产生了滞纳金。redis

固然以上的是业务范畴的问题,咱们今天要说的是支付系统发送消息到订单系统的环节中的一个问题。你们都知道为了不消息丢失或者订单系统处理异常或者网络问题等问题,咱们设计消息系统的时候都须要考虑消息持久化和消息的失败重试机制。sql

对于重试机制,假如订单系统消费了消息,可是因为网络等问题消息系统未收到反馈是否已成功处理。这时消息系统会根据配置的规则隔段时间就 retry 一次。你 retry 一次没错,是为了保证系统的处理正常性,可是若是这时网络恢复正常,我第一次收到的消息成功处理了,这时我又收到了一条消息,若是没有作一些防御措施,会产生以下状况:用户付款一次可是订单系统计算了两次,这样会形成财务帐单异常对不上帐的状况发生。那就可能用户笑呵呵老板哭兮兮了。数据库

接口幂等性

为了防止上述状况的发生,咱们须要提供一个防御措施,对于同一笔支付信息若是我其中某一次处理成功了,我虽然又接收到了消息,可是这时我不处理了,即保证接口的 幂等性编程

维基百科上的定义:后端

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。缓存

**在编程中一个幂等操做的特色是其任意屡次执行所产生的影响均与一次执行的影响相同。**幂等函数,或幂等方法,是指可使用相同参数重复执行,并能得到相同结果的函数。这些函数不会影响系统状态,也不用担忧重复执行会对系统形成改变。例如,“setTrue()”函数就是一个幂等函数,不管屡次执行,其结果都是同样的,更复杂的操做幂等保证是利用惟一交易号(流水号)实现.

任意屡次执行所产生的影响均与一次执行的影响相同,这是幂等性的核心特色。其实在咱们编程中主要操做就是CURD,其中读取(Retrieve)操做和删除(Delete)操做是自然幂等的,受影响的就是建立(Create)、更新(Update)。

对于业务中须要考虑幂等性的地方通常都是接口的重复请求,重复请求是指同一个请求由于某些缘由被屡次提交。致使这个状况会有几种场景:

  • 前端重复提交:提交订单,用户快速重复点击屡次,形成后端生成多个内容重复的订单。
  • 接口超时重试:对于给第三方调用的接口,为了防止网络抖动或其余缘由形成请求丢失,这样的接口通常都会设计成超时重试屡次。
  • 消息重复消费:MQ消息中间件,消息重复消费。

对于一些业务场景影响比较大的,接口的幂等性是个必需要考虑的问题,例如金钱的交易方面的接口。不然一个错误的、考虑不周的接口可能会给公司带来巨额的金钱损失,那么背锅的确定是程序员本身了。

幂等性实现方式

对于和web端交互的接口,咱们能够在前端拦截一部分,例如防止表单重复提交,按钮置灰、隐藏、不可点击等方式。

可是前端作控制实际效益不是很高,懂点技术的都会模拟请求调用你的服务,因此安全的策略仍是须要从后端的接口层来作。

那么后端要实现分布式接口的幂等性有哪些策略方式呢?主要能够从如下几个方面来考虑实现:

Token机制

针对前端重复连续屡次点击的状况,例如用户购物提交订单,提交订单的接口就能够经过 Token 的机制实现防止重复提交。

主要流程就是:

  1. 服务端提供了发送token的接口。咱们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取token,服务器会把token保存到redis中。(微服务确定是分布式了,若是单机就适用jvm缓存)。
  2. 而后调用业务接口请求时,把token携带过去,通常放在请求头部。
  3. 服务器判断token是否存在redis中,存在表示第一次请求,能够继续执行业务,执行业务完成后,最后须要把redis中的token删除。
  4. 若是判断token不存在redis中,就表示是重复操做,直接返回重复标记给client,这样就保证了业务代码,不被重复执行。

数据库去重表

往去重表里插入数据的时候,利用数据库的惟一索引特性,保证惟一的逻辑。惟一序列号能够是一个字段,例如订单的订单号,也能够是多字段的惟一性组合。例如设计以下的数据库表。

CREATE TABLE `t_idempotent` (
  `id` int(11) NOT NULL COMMENT 'ID',
  `serial_no` varchar(255)  NOT NULL COMMENT '惟一序列号',
  `source_type` varchar(255)  NOT NULL COMMENT '资源类型',
  `status` int(4) DEFAULT NULL COMMENT '状态',
  `remark` varchar(255)  NOT NULL COMMENT '备注',
  `create_by` bigint(20) DEFAULT NULL COMMENT '建立人',
  `create_time` datetime DEFAULT NULL COMMENT '建立时间',
  `modify_by` bigint(20) DEFAULT NULL COMMENT '修改人',
  `modify_time` datetime DEFAULT NULL COMMENT '修改时间',
  PRIMARY KEY (`id`)
  UNIQUE KEY `key_s` (`serial_no`,`source_type`, `remark`)  COMMENT '保证业务惟一性'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='幂等性校验表';
复制代码

咱们注意看以下这几个关键性字段,

  • serial_no:惟一序列号的值,在这里我设置的是经过注解@IdempotentKey来标识请求对象中的字段,经过对他们 MD5 加密获取对应的值。
  • source_type:业务类型,区分不一样的业务,订单,支付等。
  • remark:是由标识字段的拼接成的字符串,拼接符为 “|”。

因为数据创建了 serial_no,source_type, remark 三个字段组合构成的惟一索引,因此能够经过这个来去重达到接口的幂等性,具体的代码设计以下,

public class PaymentOrderReq {

    /** * 支付宝流水号 */
    @IdempotentKey(order=1)
    private String alipayNo;

    /** * 支付订单ID */
    @IdempotentKey(order=2)
    private String paymentOrderNo;

    /** * 支付金额 */
    private Long amount;
}
复制代码

由于支付宝流水号和订单号在系统中是惟一的,因此惟一序列号可由他们组合 MD5 生成,具体的生成方式以下:

private void getIdempotentKeys(Object keySource, Idempotent idempotent) {
    TreeMap<Integer, Object> keyMap = new TreeMap<Integer, Object>();
    for (Field field : keySource.getClass().getDeclaredFields()) {
        if (field.isAnnotationPresent(IdempotentKey.class)) {
            try {
                field.setAccessible(true);
                keyMap.put(field.getAnnotation(IdempotentKey.class).order(),
                        field.get(keySource));
            } catch (IllegalArgumentException | IllegalAccessException e) {
                logger.error("", e);
                return;
            }
        }
    }
    generateIdempotentKey(idempotent, keyMap.values().toArray());
}
复制代码

生成幂等Key,若是有多个key能够经过分隔符 "|" 链接,

private void generateIdempotentKey(Idempotent idempotent, Object... keyObj) {
     if (keyObj.length == 0) {
         logger.info("idempotentkey is empty,{}", keyObj);
         return;
     }
     StringBuilder serialNo= new StringBuilder();
     for (Object key : keyObj) {
         serialNo.append(key.toString()).append("|");
     }
     idempotent.setRemark(serialNo.toString());
     idempotent.setSerialNo(md5(serialNo));
 }
复制代码

一切准备就绪,则可对外提供幂等性校验的接口方法,接口方法为:

public <T> void idempotentCheck(IdempotentTypeEnum idempotentType, T keyObj) throws IdempotentException {
    Idempotent idempotent = new Idempotent();
    getIdempotentKeys(keyObj, idempotent );
    if (StringUtils.isBlank(idempotent.getSerialNo())) {
        throw new ServiceException("fail to get idempotentkey");
    }
    idempotentEvent.setSourceType(idempotentType.name());
    try {
        idempotentMapper.saveIdempotent(idempotent);
    } catch (DuplicateKeyException e) {
        logger.error("idempotent check fail", e);
        throw new IdempotentException(idempotent);
    }
}
复制代码

固然这个接口的方法具体在项目中合理的使用就看项目要求了,能够经过@Autowire注解注入到须要使用的地方,可是缺点就是每一个地方都须要调用。我我的推荐的是自定义一个注解,在须要幂等性保证的接口上加上该注解,而后经过拦截器方法拦截使用。这样简单便不会形成代码侵入和污染。

另外,使用数据库防重表的方式它有个严重的缺点,那就是系统容错性不高,若是幂等表所在的数据库链接异常或所在的服务器异常,则会致使整个系统幂等性校验出问题。若是作数据库备份来防止这种状况,又须要额外忙碌一通了啊。

Redis实现

上面介绍过防重表的设计方式和伪代码,也说过它的一个很明显的缺点。因此咱们另外介绍一个Redis的实现方式。

Redis实现的方式就是将惟一序列号做为Key,惟一序列号的生成方式和上面介绍的防重表的同样,value能够是你想填的任何信息。惟一序列号也能够是一个字段,例如订单的订单号,也能够是多字段的惟一性组合。固然这里须要设置一个 key 的过时时间,不然 Redis 中会存在过多的 key。具体校验流程以下图所示,实现代码也很简单这里就不写了。

因为企业若是考虑在项目中使用 Redis,由于大部分会拿它做为缓存来使用,那么通常都会是集群的方式出现,至少确定也会部署两台Redis服务器。因此咱们使用Redis来实现接口的幂等性是最适合不过的了。

状态机

对于不少业务是有一个业务流转状态的,每一个状态都有前置状态和后置状态,以及最后的结束状态。例如流程的待审批,审批中,驳回,从新发起,审批经过,审批拒绝。订单的待提交,待支付,已支付,取消。

以订单为例,已支付的状态的前置状态只能是待支付,而取消状态的前置状态只能是待支付,经过这种状态机的流转咱们就能够控制请求的幂等。

public enum OrderStatusEnum {

    UN_SUBMIT(0, 0, "待提交"),
    UN_PADING(0, 1, "待支付"),
    PAYED(1, 2, "已支付待发货"),
    DELIVERING(2, 3, "已发货"),
    COMPLETE(3, 4, "已完成"),
    CANCEL(0, 5, "已取消"),
    ;

    //前置状态
    private int preStatus;

    //状态值
    private int status;

    //状态描述
    private String desc;

    OrderStatusEnum(int preStatus, int status, String desc) {
        this.preStatus = preStatus;
        this.status = status;
        this.desc = desc;
    }

    //...
}
复制代码

假设当前状态是已支付,这时候若是支付接口又接收到了支付请求,则会抛异常或拒绝这次处理。

总结

经过以上的了解咱们能够知道,针对不一样的业务场景咱们须要灵活的选择幂等性的实现方式。

例如防止相似于前端重复提交、重复下单的场景就能够经过 Token 的机制实现,而那些有状态前置和后置转换的场景则能够经过状态机的方式实现幂等性,对于那些重复消费和接口重试的场景则使用数据库惟一索引的方式实现更合理。


我的公众号:JaJian

欢迎长按下图关注公众号:JaJian!

按期为你奉上分布式,微服务等一线互联网公司相关技术的讲解和分析。


1557975294786730.png

相关文章
相关标签/搜索