关于后端:日均数亿推送稳定性监控实践

8次阅读

共计 9958 个字符,预计需要花费 25 分钟才能阅读完成。

​前言:

得物音讯核心每天推送数亿音讯给得物用户,每天疏导数百万的无效用户点击,为得物 App 提供了弱小,高效且低成本的用户触达渠道。这么宏大的零碎,如何去监控零碎的稳定性,保障故障尽早发现,及时响应至关重要。为此,咱们搭建了得物音讯核心 SLA 体系,相干架构如图:

本文次要介绍咱们如何实现 SLA 监控体系,并一步步重构优化的,作为过来工作的经验总结分享给大家。

1. 背景

得物音讯核心次要承接上游各业务的音讯推送申请,如营销推送、商品推广、订单信息等。音讯核心承受业务申请后,会依据业务需要去执行【音讯内容测验,防疲劳,防反复,用户信息查问,厂商推送】等节点,最初再通过各手机厂商及得物自研的在线推送通道触达用户。整体推送流程简化如下:

咱们心愿可能对各个节点提供精确的 SLA 监控指标和告警能力,从而实现对整体零碎的稳定性保障。上面是咱们设计的局部指标:

  • 监控指标

    • 节点推送量
    • 节点推送耗时
    • 节点耗时达标率
    • 整体耗时达标率
    • 节点阻塞量
    • 其余指标
  • 告警能力

    • 节点耗时告警
    • 节点阻塞量告警
    • 其余告警能力

那咱们如何实现对这些指标的统计呢?最简略的计划就是在每个节点的入口和进口减少统计代码,如下:

这就是咱们的  计划 0  咱们用这种计划疾速落地了 SLA- 节点推送数量统计的开发。然而这个计划的毛病也很显著,思考以下几个问题:

  • 如何实现另一个 SLA 指标开发?比方节点耗时统计,也须要在每个节点的办法外部减少统计代码。
  • 如果有新的节点须要做 SLA 统计,怎么办?须要把所有的统计指标在新节点下面再实现一遍。如何防止 SLA 统计逻辑异样导致推送主流程失败?到处加 try{}catch() 去捕捉异样吗?
  • 如何分工?除了节点耗时统计外还有很多其余指标要实现。最简略的分工形式就是依照 SLA 指标来分工,各自领几个指标去开发,问题在于各个指标的统计逻辑都耦合在一起,依照统计指标来分工事实上变的不可能。

我的项目开发不好分工,通常意味着代码耦合度过高,是典型的代码坏滋味,须要及时重构。

2. 痛点和指标

从下面的几个问题登程,咱们总结出  计划 0  的几个痛点,以及咱们后续重构的指标。

2.1 痛点

  • 监控节点不清晰。音讯推送服务波及多个不同的操作步骤。这些步骤咱们称之为节点。然而这些节点的定义并不明确,只是咱们团队外部约定俗成的一些概念。这就导致日常沟通和开发中有很多含糊空间。

在我的项目开发过程中,常常会碰到长时间的争执找不到解法。起因往往是大家对根底的概念了解没有买通,各说各话。这时候要果决进行争执,首先对基本概念给出统一的定义,而后再开始探讨。含糊的概念是高效沟通的死敌。

  • 保护艰难。

    • 每个节点的统计都须要批改业务节点的代码,统计代码扩散在整个零碎的各个节点,保护起来很麻烦;
    • 同时推送流程的次要逻辑被吞没在统计代码中。典型的代码如下,一个业务办法中可能有三分之一是 SLA 统计的代码。
protected void doRepeatFilter(MessageDTO messageDTO) {
    // 业务逻辑:防反复过滤
    //...
    // 业务逻辑:防反复过滤

    if (messageSwitchApi.isOpenPushSla && messageDTO.getPushModelList().stream()
        .anyMatch(pushModel -> pushModel.getReadType().equals(MessageChannelEnums.PUSH.getChannelCode()))) {messageDTO.setCheckRepeatTime(System.currentTimeMillis());
        if (messageDTO.getQueryUserTime() > 0) {long consumeTime = messageDTO.getCheckRepeatTime() - messageDTO.getQueryUserTime();
            //SLA 耗时统计逻辑
            messageMonitorService.monitorPushNodeTimeCost(MessageConstants.MsgTimeConsumeNode.checkRepeatTime.name(), consumeTime, messageDTO);
        }
    }

}
  • 影响性能

    • SLA 监控逻辑都在推送线程中解决,有些监控统计比拟耗时,升高了推送效率。
    • 统计代码会频繁的写 Redis 缓存,对缓存压力较大。最好是能把局部数据写入本地缓存,定时去合并到 Redis 中。
  • 难以扩大

    • 新的节点须要监控时,没方法疾速接入,须要到处复制监控逻辑。
    • 新的监控指标要实现的话,须要批改每个业务节点的代码。

