价值

在学习该项目前,应该对其价值有一个清晰的认识,明确学习该项目能够带来哪些提升。

优点

  • 立意:在社区项目中经常会用到定时任务,比如定时进行数据同步、定时处理合并消息请求等,定时微服务可以很好地解决这些问题。因此除了直接调用类似 XXL-JOB 等定时模块,手动实现一个更加轻量化、可扩展的定时任务处理机制就更具有实际意义,有助于深入理解微服务架构设计。

  • 属性:定时场景是一个通用且业务关联性弱的场景,适合做成微服务独立部署,或作为中台服务去维护。

  • 核心:定时服务并不复杂,场景便于理解,但是重点在于定时的精度、任务的吞吐量等问题,能够锻炼对分布式系统的理解。

  • 技术栈:在针对核心问题上,核心组件就是 MySQL、Redis、消息队列(作为异步回调能力)等,技术栈相对简单,便于快速上手。

价值

重点在于架构能力的提高,定时微服务决定采用模块化设计、分治策略等设计理念,能够有效在核心问题上提供较好的解决方案。

背景与现状

了解项目价值后,需要继续对项目在实际业务中出现的场景进行分析,明确项目的背景与现状。

场景

  1. 某类办公软件,每天早上9点通知员工打卡
  2. 订单下单15分钟后,若未支付则自动关闭订单
  3. 红包超时未领取,自动退回到账户余额

理解

针对上述业务场景分析,会发现大多涉及一个定时/周期执行的情况,因此关于“定时”属性可以将其与业务属性剥离,形成类似“闹钟”的服务,至于唤醒某些业务后,需要其单独处理自己的业务逻辑。

现状

定时场景在市面上有较多的解决方案,但实际上:

  1. 大多公司对于定时任务需求并不复杂,重点在于定时,为此引入功能强大但臃肿的任务调度组件是不合适的,前期学习成本、接入成本与后期维护成本都会偏高

  2. 且基于下述表格对比的结果来看,设计功能聚焦、接入轻量、维护成本低且支持动态灵活的任务定时/周期处理的微服务有一定意义

  3. 决定设计一款依赖简单、学习成本较低、性能较优的定时微服务

方案 介绍 特性 不足
Java Timer Java标准库中的定时器工具 1. 简单易用
2. 基于单线程执行任务
• 单线程执行可能导致任务间相互影响
• 不适合执行长时间运行的任务
• 异常处理不足
RocketMq 分布式消息队列系统 1. 基于消息队列的定时任务执行
2. 高可用性和可扩展性
3. 支持延迟消息和定时消息
• 需要额外维护一个消息队列系统
• 延迟精度可能受到网络延迟和系统负载的影响
• 存在极小的消息丢失或重复消费的风险
其他公司内部实现 很多公司内部都有自定定时器的实现,但都只能内部使用,且服务设计多与内部业务特性挂钩 - • 仅面向团队内部使用
elastic-job-lite 轻量级分布式任务调度框架 1. 支持弹性扩展和任务分片
2. 集成简单,配置丰富
3. 基于ZooKeeper协调服务
• 依赖ZooKeeper作为协调服务,增加了系统复杂性
• 对于简单的定时场景可能过于复杂
xxl-job 代表性的自定义任务调度工具 1. 可定制性强
2. 可集成到现有应用
3. 功能灵活
• 学习曲线可能陡峭
• 可能需要额外开发和维护成本
• 对于简单的定时任务需求过于复杂
Quartz 功能强大的任务调度框架 1. 灵活的任务调度
2. 支持集群和持久化
3. 可与数据库结合使用
• 配置和管理相对复杂
• 资源消耗较大,特别是在大规模任务调度时
• 对于简单的定时需求处理过于复杂
Robfig/Cron golang版本github上star最多的开源框架 1. 目前在github中的star最多,得到广泛的使用和测试,评级结果表明,与其他go开源框架相比,它拥有最高的调度准确性 • 设计用于在单个应用实例中执行定时任务
• 不支持分布式任务调度
• 缺乏任务执行结果的监控和告警机制

架构设计

针对架构设计需要做到以下几点:

  1. 充足的准备工作:信息收集、技术调研

  2. 要有成套、成体系的方法论:

    1. 准备阶段:诉求摸底、请求量确认、精准性深究、重难点分析
    2. 设计阶段:上下游服务分析、存储选型、流程串联
    3. 优化阶段:设计复盘、潜在优化点

准备阶段

  1. 诉求摸底:摸清整体诉求,了解清楚是做什么、业务流程是怎样、核心功能点有哪些
  2. 请求量确认:根据实际QPS量级设计,避免过多、过少
  3. 精准性深究:分析定时任务的精准性要求,是否需要支持秒级、毫秒级的定时任务
  4. 重难点分析:分析定时任务中可能遇到的重难点问题,能够加深对设计的理解

设计阶段

架构=服务+存储+流程,即有哪些需要提供的服务、存储用什么、业务流程如何把服务和存储串联起来

上下游服务

针对定时微服务的分析,可能涉及的上下游服务较少。通常成型项目可能分为信息服务、库存服务、订单服务等。而定时微服务则主要负责处理定时任务,与其他服务进行交互时,通常只需要调用其提供的接口即可。

存储选型

后端开发重要的职责是与数据打交道,因此在设计定时微服务时,需要考虑如何存储定时任务的相关信息。

存储的选型可以考虑到:存储场景、可靠性、性能要求、团队技术栈等因素。较为常用的自然是 MySQL、Redis 等。

