应对系统重启的轮子-断点重跑

168 阅读9分钟

一、问题背景

商品模块.对接三方系统商品(简称为买手店),由于三方系统的商品类目品牌各不相同,与我司标准类目差别较大,因此在录入三方的商品录入到我方系统进行上架前,需要进行品牌类目数据映射,映射后生成我方标准类目商品

举例说明:

image.png

人工维护一张类目映射关系表.

1)当买手店2商品(man_shoe),录入我方系统时,生成了一个标准商品,并且根据映射关系表查出man_shoe的正确类目划分应当是男鞋,于是我方系统生成了一个新商品,上架标准商品2(男鞋)

2)买手店的类目有时候是错误的,或者映射关系发生错误时,需要重新梳理映射关系,同时需要调整 已经生成的我方系统上架标准商品的类目.这里存在一个清洗数据的过程.

3)有时候需要清洗数据的商品数量很大,达到了十几万,因此刷新数据的时间较长

4)在清洗数据的过程中,由于应用发布,导致清洗数据的过程被中断,数据出现问题

为此需要能有一种解决方案:

针对这种批量耗时长的场景,

1)需要记录 谁、什么时间、启动了什么任务、是否执行完了,影响多少数据,

2)并且,如果因为系统重启等原因导致当时的操作失败了,能有一种补救错误,保证数据最后被全部处理完

二、解决方案

概念介绍

源方法:java中的方法

方法快照: 数据库表中的一条记录,保存了源方法的关键数据(className,methodName,argList),依据这些信息可以通过反射再次出发源方法的执行

方案

提供一种方式,能让被中断的操作重复执行,进而保证数据最终全部处理完.

通过保存业务代码源方法的关键信息,生成方法快照,保存到数据库中,对于执行被中断的方法,可以通过方法快照, 再次调用源方法,从而保证数据最终正确性.

为了便于使用,所以使用注解式的设计,只需要在想要支持断点续跑功能的方法上,添加一个注解,就能实现断点续跑.

三、使用方式

依赖项:

必须是spring项目, 这是由于使用到了spring的aop和容器工厂类,同时还使用到了redis和oss (oss: 阿里云的云存储系统. 可以上传文件到阿里云,然后阿里云会返回一个htpp链接,通过这个链接可以下载当时上传的源文件)

使用步骤:

1)使用@ReInvokeAble注解,来支持断点续跑

2)使用@ShouldSerialize注解来标注 长度过长的对象或者参数. 如果确定参数不会过长,则不需要加这个注解

下面观察一下一个实例:

@ReInvokeAble 可以指定bizKey ,并且bizKey支持spel表达式取值, bizKey被用来作为唯一key,保证唯一性

@ShouldSerialize注解的参数序列化到oss进行存储,数据库保存返回的oss地址.

对于如下方法

    @PostMapping(value = "/testReinvoke")
    @ReInvokeAble(title = "断点续跑", bizKey = "'testReinvoke'")
    public Result<Boolean> testReinvoke(@RequestBody @ShouldSerialize List<ReInvokeReq> reInvokeReq, @RequestParam("name") String name) {
        System.out.println("====== "+JsonUtilsV2.serialize(reInvokeReq));
        System.out.println("====== "+name);
        return Result.ofSuccess(true);
    }

数据库保存的方法快照的内容主要如下:

{
  
    "className": "com.poizon.connector.controller.backdoor.BhInvokeController", //类名称
    "methodName": "testReinvoke" //方法名称
  
    "argList": [  //方法参数信息 共两个参数
        
      //这里是第一个参数 List<ReInvokeReq> reInvokeReq
     	 	{
            "paramClassName": "java.util.List",  //参数类型
            "paramName": "reInvokeReq",  //参数名称
            "paramValue": "http://oversea-dev.oss-cn-shanghai.aliyuncs.com/upload/temp_file/file_20220213140508.tmp", //参数值。(由于是加了@ShouldSerialize,所以实际内容被传到oss,这里存了oss 文件地址)
            "parameterizedTypes": [
                "com.poizon.connector.controller.backdoor.BhInvokeController$ReInvokeReq"  //泛型参数化类型, 主要是为了解决泛型的反序列化问题(反序列化默认会转成list或者map,并不会转成bean类型,所以这里保存了bean类型)
            ]
       	},
      
      //这里是第二个参数   String name
        {
            "paramClassName": "java.lang.String", //参数类型
            "paramName": "name", //参数名称
            "paramValue": "bbh"//参数值
            // 由于这里没有用到泛型,parameterizedTypes是null,所以序列化后没有parameterizedTypes这个字段
        }
    ],

}

特别要注意的是第一个参数里面paramValue 的值是oss地址, 这是由第一个参数reInvokeReq 被加上了注解@ShouldSerialize,表示这个参数可能会过长,因此不能直接存在数据库,先存到文件,数据库里面保存文件地址.

打开这个文件,里面的内容是

[{"idList":["1","2"],"reInvokeBeanList":[{"merchantId":"merchantId","merchantName":"merchantName"}]}]