2.2 指标

理清问题之后,针对零碎既有的缺点,咱们提出了以下的重构指标:

  • 主流程的归主流程,SLA 的归 SLA。

    • SLA 监控代码从主流程逻辑中剥离进去,彻底防止 SLA 代码对主流程代码的净化。
    • 异步执行 SLA 逻辑计算,独立于推送业务主流程,避免 SLA 异样拖垮主流程。
  • 不同监控指标的计算互相独立,防止代码耦合。
  • 实现 SLA 监控代码一次开发,到处复用。

    • 疾速反对新监控指标的实现。
    • 复用已有监控指标到新的节点,现实的形式是在节点办法上加个注解就能实现对该节点的统计和监控。

3. 分步解题

3.1 节点定义

SLA 是基于节点来实现的,那么节点的概念不容许有含糊的空间。因而在重构开始之前,咱们把节点的概念通过代码的模式固定下来。

public enum NodeEnum {MESSAGE_TO_PUSH("msg","调用推送接口"),
    FREQUENCY_FILTER("msg","防疲劳"),
    REPEAT_FILTER("push","防反复"),
    CHANNEL_PUSH("push","手机厂商通道推送"),
    LC_PUSH("push","自研长连推送")
    // 其余节点...
    }

3.2 AOP

接下来思考解耦主流程代码和 SLA 监控代码。最间接的形式当然就是 AOP 了。

以上是节点耗时统计的优化设计图。把每个推送节点作为 AOP 切点,把每个节点上的 SLA 统计迁徙到 AOP 中对立解决。到这里,就实现了 SLA 代码和主流程代码的解耦。但这还只是万里长征第一步。如果有其余的统计逻辑要实现怎么办?是否要全副沉积在 AOP 代码外面?

3.3 观察者模式

SLA 有很多个统计指标。咱们不心愿把所有指标的统计逻辑都沉积在一起。那么如何进行解耦呢?答案是观察者模式。咱们在 AOP 切点之前收回节点进入事件(EnterEvent), 切点退出之后收回节点退出事件(ExitEvent)。把各个指标统计逻辑形象成节点事件处理器。各个处理器去监听节点事件,从而实现统计指标间的逻辑解耦。

3.4 适配器

这里还须要思考一个问题。各个节点的出参和入参都不统一,咱们如何能力把不同节点的出入参对立成 event 对象来散发呢?如果咱们间接在 AOP 代码中去判断切点所属的节点,并取出该节点的参数,再去生成 event 对象,那么 AOP 的逻辑复杂度会迅速收缩,并且须要常常变动。比拟好的形式是利用适配器模式。AOP 负责找到切点对应的适配器,由适配器去负责把节点参数转为 event 对象,于是计划演变如下:

4. 整体计划

当初咱们对每个问题都找到了相应的解法,再把这些解法串起来,造成残缺的重构计划。重构之后,SLA 逻辑流程如下:

到这里计划整体设计实现。在入手实现之前,还须要和计划相干成员同步设计方案,梳理潜在危险,并进行分工。波及到全流程的重构,光有纸面的计划,很难保障计划评估的完整性和有效性。咱们心愿可能验证计划可行性,尽早的裸露计划的技术危险,保障我的项目相干小伙伴对计划的了解没有大的偏差。这里举荐一个好的形式是提供计划原型。

4.1 原型

计划原型是指对既有计划的一个最简略的实现,用于技术评估和计划阐明。

正所谓 talk is cheap, show me the code。对程序员来说,方案设计的再完满,也没有可运行的代码有说服力。咱们大略花了两个小时工夫基于现有设计疾速实现了一个原型。原型代码如下:

  • AOP 切面类 EventAop,负责把加强代码织入切点执行前后。
public class EventAop {
    @Autowired
    private EventConfig eventConfig;

