网易程序化广告业务系统中的可靠通知系统设计与使用

达芬奇密码2018-07-06 18:17
  • 需求

    NEX系统有很多需要调用外部系统进行通知的需求,比如发送email,比如给通过rabbitmq发送消息给运管平台和营销平台,比如给营销平台发送日消耗和充值记录。这些通知有如下特点:
  1. 主流程不依赖于通知返回结果,即通知失败与否不影响主流程的完成
  2. 通知应该在主流程完成之后再发出,需要避免出现主流程回滚而通知已经被发出的情况
  3. 通知需要被可靠送达,任何情况下不应该存在通知没有被送达的情况存在
  4. 接收方具有消息幂等性,即我们只关注通知是否被送达,对于通知是否可能被重复发送,我们不做保证,通知的接收方需要采取措施保证幂等性

 

  • 挑战

  1. 为了不影响主流程的完成,我们应该在主流程事务提交之后再发送消息。但是通常这一点在程序中处理比较困难,比如要在Action中处理,相关的参数需要从service层传输到controller层
  2. 主流程一旦完成就必须确保消息要被发出,不能允许主流程事务已经提交而通知没有被触发的情况。很多意外情况可能导致主流程已经提交而通知没有发出,比如服务器突然宕机,网络出现问题,数据库连接不上,数据库宕机等
  3. 如果发送失败应该有可以定时重发的机制。这就需要开发定时任务及将通知记录到相关的存储介质中以便可以定时重发,如果重试多次失败还需要报警等。如果这样的逻辑每次都要开发一遍,将造成许多重复的工作量。并且每个这样的通知接口就设计一张表,一个定时任务也不可行,会造成系统难以维护
  4. 定时任务触发的通知发送和主流程触发的定时发送还可能存在冲突,需要采用乐观锁等机制以避免无谓的重复发送通知

 

  • 设计思想

  1. 通过使用Spring的TransactionSynchronizationManager将具体的通知操作延迟到当前事务提交后才执行
  2. 在主流程中将需要触发的通知消息保存到消息表中,跟主流程在同一个事务中提交,保证只要主流程提交了,通知消息就被持久化了
  3. 使用定时任务定时检查消息表中是否还存在未发出的消息,如果存在则根据重发策略(如最大重发次数,重发间隔等)进行重发
  4. 在发送消息时检查表中状态和发送次数的值是否发生改变,如果已经发生改变说明该次发送已经被其他程序处理,则取消此次发送
  5. 将消息存储在统一的通知消息表中,消息的参数可以直接以json数据的格式存储到消息字段中。通知消息的重发策略,报警触发条件,具体发送类则统一存放到通知定义表中

 

  • 具体设计

    • 典型流程
      • 表设计

        1. solid_notify_def(通知定义表)

        Field
        Type
        Comment
        id bigint(20) NOT NULL 自增主键
        name varchar(40) NOT NULL 通知名称
        impl_class varchar(400) NOT NULL 通知的实现类
        method varchar(20) NOT NULL 通知的方法名称
        max_retry_times int(11) NOT NULL 最大重试次数,-1表示unlimited
        max_retry_interval int(11) NOT NULL 最大重试间隔,以分钟为单位
        alert_trigger_type tinyint(4) NOT NULL 报警触发类型,0 - 最终失败, 1 - 失败, 2 - NEVER
        email varchar(400) NOT NULL 调用失败后通知人邮件,注意调用失败是指经过若干次重试后超过最大重试次数后失败,逗号分隔
        description varchar(400) NULL 对该通知定义的描述
        retry_interval varchar(400) NOT NULL 重试间隔,可为固定长度或列表或策略类或表达式,s - 秒,m - 分钟,h - 小时, d - 天


        2. solid_notify_msg(通知消息表)

        Field
        Type
        Comment
        id bigint(20) NOT NULL 自增主键
        def_id bigint(20) NOT NULL 通知定义id
        param text NULL 通知参数
        description text NULL 描述信息,如该通知的一些上下文信息
        errors text NULL 调用失败记录的错误信息
        status tinyint(4) NOT NULL 该通知的状态,0 - 未发送, 1 - 发送成功, 2 - 发送失败, 3 - 发送最终失败
        retry_times int(11) NOT NULL 重试次数
        last_call_time datetime NULL 最后调用时间
        nex_call_time datetime NULL 下次执行时间
        create_time datetime NOT NULL 该通知最初创建时间

        3. solid_notify_msg_history(通知消息历史表)

        和通知消息表表结构完全一样,历史表主要用来将已经是成功状态或者最终失败状态的消息从通知消息表中移除,以免通知消息表中积攒太多消息影响性能。 

    • 类设计
      其中addNotice方法实现如下:

      @Override
      @Transactional
      public SolidNotifyMsgWithBLOBs addNotice(String defName, String param, String description) {
          final SolidNotifyMsgWithBLOBs solidNotifyMsgWithBLOBs = new SolidNotifyMsgWithBLOBs();
          SolidNotifyDef solidNotifyDef = getDefByName(defName);
          solidNotifyMsgWithBLOBs.setDefId(solidNotifyDef.getId());
          solidNotifyMsgWithBLOBs.setParam(param);
          solidNotifyMsgWithBLOBs.setDescription(description);
          solidNotifyMsgWithBLOBs.setCreateTime(new Date());
          solidNotifyMsgWithBLOBs.setStatus(SolidNotifyMsgStatus.NOT_SEND.getValue());
          solidNotifyMsgWithBLOBs.setRetryTimes(0);
          solidNotifyMsgWithBLOBs.setNexCallTime(Calendar.getInstance().getTime());
          solidNotifyMsgMapper.insert(solidNotifyMsgWithBLOBs);
          TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
              @Override
              public void afterCommit() {
                  SolidNotifyDef solidNotifyDef = solidNotifyDefMapper.selectByPrimaryKey(solidNotifyMsgWithBLOBs.getDefId());
                  sendNotice(solidNotifyMsgWithBLOBs, solidNotifyDef);
              }
          });
          return solidNotifyMsgWithBLOBs;

      }

    • 时序图
  • 使用方法

  • 1. 在服务方法中需要通知外部系统的地方调用SolidNotifyService::addNotice方法,并将参数转化成json格式
    2. 在服务类或其他类中添加通知回调方法,在回调方法中实际调用外部系统,若调用失败则抛出异常,调用者会自动处理该异常并更新对应的通知消息记录状态,注意回调方法只能有一个SolidNofityMsgWithBLOBs类型的参数
    3. 在solid_notify_def表中增加一条记录,注意如果是服务,对应的impl_class填写的应该是接口名而不是实现类名
  • 应用举例

    1. 目前DspInfoServiceImpl::setDsp方法中需要同步相关dsp信息到运管平台(采用发送rabbitmq消息的方式),在该方法末尾调用了addNotice方法:
    solidNotifyService.addNotice("syncToOmp", JSONObject.toJSONString(dspInfo), "");其中"syncToOmp"为该通知定义的名称
    2. 并增加方法DspInfoServiceImpl::syncToOmp作为回调方法:

    @Override
    public void syncToOmp(SolidNotifyMsgWithBLOBs solidNotifyMsgWithBLOBs) {
        try {
            DspInfo dspInfo = JSONObject.parseObject(solidNotifyMsgWithBLOBs.getParam(), DspInfo.class);
            sendToOmp(dspInfo);
        } catch (Exception e) {
            logger.error("syncToOmp failed", e);
            throw new NexException("syncToOmp failed", e);
        }
    }


    3. 在solid_notify_def表中增加了一条记录:

    id
    name
    impl_class
    method
    max_retry_times
    max_retry_interval
    alert_trigger_type
    email
    description
    retry_interval
    1 syncToOmp com.netease.ad.b.nex.service.dspinfo.DspInfoService syncToOmp 2 -1 0 bjlihaiwu@corp.netease.com 同步dsp信息到运管平台 5m
  • 注意事项与常见问题

    1. solid_notify_msg中的description字段用于存储一些上下文信息,可为空。比方说在执行过程中的一些结果信息希望存储到description中
    2. 在回调方法中抛出异常可以让通知任务以失败结束,这时候会根据重试策略决定重试还是最终失败。也可以主动设置SolidNofityMsgWithBLOBs的status字段为最终失败,此后该通知将不再发送并按照报警策略决定是否触发报警
    3. retry_interval为数组的情况可以用于一些重试间隔不均匀的场合,比如第一次是5秒钟后重试,第二次是5分钟后重试,第三次是一个小时候重试,第四次为一天后重试,则可以定义为[5s, 5m, 1h, 1d],下次执行时间会在当前时间的基础上加上对应次数的重试间隔
    4. 重试间隔目前还不支持表达式和策略类,稍后开发
    5. 回调方法中抛出任何异常都不会导致主流程回滚
    6. controller中如希望立即知道通知消息是否调用成功,可检查addNotice方法返回的通知消息的状态,注意只能将原返回对象的引用直接传递回controller层再检查,而不可在service层检查或者复制,因为在service调用结束后才会执行回调并更新该返回对象

本文来自网易实践者社区,经作者李海武授权发布。