流程串联

在完成好服务设计、存储选型后,再考虑实际的数据流转流程,确保能够正常运行。

同时在该阶段就要考虑对难点、要点进行详细分析。核心问题:流程细节、高并发、高精准

优化阶段

初步架构设计后,进行整体复盘与优化点考虑。

难点 - 高精准

介绍

定时场景的高精准问题指:预期设定的触发时间与实际触发时间之间的差值问题。即需要针对差值进行优化,越高的精准度适用的业务范围更广。

但难以做到毫秒级保证。因为整体是一个分布式的微服务,期间的网络调用、数据 IO、触发时机间隔等耗时导致的误差是无法避免的。只有单应用级别如Java timer 这种可以实现毫秒级别

常见差值原因

  1. 定时扫描:定时扫描任务队列并执行。周期如果是5分钟,那么最大延时误差就接近5分钟
  2. 基于消息队列实现的定时器:任务在队列中容易造成堆积,可能导致延时增加
  3. 基于分布式实现的定时器:当数据需要通过网络在不同组件中流转时,会引入网络延迟,导致延时增加
  4. 任务数据的查找延时:当一个任务的触发过程中涉及多次查改操作,如果数据存储不合理导致检索效率低,整体流程耗时自然增加(如索引不合适、缓存未击中)
  5. 同一时间任务量大:若在某一时间有大量定时任务需要出发,由于系统处理能力是有限的,任务处理必定有先有后,形成排队状态,会导致后处理的任务延时较高

总结

几乎所有定时任务框架或消息队列,只支持秒级误差。因此针对精准度的优化是定时微服务的难点问题

难点- 高负载

高负载:支持大量任务同时处理不积压

介绍

真实的业务场景,如果定时微服务接入的业务越来越多,自然导致任务量的暴涨,进而导致的问题有:

  1. 数据库压力增大:任务信息存储在数据库中,若任务量增加,数据库的读写压力也会增加,可能导致数据库性能下降
  2. 内存占用增加:每个任务都需要占用一定的内存资源,若任务量增加,内存占用也会增加,可能导致内存不足
  3. 处理延迟增加:若系统处理能力有限,当任务量增加时,任务处理的延迟也会增加,可能导致任务触发时间与预期有较大误差
  4. 系统资源浪费:若系统资源(如CPU、网络带宽)有限,当任务量增加时,系统资源的利用效率可能会下降

分析

针对高负载量,可以在以下几个方面考虑解决方案:

  1. 应用层减少任务量
    业务方整合任务列表,减少对定时微服务的调用次数。即如果业务方在同一时间有多个任务要执行,那他应该将多个任务列成一个计划列表,然后为这个计划列表创建一个定时任务,而不是为计划里的每一项创建一个定时任务

  2. 分库分表
    分库分表的基本概念是将一个大型数据库分成多个较小的数据库(分库),并将数据库的数据进一步分成多个较小的表(分表)。使得查询和更新操作可以并行在多个数据库与表之间并行执行,提高系统的拓展性和性能
    常见分库分表策略:

    1. 垂直分表:将原始数据按照列拆分成多个表,每个表只包含某些列。常用于处理包含大量无关字段表
    2. 水平分表:将原始数据按照行拆分成多个表,每个表只包含部分行数据。常用于处理包含大量数据的表
    3. 分库分表组合:将垂直分表和水平分表结合起来使用,同时对数据库和表进行切分
  3. 数据分区
    数据分区的思路其实跟分库分表理念类似,同样期望将数据进行拆分后提升并发度,提升检索效率。所以数据分区理念还可以运用在缓存数据等地方,如 Redis 数据同样进行分区设计等

  4. 线程池
    由于定时微服务对数据库进行操作是一个 IO 流,所以可以基于线程池技术,采用多线程,为每个任务分配一个线程进行处理。

难点 - 任务异常

介绍

在实际业务中,对于定时服务至少有一个要求:必须要触发,尽管可能有时间误差。但实际情况可能导致任务无法正常运行,主要分为以下三种异常情况:

  1. 新建、激活接口异常:创建、激活任务接口发生异常。此时可以通过接口返回值反馈给用户,并可以通过主动重试等操作进行解决

  2. 任务流传异常:定时任务一般存储到数据库就算创建成功,后续如何确保任务消息能够顺利准时到达受方,就是定时微服务的职责,如果出现以下异常可能导致任务无法触发:

    1. 数据库异常:整个流转过程中会频繁发生数据库存取,期间若数据库异常,可能导致任务消息丢失
    2. 中间件存取异常:若将数据库任务缓存了一份到 Redis 中,那必定期间会发生很多 Redis 存取
  3. 任务出触发阶段异常:当任务进入触发阶段,需要回调业务方告知触发结果,如果回调失败,对于业务方来说同样属于任务执行失败

分析

针对可能出现的问题可以考虑以下方案:

  1. 重试机制:如果是偶发性异常,如数据库抖动、Redis抖动等,这类异常是可以通过重试机制解决的

  2. 失败兜底机制:如果遇到重试无法解决的问题,如存储服务某个节点崩溃,正在进行替换,需要一定时间。这期间的任务需要标记为失败状态等待后续中间件恢复再重试。一般通过运行兜底的“定时脚本”任务,来处理这些失败任务,通常需要进行全盘扫描,运行成本较高

  3. 失败报警机制:如果任务失败次数超过一定阈值,需要及时报警通知相关人员,及时处理,主要针对无法再次运行成功的任务