    @Autowired
    private AdaptorFactory adaptorFactory;
    @Around("@annotation(messageNode)")
    public Object around(ProceedingJoinPoint joinPoint, MessageNode messageNode) throws Throwable {
        Object result = null;

        MessageEvent enterEvent = adaptorFactory.beforProceed(joinPoint, messageNode);
        eventConfig.publishEvent(enterEvent);
        result = joinPoint.proceed();
        MessageEvent exitEvent = adaptorFactory.postProceed(joinPoint, messageNode);
        eventConfig.publishEvent(exitEvent);
        return result;
    }
 }

  • 事件配置类 EventConfig, 这里间接应用 Spring event 播送器,负责散发 event。
public class EventConfig {

    @Bean
    public ApplicationEventMulticaster applicationEventMulticaster() { //@1
        // 创立一个事件播送器
        SimpleApplicationEventMulticaster result = new SimpleApplicationEventMulticaster();
        return result;
    }

    public void publishEvent(MessageEvent event) {this.applicationEventMulticaster().multicastEvent(event);
    }
}
  • MessageEvent, 继承 Spring event 提供的 ApplicationEvent 类。
public class MessageEvent extends ApplicationEvent {}
  • 节点适配器工厂类,获取节点对应的适配器,把节点信息转换为 MessageEvent 对象。
public class AdaptorFactory {
    @Autowired
    private DefaultNodeAdaptor defaultNodeAdaptor;

    // 反对切点之前产生事件
    public MessageEvent beforeProceed(Object[] args, MessageNode messageNode) {INodeAdaptor adaptor = getAdaptor(messageNode.node());
        return adaptor.beforeProceedEvent(args, messageNode);
    }

    // 反对切点之后产生事件
    public MessageEvent afterProceed(Object[] args, MessageNode messageNode, MessageEvent event) {INodeAdaptor adaptor = getAdaptor(messageNode.node());
        return adaptor.postProceedEvent(args, event);
    }

    private INodeAdaptor getAdaptor(NodeEnum nodeEnum) {return defaultNodeAdaptor;}
}

4.2 技术审查

在整体计划和原型代码的根底上,咱们还须要审查计划中所用的技术,是否有危险,评估这些危险对既有性能,分工排期等的影响面。比方咱们这边用到的次要是 Spring AOP,Spring Event 机制,那么他们可能潜在以下问题,须要在开发前就做好评估的:

  • Spring  AOP 的问题:Spring AOP 中公有办法无奈加强。bean 本人调用本人的 public 办法也无奈加强。
  • Spring Event 的问题:默认事件处理和事件散发是在同一个线程中运行的,实现时须要配置 Spring 事件线程池,把事件处理线程和业务线程分隔开。

潜在的技术问题要充沛沟通。每个成员的技术背景不同,你眼里很简略的技术问题,可能他人半天就爬不进去。计划设计者要充沛预知潜在的技术问题,提前沟通,防止无谓的问题排查,进而晋升开发效率。

4.3 老本收益剖析

  • 老本 

一套残缺的计划思考的不仅仅是技术可行性,还须要思考实现老本。咱们通过一个表格来简略阐明我此次重构前后的老本比照。

  • 收益

    • 代码清晰。SLA 统计逻辑和流程逻辑解耦。SLA 各个指标的统计齐全解耦,互不依赖。
    • 晋升开发效率。SLA 指标统计一次开发,到处复用。只有在须要监控的代码上加上节点注解。
    • 进步性能。SLA 逻辑在独立的线程中执行,不影响主流程。
    • 进步稳定性。SLA 逻辑和主流程解耦,SLA 频繁变更也不影响主流程代码,也不会因为 SLA 异样拖垮主流程。
    • 不便分工排期。重构也解决了不好分工的难题。因为各个指标通过重构实现了逻辑隔离,实现时齐全能够独立开发。因而咱们能够简略的依照 SLA 统计指标来安顿分工。

代码重构最难的不是技术,而是决策。决定零碎是否要重构,何时重构才是最难的局部。往往一个团队会破费大量工夫去纠结是否要重构,然而到最初都没人敢做出最终决策。之所以难是因为不足决策资料。能够思考引入老本收益表等决策工具,对重构进行定性、定量分析,帮忙咱们决策。

5. 避坑指南

实现的过程也碰到的一些比拟有意思的坑,上面列出来供大家参考。

5.1 AOP 生效

Spring AOP 应用 cglib 和 jdk 动静代理实现对原始 bean 对象的代理加强。不论是哪种形式,都会为原始 bean 对象生成一个代理对象,咱们调用原始对象时,实际上是先调用代理对象,由代理对象执行切片逻辑,并用反射去调用原始对象的办法,实际上运行如下代码。