四、原理介绍

核心思想

1)使用aop进行方法快照的记录,

2)使用单独的 快照心跳发送线程1(简称心跳线程1) 按照方法快照维度发送心跳到redis

3)使用单独的 快照状态检测线程2 (简称检测线程2)对需要重复调用的快照进行重复调用

具体的过程为:

1)切点方法调用之前,记录下方法执行时候的方法快照,保存方法快照到数据库,方法快照的状态为“执行中”,快照心跳发送线程开始发送心跳到redis

2)切点方法执行成功,则更新方法快照的状态为“执行结束”,心跳线程1会主动移除该心跳

切点方法执行异常,则更新方法快照的状态为“执行失败”,心跳线程2会主动移除该心跳

如果因为宕机重启等原因,造成切点方法被中断,则方法快照的状态一直是“执行中”,心跳线程1被强制销毁,心跳因为超时而中断

3)这时候如果系统 重启了,检测线程2,会检测出异常的快照(redis中未查询到心跳),然后进行重复调用.执行完成,更新方法快照的状态为“执行结束”或“执行失败”

实现上述的功能,实际上要解决如下几个技术点:

1)如何记录方法调用的快照

使用aop方式保存调用快照,存储到快照表 , 这里的难点是要注意参数的类型要保留住,特别是对于泛型参数.应当确保能被正确保存和解析.

因此类型这里同时使用到class和**ParameterizedType这两种类型.参考文章 如何解析泛型参数>

2)方法参数很长,是一个对象List或者是一个文件流时,Mysql字段无法完全保存,要解决这个问题

序列化写入到文件中,存到oss(其他思路也有,比如保存到非关系型数据库等等,这里不推荐).

同时,也要考虑到序列化的数据可以被正确序列化和反序列化解析出来.上面说的ParameterizedType,实际上就是为了保证泛型方法能被正确反序列化才需要做的特殊处理.

除此之外,也有一些场景是无法序列化的.具体参考后文的“局限性”描述

3)快照支持自动和手动重复调用

哪些快照属于需要被重复调用的?

在@ReInvokeAble的aop中,会为每个快照生成key,心跳线程1收集这个key,以每30s为周期,往redis发送快照的心跳.

检测线程2会在系统刚启动后检测一次心跳,后续每10分钟检测一次心跳

检测线程2发现一个快照的状态处于执行中,并且已经过了两个心跳间隔还没有发送心跳时,我们认为这个快照的执行节点已经下线,这个心跳是异常的,这个快照需要被再次执行.

4)如何识别一个方法的调用是源方法调用还是快照方法的调用

在aop中存在ReInvokeContext, 如果是是快照触发的调用,ReInvokeContext里面的CURRENT_REINVOKE_ID是非0,存储的是快照的主键id,但如果是源方法触发的调用,CURRENT_REINVOKE_ID就是0

5)原方法的调用需要进行验重,避免多次被调用,浪费资源

通过spel表达式注入 唯一健,进行验重.

  1. 先获取方法的形参名称 (java 编译器在编译代码成class文件后,不保留原来的参数名称的,获取形参名称,需要使用LocalVariableTableParameterNameDiscoverer,参考文章 如何获取形参名称)
  2. 再解析注解中的的spel表达式(SpelExpressionParser),获取到唯一键

6)方法快照的调用需要验重,保证调用唯一性,避免多个节点同时获取到同一个快照,导致该方法快照被执行了多次

使用分布式锁,保证所有快照在一个节点执行(其他方案:使用redis的 list数据结构保证多节点不会重复消费同一个快照)

7)重复调用需要设置上限,默认最多重试三次,则不再自动重试,需要人工处理

8)如何记录影响的记录行数?

实际上框架 里面提供了一个钩子函数 ,允许业务代码主动调用,将想要记录的业务信息写到快照表里面的扩展字段

五、平台化设想

断点重跑当前是一个sdk的形式,引入相关包,建一张方法快照表,即可实现功能. 但如果要支持多个项目,也可以考虑将其做成服务化或者平台化.

在客户端,保存方法快照信息(因为该部分数据属于应用私有信息)

在中心端,建设一张映射关系表,用来关联客户端的方法快照,控制心跳发送、快照状态监控,由服务端调用客户端接口,触发快照调用的逻辑

六、局限性

  1. 主要是记录跟踪同步调用方法的状态
  2. 接口或方法幂等,重复调用时数据仍然正常
  1. 由于是通过反射进行方法调用,因此方法签名如果变更了,则会调用失败.
  2. 支持的参数类型有哪些,主要支持的是java基本的数据类型和常规的对象类型

一些复杂的数据类型是不支持的:

  1. 包含通配符的参数, ImportBO<T, E> importBO

  2. 函数式参数

    @Data
    public static class ReInvokeReq {
        private List<String> idList;
        private List<ReInvokeBean> reInvokeBeanList;
    }

    @Data
    public static class ReInvokeBean {
        private String merchantId;
        private String merchantName;

        private Function function; //函数式参数,序列化和反序列化会有问题
    }