public static Object invokeJoinpointUsingReflection(@Nullable Object target, Method method, Object[] args) throws Throwable {
      // 应用反射调用原始对象的办法
     ReflectionUtils.makeAccessible(method);
     return method.invoke(target, args);
}

这时候如果被调用办法是原始对象自身的办法,那就不会调用代理对象了。这就导致代理对象逻辑没有执行,从而不触发代码加强。具体原理参考以下例子,假如咱们有一个服务实现类 AServiceImpl,提供了 method1(), method2() 两个办法。其中 method1() 调用了 method2()。

@Service("aService")
public class AServiceImpl implements AService{@MessageNode(node = NodeEnum.Node1)
    public void method1(){this.method2();
    }
    @MessageNode(node = NodeEnum.Node2)
    public void method2(){}
}

咱们的 AOP 代码通过 @MessageNode 注解作为切点织入统计逻辑。

@Component("eventAop")
@Aspect
@Slf4j
public class EventAop {@Around("@annotation(messageNode)")
    public Object around(ProceedingJoinPoint joinPoint, MessageNode messageNode) throws Throwable {
        /**
            节点开始统计逻辑...
        **/
        // 执行切点
        result = joinPoint.proceed();
        /**
            节点完结统计逻辑...
        **/

        return result;
    }
   }

Spring 启动的时候,IOC 容器会为 AserviceImpl 生成两个实例,一个是原始 AServiceImpl 对象,一个是加强过后的 ProxyAserviceImpl 对象,办法 method1 的调用过程如下,能够看到从 method1() 去调用 method2() 办法的时候,没有走代理对象,而是间接调用了指标对象的 method2() 办法。

5.1.1 解决办法

  • 注入代理对象 aopSelf,被动调用代理对象 aopSelf 的办法。

示例代码如下:

@Service("aService")
public class AServiceImpl implements AService{
    @Autowired
    @Lazy
    private AService aopSelf;

    @MessageNode(node = NodeEnum.Node1)
    public void method1(){aopSelft.method2();
    }
    @MessageNode(node = NodeEnum.Node2)
    public void method2(){}
}
  • 以上办法治标不治本。咱们从头探索为何会呈现自调用的代码加强?起因是咱们要对两个不同的节点进行 SLA 统计加强。然而这两个节点的办法定义在同一个 Service 类当中,这显然违反了编码的繁多性能准则。因而更好的形式应该是形象出一个独自的类来解决。代码如下:
@Service("aService")
public class AServiceImpl implements AService{
    @Autowired
    private BService bService;

    @MessageNode(node = NodeEnum.Node1)
    public void method1(){bService.method2();
    }
}

@Service("bService")
public class BServiceImpl implements BService{@MessageNode(node = NodeEnum.Node2)
    public void method2(){}
}

重构可能帮忙咱们发现并且定位代码坏滋味,从而领导咱们对坏代码进行从新形象和优化。

5.2 通用依赖包

实现中碰到的另一个问题,是如何提供通用依赖。因为音讯核心内局部很多不同的微服务,比方咱们有承接内部业务推送申请的 Message 服务,还有把业务申请转发给各个手机厂商的 Push 服务,还有推送达到后,给得物 App 打上小红点的 Hot 服务等等,这些服务都须要做 SLA 监控。这时候就须要把现有的计划形象出公共依赖,供各服务应用。

咱们的做法是把【节点定义,AOP 配置,Spring Event 配置,节点适配器接口类】形象到 common 依赖包,各个服务只须要依赖这个 common 包就能够疾速接入 SLA 统计能力。

这里有一个比拟有意思的点,像【AOP 配置,Spring Event 配置,节点适配器】这些 Bean 的配置,是要凋谢给各个服务本人配置,还是间接在 common 包里默认提供配置?比方上面的这个 Bean 配置,决定了 Spring Event 解决线程池的要害配置项,如外围线程数,缓存池大小。

@Bean
public ThreadPoolExecutorFactoryBean applicationEventMulticasterThreadPool() {ThreadPoolExecutorFactoryBean result = new ThreadPoolExecutorFactoryBean();
    result.setThreadNamePrefix("slaEventMulticasterThreadPool-");
    result.setCorePoolSize(5);
    result.setMaxPoolSize(5);
    result.setQueueCapacity(100000);
    return result;
}

这个配置交给各个服务本人治理,灵活性会高一点,但同时意味着服务对 common 包的应用老本也高一点,common 包使用者须要本人去决定配置内容。绝对的,在 common 包中间接提供呢,灵活性升高,然而用起来不便。前面参照 Spring Boot 约定大于配置的设计规范,还是决定间接在 common 包中提供配置,逻辑是各个服务对这些配置的需要不会有太大差异,为了这点灵活性晋升应用老本,不是很有必要。当然如果后续有服务的确须要不同的配置,还能够依据实际需要灵便反对。

约定大于配置,也能够叫做约定优于配置(convention overconfiguration),也称作按约定编程,是一种软件设计范式,指在缩小软件开发人员需做决定的数量,取得简略的益处,而又不失灵活性。

咱们都晓得 Spring, Spring Boot 的理念很先进,而实际中可能借鉴先进理念领导开发实际也算是一种工程人员的幸福,正如孔老夫子所说:就有道而正焉,堪称好学矣。

5.3 业务后果

代码实现之后,又进行了性能压测,线上灰度公布,线上数据察看等等步骤,在这里就不再赘述。那么 SLA 技术演进到这里,为咱们的推送业务获取了哪些业务后果呢?上面提供比拟典型的后果。

音讯推送服务会调用手机厂商的推送服务接口,咱们要监控厂商推送接口的耗时性能怎么办呢?在重构之前,咱们须要在各个厂商推送接口之前和之后减少统计代码,计算耗时并写入缓存。在重构之后,咱们要做的,只是简略的增加一个节点注解即可实现。比方咱们想统计 OPPO 推送接口的 SLA 指标,只需增加如下注解:

@MessageNode(node = NodeEnum.OPPO_PUSH, needEnterEvent = true)
public MessageStatisticsDTO sendPush(PushChannelBO bo) {if (bo.getPushTokenDTOList().size() == 1) {return sendToSingle(bo);
    } else {return sendToList(bo);
    }
}

而后咱们在控制台就能很快发现 OPPO 推送的统计信息。比方咱们看管制台上 OPPO 推送瓶颈耗时 20s,那阐明 OPPO 的推送连贯必定有超时,连接池的配置须要优化。

除了监控指标,咱们还反对实时告警,比方上面这个节点阻塞告警,咱们可能及时发现零碎中沉积多的节点,迅速排查是否节点有卡顿,还是上游调用量猛增,从而把潜在的线上问题扼杀在摇篮之中。

6. 展望未来

SLA 上线一周之内,咱们就曾经依赖这套技术发现零碎中的潜在问题,然而事实上对 SLA 的业务收益咱们齐全能够有更大的设想空间。比如说:

  • 咱们目前监控的次要还是技术上的指标,像节点耗时,节点阻塞等。后续咱们能够把业务相干的统计也反对上,咱们能够迅速定位是哪个业务方的调用导致系统阻塞等等业务性能数据。
  • 其次咱们能够统计各业务推送的 ROI 数据,目前音讯服务反对数亿的音讯,然而这外面哪些推送收益高,哪些收益低目前是没有明确的指标的。咱们能够收集各业务的推送量,点击量等信息去计算业务推送的 ROI 指标。
  • 当咱们有了业务性能数据,业务 ROI 指标,咱们就有机会对业务推送做精细化的管控,比方明天推送资源缓和,我是否能够暂缓低 ROI 的业务推送。比方高 ROI 推送明天曾经触达过用户,咱们是否能够勾销当天的相似推送,避免打搅用户等等。

这些都是音讯核心 SLA 可能为业务进行推送赋能的方向,而且这些方向能够基于目前的 SLA 技术架构迅速低成本的落地,真正实现技术服务于业务,技术推动业务。

7. 总结

以上是音讯核心 SLA 重构演进的整个过程。对于音讯服务来说,SLA 的开发没有止境,咱们要继续关注零碎的外围指标,不断完善监控工具。正因为如此,咱们更须要夯实 SLA 的技术根底,在灵便轻量的技术底座下来实现更简单的性能。回过头看这个重构过程,咱们也总结了以下一些教训供大家参考。

  • 不要胆怯重构,也不要适度设计。重构的目标不在于炫技,而在于解决理论问题,进步开发效率。
  • 要有原型。简单的设计往往难以发展,解决办法是从最小实际开始。计划原型就是方案设计的最小实际,有了原型之后再审查设计,演进计划会不便很多。
  • 对技术充沛掌控。预知所用技术的潜在危险,保障有足够的技术能力去解决。并且要把危险提前裸露给团队成员,缩小踩坑几率,防止无谓的开发调试老本。

 * 文 / 吴国锋
@得物技术公众号

正文完
 0